- Prowler is the Open Cloud Security Platform trusted by thousands to automate security and compliance in any cloud environment. With hundreds of ready-to-use checks and compliance frameworks, Prowler delivers real-time, customizable monitoring and seamless integrations, making cloud security simple, scalable, and cost-effective for organizations of any size.
+ Prowler is the Open Cloud Security Platform trusted by thousands to automate security and compliance in any cloud environment. With thousands of ready-to-use checks and compliance frameworks, Prowler delivers real-time, customizable monitoring and seamless integrations, making cloud security simple, scalable, and cost-effective for organizations of any size.
Secure ANY cloud at AI Speed at prowler.com
@@ -21,9 +21,9 @@
-
-
-
+
+
+
@@ -36,12 +36,12 @@
-
+
# Description
-**Prowler** is the world’s most widely used _Open-Source Cloud Security Platform_ that automates security and compliance across **any cloud environment**. With hundreds of ready-to-use security checks, remediation guidance, and compliance frameworks, Prowler is built to _“Secure ANY Cloud at AI Speed”_. Prowler delivers **AI-driven**, **customizable**, and **easy-to-use** assessments, dashboards, reports, and integrations, making cloud security **simple**, **scalable**, and **cost-effective** for organizations of any size.
+**Prowler** is the world’s most widely used _Open-Source Cloud Security Platform_ that automates security and compliance across **any cloud environment**. With thousands of ready-to-use security checks, remediation guidance, and compliance frameworks, Prowler is built to _“Secure ANY Cloud at AI Speed”_. Prowler delivers **AI-driven**, **customizable**, and **easy-to-use** assessments, dashboards, reports, and integrations, making cloud security **simple**, **scalable**, and **cost-effective** for organizations of any size.
Prowler includes hundreds of built-in controls to ensure compliance with standards and frameworks, including:
@@ -54,16 +54,16 @@ Prowler includes hundreds of built-in controls to ensure compliance with standar
- **National Security Standards:** ENS (Spanish National Security Scheme) and KISA ISMS-P (Korean)
- **Custom Security Frameworks:** Tailored to your needs
-## Prowler App / Prowler Cloud
+## Prowler Cloud & Prowler Local Server
-Prowler App / [Prowler Cloud](https://cloud.prowler.com/) is a web-based application that simplifies running Prowler across your cloud provider accounts. It provides a user-friendly interface to visualize the results and streamline your security assessments.
+[Prowler Cloud](https://cloud.prowler.com/) and Prowler Local Server, its self-hosted open-source version, are web applications that simplify running Prowler across your cloud provider accounts. They provide a user-friendly interface to visualize the results and streamline your security assessments.
-
+


->For more details, refer to the [Prowler App Documentation](https://docs.prowler.com/projects/prowler-open-source/en/latest/#prowler-app-installation)
+>For more details, refer to the [Prowler Local Server documentation](https://docs.prowler.com/getting-started/installation/prowler-app)
## Prowler CLI
@@ -73,26 +73,45 @@ prowler

-## Prowler Dashboard
+## Prowler Local Dashboard
```console
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:
+Attack Paths automatically extends every completed AWS scan with a graph that combines Cartography's cloud inventory with Prowler findings. The feature runs in the API worker after each scan.
-- An accessible Neo4j instance (the Docker Compose files already ships a `neo4j` service).
-- The following environment variables so Django and Celery can connect:
+Two graph backends are supported as the long-lived sink:
- | 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` |
+- **Neo4j** (default; the Docker Compose files already ship a `neo4j` service).
+- **Amazon Neptune** (cloud-managed; opt-in).
+
+Select the sink with `ATTACK_PATHS_SINK_DATABASE` (`neo4j` or `neptune`; default `neo4j`).
+
+> Note: Cartography ingestion always uses a temporary Neo4j database, regardless of the configured sink. The `NEO4J_*` variables below must remain set even when `ATTACK_PATHS_SINK_DATABASE=neptune`.
+
+### Neo4j sink
+
+| 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` |
+
+### Neptune sink
+
+| Variable | Description | Default |
+| --- | --- | --- |
+| `NEPTUNE_WRITER_ENDPOINT` | Bolt host for the Neptune writer instance. Required when sink is `neptune`. | _empty_ |
+| `NEPTUNE_READER_ENDPOINT` | Optional reader endpoint for read-only queries. Falls back to the writer when unset. | _empty_ |
+| `NEPTUNE_PORT` | Bolt port exposed by Neptune. | `8182` |
+| `AWS_REGION` | Region the Neptune cluster lives in. Required when sink is `neptune`. | _empty_ |
+
+Neptune authenticates with SigV4 using the standard boto3 credential chain. The worker's IAM role (or `AWS_ACCESS_KEY_ID` / `AWS_SECRET_ACCESS_KEY`) supplies the credentials. There is no Neptune password variable.
Every AWS provider scan will enqueue an Attack Paths ingestion job automatically. Other cloud providers will be added in future iterations.
@@ -102,27 +121,30 @@ Every AWS provider scan will enqueue an Attack Paths ingestion job automatically
> For the most accurate and up-to-date information about checks, services, frameworks, and categories, visit [**Prowler Hub**](https://hub.prowler.com).
-| 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 |
+| Provider | Checks | Services | [Compliance Frameworks](https://docs.prowler.com/user-guide/compliance/tutorials/compliance) | [Categories](https://docs.prowler.com/user-guide/cli/tutorials/misc#categories) | Support | Interface |
|---|---|---|---|---|---|---|
-| AWS | 600 | 84 | 44 | 18 | Official | UI, API, CLI |
-| Azure | 167 | 22 | 19 | 16 | Official | UI, API, CLI |
-| GCP | 102 | 18 | 17 | 12 | Official | UI, API, CLI |
-| Kubernetes | 83 | 7 | 7 | 11 | Official | UI, API, CLI |
-| GitHub | 24 | 3 | 1 | 5 | Official | UI, API, CLI |
-| M365 | 102 | 10 | 4 | 10 | Official | UI, API, CLI |
-| OCI | 51 | 14 | 4 | 10 | Official | UI, API, CLI |
-| Alibaba Cloud | 63 | 9 | 4 | 9 | Official | UI, API, CLI |
-| Cloudflare | 29 | 3 | 0 | 5 | Official | UI, API, CLI |
+| AWS | 621 | 86 | 47 | 19 | Official | UI, API, CLI |
+| Azure | 191 | 22 | 21 | 16 | Official | UI, API, CLI |
+| GCP | 109 | 20 | 19 | 12 | Official | UI, API, CLI |
+| Kubernetes | 92 | 7 | 8 | 11 | Official | UI, API, CLI |
+| GitHub | 24 | 3 | 2 | 5 | Official | UI, API, CLI |
+| M365 | 111 | 10 | 6 | 10 | Official | UI, API, CLI |
+| OCI | 52 | 14 | 5 | 10 | Official | UI, API, CLI |
+| Alibaba Cloud | 63 | 9 | 6 | 9 | Official | UI, API, CLI |
+| Cloudflare | 29 | 3 | 2 | 5 | Official | UI, API, 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 | 3 | 0 | 8 | Official | UI, API, CLI |
+| MongoDB Atlas | 10 | 3 | 1 | 8 | Official | UI, API, CLI |
| LLM | [See `promptfoo` docs.](https://www.promptfoo.dev/docs/red-team/plugins/) | N/A | N/A | N/A | Official | CLI |
| Image | N/A | N/A | N/A | N/A | Official | CLI, API |
-| Google Workspace | 39 | 5 | 2 | 5 | Official | UI, API, CLI |
-| OpenStack | 34 | 5 | 0 | 9 | Official | UI, API, CLI |
-| Vercel | 26 | 6 | 0 | 8 | Official | UI, API, CLI |
-| Okta | 1 | 1 | 0 | 1 | Official | CLI |
-| Scaleway [Contact us](https://prowler.com/contact) | 1 | 1 | 0 | 1 | Unofficial | CLI |
-| NHN | 6 | 2 | 1 | 0 | Unofficial | CLI |
+| Google Workspace | 65 | 11 | 3 | 6 | Official | UI, API, CLI |
+| OpenStack | 34 | 5 | 1 | 9 | Official | UI, API, CLI |
+| Vercel | 26 | 6 | 1 | 8 | Official | UI, API, CLI |
+| Okta | 29 | 8 | 2 | 2 | Official | UI, API, CLI |
+| Linode [Contact us](https://prowler.com/contact) | 10 | 3 | 1 | 4 | Unofficial | CLI |
+| E2E Networks [Contact us](https://prowler.com/contact) | 27 | 6 | 0 | 2 | Unofficial | CLI |
+| Scaleway [Contact us](https://prowler.com/contact) | 1 | 1 | 1 | 1 | Unofficial | CLI |
+| StackIT [Contact us](https://prowler.com/contact) | 7 | 2 | 1 | 3 | Unofficial | CLI |
+| NHN | 6 | 2 | 2 | 0 | Unofficial | CLI |
> [!Note]
> The numbers in the table are updated periodically.
@@ -138,19 +160,21 @@ Every AWS provider scan will enqueue an Attack Paths ingestion job automatically
# 💻 Installation
-## Prowler App
+## Prowler Local Server
-Prowler App offers flexible installation methods tailored to various environments:
+Prowler Local Server offers flexible installation methods tailored to various environments:
-> For detailed instructions on using Prowler App, refer to the [Prowler App Usage Guide](https://docs.prowler.com/projects/prowler-open-source/en/latest/tutorials/prowler-app/).
+> For detailed instructions on using Prowler Local Server, refer to the [usage guide](https://docs.prowler.com/user-guide/tutorials/prowler-app).
### Docker Compose
-**Requirements**
+#### Requirements
-* `Docker Compose` installed: https://docs.docker.com/compose/install/.
+- `Docker Compose` installed: https://docs.docker.com/compose/install/.
-**Commands**
+#### Commands
+
+_macOS/Linux:_
``` console
VERSION=$(curl -s https://api.github.com/repos/prowler-cloud/prowler/releases/latest | jq -r .tag_name)
@@ -160,10 +184,20 @@ curl -sLO "https://raw.githubusercontent.com/prowler-cloud/prowler/refs/tags/${V
docker compose up -d
```
+_Windows PowerShell:_
+
+``` powershell
+$VERSION = (Invoke-RestMethod -Uri "https://api.github.com/repos/prowler-cloud/prowler/releases/latest").tag_name
+Invoke-WebRequest -Uri "https://raw.githubusercontent.com/prowler-cloud/prowler/refs/tags/$VERSION/docker-compose.yml" -OutFile "docker-compose.yml"
+# Environment variables can be customized in the .env file. Using default values in production environments is not recommended.
+Invoke-WebRequest -Uri "https://raw.githubusercontent.com/prowler-cloud/prowler/refs/tags/$VERSION/.env" -OutFile ".env"
+docker compose up -d
+```
+
> [!WARNING]
> 🔒 For a secure setup, the API auto-generates a unique key pair, `DJANGO_TOKEN_SIGNING_KEY` and `DJANGO_TOKEN_VERIFYING_KEY`, and stores it in `~/.config/prowler-api` (non-container) or the bound Docker volume in `_data/api` (container). Never commit or reuse static/default keys. To rotate keys, delete the stored key files and restart the API.
-Once configured, access the Prowler App at http://localhost:3000. Sign up using your email and password to get started.
+Once configured, access Prowler Local Server at http://localhost:3000. Sign up using your email and password to get started.
### Common Issues with Docker Pull Installation
@@ -175,14 +209,14 @@ You can find more information in the [Troubleshooting](./docs/troubleshooting.md
### From GitHub
-**Requirements**
+#### Requirements
-* `git` installed.
-* `uv` installed: [uv installation](https://docs.astral.sh/uv/getting-started/installation/).
-* `pnpm` installed: [pnpm installation](https://pnpm.io/installation).
-* `Docker Compose` installed: https://docs.docker.com/compose/install/.
+- `git` installed.
+- `uv` installed: [uv installation](https://docs.astral.sh/uv/getting-started/installation/).
+- `pnpm` installed: [pnpm installation](https://pnpm.io/installation).
+- `Docker Compose` installed: https://docs.docker.com/compose/install/.
-**Commands to run the API**
+#### Commands to run the API
``` console
git clone https://github.com/prowler-cloud/prowler
@@ -199,7 +233,7 @@ gunicorn -c config/guniconf.py config.wsgi:application
> After completing the setup, access the API documentation at http://localhost:8080/api/v1/docs.
-**Commands to run the API Worker**
+#### Commands to run the API Worker
``` console
git clone https://github.com/prowler-cloud/prowler
@@ -212,7 +246,7 @@ cd src/backend
python -m celery -A config.celery worker -l info -E
```
-**Commands to run the API Scheduler**
+#### Commands to run the API Scheduler
``` console
git clone https://github.com/prowler-cloud/prowler
@@ -225,7 +259,7 @@ cd src/backend
python -m celery -A config.celery beat -l info --scheduler django_celery_beat.schedulers:DatabaseScheduler
```
-**Commands to run the UI**
+#### Commands to run the UI
``` console
git clone https://github.com/prowler-cloud/prowler
@@ -235,9 +269,9 @@ pnpm run build
pnpm start
```
-> Once configured, access the Prowler App at http://localhost:3000. Sign up using your email and password to get started.
+> Once configured, access Prowler Local Server at http://localhost:3000. Sign up using your email and password to get started.
-**Pre-commit Hooks Setup**
+#### Pre-commit Hooks Setup
Some pre-commit hooks require tools installed on your system:
@@ -253,18 +287,18 @@ Prowler CLI is available as a project in [PyPI](https://pypi.org/project/prowler
pip install prowler
prowler -v
```
->For further guidance, refer to [https://docs.prowler.com](https://docs.prowler.com/projects/prowler-open-source/en/latest/#prowler-cli-installation)
+>For further guidance, refer to [https://docs.prowler.com](https://docs.prowler.com/getting-started/installation/prowler-cli)
### Containers
-**Available Versions of Prowler CLI**
+#### Available Versions of Prowler CLI
The following versions of Prowler CLI are available, depending on your requirements:
- `latest`: Synchronizes with the `master` branch. Note that this version is not stable.
- `v4-latest`: Synchronizes with the `v4` branch. Note that this version is not stable.
- `v3-latest`: Synchronizes with the `v3` branch. Note that this version is not stable.
-- `` (release): Stable releases corresponding to specific versions. You can find the complete list of releases [here](https://github.com/prowler-cloud/prowler/releases).
+- `` (release): Stable releases corresponding to specific versions. See the [complete list of Prowler releases](https://github.com/prowler-cloud/prowler/releases).
- `stable`: Always points to the latest release.
- `v4-stable`: Always points to the latest release for v4.
- `v3-stable`: Always points to the latest release for v3.
@@ -273,7 +307,7 @@ The container images are available here:
- Prowler CLI:
- [DockerHub](https://hub.docker.com/r/prowlercloud/prowler/tags)
- [AWS Public ECR](https://gallery.ecr.aws/prowler-cloud/prowler)
-- Prowler App:
+- Prowler Local Server:
- [DockerHub - Prowler UI](https://hub.docker.com/r/prowlercloud/prowler-ui/tags)
- [DockerHub - Prowler API](https://hub.docker.com/r/prowlercloud/prowler-api/tags)
@@ -293,7 +327,7 @@ python prowler-cli.py -v
# 🛡️ GitHub Action
-The official **Prowler GitHub Action** runs Prowler scans in your GitHub workflows using the official [`prowlercloud/prowler`](https://hub.docker.com/r/prowlercloud/prowler) Docker image. Scans run on any [supported provider](https://docs.prowler.com/user-guide/providers/), with optional [`--push-to-cloud`](https://docs.prowler.com/user-guide/tutorials/prowler-app-import-findings) to send findings to Prowler Cloud and optional SARIF upload so findings show up in the repo's **Security → Code scanning** tab and as inline PR annotations.
+The official **Prowler GitHub Action** runs Prowler scans in your GitHub workflows using the official [`prowlercloud/prowler`](https://hub.docker.com/r/prowlercloud/prowler) Docker image. Scans run on any [supported provider](https://docs.prowler.com/user-guide/providers/), with optional [`--push-to-cloud`](https://docs.prowler.com/user-guide/tutorials/prowler-import-findings) to send findings to Prowler Cloud and optional SARIF upload so findings show up in the repo's **Security → Code scanning** tab and as inline PR annotations.
```yaml
name: Prowler IaC Scan
@@ -323,22 +357,60 @@ Full configuration, per-provider authentication, and SARIF examples: [Prowler Gi
# ✏️ High level architecture
-## Prowler App
-**Prowler App** is composed of four key components:
+## Prowler Local Server
+**Prowler Local Server** is composed of four key components:
- **Prowler UI**: A web-based interface, built with Next.js, providing a user-friendly experience for executing Prowler scans and visualizing results.
- **Prowler API**: A backend service, developed with Django REST Framework, responsible for running Prowler scans and storing the generated results.
- **Prowler SDK**: A Python SDK designed to extend the functionality of the Prowler CLI for advanced capabilities.
- **Prowler MCP Server**: A Model Context Protocol server that provides AI tools for Lighthouse, the AI-powered security assistant. This is a critical dependency for Lighthouse functionality.
-
+```mermaid
+flowchart TB
+ user([User / Security Team])
+ cli([Prowler CLI])
-
+ subgraph APP["Prowler Local Server"]
+ ui["Prowler UI (Next.js)"]
+ api["Prowler API (Django REST Framework)"]
+ worker["API Worker (Celery)"]
+ beat["API Scheduler (Celery Beat)"]
+ mcp["Prowler MCP Server (Lighthouse AI tools)"]
+ end
+
+ sdk["Prowler SDK (Python)"]
+
+ subgraph DATA["Data Layer"]
+ pg[("PostgreSQL")]
+ valkey[("Valkey / Redis")]
+ neo4j[("Neo4j")]
+ end
+
+ providers["Providers"]
+
+ user --> ui
+ user --> cli
+ ui -->|REST| api
+ ui -->|MCP HTTP| mcp
+ mcp -->|REST| api
+ api --> pg
+ api --> valkey
+ beat -->|enqueue jobs| valkey
+ valkey -->|dispatch| worker
+ worker --> pg
+ worker -->|Attack Paths| neo4j
+ worker -->|invokes| sdk
+ cli --> sdk
+
+ sdk --> providers
+```
+
+
## Prowler CLI
-**Running Prowler**
+### Running Prowler
Prowler can be executed across various environments, offering flexibility to meet your needs. It can be run from:
diff --git a/action.yml b/action.yml
index a93c67d4e8..2be0649402 100644
--- a/action.yml
+++ b/action.yml
@@ -22,7 +22,7 @@ inputs:
required: false
default: json-ocsf
push-to-cloud:
- description: Push scan findings to Prowler Cloud. Requires the PROWLER_CLOUD_API_KEY environment variable. See https://docs.prowler.com/user-guide/tutorials/prowler-app-import-findings#using-the-cli
+ description: Push scan findings to Prowler Cloud. Requires the PROWLER_CLOUD_API_KEY environment variable. See https://docs.prowler.com/user-guide/tutorials/prowler-import-findings#using-the-cli
required: false
default: "false"
flags:
@@ -299,7 +299,7 @@ runs:
echo ""
echo "**Get started in 3 steps:**"
echo "1. Create an account at [cloud.prowler.com](https://cloud.prowler.com)"
- echo "2. Generate a Prowler Cloud API key ([docs](https://docs.prowler.com/user-guide/tutorials/prowler-app-import-findings#using-the-cli))"
+ echo "2. Generate a Prowler Cloud API key ([docs](https://docs.prowler.com/user-guide/tutorials/prowler-import-findings#using-the-cli))"
echo "3. Add \`PROWLER_CLOUD_API_KEY\` to your GitHub secrets and set \`push-to-cloud: true\` on this action"
echo ""
echo "See [prowler.com/pricing](https://prowler.com/pricing) for plan details."
diff --git a/api/.env.example b/api/.env.example
index 0be5388790..f97d868890 100644
--- a/api/.env.example
+++ b/api/.env.example
@@ -24,6 +24,9 @@ DJANGO_THROTTLE_TOKEN_OBTAIN=50/minute
# Decide whether to allow Django manage database table partitions
DJANGO_MANAGE_DB_PARTITIONS=[True|False]
DJANGO_CELERY_DEADLOCK_ATTEMPTS=5
+# Optional: bound Celery's prefork pool size. Unset → Celery uses os.cpu_count().
+# Useful on Kubernetes nodes with many CPUs where unbounded prefork balloons memory.
+# DJANGO_CELERY_WORKER_CONCURRENCY=4
DJANGO_BROKER_VISIBILITY_TIMEOUT=86400
DJANGO_SENTRY_DSN=
diff --git a/api/AGENTS.md b/api/AGENTS.md
index b03dc5803c..0483e55c04 100644
--- a/api/AGENTS.md
+++ b/api/AGENTS.md
@@ -10,7 +10,7 @@
> - [`jsonapi`](../skills/jsonapi/SKILL.md) - Strict JSON:API v1.1 spec compliance
> - [`pytest`](../skills/pytest/SKILL.md) - Generic pytest patterns
-### Auto-invoke Skills
+## Auto-invoke Skills
When performing these actions, ALWAYS invoke the corresponding skill FIRST:
@@ -81,7 +81,7 @@ When performing these actions, ALWAYS invoke the corresponding skill FIRST:
## DECISION TREES
### Serializer Selection
-```
+```text
Read → Serializer
Create → CreateSerializer
Update → UpdateSerializer
@@ -89,7 +89,7 @@ Nested read → IncludeSerializer
```
### Task vs View
-```
+```text
< 100ms → View
> 100ms or external API → Celery task
Needs retry → Celery task
@@ -105,7 +105,7 @@ Django 5.1.x | DRF 3.15.x | djangorestframework-jsonapi 7.x | Celery 5.4.x | Pos
## PROJECT STRUCTURE
-```
+```text
api/src/backend/
├── api/ # Main Django app
│ ├── v1/ # API version 1 (views, serializers, urls)
diff --git a/api/CHANGELOG.md b/api/CHANGELOG.md
index cba4f9c594..1b26c2b014 100644
--- a/api/CHANGELOG.md
+++ b/api/CHANGELOG.md
@@ -2,6 +2,238 @@
All notable changes to the **Prowler API** are documented in this file.
+
+
+## [1.36.0] (Prowler v5.35.0)
+
+### 🐞 Fixed
+
+- `attack-paths-scan-perform` Celery tasks now use the configurable long-task time limits instead of the six-hour defaults [(#12009)](https://github.com/prowler-cloud/prowler/pull/12009)
+- Attack Paths scans handle provider deletion races cleanly, detect stale tasks after 16 hours, use backend-specific graph synchronization batches, and report exhausted Neptune write retries with the original database error [(#12019)](https://github.com/prowler-cloud/prowler/pull/12019)
+
+### 🔐 Security
+
+- Jira integration credentials only accept bare Atlassian site names containing letters, numbers, and hyphens [(#12012)](https://github.com/prowler-cloud/prowler/pull/12012)
+- Social account linking requires a verified matching email from both the identity provider and the existing user account without sending account connection notifications [(#12013)](https://github.com/prowler-cloud/prowler/pull/12013)
+
+---
+
+## [1.35.0] (Prowler v5.34.0)
+
+### 🐞 Fixed
+
+- `rls_transaction` now falls back directly to the primary DB for connection-level mid-query read replica failures via `execute_wrapper`, reducing non-streaming read crashes during replica recovery [(#10379)](https://github.com/prowler-cloud/prowler/pull/10379)
+- RBAC permission gates now combine permissions from every role assigned to a user in the active tenant [(#11979)](https://github.com/prowler-cloud/prowler/pull/11979)
+- `attack-paths-cleanup-stale-scans` now retries worker pings and checks recent scan activity before failing scans and removing temporary databases [(#11986)](https://github.com/prowler-cloud/prowler/pull/11986)
+
+### 🔐 Security
+
+- User role relationship updates are limited to the active tenant to preserve role assignments in other tenants [(#11903)](https://github.com/prowler-cloud/prowler/pull/11903)
+- `api` container image removes the unused Debian `libxml2` runtime package and scopes the `CVE-2026-13221` Trivy exception to unaffected Perl 5.36 packages [(#11991)](https://github.com/prowler-cloud/prowler/pull/11991)
+
+---
+
+## [1.34.2] (Prowler v5.33.2)
+
+### 🐞 Fixed
+
+- Attack Paths graph mutations now retry transient Neptune concurrency and deadline failures, while Neo4j mutations use managed transaction retries [(#11968)](https://github.com/prowler-cloud/prowler/pull/11968)
+- Attack Paths scans now use bounded child node identifiers for normalized list values in Neo4j and Neptune, preventing Neo4j RANGE index key size failures [(#11969)](https://github.com/prowler-cloud/prowler/pull/11969)
+- `scan-summary` aggregation now upserts summaries in deterministic conflict-key order, preventing PostgreSQL deadlocks during concurrent reaggregation [(#11971)](https://github.com/prowler-cloud/prowler/pull/11971)
+
+---
+
+## [1.34.1] (Prowler v5.33.1)
+
+### 🐞 Fixed
+
+- Session tokens are rejected after account password updates [(#11914)](https://github.com/prowler-cloud/prowler/pull/11914)
+- Jira dispatch task results now surface user-facing Jira failure messages [(#11925)](https://github.com/prowler-cloud/prowler/pull/11925)
+- AWS Attack Paths privilege escalation queries no longer fail on Neo4j with `Aggregation column contains implicit grouping expressions` [(#11939)](https://github.com/prowler-cloud/prowler/pull/11939)
+
+### 🔐 Security
+
+- OpenAI-compatible Lighthouse provider base URLs are restricted before connection checks [(#11940)](https://github.com/prowler-cloud/prowler/pull/11940)
+- `LIGHTHOUSE_AI_OPENAI_COMPATIBLE_ALLOWED_HOSTS` environment variable to allow internal hosts as OpenAI-compatible Lighthouse AI base URLs [(#11942)](https://github.com/prowler-cloud/prowler/pull/11942)
+
+---
+
+## [1.34.0] (Prowler v5.33.0)
+
+### 🚀 Added
+
+- Compliance PDF reports no longer require provider credentials: findings are enriched from the provider metadata stored in the database, so reports generate even after the provider secret is deleted or its credentials become invalid [(#11845)](https://github.com/prowler-cloud/prowler/pull/11845)
+
+### 🐞 Fixed
+
+- Provider scans now queue behind active provider scans instead of dispatching concurrently, and resource failed-finding counters retry database conflicts with stable row locking [(#11848)](https://github.com/prowler-cloud/prowler/pull/11848)
+
+---
+
+## [1.33.1] (Prowler v5.32.1)
+
+### 🐞 Fixed
+
+- Attack Paths: Scan rows now have database defaults for `is_migrated` and `sink_backend` so `scan-perform-scheduled` inserts survive deploy skew [(#11826)](https://github.com/prowler-cloud/prowler/pull/11826)
+- Invited users now keep their invitation context when completing authentication with Google, GitHub, or SAML, so the invitation is accepted during login [(#11752)](https://github.com/prowler-cloud/prowler/pull/11752)
+
+### 🔐 Security
+
+- User profile updates now allow users to update their own account while requiring user-management permissions to update other users in the same tenant [(#11792)](https://github.com/prowler-cloud/prowler/pull/11792)
+- Kubernetes provider credentials now reject kubeconfigs using `exec` authentication in Prowler Cloud, preventing user-supplied commands from running on Cloud workers [(#11753)](https://github.com/prowler-cloud/prowler/pull/11753)
+
+---
+
+## [1.33.0] (Prowler v5.32.0)
+
+### 🚀 Added
+
+- Timestamp precision support for `/api/v1/findings` `inserted_at` and `updated_at` filters [(#11754)](https://github.com/prowler-cloud/prowler/pull/11754)
+
+### 🔄 Changed
+
+- Attack Paths: AWS Neptune is now supported as a persistent sink database, selectable via `ATTACK_PATHS_SINK_DATABASE=neptune` (default `neo4j`), Cartography's (bumped to 0.138.1) per-scan ingest database stays on Neo4j [(#11524)](https://github.com/prowler-cloud/prowler/pull/11524)
+- Attack Paths: Scan task now checks the ingest Neo4j database and configured graph sink before starting graph ingestion [(#11743)](https://github.com/prowler-cloud/prowler/pull/11743)
+- Disable PowerShell telemetry in the API container image [(#11746)](https://github.com/prowler-cloud/prowler/pull/11746)
+
+### 🐞 Fixed
+
+- Attack Paths: Provider graph cleanup now deletes Neo4j and Neptune relationships in directed batches before deleting nodes [(#11755)](https://github.com/prowler-cloud/prowler/pull/11755)
+- `scan-perform` no longer reports an error when a provider is deleted during a running scan [(#11696)](https://github.com/prowler-cloud/prowler/pull/11696)
+
+---
+
+## [1.32.1] (Prowler v5.31.1)
+
+### 🐞 Fixed
+
+- API key auth no longer mutates `TenantAPIKey.objects` during admin DB lookups [(#11686)](https://github.com/prowler-cloud/prowler/pull/11686)
+
+---
+
+## [1.32.0] (Prowler v5.31.0)
+
+### 🚀 Added
+
+- Provider group filters for API endpoints that support cloud provider filtering, including exact and `__in` variants [(#11573)](https://github.com/prowler-cloud/prowler/pull/11573)
+- Provider filters for `GET /api/v1/compliance-overviews`, `/metadata`, and `/requirements`, using latest completed scans per matching provider [(#11587)](https://github.com/prowler-cloud/prowler/pull/11587)
+- Server-Sent Events (SSE) infrastructure for the API: a base viewset, a tenant-aware channel manager, and channel-name helpers backed by `django-eventstream` over Valkey Pub/Sub and served through the Gunicorn ASGI worker, so feature endpoints can stream events to clients over a single long-lived connection [(#11556)](https://github.com/prowler-cloud/prowler/pull/11556)
+- `DJANGO_CELERY_WORKER_CONCURRENCY` to configure Celery workers concurrency. Unset for default behaviour [(#11075)](https://github.com/prowler-cloud/prowler/pull/11075)
+
+### 🔄 Changed
+
+- Gunicorn worker timeout raised from the 30s default to 120s, so long-running requests are no longer killed prematurely [(#11631)](https://github.com/prowler-cloud/prowler/pull/11631)
+- Sentry now drops ASGI's `RequestAborted` errors from health-check probe disconnects on `/health/live` [(#11632)](https://github.com/prowler-cloud/prowler/pull/11632)
+- Gunicorn keep-alive timeout now exceeds the load balancer idle timeout, stopping 502s from reused connections [(#11647)](https://github.com/prowler-cloud/prowler/pull/11647)
+- API runs under the Uvicorn worker so keep-alive outlives the load balancer idle timeout, fixing Gunicorn's intermittent 502s [(#11663)](https://github.com/prowler-cloud/prowler/pull/11663)
+- SAML logins no longer wipe a user's roles when the IdP does not send the `userType` attribute; existing roles are kept, and when `userType` names a role that does not exist it is now created with read-only access (visibility over all providers, no management permissions) instead of no permissions at all [(#11520)](https://github.com/prowler-cloud/prowler/pull/11520)
+
+### 🐞 Fixed
+
+- Database connections no longer leak under the ASGI worker, which previously exhausted the read replica's connection slots and caused 500s on read endpoints [(#11640)](https://github.com/prowler-cloud/prowler/pull/11640)
+
+### 🔐 Security
+
+- `aiohttp` to 3.14.0 and `idna` to 3.15, patching known CVEs [(#11596)](https://github.com/prowler-cloud/prowler/pull/11596)
+- Container base image to `python:3.12.13-slim-bookworm` and `trivy` to 0.71.0, patching OS and Go module CVEs [(#11596)](https://github.com/prowler-cloud/prowler/pull/11596)
+- `trivy` binary bumped to 0.71.0 patching embedded `golang.org/x/crypto`, `golang.org/x/net`, and Go `stdlib` CVEs [(#11592)](https://github.com/prowler-cloud/prowler/pull/11592)
+
+---
+
+## [1.31.3] (Prowler v5.30.3)
+
+### 🔐 Security
+
+- SAML logins now link to an existing account only when the asserted email domain matches the ACS endpoint and the user is already a member of that domain's tenant, fixing a cross-tenant account takeover [(GHSA-h8m9-jgf8-vwvp)](https://github.com/prowler-cloud/prowler/security/advisories/GHSA-h8m9-jgf8-vwvp)
+
+---
+
+## [1.31.2] (Prowler v5.30.2)
+
+### 🔄 Changed
+
+- `scan-compliance-overviews` task now streams the findings aggregation and the requirement-row writes so it runs faster and its peak memory no longer grows with the number of regions and frameworks [(#11591)](https://github.com/prowler-cloud/prowler/pull/11591)
+
+---
+
+## [1.31.1] (Prowler v5.30.1)
+
+### 🐞 Fixed
+
+- `compliance-overviews/attributes` now resolves the provider from the scan, so multi-provider universal frameworks (e.g. CSA CCM) return the check IDs of the scan's provider and Azure/GCP requirement details show their findings instead of appearing empty [(#11546)](https://github.com/prowler-cloud/prowler/pull/11546)
+- Attack Paths: `drop_subgraph` now deletes relationships first and then nodes in batches, using less memory on Neo4j when clearing a dense provider graph [(#11557)](https://github.com/prowler-cloud/prowler/pull/11557)
+- OCI scans now use API key credentials with the configured region instead of falling back to `/home/prowler/.oci/config` [(#11558)](https://github.com/prowler-cloud/prowler/pull/11558)
+
+---
+
+## [1.31.0] (Prowler v5.30.0)
+
+### 🚀 Added
+
+- Opt-in automatic recovery of allowlisted idempotent background tasks whose worker died during a deploy or crash: when enabled via `DJANGO_TASK_RECOVERY_ENABLED` (off by default), stuck summary and deletion tasks are detected and re-run instead of staying pending forever (scan and Jira tasks are excluded), with a `reconcile_orphan_tasks` management command for on-demand recovery [(#11416)](https://github.com/prowler-cloud/prowler/pull/11416)
+- DORA compliance framework support [(#11131)](https://github.com/prowler-cloud/prowler/pull/11131)
+- Label Postgres connections with `application_name=":"` (component injected per process via `DJANGO_APP_COMPONENT`) so connections are attributable by component in `pg_stat_activity` [(#11494)](https://github.com/prowler-cloud/prowler/pull/11494)
+- DISA Okta IDaaS STIG V1R2 compliance framework export support for the Okta provider [(#11428)](https://github.com/prowler-cloud/prowler/pull/11428)
+
+### 🔄 Changed
+
+- Allowlisted idempotent background tasks are no longer lost when a worker is stopped or crashes mid-task; tasks with external side effects are marked terminal instead of blindly re-running [(#11416)](https://github.com/prowler-cloud/prowler/pull/11416)
+
+### 🐞 Fixed
+
+- Workers now shut down gracefully on deploy or restart, finishing or re-queueing in-flight tasks instead of being force-killed and leaving them stuck [(#11416)](https://github.com/prowler-cloud/prowler/pull/11416)
+- Resource `name` is now stored and refreshed on every scan, so resources no longer keep an empty name [(#11476)](https://github.com/prowler-cloud/prowler/pull/11476)
+- Compliance catalog now warms in background during startup. `compliance-overviews/attributes` returns `503` while warming, so the first request after a deploy no longer trips the API timeout [(#11530)](https://github.com/prowler-cloud/prowler/pull/11530)
+
+### 🔐 Security
+
+- `dulwich` from 0.23.0 to 1.2.5 and `pyjwt` from 2.12.1 to 2.13.0, patching `GHSA-897w-fcg9-f6xj` (arbitrary file write) and `PYSEC-2026-179` (HMAC/JWK key confusion) [(#11499)](https://github.com/prowler-cloud/prowler/pull/11499)
+
+---
+
+## [1.30.3] (Prowler v5.29.3)
+
+### 🐞 Fixed
+
+- API startup no longer crashes when Neo4j is unreachable, as the Neo4j driver now connects lazily on first use rather than during app initialization [(#11491)](https://github.com/prowler-cloud/prowler/pull/11491)
+
+---
+
+## [1.30.1] (Prowler v5.29.1)
+
+### 🐞 Fixed
+
+- `GET /api/v1/findings` N+1 query loading `resources__tags` when listing findings [(#11420)](https://github.com/prowler-cloud/prowler/pull/11420)
+- Clean up the scan tmp output directory when `scan-report` fails so partial files do not accumulate and fill the worker disk (`No space left on device`) [(#11421)](https://github.com/prowler-cloud/prowler/pull/11421)
+
+---
+
+## [1.30.0] (Prowler v5.29.0)
+
+### 🔄 Changed
+
+- Scan finding ingestion: bulk-resolve `Resource`/`ResourceTag` rows, replace per-mapping `SELECT FOR UPDATE` with deferred `ResourceTagMapping.bulk_create(ignore_conflicts=True)`, wrap each micro-batch in a single `rls_transaction`, and raise `SCAN_DB_BATCH_SIZE` to 1000 [(#11249)](https://github.com/prowler-cloud/prowler/pull/11249)
+- Faster `GET /api/v1/finding-groups/latest` aggregation on tenants where one recent scan holds most findings [(#11380)](https://github.com/prowler-cloud/prowler/pull/11380)
+
+---
+
+## [1.29.1] (Prowler v5.28.1)
+
+### 🐞 Fixed
+
+- `finding-groups` slow response with finding-level filters such as `region`; check title and description are now read from the daily summaries, which drops sorting by `check_title` [(#11326)](https://github.com/prowler-cloud/prowler/pull/11326)
+
+---
+
+## [1.29.0] (Prowler v5.28.0)
+
+### 🚀 Added
+
+- `okta` provider support [(#11184)](https://github.com/prowler-cloud/prowler/pull/11184)
+- `resource.metadata` attribute included in `/api/v1/findings?include=resources` [(#11187)](https://github.com/prowler-cloud/prowler/pull/11187)
+
+---
+
## [1.28.0] (Prowler v5.27.0)
### 🚀 Added
@@ -20,7 +252,6 @@ All notable changes to the **Prowler API** are documented in this file.
- `perform_scan_task` and `perform_scheduled_scan_task` now short-circuit with a warning and `return None` when the target provider no longer exists, instead of letting `handle_provider_deletion` raise `ProviderDeletedException`. `perform_scheduled_scan_task` also removes any orphan `PeriodicTask` it finds so beat stops re-firing scans for deleted providers. Prevents queued messages for deleted providers from being recorded as `FAILURE` [(#11185)](https://github.com/prowler-cloud/prowler/pull/11185)
- Attack Paths: `BEDROCK-001` and `BEDROCK-002` now target roles trusting `bedrock-agentcore.amazonaws.com` instead of `bedrock.amazonaws.com`, eliminating false positives against regular Bedrock service roles (Agents, Knowledge Bases, model invocation) [(#11141)](https://github.com/prowler-cloud/prowler/pull/11141)
-
---
## [1.27.1] (Prowler v5.26.1)
diff --git a/api/Dockerfile b/api/Dockerfile
index e7b758f4b8..8d6923bbfc 100644
--- a/api/Dockerfile
+++ b/api/Dockerfile
@@ -1,11 +1,13 @@
-FROM python:3.12.10-slim-bookworm@sha256:fd95fa221297a88e1cf49c55ec1828edd7c5a428187e67b5d1805692d11588db AS build
+FROM python:3.12.13-slim-bookworm@sha256:8a7e7cc04fd3e2bd787f7f24e22d5d119aa590d429b50c95dfe12b3abe52f48b AS build
LABEL maintainer="https://github.com/prowler-cloud/api"
ARG POWERSHELL_VERSION=7.5.0
ENV POWERSHELL_VERSION=${POWERSHELL_VERSION}
+# Opt out of PowerShell telemetry (Application Insights -> dc.services.visualstudio.com)
+ENV POWERSHELL_TELEMETRY_OPTOUT=1
-ARG TRIVY_VERSION=0.70.0
+ARG TRIVY_VERSION=0.71.2
ENV TRIVY_VERSION=${TRIVY_VERSION}
ARG ZIZMOR_VERSION=1.24.1
@@ -89,7 +91,7 @@ WORKDIR /home/prowler
# Ensure output directory exists
RUN mkdir -p /tmp/prowler_api_output
-COPY pyproject.toml uv.lock ./
+COPY --chown=prowler:prowler pyproject.toml uv.lock ./
RUN pip install --no-cache-dir --upgrade pip && \
pip install --no-cache-dir uv==0.11.14
@@ -97,13 +99,31 @@ RUN pip install --no-cache-dir --upgrade pip && \
ENV PATH="/home/prowler/.local/bin:$PATH"
# Add `--no-install-project` to avoid installing the current project as a package
-RUN uv sync --no-install-project && \
+RUN uv sync --locked --no-install-project && \
rm -rf ~/.cache/uv
RUN .venv/bin/python .venv/lib/python3.12/site-packages/prowler/providers/m365/lib/powershell/m365_powershell.py
-COPY src/backend/ ./backend/
-COPY docker-entrypoint.sh ./docker-entrypoint.sh
+USER root
+
+# Remove build-only packages from the final image after Python dependencies are installed.
+RUN apt-get purge -y --auto-remove \
+ gcc \
+ g++ \
+ make \
+ libxml2-dev \
+ libxmlsec1-dev \
+ libxmlsec1-openssl \
+ pkg-config \
+ libtool \
+ libxslt1-dev \
+ python3-dev \
+ && rm -rf /var/lib/apt/lists/*
+
+USER prowler
+
+COPY --chown=prowler:prowler src/backend/ ./backend/
+COPY --chown=prowler:prowler docker-entrypoint.sh ./docker-entrypoint.sh
WORKDIR /home/prowler/backend
diff --git a/api/README.md b/api/README.md
index 1ea27fa38f..c510479ab2 100644
--- a/api/README.md
+++ b/api/README.md
@@ -2,7 +2,7 @@
This repository contains the JSON API and Task Runner components for Prowler, which facilitate a complete backend that interacts with the Prowler SDK and is used by the Prowler UI.
-# Components
+## Components
The Prowler API is composed of the following components:
- The JSON API, which is an API built with Django Rest Framework.
@@ -10,13 +10,13 @@ The Prowler API is composed of the following components:
- The PostgreSQL database, which is used to store the data.
- The Valkey database, which is an in-memory database which is used as a message broker for the Celery workers.
-## Note about Valkey
+### Note about Valkey
[Valkey](https://valkey.io/) is an open source (BSD) high performance key/value datastore.
Valkey exposes a Redis 7.2 compliant API. Any service that exposes the Redis API can be used with Prowler API.
-# Modify environment variables
+## Modify environment variables
Under the root path of the project, you can find a file called `.env`. This file shows all the environment variables that the project uses. You should review it and set the values for the variables you want to change.
@@ -24,7 +24,7 @@ If you don’t set `DJANGO_TOKEN_SIGNING_KEY` or `DJANGO_TOKEN_VERIFYING_KEY`, t
**Important note**: Every Prowler version (or repository branches and tags) could have different variables set in its `.env` file. Please use the `.env` file that corresponds with each version.
-## Local deployment
+### Local deployment
Keep in mind if you export the `.env` file to use it with local deployment that you will have to do it within the context of the virtual environment, not before. Otherwise, variables will not be loaded properly.
To do this, you can run:
@@ -34,12 +34,12 @@ set -a
source .env
```
-# 🚀 Production deployment
-## Docker deployment
+## 🚀 Production deployment
+### Docker deployment
This method requires `docker` and `docker compose`.
-### Clone the repository
+#### Clone the repository
```console
# HTTPS
@@ -50,13 +50,13 @@ git clone git@github.com:prowler-cloud/api.git
```
-### Build the base image
+#### Build the base image
```console
docker compose --profile prod build
```
-### Run the production service
+#### Run the production service
This command will start the Django production server and the Celery worker and also the Valkey and PostgreSQL databases.
@@ -68,7 +68,7 @@ You can access the server in `http://localhost:8080`.
> **NOTE:** notice how the port is different. When developing using docker, the port will be `8080` to prevent conflicts.
-### View the Production Server Logs
+#### View the Production Server Logs
To view the logs for any component (e.g., Django, Celery worker), you can use the following command with a wildcard. This command will follow logs for any container that matches the specified pattern:
@@ -133,13 +133,13 @@ gunicorn -c config/guniconf.py config.wsgi:application
> By default, the Gunicorn server will try to use as many workers as your machine can handle. You can manually change that in the `src/backend/config/guniconf.py` file.
-# 🧪 Development guide
+## 🧪 Development guide
-## Local deployment
+### Local deployment
To use this method, you'll need to set up a Python virtual environment (version ">=3.11,<3.13") and keep dependencies updated. Additionally, ensure that `uv` and `docker compose` are installed.
-### Clone the repository
+#### Clone the repository
```console
# HTTPS
@@ -150,7 +150,7 @@ git clone git@github.com:prowler-cloud/api.git
```
-### Start the PostgreSQL Database and Valkey
+#### Start the PostgreSQL Database and Valkey
The PostgreSQL database (version 16.3) and Valkey (version 7) are required for the development environment. To make development easier, we have provided a `docker-compose` file that will start these components for you.
@@ -161,7 +161,7 @@ The PostgreSQL database (version 16.3) and Valkey (version 7) are required for t
docker compose up postgres valkey -d
```
-### Install the Python dependencies
+#### Install the Python dependencies
> You must have uv installed
@@ -169,7 +169,7 @@ docker compose up postgres valkey -d
uv sync
```
-### Apply migrations
+#### Apply migrations
For migrations, you need to force the `admin` database router. Assuming you have the correct environment variables and Python virtual environment, run:
@@ -178,7 +178,7 @@ cd src/backend
python manage.py migrate --database admin
```
-### Run the Django development server
+#### Run the Django development server
```console
cd src/backend
@@ -188,7 +188,7 @@ python manage.py runserver
You can access the server in `http://localhost:8000`.
All changes in the code will be automatically reloaded in the server.
-### Run the Celery worker
+#### Run the Celery worker
```console
python -m celery -A config.celery worker -l info -E
@@ -196,11 +196,47 @@ python -m celery -A config.celery worker -l info -E
The Celery worker does not detect and reload changes in the code, so you need to restart it manually when you make changes.
-## Docker deployment
+### Makefile-Assisted Local Deployment
+
+This method is an additional local development workflow. It does not replace the manual local deployment or the Docker deployment described in this guide.
+
+PostgreSQL, Valkey, and Neo4j run with Docker Compose, while Django and the Celery worker run natively through `uv`. Additionally, this workflow creates a `tmux` session with panes for the API, worker, and PostgreSQL logs.
+
+Before using this method, ensure `docker compose`, `tmux`, and `uv` are installed.
+
+This workflow is designed for macOS and should also work on Linux when Docker, `tmux`, and `uv` are available. Windows requires script changes before it can be supported.
+
+From the repository root, run:
+
+```console
+make dev
+```
+
+The API will be available at:
+
+```console
+http://localhost:8080/api/v1
+```
+
+Use these commands to manage the local stack:
+
+```console
+make dev-setup # Bootstrap dependencies, migrations, and fixtures
+make dev-attach # Attach to the tmux session
+make dev-launch # Start the stack on fixed ports and attach
+make dev-stop # Stop the tmux session and containers
+make dev-clean # Remove stopped development containers
+make dev-wipe # Stop everything and delete local development data
+make dev-status # Show development container status
+```
+
+This workflow does not start the UI. Start it separately from the `ui/` directory when needed.
+
+### Docker deployment
This method requires `docker` and `docker compose`.
-### Clone the repository
+#### Clone the repository
```console
# HTTPS
@@ -211,13 +247,13 @@ git clone git@github.com:prowler-cloud/api.git
```
-### Build the base image
+#### Build the base image
```console
docker compose --profile dev build
```
-### Run the development service
+#### Run the development service
This command will start the Django development server and the Celery worker and also the Valkey and PostgreSQL databases.
@@ -230,7 +266,7 @@ All changes in the code will be automatically reloaded in the server.
> **NOTE:** notice how the port is different. When developing using docker, the port will be `8080` to prevent conflicts.
-### View the development server logs
+#### View the development server logs
To view the logs for any component (e.g., Django, Celery worker), you can use the following command with a wildcard. This command will follow logs for any container that matches the specified pattern:
@@ -238,7 +274,7 @@ To view the logs for any component (e.g., Django, Celery worker), you can use th
docker logs -f $(docker ps --format "{{.Names}}" | grep 'api-')
```
-## Applying migrations
+### Applying migrations
For migrations, you need to force the `admin` database router. Assuming you have the correct environment variables and Python virtual environment, run:
@@ -247,7 +283,7 @@ cd src/backend
uv run python manage.py migrate --database admin
```
-## Apply fixtures
+### Apply fixtures
Fixtures are used to populate the database with initial development data.
@@ -258,7 +294,7 @@ uv run python manage.py loaddata api/fixtures/0_dev_users.json --database admin
> The default credentials are `dev@prowler.com:Thisisapassword123@` or `dev2@prowler.com:Thisisapassword123@`
-## Run tests
+### Run tests
Note that the tests will fail if you use the same `.env` file as the development environment.
@@ -269,7 +305,7 @@ cd src/backend
uv run pytest
```
-# Custom commands
+## Custom commands
Django provides a way to create custom commands that can be run from the command line.
@@ -281,7 +317,7 @@ To run a custom command, you need to be in the `prowler/api/src/backend` directo
uv run python manage.py
```
-## Generate dummy data
+### Generate dummy data
```console
python manage.py findings --tenant
@@ -298,7 +334,7 @@ This command creates, for a given tenant, a provider, scan and a set of findings
>
> The last step is required to access the findings details, since the UI needs that to print all the information.
-### Example
+#### Example
```console
~/backend $ uv run python manage.py findings --tenant
diff --git a/prowler/lib/outputs/compliance/csa/__init__.py b/api/changelog.d/.gitkeep
similarity index 100%
rename from prowler/lib/outputs/compliance/csa/__init__.py
rename to api/changelog.d/.gitkeep
diff --git a/api/changelog.d/README.md b/api/changelog.d/README.md
new file mode 100644
index 0000000000..0853174f30
--- /dev/null
+++ b/api/changelog.d/README.md
@@ -0,0 +1,10 @@
+# Changelog fragments
+
+Each PR adds one small file here instead of editing `CHANGELOG.md` directly, so concurrent PRs never conflict.
+
+- Filename: `..md`, e.g. `my-new-check.added.md` (slug is free-form: letters, digits, `.`, `_`, `-`)
+- `` is one of: `added`, `changed`, `deprecated`, `removed`, `fixed`, `security`
+- Content: one line with the changelog entry text, without the PR link and without a trailing period (the PR link is attached automatically at release time)
+- A PR adds as many fragment files as entries it needs, freely mixing types (one file per entry); same-type entries just use different slugs
+
+Fragments are compiled into `CHANGELOG.md` when a release is prepared. Full conventions: `skills/prowler-changelog/SKILL.md`.
diff --git a/api/changelog.d/compliance-overview-single-transaction.changed.md b/api/changelog.d/compliance-overview-single-transaction.changed.md
new file mode 100644
index 0000000000..0e7a6714b3
--- /dev/null
+++ b/api/changelog.d/compliance-overview-single-transaction.changed.md
@@ -0,0 +1 @@
+Compliance overview ingest now runs in a single transaction per scan with a configurable `COPY` batch size (`DJANGO_COMPLIANCE_COPY_BATCH_SIZE`, default 2000), reducing write pressure on the database
diff --git a/api/changelog.d/oci-regionless-api-legacy-region.changed.md b/api/changelog.d/oci-regionless-api-legacy-region.changed.md
new file mode 100644
index 0000000000..087b027c88
--- /dev/null
+++ b/api/changelog.d/oci-regionless-api-legacy-region.changed.md
@@ -0,0 +1 @@
+OCI provider secrets no longer require `region`; legacy `region` input is accepted for backwards compatibility but ignored before storing or scanning
diff --git a/api/docker-entrypoint.sh b/api/docker-entrypoint.sh
index 4535fb34e2..e077a3bfd6 100755
--- a/api/docker-entrypoint.sh
+++ b/api/docker-entrypoint.sh
@@ -21,13 +21,19 @@ apply_fixtures() {
}
start_dev_server() {
- echo "Starting the development server..."
- uv run python manage.py runserver 0.0.0.0:"${DJANGO_PORT:-8080}"
+ echo "Starting the development server (Gunicorn ASGI, debug + reload)..."
+ # Same server/worker as prod (config.asgi via the native `asgi` worker), so
+ # SSE streams run on the event loop exactly as they do in production. DEBUG is
+ # on so guniconf's `reload = DEBUG` hot-reloads edited code (and flips
+ # `preload_app` off so reload actually takes).
+ export DJANGO_DEBUG="${DJANGO_DEBUG:-True}"
+ export DJANGO_BIND_ADDRESS="${DJANGO_BIND_ADDRESS:-0.0.0.0}"
+ exec uv run gunicorn -c config/guniconf.py config.asgi:application
}
start_prod_server() {
echo "Starting the Gunicorn server..."
- uv run gunicorn -c config/guniconf.py config.wsgi:application
+ exec uv run gunicorn -c config/guniconf.py config.asgi:application
}
resolve_worker_hostname() {
@@ -47,7 +53,7 @@ resolve_worker_hostname() {
start_worker() {
echo "Starting the worker..."
- uv run python -m celery -A config.celery worker \
+ exec uv run python -m celery -A config.celery worker \
-n "$(resolve_worker_hostname)" \
-l "${DJANGO_LOGGING_LEVEL:-info}" \
-Q celery,scans,scan-reports,deletion,backfill,overview,integrations,compliance,attack-paths-scans \
@@ -56,7 +62,7 @@ start_worker() {
start_worker_beat() {
echo "Starting the worker-beat..."
- uv run python -m celery -A config.celery beat -l "${DJANGO_LOGGING_LEVEL:-info}" --scheduler django_celery_beat.schedulers:DatabaseScheduler
+ exec uv run python -m celery -A config.celery beat -l "${DJANGO_LOGGING_LEVEL:-info}" --scheduler django_celery_beat.schedulers:DatabaseScheduler
}
manage_db_partitions() {
@@ -68,6 +74,15 @@ manage_db_partitions() {
fi
}
+# Identify this process to Postgres (application_name=:) so
+# connections are attributable by component in pg_stat_activity. Web tiers
+# report "api"; everything else uses the launch subcommand.
+case "$1" in
+ prod|dev) DJANGO_APP_COMPONENT="api" ;;
+ *) DJANGO_APP_COMPONENT="$1" ;;
+esac
+export DJANGO_APP_COMPONENT
+
case "$1" in
dev)
apply_migrations
diff --git a/api/docs/orphan-task-recovery.md b/api/docs/orphan-task-recovery.md
new file mode 100644
index 0000000000..af4eefc1ac
--- /dev/null
+++ b/api/docs/orphan-task-recovery.md
@@ -0,0 +1,106 @@
+# Orphan Celery task recovery
+
+When a worker is terminated mid-task (a deploy, an OOM kill, a node eviction), the
+task it was running can be left non-terminal forever: the `TaskResult` stays
+`STARTED` and nothing re-runs it. This page describes the mechanisms that detect and
+recover allowlisted idempotent orphans so pending-task alerts do not fire. Scan tasks
+are not auto-recovered (re-running a scan is not safe to do automatically); the
+watchdog covers the summary/aggregation and deletion tasks.
+
+## How recovery works
+
+1. **Durable delivery.** The broker is configured so a task message is acknowledged
+ only after the task finishes (`task_acks_late`), one task is reserved at a time
+ (`worker_prefetch_multiplier = 1`), and an abruptly-lost worker re-queues its task
+ (`task_reject_on_worker_lost`). On `SIGTERM` the worker is given a soft-shutdown
+ window (`worker_soft_shutdown_timeout`) to finish or re-queue in-flight work
+ before it is force-killed. `scan-perform`, `scan-perform-scheduled` and
+ `integration-jira` opt out of redelivery with `acks_late=False`, so a crash drops
+ them rather than re-running and duplicating findings or Jira issues. Other
+ non-recovered side-effect tasks keep `acks_late=True`, so the broker can still
+ re-deliver them after a worker loss: the S3 upload rebuilds from worker-local files
+ that did not survive the crash and so no-ops, but Security Hub re-reads findings from
+ the DB and re-sends them to AWS.
+
+2. **Periodic watchdog.** A Beat task, `reconcile-orphan-tasks`, runs every couple of
+ minutes (a `django_celery_beat` periodic task created by migration). For each
+ in-flight task result with an allowlisted idempotent task name, it pings the
+ worker recorded on the task's `TaskResult`:
+ - worker responds -> the task is still running, leave it alone;
+ - worker is gone (and the task started before a short grace window) -> it is a
+ real orphan: the stale task is revoked and marked terminal (clearing the
+ pending/started alert), and the task is re-enqueued from its stored name and
+ kwargs.
+
+ The re-run is safe because only tasks with proven idempotency are allowlisted: the
+ summary/aggregation tasks clear and re-write their own rows, and deletions are
+ idempotent. Scan tasks and external side effects are excluded: re-running a scan is
+ not safe to do automatically, Jira sends would create duplicate issues, the S3
+ upload rebuilds from worker-local files that do not survive a crash, and
+ report/Security Hub recovery is out of scope.
+
+3. **Recovery cap.** A per-task Valkey counter limits how often the same task is
+ re-enqueued. After `--max-attempts` recoveries (default 3) the orphan is marked
+ terminal instead of re-enqueued, so a task that repeatedly kills its worker cannot
+ loop forever.
+
+A Postgres advisory lock ensures that, even with multiple API/worker replicas, only
+one reconciliation runs at a time; the others no-op.
+
+## On-demand command
+
+The same logic is available as a management command, useful right after a deploy or
+for manual intervention:
+
+```bash
+python manage.py reconcile_orphan_tasks # recover now
+python manage.py reconcile_orphan_tasks --dry-run # report orphans, change nothing
+python manage.py reconcile_orphan_tasks --grace-minutes 5 --max-attempts 3
+```
+
+## Configuration
+
+All settings have safe defaults; override via environment variables.
+
+| Env var | Default | Purpose |
+| --- | --- | --- |
+| `DJANGO_CELERY_WORKER_PREFETCH_MULTIPLIER` | `1` | Tasks reserved per worker process. |
+| `DJANGO_CELERY_WORKER_CONCURRENCY` | unset | Optional Celery prefork pool size. When unset, Celery uses its CPU-based default. Set this on worker containers to bound idle memory on hosts with many CPUs. |
+| `DJANGO_CELERY_WORKER_SOFT_SHUTDOWN_TIMEOUT` | `60` | Seconds the worker drains/re-queues on `SIGTERM` before force-kill. |
+| `DJANGO_CELERY_TASK_TIME_LIMIT` | `21600` (6h) | Hard limit for most tasks; connection checks are capped at 120s. |
+| `DJANGO_CELERY_TASK_SOFT_TIME_LIMIT` | hard - 600 | Soft limit; raises `SoftTimeLimitExceeded` for cleanup. |
+| `DJANGO_CELERY_LONG_TASK_TIME_LIMIT` | `172800` (48h) | Hard limit for scans and provider/tenant deletions, which can legitimately run for more than a day. |
+| `DJANGO_CELERY_LONG_TASK_SOFT_TIME_LIMIT` | long hard - 600 | Soft limit for the long-running tasks above. |
+| `DJANGO_TASK_RECOVERY_ENABLED` | `false` | Master switch for orphan-task recovery, disabled by default (opt-in); set to `true` to enable. When off, no orphan is detected, marked terminal, or re-enqueued (attack-paths stale cleanup still runs). |
+| `DJANGO_TASK_RECOVERY_SUMMARIES_ENABLED` | `true` | Auto re-enqueue orphaned scan summary/aggregation tasks. |
+| `DJANGO_TASK_RECOVERY_DELETIONS_ENABLED` | `true` | Auto re-enqueue orphaned provider/tenant deletion tasks. |
+
+Recovery is opt-in: with the master flag off (the default) the sweep does nothing.
+Once enabled, the per-group flags default to on, so every group recovers unless you
+turn one off; a task whose group flag is off is marked terminal instead of
+re-enqueued.
+
+Turning recovery off only disables this watchdog sweep; it does not change Celery's
+broker-level redelivery (`task_acks_late`/`task_reject_on_worker_lost`), which still
+re-delivers tasks that keep `acks_late=True` on worker loss, independently of this flag.
+
+`task_acks_late` and `task_reject_on_worker_lost` are enabled in `config/celery.py`.
+
+## Deployment requirement
+
+Two conditions must both hold for the soft shutdown to actually drain work:
+
+1. **The worker must receive `SIGTERM`.** The container entrypoint `exec`s the
+ Celery process so it runs as PID 1; otherwise `SIGTERM` from `docker stop`/ECS
+ hits the entrypoint shell, never reaches Celery, and the worker is hard-killed
+ (SIGKILL) at the grace deadline without draining. Custom entrypoints must
+ preserve the `exec`.
+2. **The orchestrator must give the worker enough time** before force-killing it.
+ Set the stop grace period to exceed `DJANGO_CELERY_WORKER_SOFT_SHUTDOWN_TIMEOUT`
+ plus a margin:
+ - **docker-compose:** `stop_grace_period` on the worker services (set to `120s`).
+ - **AWS ECS:** the worker container `stopTimeout` (configured in the deployment
+ repository).
+
+If either condition is missing, long tasks are still recovered by the watchdog,
+but they are cut mid-run on every deploy instead of draining.
diff --git a/api/pyproject.toml b/api/pyproject.toml
index 25a6a56ce6..607a8c7348 100644
--- a/api/pyproject.toml
+++ b/api/pyproject.toml
@@ -14,7 +14,7 @@ dev = [
"pytest-env==1.1.3",
"pytest-randomly==3.15.0",
"pytest-xdist==3.6.1",
- "ruff==0.5.0",
+ "ruff==0.15.11",
"tqdm==4.67.1",
"vulture==2.14",
"prek==0.3.9"
@@ -41,7 +41,9 @@ dependencies = [
"drf-spectacular==0.27.2",
"drf-spectacular-jsonapi==0.5.1",
"defusedxml==0.7.1",
- "gunicorn==23.0.0",
+ "django-eventstream==5.3.3",
+ "gunicorn==26.0.0",
+ "uvloop==0.22.1",
"lxml==6.1.0",
"prowler @ git+https://github.com/prowler-cloud/prowler.git@master",
"psycopg2-binary==2.9.9",
@@ -56,11 +58,12 @@ dependencies = [
"matplotlib (==3.10.8)",
"reportlab (==4.4.10)",
"neo4j (==6.1.0)",
- "cartography (==0.135.0)",
+ "cartography (==0.138.1)",
"gevent (==25.9.1)",
"werkzeug (==3.1.7)",
"sqlparse (==0.5.5)",
- "fonttools (==4.62.1)"
+ "fonttools (==4.62.1)",
+ "uvicorn-worker (==0.4.0)",
]
description = "Prowler's API (Django/DRF)"
license = "Apache-2.0"
@@ -68,7 +71,24 @@ name = "prowler-api"
package-mode = false
# Needed for the SDK compatibility
requires-python = ">=3.11,<3.13"
-version = "1.29.0"
+version = "1.37.0"
+
+# Shared ruff baseline (kept in sync with mcp_server/pyproject.toml).
+# target-version tracks this project's lowest supported Python.
+[tool.ruff]
+src = ["src"]
+target-version = "py311"
+
+[tool.ruff.lint]
+# Defaults (E4/E7/E9, F) plus import sorting, modern-syntax upgrades, and
+# comprehension lints — all mechanically auto-fixable. flake8-bugbear (B) is a
+# good next step but needs manual cleanup (e.g. B904 raise-from), so it is left
+# out of the shared baseline for now.
+extend-select = [
+ "I", # isort — import ordering (prek's isort hook covers only the SDK)
+ "UP", # pyupgrade — modern syntax for the min supported Python
+ "C4" # flake8-comprehensions
+]
[tool.uv]
# Transitive pins matching master to avoid silent drift; bump deliberately.
@@ -79,7 +99,7 @@ constraint-dependencies = [
"aiobotocore==2.25.1",
"aiofiles==24.1.0",
"aiohappyeyeballs==2.6.1",
- "aiohttp==3.13.5",
+ "aiohttp==3.14.0",
"aioitertools==0.13.0",
"aiosignal==1.4.0",
"alibabacloud-actiontrail20200706==2.4.1",
@@ -124,9 +144,8 @@ constraint-dependencies = [
"astroid==3.2.4",
"async-timeout==5.0.1",
"attrs==25.4.0",
- "authlib==1.6.9",
+ "authlib==1.6.12",
"autopep8==2.3.2",
- "awsipranges==0.3.3",
"azure-cli-core==2.83.0",
"azure-cli-telemetry==1.1.0",
"azure-common==1.1.28",
@@ -174,7 +193,7 @@ constraint-dependencies = [
"blinker==1.9.0",
"boto3==1.40.61",
"botocore==1.40.61",
- "cartography==0.135.0",
+ "cartography==0.138.1",
"celery==5.6.2",
"certifi==2026.1.4",
"cffi==2.0.0",
@@ -199,7 +218,6 @@ constraint-dependencies = [
"debugpy==1.8.20",
"decorator==5.2.1",
"defusedxml==0.7.1",
- "detect-secrets==1.5.0",
"dill==0.4.1",
"distro==1.9.0",
"dj-rest-auth==7.0.1",
@@ -209,6 +227,7 @@ constraint-dependencies = [
"django-celery-results==2.6.0",
"django-cors-headers==4.4.0",
"django-environ==0.11.2",
+ "django-eventstream==5.3.3",
"django-filter==24.3",
"django-guid==3.5.0",
"django-postgres-extra==2.0.9",
@@ -226,7 +245,7 @@ constraint-dependencies = [
"drf-simple-apikey==2.2.1",
"drf-spectacular==0.27.2",
"drf-spectacular-jsonapi==0.5.1",
- "dulwich==0.23.0",
+ "dulwich==1.2.5",
"duo-client==5.5.0",
"durationpy==0.10",
"email-validator==2.2.0",
@@ -253,7 +272,7 @@ constraint-dependencies = [
"grpc-google-iam-v1==0.14.3",
"grpcio==1.76.0",
"grpcio-status==1.76.0",
- "gunicorn==23.0.0",
+ "gunicorn==26.0.0",
"h11==0.16.0",
"h2==4.3.0",
"hpack==4.1.0",
@@ -262,8 +281,8 @@ constraint-dependencies = [
"httpx==0.28.1",
"humanfriendly==10.0",
"hyperframe==6.1.0",
- "iamdata==0.1.202602021",
- "idna==3.11",
+ "iamdata==0.1.202605131",
+ "idna==3.15",
"importlib-metadata==8.7.1",
"inflection==0.5.1",
"iniconfig==2.3.0",
@@ -281,6 +300,7 @@ constraint-dependencies = [
"jsonschema==4.23.0",
"jsonschema-specifications==2025.9.1",
"keystoneauth1==5.13.0",
+ "kingfisher-bin==1.104.0",
"kiwisolver==1.4.9",
"knack==0.11.0",
"kombu==5.6.2",
@@ -315,7 +335,7 @@ constraint-dependencies = [
"neo4j==6.1.0",
"nest-asyncio==1.6.0",
"nltk==3.9.4",
- "numpy==2.0.2",
+ "numpy==2.2.6",
"oauthlib==3.3.1",
"oci==2.169.0",
"openai==1.109.1",
@@ -344,7 +364,7 @@ constraint-dependencies = [
"psutil==7.2.2",
"psycopg2-binary==2.9.9",
"py-deviceid==0.1.1",
- "py-iam-expand==0.1.0",
+ "py-iam-expand==0.3.0",
"py-ocsf-models==0.8.1",
"pyasn1==0.6.3",
"pyasn1-modules==0.4.2",
@@ -354,7 +374,7 @@ constraint-dependencies = [
"pydantic-core==2.41.5",
"pygithub==2.8.0",
"pygments==2.20.0",
- "pyjwt==2.12.1",
+ "pyjwt==2.13.0",
"pylint==3.2.5",
"pymsalruntime==0.18.1",
"pynacl==1.6.2",
@@ -390,7 +410,7 @@ constraint-dependencies = [
"rpds-py==0.30.0",
"rsa==4.9.1",
"ruamel-yaml==0.19.1",
- "ruff==0.5.0",
+ "ruff==0.15.11",
"s3transfer==0.14.0",
"scaleway==2.10.3",
"scaleway-core==2.10.3",
@@ -420,12 +440,14 @@ constraint-dependencies = [
"uritemplate==4.2.0",
"urllib3==2.7.0",
"uuid6==2024.7.10",
+ "uvicorn==0.49.0",
+ "uvloop==0.22.1",
"vine==5.1.0",
"vulture==2.14",
"wcwidth==0.5.3",
"websocket-client==1.9.0",
"werkzeug==3.1.7",
- "workos==6.0.4",
+ "workos==6.0.8",
"wrapt==1.17.3",
"xlsxwriter==3.2.9",
"xmlsec==1.3.17",
@@ -436,14 +458,30 @@ constraint-dependencies = [
"zope-interface==8.2",
"zstd==1.5.7.3"
]
-# prowler@master needs okta==3.4.2; cartography 0.135.0 declares okta<1.0.0 for an
-# integration prowler does not import.
+# prowler@master needs okta==3.4.2, but cartography 0.138.1 requires okta<1.0.0.
+# Attack Paths does not ingest Okta today, so override the Cartography
+# dependency to the Prowler pin.
+#
+# prowler@master needs azure-mgmt-containerservice==34.1.0, but cartography
+# 0.138.1 requires azure-mgmt-containerservice>=41.0.0. Attack Paths does not
+# ingest Azure today, so override the Cartography dependency to the Prowler pin.
#
# prowler@master hard-pins microsoft-kiota-abstractions==1.9.2 in [project.dependencies].
# The microsoft-kiota-http security bump to 1.9.9 (GHSA-7j59-v9qr-6fq9) requires
# microsoft-kiota-abstractions>=1.9.9, which a constraint cannot satisfy against the
# SDK's hard pin; override it to the patched, kiota-aligned version.
+#
+# prowler@master hard-pins dulwich==0.23.0 and pyjwt==2.12.1 in [project.dependencies].
+# dulwich 1.2.5 patches GHSA-897w-fcg9-f6xj (arbitrary file write) and pyjwt 2.13.0
+# patches PYSEC-2026-179 (HMAC/JWK key-confusion); a constraint cannot satisfy these
+# against the SDK's hard pins, so override them to the patched versions until the SDK
+# bump propagates to the pinned master rev. pyjwt keeps the [crypto] extra because an
+# override replaces the whole requirement; bare pyjwt would drop it from the consumers
+# that request pyjwt[crypto] and leave cryptography (needed for RS256) only transitive.
override-dependencies = [
"okta==3.4.2",
- "microsoft-kiota-abstractions==1.9.9"
+ "azure-mgmt-containerservice==34.1.0",
+ "microsoft-kiota-abstractions==1.9.9",
+ "dulwich==1.2.5",
+ "pyjwt[crypto]==2.13.0"
]
diff --git a/api/src/backend/api/adapters.py b/api/src/backend/api/adapters.py
index e09dc972b4..99ab88bf81 100644
--- a/api/src/backend/api/adapters.py
+++ b/api/src/backend/api/adapters.py
@@ -1,9 +1,19 @@
+from allauth.account.models import EmailAddress
+from allauth.core.exceptions import ImmediateHttpResponse
from allauth.socialaccount.adapter import DefaultSocialAccountAdapter
-from django.db import transaction
-
from api.db_router import MainRouter
from api.db_utils import rls_transaction
-from api.models import Membership, Role, Tenant, User, UserRoleRelationship
+from api.models import (
+ Membership,
+ Role,
+ SAMLConfiguration,
+ Tenant,
+ User,
+ UserRoleRelationship,
+)
+from api.utils import accept_invitation_for_user
+from django.db import transaction
+from django.http import HttpResponseForbidden
class ProwlerSocialAccountAdapter(DefaultSocialAccountAdapter):
@@ -14,14 +24,81 @@ class ProwlerSocialAccountAdapter(DefaultSocialAccountAdapter):
except User.DoesNotExist:
return None
+ @staticmethod
+ def _get_invitation_token(request):
+ for source_name in ("data", "POST"):
+ data = getattr(request, source_name, None) or {}
+ if not hasattr(data, "get"):
+ continue
+ invitation_token = data.get("invitation_token")
+ if invitation_token:
+ return invitation_token
+
+ wrapped_request = getattr(request, "_request", None)
+ if wrapped_request and wrapped_request is not request:
+ return ProwlerSocialAccountAdapter._get_invitation_token(wrapped_request)
+
+ return None
+
def pre_social_login(self, request, sociallogin):
- # Link existing accounts with the same email address
- email = sociallogin.account.extra_data.get("email")
+ # The provider account is already bound, so no email-based linking is needed.
+ if sociallogin.account.pk:
+ return
+
+ # Prefer the normalized email populated by allauth. GitHub can return the
+ # primary email separately from the profile stored in extra_data.
+ email = sociallogin.user.email or sociallogin.account.extra_data.get("email")
if sociallogin.provider.id == "saml":
+ # For SAML, the asserted NameID email cannot be trusted on its own:
+ # any tenant can claim any email domain in its SAML configuration. To
+ # prevent cross-tenant account takeover (GHSA-h8m9-jgf8-vwvp), only link
+ # the incoming SAML session to an existing account when (1) the email
+ # domain matches the tenant whose ACS endpoint is being used and (2) the
+ # existing user is already a member of that tenant.
email = sociallogin.user.email
+ if not email:
+ return
+
+ domain = email.rsplit("@", 1)[-1].lower()
+ resolver_match = getattr(request, "resolver_match", None)
+ organization_slug = (
+ (resolver_match.kwargs or {}).get("organization_slug", "")
+ if resolver_match
+ else ""
+ ).lower()
+ # The ACS endpoint is scoped per email domain; reject mismatches so an
+ # attacker cannot replay an assertion through another tenant's endpoint.
+ if organization_slug != domain:
+ return
+
+ try:
+ saml_config = SAMLConfiguration.objects.using(MainRouter.admin_db).get(
+ email_domain=domain
+ )
+ except SAMLConfiguration.DoesNotExist:
+ return
+
+ existing_user = self.get_user_by_email(email)
+ if existing_user and existing_user.is_member_of_tenant(
+ str(saml_config.tenant_id)
+ ):
+ sociallogin.connect(request, existing_user)
+ return
+
if email:
existing_user = self.get_user_by_email(email)
if existing_user:
+ email_is_verified = EmailAddress.objects.filter(
+ user=existing_user,
+ email__iexact=email,
+ verified=True,
+ ).exists()
+ provider_verified_email = any(
+ address.verified and address.email.casefold() == email.casefold()
+ for address in sociallogin.email_addresses
+ )
+ if not email_is_verified or not provider_verified_email:
+ raise ImmediateHttpResponse(HttpResponseForbidden())
sociallogin.connect(request, existing_user)
def save_user(self, request, sociallogin, form=None):
@@ -42,29 +119,38 @@ class ProwlerSocialAccountAdapter(DefaultSocialAccountAdapter):
user.name = social_account_name
user.save(using=MainRouter.admin_db)
- tenant = Tenant.objects.using(MainRouter.admin_db).create(
- name=f"{user.email.split('@')[0]} default tenant"
- )
- with rls_transaction(str(tenant.id)):
- Membership.objects.using(MainRouter.admin_db).create(
- user=user, tenant=tenant, role=Membership.RoleChoices.OWNER
- )
- role = Role.objects.using(MainRouter.admin_db).create(
- name="admin",
- tenant_id=tenant.id,
- manage_users=True,
- manage_account=True,
- manage_billing=True,
- manage_providers=True,
- manage_integrations=True,
- manage_scans=True,
- unlimited_visibility=True,
- )
- UserRoleRelationship.objects.using(MainRouter.admin_db).create(
+ invitation_token = self._get_invitation_token(request)
+ if invitation_token:
+ invitation, _ = accept_invitation_for_user(
user=user,
- role=role,
- tenant_id=tenant.id,
+ invitation_token=invitation_token,
)
+ request.prowler_invitation_token = invitation_token
+ request.prowler_invitation_tenant_id = str(invitation.tenant_id)
+ else:
+ tenant = Tenant.objects.using(MainRouter.admin_db).create(
+ name=f"{user.email.split('@')[0]} default tenant"
+ )
+ with rls_transaction(str(tenant.id)):
+ Membership.objects.using(MainRouter.admin_db).create(
+ user=user, tenant=tenant, role=Membership.RoleChoices.OWNER
+ )
+ role = Role.objects.using(MainRouter.admin_db).create(
+ name="admin",
+ tenant_id=tenant.id,
+ manage_users=True,
+ manage_account=True,
+ manage_billing=True,
+ manage_providers=True,
+ manage_integrations=True,
+ manage_scans=True,
+ unlimited_visibility=True,
+ )
+ UserRoleRelationship.objects.using(MainRouter.admin_db).create(
+ user=user,
+ role=role,
+ tenant_id=tenant.id,
+ )
else:
request.session["saml_user_created"] = str(user.id)
diff --git a/api/src/backend/api/apps.py b/api/src/backend/api/apps.py
index 3209f75888..1aa0f8f54f 100644
--- a/api/src/backend/api/apps.py
+++ b/api/src/backend/api/apps.py
@@ -28,9 +28,10 @@ class ApiConfig(AppConfig):
name = "api"
def ready(self):
- from api import schema_extensions # noqa: F401
- from api import signals # noqa: F401
- from api.attack_paths import database as graph_database
+ from api import (
+ schema_extensions, # noqa: F401
+ signals, # noqa: F401
+ )
# Generate required cryptographic keys if not present, but only if:
# `"manage.py" not in sys.argv[0]`: If an external server (e.g., Gunicorn) is running the app
@@ -41,38 +42,6 @@ class ApiConfig(AppConfig):
):
self._ensure_crypto_keys()
- # Commands that don't need Neo4j
- SKIP_NEO4J_DJANGO_COMMANDS = [
- "makemigrations",
- "migrate",
- "pgpartition",
- "check",
- "help",
- "showmigrations",
- "check_and_fix_socialaccount_sites_migration",
- ]
-
- # Skip eager Neo4j init for tests, some Django commands, and Celery (prefork pool: driver must stay lazy, no post_fork hook)
- if getattr(settings, "TESTING", False) or (
- len(sys.argv) > 1
- and (
- (
- "manage.py" in sys.argv[0]
- and sys.argv[1] in SKIP_NEO4J_DJANGO_COMMANDS
- )
- or "celery" in sys.argv[0]
- )
- ):
- logger.info(
- "Skipping eager Neo4j init: tests, some Django commands, or Celery prefork pool (driver stays lazy)"
- )
-
- else:
- graph_database.init_driver()
-
- # Neo4j driver is initialized at API startup (see api.attack_paths.database)
- # It remains lazy for Celery workers and selected Django commands
-
def _ensure_crypto_keys(self):
"""
Orchestrator method that ensures all required cryptographic keys are present.
diff --git a/api/src/backend/api/attack_paths/__init__.py b/api/src/backend/api/attack_paths/__init__.py
index b2917e1d86..fc41fb63c1 100644
--- a/api/src/backend/api/attack_paths/__init__.py
+++ b/api/src/backend/api/attack_paths/__init__.py
@@ -5,7 +5,6 @@ from api.attack_paths.queries import (
get_query_by_id,
)
-
__all__ = [
"AttackPathsQueryDefinition",
"AttackPathsQueryParameterDefinition",
diff --git a/api/src/backend/api/attack_paths/cypher_sanitizer.py b/api/src/backend/api/attack_paths/cypher_sanitizer.py
index 3772b4cbef..35752b3ec9 100644
--- a/api/src/backend/api/attack_paths/cypher_sanitizer.py
+++ b/api/src/backend/api/attack_paths/cypher_sanitizer.py
@@ -4,10 +4,10 @@ Cypher sanitizer for custom (user-supplied) Attack Paths queries.
Two responsibilities:
1. **Validation** - reject queries containing SSRF or dangerous procedure
- patterns (defense-in-depth; the primary control is ``neo4j.READ_ACCESS``).
+ patterns (defense-in-depth; the primary control is `neo4j.READ_ACCESS`).
2. **Provider-scoped label injection** - inject a dynamic
- ``_Provider_{uuid}`` label into every node pattern so the database can
+ `_Provider_{uuid}` label into every node pattern so the database can
use its native label index for provider isolation.
Label-injection pipeline:
@@ -22,18 +22,16 @@ Label-injection pipeline:
import re
from rest_framework.exceptions import ValidationError
-
from tasks.jobs.attack_paths.config import get_provider_label
-
# Step 1 - String / comment protection
-# Single combined regex: strings first, then line comments.
+# Single combined regex: strings first, then line comments
# The regex engine finds the leftmost match, so a string like 'https://prowler.com'
-# is consumed as a string before the // inside it can match as a comment.
+# is consumed as a string before the // inside it can match as a comment
_PROTECTED_RE = re.compile(r"'(?:[^'\\]|\\.)*'|\"(?:[^\"\\]|\\.)*\"|//[^\n]*")
# Step 2 - Clause splitting
-# OPTIONAL MATCH must come before MATCH to avoid partial matching.
+# `OPTIONAL MATCH` must come before `MATCH` to avoid partial matching
_CLAUSE_RE = re.compile(
r"\b(OPTIONAL\s+MATCH|MATCH|WHERE|RETURN|WITH|ORDER\s+BY"
r"|SKIP|LIMIT|UNION|UNWIND|CALL)\b",
@@ -41,10 +39,10 @@ _CLAUSE_RE = re.compile(
)
# Pass A - Labeled node patterns (all segments)
-# Matches node patterns that have at least one :Label.
-# (? str:
node pattern.
"""
label = get_provider_label(provider_id)
+ return inject_label(cypher, label)
+
+
+def inject_label(cypher: str, label: str) -> str:
+ """Rewrite a Cypher query to append a label to every node pattern."""
# Step 1: Protect strings and comments (single pass, leftmost-first)
protected: list[str] = []
@@ -136,9 +139,7 @@ def inject_provider_label(cypher: str, provider_id: str) -> str:
return work
-# ---------------------------------------------------------------------------
# Validation
-# ---------------------------------------------------------------------------
# Patterns that indicate SSRF or dangerous procedure calls
# Defense-in-depth layer - the primary control is `neo4j.READ_ACCESS`
diff --git a/api/src/backend/api/attack_paths/database.py b/api/src/backend/api/attack_paths/database.py
index f5fddd0613..3ef55b7eca 100644
--- a/api/src/backend/api/attack_paths/database.py
+++ b/api/src/backend/api/attack_paths/database.py
@@ -1,233 +1,33 @@
-import atexit
-import logging
-import threading
-from contextlib import contextmanager
-from typing import Any, Iterator
+"""Backwards-compatible facade over the ingest and sink modules.
+
+Historically this module owned a single Neo4j driver used for both the
+cartography temp database and the per-tenant sink database. The port to AWS
+Neptune split those roles: the cartography ingest (temp) database is always
+Neo4j and lives in `api.attack_paths.ingest`; the sink is configurable
+(Neo4j or Neptune) and lives in `api.attack_paths.sink`. This shim preserves
+the public API that `tasks/` and `api/v1/views.py` already depend on, and
+dispatches to the right module by database-name prefix.
+
+A database name starting with `db-tmp-scan-` is a cartography temp DB and
+routes to ingest. Everything else routes to the configured sink.
+"""
+
+from contextlib import AbstractContextManager
+from typing import Any
from uuid import UUID
-import neo4j
-import neo4j.exceptions
+import neo4j # noqa: F401 - kept for tests that patch api.attack_paths.database.neo4j
+from api.attack_paths import ingest
+from api.attack_paths import sink as sink_module
from config.env import env
-from django.conf import settings
-from tasks.jobs.attack_paths.config import (
- BATCH_SIZE,
- PROVIDER_RESOURCE_LABEL,
- get_provider_label,
+from django.conf import (
+ settings, # noqa: F401 - kept for tests that patch ...database.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 = env.int(
- "ATTACK_PATHS_SERVICE_UNAVAILABLE_MAX_RETRIES", default=3
-)
-READ_QUERY_TIMEOUT_SECONDS = env.int(
- "ATTACK_PATHS_READ_QUERY_TIMEOUT_SECONDS", default=30
-)
MAX_CUSTOM_QUERY_NODES = env.int("ATTACK_PATHS_MAX_CUSTOM_QUERY_NODES", default=250)
-CONN_ACQUISITION_TIMEOUT = env.int("NEO4J_CONN_ACQUISITION_TIMEOUT", default=15)
-READ_EXCEPTION_CODES = [
- "Neo.ClientError.Statement.AccessMode",
- "Neo.ClientError.Procedure.ProcedureNotFound",
-]
-CLIENT_STATEMENT_EXCEPTION_PREFIX = "Neo.ClientError.Statement."
-# 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=CONN_ACQUISITION_TIMEOUT,
- 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, default_access_mode: str | None = None
-) -> Iterator[RetryableSession]:
- session_wrapper: RetryableSession | None = None
-
- try:
- session_wrapper = RetryableSession(
- session_factory=lambda: get_driver().session(
- database=database, default_access_mode=default_access_mode
- ),
- max_retries=SERVICE_UNAVAILABLE_MAX_RETRIES,
- )
- yield session_wrapper
-
- except neo4j.exceptions.Neo4jError as exc:
- if (
- default_access_mode == neo4j.READ_ACCESS
- and exc.code
- and exc.code in READ_EXCEPTION_CODES
- ):
- message = "Read query not allowed"
- code = READ_EXCEPTION_CODES[0]
- raise WriteQueryNotAllowedException(message=message, code=code)
-
- message = exc.message if exc.message is not None else str(exc)
-
- if exc.code and exc.code.startswith(CLIENT_STATEMENT_EXCEPTION_PREFIX):
- raise ClientStatementException(message=message, code=exc.code)
-
- raise GraphDatabaseQueryException(message=message, code=exc.code)
-
- finally:
- if session_wrapper is not None:
- session_wrapper.close()
-
-
-def execute_read_query(
- database: str,
- cypher: str,
- parameters: dict[str, Any] | None = None,
-) -> neo4j.graph.Graph:
- with get_session(database, default_access_mode=neo4j.READ_ACCESS) as session:
-
- def _run(tx: neo4j.ManagedTransaction) -> neo4j.graph.Graph:
- result = tx.run(
- cypher, parameters or {}, timeout=READ_QUERY_TIMEOUT_SECONDS
- )
- return result.graph()
-
- return session.execute_read(_run)
-
-
-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, provider_id: str) -> int:
- """
- Delete all nodes for a provider from the tenant database.
-
- Uses batched deletion to avoid memory issues with large graphs.
- Silently returns 0 if the database doesn't exist.
- """
- provider_label = get_provider_label(provider_id)
- deleted_nodes = 0
-
- try:
- with get_session(database) as session:
- deleted_count = 1
- while deleted_count > 0:
- result = session.run(
- f"""
- MATCH (n:{PROVIDER_RESOURCE_LABEL}:`{provider_label}`)
- WITH n LIMIT $batch_size
- DETACH DELETE n
- RETURN COUNT(n) AS deleted_nodes_count
- """,
- {"batch_size": BATCH_SIZE},
- )
- deleted_count = result.single().get("deleted_nodes_count", 0)
- deleted_nodes += deleted_count
-
- except GraphDatabaseQueryException as exc:
- if exc.code == "Neo.ClientError.Database.DatabaseNotFound":
- return 0
- raise
-
- return deleted_nodes
-
-
-def has_provider_data(database: str, provider_id: str) -> bool:
- """
- Check if any ProviderResource node exists for this provider.
-
- Returns `False` if the database doesn't exist.
- """
- provider_label = get_provider_label(provider_id)
- query = f"MATCH (n:{PROVIDER_RESOURCE_LABEL}:`{provider_label}`) RETURN 1 LIMIT 1"
-
- try:
- with get_session(database, default_access_mode=neo4j.READ_ACCESS) as session:
- result = session.run(query)
- return result.single() is not None
-
- except GraphDatabaseQueryException as exc:
- if exc.code == "Neo.ClientError.Database.DatabaseNotFound":
- return False
- raise
-
-
-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
-
-
-def get_database_name(entity_id: str | UUID, temporary: bool = False) -> str:
- prefix = "tmp-scan" if temporary else "tenant"
- return f"db-{prefix}-{str(entity_id).lower()}"
+TEMP_DB_PREFIX = "db-tmp-scan-"
+DATABASE_NOT_FOUND_CODE = "Neo.ClientError.Database.DatabaseNotFound"
# Exceptions
@@ -242,13 +42,190 @@ class GraphDatabaseQueryException(Exception):
def __str__(self) -> str:
if self.code:
return f"{self.code}: {self.message}"
-
return self.message
+class NeptuneWriteRetryExhaustedException(GraphDatabaseQueryException):
+ pass
+
+
class WriteQueryNotAllowedException(GraphDatabaseQueryException):
pass
class ClientStatementException(GraphDatabaseQueryException):
pass
+
+
+# Routing
+
+
+def _is_ingest_database(database: str | None) -> bool:
+ return bool(database) and database.startswith(TEMP_DB_PREFIX)
+
+
+# Driver lifecycle
+
+
+def init_driver() -> Any:
+ """Initialize the configured sink backend.
+
+ The ingest driver (Neo4j for cartography temp DBs) stays lazy: it is
+ only initialized when a temp-DB operation actually runs, which never
+ happens on API pods.
+ """
+ return sink_module.init()
+
+
+def close_driver() -> None:
+ """Close every driver held by this process."""
+ sink_module.close()
+ ingest.close_driver()
+
+
+def get_driver() -> neo4j.Driver:
+ """Return the sink backend's underlying driver.
+
+ Only meaningful for the Neo4j sink (where the backend has a single Neo4j
+ driver). On Neptune this returns the writer driver. Kept for tests and
+ legacy call-sites; prefer `get_session` for new code.
+ """
+ backend = sink_module.get_backend()
+
+ # Neo4jSink exposes get_driver(); NeptuneSink exposes get_writer()
+ if hasattr(backend, "get_driver"):
+ return backend.get_driver()
+
+ if hasattr(backend, "get_writer"):
+ return backend.get_writer()
+
+ raise RuntimeError("Active sink backend does not expose a driver handle")
+
+
+def verify_connectivity() -> None:
+ """Raise if the configured graph database is unreachable on the API read path.
+
+ Backend-agnostic entry point for the readiness probe: Neo4j verifies its
+ driver, Neptune verifies the reader endpoint.
+ """
+ sink_module.get_backend().verify_connectivity()
+
+
+def verify_scan_databases_available() -> None:
+ """Raise if either graph database needed by an Attack Paths scan is unavailable."""
+ errors: list[str] = []
+ first_error: Exception | None = None
+
+ try:
+ ingest.get_driver().verify_connectivity()
+ except Exception as exc:
+ errors.append(f"ingest Neo4j: {exc}")
+ first_error = exc
+
+ try:
+ get_driver().verify_connectivity()
+ except Exception as exc:
+ errors.append(f"sink {settings.ATTACK_PATHS_SINK_DATABASE}: {exc}")
+ if first_error is None:
+ first_error = exc
+
+ if errors:
+ raise RuntimeError(
+ "Attack Paths graph database unavailable before scan start: "
+ + "; ".join(errors)
+ ) from first_error
+
+
+def get_uri() -> str:
+ """Return the sink URI. Retained for backwards compatibility."""
+ if settings.ATTACK_PATHS_SINK_DATABASE == "neptune":
+ cfg = settings.DATABASES["neptune"]
+ return f"bolt+s://{cfg['WRITER_ENDPOINT']}:{cfg['PORT']}"
+
+ cfg = settings.DATABASES["neo4j"]
+ return f"bolt://{cfg['HOST']}:{cfg['PORT']}"
+
+
+def get_ingest_uri() -> str:
+ """Neo4j URI for the cartography temp (ingest) database, which is always
+ Neo4j regardless of the configured sink."""
+ return ingest.get_uri()
+
+
+# Session API
+
+
+def get_session(
+ database: str | None = None,
+ default_access_mode: str | None = None,
+) -> AbstractContextManager:
+ """Return a session against the right backend.
+
+ - `database` names starting with `db-tmp-scan-` always go to ingest.
+ - No database name → ingest (used for CREATE / DROP DATABASE admin ops).
+ - Any other name → sink.
+ """
+ if _is_ingest_database(database) or database is None:
+ return ingest.get_session(
+ database=database, default_access_mode=default_access_mode
+ )
+
+ return sink_module.get_backend().get_session(
+ database=database, default_access_mode=default_access_mode
+ )
+
+
+def execute_read_query(
+ database: str,
+ cypher: str,
+ parameters: dict[str, Any] | None = None,
+) -> neo4j.graph.Graph:
+ """Read-only query against the sink."""
+ return sink_module.get_backend().execute_read_query(database, cypher, parameters)
+
+
+def create_database(database: str) -> None:
+ """Create a database. Temp DBs always land on ingest (Neo4j).
+
+ On the Neo4j sink, tenant DBs also route to ingest because both drivers
+ connect to the same Neo4j cluster. On the Neptune sink, tenant DB creates
+ are no-ops.
+ """
+ if _is_ingest_database(database):
+ ingest.create_database(database)
+ return
+
+ sink_module.get_backend().create_database(database)
+
+
+def drop_database(database: str) -> None:
+ """Drop a database. Mirrors `create_database` routing."""
+ if _is_ingest_database(database):
+ ingest.drop_database(database)
+ return
+
+ sink_module.get_backend().drop_database(database)
+
+
+def drop_subgraph(database: str, provider_id: str) -> int:
+ return sink_module.get_backend().drop_subgraph(database, provider_id)
+
+
+def has_provider_data(database: str, provider_id: str) -> bool:
+ return sink_module.get_backend().has_provider_data(database, provider_id)
+
+
+def clear_cache(database: str) -> None:
+ if _is_ingest_database(database):
+ ingest.clear_cache(database)
+ return
+
+ sink_module.get_backend().clear_cache(database)
+
+
+# Name helper
+
+
+def get_database_name(entity_id: str | UUID, temporary: bool = False) -> str:
+ prefix = "tmp-scan" if temporary else "tenant"
+ return f"db-{prefix}-{str(entity_id).lower()}"
diff --git a/api/src/backend/api/attack_paths/ingest/__init__.py b/api/src/backend/api/attack_paths/ingest/__init__.py
new file mode 100644
index 0000000000..5833b8b373
--- /dev/null
+++ b/api/src/backend/api/attack_paths/ingest/__init__.py
@@ -0,0 +1,29 @@
+"""Cartography ingest layer.
+
+Public surface for the per-scan Neo4j temp database driver. Implementation
+lives in `api.attack_paths.ingest.driver`.
+"""
+
+from api.attack_paths.ingest.driver import (
+ clear_cache,
+ close_driver,
+ create_database,
+ drop_database,
+ get_driver,
+ get_session,
+ get_uri,
+ init_driver,
+ run_cypher,
+)
+
+__all__ = [
+ "clear_cache",
+ "close_driver",
+ "create_database",
+ "drop_database",
+ "get_driver",
+ "get_session",
+ "get_uri",
+ "init_driver",
+ "run_cypher",
+]
diff --git a/api/src/backend/api/attack_paths/ingest/driver.py b/api/src/backend/api/attack_paths/ingest/driver.py
new file mode 100644
index 0000000000..1b05c721e7
--- /dev/null
+++ b/api/src/backend/api/attack_paths/ingest/driver.py
@@ -0,0 +1,187 @@
+"""Cartography ingest driver: per-scan throw-away Neo4j database.
+
+Cartography writes each scan's graph into a throw-away Neo4j database named
+`db-tmp-scan-{scan_uuid}`. This is always Neo4j, regardless of the configured
+sink: Neptune is single-database and cannot host per-scan throw-away
+databases. This module owns the Neo4j driver used for those temp DBs and the
+admin ops they need (CREATE / DROP DATABASE).
+"""
+
+import atexit
+import logging
+import threading
+from collections.abc import Iterator
+from contextlib import contextmanager
+from typing import Any
+
+import neo4j
+import neo4j.exceptions
+from api.attack_paths.retryable_session import RetryableSession
+from config.env import env
+from django.conf import settings
+
+logging.getLogger("neo4j").setLevel(logging.ERROR)
+logging.getLogger("neo4j").propagate = False
+
+SERVICE_UNAVAILABLE_MAX_RETRIES = env.int(
+ "ATTACK_PATHS_SERVICE_UNAVAILABLE_MAX_RETRIES", default=3
+)
+CONN_ACQUISITION_TIMEOUT = env.int("NEO4J_CONN_ACQUISITION_TIMEOUT", default=15)
+# TCP connect timeout, ordered below the acquisition timeout so an unreachable
+# host can't pin a worker on a temp-DB op longer than this.
+CONNECTION_TIMEOUT = env.int("NEO4J_CONNECTION_TIMEOUT", default=5)
+MAX_CONNECTION_LIFETIME = env.int("NEO4J_MAX_CONNECTION_LIFETIME", default=7200)
+MAX_CONNECTION_POOL_SIZE = env.int("NEO4J_MAX_CONNECTION_POOL_SIZE", default=50)
+
+_driver: neo4j.Driver | None = None
+_lock = threading.Lock()
+
+
+def _neo4j_config() -> dict:
+ return settings.DATABASES["neo4j"]
+
+
+def get_uri() -> str:
+ """Bolt URI for the Neo4j temp (ingest) database. Always Neo4j."""
+ config = _neo4j_config()
+ host = config["HOST"]
+ port = config["PORT"]
+ if not host or not port:
+ raise RuntimeError(
+ "NEO4J_HOST / NEO4J_PORT must be set to use the attack-paths "
+ "temp database. Workers require Neo4j env even when the sink is Neptune."
+ )
+
+ return f"bolt://{host}:{port}"
+
+
+def init_driver() -> neo4j.Driver:
+ """Initialize the temp-database Neo4j driver. Idempotent."""
+ global _driver
+ if _driver is not None:
+ return _driver
+
+ with _lock:
+ if _driver is None:
+ config = _neo4j_config()
+ _driver = neo4j.GraphDatabase.driver(
+ get_uri(),
+ auth=(config["USER"], config["PASSWORD"]),
+ keep_alive=True,
+ max_connection_lifetime=MAX_CONNECTION_LIFETIME,
+ connection_timeout=CONNECTION_TIMEOUT,
+ connection_acquisition_timeout=CONN_ACQUISITION_TIMEOUT,
+ max_connection_pool_size=MAX_CONNECTION_POOL_SIZE,
+ )
+ # Best-effort connectivity check: a Neo4j that is down at boot must
+ # not crash the worker. The driver reconnects lazily on first use.
+ try:
+ _driver.verify_connectivity()
+
+ except Exception:
+ logging.warning(
+ "Neo4j temp-database unreachable at init; continuing with a "
+ "lazily-reconnecting driver",
+ exc_info=True,
+ )
+
+ atexit.register(close_driver)
+
+ return _driver
+
+
+def get_driver() -> neo4j.Driver:
+ return init_driver()
+
+
+def close_driver() -> None:
+ global _driver
+ with _lock:
+ if _driver is not None:
+ try:
+ _driver.close()
+ finally:
+ _driver = None
+
+
+@contextmanager
+def get_session(
+ database: str | None = None,
+ default_access_mode: str | None = None,
+) -> Iterator[RetryableSession]:
+ """Session against the Neo4j temp-database cluster. Used for temp DB sessions
+ and for admin operations (CREATE / DROP DATABASE) when `database` is None."""
+ from api.attack_paths.database import (
+ ClientStatementException,
+ GraphDatabaseQueryException,
+ WriteQueryNotAllowedException,
+ )
+
+ READ_EXCEPTION_CODES = [
+ "Neo.ClientError.Statement.AccessMode",
+ "Neo.ClientError.Procedure.ProcedureNotFound",
+ ]
+ CLIENT_STATEMENT_EXCEPTION_PREFIX = "Neo.ClientError.Statement."
+
+ session_wrapper: RetryableSession | None = None
+ try:
+ session_wrapper = RetryableSession(
+ session_factory=lambda: get_driver().session(
+ database=database, default_access_mode=default_access_mode
+ ),
+ max_retries=SERVICE_UNAVAILABLE_MAX_RETRIES,
+ )
+ yield session_wrapper
+
+ except neo4j.exceptions.Neo4jError as exc:
+ if (
+ default_access_mode == neo4j.READ_ACCESS
+ and exc.code
+ and exc.code in READ_EXCEPTION_CODES
+ ):
+ raise WriteQueryNotAllowedException(
+ message="Read query not allowed", code=READ_EXCEPTION_CODES[0]
+ )
+
+ message = exc.message if exc.message is not None else str(exc)
+ if exc.code and exc.code.startswith(CLIENT_STATEMENT_EXCEPTION_PREFIX):
+ raise ClientStatementException(message=message, code=exc.code)
+ raise GraphDatabaseQueryException(message=message, code=exc.code)
+
+ finally:
+ if session_wrapper is not None:
+ session_wrapper.close()
+
+
+def create_database(database: str) -> None:
+ """Create a database on the Neo4j cluster. Used for temp scan DBs."""
+ with get_session() as session:
+ session.run("CREATE DATABASE $database IF NOT EXISTS", {"database": database})
+
+
+def drop_database(database: str) -> None:
+ """Drop a database on the Neo4j cluster. Used for temp scan DBs."""
+ with get_session() as session:
+ session.run(f"DROP DATABASE `{database}` IF EXISTS DESTROY DATA")
+
+
+def clear_cache(database: str) -> None:
+ """Best-effort cache clear for a Neo4j database."""
+ from api.attack_paths.database import GraphDatabaseQueryException
+
+ try:
+ with get_session(database) as session:
+ session.run("CALL db.clearQueryCaches()")
+
+ except GraphDatabaseQueryException as exc:
+ logging.warning(f"Failed to clear query cache for database `{database}`: {exc}")
+
+
+def run_cypher(
+ database: str | None,
+ cypher: str,
+ parameters: dict[str, Any] | None = None,
+) -> Any:
+ """Execute Cypher directly without the context manager. Thin helper."""
+ with get_session(database) as session:
+ return session.run(cypher, parameters or {})
diff --git a/api/src/backend/api/attack_paths/queries/__init__.py b/api/src/backend/api/attack_paths/queries/__init__.py
index c5e6ab0393..aa90ba6878 100644
--- a/api/src/backend/api/attack_paths/queries/__init__.py
+++ b/api/src/backend/api/attack_paths/queries/__init__.py
@@ -1,12 +1,11 @@
-from api.attack_paths.queries.types import (
- AttackPathsQueryDefinition,
- AttackPathsQueryParameterDefinition,
-)
from api.attack_paths.queries.registry import (
get_queries_for_provider,
get_query_by_id,
)
-
+from api.attack_paths.queries.types import (
+ AttackPathsQueryDefinition,
+ AttackPathsQueryParameterDefinition,
+)
__all__ = [
"AttackPathsQueryDefinition",
diff --git a/api/src/backend/api/attack_paths/queries/aws.py b/api/src/backend/api/attack_paths/queries/aws.py
index 81f91de24f..60660a1632 100644
--- a/api/src/backend/api/attack_paths/queries/aws.py
+++ b/api/src/backend/api/attack_paths/queries/aws.py
@@ -5,9 +5,7 @@ from api.attack_paths.queries.types import (
)
from tasks.jobs.attack_paths.config import PROWLER_FINDING_LABEL
-
# Custom Attack Path Queries
-# --------------------------
AWS_INTERNET_EXPOSED_EC2_SENSITIVE_S3_ACCESS = AttackPathsQueryDefinition(
id="aws-internet-exposed-ec2-sensitive-s3-access",
@@ -23,14 +21,18 @@ AWS_INTERNET_EXPOSED_EC2_SENSITIVE_S3_ACCESS = AttackPathsQueryDefinition(
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_role = (r:AWSRole)-[:POLICY]->(pol:AWSPolicy)-[:STATEMENT]->(stmt:AWSPolicyStatement)
+ MATCH (stmt)-[:HAS_RESOURCE]->(res:AWSPolicyStatementResourceItem)
+ WHERE res.value CONTAINS s3.name
+ MATCH (stmt)-[:HAS_ACTION]->(act:AWSPolicyStatementActionItem)
+ WHERE toLower(act.value) STARTS WITH 's3:listbucket'
+ OR toLower(act.value) STARTS WITH 's3:getobject'
MATCH path_assume_role = (ec2)-[p:STS_ASSUMEROLE_ALLOW*1..9]-(r:AWSRole)
OPTIONAL MATCH (internet:Internet)-[can_access:CAN_ACCESS]->(ec2)
+ WITH DISTINCT path_s3, path_ec2, path_role, path_assume_role, internet, can_access
WITH collect(path_s3) + collect(path_ec2) + collect(path_role) + collect(path_assume_role) AS paths,
head(collect(internet)) AS internet, collect(can_access) AS can_access
UNWIND paths AS p
@@ -38,7 +40,7 @@ AWS_INTERNET_EXPOSED_EC2_SENSITIVE_S3_ACCESS = AttackPathsQueryDefinition(
WITH paths, internet, can_access, collect(DISTINCT n) AS unique_nodes
UNWIND unique_nodes AS n
- OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}})
+ OPTIONAL MATCH (n)-[pfr:HAS_FINDING]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}})
RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr, internet, can_access
""",
@@ -60,7 +62,6 @@ AWS_INTERNET_EXPOSED_EC2_SENSITIVE_S3_ACCESS = AttackPathsQueryDefinition(
# Basic Resource Queries
-# ----------------------
AWS_RDS_INSTANCES = AttackPathsQueryDefinition(
id="aws-rds-instances",
@@ -77,7 +78,7 @@ AWS_RDS_INSTANCES = AttackPathsQueryDefinition(
WITH paths, collect(DISTINCT n) AS unique_nodes
UNWIND unique_nodes AS n
- OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}})
+ OPTIONAL MATCH (n)-[pfr:HAS_FINDING]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}})
RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr
""",
@@ -100,7 +101,7 @@ AWS_RDS_UNENCRYPTED_STORAGE = AttackPathsQueryDefinition(
WITH paths, collect(DISTINCT n) AS unique_nodes
UNWIND unique_nodes AS n
- OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}})
+ OPTIONAL MATCH (n)-[pfr:HAS_FINDING]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}})
RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr
""",
@@ -123,7 +124,7 @@ AWS_S3_ANONYMOUS_ACCESS_BUCKETS = AttackPathsQueryDefinition(
WITH paths, collect(DISTINCT n) AS unique_nodes
UNWIND unique_nodes AS n
- OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}})
+ OPTIONAL MATCH (n)-[pfr:HAS_FINDING]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}})
RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr
""",
@@ -137,17 +138,18 @@ AWS_IAM_STATEMENTS_ALLOW_ALL_ACTIONS = AttackPathsQueryDefinition(
description="Find IAM policy statements that allow all actions via '*' within the selected account.",
provider="aws",
cypher=f"""
- 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 = '*')
+ MATCH path = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)-[:POLICY]->(pol:AWSPolicy)-[:STATEMENT]->(stmt:AWSPolicyStatement {{effect: 'Allow'}})
+ MATCH (stmt)-[:HAS_ACTION]->(act:AWSPolicyStatementActionItem)
+ WHERE act.value = '*'
+ WITH DISTINCT path
WITH collect(path) AS paths
UNWIND paths AS p
UNWIND nodes(p) AS n
WITH paths, collect(DISTINCT n) AS unique_nodes
UNWIND unique_nodes AS n
- OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}})
+ OPTIONAL MATCH (n)-[pfr:HAS_FINDING]->(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}})
RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr
""",
@@ -161,17 +163,18 @@ AWS_IAM_STATEMENTS_ALLOW_DELETE_POLICY = AttackPathsQueryDefinition(
description="Find IAM policy statements that allow the iam:DeletePolicy action within the selected account.",
provider="aws",
cypher=f"""
- 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")
+ MATCH path = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)-[:POLICY]->(pol:AWSPolicy)-[:STATEMENT]->(stmt:AWSPolicyStatement {{effect: 'Allow'}})
+ MATCH (stmt)-[:HAS_ACTION]->(act:AWSPolicyStatementActionItem)
+ WHERE act.value = 'iam:DeletePolicy'
+ WITH DISTINCT path
WITH collect(path) AS paths
UNWIND paths AS p
UNWIND nodes(p) AS n
WITH paths, collect(DISTINCT n) AS unique_nodes
UNWIND unique_nodes AS n
- OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}})
+ OPTIONAL MATCH (n)-[pfr:HAS_FINDING]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}})
RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr
""",
@@ -185,17 +188,18 @@ AWS_IAM_STATEMENTS_ALLOW_CREATE_ACTIONS = AttackPathsQueryDefinition(
description="Find IAM policy statements that allow actions containing 'create' within the selected account.",
provider="aws",
cypher=f"""
- 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")
+ MATCH path = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)-[:POLICY]->(pol:AWSPolicy)-[:STATEMENT]->(stmt:AWSPolicyStatement {{effect: 'Allow'}})
+ MATCH (stmt)-[:HAS_ACTION]->(act:AWSPolicyStatementActionItem)
+ WHERE toLower(act.value) CONTAINS 'create'
+ WITH DISTINCT path
WITH collect(path) AS paths
UNWIND paths AS p
UNWIND nodes(p) AS n
WITH paths, collect(DISTINCT n) AS unique_nodes
UNWIND unique_nodes AS n
- OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}})
+ OPTIONAL MATCH (n)-[pfr:HAS_FINDING]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}})
RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr
""",
@@ -204,7 +208,6 @@ AWS_IAM_STATEMENTS_ALLOW_CREATE_ACTIONS = AttackPathsQueryDefinition(
# Network Exposure Queries
-# ------------------------
AWS_EC2_INSTANCES_INTERNET_EXPOSED = AttackPathsQueryDefinition(
id="aws-ec2-instances-internet-exposed",
@@ -224,7 +227,7 @@ AWS_EC2_INSTANCES_INTERNET_EXPOSED = AttackPathsQueryDefinition(
WITH paths, internet, can_access, collect(DISTINCT n) AS unique_nodes
UNWIND unique_nodes AS n
- OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}})
+ OPTIONAL MATCH (n)-[pfr:HAS_FINDING]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}})
RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr, internet, can_access
""",
@@ -250,7 +253,7 @@ AWS_SECURITY_GROUPS_OPEN_INTERNET_FACING = AttackPathsQueryDefinition(
WITH paths, internet, can_access, collect(DISTINCT n) AS unique_nodes
UNWIND unique_nodes AS n
- OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}})
+ OPTIONAL MATCH (n)-[pfr:HAS_FINDING]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}})
RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr, internet, can_access
""",
@@ -275,7 +278,7 @@ AWS_CLASSIC_ELB_INTERNET_EXPOSED = AttackPathsQueryDefinition(
WITH paths, internet, can_access, collect(DISTINCT n) AS unique_nodes
UNWIND unique_nodes AS n
- OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}})
+ OPTIONAL MATCH (n)-[pfr:HAS_FINDING]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}})
RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr, internet, can_access
""",
@@ -300,7 +303,7 @@ AWS_ELBV2_INTERNET_EXPOSED = AttackPathsQueryDefinition(
WITH paths, internet, can_access, collect(DISTINCT n) AS unique_nodes
UNWIND unique_nodes AS n
- OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}})
+ OPTIONAL MATCH (n)-[pfr:HAS_FINDING]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}})
RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr, internet, can_access
""",
@@ -328,7 +331,7 @@ AWS_PUBLIC_IP_RESOURCE_LOOKUP = AttackPathsQueryDefinition(
WITH paths, internet, can_access, collect(DISTINCT n) AS unique_nodes
UNWIND unique_nodes AS n
- OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}})
+ OPTIONAL MATCH (n)-[pfr:HAS_FINDING]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}})
RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr, internet, can_access
""",
@@ -344,7 +347,6 @@ AWS_PUBLIC_IP_RESOURCE_LOOKUP = AttackPathsQueryDefinition(
# Privilege Escalation Queries (based on pathfinding.cloud research)
# https://github.com/DataDog/pathfinding.cloud
-# -------------------------------------------------------------------
# APPRUNNER-001
AWS_APPRUNNER_PRIVESC_PASSROLE_CREATE_SERVICE = AttackPathsQueryDefinition(
@@ -359,31 +361,27 @@ AWS_APPRUNNER_PRIVESC_PASSROLE_CREATE_SERVICE = AttackPathsQueryDefinition(
provider="aws",
cypher=f"""
// Find principals with iam:PassRole permission
- MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(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 = '*'
- )
+ MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)-[:POLICY]->(passrole_policy:AWSPolicy)-[:STATEMENT]->(stmt_passrole:AWSPolicyStatement {{effect: 'Allow'}})
+ MATCH (stmt_passrole)-[:HAS_ACTION]->(act:AWSPolicyStatementActionItem)
+ WHERE toLower(act.value) IN ['iam:*', 'iam:passrole']
+ OR act.value = '*'
+ WITH DISTINCT aws, principal, stmt_passrole, path_principal
// Find apprunner:CreateService permission
- MATCH (principal)--(apprunner_policy:AWSPolicy)--(stmt_apprunner:AWSPolicyStatement)
- WHERE stmt_apprunner.effect = 'Allow'
- AND any(action IN stmt_apprunner.action WHERE
- toLower(action) = 'apprunner:createservice'
- OR toLower(action) = 'apprunner:*'
- OR action = '*'
- )
+ MATCH (principal)-[:POLICY]->(apprunner_policy:AWSPolicy)-[:STATEMENT]->(stmt_apprunner:AWSPolicyStatement {{effect: 'Allow'}})
+ MATCH (stmt_apprunner)-[:HAS_ACTION]->(act2:AWSPolicyStatementActionItem)
+ WHERE toLower(act2.value) IN ['apprunner:*', 'apprunner:createservice']
+ OR act2.value = '*'
+ WITH DISTINCT aws, principal, stmt_passrole, path_principal
// Find roles that trust App Runner tasks service (can be passed to App Runner)
MATCH path_target = (aws)--(target_role:AWSRole)-[:TRUSTS_AWS_PRINCIPAL]->(:AWSPrincipal {{arn: 'tasks.apprunner.amazonaws.com'}})
- WHERE any(resource IN stmt_passrole.resource WHERE
- resource = '*'
- OR target_role.arn CONTAINS resource
- OR resource CONTAINS target_role.name
- )
+ MATCH (stmt_passrole)-[:HAS_RESOURCE]->(res:AWSPolicyStatementResourceItem)
+ WHERE res.value = '*'
+ OR res.value CONTAINS target_role.name
+ OR target_role.arn CONTAINS res.value
+ WITH DISTINCT path_principal, path_target
WITH collect(path_principal) + collect(path_target) AS paths
UNWIND paths AS p
UNWIND nodes(p) AS n
@@ -391,7 +389,7 @@ AWS_APPRUNNER_PRIVESC_PASSROLE_CREATE_SERVICE = AttackPathsQueryDefinition(
WITH paths, collect(DISTINCT n) AS unique_nodes
UNWIND unique_nodes AS n
- OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}})
+ OPTIONAL MATCH (n)-[pfr:HAS_FINDING]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}})
RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr
""",
@@ -411,25 +409,24 @@ AWS_APPRUNNER_PRIVESC_UPDATE_SERVICE = AttackPathsQueryDefinition(
provider="aws",
cypher=f"""
// Find principals with apprunner:UpdateService permission
- MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(update_policy:AWSPolicy)--(stmt_update:AWSPolicyStatement)
- WHERE stmt_update.effect = 'Allow'
- AND any(action IN stmt_update.action WHERE
- toLower(action) = 'apprunner:updateservice'
- OR toLower(action) = 'apprunner:*'
- OR action = '*'
- )
+ MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)-[:POLICY]->(update_policy:AWSPolicy)-[:STATEMENT]->(stmt_update:AWSPolicyStatement {{effect: 'Allow'}})
+ MATCH (stmt_update)-[:HAS_ACTION]->(act:AWSPolicyStatementActionItem)
+ WHERE toLower(act.value) IN ['apprunner:*', 'apprunner:updateservice']
+ OR act.value = '*'
+ WITH aws, collect(DISTINCT path_principal) AS principal_paths
// Find existing App Runner services with roles attached (potential targets)
MATCH path_target = (aws)--(target_role:AWSRole)-[:TRUSTS_AWS_PRINCIPAL]->(:AWSPrincipal {{arn: 'tasks.apprunner.amazonaws.com'}})
- WITH collect(path_principal) + collect(path_target) AS paths
+ WITH principal_paths, collect(DISTINCT path_target) AS target_paths
+ WITH principal_paths + target_paths AS paths
UNWIND paths AS p
UNWIND nodes(p) AS n
WITH paths, collect(DISTINCT n) AS unique_nodes
UNWIND unique_nodes AS n
- OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}})
+ OPTIONAL MATCH (n)-[pfr:HAS_FINDING]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}})
RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr
""",
@@ -449,49 +446,41 @@ AWS_BEDROCK_PRIVESC_PASSROLE_CODE_INTERPRETER = AttackPathsQueryDefinition(
provider="aws",
cypher=f"""
// Find principals with iam:PassRole permission
- MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(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 = '*'
- )
+ MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)-[:POLICY]->(passrole_policy:AWSPolicy)-[:STATEMENT]->(stmt_passrole:AWSPolicyStatement {{effect: 'Allow'}})
+ MATCH (stmt_passrole)-[:HAS_ACTION]->(act:AWSPolicyStatementActionItem)
+ WHERE toLower(act.value) IN ['iam:*', 'iam:passrole']
+ OR act.value = '*'
+ WITH DISTINCT aws, principal, stmt_passrole, path_principal
// Find bedrock-agentcore:CreateCodeInterpreter permission
- MATCH (principal)--(bedrock_policy:AWSPolicy)--(stmt_bedrock:AWSPolicyStatement)
- WHERE stmt_bedrock.effect = 'Allow'
- AND any(action IN stmt_bedrock.action WHERE
- toLower(action) = 'bedrock-agentcore:createcodeinterpreter'
- OR toLower(action) = 'bedrock-agentcore:*'
- OR action = '*'
- )
+ MATCH (principal)-[:POLICY]->(bedrock_policy:AWSPolicy)-[:STATEMENT]->(stmt_bedrock:AWSPolicyStatement {{effect: 'Allow'}})
+ MATCH (stmt_bedrock)-[:HAS_ACTION]->(act2:AWSPolicyStatementActionItem)
+ WHERE toLower(act2.value) IN ['bedrock-agentcore:*', 'bedrock-agentcore:createcodeinterpreter']
+ OR act2.value = '*'
+ WITH DISTINCT aws, principal, stmt_passrole, path_principal
// Find bedrock-agentcore:StartCodeInterpreterSession permission
- MATCH (principal)--(session_policy:AWSPolicy)--(stmt_session:AWSPolicyStatement)
- WHERE stmt_session.effect = 'Allow'
- AND any(action IN stmt_session.action WHERE
- toLower(action) = 'bedrock-agentcore:startcodeinterpretersession'
- OR toLower(action) = 'bedrock-agentcore:*'
- OR action = '*'
- )
+ MATCH (principal)-[:POLICY]->(session_policy:AWSPolicy)-[:STATEMENT]->(stmt_session:AWSPolicyStatement {{effect: 'Allow'}})
+ MATCH (stmt_session)-[:HAS_ACTION]->(act3:AWSPolicyStatementActionItem)
+ WHERE toLower(act3.value) IN ['bedrock-agentcore:*', 'bedrock-agentcore:startcodeinterpretersession']
+ OR act3.value = '*'
+ WITH DISTINCT aws, principal, stmt_passrole, path_principal
// Find bedrock-agentcore:InvokeCodeInterpreter permission
- MATCH (principal)--(invoke_policy:AWSPolicy)--(stmt_invoke:AWSPolicyStatement)
- WHERE stmt_invoke.effect = 'Allow'
- AND any(action IN stmt_invoke.action WHERE
- toLower(action) = 'bedrock-agentcore:invokecodeinterpreter'
- OR toLower(action) = 'bedrock-agentcore:*'
- OR action = '*'
- )
+ MATCH (principal)-[:POLICY]->(invoke_policy:AWSPolicy)-[:STATEMENT]->(stmt_invoke:AWSPolicyStatement {{effect: 'Allow'}})
+ MATCH (stmt_invoke)-[:HAS_ACTION]->(act4:AWSPolicyStatementActionItem)
+ WHERE toLower(act4.value) IN ['bedrock-agentcore:*', 'bedrock-agentcore:invokecodeinterpreter']
+ OR act4.value = '*'
+ WITH DISTINCT aws, principal, stmt_passrole, path_principal
// Find roles that trust the Bedrock AgentCore service (can be passed to a code interpreter)
MATCH path_target = (aws)--(target_role:AWSRole)-[:TRUSTS_AWS_PRINCIPAL]->(:AWSPrincipal {{arn: 'bedrock-agentcore.amazonaws.com'}})
- WHERE any(resource IN stmt_passrole.resource WHERE
- resource = '*'
- OR target_role.arn CONTAINS resource
- OR resource CONTAINS target_role.name
- )
+ MATCH (stmt_passrole)-[:HAS_RESOURCE]->(res:AWSPolicyStatementResourceItem)
+ WHERE res.value = '*'
+ OR res.value CONTAINS target_role.name
+ OR target_role.arn CONTAINS res.value
+ WITH DISTINCT path_principal, path_target
WITH collect(path_principal) + collect(path_target) AS paths
UNWIND paths AS p
UNWIND nodes(p) AS n
@@ -499,7 +488,7 @@ AWS_BEDROCK_PRIVESC_PASSROLE_CODE_INTERPRETER = AttackPathsQueryDefinition(
WITH paths, collect(DISTINCT n) AS unique_nodes
UNWIND unique_nodes AS n
- OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}})
+ OPTIONAL MATCH (n)-[pfr:HAS_FINDING]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}})
RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr
""",
@@ -519,34 +508,31 @@ AWS_BEDROCK_PRIVESC_INVOKE_CODE_INTERPRETER = AttackPathsQueryDefinition(
provider="aws",
cypher=f"""
// Find principals with bedrock-agentcore:StartCodeInterpreterSession permission
- MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(session_policy:AWSPolicy)--(stmt_session:AWSPolicyStatement)
- WHERE stmt_session.effect = 'Allow'
- AND any(action IN stmt_session.action WHERE
- toLower(action) = 'bedrock-agentcore:startcodeinterpretersession'
- OR toLower(action) = 'bedrock-agentcore:*'
- OR action = '*'
- )
+ MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)-[:POLICY]->(session_policy:AWSPolicy)-[:STATEMENT]->(stmt_session:AWSPolicyStatement {{effect: 'Allow'}})
+ MATCH (stmt_session)-[:HAS_ACTION]->(act:AWSPolicyStatementActionItem)
+ WHERE toLower(act.value) IN ['bedrock-agentcore:*', 'bedrock-agentcore:startcodeinterpretersession']
+ OR act.value = '*'
+ WITH DISTINCT aws, principal, path_principal
// Find bedrock-agentcore:InvokeCodeInterpreter permission
- MATCH (principal)--(invoke_policy:AWSPolicy)--(stmt_invoke:AWSPolicyStatement)
- WHERE stmt_invoke.effect = 'Allow'
- AND any(action IN stmt_invoke.action WHERE
- toLower(action) = 'bedrock-agentcore:invokecodeinterpreter'
- OR toLower(action) = 'bedrock-agentcore:*'
- OR action = '*'
- )
+ MATCH (principal)-[:POLICY]->(invoke_policy:AWSPolicy)-[:STATEMENT]->(stmt_invoke:AWSPolicyStatement {{effect: 'Allow'}})
+ MATCH (stmt_invoke)-[:HAS_ACTION]->(act2:AWSPolicyStatementActionItem)
+ WHERE toLower(act2.value) IN ['bedrock-agentcore:*', 'bedrock-agentcore:invokecodeinterpreter']
+ OR act2.value = '*'
+ WITH aws, collect(DISTINCT path_principal) AS principal_paths
// Find roles that trust the Bedrock AgentCore service (already attached to existing code interpreters)
MATCH path_target = (aws)--(target_role:AWSRole)-[:TRUSTS_AWS_PRINCIPAL]->(:AWSPrincipal {{arn: 'bedrock-agentcore.amazonaws.com'}})
- WITH collect(path_principal) + collect(path_target) AS paths
+ WITH principal_paths, collect(DISTINCT path_target) AS target_paths
+ WITH principal_paths + target_paths AS paths
UNWIND paths AS p
UNWIND nodes(p) AS n
WITH paths, collect(DISTINCT n) AS unique_nodes
UNWIND unique_nodes AS n
- OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}})
+ OPTIONAL MATCH (n)-[pfr:HAS_FINDING]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}})
RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr
""",
@@ -566,31 +552,27 @@ AWS_CLOUDFORMATION_PRIVESC_PASSROLE_CREATE_STACK = AttackPathsQueryDefinition(
provider="aws",
cypher=f"""
// Find principals with iam:PassRole permission
- MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(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 = '*'
- )
+ MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)-[:POLICY]->(passrole_policy:AWSPolicy)-[:STATEMENT]->(stmt_passrole:AWSPolicyStatement {{effect: 'Allow'}})
+ MATCH (stmt_passrole)-[:HAS_ACTION]->(act:AWSPolicyStatementActionItem)
+ WHERE toLower(act.value) IN ['iam:*', 'iam:passrole']
+ OR act.value = '*'
+ WITH DISTINCT aws, principal, stmt_passrole, path_principal
// Find cloudformation:CreateStack permission
- MATCH (principal)--(cfn_policy:AWSPolicy)--(stmt_cfn:AWSPolicyStatement)
- WHERE stmt_cfn.effect = 'Allow'
- AND any(action IN stmt_cfn.action WHERE
- toLower(action) = 'cloudformation:createstack'
- OR toLower(action) = 'cloudformation:*'
- OR action = '*'
- )
+ MATCH (principal)-[:POLICY]->(cfn_policy:AWSPolicy)-[:STATEMENT]->(stmt_cfn:AWSPolicyStatement {{effect: 'Allow'}})
+ MATCH (stmt_cfn)-[:HAS_ACTION]->(act2:AWSPolicyStatementActionItem)
+ WHERE toLower(act2.value) IN ['cloudformation:*', 'cloudformation:createstack']
+ OR act2.value = '*'
+ WITH DISTINCT aws, principal, stmt_passrole, path_principal
// Find roles that trust CloudFormation service (can be passed to CloudFormation)
MATCH path_target = (aws)--(target_role:AWSRole)-[:TRUSTS_AWS_PRINCIPAL]->(:AWSPrincipal {{arn: 'cloudformation.amazonaws.com'}})
- WHERE any(resource IN stmt_passrole.resource WHERE
- resource = '*'
- OR target_role.arn CONTAINS resource
- OR resource CONTAINS target_role.name
- )
+ MATCH (stmt_passrole)-[:HAS_RESOURCE]->(res:AWSPolicyStatementResourceItem)
+ WHERE res.value = '*'
+ OR res.value CONTAINS target_role.name
+ OR target_role.arn CONTAINS res.value
+ WITH DISTINCT path_principal, path_target
WITH collect(path_principal) + collect(path_target) AS paths
UNWIND paths AS p
UNWIND nodes(p) AS n
@@ -598,7 +580,7 @@ AWS_CLOUDFORMATION_PRIVESC_PASSROLE_CREATE_STACK = AttackPathsQueryDefinition(
WITH paths, collect(DISTINCT n) AS unique_nodes
UNWIND unique_nodes AS n
- OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}})
+ OPTIONAL MATCH (n)-[pfr:HAS_FINDING]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}})
RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr
""",
@@ -618,25 +600,24 @@ AWS_CLOUDFORMATION_PRIVESC_UPDATE_STACK = AttackPathsQueryDefinition(
provider="aws",
cypher=f"""
// Find principals with cloudformation:UpdateStack permission
- MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(update_policy:AWSPolicy)--(stmt_update:AWSPolicyStatement)
- WHERE stmt_update.effect = 'Allow'
- AND any(action IN stmt_update.action WHERE
- toLower(action) = 'cloudformation:updatestack'
- OR toLower(action) = 'cloudformation:*'
- OR action = '*'
- )
+ MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)-[:POLICY]->(update_policy:AWSPolicy)-[:STATEMENT]->(stmt_update:AWSPolicyStatement {{effect: 'Allow'}})
+ MATCH (stmt_update)-[:HAS_ACTION]->(act:AWSPolicyStatementActionItem)
+ WHERE toLower(act.value) IN ['cloudformation:*', 'cloudformation:updatestack']
+ OR act.value = '*'
+ WITH aws, collect(DISTINCT path_principal) AS principal_paths
// Find roles that trust CloudFormation service (already attached to existing stacks)
MATCH path_target = (aws)--(target_role:AWSRole)-[:TRUSTS_AWS_PRINCIPAL]->(:AWSPrincipal {{arn: 'cloudformation.amazonaws.com'}})
- WITH collect(path_principal) + collect(path_target) AS paths
+ WITH principal_paths, collect(DISTINCT path_target) AS target_paths
+ WITH principal_paths + target_paths AS paths
UNWIND paths AS p
UNWIND nodes(p) AS n
WITH paths, collect(DISTINCT n) AS unique_nodes
UNWIND unique_nodes AS n
- OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}})
+ OPTIONAL MATCH (n)-[pfr:HAS_FINDING]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}})
RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr
""",
@@ -656,40 +637,34 @@ AWS_CLOUDFORMATION_PRIVESC_PASSROLE_CREATE_STACKSET = AttackPathsQueryDefinition
provider="aws",
cypher=f"""
// Find principals with iam:PassRole permission
- MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(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 = '*'
- )
+ MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)-[:POLICY]->(passrole_policy:AWSPolicy)-[:STATEMENT]->(stmt_passrole:AWSPolicyStatement {{effect: 'Allow'}})
+ MATCH (stmt_passrole)-[:HAS_ACTION]->(act:AWSPolicyStatementActionItem)
+ WHERE toLower(act.value) IN ['iam:*', 'iam:passrole']
+ OR act.value = '*'
+ WITH DISTINCT aws, principal, stmt_passrole, path_principal
// Find cloudformation:CreateStackSet permission
- MATCH (principal)--(cfn_policy:AWSPolicy)--(stmt_cfn:AWSPolicyStatement)
- WHERE stmt_cfn.effect = 'Allow'
- AND any(action IN stmt_cfn.action WHERE
- toLower(action) = 'cloudformation:createstackset'
- OR toLower(action) = 'cloudformation:*'
- OR action = '*'
- )
+ MATCH (principal)-[:POLICY]->(cfn_policy:AWSPolicy)-[:STATEMENT]->(stmt_cfn:AWSPolicyStatement {{effect: 'Allow'}})
+ MATCH (stmt_cfn)-[:HAS_ACTION]->(act2:AWSPolicyStatementActionItem)
+ WHERE toLower(act2.value) IN ['cloudformation:*', 'cloudformation:createstackset']
+ OR act2.value = '*'
+ WITH DISTINCT aws, principal, stmt_passrole, path_principal
// Find cloudformation:CreateStackInstances permission
- MATCH (principal)--(cfn_instances_policy:AWSPolicy)--(stmt_cfn_instances:AWSPolicyStatement)
- WHERE stmt_cfn_instances.effect = 'Allow'
- AND any(action IN stmt_cfn_instances.action WHERE
- toLower(action) = 'cloudformation:createstackinstances'
- OR toLower(action) = 'cloudformation:*'
- OR action = '*'
- )
+ MATCH (principal)-[:POLICY]->(cfn_instances_policy:AWSPolicy)-[:STATEMENT]->(stmt_cfn_instances:AWSPolicyStatement {{effect: 'Allow'}})
+ MATCH (stmt_cfn_instances)-[:HAS_ACTION]->(act3:AWSPolicyStatementActionItem)
+ WHERE toLower(act3.value) IN ['cloudformation:*', 'cloudformation:createstackinstances']
+ OR act3.value = '*'
+ WITH DISTINCT aws, principal, stmt_passrole, path_principal
// Find roles that trust CloudFormation service (can be passed as execution role)
MATCH path_target = (aws)--(target_role:AWSRole)-[:TRUSTS_AWS_PRINCIPAL]->(:AWSPrincipal {{arn: 'cloudformation.amazonaws.com'}})
- WHERE any(resource IN stmt_passrole.resource WHERE
- resource = '*'
- OR target_role.arn CONTAINS resource
- OR resource CONTAINS target_role.name
- )
+ MATCH (stmt_passrole)-[:HAS_RESOURCE]->(res:AWSPolicyStatementResourceItem)
+ WHERE res.value = '*'
+ OR res.value CONTAINS target_role.name
+ OR target_role.arn CONTAINS res.value
+ WITH DISTINCT path_principal, path_target
WITH collect(path_principal) + collect(path_target) AS paths
UNWIND paths AS p
UNWIND nodes(p) AS n
@@ -697,7 +672,7 @@ AWS_CLOUDFORMATION_PRIVESC_PASSROLE_CREATE_STACKSET = AttackPathsQueryDefinition
WITH paths, collect(DISTINCT n) AS unique_nodes
UNWIND unique_nodes AS n
- OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}})
+ OPTIONAL MATCH (n)-[pfr:HAS_FINDING]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}})
RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr
""",
@@ -717,31 +692,27 @@ AWS_CLOUDFORMATION_PRIVESC_PASSROLE_UPDATE_STACKSET = AttackPathsQueryDefinition
provider="aws",
cypher=f"""
// Find principals with iam:PassRole permission
- MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(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 = '*'
- )
+ MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)-[:POLICY]->(passrole_policy:AWSPolicy)-[:STATEMENT]->(stmt_passrole:AWSPolicyStatement {{effect: 'Allow'}})
+ MATCH (stmt_passrole)-[:HAS_ACTION]->(act:AWSPolicyStatementActionItem)
+ WHERE toLower(act.value) IN ['iam:*', 'iam:passrole']
+ OR act.value = '*'
+ WITH DISTINCT aws, principal, stmt_passrole, path_principal
// Find cloudformation:UpdateStackSet permission
- MATCH (principal)--(cfn_policy:AWSPolicy)--(stmt_cfn:AWSPolicyStatement)
- WHERE stmt_cfn.effect = 'Allow'
- AND any(action IN stmt_cfn.action WHERE
- toLower(action) = 'cloudformation:updatestackset'
- OR toLower(action) = 'cloudformation:*'
- OR action = '*'
- )
+ MATCH (principal)-[:POLICY]->(cfn_policy:AWSPolicy)-[:STATEMENT]->(stmt_cfn:AWSPolicyStatement {{effect: 'Allow'}})
+ MATCH (stmt_cfn)-[:HAS_ACTION]->(act2:AWSPolicyStatementActionItem)
+ WHERE toLower(act2.value) IN ['cloudformation:*', 'cloudformation:updatestackset']
+ OR act2.value = '*'
+ WITH DISTINCT aws, principal, stmt_passrole, path_principal
// Find roles that trust CloudFormation service (can be passed as execution role)
MATCH path_target = (aws)--(target_role:AWSRole)-[:TRUSTS_AWS_PRINCIPAL]->(:AWSPrincipal {{arn: 'cloudformation.amazonaws.com'}})
- WHERE any(resource IN stmt_passrole.resource WHERE
- resource = '*'
- OR target_role.arn CONTAINS resource
- OR resource CONTAINS target_role.name
- )
+ MATCH (stmt_passrole)-[:HAS_RESOURCE]->(res:AWSPolicyStatementResourceItem)
+ WHERE res.value = '*'
+ OR res.value CONTAINS target_role.name
+ OR target_role.arn CONTAINS res.value
+ WITH DISTINCT path_principal, path_target
WITH collect(path_principal) + collect(path_target) AS paths
UNWIND paths AS p
UNWIND nodes(p) AS n
@@ -749,7 +720,7 @@ AWS_CLOUDFORMATION_PRIVESC_PASSROLE_UPDATE_STACKSET = AttackPathsQueryDefinition
WITH paths, collect(DISTINCT n) AS unique_nodes
UNWIND unique_nodes AS n
- OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}})
+ OPTIONAL MATCH (n)-[pfr:HAS_FINDING]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}})
RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr
""",
@@ -769,34 +740,31 @@ AWS_CLOUDFORMATION_PRIVESC_CHANGESET = AttackPathsQueryDefinition(
provider="aws",
cypher=f"""
// Find principals with cloudformation:CreateChangeSet permission
- MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(create_policy:AWSPolicy)--(stmt_create:AWSPolicyStatement)
- WHERE stmt_create.effect = 'Allow'
- AND any(action IN stmt_create.action WHERE
- toLower(action) = 'cloudformation:createchangeset'
- OR toLower(action) = 'cloudformation:*'
- OR action = '*'
- )
+ MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)-[:POLICY]->(create_policy:AWSPolicy)-[:STATEMENT]->(stmt_create:AWSPolicyStatement {{effect: 'Allow'}})
+ MATCH (stmt_create)-[:HAS_ACTION]->(act:AWSPolicyStatementActionItem)
+ WHERE toLower(act.value) IN ['cloudformation:*', 'cloudformation:createchangeset']
+ OR act.value = '*'
+ WITH DISTINCT aws, principal, path_principal
// Find cloudformation:ExecuteChangeSet permission
- MATCH (principal)--(exec_policy:AWSPolicy)--(stmt_exec:AWSPolicyStatement)
- WHERE stmt_exec.effect = 'Allow'
- AND any(action IN stmt_exec.action WHERE
- toLower(action) = 'cloudformation:executechangeset'
- OR toLower(action) = 'cloudformation:*'
- OR action = '*'
- )
+ MATCH (principal)-[:POLICY]->(exec_policy:AWSPolicy)-[:STATEMENT]->(stmt_exec:AWSPolicyStatement {{effect: 'Allow'}})
+ MATCH (stmt_exec)-[:HAS_ACTION]->(act2:AWSPolicyStatementActionItem)
+ WHERE toLower(act2.value) IN ['cloudformation:*', 'cloudformation:executechangeset']
+ OR act2.value = '*'
+ WITH aws, collect(DISTINCT path_principal) AS principal_paths
// Find roles that trust CloudFormation service (already attached to existing stacks)
MATCH path_target = (aws)--(target_role:AWSRole)-[:TRUSTS_AWS_PRINCIPAL]->(:AWSPrincipal {{arn: 'cloudformation.amazonaws.com'}})
- WITH collect(path_principal) + collect(path_target) AS paths
+ WITH principal_paths, collect(DISTINCT path_target) AS target_paths
+ WITH principal_paths + target_paths AS paths
UNWIND paths AS p
UNWIND nodes(p) AS n
WITH paths, collect(DISTINCT n) AS unique_nodes
UNWIND unique_nodes AS n
- OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}})
+ OPTIONAL MATCH (n)-[pfr:HAS_FINDING]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}})
RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr
""",
@@ -816,40 +784,34 @@ AWS_CODEBUILD_PRIVESC_PASSROLE_CREATE_PROJECT = AttackPathsQueryDefinition(
provider="aws",
cypher=f"""
// Find principals with iam:PassRole permission
- MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(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 = '*'
- )
+ MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)-[:POLICY]->(passrole_policy:AWSPolicy)-[:STATEMENT]->(stmt_passrole:AWSPolicyStatement {{effect: 'Allow'}})
+ MATCH (stmt_passrole)-[:HAS_ACTION]->(act:AWSPolicyStatementActionItem)
+ WHERE toLower(act.value) IN ['iam:*', 'iam:passrole']
+ OR act.value = '*'
+ WITH DISTINCT aws, principal, stmt_passrole, path_principal
// Find codebuild:CreateProject permission
- MATCH (principal)--(create_policy:AWSPolicy)--(stmt_create:AWSPolicyStatement)
- WHERE stmt_create.effect = 'Allow'
- AND any(action IN stmt_create.action WHERE
- toLower(action) = 'codebuild:createproject'
- OR toLower(action) = 'codebuild:*'
- OR action = '*'
- )
+ MATCH (principal)-[:POLICY]->(create_policy:AWSPolicy)-[:STATEMENT]->(stmt_create:AWSPolicyStatement {{effect: 'Allow'}})
+ MATCH (stmt_create)-[:HAS_ACTION]->(act2:AWSPolicyStatementActionItem)
+ WHERE toLower(act2.value) IN ['codebuild:*', 'codebuild:createproject']
+ OR act2.value = '*'
+ WITH DISTINCT aws, principal, stmt_passrole, path_principal
// Find codebuild:StartBuild permission
- MATCH (principal)--(build_policy:AWSPolicy)--(stmt_build:AWSPolicyStatement)
- WHERE stmt_build.effect = 'Allow'
- AND any(action IN stmt_build.action WHERE
- toLower(action) = 'codebuild:startbuild'
- OR toLower(action) = 'codebuild:*'
- OR action = '*'
- )
+ MATCH (principal)-[:POLICY]->(build_policy:AWSPolicy)-[:STATEMENT]->(stmt_build:AWSPolicyStatement {{effect: 'Allow'}})
+ MATCH (stmt_build)-[:HAS_ACTION]->(act3:AWSPolicyStatementActionItem)
+ WHERE toLower(act3.value) IN ['codebuild:*', 'codebuild:startbuild']
+ OR act3.value = '*'
+ WITH DISTINCT aws, principal, stmt_passrole, path_principal
// Find roles that trust CodeBuild service (can be passed to CodeBuild)
MATCH path_target = (aws)--(target_role:AWSRole)-[:TRUSTS_AWS_PRINCIPAL]->(:AWSPrincipal {{arn: 'codebuild.amazonaws.com'}})
- WHERE any(resource IN stmt_passrole.resource WHERE
- resource = '*'
- OR target_role.arn CONTAINS resource
- OR resource CONTAINS target_role.name
- )
+ MATCH (stmt_passrole)-[:HAS_RESOURCE]->(res:AWSPolicyStatementResourceItem)
+ WHERE res.value = '*'
+ OR res.value CONTAINS target_role.name
+ OR target_role.arn CONTAINS res.value
+ WITH DISTINCT path_principal, path_target
WITH collect(path_principal) + collect(path_target) AS paths
UNWIND paths AS p
UNWIND nodes(p) AS n
@@ -857,7 +819,7 @@ AWS_CODEBUILD_PRIVESC_PASSROLE_CREATE_PROJECT = AttackPathsQueryDefinition(
WITH paths, collect(DISTINCT n) AS unique_nodes
UNWIND unique_nodes AS n
- OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}})
+ OPTIONAL MATCH (n)-[pfr:HAS_FINDING]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}})
RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr
""",
@@ -877,25 +839,24 @@ AWS_CODEBUILD_PRIVESC_START_BUILD = AttackPathsQueryDefinition(
provider="aws",
cypher=f"""
// Find principals with codebuild:StartBuild permission
- MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(build_policy:AWSPolicy)--(stmt_build:AWSPolicyStatement)
- WHERE stmt_build.effect = 'Allow'
- AND any(action IN stmt_build.action WHERE
- toLower(action) = 'codebuild:startbuild'
- OR toLower(action) = 'codebuild:*'
- OR action = '*'
- )
+ MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)-[:POLICY]->(build_policy:AWSPolicy)-[:STATEMENT]->(stmt_build:AWSPolicyStatement {{effect: 'Allow'}})
+ MATCH (stmt_build)-[:HAS_ACTION]->(act:AWSPolicyStatementActionItem)
+ WHERE toLower(act.value) IN ['codebuild:*', 'codebuild:startbuild']
+ OR act.value = '*'
+ WITH aws, collect(DISTINCT path_principal) AS principal_paths
// Find roles that trust CodeBuild service (already attached to existing projects)
MATCH path_target = (aws)--(target_role:AWSRole)-[:TRUSTS_AWS_PRINCIPAL]->(:AWSPrincipal {{arn: 'codebuild.amazonaws.com'}})
- WITH collect(path_principal) + collect(path_target) AS paths
+ WITH principal_paths, collect(DISTINCT path_target) AS target_paths
+ WITH principal_paths + target_paths AS paths
UNWIND paths AS p
UNWIND nodes(p) AS n
WITH paths, collect(DISTINCT n) AS unique_nodes
UNWIND unique_nodes AS n
- OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}})
+ OPTIONAL MATCH (n)-[pfr:HAS_FINDING]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}})
RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr
""",
@@ -915,25 +876,24 @@ AWS_CODEBUILD_PRIVESC_START_BUILD_BATCH = AttackPathsQueryDefinition(
provider="aws",
cypher=f"""
// Find principals with codebuild:StartBuildBatch permission
- MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(build_policy:AWSPolicy)--(stmt_build:AWSPolicyStatement)
- WHERE stmt_build.effect = 'Allow'
- AND any(action IN stmt_build.action WHERE
- toLower(action) = 'codebuild:startbuildbatch'
- OR toLower(action) = 'codebuild:*'
- OR action = '*'
- )
+ MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)-[:POLICY]->(build_policy:AWSPolicy)-[:STATEMENT]->(stmt_build:AWSPolicyStatement {{effect: 'Allow'}})
+ MATCH (stmt_build)-[:HAS_ACTION]->(act:AWSPolicyStatementActionItem)
+ WHERE toLower(act.value) IN ['codebuild:*', 'codebuild:startbuildbatch']
+ OR act.value = '*'
+ WITH aws, collect(DISTINCT path_principal) AS principal_paths
// Find roles that trust CodeBuild service (already attached to existing projects)
MATCH path_target = (aws)--(target_role:AWSRole)-[:TRUSTS_AWS_PRINCIPAL]->(:AWSPrincipal {{arn: 'codebuild.amazonaws.com'}})
- WITH collect(path_principal) + collect(path_target) AS paths
+ WITH principal_paths, collect(DISTINCT path_target) AS target_paths
+ WITH principal_paths + target_paths AS paths
UNWIND paths AS p
UNWIND nodes(p) AS n
WITH paths, collect(DISTINCT n) AS unique_nodes
UNWIND unique_nodes AS n
- OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}})
+ OPTIONAL MATCH (n)-[pfr:HAS_FINDING]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}})
RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr
""",
@@ -953,40 +913,34 @@ AWS_CODEBUILD_PRIVESC_PASSROLE_CREATE_PROJECT_BATCH = AttackPathsQueryDefinition
provider="aws",
cypher=f"""
// Find principals with iam:PassRole permission
- MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(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 = '*'
- )
+ MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)-[:POLICY]->(passrole_policy:AWSPolicy)-[:STATEMENT]->(stmt_passrole:AWSPolicyStatement {{effect: 'Allow'}})
+ MATCH (stmt_passrole)-[:HAS_ACTION]->(act:AWSPolicyStatementActionItem)
+ WHERE toLower(act.value) IN ['iam:*', 'iam:passrole']
+ OR act.value = '*'
+ WITH DISTINCT aws, principal, stmt_passrole, path_principal
// Find codebuild:CreateProject permission
- MATCH (principal)--(create_policy:AWSPolicy)--(stmt_create:AWSPolicyStatement)
- WHERE stmt_create.effect = 'Allow'
- AND any(action IN stmt_create.action WHERE
- toLower(action) = 'codebuild:createproject'
- OR toLower(action) = 'codebuild:*'
- OR action = '*'
- )
+ MATCH (principal)-[:POLICY]->(create_policy:AWSPolicy)-[:STATEMENT]->(stmt_create:AWSPolicyStatement {{effect: 'Allow'}})
+ MATCH (stmt_create)-[:HAS_ACTION]->(act2:AWSPolicyStatementActionItem)
+ WHERE toLower(act2.value) IN ['codebuild:*', 'codebuild:createproject']
+ OR act2.value = '*'
+ WITH DISTINCT aws, principal, stmt_passrole, path_principal
// Find codebuild:StartBuildBatch permission
- MATCH (principal)--(batch_policy:AWSPolicy)--(stmt_batch:AWSPolicyStatement)
- WHERE stmt_batch.effect = 'Allow'
- AND any(action IN stmt_batch.action WHERE
- toLower(action) = 'codebuild:startbuildbatch'
- OR toLower(action) = 'codebuild:*'
- OR action = '*'
- )
+ MATCH (principal)-[:POLICY]->(batch_policy:AWSPolicy)-[:STATEMENT]->(stmt_batch:AWSPolicyStatement {{effect: 'Allow'}})
+ MATCH (stmt_batch)-[:HAS_ACTION]->(act3:AWSPolicyStatementActionItem)
+ WHERE toLower(act3.value) IN ['codebuild:*', 'codebuild:startbuildbatch']
+ OR act3.value = '*'
+ WITH DISTINCT aws, principal, stmt_passrole, path_principal
// Find roles that trust CodeBuild service (can be passed to CodeBuild)
MATCH path_target = (aws)--(target_role:AWSRole)-[:TRUSTS_AWS_PRINCIPAL]->(:AWSPrincipal {{arn: 'codebuild.amazonaws.com'}})
- WHERE any(resource IN stmt_passrole.resource WHERE
- resource = '*'
- OR target_role.arn CONTAINS resource
- OR resource CONTAINS target_role.name
- )
+ MATCH (stmt_passrole)-[:HAS_RESOURCE]->(res:AWSPolicyStatementResourceItem)
+ WHERE res.value = '*'
+ OR res.value CONTAINS target_role.name
+ OR target_role.arn CONTAINS res.value
+ WITH DISTINCT path_principal, path_target
WITH collect(path_principal) + collect(path_target) AS paths
UNWIND paths AS p
UNWIND nodes(p) AS n
@@ -994,7 +948,7 @@ AWS_CODEBUILD_PRIVESC_PASSROLE_CREATE_PROJECT_BATCH = AttackPathsQueryDefinition
WITH paths, collect(DISTINCT n) AS unique_nodes
UNWIND unique_nodes AS n
- OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}})
+ OPTIONAL MATCH (n)-[pfr:HAS_FINDING]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}})
RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr
""",
@@ -1014,50 +968,42 @@ AWS_DATAPIPELINE_PRIVESC_PASSROLE_CREATE_PIPELINE = AttackPathsQueryDefinition(
provider="aws",
cypher=f"""
// Find principals with iam:PassRole permission
- MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(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 = '*'
- )
+ MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)-[:POLICY]->(passrole_policy:AWSPolicy)-[:STATEMENT]->(stmt_passrole:AWSPolicyStatement {{effect: 'Allow'}})
+ MATCH (stmt_passrole)-[:HAS_ACTION]->(act:AWSPolicyStatementActionItem)
+ WHERE toLower(act.value) IN ['iam:*', 'iam:passrole']
+ OR act.value = '*'
+ WITH DISTINCT aws, principal, stmt_passrole, path_principal
// Find datapipeline:CreatePipeline permission
- MATCH (principal)--(create_policy:AWSPolicy)--(stmt_create:AWSPolicyStatement)
- WHERE stmt_create.effect = 'Allow'
- AND any(action IN stmt_create.action WHERE
- toLower(action) = 'datapipeline:createpipeline'
- OR toLower(action) = 'datapipeline:*'
- OR action = '*'
- )
+ MATCH (principal)-[:POLICY]->(create_policy:AWSPolicy)-[:STATEMENT]->(stmt_create:AWSPolicyStatement {{effect: 'Allow'}})
+ MATCH (stmt_create)-[:HAS_ACTION]->(act2:AWSPolicyStatementActionItem)
+ WHERE toLower(act2.value) IN ['datapipeline:*', 'datapipeline:createpipeline']
+ OR act2.value = '*'
+ WITH DISTINCT aws, principal, stmt_passrole, path_principal
// Find datapipeline:PutPipelineDefinition permission
- MATCH (principal)--(put_policy:AWSPolicy)--(stmt_put:AWSPolicyStatement)
- WHERE stmt_put.effect = 'Allow'
- AND any(action IN stmt_put.action WHERE
- toLower(action) = 'datapipeline:putpipelinedefinition'
- OR toLower(action) = 'datapipeline:*'
- OR action = '*'
- )
+ MATCH (principal)-[:POLICY]->(put_policy:AWSPolicy)-[:STATEMENT]->(stmt_put:AWSPolicyStatement {{effect: 'Allow'}})
+ MATCH (stmt_put)-[:HAS_ACTION]->(act3:AWSPolicyStatementActionItem)
+ WHERE toLower(act3.value) IN ['datapipeline:*', 'datapipeline:putpipelinedefinition']
+ OR act3.value = '*'
+ WITH DISTINCT aws, principal, stmt_passrole, path_principal
// Find datapipeline:ActivatePipeline permission
- MATCH (principal)--(activate_policy:AWSPolicy)--(stmt_activate:AWSPolicyStatement)
- WHERE stmt_activate.effect = 'Allow'
- AND any(action IN stmt_activate.action WHERE
- toLower(action) = 'datapipeline:activatepipeline'
- OR toLower(action) = 'datapipeline:*'
- OR action = '*'
- )
+ MATCH (principal)-[:POLICY]->(activate_policy:AWSPolicy)-[:STATEMENT]->(stmt_activate:AWSPolicyStatement {{effect: 'Allow'}})
+ MATCH (stmt_activate)-[:HAS_ACTION]->(act4:AWSPolicyStatementActionItem)
+ WHERE toLower(act4.value) IN ['datapipeline:*', 'datapipeline:activatepipeline']
+ OR act4.value = '*'
+ WITH DISTINCT aws, principal, stmt_passrole, path_principal
// Find roles that trust Data Pipeline or EMR service (can be passed to DataPipeline)
MATCH path_target = (aws)--(target_role:AWSRole)-[:TRUSTS_AWS_PRINCIPAL]->(trusted_principal:AWSPrincipal)
WHERE trusted_principal.arn IN ['datapipeline.amazonaws.com', 'elasticmapreduce.amazonaws.com']
- AND any(resource IN stmt_passrole.resource WHERE
- resource = '*'
- OR target_role.arn CONTAINS resource
- OR resource CONTAINS target_role.name
- )
+ MATCH (stmt_passrole)-[:HAS_RESOURCE]->(res:AWSPolicyStatementResourceItem)
+ WHERE res.value = '*'
+ OR res.value CONTAINS target_role.name
+ OR target_role.arn CONTAINS res.value
+ WITH DISTINCT path_principal, path_target
WITH collect(path_principal) + collect(path_target) AS paths
UNWIND paths AS p
UNWIND nodes(p) AS n
@@ -1065,7 +1011,7 @@ AWS_DATAPIPELINE_PRIVESC_PASSROLE_CREATE_PIPELINE = AttackPathsQueryDefinition(
WITH paths, collect(DISTINCT n) AS unique_nodes
UNWIND unique_nodes AS n
- OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}})
+ OPTIONAL MATCH (n)-[pfr:HAS_FINDING]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}})
RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr
""",
@@ -1085,31 +1031,27 @@ AWS_EC2_PRIVESC_PASSROLE_IAM = AttackPathsQueryDefinition(
provider="aws",
cypher=f"""
// Find principals with iam:PassRole permission
- MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(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 = '*'
- )
+ MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)-[:POLICY]->(passrole_policy:AWSPolicy)-[:STATEMENT]->(stmt_passrole:AWSPolicyStatement {{effect: 'Allow'}})
+ MATCH (stmt_passrole)-[:HAS_ACTION]->(act:AWSPolicyStatementActionItem)
+ WHERE toLower(act.value) IN ['iam:*', 'iam:passrole']
+ OR act.value = '*'
+ WITH DISTINCT aws, principal, stmt_passrole, path_principal
// Find ec2:RunInstances permission
- MATCH (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 = '*'
- )
+ MATCH (principal)-[:POLICY]->(ec2_policy:AWSPolicy)-[:STATEMENT]->(stmt_ec2:AWSPolicyStatement {{effect: 'Allow'}})
+ MATCH (stmt_ec2)-[:HAS_ACTION]->(act2:AWSPolicyStatementActionItem)
+ WHERE toLower(act2.value) IN ['ec2:*', 'ec2:runinstances']
+ OR act2.value = '*'
+ WITH DISTINCT aws, principal, stmt_passrole, path_principal
// Find roles that trust EC2 service (can be passed to EC2)
MATCH path_target = (aws)--(target_role:AWSRole)-[:TRUSTS_AWS_PRINCIPAL]->(:AWSPrincipal {{arn: 'ec2.amazonaws.com'}})
- WHERE any(resource IN stmt_passrole.resource WHERE
- resource = '*'
- OR target_role.arn CONTAINS resource
- OR resource CONTAINS target_role.name
- )
+ MATCH (stmt_passrole)-[:HAS_RESOURCE]->(res:AWSPolicyStatementResourceItem)
+ WHERE res.value = '*'
+ OR res.value CONTAINS target_role.name
+ OR target_role.arn CONTAINS res.value
+ WITH DISTINCT path_principal, path_target
WITH collect(path_principal) + collect(path_target) AS paths
UNWIND paths AS p
UNWIND nodes(p) AS n
@@ -1117,7 +1059,7 @@ AWS_EC2_PRIVESC_PASSROLE_IAM = AttackPathsQueryDefinition(
WITH paths, collect(DISTINCT n) AS unique_nodes
UNWIND unique_nodes AS n
- OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}})
+ OPTIONAL MATCH (n)-[pfr:HAS_FINDING]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}})
RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr
""",
@@ -1137,43 +1079,38 @@ AWS_EC2_PRIVESC_MODIFY_INSTANCE_ATTRIBUTE = AttackPathsQueryDefinition(
provider="aws",
cypher=f"""
// Find principals with ec2:ModifyInstanceAttribute permission
- MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(modify_policy:AWSPolicy)--(stmt_modify:AWSPolicyStatement)
- WHERE stmt_modify.effect = 'Allow'
- AND any(action IN stmt_modify.action WHERE
- toLower(action) = 'ec2:modifyinstanceattribute'
- OR toLower(action) = 'ec2:*'
- OR action = '*'
- )
+ MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)-[:POLICY]->(modify_policy:AWSPolicy)-[:STATEMENT]->(stmt_modify:AWSPolicyStatement {{effect: 'Allow'}})
+ MATCH (stmt_modify)-[:HAS_ACTION]->(act:AWSPolicyStatementActionItem)
+ WHERE toLower(act.value) IN ['ec2:*', 'ec2:modifyinstanceattribute']
+ OR act.value = '*'
+ WITH DISTINCT aws, principal, path_principal
// Find ec2:StopInstances permission (can be same or different policy)
- MATCH (principal)--(stop_policy:AWSPolicy)--(stmt_stop:AWSPolicyStatement)
- WHERE stmt_stop.effect = 'Allow'
- AND any(action IN stmt_stop.action WHERE
- toLower(action) = 'ec2:stopinstances'
- OR toLower(action) = 'ec2:*'
- OR action = '*'
- )
+ MATCH (principal)-[:POLICY]->(stop_policy:AWSPolicy)-[:STATEMENT]->(stmt_stop:AWSPolicyStatement {{effect: 'Allow'}})
+ MATCH (stmt_stop)-[:HAS_ACTION]->(act2:AWSPolicyStatementActionItem)
+ WHERE toLower(act2.value) IN ['ec2:*', 'ec2:stopinstances']
+ OR act2.value = '*'
+ WITH DISTINCT aws, principal, path_principal
// Find ec2:StartInstances permission (can be same or different policy)
- MATCH (principal)--(start_policy:AWSPolicy)--(stmt_start:AWSPolicyStatement)
- WHERE stmt_start.effect = 'Allow'
- AND any(action IN stmt_start.action WHERE
- toLower(action) = 'ec2:startinstances'
- OR toLower(action) = 'ec2:*'
- OR action = '*'
- )
+ MATCH (principal)-[:POLICY]->(start_policy:AWSPolicy)-[:STATEMENT]->(stmt_start:AWSPolicyStatement {{effect: 'Allow'}})
+ MATCH (stmt_start)-[:HAS_ACTION]->(act3:AWSPolicyStatementActionItem)
+ WHERE toLower(act3.value) IN ['ec2:*', 'ec2:startinstances']
+ OR act3.value = '*'
+ WITH aws, collect(DISTINCT path_principal) AS principal_paths
// Find EC2 instances with instance profiles (potential targets)
MATCH path_target = (aws)--(ec2:EC2Instance)-[:STS_ASSUMEROLE_ALLOW]->(target_role:AWSRole)
- WITH collect(path_principal) + collect(path_target) AS paths
+ WITH principal_paths, collect(DISTINCT path_target) AS target_paths
+ WITH principal_paths + target_paths AS paths
UNWIND paths AS p
UNWIND nodes(p) AS n
WITH paths, collect(DISTINCT n) AS unique_nodes
UNWIND unique_nodes AS n
- OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}})
+ OPTIONAL MATCH (n)-[pfr:HAS_FINDING]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}})
RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr
""",
@@ -1193,31 +1130,27 @@ AWS_EC2_PRIVESC_PASSROLE_SPOT_INSTANCES = AttackPathsQueryDefinition(
provider="aws",
cypher=f"""
// Find principals with iam:PassRole permission
- MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(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 = '*'
- )
+ MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)-[:POLICY]->(passrole_policy:AWSPolicy)-[:STATEMENT]->(stmt_passrole:AWSPolicyStatement {{effect: 'Allow'}})
+ MATCH (stmt_passrole)-[:HAS_ACTION]->(act:AWSPolicyStatementActionItem)
+ WHERE toLower(act.value) IN ['iam:*', 'iam:passrole']
+ OR act.value = '*'
+ WITH DISTINCT aws, principal, stmt_passrole, path_principal
// Find ec2:RequestSpotInstances permission
- MATCH (principal)--(spot_policy:AWSPolicy)--(stmt_spot:AWSPolicyStatement)
- WHERE stmt_spot.effect = 'Allow'
- AND any(action IN stmt_spot.action WHERE
- toLower(action) = 'ec2:requestspotinstances'
- OR toLower(action) = 'ec2:*'
- OR action = '*'
- )
+ MATCH (principal)-[:POLICY]->(spot_policy:AWSPolicy)-[:STATEMENT]->(stmt_spot:AWSPolicyStatement {{effect: 'Allow'}})
+ MATCH (stmt_spot)-[:HAS_ACTION]->(act2:AWSPolicyStatementActionItem)
+ WHERE toLower(act2.value) IN ['ec2:*', 'ec2:requestspotinstances']
+ OR act2.value = '*'
+ WITH DISTINCT aws, principal, stmt_passrole, path_principal
// Find roles that trust EC2 service (can be passed to EC2 spot instances)
MATCH path_target = (aws)--(target_role:AWSRole)-[:TRUSTS_AWS_PRINCIPAL]->(:AWSPrincipal {{arn: 'ec2.amazonaws.com'}})
- WHERE any(resource IN stmt_passrole.resource WHERE
- resource = '*'
- OR target_role.arn CONTAINS resource
- OR resource CONTAINS target_role.name
- )
+ MATCH (stmt_passrole)-[:HAS_RESOURCE]->(res:AWSPolicyStatementResourceItem)
+ WHERE res.value = '*'
+ OR res.value CONTAINS target_role.name
+ OR target_role.arn CONTAINS res.value
+ WITH DISTINCT path_principal, path_target
WITH collect(path_principal) + collect(path_target) AS paths
UNWIND paths AS p
UNWIND nodes(p) AS n
@@ -1225,7 +1158,7 @@ AWS_EC2_PRIVESC_PASSROLE_SPOT_INSTANCES = AttackPathsQueryDefinition(
WITH paths, collect(DISTINCT n) AS unique_nodes
UNWIND unique_nodes AS n
- OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}})
+ OPTIONAL MATCH (n)-[pfr:HAS_FINDING]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}})
RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr
""",
@@ -1245,34 +1178,31 @@ AWS_EC2_PRIVESC_LAUNCH_TEMPLATE = AttackPathsQueryDefinition(
provider="aws",
cypher=f"""
// Find principals with ec2:CreateLaunchTemplateVersion permission
- MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(create_policy:AWSPolicy)--(stmt_create:AWSPolicyStatement)
- WHERE stmt_create.effect = 'Allow'
- AND any(action IN stmt_create.action WHERE
- toLower(action) = 'ec2:createlaunchtemplateversion'
- OR toLower(action) = 'ec2:*'
- OR action = '*'
- )
+ MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)-[:POLICY]->(create_policy:AWSPolicy)-[:STATEMENT]->(stmt_create:AWSPolicyStatement {{effect: 'Allow'}})
+ MATCH (stmt_create)-[:HAS_ACTION]->(act:AWSPolicyStatementActionItem)
+ WHERE toLower(act.value) IN ['ec2:*', 'ec2:createlaunchtemplateversion']
+ OR act.value = '*'
+ WITH DISTINCT aws, principal, path_principal
// Find ec2:ModifyLaunchTemplate permission
- MATCH (principal)--(modify_policy:AWSPolicy)--(stmt_modify:AWSPolicyStatement)
- WHERE stmt_modify.effect = 'Allow'
- AND any(action IN stmt_modify.action WHERE
- toLower(action) = 'ec2:modifylaunchtemplate'
- OR toLower(action) = 'ec2:*'
- OR action = '*'
- )
+ MATCH (principal)-[:POLICY]->(modify_policy:AWSPolicy)-[:STATEMENT]->(stmt_modify:AWSPolicyStatement {{effect: 'Allow'}})
+ MATCH (stmt_modify)-[:HAS_ACTION]->(act2:AWSPolicyStatementActionItem)
+ WHERE toLower(act2.value) IN ['ec2:*', 'ec2:modifylaunchtemplate']
+ OR act2.value = '*'
+ WITH aws, collect(DISTINCT path_principal) AS principal_paths
// Find launch templates in the account (potential targets)
MATCH path_target = (aws)--(template:LaunchTemplate)
- WITH collect(path_principal) + collect(path_target) AS paths
+ WITH principal_paths, collect(DISTINCT path_target) AS target_paths
+ WITH principal_paths + target_paths AS paths
UNWIND paths AS p
UNWIND nodes(p) AS n
WITH paths, collect(DISTINCT n) AS unique_nodes
UNWIND unique_nodes AS n
- OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}})
+ OPTIONAL MATCH (n)-[pfr:HAS_FINDING]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}})
RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr
""",
@@ -1292,25 +1222,24 @@ AWS_EC2INSTANCECONNECT_PRIVESC_SEND_SSH_PUBLIC_KEY = AttackPathsQueryDefinition(
provider="aws",
cypher=f"""
// Find principals with ec2-instance-connect:SendSSHPublicKey permission
- MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(connect_policy:AWSPolicy)--(stmt_connect:AWSPolicyStatement)
- WHERE stmt_connect.effect = 'Allow'
- AND any(action IN stmt_connect.action WHERE
- toLower(action) = 'ec2-instance-connect:sendsshpublickey'
- OR toLower(action) = 'ec2-instance-connect:*'
- OR action = '*'
- )
+ MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)-[:POLICY]->(connect_policy:AWSPolicy)-[:STATEMENT]->(stmt_connect:AWSPolicyStatement {{effect: 'Allow'}})
+ MATCH (stmt_connect)-[:HAS_ACTION]->(act:AWSPolicyStatementActionItem)
+ WHERE toLower(act.value) IN ['ec2-instance-connect:*', 'ec2-instance-connect:sendsshpublickey']
+ OR act.value = '*'
+ WITH aws, collect(DISTINCT path_principal) AS principal_paths
// Find EC2 instances with attached roles (targets for credential theft via IMDS)
MATCH path_target = (aws)--(ec2:EC2Instance)-[:STS_ASSUMEROLE_ALLOW]->(target_role:AWSRole)
- WITH collect(path_principal) + collect(path_target) AS paths
+ WITH principal_paths, collect(DISTINCT path_target) AS target_paths
+ WITH principal_paths + target_paths AS paths
UNWIND paths AS p
UNWIND nodes(p) AS n
WITH paths, collect(DISTINCT n) AS unique_nodes
UNWIND unique_nodes AS n
- OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}})
+ OPTIONAL MATCH (n)-[pfr:HAS_FINDING]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}})
RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr
""",
@@ -1329,58 +1258,46 @@ AWS_ECS_PRIVESC_PASSROLE_CREATE_SERVICE = AttackPathsQueryDefinition(
link="https://pathfinding.cloud/paths/ecs-001",
),
cypher=f"""
- // Find principals with iam:PassRole permission
- MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(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 principals with iam:PassRole permission (this IS path_principal)
+ MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)-[:POLICY]->(passrole_policy:AWSPolicy)-[:STATEMENT]->(stmt_passrole:AWSPolicyStatement {{effect: 'Allow'}})
+ MATCH (stmt_passrole)-[:HAS_ACTION]->(act:AWSPolicyStatementActionItem)
+ WHERE toLower(act.value) IN ['iam:*', 'iam:passrole']
+ OR act.value = '*'
+ WITH DISTINCT aws, principal, stmt_passrole, path_principal
- // Find ecs:CreateCluster permission
- MATCH (principal)--(cluster_policy:AWSPolicy)--(stmt_cluster:AWSPolicyStatement)
- WHERE stmt_cluster.effect = 'Allow'
- AND any(action IN stmt_cluster.action WHERE
- toLower(action) = 'ecs:createcluster'
- OR toLower(action) = 'ecs:*'
- OR action = '*'
- )
+ // Gate: ecs:CreateCluster (existence only)
+ MATCH (principal)-[:POLICY]->(:AWSPolicy)-[:STATEMENT]->(:AWSPolicyStatement {{effect: 'Allow'}})-[:HAS_ACTION]->(a2:AWSPolicyStatementActionItem)
+ WHERE toLower(a2.value) IN ['ecs:*', 'ecs:createcluster']
+ OR a2.value = '*'
+ WITH DISTINCT aws, principal, stmt_passrole, path_principal
- // Find ecs:RegisterTaskDefinition permission
- MATCH (principal)--(taskdef_policy:AWSPolicy)--(stmt_taskdef:AWSPolicyStatement)
- WHERE stmt_taskdef.effect = 'Allow'
- AND any(action IN stmt_taskdef.action WHERE
- toLower(action) = 'ecs:registertaskdefinition'
- OR toLower(action) = 'ecs:*'
- OR action = '*'
- )
+ // Gate: ecs:RegisterTaskDefinition (existence only)
+ MATCH (principal)-[:POLICY]->(:AWSPolicy)-[:STATEMENT]->(:AWSPolicyStatement {{effect: 'Allow'}})-[:HAS_ACTION]->(a3:AWSPolicyStatementActionItem)
+ WHERE toLower(a3.value) IN ['ecs:*', 'ecs:registertaskdefinition']
+ OR a3.value = '*'
+ WITH DISTINCT aws, principal, stmt_passrole, path_principal
- // Find ecs:CreateService permission
- MATCH (principal)--(service_policy:AWSPolicy)--(stmt_service:AWSPolicyStatement)
- WHERE stmt_service.effect = 'Allow'
- AND any(action IN stmt_service.action WHERE
- toLower(action) = 'ecs:createservice'
- OR toLower(action) = 'ecs:*'
- OR action = '*'
- )
+ // Gate: ecs:CreateService (existence only)
+ MATCH (principal)-[:POLICY]->(:AWSPolicy)-[:STATEMENT]->(:AWSPolicyStatement {{effect: 'Allow'}})-[:HAS_ACTION]->(a4:AWSPolicyStatementActionItem)
+ WHERE toLower(a4.value) IN ['ecs:*', 'ecs:createservice']
+ OR a4.value = '*'
+ WITH DISTINCT aws, principal, stmt_passrole, path_principal
- // Find roles that trust ECS tasks service (can be passed to ECS tasks)
+ // Target: a role trusting ECS tasks that the passrole resource can target
MATCH path_target = (aws)--(target_role:AWSRole)-[:TRUSTS_AWS_PRINCIPAL]->(:AWSPrincipal {{arn: 'ecs-tasks.amazonaws.com'}})
- WHERE any(resource IN stmt_passrole.resource WHERE
- resource = '*'
- OR target_role.arn CONTAINS resource
- OR resource CONTAINS target_role.name
- )
+ MATCH (stmt_passrole)-[:HAS_RESOURCE]->(res:AWSPolicyStatementResourceItem)
+ WHERE res.value = '*'
+ OR res.value CONTAINS target_role.name
+ OR target_role.arn CONTAINS res.value
+ WITH DISTINCT path_principal, path_target
WITH collect(path_principal) + collect(path_target) AS paths
UNWIND paths AS p
UNWIND nodes(p) AS n
WITH paths, collect(DISTINCT n) AS unique_nodes
UNWIND unique_nodes AS n
-
- OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}})
+ OPTIONAL MATCH (n)-[pfr:HAS_FINDING]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}})
RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr
""",
@@ -1399,58 +1316,48 @@ AWS_ECS_PRIVESC_PASSROLE_RUN_TASK = AttackPathsQueryDefinition(
),
provider="aws",
cypher=f"""
- // Find principals with iam:PassRole permission
- MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(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 principals with iam:PassRole permission (this IS path_principal)
+ MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)-[:POLICY]->(passrole_policy:AWSPolicy)-[:STATEMENT]->(stmt_passrole:AWSPolicyStatement {{effect: 'Allow'}})
+ MATCH (stmt_passrole)-[:HAS_ACTION]->(act:AWSPolicyStatementActionItem)
+ WHERE toLower(act.value) IN ['iam:*', 'iam:passrole']
+ OR act.value = '*'
- // Find ecs:CreateCluster permission
- MATCH (principal)--(cluster_policy:AWSPolicy)--(stmt_cluster:AWSPolicyStatement)
- WHERE stmt_cluster.effect = 'Allow'
- AND any(action IN stmt_cluster.action WHERE
- toLower(action) = 'ecs:createcluster'
- OR toLower(action) = 'ecs:*'
- OR action = '*'
- )
+ // Collapse: one row per (passrole chain), independent of how many action items matched
+ WITH DISTINCT aws, principal, stmt_passrole, path_principal
- // Find ecs:RegisterTaskDefinition permission
- MATCH (principal)--(taskdef_policy:AWSPolicy)--(stmt_taskdef:AWSPolicyStatement)
- WHERE stmt_taskdef.effect = 'Allow'
- AND any(action IN stmt_taskdef.action WHERE
- toLower(action) = 'ecs:registertaskdefinition'
- OR toLower(action) = 'ecs:*'
- OR action = '*'
- )
+ // Gate: ecs:CreateCluster exists on the principal -> collapse back to one row
+ MATCH (principal)-[:POLICY]->(:AWSPolicy)-[:STATEMENT]->(s2:AWSPolicyStatement {{effect: 'Allow'}})-[:HAS_ACTION]->(a2:AWSPolicyStatementActionItem)
+ WHERE toLower(a2.value) IN ['ecs:*', 'ecs:createcluster']
+ OR a2.value = '*'
+ WITH DISTINCT aws, principal, stmt_passrole, path_principal
- // Find ecs:RunTask permission
- MATCH (principal)--(runtask_policy:AWSPolicy)--(stmt_runtask:AWSPolicyStatement)
- WHERE stmt_runtask.effect = 'Allow'
- AND any(action IN stmt_runtask.action WHERE
- toLower(action) = 'ecs:runtask'
- OR toLower(action) = 'ecs:*'
- OR action = '*'
- )
+ // Gate: ecs:RegisterTaskDefinition exists on the principal
+ MATCH (principal)-[:POLICY]->(:AWSPolicy)-[:STATEMENT]->(s3:AWSPolicyStatement {{effect: 'Allow'}})-[:HAS_ACTION]->(a3:AWSPolicyStatementActionItem)
+ WHERE toLower(a3.value) IN ['ecs:*', 'ecs:registertaskdefinition']
+ OR a3.value = '*'
+ WITH DISTINCT aws, principal, stmt_passrole, path_principal
- // Find roles that trust ECS tasks service (can be passed to ECS tasks)
+ // Gate: ecs:RunTask exists on the principal
+ MATCH (principal)-[:POLICY]->(:AWSPolicy)-[:STATEMENT]->(s4:AWSPolicyStatement {{effect: 'Allow'}})-[:HAS_ACTION]->(a4:AWSPolicyStatementActionItem)
+ WHERE toLower(a4.value) IN ['ecs:*', 'ecs:runtask']
+ OR a4.value = '*'
+ WITH DISTINCT aws, principal, stmt_passrole, path_principal
+
+ // Target: a role that trusts ECS tasks and that the passrole resource can target
MATCH path_target = (aws)--(target_role:AWSRole)-[:TRUSTS_AWS_PRINCIPAL]->(:AWSPrincipal {{arn: 'ecs-tasks.amazonaws.com'}})
- WHERE any(resource IN stmt_passrole.resource WHERE
- resource = '*'
- OR target_role.arn CONTAINS resource
- OR resource CONTAINS target_role.name
- )
+ MATCH (stmt_passrole)-[:HAS_RESOURCE]->(res:AWSPolicyStatementResourceItem)
+ WHERE res.value = '*'
+ OR res.value CONTAINS target_role.name
+ OR target_role.arn CONTAINS res.value
+ WITH DISTINCT path_principal, path_target
WITH collect(path_principal) + collect(path_target) AS paths
UNWIND paths AS p
UNWIND nodes(p) AS n
WITH paths, collect(DISTINCT n) AS unique_nodes
UNWIND unique_nodes AS n
-
- OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}})
+ OPTIONAL MATCH (n)-[pfr:HAS_FINDING]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}})
RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr
""",
@@ -1469,49 +1376,40 @@ AWS_ECS_PRIVESC_PASSROLE_CREATE_SERVICE_EXISTING_CLUSTER = AttackPathsQueryDefin
),
provider="aws",
cypher=f"""
- // Find principals with iam:PassRole permission
- MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(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 principals with iam:PassRole permission (this IS path_principal)
+ MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)-[:POLICY]->(passrole_policy:AWSPolicy)-[:STATEMENT]->(stmt_passrole:AWSPolicyStatement {{effect: 'Allow'}})
+ MATCH (stmt_passrole)-[:HAS_ACTION]->(act:AWSPolicyStatementActionItem)
+ WHERE toLower(act.value) IN ['iam:*', 'iam:passrole']
+ OR act.value = '*'
+ WITH DISTINCT aws, principal, stmt_passrole, path_principal
- // Find ecs:RegisterTaskDefinition permission
- MATCH (principal)--(taskdef_policy:AWSPolicy)--(stmt_taskdef:AWSPolicyStatement)
- WHERE stmt_taskdef.effect = 'Allow'
- AND any(action IN stmt_taskdef.action WHERE
- toLower(action) = 'ecs:registertaskdefinition'
- OR toLower(action) = 'ecs:*'
- OR action = '*'
- )
+ // Gate: ecs:RegisterTaskDefinition (existence only)
+ MATCH (principal)-[:POLICY]->(:AWSPolicy)-[:STATEMENT]->(:AWSPolicyStatement {{effect: 'Allow'}})-[:HAS_ACTION]->(a2:AWSPolicyStatementActionItem)
+ WHERE toLower(a2.value) IN ['ecs:*', 'ecs:registertaskdefinition']
+ OR a2.value = '*'
+ WITH DISTINCT aws, principal, stmt_passrole, path_principal
- // Find ecs:CreateService permission
- MATCH (principal)--(service_policy:AWSPolicy)--(stmt_service:AWSPolicyStatement)
- WHERE stmt_service.effect = 'Allow'
- AND any(action IN stmt_service.action WHERE
- toLower(action) = 'ecs:createservice'
- OR toLower(action) = 'ecs:*'
- OR action = '*'
- )
+ // Gate: ecs:CreateService (existence only)
+ MATCH (principal)-[:POLICY]->(:AWSPolicy)-[:STATEMENT]->(:AWSPolicyStatement {{effect: 'Allow'}})-[:HAS_ACTION]->(a3:AWSPolicyStatementActionItem)
+ WHERE toLower(a3.value) IN ['ecs:*', 'ecs:createservice']
+ OR a3.value = '*'
+ WITH DISTINCT aws, principal, stmt_passrole, path_principal
- // Find roles that trust ECS tasks service (can be passed to ECS tasks)
+ // Target: a role trusting ECS tasks that the passrole resource can target
MATCH path_target = (aws)--(target_role:AWSRole)-[:TRUSTS_AWS_PRINCIPAL]->(:AWSPrincipal {{arn: 'ecs-tasks.amazonaws.com'}})
- WHERE any(resource IN stmt_passrole.resource WHERE
- resource = '*'
- OR target_role.arn CONTAINS resource
- OR resource CONTAINS target_role.name
- )
+ MATCH (stmt_passrole)-[:HAS_RESOURCE]->(res:AWSPolicyStatementResourceItem)
+ WHERE res.value = '*'
+ OR res.value CONTAINS target_role.name
+ OR target_role.arn CONTAINS res.value
+ WITH DISTINCT path_principal, path_target
WITH collect(path_principal) + collect(path_target) AS paths
UNWIND paths AS p
UNWIND nodes(p) AS n
WITH paths, collect(DISTINCT n) AS unique_nodes
UNWIND unique_nodes AS n
-
- OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}})
+ OPTIONAL MATCH (n)-[pfr:HAS_FINDING]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}})
RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr
""",
@@ -1530,49 +1428,40 @@ AWS_ECS_PRIVESC_PASSROLE_RUN_TASK_EXISTING_CLUSTER = AttackPathsQueryDefinition(
),
provider="aws",
cypher=f"""
- // Find principals with iam:PassRole permission
- MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(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 principals with iam:PassRole permission (this IS path_principal)
+ MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)-[:POLICY]->(passrole_policy:AWSPolicy)-[:STATEMENT]->(stmt_passrole:AWSPolicyStatement {{effect: 'Allow'}})
+ MATCH (stmt_passrole)-[:HAS_ACTION]->(act:AWSPolicyStatementActionItem)
+ WHERE toLower(act.value) IN ['iam:*', 'iam:passrole']
+ OR act.value = '*'
+ WITH DISTINCT aws, principal, stmt_passrole, path_principal
- // Find ecs:RegisterTaskDefinition permission
- MATCH (principal)--(taskdef_policy:AWSPolicy)--(stmt_taskdef:AWSPolicyStatement)
- WHERE stmt_taskdef.effect = 'Allow'
- AND any(action IN stmt_taskdef.action WHERE
- toLower(action) = 'ecs:registertaskdefinition'
- OR toLower(action) = 'ecs:*'
- OR action = '*'
- )
+ // Gate: ecs:RegisterTaskDefinition (existence only)
+ MATCH (principal)-[:POLICY]->(:AWSPolicy)-[:STATEMENT]->(:AWSPolicyStatement {{effect: 'Allow'}})-[:HAS_ACTION]->(a2:AWSPolicyStatementActionItem)
+ WHERE toLower(a2.value) IN ['ecs:*', 'ecs:registertaskdefinition']
+ OR a2.value = '*'
+ WITH DISTINCT aws, principal, stmt_passrole, path_principal
- // Find ecs:RunTask permission
- MATCH (principal)--(runtask_policy:AWSPolicy)--(stmt_runtask:AWSPolicyStatement)
- WHERE stmt_runtask.effect = 'Allow'
- AND any(action IN stmt_runtask.action WHERE
- toLower(action) = 'ecs:runtask'
- OR toLower(action) = 'ecs:*'
- OR action = '*'
- )
+ // Gate: ecs:RunTask (existence only)
+ MATCH (principal)-[:POLICY]->(:AWSPolicy)-[:STATEMENT]->(:AWSPolicyStatement {{effect: 'Allow'}})-[:HAS_ACTION]->(a3:AWSPolicyStatementActionItem)
+ WHERE toLower(a3.value) IN ['ecs:*', 'ecs:runtask']
+ OR a3.value = '*'
+ WITH DISTINCT aws, principal, stmt_passrole, path_principal
- // Find roles that trust ECS tasks service (can be passed to ECS tasks)
+ // Target: a role trusting ECS tasks that the passrole resource can target
MATCH path_target = (aws)--(target_role:AWSRole)-[:TRUSTS_AWS_PRINCIPAL]->(:AWSPrincipal {{arn: 'ecs-tasks.amazonaws.com'}})
- WHERE any(resource IN stmt_passrole.resource WHERE
- resource = '*'
- OR target_role.arn CONTAINS resource
- OR resource CONTAINS target_role.name
- )
+ MATCH (stmt_passrole)-[:HAS_RESOURCE]->(res:AWSPolicyStatementResourceItem)
+ WHERE res.value = '*'
+ OR res.value CONTAINS target_role.name
+ OR target_role.arn CONTAINS res.value
+ WITH DISTINCT path_principal, path_target
WITH collect(path_principal) + collect(path_target) AS paths
UNWIND paths AS p
UNWIND nodes(p) AS n
WITH paths, collect(DISTINCT n) AS unique_nodes
UNWIND unique_nodes AS n
-
- OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}})
+ OPTIONAL MATCH (n)-[pfr:HAS_FINDING]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}})
RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr
""",
@@ -1591,49 +1480,40 @@ AWS_ECS_PRIVESC_PASSROLE_START_TASK_EXISTING_CLUSTER = AttackPathsQueryDefinitio
),
provider="aws",
cypher=f"""
- // Find principals with iam:PassRole permission
- MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(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 principals with iam:PassRole permission (this IS path_principal)
+ MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)-[:POLICY]->(passrole_policy:AWSPolicy)-[:STATEMENT]->(stmt_passrole:AWSPolicyStatement {{effect: 'Allow'}})
+ MATCH (stmt_passrole)-[:HAS_ACTION]->(act:AWSPolicyStatementActionItem)
+ WHERE toLower(act.value) IN ['iam:*', 'iam:passrole']
+ OR act.value = '*'
+ WITH DISTINCT aws, principal, stmt_passrole, path_principal
- // Find ecs:RegisterTaskDefinition permission
- MATCH (principal)--(taskdef_policy:AWSPolicy)--(stmt_taskdef:AWSPolicyStatement)
- WHERE stmt_taskdef.effect = 'Allow'
- AND any(action IN stmt_taskdef.action WHERE
- toLower(action) = 'ecs:registertaskdefinition'
- OR toLower(action) = 'ecs:*'
- OR action = '*'
- )
+ // Gate: ecs:RegisterTaskDefinition (existence only)
+ MATCH (principal)-[:POLICY]->(:AWSPolicy)-[:STATEMENT]->(:AWSPolicyStatement {{effect: 'Allow'}})-[:HAS_ACTION]->(a2:AWSPolicyStatementActionItem)
+ WHERE toLower(a2.value) IN ['ecs:*', 'ecs:registertaskdefinition']
+ OR a2.value = '*'
+ WITH DISTINCT aws, principal, stmt_passrole, path_principal
- // Find ecs:StartTask permission
- MATCH (principal)--(starttask_policy:AWSPolicy)--(stmt_starttask:AWSPolicyStatement)
- WHERE stmt_starttask.effect = 'Allow'
- AND any(action IN stmt_starttask.action WHERE
- toLower(action) = 'ecs:starttask'
- OR toLower(action) = 'ecs:*'
- OR action = '*'
- )
+ // Gate: ecs:StartTask (existence only)
+ MATCH (principal)-[:POLICY]->(:AWSPolicy)-[:STATEMENT]->(:AWSPolicyStatement {{effect: 'Allow'}})-[:HAS_ACTION]->(a3:AWSPolicyStatementActionItem)
+ WHERE toLower(a3.value) IN ['ecs:*', 'ecs:starttask']
+ OR a3.value = '*'
+ WITH DISTINCT aws, principal, stmt_passrole, path_principal
- // Find roles that trust ECS tasks service (can be passed to ECS tasks)
+ // Target: a role trusting ECS tasks that the passrole resource can target
MATCH path_target = (aws)--(target_role:AWSRole)-[:TRUSTS_AWS_PRINCIPAL]->(:AWSPrincipal {{arn: 'ecs-tasks.amazonaws.com'}})
- WHERE any(resource IN stmt_passrole.resource WHERE
- resource = '*'
- OR target_role.arn CONTAINS resource
- OR resource CONTAINS target_role.name
- )
+ MATCH (stmt_passrole)-[:HAS_RESOURCE]->(res:AWSPolicyStatementResourceItem)
+ WHERE res.value = '*'
+ OR res.value CONTAINS target_role.name
+ OR target_role.arn CONTAINS res.value
+ WITH DISTINCT path_principal, path_target
WITH collect(path_principal) + collect(path_target) AS paths
UNWIND paths AS p
UNWIND nodes(p) AS n
WITH paths, collect(DISTINCT n) AS unique_nodes
UNWIND unique_nodes AS n
-
- OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}})
+ OPTIONAL MATCH (n)-[pfr:HAS_FINDING]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}})
RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr
""",
@@ -1652,35 +1532,30 @@ AWS_ECS_PRIVESC_EXECUTE_COMMAND = AttackPathsQueryDefinition(
),
provider="aws",
cypher=f"""
- // Find principals with ecs:ExecuteCommand permission
- MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(exec_policy:AWSPolicy)--(stmt_exec:AWSPolicyStatement)
- WHERE stmt_exec.effect = 'Allow'
- AND any(action IN stmt_exec.action WHERE
- toLower(action) = 'ecs:executecommand'
- OR toLower(action) = 'ecs:*'
- OR action = '*'
- )
+ // Find principals with ecs:ExecuteCommand permission (this IS path_principal)
+ MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)-[:POLICY]->(exec_policy:AWSPolicy)-[:STATEMENT]->(stmt_exec:AWSPolicyStatement {{effect: 'Allow'}})
+ MATCH (stmt_exec)-[:HAS_ACTION]->(act:AWSPolicyStatementActionItem)
+ WHERE toLower(act.value) IN ['ecs:*', 'ecs:executecommand']
+ OR act.value = '*'
+ WITH DISTINCT aws, principal, path_principal
- // Find ecs:DescribeTasks permission (required by AWS CLI to get container runtime ID)
- MATCH (principal)--(describe_policy:AWSPolicy)--(stmt_describe:AWSPolicyStatement)
- WHERE stmt_describe.effect = 'Allow'
- AND any(action IN stmt_describe.action WHERE
- toLower(action) = 'ecs:describetasks'
- OR toLower(action) = 'ecs:*'
- OR action = '*'
- )
+ // Gate: ecs:DescribeTasks (existence only)
+ MATCH (principal)-[:POLICY]->(:AWSPolicy)-[:STATEMENT]->(:AWSPolicyStatement {{effect: 'Allow'}})-[:HAS_ACTION]->(a2:AWSPolicyStatementActionItem)
+ WHERE toLower(a2.value) IN ['ecs:*', 'ecs:describetasks']
+ OR a2.value = '*'
+ WITH aws, collect(DISTINCT path_principal) AS principal_paths
- // Find roles that trust ECS tasks service (already attached to running tasks)
+ // Target: roles already attached to running tasks (trust ECS tasks service)
MATCH path_target = (aws)--(target_role:AWSRole)-[:TRUSTS_AWS_PRINCIPAL]->(:AWSPrincipal {{arn: 'ecs-tasks.amazonaws.com'}})
- WITH collect(path_principal) + collect(path_target) AS paths
+ WITH principal_paths, collect(DISTINCT path_target) AS target_paths
+ WITH principal_paths + target_paths AS paths
UNWIND paths AS p
UNWIND nodes(p) AS n
WITH paths, collect(DISTINCT n) AS unique_nodes
UNWIND unique_nodes AS n
-
- OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}})
+ OPTIONAL MATCH (n)-[pfr:HAS_FINDING]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}})
RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr
""",
@@ -1700,31 +1575,27 @@ AWS_GLUE_PRIVESC_PASSROLE_DEV_ENDPOINT = AttackPathsQueryDefinition(
provider="aws",
cypher=f"""
// Find principals with iam:PassRole permission
- MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(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 = '*'
- )
+ MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)-[:POLICY]->(passrole_policy:AWSPolicy)-[:STATEMENT]->(stmt_passrole:AWSPolicyStatement {{effect: 'Allow'}})
+ MATCH (stmt_passrole)-[:HAS_ACTION]->(act:AWSPolicyStatementActionItem)
+ WHERE toLower(act.value) IN ['iam:*', 'iam:passrole']
+ OR act.value = '*'
+ WITH DISTINCT aws, principal, stmt_passrole, path_principal
// Find glue:CreateDevEndpoint permission
- MATCH (principal)--(glue_policy:AWSPolicy)--(stmt_glue:AWSPolicyStatement)
- WHERE stmt_glue.effect = 'Allow'
- AND any(action IN stmt_glue.action WHERE
- toLower(action) = 'glue:createdevendpoint'
- OR toLower(action) = 'glue:*'
- OR action = '*'
- )
+ MATCH (principal)-[:POLICY]->(glue_policy:AWSPolicy)-[:STATEMENT]->(stmt_glue:AWSPolicyStatement {{effect: 'Allow'}})
+ MATCH (stmt_glue)-[:HAS_ACTION]->(act2:AWSPolicyStatementActionItem)
+ WHERE toLower(act2.value) IN ['glue:*', 'glue:createdevendpoint']
+ OR act2.value = '*'
+ WITH DISTINCT aws, principal, stmt_passrole, path_principal
// Find roles that trust Glue service (can be passed to Glue)
MATCH path_target = (aws)--(target_role:AWSRole)-[:TRUSTS_AWS_PRINCIPAL]->(:AWSPrincipal {{arn: 'glue.amazonaws.com'}})
- WHERE any(resource IN stmt_passrole.resource WHERE
- resource = '*'
- OR target_role.arn CONTAINS resource
- OR resource CONTAINS target_role.name
- )
+ MATCH (stmt_passrole)-[:HAS_RESOURCE]->(res:AWSPolicyStatementResourceItem)
+ WHERE res.value = '*'
+ OR res.value CONTAINS target_role.name
+ OR target_role.arn CONTAINS res.value
+ WITH DISTINCT path_principal, path_target
WITH collect(path_principal) + collect(path_target) AS paths
UNWIND paths AS p
UNWIND nodes(p) AS n
@@ -1732,7 +1603,7 @@ AWS_GLUE_PRIVESC_PASSROLE_DEV_ENDPOINT = AttackPathsQueryDefinition(
WITH paths, collect(DISTINCT n) AS unique_nodes
UNWIND unique_nodes AS n
- OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}})
+ OPTIONAL MATCH (n)-[pfr:HAS_FINDING]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}})
RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr
""",
@@ -1752,25 +1623,24 @@ AWS_GLUE_PRIVESC_UPDATE_DEV_ENDPOINT = AttackPathsQueryDefinition(
provider="aws",
cypher=f"""
// Find principals with glue:UpdateDevEndpoint permission
- MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(policy:AWSPolicy)--(stmt:AWSPolicyStatement)
- WHERE stmt.effect = 'Allow'
- AND any(action IN stmt.action WHERE
- toLower(action) = 'glue:updatedevendpoint'
- OR toLower(action) = 'glue:*'
- OR action = '*'
- )
+ MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)-[:POLICY]->(policy:AWSPolicy)-[:STATEMENT]->(stmt:AWSPolicyStatement {{effect: 'Allow'}})
+ MATCH (stmt)-[:HAS_ACTION]->(act:AWSPolicyStatementActionItem)
+ WHERE toLower(act.value) IN ['glue:*', 'glue:updatedevendpoint']
+ OR act.value = '*'
+ WITH aws, collect(DISTINCT path_principal) AS principal_paths
// Find roles that trust Glue service (already attached to existing dev endpoints)
MATCH path_target = (aws)--(target_role:AWSRole)-[:TRUSTS_AWS_PRINCIPAL]->(:AWSPrincipal {{arn: 'glue.amazonaws.com'}})
- WITH collect(path_principal) + collect(path_target) AS paths
+ WITH principal_paths, collect(DISTINCT path_target) AS target_paths
+ WITH principal_paths + target_paths AS paths
UNWIND paths AS p
UNWIND nodes(p) AS n
WITH paths, collect(DISTINCT n) AS unique_nodes
UNWIND unique_nodes AS n
- OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}})
+ OPTIONAL MATCH (n)-[pfr:HAS_FINDING]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}})
RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr
""",
@@ -1790,40 +1660,34 @@ AWS_GLUE_PRIVESC_PASSROLE_CREATE_JOB = AttackPathsQueryDefinition(
provider="aws",
cypher=f"""
// Find principals with iam:PassRole permission
- MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(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 = '*'
- )
+ MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)-[:POLICY]->(passrole_policy:AWSPolicy)-[:STATEMENT]->(stmt_passrole:AWSPolicyStatement {{effect: 'Allow'}})
+ MATCH (stmt_passrole)-[:HAS_ACTION]->(act:AWSPolicyStatementActionItem)
+ WHERE toLower(act.value) IN ['iam:*', 'iam:passrole']
+ OR act.value = '*'
+ WITH DISTINCT aws, principal, stmt_passrole, path_principal
// Find glue:CreateJob permission
- MATCH (principal)--(createjob_policy:AWSPolicy)--(stmt_createjob:AWSPolicyStatement)
- WHERE stmt_createjob.effect = 'Allow'
- AND any(action IN stmt_createjob.action WHERE
- toLower(action) = 'glue:createjob'
- OR toLower(action) = 'glue:*'
- OR action = '*'
- )
+ MATCH (principal)-[:POLICY]->(createjob_policy:AWSPolicy)-[:STATEMENT]->(stmt_createjob:AWSPolicyStatement {{effect: 'Allow'}})
+ MATCH (stmt_createjob)-[:HAS_ACTION]->(act2:AWSPolicyStatementActionItem)
+ WHERE toLower(act2.value) IN ['glue:*', 'glue:createjob']
+ OR act2.value = '*'
+ WITH DISTINCT aws, principal, stmt_passrole, path_principal
// Find glue:StartJobRun permission
- MATCH (principal)--(startjob_policy:AWSPolicy)--(stmt_startjob:AWSPolicyStatement)
- WHERE stmt_startjob.effect = 'Allow'
- AND any(action IN stmt_startjob.action WHERE
- toLower(action) = 'glue:startjobrun'
- OR toLower(action) = 'glue:*'
- OR action = '*'
- )
+ MATCH (principal)-[:POLICY]->(startjob_policy:AWSPolicy)-[:STATEMENT]->(stmt_startjob:AWSPolicyStatement {{effect: 'Allow'}})
+ MATCH (stmt_startjob)-[:HAS_ACTION]->(act3:AWSPolicyStatementActionItem)
+ WHERE toLower(act3.value) IN ['glue:*', 'glue:startjobrun']
+ OR act3.value = '*'
+ WITH DISTINCT aws, principal, stmt_passrole, path_principal
// Find roles that trust Glue service (can be passed to Glue jobs)
MATCH path_target = (aws)--(target_role:AWSRole)-[:TRUSTS_AWS_PRINCIPAL]->(:AWSPrincipal {{arn: 'glue.amazonaws.com'}})
- WHERE any(resource IN stmt_passrole.resource WHERE
- resource = '*'
- OR target_role.arn CONTAINS resource
- OR resource CONTAINS target_role.name
- )
+ MATCH (stmt_passrole)-[:HAS_RESOURCE]->(res:AWSPolicyStatementResourceItem)
+ WHERE res.value = '*'
+ OR res.value CONTAINS target_role.name
+ OR target_role.arn CONTAINS res.value
+ WITH DISTINCT path_principal, path_target
WITH collect(path_principal) + collect(path_target) AS paths
UNWIND paths AS p
UNWIND nodes(p) AS n
@@ -1831,7 +1695,7 @@ AWS_GLUE_PRIVESC_PASSROLE_CREATE_JOB = AttackPathsQueryDefinition(
WITH paths, collect(DISTINCT n) AS unique_nodes
UNWIND unique_nodes AS n
- OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}})
+ OPTIONAL MATCH (n)-[pfr:HAS_FINDING]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}})
RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr
""",
@@ -1851,40 +1715,34 @@ AWS_GLUE_PRIVESC_PASSROLE_CREATE_JOB_TRIGGER = AttackPathsQueryDefinition(
provider="aws",
cypher=f"""
// Find principals with iam:PassRole permission
- MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(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 = '*'
- )
+ MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)-[:POLICY]->(passrole_policy:AWSPolicy)-[:STATEMENT]->(stmt_passrole:AWSPolicyStatement {{effect: 'Allow'}})
+ MATCH (stmt_passrole)-[:HAS_ACTION]->(act:AWSPolicyStatementActionItem)
+ WHERE toLower(act.value) IN ['iam:*', 'iam:passrole']
+ OR act.value = '*'
+ WITH DISTINCT aws, principal, stmt_passrole, path_principal
// Find glue:CreateJob permission
- MATCH (principal)--(createjob_policy:AWSPolicy)--(stmt_createjob:AWSPolicyStatement)
- WHERE stmt_createjob.effect = 'Allow'
- AND any(action IN stmt_createjob.action WHERE
- toLower(action) = 'glue:createjob'
- OR toLower(action) = 'glue:*'
- OR action = '*'
- )
+ MATCH (principal)-[:POLICY]->(createjob_policy:AWSPolicy)-[:STATEMENT]->(stmt_createjob:AWSPolicyStatement {{effect: 'Allow'}})
+ MATCH (stmt_createjob)-[:HAS_ACTION]->(act2:AWSPolicyStatementActionItem)
+ WHERE toLower(act2.value) IN ['glue:*', 'glue:createjob']
+ OR act2.value = '*'
+ WITH DISTINCT aws, principal, stmt_passrole, path_principal
// Find glue:CreateTrigger permission
- MATCH (principal)--(trigger_policy:AWSPolicy)--(stmt_trigger:AWSPolicyStatement)
- WHERE stmt_trigger.effect = 'Allow'
- AND any(action IN stmt_trigger.action WHERE
- toLower(action) = 'glue:createtrigger'
- OR toLower(action) = 'glue:*'
- OR action = '*'
- )
+ MATCH (principal)-[:POLICY]->(trigger_policy:AWSPolicy)-[:STATEMENT]->(stmt_trigger:AWSPolicyStatement {{effect: 'Allow'}})
+ MATCH (stmt_trigger)-[:HAS_ACTION]->(act3:AWSPolicyStatementActionItem)
+ WHERE toLower(act3.value) IN ['glue:*', 'glue:createtrigger']
+ OR act3.value = '*'
+ WITH DISTINCT aws, principal, stmt_passrole, path_principal
// Find roles that trust Glue service (can be passed to Glue jobs)
MATCH path_target = (aws)--(target_role:AWSRole)-[:TRUSTS_AWS_PRINCIPAL]->(:AWSPrincipal {{arn: 'glue.amazonaws.com'}})
- WHERE any(resource IN stmt_passrole.resource WHERE
- resource = '*'
- OR target_role.arn CONTAINS resource
- OR resource CONTAINS target_role.name
- )
+ MATCH (stmt_passrole)-[:HAS_RESOURCE]->(res:AWSPolicyStatementResourceItem)
+ WHERE res.value = '*'
+ OR res.value CONTAINS target_role.name
+ OR target_role.arn CONTAINS res.value
+ WITH DISTINCT path_principal, path_target
WITH collect(path_principal) + collect(path_target) AS paths
UNWIND paths AS p
UNWIND nodes(p) AS n
@@ -1892,7 +1750,7 @@ AWS_GLUE_PRIVESC_PASSROLE_CREATE_JOB_TRIGGER = AttackPathsQueryDefinition(
WITH paths, collect(DISTINCT n) AS unique_nodes
UNWIND unique_nodes AS n
- OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}})
+ OPTIONAL MATCH (n)-[pfr:HAS_FINDING]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}})
RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr
""",
@@ -1912,40 +1770,34 @@ AWS_GLUE_PRIVESC_PASSROLE_UPDATE_JOB = AttackPathsQueryDefinition(
provider="aws",
cypher=f"""
// Find principals with iam:PassRole permission
- MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(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 = '*'
- )
+ MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)-[:POLICY]->(passrole_policy:AWSPolicy)-[:STATEMENT]->(stmt_passrole:AWSPolicyStatement {{effect: 'Allow'}})
+ MATCH (stmt_passrole)-[:HAS_ACTION]->(act:AWSPolicyStatementActionItem)
+ WHERE toLower(act.value) IN ['iam:*', 'iam:passrole']
+ OR act.value = '*'
+ WITH DISTINCT aws, principal, stmt_passrole, path_principal
// Find glue:UpdateJob permission
- MATCH (principal)--(updatejob_policy:AWSPolicy)--(stmt_updatejob:AWSPolicyStatement)
- WHERE stmt_updatejob.effect = 'Allow'
- AND any(action IN stmt_updatejob.action WHERE
- toLower(action) = 'glue:updatejob'
- OR toLower(action) = 'glue:*'
- OR action = '*'
- )
+ MATCH (principal)-[:POLICY]->(updatejob_policy:AWSPolicy)-[:STATEMENT]->(stmt_updatejob:AWSPolicyStatement {{effect: 'Allow'}})
+ MATCH (stmt_updatejob)-[:HAS_ACTION]->(act2:AWSPolicyStatementActionItem)
+ WHERE toLower(act2.value) IN ['glue:*', 'glue:updatejob']
+ OR act2.value = '*'
+ WITH DISTINCT aws, principal, stmt_passrole, path_principal
// Find glue:StartJobRun permission
- MATCH (principal)--(startjob_policy:AWSPolicy)--(stmt_startjob:AWSPolicyStatement)
- WHERE stmt_startjob.effect = 'Allow'
- AND any(action IN stmt_startjob.action WHERE
- toLower(action) = 'glue:startjobrun'
- OR toLower(action) = 'glue:*'
- OR action = '*'
- )
+ MATCH (principal)-[:POLICY]->(startjob_policy:AWSPolicy)-[:STATEMENT]->(stmt_startjob:AWSPolicyStatement {{effect: 'Allow'}})
+ MATCH (stmt_startjob)-[:HAS_ACTION]->(act3:AWSPolicyStatementActionItem)
+ WHERE toLower(act3.value) IN ['glue:*', 'glue:startjobrun']
+ OR act3.value = '*'
+ WITH DISTINCT aws, principal, stmt_passrole, path_principal
// Find roles that trust Glue service (can be passed to Glue jobs)
MATCH path_target = (aws)--(target_role:AWSRole)-[:TRUSTS_AWS_PRINCIPAL]->(:AWSPrincipal {{arn: 'glue.amazonaws.com'}})
- WHERE any(resource IN stmt_passrole.resource WHERE
- resource = '*'
- OR target_role.arn CONTAINS resource
- OR resource CONTAINS target_role.name
- )
+ MATCH (stmt_passrole)-[:HAS_RESOURCE]->(res:AWSPolicyStatementResourceItem)
+ WHERE res.value = '*'
+ OR res.value CONTAINS target_role.name
+ OR target_role.arn CONTAINS res.value
+ WITH DISTINCT path_principal, path_target
WITH collect(path_principal) + collect(path_target) AS paths
UNWIND paths AS p
UNWIND nodes(p) AS n
@@ -1953,7 +1805,7 @@ AWS_GLUE_PRIVESC_PASSROLE_UPDATE_JOB = AttackPathsQueryDefinition(
WITH paths, collect(DISTINCT n) AS unique_nodes
UNWIND unique_nodes AS n
- OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}})
+ OPTIONAL MATCH (n)-[pfr:HAS_FINDING]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}})
RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr
""",
@@ -1973,40 +1825,34 @@ AWS_GLUE_PRIVESC_PASSROLE_UPDATE_JOB_TRIGGER = AttackPathsQueryDefinition(
provider="aws",
cypher=f"""
// Find principals with iam:PassRole permission
- MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(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 = '*'
- )
+ MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)-[:POLICY]->(passrole_policy:AWSPolicy)-[:STATEMENT]->(stmt_passrole:AWSPolicyStatement {{effect: 'Allow'}})
+ MATCH (stmt_passrole)-[:HAS_ACTION]->(act:AWSPolicyStatementActionItem)
+ WHERE toLower(act.value) IN ['iam:*', 'iam:passrole']
+ OR act.value = '*'
+ WITH DISTINCT aws, principal, stmt_passrole, path_principal
// Find glue:UpdateJob permission
- MATCH (principal)--(updatejob_policy:AWSPolicy)--(stmt_updatejob:AWSPolicyStatement)
- WHERE stmt_updatejob.effect = 'Allow'
- AND any(action IN stmt_updatejob.action WHERE
- toLower(action) = 'glue:updatejob'
- OR toLower(action) = 'glue:*'
- OR action = '*'
- )
+ MATCH (principal)-[:POLICY]->(updatejob_policy:AWSPolicy)-[:STATEMENT]->(stmt_updatejob:AWSPolicyStatement {{effect: 'Allow'}})
+ MATCH (stmt_updatejob)-[:HAS_ACTION]->(act2:AWSPolicyStatementActionItem)
+ WHERE toLower(act2.value) IN ['glue:*', 'glue:updatejob']
+ OR act2.value = '*'
+ WITH DISTINCT aws, principal, stmt_passrole, path_principal
// Find glue:CreateTrigger permission
- MATCH (principal)--(trigger_policy:AWSPolicy)--(stmt_trigger:AWSPolicyStatement)
- WHERE stmt_trigger.effect = 'Allow'
- AND any(action IN stmt_trigger.action WHERE
- toLower(action) = 'glue:createtrigger'
- OR toLower(action) = 'glue:*'
- OR action = '*'
- )
+ MATCH (principal)-[:POLICY]->(trigger_policy:AWSPolicy)-[:STATEMENT]->(stmt_trigger:AWSPolicyStatement {{effect: 'Allow'}})
+ MATCH (stmt_trigger)-[:HAS_ACTION]->(act3:AWSPolicyStatementActionItem)
+ WHERE toLower(act3.value) IN ['glue:*', 'glue:createtrigger']
+ OR act3.value = '*'
+ WITH DISTINCT aws, principal, stmt_passrole, path_principal
// Find roles that trust Glue service (can be passed to Glue jobs)
MATCH path_target = (aws)--(target_role:AWSRole)-[:TRUSTS_AWS_PRINCIPAL]->(:AWSPrincipal {{arn: 'glue.amazonaws.com'}})
- WHERE any(resource IN stmt_passrole.resource WHERE
- resource = '*'
- OR target_role.arn CONTAINS resource
- OR resource CONTAINS target_role.name
- )
+ MATCH (stmt_passrole)-[:HAS_RESOURCE]->(res:AWSPolicyStatementResourceItem)
+ WHERE res.value = '*'
+ OR res.value CONTAINS target_role.name
+ OR target_role.arn CONTAINS res.value
+ WITH DISTINCT path_principal, path_target
WITH collect(path_principal) + collect(path_target) AS paths
UNWIND paths AS p
UNWIND nodes(p) AS n
@@ -2014,7 +1860,7 @@ AWS_GLUE_PRIVESC_PASSROLE_UPDATE_JOB_TRIGGER = AttackPathsQueryDefinition(
WITH paths, collect(DISTINCT n) AS unique_nodes
UNWIND unique_nodes AS n
- OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}})
+ OPTIONAL MATCH (n)-[pfr:HAS_FINDING]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}})
RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr
""",
@@ -2034,22 +1880,20 @@ AWS_IAM_PRIVESC_CREATE_POLICY_VERSION = AttackPathsQueryDefinition(
provider="aws",
cypher=f"""
// Find principals with iam:CreatePolicyVersion permission
- MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(policy:AWSPolicy)--(stmt:AWSPolicyStatement)
- WHERE stmt.effect = 'Allow'
- AND any(action IN stmt.action WHERE
- toLower(action) = 'iam:createpolicyversion'
- OR toLower(action) = 'iam:*'
- OR action = '*'
- )
+ MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)-[:POLICY]->(policy:AWSPolicy)-[:STATEMENT]->(stmt:AWSPolicyStatement {{effect: 'Allow'}})
+ MATCH (stmt)-[:HAS_ACTION]->(act:AWSPolicyStatementActionItem)
+ WHERE toLower(act.value) IN ['iam:*', 'iam:createpolicyversion']
+ OR act.value = '*'
+ WITH DISTINCT aws, principal, stmt, path_principal
// Find customer-managed policies attached to the same principal that can be overwritten
MATCH path_target = (aws)--(target_policy:AWSPolicy)--(principal)
WHERE target_policy.arn CONTAINS $provider_uid
- AND any(resource IN stmt.resource WHERE
- resource = '*'
- OR target_policy.arn CONTAINS resource
- )
+ MATCH (stmt)-[:HAS_RESOURCE]->(res:AWSPolicyStatementResourceItem)
+ WHERE res.value = '*'
+ OR target_policy.arn CONTAINS res.value
+ WITH DISTINCT path_principal, path_target
WITH collect(path_principal) + collect(path_target) AS paths
UNWIND paths AS p
UNWIND nodes(p) AS n
@@ -2057,7 +1901,7 @@ AWS_IAM_PRIVESC_CREATE_POLICY_VERSION = AttackPathsQueryDefinition(
WITH paths, collect(DISTINCT n) AS unique_nodes
UNWIND unique_nodes AS n
- OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}})
+ OPTIONAL MATCH (n)-[pfr:HAS_FINDING]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}})
RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr
""",
@@ -2077,22 +1921,20 @@ AWS_IAM_PRIVESC_CREATE_ACCESS_KEY = AttackPathsQueryDefinition(
provider="aws",
cypher=f"""
// Find principals with iam:CreateAccessKey permission
- MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(policy:AWSPolicy)--(stmt:AWSPolicyStatement)
- WHERE stmt.effect = 'Allow'
- AND any(action IN stmt.action WHERE
- toLower(action) = 'iam:createaccesskey'
- OR toLower(action) = 'iam:*'
- OR action = '*'
- )
+ MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)-[:POLICY]->(policy:AWSPolicy)-[:STATEMENT]->(stmt:AWSPolicyStatement {{effect: 'Allow'}})
+ MATCH (stmt)-[:HAS_ACTION]->(act:AWSPolicyStatementActionItem)
+ WHERE toLower(act.value) IN ['iam:*', 'iam:createaccesskey']
+ OR act.value = '*'
+ WITH DISTINCT aws, principal, stmt, path_principal
// Find target users that the principal can create access keys for
MATCH path_target = (aws)--(target_user:AWSUser)
- WHERE any(resource IN stmt.resource WHERE
- resource = '*'
- OR target_user.arn CONTAINS resource
- OR resource CONTAINS target_user.name
- )
+ MATCH (stmt)-[:HAS_RESOURCE]->(res:AWSPolicyStatementResourceItem)
+ WHERE res.value = '*'
+ OR res.value CONTAINS target_user.name
+ OR target_user.arn CONTAINS res.value
+ WITH DISTINCT path_principal, path_target
WITH collect(path_principal) + collect(path_target) AS paths
UNWIND paths AS p
UNWIND nodes(p) AS n
@@ -2100,7 +1942,7 @@ AWS_IAM_PRIVESC_CREATE_ACCESS_KEY = AttackPathsQueryDefinition(
WITH paths, collect(DISTINCT n) AS unique_nodes
UNWIND unique_nodes AS n
- OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}})
+ OPTIONAL MATCH (n)-[pfr:HAS_FINDING]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}})
RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr
""",
@@ -2119,45 +1961,39 @@ AWS_IAM_PRIVESC_DELETE_CREATE_ACCESS_KEY = AttackPathsQueryDefinition(
),
provider="aws",
cypher=f"""
- // Find principals with iam:CreateAccessKey permission
- MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(policy:AWSPolicy)--(stmt:AWSPolicyStatement)
- WHERE stmt.effect = 'Allow'
- AND any(action IN stmt.action WHERE
- toLower(action) = 'iam:createaccesskey'
- OR toLower(action) = 'iam:*'
- OR action = '*'
- )
+ // Find principals with iam:CreateAccessKey permission (this IS path_principal)
+ MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)-[:POLICY]->(policy:AWSPolicy)-[:STATEMENT]->(stmt:AWSPolicyStatement {{effect: 'Allow'}})
+ MATCH (stmt)-[:HAS_ACTION]->(act:AWSPolicyStatementActionItem)
+ WHERE toLower(act.value) IN ['iam:*', 'iam:createaccesskey']
+ OR act.value = '*'
+ WITH DISTINCT aws, principal, stmt, path_principal
- // Find iam:DeleteAccessKey permission
- MATCH (principal)--(delete_policy:AWSPolicy)--(stmt_delete:AWSPolicyStatement)
- WHERE stmt_delete.effect = 'Allow'
- AND any(action IN stmt_delete.action WHERE
- toLower(action) = 'iam:deleteaccesskey'
- OR toLower(action) = 'iam:*'
- OR action = '*'
- )
+ // Find iam:DeleteAccessKey permission (keep stmt2: its resource is checked)
+ MATCH (principal)-[:POLICY]->(:AWSPolicy)-[:STATEMENT]->(stmt2:AWSPolicyStatement {{effect: 'Allow'}})
+ MATCH (stmt2)-[:HAS_ACTION]->(act2:AWSPolicyStatementActionItem)
+ WHERE toLower(act2.value) IN ['iam:*', 'iam:deleteaccesskey']
+ OR act2.value = '*'
+ WITH DISTINCT aws, principal, stmt, stmt2, path_principal
// Find target users that the principal can rotate access keys for
MATCH path_target = (aws)--(target_user:AWSUser)
- WHERE any(resource IN stmt.resource WHERE
- resource = '*'
- OR target_user.arn CONTAINS resource
- OR resource CONTAINS target_user.name
- )
- AND any(resource IN stmt_delete.resource WHERE
- resource = '*'
- OR target_user.arn CONTAINS resource
- OR resource CONTAINS target_user.name
- )
+ MATCH (stmt)-[:HAS_RESOURCE]->(res:AWSPolicyStatementResourceItem)
+ WHERE res.value = '*'
+ OR res.value CONTAINS target_user.name
+ OR target_user.arn CONTAINS res.value
+ MATCH (stmt2)-[:HAS_RESOURCE]->(res2:AWSPolicyStatementResourceItem)
+ WHERE res2.value = '*'
+ OR res2.value CONTAINS target_user.name
+ OR target_user.arn CONTAINS res2.value
+ WITH DISTINCT path_principal, path_target
WITH collect(path_principal) + collect(path_target) AS paths
UNWIND paths AS p
UNWIND nodes(p) AS n
WITH paths, collect(DISTINCT n) AS unique_nodes
UNWIND unique_nodes AS n
-
- OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}})
+ OPTIONAL MATCH (n)-[pfr:HAS_FINDING]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}})
RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr
""",
@@ -2177,22 +2013,20 @@ AWS_IAM_PRIVESC_CREATE_LOGIN_PROFILE = AttackPathsQueryDefinition(
provider="aws",
cypher=f"""
// Find principals with iam:CreateLoginProfile permission
- MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(policy:AWSPolicy)--(stmt:AWSPolicyStatement)
- WHERE stmt.effect = 'Allow'
- AND any(action IN stmt.action WHERE
- toLower(action) = 'iam:createloginprofile'
- OR toLower(action) = 'iam:*'
- OR action = '*'
- )
+ MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)-[:POLICY]->(policy:AWSPolicy)-[:STATEMENT]->(stmt:AWSPolicyStatement {{effect: 'Allow'}})
+ MATCH (stmt)-[:HAS_ACTION]->(act:AWSPolicyStatementActionItem)
+ WHERE toLower(act.value) IN ['iam:*', 'iam:createloginprofile']
+ OR act.value = '*'
+ WITH DISTINCT aws, principal, stmt, path_principal
// Find target users that the principal can create login profiles for
MATCH path_target = (aws)--(target_user:AWSUser)
- WHERE any(resource IN stmt.resource WHERE
- resource = '*'
- OR target_user.arn CONTAINS resource
- OR resource CONTAINS target_user.name
- )
+ MATCH (stmt)-[:HAS_RESOURCE]->(res:AWSPolicyStatementResourceItem)
+ WHERE res.value = '*'
+ OR res.value CONTAINS target_user.name
+ OR target_user.arn CONTAINS res.value
+ WITH DISTINCT path_principal, path_target
WITH collect(path_principal) + collect(path_target) AS paths
UNWIND paths AS p
UNWIND nodes(p) AS n
@@ -2200,7 +2034,7 @@ AWS_IAM_PRIVESC_CREATE_LOGIN_PROFILE = AttackPathsQueryDefinition(
WITH paths, collect(DISTINCT n) AS unique_nodes
UNWIND unique_nodes AS n
- OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}})
+ OPTIONAL MATCH (n)-[pfr:HAS_FINDING]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}})
RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr
""",
@@ -2220,19 +2054,16 @@ AWS_IAM_PRIVESC_PUT_ROLE_POLICY = AttackPathsQueryDefinition(
provider="aws",
cypher=f"""
// Find roles with iam:PutRolePolicy permission scoped to themselves
- MATCH path = (aws:AWSAccount {{id: $provider_uid}})--(role:AWSRole)--(policy:AWSPolicy)--(stmt:AWSPolicyStatement)
- WHERE stmt.effect = 'Allow'
- AND any(action IN stmt.action WHERE
- toLower(action) = 'iam:putrolepolicy'
- OR toLower(action) = 'iam:*'
- OR action = '*'
- )
- AND any(resource IN stmt.resource WHERE
- resource = '*'
- OR role.arn CONTAINS resource
- OR resource CONTAINS role.name
- )
+ MATCH path = (aws:AWSAccount {{id: $provider_uid}})--(role:AWSRole)-[:POLICY]->(policy:AWSPolicy)-[:STATEMENT]->(stmt:AWSPolicyStatement {{effect: 'Allow'}})
+ MATCH (stmt)-[:HAS_ACTION]->(act:AWSPolicyStatementActionItem)
+ WHERE toLower(act.value) IN ['iam:*', 'iam:putrolepolicy']
+ OR act.value = '*'
+ MATCH (stmt)-[:HAS_RESOURCE]->(res:AWSPolicyStatementResourceItem)
+ WHERE res.value = '*'
+ OR res.value CONTAINS role.name
+ OR role.arn CONTAINS res.value
+ WITH DISTINCT path
WITH collect(path) AS paths
UNWIND paths AS p
UNWIND nodes(p) AS n
@@ -2240,7 +2071,7 @@ AWS_IAM_PRIVESC_PUT_ROLE_POLICY = AttackPathsQueryDefinition(
WITH paths, collect(DISTINCT n) AS unique_nodes
UNWIND unique_nodes AS n
- OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}})
+ OPTIONAL MATCH (n)-[pfr:HAS_FINDING]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}})
RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr
""",
@@ -2260,22 +2091,20 @@ AWS_IAM_PRIVESC_UPDATE_LOGIN_PROFILE = AttackPathsQueryDefinition(
provider="aws",
cypher=f"""
// Find principals with iam:UpdateLoginProfile permission
- MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(policy:AWSPolicy)--(stmt:AWSPolicyStatement)
- WHERE stmt.effect = 'Allow'
- AND any(action IN stmt.action WHERE
- toLower(action) = 'iam:updateloginprofile'
- OR toLower(action) = 'iam:*'
- OR action = '*'
- )
+ MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)-[:POLICY]->(policy:AWSPolicy)-[:STATEMENT]->(stmt:AWSPolicyStatement {{effect: 'Allow'}})
+ MATCH (stmt)-[:HAS_ACTION]->(act:AWSPolicyStatementActionItem)
+ WHERE toLower(act.value) IN ['iam:*', 'iam:updateloginprofile']
+ OR act.value = '*'
+ WITH DISTINCT aws, principal, stmt, path_principal
// Find target users that the principal can update login profiles for
MATCH path_target = (aws)--(target_user:AWSUser)
- WHERE any(resource IN stmt.resource WHERE
- resource = '*'
- OR target_user.arn CONTAINS resource
- OR resource CONTAINS target_user.name
- )
+ MATCH (stmt)-[:HAS_RESOURCE]->(res:AWSPolicyStatementResourceItem)
+ WHERE res.value = '*'
+ OR res.value CONTAINS target_user.name
+ OR target_user.arn CONTAINS res.value
+ WITH DISTINCT path_principal, path_target
WITH collect(path_principal) + collect(path_target) AS paths
UNWIND paths AS p
UNWIND nodes(p) AS n
@@ -2283,7 +2112,7 @@ AWS_IAM_PRIVESC_UPDATE_LOGIN_PROFILE = AttackPathsQueryDefinition(
WITH paths, collect(DISTINCT n) AS unique_nodes
UNWIND unique_nodes AS n
- OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}})
+ OPTIONAL MATCH (n)-[pfr:HAS_FINDING]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}})
RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr
""",
@@ -2303,19 +2132,16 @@ AWS_IAM_PRIVESC_PUT_USER_POLICY = AttackPathsQueryDefinition(
provider="aws",
cypher=f"""
// Find users with iam:PutUserPolicy permission scoped to themselves
- MATCH path = (aws:AWSAccount {{id: $provider_uid}})--(user:AWSUser)--(policy:AWSPolicy)--(stmt:AWSPolicyStatement)
- WHERE stmt.effect = 'Allow'
- AND any(action IN stmt.action WHERE
- toLower(action) = 'iam:putuserpolicy'
- OR toLower(action) = 'iam:*'
- OR action = '*'
- )
- AND any(resource IN stmt.resource WHERE
- resource = '*'
- OR user.arn CONTAINS resource
- OR resource CONTAINS user.name
- )
+ MATCH path = (aws:AWSAccount {{id: $provider_uid}})--(user:AWSUser)-[:POLICY]->(policy:AWSPolicy)-[:STATEMENT]->(stmt:AWSPolicyStatement {{effect: 'Allow'}})
+ MATCH (stmt)-[:HAS_ACTION]->(act:AWSPolicyStatementActionItem)
+ WHERE toLower(act.value) IN ['iam:*', 'iam:putuserpolicy']
+ OR act.value = '*'
+ MATCH (stmt)-[:HAS_RESOURCE]->(res:AWSPolicyStatementResourceItem)
+ WHERE res.value = '*'
+ OR res.value CONTAINS user.name
+ OR user.arn CONTAINS res.value
+ WITH DISTINCT path
WITH collect(path) AS paths
UNWIND paths AS p
UNWIND nodes(p) AS n
@@ -2323,7 +2149,7 @@ AWS_IAM_PRIVESC_PUT_USER_POLICY = AttackPathsQueryDefinition(
WITH paths, collect(DISTINCT n) AS unique_nodes
UNWIND unique_nodes AS n
- OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}})
+ OPTIONAL MATCH (n)-[pfr:HAS_FINDING]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}})
RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr
""",
@@ -2343,19 +2169,16 @@ AWS_IAM_PRIVESC_ATTACH_USER_POLICY = AttackPathsQueryDefinition(
provider="aws",
cypher=f"""
// Find users with iam:AttachUserPolicy permission scoped to themselves
- MATCH path = (aws:AWSAccount {{id: $provider_uid}})--(user:AWSUser)--(policy:AWSPolicy)--(stmt:AWSPolicyStatement)
- WHERE stmt.effect = 'Allow'
- AND any(action IN stmt.action WHERE
- toLower(action) = 'iam:attachuserpolicy'
- OR toLower(action) = 'iam:*'
- OR action = '*'
- )
- AND any(resource IN stmt.resource WHERE
- resource = '*'
- OR user.arn CONTAINS resource
- OR resource CONTAINS user.name
- )
+ MATCH path = (aws:AWSAccount {{id: $provider_uid}})--(user:AWSUser)-[:POLICY]->(policy:AWSPolicy)-[:STATEMENT]->(stmt:AWSPolicyStatement {{effect: 'Allow'}})
+ MATCH (stmt)-[:HAS_ACTION]->(act:AWSPolicyStatementActionItem)
+ WHERE toLower(act.value) IN ['iam:*', 'iam:attachuserpolicy']
+ OR act.value = '*'
+ MATCH (stmt)-[:HAS_RESOURCE]->(res:AWSPolicyStatementResourceItem)
+ WHERE res.value = '*'
+ OR res.value CONTAINS user.name
+ OR user.arn CONTAINS res.value
+ WITH DISTINCT path
WITH collect(path) AS paths
UNWIND paths AS p
UNWIND nodes(p) AS n
@@ -2363,7 +2186,7 @@ AWS_IAM_PRIVESC_ATTACH_USER_POLICY = AttackPathsQueryDefinition(
WITH paths, collect(DISTINCT n) AS unique_nodes
UNWIND unique_nodes AS n
- OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}})
+ OPTIONAL MATCH (n)-[pfr:HAS_FINDING]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}})
RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr
""",
@@ -2383,19 +2206,16 @@ AWS_IAM_PRIVESC_ATTACH_ROLE_POLICY = AttackPathsQueryDefinition(
provider="aws",
cypher=f"""
// Find roles with iam:AttachRolePolicy permission scoped to themselves
- MATCH path = (aws:AWSAccount {{id: $provider_uid}})--(role:AWSRole)--(policy:AWSPolicy)--(stmt:AWSPolicyStatement)
- WHERE stmt.effect = 'Allow'
- AND any(action IN stmt.action WHERE
- toLower(action) = 'iam:attachrolepolicy'
- OR toLower(action) = 'iam:*'
- OR action = '*'
- )
- AND any(resource IN stmt.resource WHERE
- resource = '*'
- OR role.arn CONTAINS resource
- OR resource CONTAINS role.name
- )
+ MATCH path = (aws:AWSAccount {{id: $provider_uid}})--(role:AWSRole)-[:POLICY]->(policy:AWSPolicy)-[:STATEMENT]->(stmt:AWSPolicyStatement {{effect: 'Allow'}})
+ MATCH (stmt)-[:HAS_ACTION]->(act:AWSPolicyStatementActionItem)
+ WHERE toLower(act.value) IN ['iam:*', 'iam:attachrolepolicy']
+ OR act.value = '*'
+ MATCH (stmt)-[:HAS_RESOURCE]->(res:AWSPolicyStatementResourceItem)
+ WHERE res.value = '*'
+ OR res.value CONTAINS role.name
+ OR role.arn CONTAINS res.value
+ WITH DISTINCT path
WITH collect(path) AS paths
UNWIND paths AS p
UNWIND nodes(p) AS n
@@ -2403,7 +2223,7 @@ AWS_IAM_PRIVESC_ATTACH_ROLE_POLICY = AttackPathsQueryDefinition(
WITH paths, collect(DISTINCT n) AS unique_nodes
UNWIND unique_nodes AS n
- OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}})
+ OPTIONAL MATCH (n)-[pfr:HAS_FINDING]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}})
RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr
""",
@@ -2423,22 +2243,20 @@ AWS_IAM_PRIVESC_ATTACH_GROUP_POLICY = AttackPathsQueryDefinition(
provider="aws",
cypher=f"""
// Find users with iam:AttachGroupPolicy permission
- MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(user:AWSUser)--(policy:AWSPolicy)--(stmt:AWSPolicyStatement)
- WHERE stmt.effect = 'Allow'
- AND any(action IN stmt.action WHERE
- toLower(action) = 'iam:attachgrouppolicy'
- OR toLower(action) = 'iam:*'
- OR action = '*'
- )
+ MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(user:AWSUser)-[:POLICY]->(policy:AWSPolicy)-[:STATEMENT]->(stmt:AWSPolicyStatement {{effect: 'Allow'}})
+ MATCH (stmt)-[:HAS_ACTION]->(act:AWSPolicyStatementActionItem)
+ WHERE toLower(act.value) IN ['iam:*', 'iam:attachgrouppolicy']
+ OR act.value = '*'
+ WITH DISTINCT aws, user, stmt, path_principal
// Find groups the user is a member of and can attach policies to
- MATCH path_target = (aws)-[:RESOURCE]->(target_group:AWSGroup)<-[:MEMBER_AWS_GROUP]-(user)
- WHERE any(resource IN stmt.resource WHERE
- resource = '*'
- OR target_group.arn CONTAINS resource
- OR resource CONTAINS target_group.name
- )
+ MATCH path_target = (aws)--(target_group:AWSGroup)<-[:MEMBER_AWS_GROUP]-(user)
+ MATCH (stmt)-[:HAS_RESOURCE]->(res:AWSPolicyStatementResourceItem)
+ WHERE res.value = '*'
+ OR res.value CONTAINS target_group.name
+ OR target_group.arn CONTAINS res.value
+ WITH DISTINCT path_principal, path_target
WITH collect(path_principal) + collect(path_target) AS paths
UNWIND paths AS p
UNWIND nodes(p) AS n
@@ -2446,7 +2264,7 @@ AWS_IAM_PRIVESC_ATTACH_GROUP_POLICY = AttackPathsQueryDefinition(
WITH paths, collect(DISTINCT n) AS unique_nodes
UNWIND unique_nodes AS n
- OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}})
+ OPTIONAL MATCH (n)-[pfr:HAS_FINDING]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}})
RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr
""",
@@ -2466,22 +2284,20 @@ AWS_IAM_PRIVESC_PUT_GROUP_POLICY = AttackPathsQueryDefinition(
provider="aws",
cypher=f"""
// Find users with iam:PutGroupPolicy permission
- MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(user:AWSUser)--(policy:AWSPolicy)--(stmt:AWSPolicyStatement)
- WHERE stmt.effect = 'Allow'
- AND any(action IN stmt.action WHERE
- toLower(action) = 'iam:putgrouppolicy'
- OR toLower(action) = 'iam:*'
- OR action = '*'
- )
+ MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(user:AWSUser)-[:POLICY]->(policy:AWSPolicy)-[:STATEMENT]->(stmt:AWSPolicyStatement {{effect: 'Allow'}})
+ MATCH (stmt)-[:HAS_ACTION]->(act:AWSPolicyStatementActionItem)
+ WHERE toLower(act.value) IN ['iam:*', 'iam:putgrouppolicy']
+ OR act.value = '*'
+ WITH DISTINCT aws, user, stmt, path_principal
// Find groups the user is a member of and can put policies on
- MATCH path_target = (aws)-[:RESOURCE]->(target_group:AWSGroup)<-[:MEMBER_AWS_GROUP]-(user)
- WHERE any(resource IN stmt.resource WHERE
- resource = '*'
- OR target_group.arn CONTAINS resource
- OR resource CONTAINS target_group.name
- )
+ MATCH path_target = (aws)--(target_group:AWSGroup)<-[:MEMBER_AWS_GROUP]-(user)
+ MATCH (stmt)-[:HAS_RESOURCE]->(res:AWSPolicyStatementResourceItem)
+ WHERE res.value = '*'
+ OR res.value CONTAINS target_group.name
+ OR target_group.arn CONTAINS res.value
+ WITH DISTINCT path_principal, path_target
WITH collect(path_principal) + collect(path_target) AS paths
UNWIND paths AS p
UNWIND nodes(p) AS n
@@ -2489,7 +2305,7 @@ AWS_IAM_PRIVESC_PUT_GROUP_POLICY = AttackPathsQueryDefinition(
WITH paths, collect(DISTINCT n) AS unique_nodes
UNWIND unique_nodes AS n
- OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}})
+ OPTIONAL MATCH (n)-[pfr:HAS_FINDING]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}})
RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr
""",
@@ -2508,31 +2324,30 @@ AWS_IAM_PRIVESC_UPDATE_ASSUME_ROLE_POLICY = AttackPathsQueryDefinition(
),
provider="aws",
cypher=f"""
- // Find principals with iam:UpdateAssumeRolePolicy permission
- MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(policy:AWSPolicy)--(stmt:AWSPolicyStatement)
- WHERE stmt.effect = 'Allow'
- AND any(action IN stmt.action WHERE
- toLower(action) = 'iam:updateassumerolepolicy'
- OR toLower(action) = 'iam:*'
- OR action = '*'
- )
+ // Find principals with iam:UpdateAssumeRolePolicy permission (this IS path_principal)
+ MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)-[:POLICY]->(policy:AWSPolicy)-[:STATEMENT]->(stmt:AWSPolicyStatement {{effect: 'Allow'}})
+ MATCH (stmt)-[:HAS_ACTION]->(act:AWSPolicyStatementActionItem)
+ WHERE toLower(act.value) IN ['iam:*', 'iam:updateassumerolepolicy']
+ OR act.value = '*'
- // Find target roles whose trust policy can be modified
+ // Collapse the action-item fan-out: one row per (statement chain), not per matching action
+ WITH DISTINCT aws, stmt, path_principal
+
+ // Find target roles whose trust policy this statement's resource can target
MATCH path_target = (aws)--(target_role:AWSRole)
- WHERE any(resource IN stmt.resource WHERE
- resource = '*'
- OR target_role.arn CONTAINS resource
- OR resource CONTAINS target_role.name
- )
+ MATCH (stmt)-[:HAS_RESOURCE]->(res:AWSPolicyStatementResourceItem)
+ WHERE res.value = '*'
+ OR res.value CONTAINS target_role.name
+ OR target_role.arn CONTAINS res.value
+ WITH DISTINCT path_principal, path_target
WITH collect(path_principal) + collect(path_target) AS paths
UNWIND paths AS p
UNWIND nodes(p) AS n
WITH paths, collect(DISTINCT n) AS unique_nodes
UNWIND unique_nodes AS n
-
- OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}})
+ OPTIONAL MATCH (n)-[pfr:HAS_FINDING]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}})
RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr
""",
@@ -2552,22 +2367,20 @@ AWS_IAM_PRIVESC_ADD_USER_TO_GROUP = AttackPathsQueryDefinition(
provider="aws",
cypher=f"""
// Find principals with iam:AddUserToGroup permission
- MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(policy:AWSPolicy)--(stmt:AWSPolicyStatement)
- WHERE stmt.effect = 'Allow'
- AND any(action IN stmt.action WHERE
- toLower(action) = 'iam:addusertogroup'
- OR toLower(action) = 'iam:*'
- OR action = '*'
- )
+ MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)-[:POLICY]->(policy:AWSPolicy)-[:STATEMENT]->(stmt:AWSPolicyStatement {{effect: 'Allow'}})
+ MATCH (stmt)-[:HAS_ACTION]->(act:AWSPolicyStatementActionItem)
+ WHERE toLower(act.value) IN ['iam:*', 'iam:addusertogroup']
+ OR act.value = '*'
+ WITH DISTINCT aws, principal, stmt, path_principal
// Find target groups the principal can add users to
- MATCH path_target = (aws)-[:RESOURCE]->(target_group:AWSGroup)
- WHERE any(resource IN stmt.resource WHERE
- resource = '*'
- OR target_group.arn CONTAINS resource
- OR resource CONTAINS target_group.name
- )
+ MATCH path_target = (aws)--(target_group:AWSGroup)
+ MATCH (stmt)-[:HAS_RESOURCE]->(res:AWSPolicyStatementResourceItem)
+ WHERE res.value = '*'
+ OR res.value CONTAINS target_group.name
+ OR target_group.arn CONTAINS res.value
+ WITH DISTINCT path_principal, path_target
WITH collect(path_principal) + collect(path_target) AS paths
UNWIND paths AS p
UNWIND nodes(p) AS n
@@ -2575,7 +2388,7 @@ AWS_IAM_PRIVESC_ADD_USER_TO_GROUP = AttackPathsQueryDefinition(
WITH paths, collect(DISTINCT n) AS unique_nodes
UNWIND unique_nodes AS n
- OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}})
+ OPTIONAL MATCH (n)-[pfr:HAS_FINDING]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}})
RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr
""",
@@ -2595,22 +2408,20 @@ AWS_IAM_PRIVESC_ATTACH_ROLE_POLICY_ASSUME_ROLE = AttackPathsQueryDefinition(
provider="aws",
cypher=f"""
// Find principals with iam:AttachRolePolicy permission
- MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(policy:AWSPolicy)--(stmt:AWSPolicyStatement)
- WHERE stmt.effect = 'Allow'
- AND any(action IN stmt.action WHERE
- toLower(action) = 'iam:attachrolepolicy'
- OR toLower(action) = 'iam:*'
- OR action = '*'
- )
+ MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)-[:POLICY]->(policy:AWSPolicy)-[:STATEMENT]->(stmt:AWSPolicyStatement {{effect: 'Allow'}})
+ MATCH (stmt)-[:HAS_ACTION]->(act:AWSPolicyStatementActionItem)
+ WHERE toLower(act.value) IN ['iam:*', 'iam:attachrolepolicy']
+ OR act.value = '*'
+ WITH DISTINCT aws, principal, stmt, path_principal
// Find target roles the principal can assume and attach policies to
MATCH path_target = (aws)--(target_role:AWSRole)<-[:STS_ASSUMEROLE_ALLOW]-(principal)
- WHERE any(resource IN stmt.resource WHERE
- resource = '*'
- OR target_role.arn CONTAINS resource
- OR resource CONTAINS target_role.name
- )
+ MATCH (stmt)-[:HAS_RESOURCE]->(res:AWSPolicyStatementResourceItem)
+ WHERE res.value = '*'
+ OR res.value CONTAINS target_role.name
+ OR target_role.arn CONTAINS res.value
+ WITH DISTINCT path_principal, path_target
WITH collect(path_principal) + collect(path_target) AS paths
UNWIND paths AS p
UNWIND nodes(p) AS n
@@ -2618,7 +2429,7 @@ AWS_IAM_PRIVESC_ATTACH_ROLE_POLICY_ASSUME_ROLE = AttackPathsQueryDefinition(
WITH paths, collect(DISTINCT n) AS unique_nodes
UNWIND unique_nodes AS n
- OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}})
+ OPTIONAL MATCH (n)-[pfr:HAS_FINDING]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}})
RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr
""",
@@ -2637,45 +2448,39 @@ AWS_IAM_PRIVESC_ATTACH_USER_POLICY_CREATE_ACCESS_KEY = AttackPathsQueryDefinitio
),
provider="aws",
cypher=f"""
- // Find principals with iam:AttachUserPolicy permission
- MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(policy:AWSPolicy)--(stmt:AWSPolicyStatement)
- WHERE stmt.effect = 'Allow'
- AND any(action IN stmt.action WHERE
- toLower(action) = 'iam:attachuserpolicy'
- OR toLower(action) = 'iam:*'
- OR action = '*'
- )
+ // Find principals with iam:AttachUserPolicy permission (this IS path_principal)
+ MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)-[:POLICY]->(policy:AWSPolicy)-[:STATEMENT]->(stmt:AWSPolicyStatement {{effect: 'Allow'}})
+ MATCH (stmt)-[:HAS_ACTION]->(act:AWSPolicyStatementActionItem)
+ WHERE toLower(act.value) IN ['iam:*', 'iam:attachuserpolicy']
+ OR act.value = '*'
+ WITH DISTINCT aws, principal, stmt, path_principal
- // Find iam:CreateAccessKey permission
- MATCH (principal)--(policy2:AWSPolicy)--(stmt2:AWSPolicyStatement)
- WHERE stmt2.effect = 'Allow'
- AND any(action IN stmt2.action WHERE
- toLower(action) = 'iam:createaccesskey'
- OR toLower(action) = 'iam:*'
- OR action = '*'
- )
+ // Find iam:CreateAccessKey permission (keep stmt2: its resource is checked)
+ MATCH (principal)-[:POLICY]->(:AWSPolicy)-[:STATEMENT]->(stmt2:AWSPolicyStatement {{effect: 'Allow'}})
+ MATCH (stmt2)-[:HAS_ACTION]->(act2:AWSPolicyStatementActionItem)
+ WHERE toLower(act2.value) IN ['iam:*', 'iam:createaccesskey']
+ OR act2.value = '*'
+ WITH DISTINCT aws, principal, stmt, stmt2, path_principal
// Find target users the principal can attach policies to and create keys for
MATCH path_target = (aws)--(target_user:AWSUser)
- WHERE any(resource IN stmt.resource WHERE
- resource = '*'
- OR target_user.arn CONTAINS resource
- OR resource CONTAINS target_user.name
- )
- AND any(resource IN stmt2.resource WHERE
- resource = '*'
- OR target_user.arn CONTAINS resource
- OR resource CONTAINS target_user.name
- )
+ MATCH (stmt)-[:HAS_RESOURCE]->(res:AWSPolicyStatementResourceItem)
+ WHERE res.value = '*'
+ OR res.value CONTAINS target_user.name
+ OR target_user.arn CONTAINS res.value
+ MATCH (stmt2)-[:HAS_RESOURCE]->(res2:AWSPolicyStatementResourceItem)
+ WHERE res2.value = '*'
+ OR res2.value CONTAINS target_user.name
+ OR target_user.arn CONTAINS res2.value
+ WITH DISTINCT path_principal, path_target
WITH collect(path_principal) + collect(path_target) AS paths
UNWIND paths AS p
UNWIND nodes(p) AS n
WITH paths, collect(DISTINCT n) AS unique_nodes
UNWIND unique_nodes AS n
-
- OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}})
+ OPTIONAL MATCH (n)-[pfr:HAS_FINDING]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}})
RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr
""",
@@ -2695,23 +2500,21 @@ AWS_IAM_PRIVESC_CREATE_POLICY_VERSION_ASSUME_ROLE = AttackPathsQueryDefinition(
provider="aws",
cypher=f"""
// Find principals with iam:CreatePolicyVersion permission
- MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(policy:AWSPolicy)--(stmt:AWSPolicyStatement)
- WHERE stmt.effect = 'Allow'
- AND any(action IN stmt.action WHERE
- toLower(action) = 'iam:createpolicyversion'
- OR toLower(action) = 'iam:*'
- OR action = '*'
- )
+ MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)-[:POLICY]->(policy:AWSPolicy)-[:STATEMENT]->(stmt:AWSPolicyStatement {{effect: 'Allow'}})
+ MATCH (stmt)-[:HAS_ACTION]->(act:AWSPolicyStatementActionItem)
+ WHERE toLower(act.value) IN ['iam:*', 'iam:createpolicyversion']
+ OR act.value = '*'
+ WITH DISTINCT aws, principal, stmt, path_principal
// Find target roles the principal can assume that have customer-managed policies the principal can modify
MATCH path_target = (aws)--(target_role:AWSRole)<-[:STS_ASSUMEROLE_ALLOW]-(principal)
MATCH (target_role)--(target_policy:AWSPolicy)
WHERE target_policy.arn CONTAINS $provider_uid
- AND any(resource IN stmt.resource WHERE
- resource = '*'
- OR target_policy.arn CONTAINS resource
- )
+ MATCH (stmt)-[:HAS_RESOURCE]->(res:AWSPolicyStatementResourceItem)
+ WHERE res.value = '*'
+ OR target_policy.arn CONTAINS res.value
+ WITH DISTINCT path_principal, path_target
WITH collect(path_principal) + collect(path_target) AS paths
UNWIND paths AS p
UNWIND nodes(p) AS n
@@ -2719,7 +2522,7 @@ AWS_IAM_PRIVESC_CREATE_POLICY_VERSION_ASSUME_ROLE = AttackPathsQueryDefinition(
WITH paths, collect(DISTINCT n) AS unique_nodes
UNWIND unique_nodes AS n
- OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}})
+ OPTIONAL MATCH (n)-[pfr:HAS_FINDING]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}})
RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr
""",
@@ -2739,22 +2542,20 @@ AWS_IAM_PRIVESC_PUT_ROLE_POLICY_ASSUME_ROLE = AttackPathsQueryDefinition(
provider="aws",
cypher=f"""
// Find principals with iam:PutRolePolicy permission
- MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(policy:AWSPolicy)--(stmt:AWSPolicyStatement)
- WHERE stmt.effect = 'Allow'
- AND any(action IN stmt.action WHERE
- toLower(action) = 'iam:putrolepolicy'
- OR toLower(action) = 'iam:*'
- OR action = '*'
- )
+ MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)-[:POLICY]->(policy:AWSPolicy)-[:STATEMENT]->(stmt:AWSPolicyStatement {{effect: 'Allow'}})
+ MATCH (stmt)-[:HAS_ACTION]->(act:AWSPolicyStatementActionItem)
+ WHERE toLower(act.value) IN ['iam:*', 'iam:putrolepolicy']
+ OR act.value = '*'
+ WITH DISTINCT aws, principal, stmt, path_principal
// Find target roles the principal can assume and put inline policies on
MATCH path_target = (aws)--(target_role:AWSRole)<-[:STS_ASSUMEROLE_ALLOW]-(principal)
- WHERE any(resource IN stmt.resource WHERE
- resource = '*'
- OR target_role.arn CONTAINS resource
- OR resource CONTAINS target_role.name
- )
+ MATCH (stmt)-[:HAS_RESOURCE]->(res:AWSPolicyStatementResourceItem)
+ WHERE res.value = '*'
+ OR res.value CONTAINS target_role.name
+ OR target_role.arn CONTAINS res.value
+ WITH DISTINCT path_principal, path_target
WITH collect(path_principal) + collect(path_target) AS paths
UNWIND paths AS p
UNWIND nodes(p) AS n
@@ -2762,7 +2563,7 @@ AWS_IAM_PRIVESC_PUT_ROLE_POLICY_ASSUME_ROLE = AttackPathsQueryDefinition(
WITH paths, collect(DISTINCT n) AS unique_nodes
UNWIND unique_nodes AS n
- OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}})
+ OPTIONAL MATCH (n)-[pfr:HAS_FINDING]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}})
RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr
""",
@@ -2781,45 +2582,39 @@ AWS_IAM_PRIVESC_PUT_USER_POLICY_CREATE_ACCESS_KEY = AttackPathsQueryDefinition(
),
provider="aws",
cypher=f"""
- // Find principals with iam:PutUserPolicy permission
- MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(policy:AWSPolicy)--(stmt:AWSPolicyStatement)
- WHERE stmt.effect = 'Allow'
- AND any(action IN stmt.action WHERE
- toLower(action) = 'iam:putuserpolicy'
- OR toLower(action) = 'iam:*'
- OR action = '*'
- )
+ // Find principals with iam:PutUserPolicy permission (this IS path_principal)
+ MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)-[:POLICY]->(policy:AWSPolicy)-[:STATEMENT]->(stmt:AWSPolicyStatement {{effect: 'Allow'}})
+ MATCH (stmt)-[:HAS_ACTION]->(act:AWSPolicyStatementActionItem)
+ WHERE toLower(act.value) IN ['iam:*', 'iam:putuserpolicy']
+ OR act.value = '*'
+ WITH DISTINCT aws, principal, stmt, path_principal
- // Find iam:CreateAccessKey permission
- MATCH (principal)--(policy2:AWSPolicy)--(stmt2:AWSPolicyStatement)
- WHERE stmt2.effect = 'Allow'
- AND any(action IN stmt2.action WHERE
- toLower(action) = 'iam:createaccesskey'
- OR toLower(action) = 'iam:*'
- OR action = '*'
- )
+ // Find iam:CreateAccessKey permission (keep stmt2: its resource is checked)
+ MATCH (principal)-[:POLICY]->(:AWSPolicy)-[:STATEMENT]->(stmt2:AWSPolicyStatement {{effect: 'Allow'}})
+ MATCH (stmt2)-[:HAS_ACTION]->(act2:AWSPolicyStatementActionItem)
+ WHERE toLower(act2.value) IN ['iam:*', 'iam:createaccesskey']
+ OR act2.value = '*'
+ WITH DISTINCT aws, principal, stmt, stmt2, path_principal
// Find target users the principal can put policies on and create keys for
MATCH path_target = (aws)--(target_user:AWSUser)
- WHERE any(resource IN stmt.resource WHERE
- resource = '*'
- OR target_user.arn CONTAINS resource
- OR resource CONTAINS target_user.name
- )
- AND any(resource IN stmt2.resource WHERE
- resource = '*'
- OR target_user.arn CONTAINS resource
- OR resource CONTAINS target_user.name
- )
+ MATCH (stmt)-[:HAS_RESOURCE]->(res:AWSPolicyStatementResourceItem)
+ WHERE res.value = '*'
+ OR res.value CONTAINS target_user.name
+ OR target_user.arn CONTAINS res.value
+ MATCH (stmt2)-[:HAS_RESOURCE]->(res2:AWSPolicyStatementResourceItem)
+ WHERE res2.value = '*'
+ OR res2.value CONTAINS target_user.name
+ OR target_user.arn CONTAINS res2.value
+ WITH DISTINCT path_principal, path_target
WITH collect(path_principal) + collect(path_target) AS paths
UNWIND paths AS p
UNWIND nodes(p) AS n
WITH paths, collect(DISTINCT n) AS unique_nodes
UNWIND unique_nodes AS n
-
- OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}})
+ OPTIONAL MATCH (n)-[pfr:HAS_FINDING]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}})
RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr
""",
@@ -2838,45 +2633,39 @@ AWS_IAM_PRIVESC_ATTACH_ROLE_POLICY_UPDATE_ASSUME_ROLE = AttackPathsQueryDefiniti
),
provider="aws",
cypher=f"""
- // Find principals with iam:AttachRolePolicy permission
- MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(policy:AWSPolicy)--(stmt:AWSPolicyStatement)
- WHERE stmt.effect = 'Allow'
- AND any(action IN stmt.action WHERE
- toLower(action) = 'iam:attachrolepolicy'
- OR toLower(action) = 'iam:*'
- OR action = '*'
- )
+ // Find principals with iam:AttachRolePolicy permission (this IS path_principal)
+ MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)-[:POLICY]->(policy:AWSPolicy)-[:STATEMENT]->(stmt:AWSPolicyStatement {{effect: 'Allow'}})
+ MATCH (stmt)-[:HAS_ACTION]->(act:AWSPolicyStatementActionItem)
+ WHERE toLower(act.value) IN ['iam:*', 'iam:attachrolepolicy']
+ OR act.value = '*'
+ WITH DISTINCT aws, principal, stmt, path_principal
- // Find iam:UpdateAssumeRolePolicy permission
- MATCH (principal)--(policy2:AWSPolicy)--(stmt2:AWSPolicyStatement)
- WHERE stmt2.effect = 'Allow'
- AND any(action IN stmt2.action WHERE
- toLower(action) = 'iam:updateassumerolepolicy'
- OR toLower(action) = 'iam:*'
- OR action = '*'
- )
+ // Find iam:UpdateAssumeRolePolicy permission (keep stmt2: its resource is checked)
+ MATCH (principal)-[:POLICY]->(:AWSPolicy)-[:STATEMENT]->(stmt2:AWSPolicyStatement {{effect: 'Allow'}})
+ MATCH (stmt2)-[:HAS_ACTION]->(act2:AWSPolicyStatementActionItem)
+ WHERE toLower(act2.value) IN ['iam:*', 'iam:updateassumerolepolicy']
+ OR act2.value = '*'
+ WITH DISTINCT aws, principal, stmt, stmt2, path_principal
// Find target roles the principal can attach policies to and update trust policy for
MATCH path_target = (aws)--(target_role:AWSRole)
- WHERE any(resource IN stmt.resource WHERE
- resource = '*'
- OR target_role.arn CONTAINS resource
- OR resource CONTAINS target_role.name
- )
- AND any(resource IN stmt2.resource WHERE
- resource = '*'
- OR target_role.arn CONTAINS resource
- OR resource CONTAINS target_role.name
- )
+ MATCH (stmt)-[:HAS_RESOURCE]->(res:AWSPolicyStatementResourceItem)
+ WHERE res.value = '*'
+ OR res.value CONTAINS target_role.name
+ OR target_role.arn CONTAINS res.value
+ MATCH (stmt2)-[:HAS_RESOURCE]->(res2:AWSPolicyStatementResourceItem)
+ WHERE res2.value = '*'
+ OR res2.value CONTAINS target_role.name
+ OR target_role.arn CONTAINS res2.value
+ WITH DISTINCT path_principal, path_target
WITH collect(path_principal) + collect(path_target) AS paths
UNWIND paths AS p
UNWIND nodes(p) AS n
WITH paths, collect(DISTINCT n) AS unique_nodes
UNWIND unique_nodes AS n
-
- OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}})
+ OPTIONAL MATCH (n)-[pfr:HAS_FINDING]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}})
RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr
""",
@@ -2895,46 +2684,40 @@ AWS_IAM_PRIVESC_CREATE_POLICY_VERSION_UPDATE_ASSUME_ROLE = AttackPathsQueryDefin
),
provider="aws",
cypher=f"""
- // Find principals with iam:CreatePolicyVersion permission
- MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(policy:AWSPolicy)--(stmt:AWSPolicyStatement)
- WHERE stmt.effect = 'Allow'
- AND any(action IN stmt.action WHERE
- toLower(action) = 'iam:createpolicyversion'
- OR toLower(action) = 'iam:*'
- OR action = '*'
- )
+ // Find principals with iam:CreatePolicyVersion permission (this IS path_principal)
+ MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)-[:POLICY]->(policy:AWSPolicy)-[:STATEMENT]->(stmt:AWSPolicyStatement {{effect: 'Allow'}})
+ MATCH (stmt)-[:HAS_ACTION]->(act:AWSPolicyStatementActionItem)
+ WHERE toLower(act.value) IN ['iam:*', 'iam:createpolicyversion']
+ OR act.value = '*'
+ WITH DISTINCT aws, principal, stmt, path_principal
- // Find iam:UpdateAssumeRolePolicy permission
- MATCH (principal)--(policy2:AWSPolicy)--(stmt2:AWSPolicyStatement)
- WHERE stmt2.effect = 'Allow'
- AND any(action IN stmt2.action WHERE
- toLower(action) = 'iam:updateassumerolepolicy'
- OR toLower(action) = 'iam:*'
- OR action = '*'
- )
+ // Find iam:UpdateAssumeRolePolicy permission (keep stmt2: its resource is checked)
+ MATCH (principal)-[:POLICY]->(:AWSPolicy)-[:STATEMENT]->(stmt2:AWSPolicyStatement {{effect: 'Allow'}})
+ MATCH (stmt2)-[:HAS_ACTION]->(act2:AWSPolicyStatementActionItem)
+ WHERE toLower(act2.value) IN ['iam:*', 'iam:updateassumerolepolicy']
+ OR act2.value = '*'
+ WITH DISTINCT aws, principal, stmt, stmt2, path_principal
// Find target roles with customer-managed policies the principal can modify and update trust policy for
MATCH path_target = (aws)--(target_role:AWSRole)
- WHERE any(resource IN stmt2.resource WHERE
- resource = '*'
- OR target_role.arn CONTAINS resource
- OR resource CONTAINS target_role.name
- )
- MATCH (target_role)--(target_policy:AWSPolicy)
+ MATCH (stmt2)-[:HAS_RESOURCE]->(res2:AWSPolicyStatementResourceItem)
+ WHERE res2.value = '*'
+ OR res2.value CONTAINS target_role.name
+ OR target_role.arn CONTAINS res2.value
+ MATCH (target_role)-[:POLICY]->(target_policy:AWSPolicy)
WHERE target_policy.arn CONTAINS $provider_uid
- AND any(resource IN stmt.resource WHERE
- resource = '*'
- OR target_policy.arn CONTAINS resource
- )
+ MATCH (stmt)-[:HAS_RESOURCE]->(res:AWSPolicyStatementResourceItem)
+ WHERE res.value = '*'
+ OR target_policy.arn CONTAINS res.value
+ WITH DISTINCT path_principal, path_target
WITH collect(path_principal) + collect(path_target) AS paths
UNWIND paths AS p
UNWIND nodes(p) AS n
WITH paths, collect(DISTINCT n) AS unique_nodes
UNWIND unique_nodes AS n
-
- OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}})
+ OPTIONAL MATCH (n)-[pfr:HAS_FINDING]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}})
RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr
""",
@@ -2953,45 +2736,39 @@ AWS_IAM_PRIVESC_PUT_ROLE_POLICY_UPDATE_ASSUME_ROLE = AttackPathsQueryDefinition(
),
provider="aws",
cypher=f"""
- // Find principals with iam:PutRolePolicy permission
- MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(policy:AWSPolicy)--(stmt:AWSPolicyStatement)
- WHERE stmt.effect = 'Allow'
- AND any(action IN stmt.action WHERE
- toLower(action) = 'iam:putrolepolicy'
- OR toLower(action) = 'iam:*'
- OR action = '*'
- )
+ // Find principals with iam:PutRolePolicy permission (this IS path_principal)
+ MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)-[:POLICY]->(policy:AWSPolicy)-[:STATEMENT]->(stmt:AWSPolicyStatement {{effect: 'Allow'}})
+ MATCH (stmt)-[:HAS_ACTION]->(act:AWSPolicyStatementActionItem)
+ WHERE toLower(act.value) IN ['iam:*', 'iam:putrolepolicy']
+ OR act.value = '*'
+ WITH DISTINCT aws, principal, stmt, path_principal
- // Find iam:UpdateAssumeRolePolicy permission
- MATCH (principal)--(policy2:AWSPolicy)--(stmt2:AWSPolicyStatement)
- WHERE stmt2.effect = 'Allow'
- AND any(action IN stmt2.action WHERE
- toLower(action) = 'iam:updateassumerolepolicy'
- OR toLower(action) = 'iam:*'
- OR action = '*'
- )
+ // Find iam:UpdateAssumeRolePolicy permission (keep stmt2: its resource is checked)
+ MATCH (principal)-[:POLICY]->(:AWSPolicy)-[:STATEMENT]->(stmt2:AWSPolicyStatement {{effect: 'Allow'}})
+ MATCH (stmt2)-[:HAS_ACTION]->(act2:AWSPolicyStatementActionItem)
+ WHERE toLower(act2.value) IN ['iam:*', 'iam:updateassumerolepolicy']
+ OR act2.value = '*'
+ WITH DISTINCT aws, principal, stmt, stmt2, path_principal
// Find target roles the principal can put inline policies on and update trust policy for
MATCH path_target = (aws)--(target_role:AWSRole)
- WHERE any(resource IN stmt.resource WHERE
- resource = '*'
- OR target_role.arn CONTAINS resource
- OR resource CONTAINS target_role.name
- )
- AND any(resource IN stmt2.resource WHERE
- resource = '*'
- OR target_role.arn CONTAINS resource
- OR resource CONTAINS target_role.name
- )
+ MATCH (stmt)-[:HAS_RESOURCE]->(res:AWSPolicyStatementResourceItem)
+ WHERE res.value = '*'
+ OR res.value CONTAINS target_role.name
+ OR target_role.arn CONTAINS res.value
+ MATCH (stmt2)-[:HAS_RESOURCE]->(res2:AWSPolicyStatementResourceItem)
+ WHERE res2.value = '*'
+ OR res2.value CONTAINS target_role.name
+ OR target_role.arn CONTAINS res2.value
+ WITH DISTINCT path_principal, path_target
WITH collect(path_principal) + collect(path_target) AS paths
UNWIND paths AS p
UNWIND nodes(p) AS n
WITH paths, collect(DISTINCT n) AS unique_nodes
UNWIND unique_nodes AS n
-
- OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}})
+ OPTIONAL MATCH (n)-[pfr:HAS_FINDING]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}})
RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr
""",
@@ -3011,40 +2788,34 @@ AWS_LAMBDA_PRIVESC_PASSROLE_CREATE_FUNCTION = AttackPathsQueryDefinition(
provider="aws",
cypher=f"""
// Find principals with iam:PassRole permission
- MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(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 = '*'
- )
+ MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)-[:POLICY]->(passrole_policy:AWSPolicy)-[:STATEMENT]->(stmt_passrole:AWSPolicyStatement {{effect: 'Allow'}})
+ MATCH (stmt_passrole)-[:HAS_ACTION]->(act:AWSPolicyStatementActionItem)
+ WHERE toLower(act.value) IN ['iam:*', 'iam:passrole']
+ OR act.value = '*'
+ WITH DISTINCT aws, principal, stmt_passrole, path_principal
// Find lambda:CreateFunction permission
- MATCH (principal)--(create_policy:AWSPolicy)--(stmt_create:AWSPolicyStatement)
- WHERE stmt_create.effect = 'Allow'
- AND any(action IN stmt_create.action WHERE
- toLower(action) = 'lambda:createfunction'
- OR toLower(action) = 'lambda:*'
- OR action = '*'
- )
+ MATCH (principal)-[:POLICY]->(create_policy:AWSPolicy)-[:STATEMENT]->(stmt_create:AWSPolicyStatement {{effect: 'Allow'}})
+ MATCH (stmt_create)-[:HAS_ACTION]->(act2:AWSPolicyStatementActionItem)
+ WHERE toLower(act2.value) IN ['lambda:*', 'lambda:createfunction']
+ OR act2.value = '*'
+ WITH DISTINCT aws, principal, stmt_passrole, path_principal
// Find lambda:InvokeFunction permission
- MATCH (principal)--(invoke_policy:AWSPolicy)--(stmt_invoke:AWSPolicyStatement)
- WHERE stmt_invoke.effect = 'Allow'
- AND any(action IN stmt_invoke.action WHERE
- toLower(action) = 'lambda:invokefunction'
- OR toLower(action) = 'lambda:*'
- OR action = '*'
- )
+ MATCH (principal)-[:POLICY]->(invoke_policy:AWSPolicy)-[:STATEMENT]->(stmt_invoke:AWSPolicyStatement {{effect: 'Allow'}})
+ MATCH (stmt_invoke)-[:HAS_ACTION]->(act3:AWSPolicyStatementActionItem)
+ WHERE toLower(act3.value) IN ['lambda:*', 'lambda:invokefunction']
+ OR act3.value = '*'
+ WITH DISTINCT aws, principal, stmt_passrole, path_principal
// Find roles that trust Lambda service (can be passed to Lambda)
MATCH path_target = (aws)--(target_role:AWSRole)-[:TRUSTS_AWS_PRINCIPAL]->(:AWSPrincipal {{arn: 'lambda.amazonaws.com'}})
- WHERE any(resource IN stmt_passrole.resource WHERE
- resource = '*'
- OR target_role.arn CONTAINS resource
- OR resource CONTAINS target_role.name
- )
+ MATCH (stmt_passrole)-[:HAS_RESOURCE]->(res:AWSPolicyStatementResourceItem)
+ WHERE res.value = '*'
+ OR res.value CONTAINS target_role.name
+ OR target_role.arn CONTAINS res.value
+ WITH DISTINCT path_principal, path_target
WITH collect(path_principal) + collect(path_target) AS paths
UNWIND paths AS p
UNWIND nodes(p) AS n
@@ -3052,7 +2823,7 @@ AWS_LAMBDA_PRIVESC_PASSROLE_CREATE_FUNCTION = AttackPathsQueryDefinition(
WITH paths, collect(DISTINCT n) AS unique_nodes
UNWIND unique_nodes AS n
- OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}})
+ OPTIONAL MATCH (n)-[pfr:HAS_FINDING]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}})
RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr
""",
@@ -3072,40 +2843,34 @@ AWS_LAMBDA_PRIVESC_PASSROLE_CREATE_FUNCTION_EVENT_SOURCE = AttackPathsQueryDefin
provider="aws",
cypher=f"""
// Find principals with iam:PassRole permission
- MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(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 = '*'
- )
+ MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)-[:POLICY]->(passrole_policy:AWSPolicy)-[:STATEMENT]->(stmt_passrole:AWSPolicyStatement {{effect: 'Allow'}})
+ MATCH (stmt_passrole)-[:HAS_ACTION]->(act:AWSPolicyStatementActionItem)
+ WHERE toLower(act.value) IN ['iam:*', 'iam:passrole']
+ OR act.value = '*'
+ WITH DISTINCT aws, principal, stmt_passrole, path_principal
// Find lambda:CreateFunction permission
- MATCH (principal)--(create_policy:AWSPolicy)--(stmt_create:AWSPolicyStatement)
- WHERE stmt_create.effect = 'Allow'
- AND any(action IN stmt_create.action WHERE
- toLower(action) = 'lambda:createfunction'
- OR toLower(action) = 'lambda:*'
- OR action = '*'
- )
+ MATCH (principal)-[:POLICY]->(create_policy:AWSPolicy)-[:STATEMENT]->(stmt_create:AWSPolicyStatement {{effect: 'Allow'}})
+ MATCH (stmt_create)-[:HAS_ACTION]->(act2:AWSPolicyStatementActionItem)
+ WHERE toLower(act2.value) IN ['lambda:*', 'lambda:createfunction']
+ OR act2.value = '*'
+ WITH DISTINCT aws, principal, stmt_passrole, path_principal
// Find lambda:CreateEventSourceMapping permission
- MATCH (principal)--(event_policy:AWSPolicy)--(stmt_event:AWSPolicyStatement)
- WHERE stmt_event.effect = 'Allow'
- AND any(action IN stmt_event.action WHERE
- toLower(action) = 'lambda:createeventsourcemapping'
- OR toLower(action) = 'lambda:*'
- OR action = '*'
- )
+ MATCH (principal)-[:POLICY]->(event_policy:AWSPolicy)-[:STATEMENT]->(stmt_event:AWSPolicyStatement {{effect: 'Allow'}})
+ MATCH (stmt_event)-[:HAS_ACTION]->(act3:AWSPolicyStatementActionItem)
+ WHERE toLower(act3.value) IN ['lambda:*', 'lambda:createeventsourcemapping']
+ OR act3.value = '*'
+ WITH DISTINCT aws, principal, stmt_passrole, path_principal
// Find roles that trust Lambda service (can be passed to Lambda)
MATCH path_target = (aws)--(target_role:AWSRole)-[:TRUSTS_AWS_PRINCIPAL]->(:AWSPrincipal {{arn: 'lambda.amazonaws.com'}})
- WHERE any(resource IN stmt_passrole.resource WHERE
- resource = '*'
- OR target_role.arn CONTAINS resource
- OR resource CONTAINS target_role.name
- )
+ MATCH (stmt_passrole)-[:HAS_RESOURCE]->(res:AWSPolicyStatementResourceItem)
+ WHERE res.value = '*'
+ OR res.value CONTAINS target_role.name
+ OR target_role.arn CONTAINS res.value
+ WITH DISTINCT path_principal, path_target
WITH collect(path_principal) + collect(path_target) AS paths
UNWIND paths AS p
UNWIND nodes(p) AS n
@@ -3113,7 +2878,7 @@ AWS_LAMBDA_PRIVESC_PASSROLE_CREATE_FUNCTION_EVENT_SOURCE = AttackPathsQueryDefin
WITH paths, collect(DISTINCT n) AS unique_nodes
UNWIND unique_nodes AS n
- OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}})
+ OPTIONAL MATCH (n)-[pfr:HAS_FINDING]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}})
RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr
""",
@@ -3133,22 +2898,20 @@ AWS_LAMBDA_PRIVESC_UPDATE_FUNCTION_CODE = AttackPathsQueryDefinition(
provider="aws",
cypher=f"""
// Find principals with lambda:UpdateFunctionCode permission
- MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(policy:AWSPolicy)--(stmt:AWSPolicyStatement)
- WHERE stmt.effect = 'Allow'
- AND any(action IN stmt.action WHERE
- toLower(action) = 'lambda:updatefunctioncode'
- OR toLower(action) = 'lambda:*'
- OR action = '*'
- )
+ MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)-[:POLICY]->(policy:AWSPolicy)-[:STATEMENT]->(stmt:AWSPolicyStatement {{effect: 'Allow'}})
+ MATCH (stmt)-[:HAS_ACTION]->(act:AWSPolicyStatementActionItem)
+ WHERE toLower(act.value) IN ['lambda:*', 'lambda:updatefunctioncode']
+ OR act.value = '*'
+ WITH DISTINCT aws, principal, stmt, path_principal
// Find existing Lambda functions with execution roles
- MATCH path_target = (aws)-[:RESOURCE]->(lambda_fn:AWSLambda)-[:STS_ASSUMEROLE_ALLOW]->(target_role:AWSRole)
- WHERE any(resource IN stmt.resource WHERE
- resource = '*'
- OR lambda_fn.arn CONTAINS resource
- OR resource CONTAINS lambda_fn.name
- )
+ MATCH path_target = (aws)--(lambda_fn:AWSLambda)-[:STS_ASSUMEROLE_ALLOW]->(target_role:AWSRole)
+ MATCH (stmt)-[:HAS_RESOURCE]->(res:AWSPolicyStatementResourceItem)
+ WHERE res.value = '*'
+ OR res.value CONTAINS lambda_fn.name
+ OR lambda_fn.arn CONTAINS res.value
+ WITH DISTINCT path_principal, path_target
WITH collect(path_principal) + collect(path_target) AS paths
UNWIND paths AS p
UNWIND nodes(p) AS n
@@ -3156,7 +2919,7 @@ AWS_LAMBDA_PRIVESC_UPDATE_FUNCTION_CODE = AttackPathsQueryDefinition(
WITH paths, collect(DISTINCT n) AS unique_nodes
UNWIND unique_nodes AS n
- OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}})
+ OPTIONAL MATCH (n)-[pfr:HAS_FINDING]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}})
RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr
""",
@@ -3175,45 +2938,39 @@ AWS_LAMBDA_PRIVESC_UPDATE_FUNCTION_CODE_INVOKE = AttackPathsQueryDefinition(
),
provider="aws",
cypher=f"""
- // Find principals with lambda:UpdateFunctionCode permission
- MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(policy:AWSPolicy)--(stmt:AWSPolicyStatement)
- WHERE stmt.effect = 'Allow'
- AND any(action IN stmt.action WHERE
- toLower(action) = 'lambda:updatefunctioncode'
- OR toLower(action) = 'lambda:*'
- OR action = '*'
- )
+ // Find principals with lambda:UpdateFunctionCode permission (this IS path_principal)
+ MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)-[:POLICY]->(policy:AWSPolicy)-[:STATEMENT]->(stmt:AWSPolicyStatement {{effect: 'Allow'}})
+ MATCH (stmt)-[:HAS_ACTION]->(act:AWSPolicyStatementActionItem)
+ WHERE toLower(act.value) IN ['lambda:*', 'lambda:updatefunctioncode']
+ OR act.value = '*'
+ WITH DISTINCT aws, principal, stmt, path_principal
- // Find lambda:InvokeFunction permission
- MATCH (principal)--(policy2:AWSPolicy)--(stmt2:AWSPolicyStatement)
- WHERE stmt2.effect = 'Allow'
- AND any(action IN stmt2.action WHERE
- toLower(action) = 'lambda:invokefunction'
- OR toLower(action) = 'lambda:*'
- OR action = '*'
- )
+ // Find lambda:InvokeFunction permission (keep stmt2: its resource is checked)
+ MATCH (principal)-[:POLICY]->(:AWSPolicy)-[:STATEMENT]->(stmt2:AWSPolicyStatement {{effect: 'Allow'}})
+ MATCH (stmt2)-[:HAS_ACTION]->(act2:AWSPolicyStatementActionItem)
+ WHERE toLower(act2.value) IN ['lambda:*', 'lambda:invokefunction']
+ OR act2.value = '*'
+ WITH DISTINCT aws, principal, stmt, stmt2, path_principal
// Find existing Lambda functions with execution roles
- MATCH path_target = (aws)-[:RESOURCE]->(lambda_fn:AWSLambda)-[:STS_ASSUMEROLE_ALLOW]->(target_role:AWSRole)
- WHERE any(resource IN stmt.resource WHERE
- resource = '*'
- OR lambda_fn.arn CONTAINS resource
- OR resource CONTAINS lambda_fn.name
- )
- AND any(resource IN stmt2.resource WHERE
- resource = '*'
- OR lambda_fn.arn CONTAINS resource
- OR resource CONTAINS lambda_fn.name
- )
+ MATCH path_target = (aws)--(lambda_fn:AWSLambda)-[:STS_ASSUMEROLE_ALLOW]->(target_role:AWSRole)
+ MATCH (stmt)-[:HAS_RESOURCE]->(res:AWSPolicyStatementResourceItem)
+ WHERE res.value = '*'
+ OR res.value CONTAINS lambda_fn.name
+ OR lambda_fn.arn CONTAINS res.value
+ MATCH (stmt2)-[:HAS_RESOURCE]->(res2:AWSPolicyStatementResourceItem)
+ WHERE res2.value = '*'
+ OR res2.value CONTAINS lambda_fn.name
+ OR lambda_fn.arn CONTAINS res2.value
+ WITH DISTINCT path_principal, path_target
WITH collect(path_principal) + collect(path_target) AS paths
UNWIND paths AS p
UNWIND nodes(p) AS n
WITH paths, collect(DISTINCT n) AS unique_nodes
UNWIND unique_nodes AS n
-
- OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}})
+ OPTIONAL MATCH (n)-[pfr:HAS_FINDING]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}})
RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr
""",
@@ -3232,45 +2989,39 @@ AWS_LAMBDA_PRIVESC_UPDATE_FUNCTION_CODE_ADD_PERMISSION = AttackPathsQueryDefinit
),
provider="aws",
cypher=f"""
- // Find principals with lambda:UpdateFunctionCode permission
- MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(policy:AWSPolicy)--(stmt:AWSPolicyStatement)
- WHERE stmt.effect = 'Allow'
- AND any(action IN stmt.action WHERE
- toLower(action) = 'lambda:updatefunctioncode'
- OR toLower(action) = 'lambda:*'
- OR action = '*'
- )
+ // Find principals with lambda:UpdateFunctionCode permission (this IS path_principal)
+ MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)-[:POLICY]->(policy:AWSPolicy)-[:STATEMENT]->(stmt:AWSPolicyStatement {{effect: 'Allow'}})
+ MATCH (stmt)-[:HAS_ACTION]->(act:AWSPolicyStatementActionItem)
+ WHERE toLower(act.value) IN ['lambda:*', 'lambda:updatefunctioncode']
+ OR act.value = '*'
+ WITH DISTINCT aws, principal, stmt, path_principal
- // Find lambda:AddPermission permission
- MATCH (principal)--(policy2:AWSPolicy)--(stmt2:AWSPolicyStatement)
- WHERE stmt2.effect = 'Allow'
- AND any(action IN stmt2.action WHERE
- toLower(action) = 'lambda:addpermission'
- OR toLower(action) = 'lambda:*'
- OR action = '*'
- )
+ // Find lambda:AddPermission permission (keep stmt2: its resource is checked)
+ MATCH (principal)-[:POLICY]->(:AWSPolicy)-[:STATEMENT]->(stmt2:AWSPolicyStatement {{effect: 'Allow'}})
+ MATCH (stmt2)-[:HAS_ACTION]->(act2:AWSPolicyStatementActionItem)
+ WHERE toLower(act2.value) IN ['lambda:*', 'lambda:addpermission']
+ OR act2.value = '*'
+ WITH DISTINCT aws, principal, stmt, stmt2, path_principal
// Find existing Lambda functions with execution roles
- MATCH path_target = (aws)-[:RESOURCE]->(lambda_fn:AWSLambda)-[:STS_ASSUMEROLE_ALLOW]->(target_role:AWSRole)
- WHERE any(resource IN stmt.resource WHERE
- resource = '*'
- OR lambda_fn.arn CONTAINS resource
- OR resource CONTAINS lambda_fn.name
- )
- AND any(resource IN stmt2.resource WHERE
- resource = '*'
- OR lambda_fn.arn CONTAINS resource
- OR resource CONTAINS lambda_fn.name
- )
+ MATCH path_target = (aws)--(lambda_fn:AWSLambda)-[:STS_ASSUMEROLE_ALLOW]->(target_role:AWSRole)
+ MATCH (stmt)-[:HAS_RESOURCE]->(res:AWSPolicyStatementResourceItem)
+ WHERE res.value = '*'
+ OR res.value CONTAINS lambda_fn.name
+ OR lambda_fn.arn CONTAINS res.value
+ MATCH (stmt2)-[:HAS_RESOURCE]->(res2:AWSPolicyStatementResourceItem)
+ WHERE res2.value = '*'
+ OR res2.value CONTAINS lambda_fn.name
+ OR lambda_fn.arn CONTAINS res2.value
+ WITH DISTINCT path_principal, path_target
WITH collect(path_principal) + collect(path_target) AS paths
UNWIND paths AS p
UNWIND nodes(p) AS n
WITH paths, collect(DISTINCT n) AS unique_nodes
UNWIND unique_nodes AS n
-
- OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}})
+ OPTIONAL MATCH (n)-[pfr:HAS_FINDING]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}})
RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr
""",
@@ -3290,40 +3041,34 @@ AWS_LAMBDA_PRIVESC_PASSROLE_CREATE_FUNCTION_ADD_PERMISSION = AttackPathsQueryDef
provider="aws",
cypher=f"""
// Find principals with iam:PassRole permission
- MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(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 = '*'
- )
+ MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)-[:POLICY]->(passrole_policy:AWSPolicy)-[:STATEMENT]->(stmt_passrole:AWSPolicyStatement {{effect: 'Allow'}})
+ MATCH (stmt_passrole)-[:HAS_ACTION]->(act:AWSPolicyStatementActionItem)
+ WHERE toLower(act.value) IN ['iam:*', 'iam:passrole']
+ OR act.value = '*'
+ WITH DISTINCT aws, principal, stmt_passrole, path_principal
// Find lambda:CreateFunction permission
- MATCH (principal)--(create_policy:AWSPolicy)--(stmt_create:AWSPolicyStatement)
- WHERE stmt_create.effect = 'Allow'
- AND any(action IN stmt_create.action WHERE
- toLower(action) = 'lambda:createfunction'
- OR toLower(action) = 'lambda:*'
- OR action = '*'
- )
+ MATCH (principal)-[:POLICY]->(create_policy:AWSPolicy)-[:STATEMENT]->(stmt_create:AWSPolicyStatement {{effect: 'Allow'}})
+ MATCH (stmt_create)-[:HAS_ACTION]->(act2:AWSPolicyStatementActionItem)
+ WHERE toLower(act2.value) IN ['lambda:*', 'lambda:createfunction']
+ OR act2.value = '*'
+ WITH DISTINCT aws, principal, stmt_passrole, path_principal
// Find lambda:AddPermission permission
- MATCH (principal)--(perm_policy:AWSPolicy)--(stmt_perm:AWSPolicyStatement)
- WHERE stmt_perm.effect = 'Allow'
- AND any(action IN stmt_perm.action WHERE
- toLower(action) = 'lambda:addpermission'
- OR toLower(action) = 'lambda:*'
- OR action = '*'
- )
+ MATCH (principal)-[:POLICY]->(perm_policy:AWSPolicy)-[:STATEMENT]->(stmt_perm:AWSPolicyStatement {{effect: 'Allow'}})
+ MATCH (stmt_perm)-[:HAS_ACTION]->(act3:AWSPolicyStatementActionItem)
+ WHERE toLower(act3.value) IN ['lambda:*', 'lambda:addpermission']
+ OR act3.value = '*'
+ WITH DISTINCT aws, principal, stmt_passrole, path_principal
// Find roles that trust Lambda service (can be passed to Lambda)
MATCH path_target = (aws)--(target_role:AWSRole)-[:TRUSTS_AWS_PRINCIPAL]->(:AWSPrincipal {{arn: 'lambda.amazonaws.com'}})
- WHERE any(resource IN stmt_passrole.resource WHERE
- resource = '*'
- OR target_role.arn CONTAINS resource
- OR resource CONTAINS target_role.name
- )
+ MATCH (stmt_passrole)-[:HAS_RESOURCE]->(res:AWSPolicyStatementResourceItem)
+ WHERE res.value = '*'
+ OR res.value CONTAINS target_role.name
+ OR target_role.arn CONTAINS res.value
+ WITH DISTINCT path_principal, path_target
WITH collect(path_principal) + collect(path_target) AS paths
UNWIND paths AS p
UNWIND nodes(p) AS n
@@ -3331,7 +3076,7 @@ AWS_LAMBDA_PRIVESC_PASSROLE_CREATE_FUNCTION_ADD_PERMISSION = AttackPathsQueryDef
WITH paths, collect(DISTINCT n) AS unique_nodes
UNWIND unique_nodes AS n
- OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}})
+ OPTIONAL MATCH (n)-[pfr:HAS_FINDING]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}})
RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr
""",
@@ -3351,31 +3096,27 @@ AWS_SAGEMAKER_PRIVESC_PASSROLE_CREATE_NOTEBOOK = AttackPathsQueryDefinition(
provider="aws",
cypher=f"""
// Find principals with iam:PassRole permission
- MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(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 = '*'
- )
+ MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)-[:POLICY]->(passrole_policy:AWSPolicy)-[:STATEMENT]->(stmt_passrole:AWSPolicyStatement {{effect: 'Allow'}})
+ MATCH (stmt_passrole)-[:HAS_ACTION]->(act:AWSPolicyStatementActionItem)
+ WHERE toLower(act.value) IN ['iam:*', 'iam:passrole']
+ OR act.value = '*'
+ WITH DISTINCT aws, principal, stmt_passrole, path_principal
// Find sagemaker:CreateNotebookInstance permission
- MATCH (principal)--(sm_policy:AWSPolicy)--(stmt_sm:AWSPolicyStatement)
- WHERE stmt_sm.effect = 'Allow'
- AND any(action IN stmt_sm.action WHERE
- toLower(action) = 'sagemaker:createnotebookinstance'
- OR toLower(action) = 'sagemaker:*'
- OR action = '*'
- )
+ MATCH (principal)-[:POLICY]->(sm_policy:AWSPolicy)-[:STATEMENT]->(stmt_sm:AWSPolicyStatement {{effect: 'Allow'}})
+ MATCH (stmt_sm)-[:HAS_ACTION]->(act2:AWSPolicyStatementActionItem)
+ WHERE toLower(act2.value) IN ['sagemaker:*', 'sagemaker:createnotebookinstance']
+ OR act2.value = '*'
+ WITH DISTINCT aws, principal, stmt_passrole, path_principal
// Find roles that trust SageMaker service (can be passed to SageMaker)
MATCH path_target = (aws)--(target_role:AWSRole)-[:TRUSTS_AWS_PRINCIPAL]->(:AWSPrincipal {{arn: 'sagemaker.amazonaws.com'}})
- WHERE any(resource IN stmt_passrole.resource WHERE
- resource = '*'
- OR target_role.arn CONTAINS resource
- OR resource CONTAINS target_role.name
- )
+ MATCH (stmt_passrole)-[:HAS_RESOURCE]->(res:AWSPolicyStatementResourceItem)
+ WHERE res.value = '*'
+ OR res.value CONTAINS target_role.name
+ OR target_role.arn CONTAINS res.value
+ WITH DISTINCT path_principal, path_target
WITH collect(path_principal) + collect(path_target) AS paths
UNWIND paths AS p
UNWIND nodes(p) AS n
@@ -3383,7 +3124,7 @@ AWS_SAGEMAKER_PRIVESC_PASSROLE_CREATE_NOTEBOOK = AttackPathsQueryDefinition(
WITH paths, collect(DISTINCT n) AS unique_nodes
UNWIND unique_nodes AS n
- OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}})
+ OPTIONAL MATCH (n)-[pfr:HAS_FINDING]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}})
RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr
""",
@@ -3403,31 +3144,27 @@ AWS_SAGEMAKER_PRIVESC_PASSROLE_CREATE_TRAINING_JOB = AttackPathsQueryDefinition(
provider="aws",
cypher=f"""
// Find principals with iam:PassRole permission
- MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(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 = '*'
- )
+ MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)-[:POLICY]->(passrole_policy:AWSPolicy)-[:STATEMENT]->(stmt_passrole:AWSPolicyStatement {{effect: 'Allow'}})
+ MATCH (stmt_passrole)-[:HAS_ACTION]->(act:AWSPolicyStatementActionItem)
+ WHERE toLower(act.value) IN ['iam:*', 'iam:passrole']
+ OR act.value = '*'
+ WITH DISTINCT aws, principal, stmt_passrole, path_principal
// Find sagemaker:CreateTrainingJob permission
- MATCH (principal)--(sm_policy:AWSPolicy)--(stmt_sm:AWSPolicyStatement)
- WHERE stmt_sm.effect = 'Allow'
- AND any(action IN stmt_sm.action WHERE
- toLower(action) = 'sagemaker:createtrainingjob'
- OR toLower(action) = 'sagemaker:*'
- OR action = '*'
- )
+ MATCH (principal)-[:POLICY]->(sm_policy:AWSPolicy)-[:STATEMENT]->(stmt_sm:AWSPolicyStatement {{effect: 'Allow'}})
+ MATCH (stmt_sm)-[:HAS_ACTION]->(act2:AWSPolicyStatementActionItem)
+ WHERE toLower(act2.value) IN ['sagemaker:*', 'sagemaker:createtrainingjob']
+ OR act2.value = '*'
+ WITH DISTINCT aws, principal, stmt_passrole, path_principal
// Find roles that trust SageMaker service (can be passed to SageMaker)
MATCH path_target = (aws)--(target_role:AWSRole)-[:TRUSTS_AWS_PRINCIPAL]->(:AWSPrincipal {{arn: 'sagemaker.amazonaws.com'}})
- WHERE any(resource IN stmt_passrole.resource WHERE
- resource = '*'
- OR target_role.arn CONTAINS resource
- OR resource CONTAINS target_role.name
- )
+ MATCH (stmt_passrole)-[:HAS_RESOURCE]->(res:AWSPolicyStatementResourceItem)
+ WHERE res.value = '*'
+ OR res.value CONTAINS target_role.name
+ OR target_role.arn CONTAINS res.value
+ WITH DISTINCT path_principal, path_target
WITH collect(path_principal) + collect(path_target) AS paths
UNWIND paths AS p
UNWIND nodes(p) AS n
@@ -3435,7 +3172,7 @@ AWS_SAGEMAKER_PRIVESC_PASSROLE_CREATE_TRAINING_JOB = AttackPathsQueryDefinition(
WITH paths, collect(DISTINCT n) AS unique_nodes
UNWIND unique_nodes AS n
- OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}})
+ OPTIONAL MATCH (n)-[pfr:HAS_FINDING]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}})
RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr
""",
@@ -3455,31 +3192,27 @@ AWS_SAGEMAKER_PRIVESC_PASSROLE_CREATE_PROCESSING_JOB = AttackPathsQueryDefinitio
provider="aws",
cypher=f"""
// Find principals with iam:PassRole permission
- MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(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 = '*'
- )
+ MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)-[:POLICY]->(passrole_policy:AWSPolicy)-[:STATEMENT]->(stmt_passrole:AWSPolicyStatement {{effect: 'Allow'}})
+ MATCH (stmt_passrole)-[:HAS_ACTION]->(act:AWSPolicyStatementActionItem)
+ WHERE toLower(act.value) IN ['iam:*', 'iam:passrole']
+ OR act.value = '*'
+ WITH DISTINCT aws, principal, stmt_passrole, path_principal
// Find sagemaker:CreateProcessingJob permission
- MATCH (principal)--(sm_policy:AWSPolicy)--(stmt_sm:AWSPolicyStatement)
- WHERE stmt_sm.effect = 'Allow'
- AND any(action IN stmt_sm.action WHERE
- toLower(action) = 'sagemaker:createprocessingjob'
- OR toLower(action) = 'sagemaker:*'
- OR action = '*'
- )
+ MATCH (principal)-[:POLICY]->(sm_policy:AWSPolicy)-[:STATEMENT]->(stmt_sm:AWSPolicyStatement {{effect: 'Allow'}})
+ MATCH (stmt_sm)-[:HAS_ACTION]->(act2:AWSPolicyStatementActionItem)
+ WHERE toLower(act2.value) IN ['sagemaker:*', 'sagemaker:createprocessingjob']
+ OR act2.value = '*'
+ WITH DISTINCT aws, principal, stmt_passrole, path_principal
// Find roles that trust SageMaker service (can be passed to SageMaker)
MATCH path_target = (aws)--(target_role:AWSRole)-[:TRUSTS_AWS_PRINCIPAL]->(:AWSPrincipal {{arn: 'sagemaker.amazonaws.com'}})
- WHERE any(resource IN stmt_passrole.resource WHERE
- resource = '*'
- OR target_role.arn CONTAINS resource
- OR resource CONTAINS target_role.name
- )
+ MATCH (stmt_passrole)-[:HAS_RESOURCE]->(res:AWSPolicyStatementResourceItem)
+ WHERE res.value = '*'
+ OR res.value CONTAINS target_role.name
+ OR target_role.arn CONTAINS res.value
+ WITH DISTINCT path_principal, path_target
WITH collect(path_principal) + collect(path_target) AS paths
UNWIND paths AS p
UNWIND nodes(p) AS n
@@ -3487,7 +3220,7 @@ AWS_SAGEMAKER_PRIVESC_PASSROLE_CREATE_PROCESSING_JOB = AttackPathsQueryDefinitio
WITH paths, collect(DISTINCT n) AS unique_nodes
UNWIND unique_nodes AS n
- OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}})
+ OPTIONAL MATCH (n)-[pfr:HAS_FINDING]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}})
RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr
""",
@@ -3507,22 +3240,20 @@ AWS_SAGEMAKER_PRIVESC_PRESIGNED_NOTEBOOK_URL = AttackPathsQueryDefinition(
provider="aws",
cypher=f"""
// Find principals with sagemaker:CreatePresignedNotebookInstanceUrl permission
- MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(policy:AWSPolicy)--(stmt:AWSPolicyStatement)
- WHERE stmt.effect = 'Allow'
- AND any(action IN stmt.action WHERE
- toLower(action) = 'sagemaker:createpresignednotebookinstanceurl'
- OR toLower(action) = 'sagemaker:*'
- OR action = '*'
- )
+ MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)-[:POLICY]->(policy:AWSPolicy)-[:STATEMENT]->(stmt:AWSPolicyStatement {{effect: 'Allow'}})
+ MATCH (stmt)-[:HAS_ACTION]->(act:AWSPolicyStatementActionItem)
+ WHERE toLower(act.value) IN ['sagemaker:*', 'sagemaker:createpresignednotebookinstanceurl']
+ OR act.value = '*'
+ WITH DISTINCT aws, principal, stmt, path_principal
// Find existing SageMaker notebook instances with execution roles
- MATCH path_target = (aws)-[:RESOURCE]->(notebook:AWSSageMakerNotebookInstance)-[:HAS_EXECUTION_ROLE]->(target_role:AWSRole)
- WHERE any(resource IN stmt.resource WHERE
- resource = '*'
- OR notebook.arn CONTAINS resource
- OR resource CONTAINS notebook.notebook_instance_name
- )
+ MATCH path_target = (aws)--(notebook:AWSSageMakerNotebookInstance)-[:HAS_EXECUTION_ROLE]->(target_role:AWSRole)
+ MATCH (stmt)-[:HAS_RESOURCE]->(res:AWSPolicyStatementResourceItem)
+ WHERE res.value = '*'
+ OR res.value CONTAINS notebook.notebook_instance_name
+ OR notebook.arn CONTAINS res.value
+ WITH DISTINCT path_principal, path_target
WITH collect(path_principal) + collect(path_target) AS paths
UNWIND paths AS p
UNWIND nodes(p) AS n
@@ -3530,7 +3261,7 @@ AWS_SAGEMAKER_PRIVESC_PRESIGNED_NOTEBOOK_URL = AttackPathsQueryDefinition(
WITH paths, collect(DISTINCT n) AS unique_nodes
UNWIND unique_nodes AS n
- OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}})
+ OPTIONAL MATCH (n)-[pfr:HAS_FINDING]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}})
RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr
""",
@@ -3549,58 +3280,46 @@ AWS_SAGEMAKER_PRIVESC_LIFECYCLE_CONFIG_NOTEBOOK = AttackPathsQueryDefinition(
),
provider="aws",
cypher=f"""
- // Find principals with sagemaker:CreateNotebookInstanceLifecycleConfig permission
- MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(policy:AWSPolicy)--(stmt:AWSPolicyStatement)
- WHERE stmt.effect = 'Allow'
- AND any(action IN stmt.action WHERE
- toLower(action) = 'sagemaker:createnotebookinstancelifecycleconfig'
- OR toLower(action) = 'sagemaker:*'
- OR action = '*'
- )
+ // Find principals with sagemaker:CreateNotebookInstanceLifecycleConfig permission (this IS path_principal)
+ MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)-[:POLICY]->(policy:AWSPolicy)-[:STATEMENT]->(stmt:AWSPolicyStatement {{effect: 'Allow'}})
+ MATCH (stmt)-[:HAS_ACTION]->(act:AWSPolicyStatementActionItem)
+ WHERE toLower(act.value) IN ['sagemaker:*', 'sagemaker:createnotebookinstancelifecycleconfig']
+ OR act.value = '*'
+ WITH DISTINCT aws, principal, path_principal
- // Find sagemaker:UpdateNotebookInstance permission
- MATCH (principal)--(policy2:AWSPolicy)--(stmt2:AWSPolicyStatement)
- WHERE stmt2.effect = 'Allow'
- AND any(action IN stmt2.action WHERE
- toLower(action) = 'sagemaker:updatenotebookinstance'
- OR toLower(action) = 'sagemaker:*'
- OR action = '*'
- )
+ // Gate: sagemaker:UpdateNotebookInstance (keep stmt2: its resource is checked)
+ MATCH (principal)-[:POLICY]->(:AWSPolicy)-[:STATEMENT]->(stmt2:AWSPolicyStatement {{effect: 'Allow'}})-[:HAS_ACTION]->(act2:AWSPolicyStatementActionItem)
+ WHERE toLower(act2.value) IN ['sagemaker:*', 'sagemaker:updatenotebookinstance']
+ OR act2.value = '*'
+ WITH DISTINCT aws, principal, stmt2, path_principal
- // Find sagemaker:StopNotebookInstance permission
- MATCH (principal)--(policy3:AWSPolicy)--(stmt3:AWSPolicyStatement)
- WHERE stmt3.effect = 'Allow'
- AND any(action IN stmt3.action WHERE
- toLower(action) = 'sagemaker:stopnotebookinstance'
- OR toLower(action) = 'sagemaker:*'
- OR action = '*'
- )
+ // Gate: sagemaker:StopNotebookInstance (existence only)
+ MATCH (principal)-[:POLICY]->(:AWSPolicy)-[:STATEMENT]->(:AWSPolicyStatement {{effect: 'Allow'}})-[:HAS_ACTION]->(act3:AWSPolicyStatementActionItem)
+ WHERE toLower(act3.value) IN ['sagemaker:*', 'sagemaker:stopnotebookinstance']
+ OR act3.value = '*'
+ WITH DISTINCT aws, principal, stmt2, path_principal
- // Find sagemaker:StartNotebookInstance permission
- MATCH (principal)--(policy4:AWSPolicy)--(stmt4:AWSPolicyStatement)
- WHERE stmt4.effect = 'Allow'
- AND any(action IN stmt4.action WHERE
- toLower(action) = 'sagemaker:startnotebookinstance'
- OR toLower(action) = 'sagemaker:*'
- OR action = '*'
- )
+ // Gate: sagemaker:StartNotebookInstance (existence only)
+ MATCH (principal)-[:POLICY]->(:AWSPolicy)-[:STATEMENT]->(:AWSPolicyStatement {{effect: 'Allow'}})-[:HAS_ACTION]->(act4:AWSPolicyStatementActionItem)
+ WHERE toLower(act4.value) IN ['sagemaker:*', 'sagemaker:startnotebookinstance']
+ OR act4.value = '*'
+ WITH DISTINCT aws, principal, stmt2, path_principal
// Find existing SageMaker notebook instances with execution roles
- MATCH path_target = (aws)-[:RESOURCE]->(notebook:AWSSageMakerNotebookInstance)-[:HAS_EXECUTION_ROLE]->(target_role:AWSRole)
- WHERE any(resource IN stmt2.resource WHERE
- resource = '*'
- OR notebook.arn CONTAINS resource
- OR resource CONTAINS notebook.notebook_instance_name
- )
+ MATCH path_target = (aws)--(notebook:AWSSageMakerNotebookInstance)-[:HAS_EXECUTION_ROLE]->(target_role:AWSRole)
+ MATCH (stmt2)-[:HAS_RESOURCE]->(res:AWSPolicyStatementResourceItem)
+ WHERE res.value = '*'
+ OR res.value CONTAINS notebook.notebook_instance_name
+ OR notebook.arn CONTAINS res.value
+ WITH DISTINCT path_principal, path_target
WITH collect(path_principal) + collect(path_target) AS paths
UNWIND paths AS p
UNWIND nodes(p) AS n
WITH paths, collect(DISTINCT n) AS unique_nodes
UNWIND unique_nodes AS n
-
- OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}})
+ OPTIONAL MATCH (n)-[pfr:HAS_FINDING]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}})
RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr
""",
@@ -3620,25 +3339,24 @@ AWS_SSM_PRIVESC_START_SESSION = AttackPathsQueryDefinition(
provider="aws",
cypher=f"""
// Find principals with ssm:StartSession permission
- MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(policy:AWSPolicy)--(stmt:AWSPolicyStatement)
- WHERE stmt.effect = 'Allow'
- AND any(action IN stmt.action WHERE
- toLower(action) = 'ssm:startsession'
- OR toLower(action) = 'ssm:*'
- OR action = '*'
- )
+ MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)-[:POLICY]->(policy:AWSPolicy)-[:STATEMENT]->(stmt:AWSPolicyStatement {{effect: 'Allow'}})
+ MATCH (stmt)-[:HAS_ACTION]->(act:AWSPolicyStatementActionItem)
+ WHERE toLower(act.value) IN ['ssm:*', 'ssm:startsession']
+ OR act.value = '*'
+ WITH aws, collect(DISTINCT path_principal) AS principal_paths
// Find EC2 instances with attached roles (targets for credential theft via IMDS)
MATCH path_target = (aws)--(ec2:EC2Instance)-[:STS_ASSUMEROLE_ALLOW]->(target_role:AWSRole)
- WITH collect(path_principal) + collect(path_target) AS paths
+ WITH principal_paths, collect(DISTINCT path_target) AS target_paths
+ WITH principal_paths + target_paths AS paths
UNWIND paths AS p
UNWIND nodes(p) AS n
WITH paths, collect(DISTINCT n) AS unique_nodes
UNWIND unique_nodes AS n
- OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}})
+ OPTIONAL MATCH (n)-[pfr:HAS_FINDING]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}})
RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr
""",
@@ -3658,25 +3376,24 @@ AWS_SSM_PRIVESC_SEND_COMMAND = AttackPathsQueryDefinition(
provider="aws",
cypher=f"""
// Find principals with ssm:SendCommand permission
- MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(policy:AWSPolicy)--(stmt:AWSPolicyStatement)
- WHERE stmt.effect = 'Allow'
- AND any(action IN stmt.action WHERE
- toLower(action) = 'ssm:sendcommand'
- OR toLower(action) = 'ssm:*'
- OR action = '*'
- )
+ MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)-[:POLICY]->(policy:AWSPolicy)-[:STATEMENT]->(stmt:AWSPolicyStatement {{effect: 'Allow'}})
+ MATCH (stmt)-[:HAS_ACTION]->(act:AWSPolicyStatementActionItem)
+ WHERE toLower(act.value) IN ['ssm:*', 'ssm:sendcommand']
+ OR act.value = '*'
+ WITH aws, collect(DISTINCT path_principal) AS principal_paths
// Find EC2 instances with attached roles (targets for credential theft via IMDS)
MATCH path_target = (aws)--(ec2:EC2Instance)-[:STS_ASSUMEROLE_ALLOW]->(target_role:AWSRole)
- WITH collect(path_principal) + collect(path_target) AS paths
+ WITH principal_paths, collect(DISTINCT path_target) AS target_paths
+ WITH principal_paths + target_paths AS paths
UNWIND paths AS p
UNWIND nodes(p) AS n
WITH paths, collect(DISTINCT n) AS unique_nodes
UNWIND unique_nodes AS n
- OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}})
+ OPTIONAL MATCH (n)-[pfr:HAS_FINDING]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}})
RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr
""",
@@ -3696,22 +3413,20 @@ AWS_STS_PRIVESC_ASSUME_ROLE = AttackPathsQueryDefinition(
provider="aws",
cypher=f"""
// Find principals with sts:AssumeRole permission
- MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(policy:AWSPolicy)--(stmt:AWSPolicyStatement)
- WHERE stmt.effect = 'Allow'
- AND any(action IN stmt.action WHERE
- toLower(action) = 'sts:assumerole'
- OR toLower(action) = 'sts:*'
- OR action = '*'
- )
+ MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)-[:POLICY]->(policy:AWSPolicy)-[:STATEMENT]->(stmt:AWSPolicyStatement {{effect: 'Allow'}})
+ MATCH (stmt)-[:HAS_ACTION]->(act:AWSPolicyStatementActionItem)
+ WHERE toLower(act.value) IN ['sts:*', 'sts:assumerole']
+ OR act.value = '*'
+ WITH DISTINCT aws, principal, stmt, path_principal
// Find target roles the principal can assume (bidirectional trust via Cartography)
MATCH path_target = (aws)--(target_role:AWSRole)<-[:STS_ASSUMEROLE_ALLOW]-(principal)
- WHERE any(resource IN stmt.resource WHERE
- resource = '*'
- OR target_role.arn CONTAINS resource
- OR resource CONTAINS target_role.name
- )
+ MATCH (stmt)-[:HAS_RESOURCE]->(res:AWSPolicyStatementResourceItem)
+ WHERE res.value = '*'
+ OR res.value CONTAINS target_role.name
+ OR target_role.arn CONTAINS res.value
+ WITH DISTINCT path_principal, path_target
WITH collect(path_principal) + collect(path_target) AS paths
UNWIND paths AS p
UNWIND nodes(p) AS n
@@ -3719,7 +3434,7 @@ AWS_STS_PRIVESC_ASSUME_ROLE = AttackPathsQueryDefinition(
WITH paths, collect(DISTINCT n) AS unique_nodes
UNWIND unique_nodes AS n
- OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}})
+ OPTIONAL MATCH (n)-[pfr:HAS_FINDING]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}})
RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr
""",
@@ -3727,7 +3442,6 @@ AWS_STS_PRIVESC_ASSUME_ROLE = AttackPathsQueryDefinition(
)
# AWS Queries List
-# ----------------
AWS_QUERIES: list[AttackPathsQueryDefinition] = [
AWS_INTERNET_EXPOSED_EC2_SENSITIVE_S3_ACCESS,
diff --git a/api/src/backend/api/attack_paths/queries/aws_deprecated.py b/api/src/backend/api/attack_paths/queries/aws_deprecated.py
new file mode 100644
index 0000000000..b94c329202
--- /dev/null
+++ b/api/src/backend/api/attack_paths/queries/aws_deprecated.py
@@ -0,0 +1,3819 @@
+# TODO: drop after Neptune cutover
+#
+# Pre-cutover query catalog for AWS scans whose graph data was written under
+# the previous schema, where list-typed policy properties were serialised as
+# comma-delimited strings on the parent node. The registry routes scans with
+# `is_migrated=False` to this module; all other scans use `aws.py`. Both
+# files expose the same query IDs and parameter shapes so the API surface
+# stays uniform across the cutover window. This file is deleted, along with
+# `AttackPathsScan.is_migrated`, once the legacy data is fully drained.
+from api.attack_paths.queries.types import (
+ AttackPathsQueryAttribution,
+ AttackPathsQueryDefinition,
+ AttackPathsQueryParameterDefinition,
+)
+from tasks.jobs.attack_paths.config import PROWLER_FINDING_LABEL
+
+# Custom Attack Path Queries
+# --------------------------
+
+AWS_INTERNET_EXPOSED_EC2_SENSITIVE_S3_ACCESS = AttackPathsQueryDefinition(
+ id="aws-internet-exposed-ec2-sensitive-s3-access",
+ name="Internet-Exposed EC2 with Sensitive S3 Access",
+ short_description="Find SSH-exposed EC2 instances that can assume roles to read tagged sensitive S3 buckets.",
+ 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=f"""
+ 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)
+
+ OPTIONAL MATCH (internet:Internet)-[can_access:CAN_ACCESS]->(ec2)
+
+ WITH collect(path_s3) + collect(path_ec2) + collect(path_role) + collect(path_assume_role) AS paths,
+ head(collect(internet)) AS internet, collect(can_access) AS can_access
+ UNWIND paths AS p
+ UNWIND nodes(p) AS n
+
+ WITH paths, internet, can_access, collect(DISTINCT n) AS unique_nodes
+ UNWIND unique_nodes AS n
+ OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}})
+
+ RETURN paths, 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",
+ ),
+ ],
+)
+
+
+# Basic Resource Queries
+# ----------------------
+
+AWS_RDS_INSTANCES = AttackPathsQueryDefinition(
+ id="aws-rds-instances",
+ name="RDS Instances Inventory",
+ short_description="List all provisioned RDS database instances in the account.",
+ description="List the selected AWS account alongside the RDS instances it owns.",
+ provider="aws",
+ cypher=f"""
+ MATCH path = (aws:AWSAccount {{id: $provider_uid}})--(rds:RDSInstance)
+
+ WITH collect(path) AS paths
+ UNWIND paths AS p
+ UNWIND nodes(p) AS n
+
+ WITH paths, collect(DISTINCT n) AS unique_nodes
+ UNWIND unique_nodes AS n
+ OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}})
+
+ RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr
+ """,
+ parameters=[],
+)
+
+AWS_RDS_UNENCRYPTED_STORAGE = AttackPathsQueryDefinition(
+ id="aws-rds-unencrypted-storage",
+ name="Unencrypted RDS Instances",
+ short_description="Find RDS instances with storage encryption disabled.",
+ description="Find RDS instances with storage encryption disabled within the selected account.",
+ provider="aws",
+ cypher=f"""
+ MATCH path = (aws:AWSAccount {{id: $provider_uid}})--(rds:RDSInstance)
+ WHERE rds.storage_encrypted = false
+
+ WITH collect(path) AS paths
+ UNWIND paths AS p
+ UNWIND nodes(p) AS n
+
+ WITH paths, collect(DISTINCT n) AS unique_nodes
+ UNWIND unique_nodes AS n
+ OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}})
+
+ RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr
+ """,
+ parameters=[],
+)
+
+AWS_S3_ANONYMOUS_ACCESS_BUCKETS = AttackPathsQueryDefinition(
+ id="aws-s3-anonymous-access-buckets",
+ name="S3 Buckets with Anonymous Access",
+ short_description="Find S3 buckets that allow anonymous access.",
+ description="Find S3 buckets that allow anonymous access within the selected account.",
+ provider="aws",
+ cypher=f"""
+ MATCH path = (aws:AWSAccount {{id: $provider_uid}})--(s3:S3Bucket)
+ WHERE s3.anonymous_access = true
+
+ WITH collect(path) AS paths
+ UNWIND paths AS p
+ UNWIND nodes(p) AS n
+
+ WITH paths, collect(DISTINCT n) AS unique_nodes
+ UNWIND unique_nodes AS n
+ OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}})
+
+ RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr
+ """,
+ parameters=[],
+)
+
+AWS_IAM_STATEMENTS_ALLOW_ALL_ACTIONS = AttackPathsQueryDefinition(
+ id="aws-iam-statements-allow-all-actions",
+ name="IAM Statements Allowing All Actions",
+ short_description="Find IAM policy statements that allow all actions via wildcard (*).",
+ description="Find IAM policy statements that allow all actions via '*' within the selected account.",
+ provider="aws",
+ cypher=f"""
+ 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 = '*')
+
+ WITH collect(path) AS paths
+ UNWIND paths AS p
+ UNWIND nodes(p) AS n
+
+ WITH paths, collect(DISTINCT n) AS unique_nodes
+ UNWIND unique_nodes AS n
+ OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}})
+
+ RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr
+ """,
+ parameters=[],
+)
+
+AWS_IAM_STATEMENTS_ALLOW_DELETE_POLICY = AttackPathsQueryDefinition(
+ id="aws-iam-statements-allow-delete-policy",
+ name="IAM Statements Allowing Policy Deletion",
+ short_description="Find IAM policy statements that allow iam:DeletePolicy.",
+ description="Find IAM policy statements that allow the iam:DeletePolicy action within the selected account.",
+ provider="aws",
+ cypher=f"""
+ 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")
+
+ WITH collect(path) AS paths
+ UNWIND paths AS p
+ UNWIND nodes(p) AS n
+
+ WITH paths, collect(DISTINCT n) AS unique_nodes
+ UNWIND unique_nodes AS n
+ OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}})
+
+ RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr
+ """,
+ parameters=[],
+)
+
+AWS_IAM_STATEMENTS_ALLOW_CREATE_ACTIONS = AttackPathsQueryDefinition(
+ id="aws-iam-statements-allow-create-actions",
+ name="IAM Statements Allowing Create Actions",
+ short_description="Find IAM policy statements that allow any create action.",
+ description="Find IAM policy statements that allow actions containing 'create' within the selected account.",
+ provider="aws",
+ cypher=f"""
+ 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")
+
+ WITH collect(path) AS paths
+ UNWIND paths AS p
+ UNWIND nodes(p) AS n
+
+ WITH paths, collect(DISTINCT n) AS unique_nodes
+ UNWIND unique_nodes AS n
+ OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}})
+
+ RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr
+ """,
+ parameters=[],
+)
+
+
+# Network Exposure Queries
+# ------------------------
+
+AWS_EC2_INSTANCES_INTERNET_EXPOSED = AttackPathsQueryDefinition(
+ id="aws-ec2-instances-internet-exposed",
+ name="Internet-Exposed EC2 Instances",
+ short_description="Find EC2 instances flagged as exposed to the internet.",
+ description="Find EC2 instances flagged as exposed to the internet within the selected account.",
+ provider="aws",
+ cypher=f"""
+ MATCH path = (aws:AWSAccount {{id: $provider_uid}})--(ec2:EC2Instance)
+ WHERE ec2.exposed_internet = true
+
+ OPTIONAL MATCH (internet:Internet)-[can_access:CAN_ACCESS]->(ec2)
+
+ WITH collect(path) AS paths, head(collect(internet)) AS internet, collect(can_access) AS can_access
+ UNWIND paths AS p
+ UNWIND nodes(p) AS n
+
+ WITH paths, internet, can_access, collect(DISTINCT n) AS unique_nodes
+ UNWIND unique_nodes AS n
+ OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}})
+
+ RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr, internet, can_access
+ """,
+ parameters=[],
+)
+
+AWS_SECURITY_GROUPS_OPEN_INTERNET_FACING = AttackPathsQueryDefinition(
+ id="aws-security-groups-open-internet-facing",
+ name="Open Security Groups on Internet-Facing Resources",
+ short_description="Find internet-facing resources with security groups allowing inbound from 0.0.0.0/0.",
+ description="Find internet-facing resources associated with security groups that allow inbound access from '0.0.0.0/0'.",
+ provider="aws",
+ cypher=f"""
+ MATCH path = (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"
+
+ OPTIONAL MATCH (internet:Internet)-[can_access:CAN_ACCESS]->(ec2)
+
+ WITH collect(path) AS paths, head(collect(internet)) AS internet, collect(can_access) AS can_access
+ UNWIND paths AS p
+ UNWIND nodes(p) AS n
+
+ WITH paths, internet, can_access, collect(DISTINCT n) AS unique_nodes
+ UNWIND unique_nodes AS n
+ OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}})
+
+ RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr, internet, can_access
+ """,
+ parameters=[],
+)
+
+AWS_CLASSIC_ELB_INTERNET_EXPOSED = AttackPathsQueryDefinition(
+ id="aws-classic-elb-internet-exposed",
+ name="Internet-Exposed Classic Load Balancers",
+ short_description="Find Classic Load Balancers exposed to the internet with their listeners.",
+ description="Find Classic Load Balancers exposed to the internet along with their listeners.",
+ provider="aws",
+ cypher=f"""
+ MATCH path = (aws:AWSAccount {{id: $provider_uid}})--(elb:LoadBalancer)--(listener:ELBListener)
+ WHERE elb.exposed_internet = true
+
+ OPTIONAL MATCH (internet:Internet)-[can_access:CAN_ACCESS]->(elb)
+
+ WITH collect(path) AS paths, head(collect(internet)) AS internet, collect(can_access) AS can_access
+ UNWIND paths AS p
+ UNWIND nodes(p) AS n
+
+ WITH paths, internet, can_access, collect(DISTINCT n) AS unique_nodes
+ UNWIND unique_nodes AS n
+ OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}})
+
+ RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr, internet, can_access
+ """,
+ parameters=[],
+)
+
+AWS_ELBV2_INTERNET_EXPOSED = AttackPathsQueryDefinition(
+ id="aws-elbv2-internet-exposed",
+ name="Internet-Exposed ALB/NLB Load Balancers",
+ short_description="Find ELBv2 (ALB/NLB) load balancers exposed to the internet with their listeners.",
+ description="Find ELBv2 load balancers exposed to the internet along with their listeners.",
+ provider="aws",
+ cypher=f"""
+ MATCH path = (aws:AWSAccount {{id: $provider_uid}})--(elbv2:LoadBalancerV2)--(listener:ELBV2Listener)
+ WHERE elbv2.exposed_internet = true
+
+ OPTIONAL MATCH (internet:Internet)-[can_access:CAN_ACCESS]->(elbv2)
+
+ WITH collect(path) AS paths, head(collect(internet)) AS internet, collect(can_access) AS can_access
+ UNWIND paths AS p
+ UNWIND nodes(p) AS n
+
+ WITH paths, internet, can_access, collect(DISTINCT n) AS unique_nodes
+ UNWIND unique_nodes AS n
+ OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}})
+
+ RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr, internet, can_access
+ """,
+ parameters=[],
+)
+
+AWS_PUBLIC_IP_RESOURCE_LOOKUP = AttackPathsQueryDefinition(
+ id="aws-public-ip-resource-lookup",
+ name="Resource Lookup by Public IP",
+ short_description="Find the AWS resource associated with a given 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=f"""
+ MATCH path = (aws:AWSAccount {{id: $provider_uid}})-[r]-(x)-[q]-(y)
+ WHERE (x:EC2PrivateIp AND x.public_ip = $ip)
+ OR (x:EC2Instance AND x.publicipaddress = $ip)
+ OR (x:NetworkInterface AND x.public_ip = $ip)
+ OR (x:ElasticIPAddress AND x.public_ip = $ip)
+
+ OPTIONAL MATCH (internet:Internet)-[can_access:CAN_ACCESS]->(x)
+
+ WITH collect(path) AS paths, head(collect(internet)) AS internet, collect(can_access) AS can_access
+ UNWIND paths AS p
+ UNWIND nodes(p) AS n
+
+ WITH paths, internet, can_access, collect(DISTINCT n) AS unique_nodes
+ UNWIND unique_nodes AS n
+ OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}})
+
+ RETURN paths, 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
+# -------------------------------------------------------------------
+
+# APPRUNNER-001
+AWS_APPRUNNER_PRIVESC_PASSROLE_CREATE_SERVICE = AttackPathsQueryDefinition(
+ id="aws-apprunner-privesc-passrole-create-service",
+ name="App Runner Service Creation with Privileged Role (APPRUNNER-001)",
+ short_description="Create an App Runner service with a privileged IAM role to gain its permissions.",
+ description="Detect principals who can pass IAM roles and create App Runner services. This allows creating a service with a privileged role attached, gaining that role's permissions via StartCommand execution, a container web shell, or a malicious apprunner.yaml configuration.",
+ attribution=AttackPathsQueryAttribution(
+ text="pathfinding.cloud - APPRUNNER-001 - iam:PassRole + apprunner:CreateService",
+ link="https://pathfinding.cloud/paths/apprunner-001",
+ ),
+ provider="aws",
+ cypher=f"""
+ // Find principals with iam:PassRole permission
+ MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(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 apprunner:CreateService permission
+ MATCH (principal)--(apprunner_policy:AWSPolicy)--(stmt_apprunner:AWSPolicyStatement)
+ WHERE stmt_apprunner.effect = 'Allow'
+ AND any(action IN stmt_apprunner.action WHERE
+ toLower(action) = 'apprunner:createservice'
+ OR toLower(action) = 'apprunner:*'
+ OR action = '*'
+ )
+
+ // Find roles that trust App Runner tasks service (can be passed to App Runner)
+ MATCH path_target = (aws)--(target_role:AWSRole)-[:TRUSTS_AWS_PRINCIPAL]->(:AWSPrincipal {{arn: 'tasks.apprunner.amazonaws.com'}})
+ WHERE any(resource IN stmt_passrole.resource WHERE
+ resource = '*'
+ OR target_role.arn CONTAINS resource
+ OR resource CONTAINS target_role.name
+ )
+
+ WITH collect(path_principal) + collect(path_target) AS paths
+ UNWIND paths AS p
+ UNWIND nodes(p) AS n
+
+ WITH paths, collect(DISTINCT n) AS unique_nodes
+ UNWIND unique_nodes AS n
+
+ OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}})
+
+ RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr
+ """,
+ parameters=[],
+)
+
+# APPRUNNER-002
+AWS_APPRUNNER_PRIVESC_UPDATE_SERVICE = AttackPathsQueryDefinition(
+ id="aws-apprunner-privesc-update-service",
+ name="App Runner Service Update for Role Access (APPRUNNER-002)",
+ short_description="Update an existing App Runner service to leverage its already-attached privileged role.",
+ description="Detect principals who can update existing App Runner services. This allows modifying a service's configuration to execute arbitrary code with the service's already-attached IAM role, without requiring iam:PassRole. Exploitation methods include injecting a malicious StartCommand, updating to a container image with a web shell, or pointing to a repository with a malicious apprunner.yaml file.",
+ attribution=AttackPathsQueryAttribution(
+ text="pathfinding.cloud - APPRUNNER-002 - apprunner:UpdateService",
+ link="https://pathfinding.cloud/paths/apprunner-002",
+ ),
+ provider="aws",
+ cypher=f"""
+ // Find principals with apprunner:UpdateService permission
+ MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(update_policy:AWSPolicy)--(stmt_update:AWSPolicyStatement)
+ WHERE stmt_update.effect = 'Allow'
+ AND any(action IN stmt_update.action WHERE
+ toLower(action) = 'apprunner:updateservice'
+ OR toLower(action) = 'apprunner:*'
+ OR action = '*'
+ )
+
+ // Find existing App Runner services with roles attached (potential targets)
+ MATCH path_target = (aws)--(target_role:AWSRole)-[:TRUSTS_AWS_PRINCIPAL]->(:AWSPrincipal {{arn: 'tasks.apprunner.amazonaws.com'}})
+
+ WITH collect(path_principal) + collect(path_target) AS paths
+ UNWIND paths AS p
+ UNWIND nodes(p) AS n
+
+ WITH paths, collect(DISTINCT n) AS unique_nodes
+ UNWIND unique_nodes AS n
+
+ OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}})
+
+ RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr
+ """,
+ parameters=[],
+)
+
+# BEDROCK-001
+AWS_BEDROCK_PRIVESC_PASSROLE_CODE_INTERPRETER = AttackPathsQueryDefinition(
+ id="aws-bedrock-privesc-passrole-code-interpreter",
+ name="Bedrock Code Interpreter with Privileged Role (BEDROCK-001)",
+ short_description="Create a Bedrock AgentCore Code Interpreter with a privileged role attached.",
+ description="Detect principals who can pass IAM roles and create Bedrock AgentCore Code Interpreters. This allows creating a code interpreter with a privileged role attached, gaining that role's permissions.",
+ attribution=AttackPathsQueryAttribution(
+ text="pathfinding.cloud - BEDROCK-001 - iam:PassRole + bedrock-agentcore:CreateCodeInterpreter + bedrock-agentcore:StartCodeInterpreterSession + bedrock-agentcore:InvokeCodeInterpreter",
+ link="https://pathfinding.cloud/paths/bedrock-001",
+ ),
+ provider="aws",
+ cypher=f"""
+ // Find principals with iam:PassRole permission
+ MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(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 bedrock-agentcore:CreateCodeInterpreter permission
+ MATCH (principal)--(bedrock_policy:AWSPolicy)--(stmt_bedrock:AWSPolicyStatement)
+ WHERE stmt_bedrock.effect = 'Allow'
+ AND any(action IN stmt_bedrock.action WHERE
+ toLower(action) = 'bedrock-agentcore:createcodeinterpreter'
+ OR toLower(action) = 'bedrock-agentcore:*'
+ OR action = '*'
+ )
+
+ // Find bedrock-agentcore:StartCodeInterpreterSession permission
+ MATCH (principal)--(session_policy:AWSPolicy)--(stmt_session:AWSPolicyStatement)
+ WHERE stmt_session.effect = 'Allow'
+ AND any(action IN stmt_session.action WHERE
+ toLower(action) = 'bedrock-agentcore:startcodeinterpretersession'
+ OR toLower(action) = 'bedrock-agentcore:*'
+ OR action = '*'
+ )
+
+ // Find bedrock-agentcore:InvokeCodeInterpreter permission
+ MATCH (principal)--(invoke_policy:AWSPolicy)--(stmt_invoke:AWSPolicyStatement)
+ WHERE stmt_invoke.effect = 'Allow'
+ AND any(action IN stmt_invoke.action WHERE
+ toLower(action) = 'bedrock-agentcore:invokecodeinterpreter'
+ OR toLower(action) = 'bedrock-agentcore:*'
+ OR action = '*'
+ )
+
+ // Find roles that trust the Bedrock AgentCore service (can be passed to a code interpreter)
+ MATCH path_target = (aws)--(target_role:AWSRole)-[:TRUSTS_AWS_PRINCIPAL]->(:AWSPrincipal {{arn: 'bedrock-agentcore.amazonaws.com'}})
+ WHERE any(resource IN stmt_passrole.resource WHERE
+ resource = '*'
+ OR target_role.arn CONTAINS resource
+ OR resource CONTAINS target_role.name
+ )
+
+ WITH collect(path_principal) + collect(path_target) AS paths
+ UNWIND paths AS p
+ UNWIND nodes(p) AS n
+
+ WITH paths, collect(DISTINCT n) AS unique_nodes
+ UNWIND unique_nodes AS n
+
+ OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}})
+
+ RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr
+ """,
+ parameters=[],
+)
+
+# BEDROCK-002
+AWS_BEDROCK_PRIVESC_INVOKE_CODE_INTERPRETER = AttackPathsQueryDefinition(
+ id="aws-bedrock-privesc-invoke-code-interpreter",
+ name="Bedrock Code Interpreter Session Hijacking (BEDROCK-002)",
+ short_description="Start a session on an existing Bedrock code interpreter to exfiltrate its privileged role credentials.",
+ description="Detect principals who can start sessions and invoke code on existing Bedrock AgentCore code interpreters. This allows executing arbitrary Python code within an interpreter that has a privileged role attached, gaining that role's credentials via the MicroVM Metadata Service without requiring iam:PassRole.",
+ attribution=AttackPathsQueryAttribution(
+ text="pathfinding.cloud - BEDROCK-002 - bedrock-agentcore:StartCodeInterpreterSession + bedrock-agentcore:InvokeCodeInterpreter",
+ link="https://pathfinding.cloud/paths/bedrock-002",
+ ),
+ provider="aws",
+ cypher=f"""
+ // Find principals with bedrock-agentcore:StartCodeInterpreterSession permission
+ MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(session_policy:AWSPolicy)--(stmt_session:AWSPolicyStatement)
+ WHERE stmt_session.effect = 'Allow'
+ AND any(action IN stmt_session.action WHERE
+ toLower(action) = 'bedrock-agentcore:startcodeinterpretersession'
+ OR toLower(action) = 'bedrock-agentcore:*'
+ OR action = '*'
+ )
+
+ // Find bedrock-agentcore:InvokeCodeInterpreter permission
+ MATCH (principal)--(invoke_policy:AWSPolicy)--(stmt_invoke:AWSPolicyStatement)
+ WHERE stmt_invoke.effect = 'Allow'
+ AND any(action IN stmt_invoke.action WHERE
+ toLower(action) = 'bedrock-agentcore:invokecodeinterpreter'
+ OR toLower(action) = 'bedrock-agentcore:*'
+ OR action = '*'
+ )
+
+ // Find roles that trust the Bedrock AgentCore service (already attached to existing code interpreters)
+ MATCH path_target = (aws)--(target_role:AWSRole)-[:TRUSTS_AWS_PRINCIPAL]->(:AWSPrincipal {{arn: 'bedrock-agentcore.amazonaws.com'}})
+
+ WITH collect(path_principal) + collect(path_target) AS paths
+ UNWIND paths AS p
+ UNWIND nodes(p) AS n
+
+ WITH paths, collect(DISTINCT n) AS unique_nodes
+ UNWIND unique_nodes AS n
+
+ OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}})
+
+ RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr
+ """,
+ parameters=[],
+)
+
+# CLOUDFORMATION-001
+AWS_CLOUDFORMATION_PRIVESC_PASSROLE_CREATE_STACK = AttackPathsQueryDefinition(
+ id="aws-cloudformation-privesc-passrole-create-stack",
+ name="CloudFormation Stack Creation with Privileged Role (CLOUDFORMATION-001)",
+ short_description="Create a CloudFormation stack with a privileged role to provision arbitrary AWS resources.",
+ description="Detect principals who can pass IAM roles and create CloudFormation stacks. This allows launching a stack with a malicious template that executes with the passed role's permissions, enabling creation of resources like IAM users, Lambda functions, or EC2 instances controlled by the attacker.",
+ attribution=AttackPathsQueryAttribution(
+ text="pathfinding.cloud - CLOUDFORMATION-001 - iam:PassRole + cloudformation:CreateStack",
+ link="https://pathfinding.cloud/paths/cloudformation-001",
+ ),
+ provider="aws",
+ cypher=f"""
+ // Find principals with iam:PassRole permission
+ MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(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 cloudformation:CreateStack permission
+ MATCH (principal)--(cfn_policy:AWSPolicy)--(stmt_cfn:AWSPolicyStatement)
+ WHERE stmt_cfn.effect = 'Allow'
+ AND any(action IN stmt_cfn.action WHERE
+ toLower(action) = 'cloudformation:createstack'
+ OR toLower(action) = 'cloudformation:*'
+ OR action = '*'
+ )
+
+ // Find roles that trust CloudFormation service (can be passed to CloudFormation)
+ MATCH path_target = (aws)--(target_role:AWSRole)-[:TRUSTS_AWS_PRINCIPAL]->(:AWSPrincipal {{arn: 'cloudformation.amazonaws.com'}})
+ WHERE any(resource IN stmt_passrole.resource WHERE
+ resource = '*'
+ OR target_role.arn CONTAINS resource
+ OR resource CONTAINS target_role.name
+ )
+
+ WITH collect(path_principal) + collect(path_target) AS paths
+ UNWIND paths AS p
+ UNWIND nodes(p) AS n
+
+ WITH paths, collect(DISTINCT n) AS unique_nodes
+ UNWIND unique_nodes AS n
+
+ OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}})
+
+ RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr
+ """,
+ parameters=[],
+)
+
+# CLOUDFORMATION-002
+AWS_CLOUDFORMATION_PRIVESC_UPDATE_STACK = AttackPathsQueryDefinition(
+ id="aws-cloudformation-privesc-update-stack",
+ name="CloudFormation Stack Update for Role Access (CLOUDFORMATION-002)",
+ short_description="Update an existing CloudFormation stack to leverage its already-attached privileged service role.",
+ description="Detect principals who can update existing CloudFormation stacks. This allows modifying a stack's template to add new resources (such as IAM roles with admin access) that are created with the stack's already-attached service role permissions, without requiring iam:PassRole.",
+ attribution=AttackPathsQueryAttribution(
+ text="pathfinding.cloud - CLOUDFORMATION-002 - cloudformation:UpdateStack",
+ link="https://pathfinding.cloud/paths/cloudformation-002",
+ ),
+ provider="aws",
+ cypher=f"""
+ // Find principals with cloudformation:UpdateStack permission
+ MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(update_policy:AWSPolicy)--(stmt_update:AWSPolicyStatement)
+ WHERE stmt_update.effect = 'Allow'
+ AND any(action IN stmt_update.action WHERE
+ toLower(action) = 'cloudformation:updatestack'
+ OR toLower(action) = 'cloudformation:*'
+ OR action = '*'
+ )
+
+ // Find roles that trust CloudFormation service (already attached to existing stacks)
+ MATCH path_target = (aws)--(target_role:AWSRole)-[:TRUSTS_AWS_PRINCIPAL]->(:AWSPrincipal {{arn: 'cloudformation.amazonaws.com'}})
+
+ WITH collect(path_principal) + collect(path_target) AS paths
+ UNWIND paths AS p
+ UNWIND nodes(p) AS n
+
+ WITH paths, collect(DISTINCT n) AS unique_nodes
+ UNWIND unique_nodes AS n
+
+ OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}})
+
+ RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr
+ """,
+ parameters=[],
+)
+
+# CLOUDFORMATION-003
+AWS_CLOUDFORMATION_PRIVESC_PASSROLE_CREATE_STACKSET = AttackPathsQueryDefinition(
+ id="aws-cloudformation-privesc-passrole-create-stackset",
+ name="CloudFormation StackSet Creation with Privileged Role (CLOUDFORMATION-003)",
+ short_description="Create a CloudFormation StackSet with a privileged execution role to provision arbitrary resources across accounts.",
+ description="Detect principals who can pass IAM roles, create CloudFormation StackSets, and deploy stack instances. This allows creating a StackSet with a malicious template and a privileged execution role, then deploying instances that create resources (such as IAM roles with admin access) using that role's permissions.",
+ attribution=AttackPathsQueryAttribution(
+ text="pathfinding.cloud - CLOUDFORMATION-003 - iam:PassRole + cloudformation:CreateStackSet + cloudformation:CreateStackInstances",
+ link="https://pathfinding.cloud/paths/cloudformation-003",
+ ),
+ provider="aws",
+ cypher=f"""
+ // Find principals with iam:PassRole permission
+ MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(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 cloudformation:CreateStackSet permission
+ MATCH (principal)--(cfn_policy:AWSPolicy)--(stmt_cfn:AWSPolicyStatement)
+ WHERE stmt_cfn.effect = 'Allow'
+ AND any(action IN stmt_cfn.action WHERE
+ toLower(action) = 'cloudformation:createstackset'
+ OR toLower(action) = 'cloudformation:*'
+ OR action = '*'
+ )
+
+ // Find cloudformation:CreateStackInstances permission
+ MATCH (principal)--(cfn_instances_policy:AWSPolicy)--(stmt_cfn_instances:AWSPolicyStatement)
+ WHERE stmt_cfn_instances.effect = 'Allow'
+ AND any(action IN stmt_cfn_instances.action WHERE
+ toLower(action) = 'cloudformation:createstackinstances'
+ OR toLower(action) = 'cloudformation:*'
+ OR action = '*'
+ )
+
+ // Find roles that trust CloudFormation service (can be passed as execution role)
+ MATCH path_target = (aws)--(target_role:AWSRole)-[:TRUSTS_AWS_PRINCIPAL]->(:AWSPrincipal {{arn: 'cloudformation.amazonaws.com'}})
+ WHERE any(resource IN stmt_passrole.resource WHERE
+ resource = '*'
+ OR target_role.arn CONTAINS resource
+ OR resource CONTAINS target_role.name
+ )
+
+ WITH collect(path_principal) + collect(path_target) AS paths
+ UNWIND paths AS p
+ UNWIND nodes(p) AS n
+
+ WITH paths, collect(DISTINCT n) AS unique_nodes
+ UNWIND unique_nodes AS n
+
+ OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}})
+
+ RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr
+ """,
+ parameters=[],
+)
+
+# CLOUDFORMATION-004
+AWS_CLOUDFORMATION_PRIVESC_PASSROLE_UPDATE_STACKSET = AttackPathsQueryDefinition(
+ id="aws-cloudformation-privesc-passrole-update-stackset",
+ name="CloudFormation StackSet Update with Privileged Role (CLOUDFORMATION-004)",
+ short_description="Update an existing CloudFormation StackSet to inject malicious resources using a privileged execution role.",
+ description="Detect principals who can pass IAM roles and update CloudFormation StackSets. This allows modifying an existing StackSet's template to add resources (such as IAM roles with admin access) that are provisioned by the StackSet's privileged execution role across target accounts.",
+ attribution=AttackPathsQueryAttribution(
+ text="pathfinding.cloud - CLOUDFORMATION-004 - iam:PassRole + cloudformation:UpdateStackSet",
+ link="https://pathfinding.cloud/paths/cloudformation-004",
+ ),
+ provider="aws",
+ cypher=f"""
+ // Find principals with iam:PassRole permission
+ MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(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 cloudformation:UpdateStackSet permission
+ MATCH (principal)--(cfn_policy:AWSPolicy)--(stmt_cfn:AWSPolicyStatement)
+ WHERE stmt_cfn.effect = 'Allow'
+ AND any(action IN stmt_cfn.action WHERE
+ toLower(action) = 'cloudformation:updatestackset'
+ OR toLower(action) = 'cloudformation:*'
+ OR action = '*'
+ )
+
+ // Find roles that trust CloudFormation service (can be passed as execution role)
+ MATCH path_target = (aws)--(target_role:AWSRole)-[:TRUSTS_AWS_PRINCIPAL]->(:AWSPrincipal {{arn: 'cloudformation.amazonaws.com'}})
+ WHERE any(resource IN stmt_passrole.resource WHERE
+ resource = '*'
+ OR target_role.arn CONTAINS resource
+ OR resource CONTAINS target_role.name
+ )
+
+ WITH collect(path_principal) + collect(path_target) AS paths
+ UNWIND paths AS p
+ UNWIND nodes(p) AS n
+
+ WITH paths, collect(DISTINCT n) AS unique_nodes
+ UNWIND unique_nodes AS n
+
+ OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}})
+
+ RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr
+ """,
+ parameters=[],
+)
+
+# CLOUDFORMATION-005
+AWS_CLOUDFORMATION_PRIVESC_CHANGESET = AttackPathsQueryDefinition(
+ id="aws-cloudformation-privesc-changeset",
+ name="CloudFormation Change Set Privilege Escalation (CLOUDFORMATION-005)",
+ short_description="Create and execute a change set on an existing stack to leverage its privileged service role.",
+ description="Detect principals who can create and execute CloudFormation change sets. This allows modifying an existing stack's template through a staged change set, inheriting the stack's already-attached service role permissions to provision arbitrary resources without requiring iam:PassRole.",
+ attribution=AttackPathsQueryAttribution(
+ text="pathfinding.cloud - CLOUDFORMATION-005 - cloudformation:CreateChangeSet + cloudformation:ExecuteChangeSet",
+ link="https://pathfinding.cloud/paths/cloudformation-005",
+ ),
+ provider="aws",
+ cypher=f"""
+ // Find principals with cloudformation:CreateChangeSet permission
+ MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(create_policy:AWSPolicy)--(stmt_create:AWSPolicyStatement)
+ WHERE stmt_create.effect = 'Allow'
+ AND any(action IN stmt_create.action WHERE
+ toLower(action) = 'cloudformation:createchangeset'
+ OR toLower(action) = 'cloudformation:*'
+ OR action = '*'
+ )
+
+ // Find cloudformation:ExecuteChangeSet permission
+ MATCH (principal)--(exec_policy:AWSPolicy)--(stmt_exec:AWSPolicyStatement)
+ WHERE stmt_exec.effect = 'Allow'
+ AND any(action IN stmt_exec.action WHERE
+ toLower(action) = 'cloudformation:executechangeset'
+ OR toLower(action) = 'cloudformation:*'
+ OR action = '*'
+ )
+
+ // Find roles that trust CloudFormation service (already attached to existing stacks)
+ MATCH path_target = (aws)--(target_role:AWSRole)-[:TRUSTS_AWS_PRINCIPAL]->(:AWSPrincipal {{arn: 'cloudformation.amazonaws.com'}})
+
+ WITH collect(path_principal) + collect(path_target) AS paths
+ UNWIND paths AS p
+ UNWIND nodes(p) AS n
+
+ WITH paths, collect(DISTINCT n) AS unique_nodes
+ UNWIND unique_nodes AS n
+
+ OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}})
+
+ RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr
+ """,
+ parameters=[],
+)
+
+# CODEBUILD-001
+AWS_CODEBUILD_PRIVESC_PASSROLE_CREATE_PROJECT = AttackPathsQueryDefinition(
+ id="aws-codebuild-privesc-passrole-create-project",
+ name="CodeBuild Project Creation with Privileged Role (CODEBUILD-001)",
+ short_description="Create a CodeBuild project with a privileged role to execute arbitrary code via a malicious buildspec.",
+ description="Detect principals who can pass IAM roles, create CodeBuild projects, and start builds. This allows creating a project with a privileged role attached and executing arbitrary code through a malicious buildspec, gaining that role's permissions.",
+ attribution=AttackPathsQueryAttribution(
+ text="pathfinding.cloud - CODEBUILD-001 - iam:PassRole + codebuild:CreateProject + codebuild:StartBuild",
+ link="https://pathfinding.cloud/paths/codebuild-001",
+ ),
+ provider="aws",
+ cypher=f"""
+ // Find principals with iam:PassRole permission
+ MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(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 codebuild:CreateProject permission
+ MATCH (principal)--(create_policy:AWSPolicy)--(stmt_create:AWSPolicyStatement)
+ WHERE stmt_create.effect = 'Allow'
+ AND any(action IN stmt_create.action WHERE
+ toLower(action) = 'codebuild:createproject'
+ OR toLower(action) = 'codebuild:*'
+ OR action = '*'
+ )
+
+ // Find codebuild:StartBuild permission
+ MATCH (principal)--(build_policy:AWSPolicy)--(stmt_build:AWSPolicyStatement)
+ WHERE stmt_build.effect = 'Allow'
+ AND any(action IN stmt_build.action WHERE
+ toLower(action) = 'codebuild:startbuild'
+ OR toLower(action) = 'codebuild:*'
+ OR action = '*'
+ )
+
+ // Find roles that trust CodeBuild service (can be passed to CodeBuild)
+ MATCH path_target = (aws)--(target_role:AWSRole)-[:TRUSTS_AWS_PRINCIPAL]->(:AWSPrincipal {{arn: 'codebuild.amazonaws.com'}})
+ WHERE any(resource IN stmt_passrole.resource WHERE
+ resource = '*'
+ OR target_role.arn CONTAINS resource
+ OR resource CONTAINS target_role.name
+ )
+
+ WITH collect(path_principal) + collect(path_target) AS paths
+ UNWIND paths AS p
+ UNWIND nodes(p) AS n
+
+ WITH paths, collect(DISTINCT n) AS unique_nodes
+ UNWIND unique_nodes AS n
+
+ OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}})
+
+ RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr
+ """,
+ parameters=[],
+)
+
+# CODEBUILD-002
+AWS_CODEBUILD_PRIVESC_START_BUILD = AttackPathsQueryDefinition(
+ id="aws-codebuild-privesc-start-build",
+ name="CodeBuild Buildspec Override for Role Access (CODEBUILD-002)",
+ short_description="Start a build on an existing CodeBuild project with a buildspec override to execute code with its privileged role.",
+ description="Detect principals who can start builds on existing CodeBuild projects. This allows overriding the buildspec with malicious commands that execute with the project's already-attached service role permissions, without requiring iam:PassRole or codebuild:CreateProject.",
+ attribution=AttackPathsQueryAttribution(
+ text="pathfinding.cloud - CODEBUILD-002 - codebuild:StartBuild",
+ link="https://pathfinding.cloud/paths/codebuild-002",
+ ),
+ provider="aws",
+ cypher=f"""
+ // Find principals with codebuild:StartBuild permission
+ MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(build_policy:AWSPolicy)--(stmt_build:AWSPolicyStatement)
+ WHERE stmt_build.effect = 'Allow'
+ AND any(action IN stmt_build.action WHERE
+ toLower(action) = 'codebuild:startbuild'
+ OR toLower(action) = 'codebuild:*'
+ OR action = '*'
+ )
+
+ // Find roles that trust CodeBuild service (already attached to existing projects)
+ MATCH path_target = (aws)--(target_role:AWSRole)-[:TRUSTS_AWS_PRINCIPAL]->(:AWSPrincipal {{arn: 'codebuild.amazonaws.com'}})
+
+ WITH collect(path_principal) + collect(path_target) AS paths
+ UNWIND paths AS p
+ UNWIND nodes(p) AS n
+
+ WITH paths, collect(DISTINCT n) AS unique_nodes
+ UNWIND unique_nodes AS n
+
+ OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}})
+
+ RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr
+ """,
+ parameters=[],
+)
+
+# CODEBUILD-003
+AWS_CODEBUILD_PRIVESC_START_BUILD_BATCH = AttackPathsQueryDefinition(
+ id="aws-codebuild-privesc-start-build-batch",
+ name="CodeBuild Batch Buildspec Override for Role Access (CODEBUILD-003)",
+ short_description="Start a batch build on an existing CodeBuild project with a buildspec override to execute code with its privileged role.",
+ description="Detect principals who can start batch builds on existing CodeBuild projects. This allows overriding the buildspec with malicious commands that execute with the project's already-attached service role permissions, without requiring iam:PassRole or codebuild:CreateProject.",
+ attribution=AttackPathsQueryAttribution(
+ text="pathfinding.cloud - CODEBUILD-003 - codebuild:StartBuildBatch",
+ link="https://pathfinding.cloud/paths/codebuild-003",
+ ),
+ provider="aws",
+ cypher=f"""
+ // Find principals with codebuild:StartBuildBatch permission
+ MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(build_policy:AWSPolicy)--(stmt_build:AWSPolicyStatement)
+ WHERE stmt_build.effect = 'Allow'
+ AND any(action IN stmt_build.action WHERE
+ toLower(action) = 'codebuild:startbuildbatch'
+ OR toLower(action) = 'codebuild:*'
+ OR action = '*'
+ )
+
+ // Find roles that trust CodeBuild service (already attached to existing projects)
+ MATCH path_target = (aws)--(target_role:AWSRole)-[:TRUSTS_AWS_PRINCIPAL]->(:AWSPrincipal {{arn: 'codebuild.amazonaws.com'}})
+
+ WITH collect(path_principal) + collect(path_target) AS paths
+ UNWIND paths AS p
+ UNWIND nodes(p) AS n
+
+ WITH paths, collect(DISTINCT n) AS unique_nodes
+ UNWIND unique_nodes AS n
+
+ OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}})
+
+ RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr
+ """,
+ parameters=[],
+)
+
+# CODEBUILD-004
+AWS_CODEBUILD_PRIVESC_PASSROLE_CREATE_PROJECT_BATCH = AttackPathsQueryDefinition(
+ id="aws-codebuild-privesc-passrole-create-project-batch",
+ name="CodeBuild Batch Project Creation with Privileged Role (CODEBUILD-004)",
+ short_description="Create a CodeBuild project configured for batch builds with a privileged role to execute arbitrary code via a malicious buildspec.",
+ description="Detect principals who can pass IAM roles, create CodeBuild projects, and start batch builds. This allows creating a project with a privileged role attached and executing arbitrary code through a malicious batch buildspec, gaining that role's permissions.",
+ attribution=AttackPathsQueryAttribution(
+ text="pathfinding.cloud - CODEBUILD-004 - iam:PassRole + codebuild:CreateProject + codebuild:StartBuildBatch",
+ link="https://pathfinding.cloud/paths/codebuild-004",
+ ),
+ provider="aws",
+ cypher=f"""
+ // Find principals with iam:PassRole permission
+ MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(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 codebuild:CreateProject permission
+ MATCH (principal)--(create_policy:AWSPolicy)--(stmt_create:AWSPolicyStatement)
+ WHERE stmt_create.effect = 'Allow'
+ AND any(action IN stmt_create.action WHERE
+ toLower(action) = 'codebuild:createproject'
+ OR toLower(action) = 'codebuild:*'
+ OR action = '*'
+ )
+
+ // Find codebuild:StartBuildBatch permission
+ MATCH (principal)--(batch_policy:AWSPolicy)--(stmt_batch:AWSPolicyStatement)
+ WHERE stmt_batch.effect = 'Allow'
+ AND any(action IN stmt_batch.action WHERE
+ toLower(action) = 'codebuild:startbuildbatch'
+ OR toLower(action) = 'codebuild:*'
+ OR action = '*'
+ )
+
+ // Find roles that trust CodeBuild service (can be passed to CodeBuild)
+ MATCH path_target = (aws)--(target_role:AWSRole)-[:TRUSTS_AWS_PRINCIPAL]->(:AWSPrincipal {{arn: 'codebuild.amazonaws.com'}})
+ WHERE any(resource IN stmt_passrole.resource WHERE
+ resource = '*'
+ OR target_role.arn CONTAINS resource
+ OR resource CONTAINS target_role.name
+ )
+
+ WITH collect(path_principal) + collect(path_target) AS paths
+ UNWIND paths AS p
+ UNWIND nodes(p) AS n
+
+ WITH paths, collect(DISTINCT n) AS unique_nodes
+ UNWIND unique_nodes AS n
+
+ OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}})
+
+ RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr
+ """,
+ parameters=[],
+)
+
+# DATAPIPELINE-001
+AWS_DATAPIPELINE_PRIVESC_PASSROLE_CREATE_PIPELINE = AttackPathsQueryDefinition(
+ id="aws-datapipeline-privesc-passrole-create-pipeline",
+ name="Data Pipeline Creation with Privileged Role (DATAPIPELINE-001)",
+ short_description="Create a Data Pipeline with a privileged role to execute arbitrary commands on provisioned infrastructure.",
+ description="Detect principals who can pass IAM roles, create Data Pipelines, define pipeline objects, and activate them. This allows creating a pipeline with a privileged role attached and executing arbitrary commands on the provisioned EC2 instances or EMR clusters, gaining that role's permissions.",
+ attribution=AttackPathsQueryAttribution(
+ text="pathfinding.cloud - DATAPIPELINE-001 - iam:PassRole + datapipeline:CreatePipeline + datapipeline:PutPipelineDefinition + datapipeline:ActivatePipeline",
+ link="https://pathfinding.cloud/paths/datapipeline-001",
+ ),
+ provider="aws",
+ cypher=f"""
+ // Find principals with iam:PassRole permission
+ MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(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 datapipeline:CreatePipeline permission
+ MATCH (principal)--(create_policy:AWSPolicy)--(stmt_create:AWSPolicyStatement)
+ WHERE stmt_create.effect = 'Allow'
+ AND any(action IN stmt_create.action WHERE
+ toLower(action) = 'datapipeline:createpipeline'
+ OR toLower(action) = 'datapipeline:*'
+ OR action = '*'
+ )
+
+ // Find datapipeline:PutPipelineDefinition permission
+ MATCH (principal)--(put_policy:AWSPolicy)--(stmt_put:AWSPolicyStatement)
+ WHERE stmt_put.effect = 'Allow'
+ AND any(action IN stmt_put.action WHERE
+ toLower(action) = 'datapipeline:putpipelinedefinition'
+ OR toLower(action) = 'datapipeline:*'
+ OR action = '*'
+ )
+
+ // Find datapipeline:ActivatePipeline permission
+ MATCH (principal)--(activate_policy:AWSPolicy)--(stmt_activate:AWSPolicyStatement)
+ WHERE stmt_activate.effect = 'Allow'
+ AND any(action IN stmt_activate.action WHERE
+ toLower(action) = 'datapipeline:activatepipeline'
+ OR toLower(action) = 'datapipeline:*'
+ OR action = '*'
+ )
+
+ // Find roles that trust Data Pipeline or EMR service (can be passed to DataPipeline)
+ MATCH path_target = (aws)--(target_role:AWSRole)-[:TRUSTS_AWS_PRINCIPAL]->(trusted_principal:AWSPrincipal)
+ WHERE trusted_principal.arn IN ['datapipeline.amazonaws.com', 'elasticmapreduce.amazonaws.com']
+ AND any(resource IN stmt_passrole.resource WHERE
+ resource = '*'
+ OR target_role.arn CONTAINS resource
+ OR resource CONTAINS target_role.name
+ )
+
+ WITH collect(path_principal) + collect(path_target) AS paths
+ UNWIND paths AS p
+ UNWIND nodes(p) AS n
+
+ WITH paths, collect(DISTINCT n) AS unique_nodes
+ UNWIND unique_nodes AS n
+
+ OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}})
+
+ RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr
+ """,
+ parameters=[],
+)
+
+# EC2-001
+AWS_EC2_PRIVESC_PASSROLE_IAM = AttackPathsQueryDefinition(
+ id="aws-ec2-privesc-passrole-iam",
+ name="EC2 Instance Launch with Privileged Role (EC2-001)",
+ short_description="Launch EC2 instances with privileged IAM roles to gain their permissions via IMDS.",
+ 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.",
+ attribution=AttackPathsQueryAttribution(
+ text="pathfinding.cloud - EC2-001 - iam:PassRole + ec2:RunInstances",
+ link="https://pathfinding.cloud/paths/ec2-001",
+ ),
+ provider="aws",
+ cypher=f"""
+ // Find principals with iam:PassRole permission
+ MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(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 ec2:RunInstances permission
+ MATCH (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)-[:TRUSTS_AWS_PRINCIPAL]->(:AWSPrincipal {{arn: 'ec2.amazonaws.com'}})
+ WHERE any(resource IN stmt_passrole.resource WHERE
+ resource = '*'
+ OR target_role.arn CONTAINS resource
+ OR resource CONTAINS target_role.name
+ )
+
+ WITH collect(path_principal) + collect(path_target) AS paths
+ UNWIND paths AS p
+ UNWIND nodes(p) AS n
+
+ WITH paths, collect(DISTINCT n) AS unique_nodes
+ UNWIND unique_nodes AS n
+
+ OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}})
+
+ RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr
+ """,
+ parameters=[],
+)
+
+# EC2-002
+AWS_EC2_PRIVESC_MODIFY_INSTANCE_ATTRIBUTE = AttackPathsQueryDefinition(
+ id="aws-ec2-privesc-modify-instance-attribute",
+ name="EC2 Role Hijacking via UserData Injection (EC2-002)",
+ short_description="Inject malicious scripts into EC2 instance userData to gain the attached role's permissions.",
+ description="Detect principals who can modify EC2 instance userData, stop, and start instances. This allows injecting malicious scripts that execute on instance restart, gaining the permissions of the instance's attached IAM role.",
+ attribution=AttackPathsQueryAttribution(
+ text="pathfinding.cloud - EC2-002 - ec2:ModifyInstanceAttribute + ec2:StopInstances + ec2:StartInstances",
+ link="https://pathfinding.cloud/paths/ec2-002",
+ ),
+ provider="aws",
+ cypher=f"""
+ // Find principals with ec2:ModifyInstanceAttribute permission
+ MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(modify_policy:AWSPolicy)--(stmt_modify:AWSPolicyStatement)
+ WHERE stmt_modify.effect = 'Allow'
+ AND any(action IN stmt_modify.action WHERE
+ toLower(action) = 'ec2:modifyinstanceattribute'
+ OR toLower(action) = 'ec2:*'
+ OR action = '*'
+ )
+
+ // Find ec2:StopInstances permission (can be same or different policy)
+ MATCH (principal)--(stop_policy:AWSPolicy)--(stmt_stop:AWSPolicyStatement)
+ WHERE stmt_stop.effect = 'Allow'
+ AND any(action IN stmt_stop.action WHERE
+ toLower(action) = 'ec2:stopinstances'
+ OR toLower(action) = 'ec2:*'
+ OR action = '*'
+ )
+
+ // Find ec2:StartInstances permission (can be same or different policy)
+ MATCH (principal)--(start_policy:AWSPolicy)--(stmt_start:AWSPolicyStatement)
+ WHERE stmt_start.effect = 'Allow'
+ AND any(action IN stmt_start.action WHERE
+ toLower(action) = 'ec2:startinstances'
+ OR toLower(action) = 'ec2:*'
+ OR action = '*'
+ )
+
+ // Find EC2 instances with instance profiles (potential targets)
+ MATCH path_target = (aws)--(ec2:EC2Instance)-[:STS_ASSUMEROLE_ALLOW]->(target_role:AWSRole)
+
+ WITH collect(path_principal) + collect(path_target) AS paths
+ UNWIND paths AS p
+ UNWIND nodes(p) AS n
+
+ WITH paths, collect(DISTINCT n) AS unique_nodes
+ UNWIND unique_nodes AS n
+
+ OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}})
+
+ RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr
+ """,
+ parameters=[],
+)
+
+# EC2-003
+AWS_EC2_PRIVESC_PASSROLE_SPOT_INSTANCES = AttackPathsQueryDefinition(
+ id="aws-ec2-privesc-passrole-spot-instances",
+ name="Spot Instance Launch with Privileged Role (EC2-003)",
+ short_description="Launch EC2 Spot Instances with privileged IAM roles to gain their permissions via IMDS.",
+ description="Detect principals who can pass IAM roles and request EC2 Spot Instances. This allows launching a spot instance with a privileged role attached, gaining that role's permissions via the instance metadata service.",
+ attribution=AttackPathsQueryAttribution(
+ text="pathfinding.cloud - EC2-003 - iam:PassRole + ec2:RequestSpotInstances",
+ link="https://pathfinding.cloud/paths/ec2-003",
+ ),
+ provider="aws",
+ cypher=f"""
+ // Find principals with iam:PassRole permission
+ MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(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 ec2:RequestSpotInstances permission
+ MATCH (principal)--(spot_policy:AWSPolicy)--(stmt_spot:AWSPolicyStatement)
+ WHERE stmt_spot.effect = 'Allow'
+ AND any(action IN stmt_spot.action WHERE
+ toLower(action) = 'ec2:requestspotinstances'
+ OR toLower(action) = 'ec2:*'
+ OR action = '*'
+ )
+
+ // Find roles that trust EC2 service (can be passed to EC2 spot instances)
+ MATCH path_target = (aws)--(target_role:AWSRole)-[:TRUSTS_AWS_PRINCIPAL]->(:AWSPrincipal {{arn: 'ec2.amazonaws.com'}})
+ WHERE any(resource IN stmt_passrole.resource WHERE
+ resource = '*'
+ OR target_role.arn CONTAINS resource
+ OR resource CONTAINS target_role.name
+ )
+
+ WITH collect(path_principal) + collect(path_target) AS paths
+ UNWIND paths AS p
+ UNWIND nodes(p) AS n
+
+ WITH paths, collect(DISTINCT n) AS unique_nodes
+ UNWIND unique_nodes AS n
+
+ OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}})
+
+ RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr
+ """,
+ parameters=[],
+)
+
+# EC2-004
+AWS_EC2_PRIVESC_LAUNCH_TEMPLATE = AttackPathsQueryDefinition(
+ id="aws-ec2-privesc-launch-template",
+ name="Launch Template Poisoning for Role Access (EC2-004)",
+ short_description="Inject malicious userData into launch templates that reference privileged roles, no PassRole needed.",
+ description="Detect principals who can create new launch template versions and modify launch templates. This allows injecting malicious user data into existing templates that already reference privileged IAM roles, without requiring iam:PassRole permissions.",
+ attribution=AttackPathsQueryAttribution(
+ text="pathfinding.cloud - EC2-004 - ec2:CreateLaunchTemplateVersion + ec2:ModifyLaunchTemplate",
+ link="https://pathfinding.cloud/paths/ec2-004",
+ ),
+ provider="aws",
+ cypher=f"""
+ // Find principals with ec2:CreateLaunchTemplateVersion permission
+ MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(create_policy:AWSPolicy)--(stmt_create:AWSPolicyStatement)
+ WHERE stmt_create.effect = 'Allow'
+ AND any(action IN stmt_create.action WHERE
+ toLower(action) = 'ec2:createlaunchtemplateversion'
+ OR toLower(action) = 'ec2:*'
+ OR action = '*'
+ )
+
+ // Find ec2:ModifyLaunchTemplate permission
+ MATCH (principal)--(modify_policy:AWSPolicy)--(stmt_modify:AWSPolicyStatement)
+ WHERE stmt_modify.effect = 'Allow'
+ AND any(action IN stmt_modify.action WHERE
+ toLower(action) = 'ec2:modifylaunchtemplate'
+ OR toLower(action) = 'ec2:*'
+ OR action = '*'
+ )
+
+ // Find launch templates in the account (potential targets)
+ MATCH path_target = (aws)--(template:LaunchTemplate)
+
+ WITH collect(path_principal) + collect(path_target) AS paths
+ UNWIND paths AS p
+ UNWIND nodes(p) AS n
+
+ WITH paths, collect(DISTINCT n) AS unique_nodes
+ UNWIND unique_nodes AS n
+
+ OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}})
+
+ RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr
+ """,
+ parameters=[],
+)
+
+# EC2INSTANCECONNECT-003
+AWS_EC2INSTANCECONNECT_PRIVESC_SEND_SSH_PUBLIC_KEY = AttackPathsQueryDefinition(
+ id="aws-ec2instanceconnect-privesc-send-ssh-public-key",
+ name="EC2 Instance Connect SSH Access for Role Credentials (EC2INSTANCECONNECT-003)",
+ short_description="Push a temporary SSH key to an EC2 instance via Instance Connect to access its attached role credentials through IMDS.",
+ description="Detect principals who can send SSH public keys via EC2 Instance Connect. This allows establishing an SSH session on a running EC2 instance and retrieving the attached IAM role's temporary credentials from the Instance Metadata Service (IMDS), gaining that role's permissions.",
+ attribution=AttackPathsQueryAttribution(
+ text="pathfinding.cloud - EC2INSTANCECONNECT-003 - ec2-instance-connect:SendSSHPublicKey",
+ link="https://pathfinding.cloud/paths/ec2instanceconnect-003",
+ ),
+ provider="aws",
+ cypher=f"""
+ // Find principals with ec2-instance-connect:SendSSHPublicKey permission
+ MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(connect_policy:AWSPolicy)--(stmt_connect:AWSPolicyStatement)
+ WHERE stmt_connect.effect = 'Allow'
+ AND any(action IN stmt_connect.action WHERE
+ toLower(action) = 'ec2-instance-connect:sendsshpublickey'
+ OR toLower(action) = 'ec2-instance-connect:*'
+ OR action = '*'
+ )
+
+ // Find EC2 instances with attached roles (targets for credential theft via IMDS)
+ MATCH path_target = (aws)--(ec2:EC2Instance)-[:STS_ASSUMEROLE_ALLOW]->(target_role:AWSRole)
+
+ WITH collect(path_principal) + collect(path_target) AS paths
+ UNWIND paths AS p
+ UNWIND nodes(p) AS n
+
+ WITH paths, collect(DISTINCT n) AS unique_nodes
+ UNWIND unique_nodes AS n
+
+ OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}})
+
+ RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr
+ """,
+ parameters=[],
+)
+
+# ECS-001
+AWS_ECS_PRIVESC_PASSROLE_CREATE_SERVICE = AttackPathsQueryDefinition(
+ id="aws-ecs-privesc-passrole-create-service",
+ name="ECS Service Creation with Privileged Role (ECS-001 - New Cluster)",
+ short_description="Create an ECS cluster and service with a privileged Fargate task role to execute arbitrary code.",
+ description="Detect principals who can pass IAM roles, create ECS clusters, register task definitions, and create services. This allows creating a Fargate task with a privileged role attached, gaining that role's permissions to execute arbitrary code via the container.",
+ provider="aws",
+ attribution=AttackPathsQueryAttribution(
+ text="pathfinding.cloud - ECS-001 - iam:PassRole + ecs:CreateCluster + ecs:RegisterTaskDefinition + ecs:CreateService",
+ link="https://pathfinding.cloud/paths/ecs-001",
+ ),
+ cypher=f"""
+ // Find principals with iam:PassRole permission
+ MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(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 ecs:CreateCluster permission
+ MATCH (principal)--(cluster_policy:AWSPolicy)--(stmt_cluster:AWSPolicyStatement)
+ WHERE stmt_cluster.effect = 'Allow'
+ AND any(action IN stmt_cluster.action WHERE
+ toLower(action) = 'ecs:createcluster'
+ OR toLower(action) = 'ecs:*'
+ OR action = '*'
+ )
+
+ // Find ecs:RegisterTaskDefinition permission
+ MATCH (principal)--(taskdef_policy:AWSPolicy)--(stmt_taskdef:AWSPolicyStatement)
+ WHERE stmt_taskdef.effect = 'Allow'
+ AND any(action IN stmt_taskdef.action WHERE
+ toLower(action) = 'ecs:registertaskdefinition'
+ OR toLower(action) = 'ecs:*'
+ OR action = '*'
+ )
+
+ // Find ecs:CreateService permission
+ MATCH (principal)--(service_policy:AWSPolicy)--(stmt_service:AWSPolicyStatement)
+ WHERE stmt_service.effect = 'Allow'
+ AND any(action IN stmt_service.action WHERE
+ toLower(action) = 'ecs:createservice'
+ OR toLower(action) = 'ecs:*'
+ OR action = '*'
+ )
+
+ // Find roles that trust ECS tasks service (can be passed to ECS tasks)
+ MATCH path_target = (aws)--(target_role:AWSRole)-[:TRUSTS_AWS_PRINCIPAL]->(:AWSPrincipal {{arn: 'ecs-tasks.amazonaws.com'}})
+ WHERE any(resource IN stmt_passrole.resource WHERE
+ resource = '*'
+ OR target_role.arn CONTAINS resource
+ OR resource CONTAINS target_role.name
+ )
+
+ WITH collect(path_principal) + collect(path_target) AS paths
+ UNWIND paths AS p
+ UNWIND nodes(p) AS n
+
+ WITH paths, collect(DISTINCT n) AS unique_nodes
+ UNWIND unique_nodes AS n
+
+ OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}})
+
+ RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr
+ """,
+ parameters=[],
+)
+
+# ECS-002
+AWS_ECS_PRIVESC_PASSROLE_RUN_TASK = AttackPathsQueryDefinition(
+ id="aws-ecs-privesc-passrole-run-task",
+ name="ECS Task Execution with Privileged Role (ECS-002 - New Cluster)",
+ short_description="Create an ECS cluster and run a one-off Fargate task with a privileged role to execute arbitrary code.",
+ description="Detect principals who can pass IAM roles, create ECS clusters, register task definitions, and run tasks. This allows creating a Fargate task with a privileged role attached, gaining that role's permissions to execute arbitrary code via the container. Unlike ecs:CreateService, ecs:RunTask executes the task once without creating a persistent service.",
+ attribution=AttackPathsQueryAttribution(
+ text="pathfinding.cloud - ECS-002 - iam:PassRole + ecs:CreateCluster + ecs:RegisterTaskDefinition + ecs:RunTask",
+ link="https://pathfinding.cloud/paths/ecs-002",
+ ),
+ provider="aws",
+ cypher=f"""
+ // Find principals with iam:PassRole permission
+ MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(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 ecs:CreateCluster permission
+ MATCH (principal)--(cluster_policy:AWSPolicy)--(stmt_cluster:AWSPolicyStatement)
+ WHERE stmt_cluster.effect = 'Allow'
+ AND any(action IN stmt_cluster.action WHERE
+ toLower(action) = 'ecs:createcluster'
+ OR toLower(action) = 'ecs:*'
+ OR action = '*'
+ )
+
+ // Find ecs:RegisterTaskDefinition permission
+ MATCH (principal)--(taskdef_policy:AWSPolicy)--(stmt_taskdef:AWSPolicyStatement)
+ WHERE stmt_taskdef.effect = 'Allow'
+ AND any(action IN stmt_taskdef.action WHERE
+ toLower(action) = 'ecs:registertaskdefinition'
+ OR toLower(action) = 'ecs:*'
+ OR action = '*'
+ )
+
+ // Find ecs:RunTask permission
+ MATCH (principal)--(runtask_policy:AWSPolicy)--(stmt_runtask:AWSPolicyStatement)
+ WHERE stmt_runtask.effect = 'Allow'
+ AND any(action IN stmt_runtask.action WHERE
+ toLower(action) = 'ecs:runtask'
+ OR toLower(action) = 'ecs:*'
+ OR action = '*'
+ )
+
+ // Find roles that trust ECS tasks service (can be passed to ECS tasks)
+ MATCH path_target = (aws)--(target_role:AWSRole)-[:TRUSTS_AWS_PRINCIPAL]->(:AWSPrincipal {{arn: 'ecs-tasks.amazonaws.com'}})
+ WHERE any(resource IN stmt_passrole.resource WHERE
+ resource = '*'
+ OR target_role.arn CONTAINS resource
+ OR resource CONTAINS target_role.name
+ )
+
+ WITH collect(path_principal) + collect(path_target) AS paths
+ UNWIND paths AS p
+ UNWIND nodes(p) AS n
+
+ WITH paths, collect(DISTINCT n) AS unique_nodes
+ UNWIND unique_nodes AS n
+
+ OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}})
+
+ RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr
+ """,
+ parameters=[],
+)
+
+# ECS-003
+AWS_ECS_PRIVESC_PASSROLE_CREATE_SERVICE_EXISTING_CLUSTER = AttackPathsQueryDefinition(
+ id="aws-ecs-privesc-passrole-create-service-existing-cluster",
+ name="ECS Service Creation with Privileged Role (ECS-003 - Existing Cluster)",
+ short_description="Deploy a Fargate service with a privileged role on an existing ECS cluster.",
+ description="Detect principals who can pass IAM roles, register ECS task definitions, and create services on existing clusters. Unlike ECS-001, this does not require ecs:CreateCluster since it targets clusters that already exist. The attacker registers a task definition with a privileged role and launches it as a Fargate service, gaining that role's permissions.",
+ attribution=AttackPathsQueryAttribution(
+ text="pathfinding.cloud - ECS-003 - iam:PassRole + ecs:RegisterTaskDefinition + ecs:CreateService",
+ link="https://pathfinding.cloud/paths/ecs-003",
+ ),
+ provider="aws",
+ cypher=f"""
+ // Find principals with iam:PassRole permission
+ MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(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 ecs:RegisterTaskDefinition permission
+ MATCH (principal)--(taskdef_policy:AWSPolicy)--(stmt_taskdef:AWSPolicyStatement)
+ WHERE stmt_taskdef.effect = 'Allow'
+ AND any(action IN stmt_taskdef.action WHERE
+ toLower(action) = 'ecs:registertaskdefinition'
+ OR toLower(action) = 'ecs:*'
+ OR action = '*'
+ )
+
+ // Find ecs:CreateService permission
+ MATCH (principal)--(service_policy:AWSPolicy)--(stmt_service:AWSPolicyStatement)
+ WHERE stmt_service.effect = 'Allow'
+ AND any(action IN stmt_service.action WHERE
+ toLower(action) = 'ecs:createservice'
+ OR toLower(action) = 'ecs:*'
+ OR action = '*'
+ )
+
+ // Find roles that trust ECS tasks service (can be passed to ECS tasks)
+ MATCH path_target = (aws)--(target_role:AWSRole)-[:TRUSTS_AWS_PRINCIPAL]->(:AWSPrincipal {{arn: 'ecs-tasks.amazonaws.com'}})
+ WHERE any(resource IN stmt_passrole.resource WHERE
+ resource = '*'
+ OR target_role.arn CONTAINS resource
+ OR resource CONTAINS target_role.name
+ )
+
+ WITH collect(path_principal) + collect(path_target) AS paths
+ UNWIND paths AS p
+ UNWIND nodes(p) AS n
+
+ WITH paths, collect(DISTINCT n) AS unique_nodes
+ UNWIND unique_nodes AS n
+
+ OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}})
+
+ RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr
+ """,
+ parameters=[],
+)
+
+# ECS-004
+AWS_ECS_PRIVESC_PASSROLE_RUN_TASK_EXISTING_CLUSTER = AttackPathsQueryDefinition(
+ id="aws-ecs-privesc-passrole-run-task-existing-cluster",
+ name="ECS Task Execution with Privileged Role (ECS-004 - Existing Cluster)",
+ short_description="Run a one-off Fargate task with a privileged role on an existing ECS cluster.",
+ description="Detect principals who can pass IAM roles, register ECS task definitions, and run tasks on existing clusters. Unlike ECS-002, this does not require ecs:CreateCluster since it targets clusters that already exist. The attacker registers a task definition with a privileged role and runs it as a one-off Fargate task, gaining that role's permissions.",
+ attribution=AttackPathsQueryAttribution(
+ text="pathfinding.cloud - ECS-004 - iam:PassRole + ecs:RegisterTaskDefinition + ecs:RunTask",
+ link="https://pathfinding.cloud/paths/ecs-004",
+ ),
+ provider="aws",
+ cypher=f"""
+ // Find principals with iam:PassRole permission
+ MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(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 ecs:RegisterTaskDefinition permission
+ MATCH (principal)--(taskdef_policy:AWSPolicy)--(stmt_taskdef:AWSPolicyStatement)
+ WHERE stmt_taskdef.effect = 'Allow'
+ AND any(action IN stmt_taskdef.action WHERE
+ toLower(action) = 'ecs:registertaskdefinition'
+ OR toLower(action) = 'ecs:*'
+ OR action = '*'
+ )
+
+ // Find ecs:RunTask permission
+ MATCH (principal)--(runtask_policy:AWSPolicy)--(stmt_runtask:AWSPolicyStatement)
+ WHERE stmt_runtask.effect = 'Allow'
+ AND any(action IN stmt_runtask.action WHERE
+ toLower(action) = 'ecs:runtask'
+ OR toLower(action) = 'ecs:*'
+ OR action = '*'
+ )
+
+ // Find roles that trust ECS tasks service (can be passed to ECS tasks)
+ MATCH path_target = (aws)--(target_role:AWSRole)-[:TRUSTS_AWS_PRINCIPAL]->(:AWSPrincipal {{arn: 'ecs-tasks.amazonaws.com'}})
+ WHERE any(resource IN stmt_passrole.resource WHERE
+ resource = '*'
+ OR target_role.arn CONTAINS resource
+ OR resource CONTAINS target_role.name
+ )
+
+ WITH collect(path_principal) + collect(path_target) AS paths
+ UNWIND paths AS p
+ UNWIND nodes(p) AS n
+
+ WITH paths, collect(DISTINCT n) AS unique_nodes
+ UNWIND unique_nodes AS n
+
+ OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}})
+
+ RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr
+ """,
+ parameters=[],
+)
+
+# ECS-005
+AWS_ECS_PRIVESC_PASSROLE_START_TASK_EXISTING_CLUSTER = AttackPathsQueryDefinition(
+ id="aws-ecs-privesc-passrole-start-task-existing-cluster",
+ name="ECS Task Start with Privileged Role on EC2 (ECS-005 - Existing Cluster)",
+ short_description="Register a task definition with a privileged role and start it on an EC2 container instance to execute arbitrary code.",
+ description="Detect principals who can pass IAM roles, register ECS task definitions, and start tasks on existing EC2 container instances. Unlike ecs:RunTask which works with both EC2 and Fargate, ecs:StartTask is specific to EC2 launch types and requires specifying an existing container instance ARN. The attacker registers a task definition with a privileged role and starts it on a container instance, gaining that role's permissions.",
+ attribution=AttackPathsQueryAttribution(
+ text="pathfinding.cloud - ECS-005 - iam:PassRole + ecs:RegisterTaskDefinition + ecs:StartTask",
+ link="https://pathfinding.cloud/paths/ecs-005",
+ ),
+ provider="aws",
+ cypher=f"""
+ // Find principals with iam:PassRole permission
+ MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(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 ecs:RegisterTaskDefinition permission
+ MATCH (principal)--(taskdef_policy:AWSPolicy)--(stmt_taskdef:AWSPolicyStatement)
+ WHERE stmt_taskdef.effect = 'Allow'
+ AND any(action IN stmt_taskdef.action WHERE
+ toLower(action) = 'ecs:registertaskdefinition'
+ OR toLower(action) = 'ecs:*'
+ OR action = '*'
+ )
+
+ // Find ecs:StartTask permission
+ MATCH (principal)--(starttask_policy:AWSPolicy)--(stmt_starttask:AWSPolicyStatement)
+ WHERE stmt_starttask.effect = 'Allow'
+ AND any(action IN stmt_starttask.action WHERE
+ toLower(action) = 'ecs:starttask'
+ OR toLower(action) = 'ecs:*'
+ OR action = '*'
+ )
+
+ // Find roles that trust ECS tasks service (can be passed to ECS tasks)
+ MATCH path_target = (aws)--(target_role:AWSRole)-[:TRUSTS_AWS_PRINCIPAL]->(:AWSPrincipal {{arn: 'ecs-tasks.amazonaws.com'}})
+ WHERE any(resource IN stmt_passrole.resource WHERE
+ resource = '*'
+ OR target_role.arn CONTAINS resource
+ OR resource CONTAINS target_role.name
+ )
+
+ WITH collect(path_principal) + collect(path_target) AS paths
+ UNWIND paths AS p
+ UNWIND nodes(p) AS n
+
+ WITH paths, collect(DISTINCT n) AS unique_nodes
+ UNWIND unique_nodes AS n
+
+ OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}})
+
+ RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr
+ """,
+ parameters=[],
+)
+
+# ECS-006
+AWS_ECS_PRIVESC_EXECUTE_COMMAND = AttackPathsQueryDefinition(
+ id="aws-ecs-privesc-execute-command",
+ name="ECS Exec Container Hijacking for Role Credentials (ECS-006)",
+ short_description="Shell into a running ECS container via ECS Exec to steal the attached task role's credentials.",
+ description="Detect principals who can execute commands in running ECS containers and describe tasks. This allows establishing an interactive shell session in a container where ECS Exec is enabled, then retrieving the task role's temporary credentials from the container metadata service, without requiring iam:PassRole.",
+ attribution=AttackPathsQueryAttribution(
+ text="pathfinding.cloud - ECS-006 - ecs:ExecuteCommand + ecs:DescribeTasks",
+ link="https://pathfinding.cloud/paths/ecs-006",
+ ),
+ provider="aws",
+ cypher=f"""
+ // Find principals with ecs:ExecuteCommand permission
+ MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(exec_policy:AWSPolicy)--(stmt_exec:AWSPolicyStatement)
+ WHERE stmt_exec.effect = 'Allow'
+ AND any(action IN stmt_exec.action WHERE
+ toLower(action) = 'ecs:executecommand'
+ OR toLower(action) = 'ecs:*'
+ OR action = '*'
+ )
+
+ // Find ecs:DescribeTasks permission (required by AWS CLI to get container runtime ID)
+ MATCH (principal)--(describe_policy:AWSPolicy)--(stmt_describe:AWSPolicyStatement)
+ WHERE stmt_describe.effect = 'Allow'
+ AND any(action IN stmt_describe.action WHERE
+ toLower(action) = 'ecs:describetasks'
+ OR toLower(action) = 'ecs:*'
+ OR action = '*'
+ )
+
+ // Find roles that trust ECS tasks service (already attached to running tasks)
+ MATCH path_target = (aws)--(target_role:AWSRole)-[:TRUSTS_AWS_PRINCIPAL]->(:AWSPrincipal {{arn: 'ecs-tasks.amazonaws.com'}})
+
+ WITH collect(path_principal) + collect(path_target) AS paths
+ UNWIND paths AS p
+ UNWIND nodes(p) AS n
+
+ WITH paths, collect(DISTINCT n) AS unique_nodes
+ UNWIND unique_nodes AS n
+
+ OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}})
+
+ RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr
+ """,
+ parameters=[],
+)
+
+# GLUE-001
+AWS_GLUE_PRIVESC_PASSROLE_DEV_ENDPOINT = AttackPathsQueryDefinition(
+ id="aws-glue-privesc-passrole-dev-endpoint",
+ name="Glue Dev Endpoint with Privileged Role (GLUE-001)",
+ short_description="Create a Glue development endpoint with a privileged role attached to gain its permissions.",
+ description="Detect principals who can pass IAM roles and create Glue development endpoints. This allows creating a dev endpoint with a privileged role attached, gaining that role's permissions.",
+ attribution=AttackPathsQueryAttribution(
+ text="pathfinding.cloud - GLUE-001 - iam:PassRole + glue:CreateDevEndpoint",
+ link="https://pathfinding.cloud/paths/glue-001",
+ ),
+ provider="aws",
+ cypher=f"""
+ // Find principals with iam:PassRole permission
+ MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(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 glue:CreateDevEndpoint permission
+ MATCH (principal)--(glue_policy:AWSPolicy)--(stmt_glue:AWSPolicyStatement)
+ WHERE stmt_glue.effect = 'Allow'
+ AND any(action IN stmt_glue.action WHERE
+ toLower(action) = 'glue:createdevendpoint'
+ OR toLower(action) = 'glue:*'
+ OR action = '*'
+ )
+
+ // Find roles that trust Glue service (can be passed to Glue)
+ MATCH path_target = (aws)--(target_role:AWSRole)-[:TRUSTS_AWS_PRINCIPAL]->(:AWSPrincipal {{arn: 'glue.amazonaws.com'}})
+ WHERE any(resource IN stmt_passrole.resource WHERE
+ resource = '*'
+ OR target_role.arn CONTAINS resource
+ OR resource CONTAINS target_role.name
+ )
+
+ WITH collect(path_principal) + collect(path_target) AS paths
+ UNWIND paths AS p
+ UNWIND nodes(p) AS n
+
+ WITH paths, collect(DISTINCT n) AS unique_nodes
+ UNWIND unique_nodes AS n
+
+ OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}})
+
+ RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr
+ """,
+ parameters=[],
+)
+
+# GLUE-002
+AWS_GLUE_PRIVESC_UPDATE_DEV_ENDPOINT = AttackPathsQueryDefinition(
+ id="aws-glue-privesc-update-dev-endpoint",
+ name="Glue Dev Endpoint SSH Hijacking via Update (GLUE-002)",
+ short_description="Update an existing Glue development endpoint to inject an SSH public key and access its attached role credentials.",
+ description="Detect principals who can update Glue development endpoints. This allows adding an attacker-controlled SSH public key to an existing dev endpoint that already has a privileged role attached, then SSHing into it to steal the role's temporary credentials without requiring iam:PassRole.",
+ attribution=AttackPathsQueryAttribution(
+ text="pathfinding.cloud - GLUE-002 - glue:UpdateDevEndpoint",
+ link="https://pathfinding.cloud/paths/glue-002",
+ ),
+ provider="aws",
+ cypher=f"""
+ // Find principals with glue:UpdateDevEndpoint permission
+ MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(policy:AWSPolicy)--(stmt:AWSPolicyStatement)
+ WHERE stmt.effect = 'Allow'
+ AND any(action IN stmt.action WHERE
+ toLower(action) = 'glue:updatedevendpoint'
+ OR toLower(action) = 'glue:*'
+ OR action = '*'
+ )
+
+ // Find roles that trust Glue service (already attached to existing dev endpoints)
+ MATCH path_target = (aws)--(target_role:AWSRole)-[:TRUSTS_AWS_PRINCIPAL]->(:AWSPrincipal {{arn: 'glue.amazonaws.com'}})
+
+ WITH collect(path_principal) + collect(path_target) AS paths
+ UNWIND paths AS p
+ UNWIND nodes(p) AS n
+
+ WITH paths, collect(DISTINCT n) AS unique_nodes
+ UNWIND unique_nodes AS n
+
+ OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}})
+
+ RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr
+ """,
+ parameters=[],
+)
+
+# GLUE-003
+AWS_GLUE_PRIVESC_PASSROLE_CREATE_JOB = AttackPathsQueryDefinition(
+ id="aws-glue-privesc-passrole-create-job",
+ name="Glue Job Creation with Privileged Role (GLUE-003)",
+ short_description="Create a Glue job with a privileged role and start it to execute arbitrary code with that role's permissions.",
+ description="Detect principals who can pass IAM roles, create Glue jobs, and start job runs. This allows creating a Python shell job with a privileged role attached and executing arbitrary code that modifies IAM permissions, a cost-effective alternative to Glue development endpoints.",
+ attribution=AttackPathsQueryAttribution(
+ text="pathfinding.cloud - GLUE-003 - iam:PassRole + glue:CreateJob + glue:StartJobRun",
+ link="https://pathfinding.cloud/paths/glue-003",
+ ),
+ provider="aws",
+ cypher=f"""
+ // Find principals with iam:PassRole permission
+ MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(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 glue:CreateJob permission
+ MATCH (principal)--(createjob_policy:AWSPolicy)--(stmt_createjob:AWSPolicyStatement)
+ WHERE stmt_createjob.effect = 'Allow'
+ AND any(action IN stmt_createjob.action WHERE
+ toLower(action) = 'glue:createjob'
+ OR toLower(action) = 'glue:*'
+ OR action = '*'
+ )
+
+ // Find glue:StartJobRun permission
+ MATCH (principal)--(startjob_policy:AWSPolicy)--(stmt_startjob:AWSPolicyStatement)
+ WHERE stmt_startjob.effect = 'Allow'
+ AND any(action IN stmt_startjob.action WHERE
+ toLower(action) = 'glue:startjobrun'
+ OR toLower(action) = 'glue:*'
+ OR action = '*'
+ )
+
+ // Find roles that trust Glue service (can be passed to Glue jobs)
+ MATCH path_target = (aws)--(target_role:AWSRole)-[:TRUSTS_AWS_PRINCIPAL]->(:AWSPrincipal {{arn: 'glue.amazonaws.com'}})
+ WHERE any(resource IN stmt_passrole.resource WHERE
+ resource = '*'
+ OR target_role.arn CONTAINS resource
+ OR resource CONTAINS target_role.name
+ )
+
+ WITH collect(path_principal) + collect(path_target) AS paths
+ UNWIND paths AS p
+ UNWIND nodes(p) AS n
+
+ WITH paths, collect(DISTINCT n) AS unique_nodes
+ UNWIND unique_nodes AS n
+
+ OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}})
+
+ RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr
+ """,
+ parameters=[],
+)
+
+# GLUE-004
+AWS_GLUE_PRIVESC_PASSROLE_CREATE_JOB_TRIGGER = AttackPathsQueryDefinition(
+ id="aws-glue-privesc-passrole-create-job-trigger",
+ name="Glue Job Creation with Scheduled Trigger and Privileged Role (GLUE-004)",
+ short_description="Create a Glue job with a privileged role and a scheduled trigger to persistently execute arbitrary code.",
+ description="Detect principals who can pass IAM roles, create Glue jobs, and create triggers with automatic activation. Unlike manual execution via StartJobRun, this creates a persistent attack by scheduling the job to run repeatedly, making it harder to detect and remediate.",
+ attribution=AttackPathsQueryAttribution(
+ text="pathfinding.cloud - GLUE-004 - iam:PassRole + glue:CreateJob + glue:CreateTrigger",
+ link="https://pathfinding.cloud/paths/glue-004",
+ ),
+ provider="aws",
+ cypher=f"""
+ // Find principals with iam:PassRole permission
+ MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(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 glue:CreateJob permission
+ MATCH (principal)--(createjob_policy:AWSPolicy)--(stmt_createjob:AWSPolicyStatement)
+ WHERE stmt_createjob.effect = 'Allow'
+ AND any(action IN stmt_createjob.action WHERE
+ toLower(action) = 'glue:createjob'
+ OR toLower(action) = 'glue:*'
+ OR action = '*'
+ )
+
+ // Find glue:CreateTrigger permission
+ MATCH (principal)--(trigger_policy:AWSPolicy)--(stmt_trigger:AWSPolicyStatement)
+ WHERE stmt_trigger.effect = 'Allow'
+ AND any(action IN stmt_trigger.action WHERE
+ toLower(action) = 'glue:createtrigger'
+ OR toLower(action) = 'glue:*'
+ OR action = '*'
+ )
+
+ // Find roles that trust Glue service (can be passed to Glue jobs)
+ MATCH path_target = (aws)--(target_role:AWSRole)-[:TRUSTS_AWS_PRINCIPAL]->(:AWSPrincipal {{arn: 'glue.amazonaws.com'}})
+ WHERE any(resource IN stmt_passrole.resource WHERE
+ resource = '*'
+ OR target_role.arn CONTAINS resource
+ OR resource CONTAINS target_role.name
+ )
+
+ WITH collect(path_principal) + collect(path_target) AS paths
+ UNWIND paths AS p
+ UNWIND nodes(p) AS n
+
+ WITH paths, collect(DISTINCT n) AS unique_nodes
+ UNWIND unique_nodes AS n
+
+ OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}})
+
+ RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr
+ """,
+ parameters=[],
+)
+
+# GLUE-005
+AWS_GLUE_PRIVESC_PASSROLE_UPDATE_JOB = AttackPathsQueryDefinition(
+ id="aws-glue-privesc-passrole-update-job",
+ name="Glue Job Hijacking via Update with Privileged Role (GLUE-005)",
+ short_description="Update an existing Glue job to attach a privileged role and inject malicious code, then start it to gain that role's permissions.",
+ description="Detect principals who can pass IAM roles, update existing Glue jobs, and start job runs. This allows modifying an existing job's role and script to execute arbitrary code with elevated privileges, a stealthier variant of job creation since it reuses existing infrastructure rather than creating new resources.",
+ attribution=AttackPathsQueryAttribution(
+ text="pathfinding.cloud - GLUE-005 - iam:PassRole + glue:UpdateJob + glue:StartJobRun",
+ link="https://pathfinding.cloud/paths/glue-005",
+ ),
+ provider="aws",
+ cypher=f"""
+ // Find principals with iam:PassRole permission
+ MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(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 glue:UpdateJob permission
+ MATCH (principal)--(updatejob_policy:AWSPolicy)--(stmt_updatejob:AWSPolicyStatement)
+ WHERE stmt_updatejob.effect = 'Allow'
+ AND any(action IN stmt_updatejob.action WHERE
+ toLower(action) = 'glue:updatejob'
+ OR toLower(action) = 'glue:*'
+ OR action = '*'
+ )
+
+ // Find glue:StartJobRun permission
+ MATCH (principal)--(startjob_policy:AWSPolicy)--(stmt_startjob:AWSPolicyStatement)
+ WHERE stmt_startjob.effect = 'Allow'
+ AND any(action IN stmt_startjob.action WHERE
+ toLower(action) = 'glue:startjobrun'
+ OR toLower(action) = 'glue:*'
+ OR action = '*'
+ )
+
+ // Find roles that trust Glue service (can be passed to Glue jobs)
+ MATCH path_target = (aws)--(target_role:AWSRole)-[:TRUSTS_AWS_PRINCIPAL]->(:AWSPrincipal {{arn: 'glue.amazonaws.com'}})
+ WHERE any(resource IN stmt_passrole.resource WHERE
+ resource = '*'
+ OR target_role.arn CONTAINS resource
+ OR resource CONTAINS target_role.name
+ )
+
+ WITH collect(path_principal) + collect(path_target) AS paths
+ UNWIND paths AS p
+ UNWIND nodes(p) AS n
+
+ WITH paths, collect(DISTINCT n) AS unique_nodes
+ UNWIND unique_nodes AS n
+
+ OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}})
+
+ RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr
+ """,
+ parameters=[],
+)
+
+# GLUE-006
+AWS_GLUE_PRIVESC_PASSROLE_UPDATE_JOB_TRIGGER = AttackPathsQueryDefinition(
+ id="aws-glue-privesc-passrole-update-job-trigger",
+ name="Glue Job Hijacking with Scheduled Trigger and Privileged Role (GLUE-006)",
+ short_description="Update an existing Glue job to attach a privileged role and inject malicious code, then create a scheduled trigger for persistent automated execution.",
+ description="Detect principals who can pass IAM roles, update existing Glue jobs, and create triggers with automatic activation. This combines the stealth of modifying existing infrastructure with the persistence of scheduled automation, creating a recurring backdoor that re-executes even after remediation attempts.",
+ attribution=AttackPathsQueryAttribution(
+ text="pathfinding.cloud - GLUE-006 - iam:PassRole + glue:UpdateJob + glue:CreateTrigger",
+ link="https://pathfinding.cloud/paths/glue-006",
+ ),
+ provider="aws",
+ cypher=f"""
+ // Find principals with iam:PassRole permission
+ MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(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 glue:UpdateJob permission
+ MATCH (principal)--(updatejob_policy:AWSPolicy)--(stmt_updatejob:AWSPolicyStatement)
+ WHERE stmt_updatejob.effect = 'Allow'
+ AND any(action IN stmt_updatejob.action WHERE
+ toLower(action) = 'glue:updatejob'
+ OR toLower(action) = 'glue:*'
+ OR action = '*'
+ )
+
+ // Find glue:CreateTrigger permission
+ MATCH (principal)--(trigger_policy:AWSPolicy)--(stmt_trigger:AWSPolicyStatement)
+ WHERE stmt_trigger.effect = 'Allow'
+ AND any(action IN stmt_trigger.action WHERE
+ toLower(action) = 'glue:createtrigger'
+ OR toLower(action) = 'glue:*'
+ OR action = '*'
+ )
+
+ // Find roles that trust Glue service (can be passed to Glue jobs)
+ MATCH path_target = (aws)--(target_role:AWSRole)-[:TRUSTS_AWS_PRINCIPAL]->(:AWSPrincipal {{arn: 'glue.amazonaws.com'}})
+ WHERE any(resource IN stmt_passrole.resource WHERE
+ resource = '*'
+ OR target_role.arn CONTAINS resource
+ OR resource CONTAINS target_role.name
+ )
+
+ WITH collect(path_principal) + collect(path_target) AS paths
+ UNWIND paths AS p
+ UNWIND nodes(p) AS n
+
+ WITH paths, collect(DISTINCT n) AS unique_nodes
+ UNWIND unique_nodes AS n
+
+ OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}})
+
+ RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr
+ """,
+ parameters=[],
+)
+
+# IAM-001
+AWS_IAM_PRIVESC_CREATE_POLICY_VERSION = AttackPathsQueryDefinition(
+ id="aws-iam-privesc-create-policy-version",
+ name="Policy Version Override for Self-Escalation (IAM-001)",
+ short_description="Create a new version of an attached policy with administrative permissions, instantly escalating the principal's own privileges.",
+ description="Detect principals who can create new policy versions. If a customer-managed policy is already attached to a principal and that principal has iam:CreatePolicyVersion on that policy, they can replace its contents with a fully permissive policy and set it as the default, gaining immediate administrative access.",
+ attribution=AttackPathsQueryAttribution(
+ text="pathfinding.cloud - IAM-001 - iam:CreatePolicyVersion",
+ link="https://pathfinding.cloud/paths/iam-001",
+ ),
+ provider="aws",
+ cypher=f"""
+ // Find principals with iam:CreatePolicyVersion permission
+ MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(policy:AWSPolicy)--(stmt:AWSPolicyStatement)
+ WHERE stmt.effect = 'Allow'
+ AND any(action IN stmt.action WHERE
+ toLower(action) = 'iam:createpolicyversion'
+ OR toLower(action) = 'iam:*'
+ OR action = '*'
+ )
+
+ // Find customer-managed policies attached to the same principal that can be overwritten
+ MATCH path_target = (aws)--(target_policy:AWSPolicy)--(principal)
+ WHERE target_policy.arn CONTAINS $provider_uid
+ AND any(resource IN stmt.resource WHERE
+ resource = '*'
+ OR target_policy.arn CONTAINS resource
+ )
+
+ WITH collect(path_principal) + collect(path_target) AS paths
+ UNWIND paths AS p
+ UNWIND nodes(p) AS n
+
+ WITH paths, collect(DISTINCT n) AS unique_nodes
+ UNWIND unique_nodes AS n
+
+ OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}})
+
+ RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr
+ """,
+ parameters=[],
+)
+
+# IAM-002
+AWS_IAM_PRIVESC_CREATE_ACCESS_KEY = AttackPathsQueryDefinition(
+ id="aws-iam-privesc-create-access-key",
+ name="Access Key Creation for Lateral Movement (IAM-002)",
+ short_description="Create access keys for other IAM users to gain their permissions and move laterally across the account.",
+ description="Detect principals who can create access keys for other IAM users. This allows generating new credentials for any target user within the resource scope, immediately gaining that user's permissions without needing their password or existing keys.",
+ attribution=AttackPathsQueryAttribution(
+ text="pathfinding.cloud - IAM-002 - iam:CreateAccessKey",
+ link="https://pathfinding.cloud/paths/iam-002",
+ ),
+ provider="aws",
+ cypher=f"""
+ // Find principals with iam:CreateAccessKey permission
+ MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(policy:AWSPolicy)--(stmt:AWSPolicyStatement)
+ WHERE stmt.effect = 'Allow'
+ AND any(action IN stmt.action WHERE
+ toLower(action) = 'iam:createaccesskey'
+ OR toLower(action) = 'iam:*'
+ OR action = '*'
+ )
+
+ // Find target users that the principal can create access keys for
+ MATCH path_target = (aws)--(target_user:AWSUser)
+ WHERE any(resource IN stmt.resource WHERE
+ resource = '*'
+ OR target_user.arn CONTAINS resource
+ OR resource CONTAINS target_user.name
+ )
+
+ WITH collect(path_principal) + collect(path_target) AS paths
+ UNWIND paths AS p
+ UNWIND nodes(p) AS n
+
+ WITH paths, collect(DISTINCT n) AS unique_nodes
+ UNWIND unique_nodes AS n
+
+ OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}})
+
+ RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr
+ """,
+ parameters=[],
+)
+
+# IAM-003
+AWS_IAM_PRIVESC_DELETE_CREATE_ACCESS_KEY = AttackPathsQueryDefinition(
+ id="aws-iam-privesc-delete-create-access-key",
+ name="Access Key Rotation Attack for Lateral Movement (IAM-003)",
+ short_description="Delete and recreate access keys for other IAM users to bypass the two-key limit and gain their permissions.",
+ description="Detect principals who can both delete and create access keys for other IAM users. This variation of IAM-002 handles the scenario where a target user already has the maximum of two access keys by first deleting one, then creating a replacement under the attacker's control.",
+ attribution=AttackPathsQueryAttribution(
+ text="pathfinding.cloud - IAM-003 - iam:CreateAccessKey + iam:DeleteAccessKey",
+ link="https://pathfinding.cloud/paths/iam-003",
+ ),
+ provider="aws",
+ cypher=f"""
+ // Find principals with iam:CreateAccessKey permission
+ MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(policy:AWSPolicy)--(stmt:AWSPolicyStatement)
+ WHERE stmt.effect = 'Allow'
+ AND any(action IN stmt.action WHERE
+ toLower(action) = 'iam:createaccesskey'
+ OR toLower(action) = 'iam:*'
+ OR action = '*'
+ )
+
+ // Find iam:DeleteAccessKey permission
+ MATCH (principal)--(delete_policy:AWSPolicy)--(stmt_delete:AWSPolicyStatement)
+ WHERE stmt_delete.effect = 'Allow'
+ AND any(action IN stmt_delete.action WHERE
+ toLower(action) = 'iam:deleteaccesskey'
+ OR toLower(action) = 'iam:*'
+ OR action = '*'
+ )
+
+ // Find target users that the principal can rotate access keys for
+ MATCH path_target = (aws)--(target_user:AWSUser)
+ WHERE any(resource IN stmt.resource WHERE
+ resource = '*'
+ OR target_user.arn CONTAINS resource
+ OR resource CONTAINS target_user.name
+ )
+ AND any(resource IN stmt_delete.resource WHERE
+ resource = '*'
+ OR target_user.arn CONTAINS resource
+ OR resource CONTAINS target_user.name
+ )
+
+ WITH collect(path_principal) + collect(path_target) AS paths
+ UNWIND paths AS p
+ UNWIND nodes(p) AS n
+
+ WITH paths, collect(DISTINCT n) AS unique_nodes
+ UNWIND unique_nodes AS n
+
+ OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}})
+
+ RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr
+ """,
+ parameters=[],
+)
+
+# IAM-004
+AWS_IAM_PRIVESC_CREATE_LOGIN_PROFILE = AttackPathsQueryDefinition(
+ id="aws-iam-privesc-create-login-profile",
+ name="Console Login Profile Creation for Lateral Movement (IAM-004)",
+ short_description="Create console login profiles for other IAM users to access the AWS Console with their permissions.",
+ description="Detect principals who can create console login profiles for other IAM users. By setting a known password on a target user that lacks a login profile, the attacker gains AWS Console access with that user's permissions without needing their existing credentials.",
+ attribution=AttackPathsQueryAttribution(
+ text="pathfinding.cloud - IAM-004 - iam:CreateLoginProfile",
+ link="https://pathfinding.cloud/paths/iam-004",
+ ),
+ provider="aws",
+ cypher=f"""
+ // Find principals with iam:CreateLoginProfile permission
+ MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(policy:AWSPolicy)--(stmt:AWSPolicyStatement)
+ WHERE stmt.effect = 'Allow'
+ AND any(action IN stmt.action WHERE
+ toLower(action) = 'iam:createloginprofile'
+ OR toLower(action) = 'iam:*'
+ OR action = '*'
+ )
+
+ // Find target users that the principal can create login profiles for
+ MATCH path_target = (aws)--(target_user:AWSUser)
+ WHERE any(resource IN stmt.resource WHERE
+ resource = '*'
+ OR target_user.arn CONTAINS resource
+ OR resource CONTAINS target_user.name
+ )
+
+ WITH collect(path_principal) + collect(path_target) AS paths
+ UNWIND paths AS p
+ UNWIND nodes(p) AS n
+
+ WITH paths, collect(DISTINCT n) AS unique_nodes
+ UNWIND unique_nodes AS n
+
+ OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}})
+
+ RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr
+ """,
+ parameters=[],
+)
+
+# IAM-005
+AWS_IAM_PRIVESC_PUT_ROLE_POLICY = AttackPathsQueryDefinition(
+ id="aws-iam-privesc-put-role-policy",
+ name="Inline Policy Injection for Self-Escalation (IAM-005)",
+ short_description="Attach an inline policy with administrative permissions to your own role, instantly escalating privileges.",
+ description="Detect roles that can use iam:PutRolePolicy on themselves. A role with this permission can attach an inline policy granting any permissions, including full administrative access, without needing to modify or assume any other resource.",
+ attribution=AttackPathsQueryAttribution(
+ text="pathfinding.cloud - IAM-005 - iam:PutRolePolicy",
+ link="https://pathfinding.cloud/paths/iam-005",
+ ),
+ provider="aws",
+ cypher=f"""
+ // Find roles with iam:PutRolePolicy permission scoped to themselves
+ MATCH path = (aws:AWSAccount {{id: $provider_uid}})--(role:AWSRole)--(policy:AWSPolicy)--(stmt:AWSPolicyStatement)
+ WHERE stmt.effect = 'Allow'
+ AND any(action IN stmt.action WHERE
+ toLower(action) = 'iam:putrolepolicy'
+ OR toLower(action) = 'iam:*'
+ OR action = '*'
+ )
+ AND any(resource IN stmt.resource WHERE
+ resource = '*'
+ OR role.arn CONTAINS resource
+ OR resource CONTAINS role.name
+ )
+
+ WITH collect(path) AS paths
+ UNWIND paths AS p
+ UNWIND nodes(p) AS n
+
+ WITH paths, collect(DISTINCT n) AS unique_nodes
+ UNWIND unique_nodes AS n
+
+ OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}})
+
+ RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr
+ """,
+ parameters=[],
+)
+
+# IAM-006
+AWS_IAM_PRIVESC_UPDATE_LOGIN_PROFILE = AttackPathsQueryDefinition(
+ id="aws-iam-privesc-update-login-profile",
+ name="Console Password Override for Lateral Movement (IAM-006)",
+ short_description="Change the console password of other IAM users to log in as them and gain their permissions.",
+ description="Detect principals who can update console login profiles for other IAM users. By resetting a target user's password, the attacker gains AWS Console access with that user's permissions. Unlike IAM-004, this targets users who already have a login profile configured.",
+ attribution=AttackPathsQueryAttribution(
+ text="pathfinding.cloud - IAM-006 - iam:UpdateLoginProfile",
+ link="https://pathfinding.cloud/paths/iam-006",
+ ),
+ provider="aws",
+ cypher=f"""
+ // Find principals with iam:UpdateLoginProfile permission
+ MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(policy:AWSPolicy)--(stmt:AWSPolicyStatement)
+ WHERE stmt.effect = 'Allow'
+ AND any(action IN stmt.action WHERE
+ toLower(action) = 'iam:updateloginprofile'
+ OR toLower(action) = 'iam:*'
+ OR action = '*'
+ )
+
+ // Find target users that the principal can update login profiles for
+ MATCH path_target = (aws)--(target_user:AWSUser)
+ WHERE any(resource IN stmt.resource WHERE
+ resource = '*'
+ OR target_user.arn CONTAINS resource
+ OR resource CONTAINS target_user.name
+ )
+
+ WITH collect(path_principal) + collect(path_target) AS paths
+ UNWIND paths AS p
+ UNWIND nodes(p) AS n
+
+ WITH paths, collect(DISTINCT n) AS unique_nodes
+ UNWIND unique_nodes AS n
+
+ OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}})
+
+ RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr
+ """,
+ parameters=[],
+)
+
+# IAM-007
+AWS_IAM_PRIVESC_PUT_USER_POLICY = AttackPathsQueryDefinition(
+ id="aws-iam-privesc-put-user-policy",
+ name="Inline Policy Injection on User for Self-Escalation (IAM-007)",
+ short_description="Attach an inline policy with administrative permissions to your own IAM user, instantly escalating privileges.",
+ description="Detect IAM users that can use iam:PutUserPolicy on themselves. A user with this permission can attach an inline policy granting any permissions, including full administrative access, without needing to modify or assume any other resource. This is the user equivalent of IAM-005 (PutRolePolicy).",
+ attribution=AttackPathsQueryAttribution(
+ text="pathfinding.cloud - IAM-007 - iam:PutUserPolicy",
+ link="https://pathfinding.cloud/paths/iam-007",
+ ),
+ provider="aws",
+ cypher=f"""
+ // Find users with iam:PutUserPolicy permission scoped to themselves
+ MATCH path = (aws:AWSAccount {{id: $provider_uid}})--(user:AWSUser)--(policy:AWSPolicy)--(stmt:AWSPolicyStatement)
+ WHERE stmt.effect = 'Allow'
+ AND any(action IN stmt.action WHERE
+ toLower(action) = 'iam:putuserpolicy'
+ OR toLower(action) = 'iam:*'
+ OR action = '*'
+ )
+ AND any(resource IN stmt.resource WHERE
+ resource = '*'
+ OR user.arn CONTAINS resource
+ OR resource CONTAINS user.name
+ )
+
+ WITH collect(path) AS paths
+ UNWIND paths AS p
+ UNWIND nodes(p) AS n
+
+ WITH paths, collect(DISTINCT n) AS unique_nodes
+ UNWIND unique_nodes AS n
+
+ OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}})
+
+ RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr
+ """,
+ parameters=[],
+)
+
+# IAM-008
+AWS_IAM_PRIVESC_ATTACH_USER_POLICY = AttackPathsQueryDefinition(
+ id="aws-iam-privesc-attach-user-policy",
+ name="Managed Policy Attachment on User for Self-Escalation (IAM-008)",
+ short_description="Attach existing managed policies with administrative permissions to your own IAM user, instantly escalating privileges.",
+ description="Detect IAM users that can use iam:AttachUserPolicy on themselves. A user with this permission can attach any existing managed policy, including AdministratorAccess, to themselves without needing to modify or assume any other resource. Unlike IAM-007 (PutUserPolicy), this requires an existing managed policy with elevated permissions.",
+ attribution=AttackPathsQueryAttribution(
+ text="pathfinding.cloud - IAM-008 - iam:AttachUserPolicy",
+ link="https://pathfinding.cloud/paths/iam-008",
+ ),
+ provider="aws",
+ cypher=f"""
+ // Find users with iam:AttachUserPolicy permission scoped to themselves
+ MATCH path = (aws:AWSAccount {{id: $provider_uid}})--(user:AWSUser)--(policy:AWSPolicy)--(stmt:AWSPolicyStatement)
+ WHERE stmt.effect = 'Allow'
+ AND any(action IN stmt.action WHERE
+ toLower(action) = 'iam:attachuserpolicy'
+ OR toLower(action) = 'iam:*'
+ OR action = '*'
+ )
+ AND any(resource IN stmt.resource WHERE
+ resource = '*'
+ OR user.arn CONTAINS resource
+ OR resource CONTAINS user.name
+ )
+
+ WITH collect(path) AS paths
+ UNWIND paths AS p
+ UNWIND nodes(p) AS n
+
+ WITH paths, collect(DISTINCT n) AS unique_nodes
+ UNWIND unique_nodes AS n
+
+ OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}})
+
+ RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr
+ """,
+ parameters=[],
+)
+
+# IAM-009
+AWS_IAM_PRIVESC_ATTACH_ROLE_POLICY = AttackPathsQueryDefinition(
+ id="aws-iam-privesc-attach-role-policy",
+ name="Managed Policy Attachment on Role for Self-Escalation (IAM-009)",
+ short_description="Attach existing managed policies with administrative permissions to your own IAM role, instantly escalating privileges.",
+ description="Detect IAM roles that can use iam:AttachRolePolicy on themselves. A role with this permission can attach any existing managed policy, including AdministratorAccess, to itself without needing to modify or assume any other resource. Unlike IAM-005 (PutRolePolicy), this requires an existing managed policy with elevated permissions.",
+ attribution=AttackPathsQueryAttribution(
+ text="pathfinding.cloud - IAM-009 - iam:AttachRolePolicy",
+ link="https://pathfinding.cloud/paths/iam-009",
+ ),
+ provider="aws",
+ cypher=f"""
+ // Find roles with iam:AttachRolePolicy permission scoped to themselves
+ MATCH path = (aws:AWSAccount {{id: $provider_uid}})--(role:AWSRole)--(policy:AWSPolicy)--(stmt:AWSPolicyStatement)
+ WHERE stmt.effect = 'Allow'
+ AND any(action IN stmt.action WHERE
+ toLower(action) = 'iam:attachrolepolicy'
+ OR toLower(action) = 'iam:*'
+ OR action = '*'
+ )
+ AND any(resource IN stmt.resource WHERE
+ resource = '*'
+ OR role.arn CONTAINS resource
+ OR resource CONTAINS role.name
+ )
+
+ WITH collect(path) AS paths
+ UNWIND paths AS p
+ UNWIND nodes(p) AS n
+
+ WITH paths, collect(DISTINCT n) AS unique_nodes
+ UNWIND unique_nodes AS n
+
+ OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}})
+
+ RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr
+ """,
+ parameters=[],
+)
+
+# IAM-010
+AWS_IAM_PRIVESC_ATTACH_GROUP_POLICY = AttackPathsQueryDefinition(
+ id="aws-iam-privesc-attach-group-policy",
+ name="Managed Policy Attachment on Group for Self-Escalation (IAM-010)",
+ short_description="Attach existing managed policies with administrative permissions to a group you belong to, escalating privileges for all group members.",
+ description="Detect IAM users that can use iam:AttachGroupPolicy on a group they are a member of. A user with this permission can attach any existing managed policy, including AdministratorAccess, to a group they belong to, immediately escalating privileges for all group members.",
+ attribution=AttackPathsQueryAttribution(
+ text="pathfinding.cloud - IAM-010 - iam:AttachGroupPolicy",
+ link="https://pathfinding.cloud/paths/iam-010",
+ ),
+ provider="aws",
+ cypher=f"""
+ // Find users with iam:AttachGroupPolicy permission
+ MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(user:AWSUser)--(policy:AWSPolicy)--(stmt:AWSPolicyStatement)
+ WHERE stmt.effect = 'Allow'
+ AND any(action IN stmt.action WHERE
+ toLower(action) = 'iam:attachgrouppolicy'
+ OR toLower(action) = 'iam:*'
+ OR action = '*'
+ )
+
+ // Find groups the user is a member of and can attach policies to
+ MATCH path_target = (aws)-[:RESOURCE]->(target_group:AWSGroup)<-[:MEMBER_AWS_GROUP]-(user)
+ WHERE any(resource IN stmt.resource WHERE
+ resource = '*'
+ OR target_group.arn CONTAINS resource
+ OR resource CONTAINS target_group.name
+ )
+
+ WITH collect(path_principal) + collect(path_target) AS paths
+ UNWIND paths AS p
+ UNWIND nodes(p) AS n
+
+ WITH paths, collect(DISTINCT n) AS unique_nodes
+ UNWIND unique_nodes AS n
+
+ OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}})
+
+ RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr
+ """,
+ parameters=[],
+)
+
+# IAM-011
+AWS_IAM_PRIVESC_PUT_GROUP_POLICY = AttackPathsQueryDefinition(
+ id="aws-iam-privesc-put-group-policy",
+ name="Inline Policy Injection on Group for Self-Escalation (IAM-011)",
+ short_description="Attach an inline policy with administrative permissions to a group you belong to, escalating privileges for all group members.",
+ description="Detect IAM users that can use iam:PutGroupPolicy on a group they are a member of. A user with this permission can attach an inline policy granting any permissions to a group they belong to, immediately escalating privileges for all group members. Unlike IAM-010, this does not require an existing managed policy.",
+ attribution=AttackPathsQueryAttribution(
+ text="pathfinding.cloud - IAM-011 - iam:PutGroupPolicy",
+ link="https://pathfinding.cloud/paths/iam-011",
+ ),
+ provider="aws",
+ cypher=f"""
+ // Find users with iam:PutGroupPolicy permission
+ MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(user:AWSUser)--(policy:AWSPolicy)--(stmt:AWSPolicyStatement)
+ WHERE stmt.effect = 'Allow'
+ AND any(action IN stmt.action WHERE
+ toLower(action) = 'iam:putgrouppolicy'
+ OR toLower(action) = 'iam:*'
+ OR action = '*'
+ )
+
+ // Find groups the user is a member of and can put policies on
+ MATCH path_target = (aws)-[:RESOURCE]->(target_group:AWSGroup)<-[:MEMBER_AWS_GROUP]-(user)
+ WHERE any(resource IN stmt.resource WHERE
+ resource = '*'
+ OR target_group.arn CONTAINS resource
+ OR resource CONTAINS target_group.name
+ )
+
+ WITH collect(path_principal) + collect(path_target) AS paths
+ UNWIND paths AS p
+ UNWIND nodes(p) AS n
+
+ WITH paths, collect(DISTINCT n) AS unique_nodes
+ UNWIND unique_nodes AS n
+
+ OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}})
+
+ RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr
+ """,
+ parameters=[],
+)
+
+# IAM-012
+AWS_IAM_PRIVESC_UPDATE_ASSUME_ROLE_POLICY = AttackPathsQueryDefinition(
+ id="aws-iam-privesc-update-assume-role-policy",
+ name="Trust Policy Hijacking for Role Assumption (IAM-012)",
+ short_description="Modify a role's trust policy to allow yourself to assume it, gaining the role's permissions.",
+ description="Detect principals who can update the assume role policy (trust policy) of other IAM roles. By modifying a target role's trust policy to trust the attacker's principal, the attacker can then assume the role and gain all its permissions, including potential administrative access.",
+ attribution=AttackPathsQueryAttribution(
+ text="pathfinding.cloud - IAM-012 - iam:UpdateAssumeRolePolicy",
+ link="https://pathfinding.cloud/paths/iam-012",
+ ),
+ provider="aws",
+ cypher=f"""
+ // Find principals with iam:UpdateAssumeRolePolicy permission
+ MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(policy:AWSPolicy)--(stmt:AWSPolicyStatement)
+ WHERE stmt.effect = 'Allow'
+ AND any(action IN stmt.action WHERE
+ toLower(action) = 'iam:updateassumerolepolicy'
+ OR toLower(action) = 'iam:*'
+ OR action = '*'
+ )
+
+ // Find target roles whose trust policy can be modified
+ MATCH path_target = (aws)--(target_role:AWSRole)
+ WHERE any(resource IN stmt.resource WHERE
+ resource = '*'
+ OR target_role.arn CONTAINS resource
+ OR resource CONTAINS target_role.name
+ )
+
+ WITH collect(path_principal) + collect(path_target) AS paths
+ UNWIND paths AS p
+ UNWIND nodes(p) AS n
+
+ WITH paths, collect(DISTINCT n) AS unique_nodes
+ UNWIND unique_nodes AS n
+
+ OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}})
+
+ RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr
+ """,
+ parameters=[],
+)
+
+# IAM-013
+AWS_IAM_PRIVESC_ADD_USER_TO_GROUP = AttackPathsQueryDefinition(
+ id="aws-iam-privesc-add-user-to-group",
+ name="Group Membership Hijacking for Privilege Escalation (IAM-013)",
+ short_description="Add yourself to a privileged IAM group to inherit its permissions, gaining access to all policies attached to the group.",
+ description="Detect principals who can add users to IAM groups. By adding themselves to a group with elevated permissions such as AdministratorAccess, the attacker immediately inherits all policies attached to that group. The level of access gained depends on the permissions of the target group.",
+ attribution=AttackPathsQueryAttribution(
+ text="pathfinding.cloud - IAM-013 - iam:AddUserToGroup",
+ link="https://pathfinding.cloud/paths/iam-013",
+ ),
+ provider="aws",
+ cypher=f"""
+ // Find principals with iam:AddUserToGroup permission
+ MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(policy:AWSPolicy)--(stmt:AWSPolicyStatement)
+ WHERE stmt.effect = 'Allow'
+ AND any(action IN stmt.action WHERE
+ toLower(action) = 'iam:addusertogroup'
+ OR toLower(action) = 'iam:*'
+ OR action = '*'
+ )
+
+ // Find target groups the principal can add users to
+ MATCH path_target = (aws)-[:RESOURCE]->(target_group:AWSGroup)
+ WHERE any(resource IN stmt.resource WHERE
+ resource = '*'
+ OR target_group.arn CONTAINS resource
+ OR resource CONTAINS target_group.name
+ )
+
+ WITH collect(path_principal) + collect(path_target) AS paths
+ UNWIND paths AS p
+ UNWIND nodes(p) AS n
+
+ WITH paths, collect(DISTINCT n) AS unique_nodes
+ UNWIND unique_nodes AS n
+
+ OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}})
+
+ RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr
+ """,
+ parameters=[],
+)
+
+# IAM-014
+AWS_IAM_PRIVESC_ATTACH_ROLE_POLICY_ASSUME_ROLE = AttackPathsQueryDefinition(
+ id="aws-iam-privesc-attach-role-policy-assume-role",
+ name="Managed Policy Attachment with Role Assumption for Lateral Movement (IAM-014)",
+ short_description="Attach administrative managed policies to another role you can assume, then assume it to gain elevated privileges.",
+ description="Detect principals who can attach managed policies to a different IAM role and also assume that role. By attaching AdministratorAccess to a target role and then assuming it, the attacker gains full administrative access. This is a variation of IAM-009 for lateral movement where the principal targets another assumable role instead of their own.",
+ attribution=AttackPathsQueryAttribution(
+ text="pathfinding.cloud - IAM-014 - iam:AttachRolePolicy + sts:AssumeRole",
+ link="https://pathfinding.cloud/paths/iam-014",
+ ),
+ provider="aws",
+ cypher=f"""
+ // Find principals with iam:AttachRolePolicy permission
+ MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(policy:AWSPolicy)--(stmt:AWSPolicyStatement)
+ WHERE stmt.effect = 'Allow'
+ AND any(action IN stmt.action WHERE
+ toLower(action) = 'iam:attachrolepolicy'
+ OR toLower(action) = 'iam:*'
+ OR action = '*'
+ )
+
+ // Find target roles the principal can assume and attach policies to
+ MATCH path_target = (aws)--(target_role:AWSRole)<-[:STS_ASSUMEROLE_ALLOW]-(principal)
+ WHERE any(resource IN stmt.resource WHERE
+ resource = '*'
+ OR target_role.arn CONTAINS resource
+ OR resource CONTAINS target_role.name
+ )
+
+ WITH collect(path_principal) + collect(path_target) AS paths
+ UNWIND paths AS p
+ UNWIND nodes(p) AS n
+
+ WITH paths, collect(DISTINCT n) AS unique_nodes
+ UNWIND unique_nodes AS n
+
+ OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}})
+
+ RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr
+ """,
+ parameters=[],
+)
+
+# IAM-015
+AWS_IAM_PRIVESC_ATTACH_USER_POLICY_CREATE_ACCESS_KEY = AttackPathsQueryDefinition(
+ id="aws-iam-privesc-attach-user-policy-create-access-key",
+ name="Managed Policy Attachment with Access Key Creation for Lateral Movement (IAM-015)",
+ short_description="Attach administrative managed policies to another IAM user and create access keys for them to gain programmatic access with elevated privileges.",
+ description="Detect principals who can attach managed policies to another IAM user and also create access keys for that user. By attaching AdministratorAccess to a target user and creating access keys, the attacker gains programmatic access with the target user's elevated permissions. This combines IAM-008 (AttachUserPolicy) with IAM-002 (CreateAccessKey) for lateral movement.",
+ attribution=AttackPathsQueryAttribution(
+ text="pathfinding.cloud - IAM-015 - iam:AttachUserPolicy + iam:CreateAccessKey",
+ link="https://pathfinding.cloud/paths/iam-015",
+ ),
+ provider="aws",
+ cypher=f"""
+ // Find principals with iam:AttachUserPolicy permission
+ MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(policy:AWSPolicy)--(stmt:AWSPolicyStatement)
+ WHERE stmt.effect = 'Allow'
+ AND any(action IN stmt.action WHERE
+ toLower(action) = 'iam:attachuserpolicy'
+ OR toLower(action) = 'iam:*'
+ OR action = '*'
+ )
+
+ // Find iam:CreateAccessKey permission
+ MATCH (principal)--(policy2:AWSPolicy)--(stmt2:AWSPolicyStatement)
+ WHERE stmt2.effect = 'Allow'
+ AND any(action IN stmt2.action WHERE
+ toLower(action) = 'iam:createaccesskey'
+ OR toLower(action) = 'iam:*'
+ OR action = '*'
+ )
+
+ // Find target users the principal can attach policies to and create keys for
+ MATCH path_target = (aws)--(target_user:AWSUser)
+ WHERE any(resource IN stmt.resource WHERE
+ resource = '*'
+ OR target_user.arn CONTAINS resource
+ OR resource CONTAINS target_user.name
+ )
+ AND any(resource IN stmt2.resource WHERE
+ resource = '*'
+ OR target_user.arn CONTAINS resource
+ OR resource CONTAINS target_user.name
+ )
+
+ WITH collect(path_principal) + collect(path_target) AS paths
+ UNWIND paths AS p
+ UNWIND nodes(p) AS n
+
+ WITH paths, collect(DISTINCT n) AS unique_nodes
+ UNWIND unique_nodes AS n
+
+ OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}})
+
+ RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr
+ """,
+ parameters=[],
+)
+
+# IAM-016
+AWS_IAM_PRIVESC_CREATE_POLICY_VERSION_ASSUME_ROLE = AttackPathsQueryDefinition(
+ id="aws-iam-privesc-create-policy-version-assume-role",
+ name="Policy Version Override with Role Assumption for Lateral Movement (IAM-016)",
+ short_description="Create a new version of a customer-managed policy attached to another role with administrative permissions, then assume that role to gain elevated access.",
+ description="Detect principals who can create new versions of customer-managed policies attached to other roles and also assume those roles. By creating a new policy version with administrative permissions on a policy attached to a target role, then assuming that role, the attacker gains full administrative access. This is a variation of IAM-001 for lateral movement where the modified policy is attached to an assumable role rather than the attacker's own principal.",
+ attribution=AttackPathsQueryAttribution(
+ text="pathfinding.cloud - IAM-016 - iam:CreatePolicyVersion + sts:AssumeRole",
+ link="https://pathfinding.cloud/paths/iam-016",
+ ),
+ provider="aws",
+ cypher=f"""
+ // Find principals with iam:CreatePolicyVersion permission
+ MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(policy:AWSPolicy)--(stmt:AWSPolicyStatement)
+ WHERE stmt.effect = 'Allow'
+ AND any(action IN stmt.action WHERE
+ toLower(action) = 'iam:createpolicyversion'
+ OR toLower(action) = 'iam:*'
+ OR action = '*'
+ )
+
+ // Find target roles the principal can assume that have customer-managed policies the principal can modify
+ MATCH path_target = (aws)--(target_role:AWSRole)<-[:STS_ASSUMEROLE_ALLOW]-(principal)
+ MATCH (target_role)--(target_policy:AWSPolicy)
+ WHERE target_policy.arn CONTAINS $provider_uid
+ AND any(resource IN stmt.resource WHERE
+ resource = '*'
+ OR target_policy.arn CONTAINS resource
+ )
+
+ WITH collect(path_principal) + collect(path_target) AS paths
+ UNWIND paths AS p
+ UNWIND nodes(p) AS n
+
+ WITH paths, collect(DISTINCT n) AS unique_nodes
+ UNWIND unique_nodes AS n
+
+ OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}})
+
+ RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr
+ """,
+ parameters=[],
+)
+
+# IAM-017
+AWS_IAM_PRIVESC_PUT_ROLE_POLICY_ASSUME_ROLE = AttackPathsQueryDefinition(
+ id="aws-iam-privesc-put-role-policy-assume-role",
+ name="Inline Policy Injection with Role Assumption for Lateral Movement (IAM-017)",
+ short_description="Attach an inline policy with administrative permissions to another role you can assume, then assume it to gain elevated privileges.",
+ description="Detect principals who can add inline policies to a different IAM role and also assume that role. By adding an inline policy granting administrative permissions to a target role and then assuming it, the attacker gains full administrative access. This is a variation of IAM-005 for lateral movement where the principal targets another assumable role instead of their own.",
+ attribution=AttackPathsQueryAttribution(
+ text="pathfinding.cloud - IAM-017 - iam:PutRolePolicy + sts:AssumeRole",
+ link="https://pathfinding.cloud/paths/iam-017",
+ ),
+ provider="aws",
+ cypher=f"""
+ // Find principals with iam:PutRolePolicy permission
+ MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(policy:AWSPolicy)--(stmt:AWSPolicyStatement)
+ WHERE stmt.effect = 'Allow'
+ AND any(action IN stmt.action WHERE
+ toLower(action) = 'iam:putrolepolicy'
+ OR toLower(action) = 'iam:*'
+ OR action = '*'
+ )
+
+ // Find target roles the principal can assume and put inline policies on
+ MATCH path_target = (aws)--(target_role:AWSRole)<-[:STS_ASSUMEROLE_ALLOW]-(principal)
+ WHERE any(resource IN stmt.resource WHERE
+ resource = '*'
+ OR target_role.arn CONTAINS resource
+ OR resource CONTAINS target_role.name
+ )
+
+ WITH collect(path_principal) + collect(path_target) AS paths
+ UNWIND paths AS p
+ UNWIND nodes(p) AS n
+
+ WITH paths, collect(DISTINCT n) AS unique_nodes
+ UNWIND unique_nodes AS n
+
+ OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}})
+
+ RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr
+ """,
+ parameters=[],
+)
+
+# IAM-018
+AWS_IAM_PRIVESC_PUT_USER_POLICY_CREATE_ACCESS_KEY = AttackPathsQueryDefinition(
+ id="aws-iam-privesc-put-user-policy-create-access-key",
+ name="Inline Policy Injection with Access Key Creation for Lateral Movement (IAM-018)",
+ short_description="Attach an inline policy with administrative permissions to another IAM user and create access keys for them to gain programmatic access with elevated privileges.",
+ description="Detect principals who can add inline policies to another IAM user and also create access keys for that user. By adding an administrative inline policy to a target user and creating access keys, the attacker gains programmatic access with the target user's elevated permissions. This combines IAM-007 (PutUserPolicy) with IAM-002 (CreateAccessKey) for lateral movement.",
+ attribution=AttackPathsQueryAttribution(
+ text="pathfinding.cloud - IAM-018 - iam:PutUserPolicy + iam:CreateAccessKey",
+ link="https://pathfinding.cloud/paths/iam-018",
+ ),
+ provider="aws",
+ cypher=f"""
+ // Find principals with iam:PutUserPolicy permission
+ MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(policy:AWSPolicy)--(stmt:AWSPolicyStatement)
+ WHERE stmt.effect = 'Allow'
+ AND any(action IN stmt.action WHERE
+ toLower(action) = 'iam:putuserpolicy'
+ OR toLower(action) = 'iam:*'
+ OR action = '*'
+ )
+
+ // Find iam:CreateAccessKey permission
+ MATCH (principal)--(policy2:AWSPolicy)--(stmt2:AWSPolicyStatement)
+ WHERE stmt2.effect = 'Allow'
+ AND any(action IN stmt2.action WHERE
+ toLower(action) = 'iam:createaccesskey'
+ OR toLower(action) = 'iam:*'
+ OR action = '*'
+ )
+
+ // Find target users the principal can put policies on and create keys for
+ MATCH path_target = (aws)--(target_user:AWSUser)
+ WHERE any(resource IN stmt.resource WHERE
+ resource = '*'
+ OR target_user.arn CONTAINS resource
+ OR resource CONTAINS target_user.name
+ )
+ AND any(resource IN stmt2.resource WHERE
+ resource = '*'
+ OR target_user.arn CONTAINS resource
+ OR resource CONTAINS target_user.name
+ )
+
+ WITH collect(path_principal) + collect(path_target) AS paths
+ UNWIND paths AS p
+ UNWIND nodes(p) AS n
+
+ WITH paths, collect(DISTINCT n) AS unique_nodes
+ UNWIND unique_nodes AS n
+
+ OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}})
+
+ RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr
+ """,
+ parameters=[],
+)
+
+# IAM-019
+AWS_IAM_PRIVESC_ATTACH_ROLE_POLICY_UPDATE_ASSUME_ROLE = AttackPathsQueryDefinition(
+ id="aws-iam-privesc-attach-role-policy-update-assume-role",
+ name="Managed Policy Attachment with Trust Policy Hijacking for Privilege Escalation (IAM-019)",
+ short_description="Attach administrative managed policies to a role and modify its trust policy to allow yourself to assume it, gaining elevated privileges without prior assume-role access.",
+ description="Detect principals who can attach managed policies to an IAM role and also update that role's trust policy. By attaching AdministratorAccess and modifying the trust policy to allow the attacker, the principal can then assume the role without needing pre-existing sts:AssumeRole permission. This combines IAM-009 (AttachRolePolicy) with IAM-012 (UpdateAssumeRolePolicy).",
+ attribution=AttackPathsQueryAttribution(
+ text="pathfinding.cloud - IAM-019 - iam:AttachRolePolicy + iam:UpdateAssumeRolePolicy",
+ link="https://pathfinding.cloud/paths/iam-019",
+ ),
+ provider="aws",
+ cypher=f"""
+ // Find principals with iam:AttachRolePolicy permission
+ MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(policy:AWSPolicy)--(stmt:AWSPolicyStatement)
+ WHERE stmt.effect = 'Allow'
+ AND any(action IN stmt.action WHERE
+ toLower(action) = 'iam:attachrolepolicy'
+ OR toLower(action) = 'iam:*'
+ OR action = '*'
+ )
+
+ // Find iam:UpdateAssumeRolePolicy permission
+ MATCH (principal)--(policy2:AWSPolicy)--(stmt2:AWSPolicyStatement)
+ WHERE stmt2.effect = 'Allow'
+ AND any(action IN stmt2.action WHERE
+ toLower(action) = 'iam:updateassumerolepolicy'
+ OR toLower(action) = 'iam:*'
+ OR action = '*'
+ )
+
+ // Find target roles the principal can attach policies to and update trust policy for
+ MATCH path_target = (aws)--(target_role:AWSRole)
+ WHERE any(resource IN stmt.resource WHERE
+ resource = '*'
+ OR target_role.arn CONTAINS resource
+ OR resource CONTAINS target_role.name
+ )
+ AND any(resource IN stmt2.resource WHERE
+ resource = '*'
+ OR target_role.arn CONTAINS resource
+ OR resource CONTAINS target_role.name
+ )
+
+ WITH collect(path_principal) + collect(path_target) AS paths
+ UNWIND paths AS p
+ UNWIND nodes(p) AS n
+
+ WITH paths, collect(DISTINCT n) AS unique_nodes
+ UNWIND unique_nodes AS n
+
+ OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}})
+
+ RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr
+ """,
+ parameters=[],
+)
+
+# IAM-020
+AWS_IAM_PRIVESC_CREATE_POLICY_VERSION_UPDATE_ASSUME_ROLE = AttackPathsQueryDefinition(
+ id="aws-iam-privesc-create-policy-version-update-assume-role",
+ name="Policy Version Override with Trust Policy Hijacking for Privilege Escalation (IAM-020)",
+ short_description="Create a new version of a customer-managed policy attached to a role with administrative permissions and modify its trust policy to assume it, without prior assume-role access.",
+ description="Detect principals who can create new versions of customer-managed policies attached to roles and also update those roles' trust policies. By creating an administrative policy version and modifying the trust policy to allow the attacker, the principal can assume the role without needing pre-existing sts:AssumeRole permission. This combines IAM-001 (CreatePolicyVersion) with IAM-012 (UpdateAssumeRolePolicy).",
+ attribution=AttackPathsQueryAttribution(
+ text="pathfinding.cloud - IAM-020 - iam:CreatePolicyVersion + iam:UpdateAssumeRolePolicy",
+ link="https://pathfinding.cloud/paths/iam-020",
+ ),
+ provider="aws",
+ cypher=f"""
+ // Find principals with iam:CreatePolicyVersion permission
+ MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(policy:AWSPolicy)--(stmt:AWSPolicyStatement)
+ WHERE stmt.effect = 'Allow'
+ AND any(action IN stmt.action WHERE
+ toLower(action) = 'iam:createpolicyversion'
+ OR toLower(action) = 'iam:*'
+ OR action = '*'
+ )
+
+ // Find iam:UpdateAssumeRolePolicy permission
+ MATCH (principal)--(policy2:AWSPolicy)--(stmt2:AWSPolicyStatement)
+ WHERE stmt2.effect = 'Allow'
+ AND any(action IN stmt2.action WHERE
+ toLower(action) = 'iam:updateassumerolepolicy'
+ OR toLower(action) = 'iam:*'
+ OR action = '*'
+ )
+
+ // Find target roles with customer-managed policies the principal can modify and update trust policy for
+ MATCH path_target = (aws)--(target_role:AWSRole)
+ WHERE any(resource IN stmt2.resource WHERE
+ resource = '*'
+ OR target_role.arn CONTAINS resource
+ OR resource CONTAINS target_role.name
+ )
+ MATCH (target_role)--(target_policy:AWSPolicy)
+ WHERE target_policy.arn CONTAINS $provider_uid
+ AND any(resource IN stmt.resource WHERE
+ resource = '*'
+ OR target_policy.arn CONTAINS resource
+ )
+
+ WITH collect(path_principal) + collect(path_target) AS paths
+ UNWIND paths AS p
+ UNWIND nodes(p) AS n
+
+ WITH paths, collect(DISTINCT n) AS unique_nodes
+ UNWIND unique_nodes AS n
+
+ OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}})
+
+ RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr
+ """,
+ parameters=[],
+)
+
+# IAM-021
+AWS_IAM_PRIVESC_PUT_ROLE_POLICY_UPDATE_ASSUME_ROLE = AttackPathsQueryDefinition(
+ id="aws-iam-privesc-put-role-policy-update-assume-role",
+ name="Inline Policy Injection with Trust Policy Hijacking for Privilege Escalation (IAM-021)",
+ short_description="Add an inline policy with administrative permissions to a role and modify its trust policy to allow yourself to assume it, gaining elevated privileges without prior assume-role access.",
+ description="Detect principals who can add inline policies to an IAM role and also update that role's trust policy. By adding an administrative inline policy and modifying the trust policy to allow the attacker, the principal can then assume the role without needing pre-existing sts:AssumeRole permission. This combines IAM-005 (PutRolePolicy) with IAM-012 (UpdateAssumeRolePolicy).",
+ attribution=AttackPathsQueryAttribution(
+ text="pathfinding.cloud - IAM-021 - iam:PutRolePolicy + iam:UpdateAssumeRolePolicy",
+ link="https://pathfinding.cloud/paths/iam-021",
+ ),
+ provider="aws",
+ cypher=f"""
+ // Find principals with iam:PutRolePolicy permission
+ MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(policy:AWSPolicy)--(stmt:AWSPolicyStatement)
+ WHERE stmt.effect = 'Allow'
+ AND any(action IN stmt.action WHERE
+ toLower(action) = 'iam:putrolepolicy'
+ OR toLower(action) = 'iam:*'
+ OR action = '*'
+ )
+
+ // Find iam:UpdateAssumeRolePolicy permission
+ MATCH (principal)--(policy2:AWSPolicy)--(stmt2:AWSPolicyStatement)
+ WHERE stmt2.effect = 'Allow'
+ AND any(action IN stmt2.action WHERE
+ toLower(action) = 'iam:updateassumerolepolicy'
+ OR toLower(action) = 'iam:*'
+ OR action = '*'
+ )
+
+ // Find target roles the principal can put inline policies on and update trust policy for
+ MATCH path_target = (aws)--(target_role:AWSRole)
+ WHERE any(resource IN stmt.resource WHERE
+ resource = '*'
+ OR target_role.arn CONTAINS resource
+ OR resource CONTAINS target_role.name
+ )
+ AND any(resource IN stmt2.resource WHERE
+ resource = '*'
+ OR target_role.arn CONTAINS resource
+ OR resource CONTAINS target_role.name
+ )
+
+ WITH collect(path_principal) + collect(path_target) AS paths
+ UNWIND paths AS p
+ UNWIND nodes(p) AS n
+
+ WITH paths, collect(DISTINCT n) AS unique_nodes
+ UNWIND unique_nodes AS n
+
+ OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}})
+
+ RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr
+ """,
+ parameters=[],
+)
+
+# LAMBDA-001
+AWS_LAMBDA_PRIVESC_PASSROLE_CREATE_FUNCTION = AttackPathsQueryDefinition(
+ id="aws-lambda-privesc-passrole-create-function",
+ name="Lambda Function Creation with Privileged Role (LAMBDA-001)",
+ short_description="Create a Lambda function with a privileged IAM role and invoke it to execute code with that role's permissions.",
+ description="Detect principals who can create Lambda functions with privileged IAM roles and invoke them. By passing a privileged role to a new Lambda function and invoking it, the attacker executes code with the role's permissions, gaining access to any resources the role can access.",
+ attribution=AttackPathsQueryAttribution(
+ text="pathfinding.cloud - LAMBDA-001 - iam:PassRole + lambda:CreateFunction + lambda:InvokeFunction",
+ link="https://pathfinding.cloud/paths/lambda-001",
+ ),
+ provider="aws",
+ cypher=f"""
+ // Find principals with iam:PassRole permission
+ MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(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 lambda:CreateFunction permission
+ MATCH (principal)--(create_policy:AWSPolicy)--(stmt_create:AWSPolicyStatement)
+ WHERE stmt_create.effect = 'Allow'
+ AND any(action IN stmt_create.action WHERE
+ toLower(action) = 'lambda:createfunction'
+ OR toLower(action) = 'lambda:*'
+ OR action = '*'
+ )
+
+ // Find lambda:InvokeFunction permission
+ MATCH (principal)--(invoke_policy:AWSPolicy)--(stmt_invoke:AWSPolicyStatement)
+ WHERE stmt_invoke.effect = 'Allow'
+ AND any(action IN stmt_invoke.action WHERE
+ toLower(action) = 'lambda:invokefunction'
+ OR toLower(action) = 'lambda:*'
+ OR action = '*'
+ )
+
+ // Find roles that trust Lambda service (can be passed to Lambda)
+ MATCH path_target = (aws)--(target_role:AWSRole)-[:TRUSTS_AWS_PRINCIPAL]->(:AWSPrincipal {{arn: 'lambda.amazonaws.com'}})
+ WHERE any(resource IN stmt_passrole.resource WHERE
+ resource = '*'
+ OR target_role.arn CONTAINS resource
+ OR resource CONTAINS target_role.name
+ )
+
+ WITH collect(path_principal) + collect(path_target) AS paths
+ UNWIND paths AS p
+ UNWIND nodes(p) AS n
+
+ WITH paths, collect(DISTINCT n) AS unique_nodes
+ UNWIND unique_nodes AS n
+
+ OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}})
+
+ RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr
+ """,
+ parameters=[],
+)
+
+# LAMBDA-002
+AWS_LAMBDA_PRIVESC_PASSROLE_CREATE_FUNCTION_EVENT_SOURCE = AttackPathsQueryDefinition(
+ id="aws-lambda-privesc-passrole-create-function-event-source",
+ name="Lambda Function Creation with Event Source Trigger (LAMBDA-002)",
+ short_description="Create a Lambda function with a privileged IAM role and an event source mapping to trigger it automatically, executing code with the role's permissions.",
+ description="Detect principals who can create Lambda functions with privileged IAM roles and configure event source mappings to trigger them. By passing a privileged role to a new Lambda function and creating an event source mapping (DynamoDB stream, Kinesis, SQS), the attacker executes code with elevated privileges without needing to invoke the function directly.",
+ attribution=AttackPathsQueryAttribution(
+ text="pathfinding.cloud - LAMBDA-002 - iam:PassRole + lambda:CreateFunction + lambda:CreateEventSourceMapping",
+ link="https://pathfinding.cloud/paths/lambda-002",
+ ),
+ provider="aws",
+ cypher=f"""
+ // Find principals with iam:PassRole permission
+ MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(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 lambda:CreateFunction permission
+ MATCH (principal)--(create_policy:AWSPolicy)--(stmt_create:AWSPolicyStatement)
+ WHERE stmt_create.effect = 'Allow'
+ AND any(action IN stmt_create.action WHERE
+ toLower(action) = 'lambda:createfunction'
+ OR toLower(action) = 'lambda:*'
+ OR action = '*'
+ )
+
+ // Find lambda:CreateEventSourceMapping permission
+ MATCH (principal)--(event_policy:AWSPolicy)--(stmt_event:AWSPolicyStatement)
+ WHERE stmt_event.effect = 'Allow'
+ AND any(action IN stmt_event.action WHERE
+ toLower(action) = 'lambda:createeventsourcemapping'
+ OR toLower(action) = 'lambda:*'
+ OR action = '*'
+ )
+
+ // Find roles that trust Lambda service (can be passed to Lambda)
+ MATCH path_target = (aws)--(target_role:AWSRole)-[:TRUSTS_AWS_PRINCIPAL]->(:AWSPrincipal {{arn: 'lambda.amazonaws.com'}})
+ WHERE any(resource IN stmt_passrole.resource WHERE
+ resource = '*'
+ OR target_role.arn CONTAINS resource
+ OR resource CONTAINS target_role.name
+ )
+
+ WITH collect(path_principal) + collect(path_target) AS paths
+ UNWIND paths AS p
+ UNWIND nodes(p) AS n
+
+ WITH paths, collect(DISTINCT n) AS unique_nodes
+ UNWIND unique_nodes AS n
+
+ OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}})
+
+ RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr
+ """,
+ parameters=[],
+)
+
+# LAMBDA-003
+AWS_LAMBDA_PRIVESC_UPDATE_FUNCTION_CODE = AttackPathsQueryDefinition(
+ id="aws-lambda-privesc-update-function-code",
+ name="Lambda Function Code Injection (LAMBDA-003)",
+ short_description="Modify the code of an existing Lambda function to execute arbitrary commands with the function's execution role permissions.",
+ description="Detect principals who can update the code of existing Lambda functions. By replacing a Lambda function's code with malicious code, the attacker executes arbitrary commands with the privileges of the function's execution role when it is next invoked, either manually or via automatic triggers.",
+ attribution=AttackPathsQueryAttribution(
+ text="pathfinding.cloud - LAMBDA-003 - lambda:UpdateFunctionCode",
+ link="https://pathfinding.cloud/paths/lambda-003",
+ ),
+ provider="aws",
+ cypher=f"""
+ // Find principals with lambda:UpdateFunctionCode permission
+ MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(policy:AWSPolicy)--(stmt:AWSPolicyStatement)
+ WHERE stmt.effect = 'Allow'
+ AND any(action IN stmt.action WHERE
+ toLower(action) = 'lambda:updatefunctioncode'
+ OR toLower(action) = 'lambda:*'
+ OR action = '*'
+ )
+
+ // Find existing Lambda functions with execution roles
+ MATCH path_target = (aws)-[:RESOURCE]->(lambda_fn:AWSLambda)-[:STS_ASSUMEROLE_ALLOW]->(target_role:AWSRole)
+ WHERE any(resource IN stmt.resource WHERE
+ resource = '*'
+ OR lambda_fn.arn CONTAINS resource
+ OR resource CONTAINS lambda_fn.name
+ )
+
+ WITH collect(path_principal) + collect(path_target) AS paths
+ UNWIND paths AS p
+ UNWIND nodes(p) AS n
+
+ WITH paths, collect(DISTINCT n) AS unique_nodes
+ UNWIND unique_nodes AS n
+
+ OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}})
+
+ RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr
+ """,
+ parameters=[],
+)
+
+# LAMBDA-004
+AWS_LAMBDA_PRIVESC_UPDATE_FUNCTION_CODE_INVOKE = AttackPathsQueryDefinition(
+ id="aws-lambda-privesc-update-function-code-invoke",
+ name="Lambda Function Code Injection with Direct Invocation (LAMBDA-004)",
+ short_description="Modify the code of an existing Lambda function and invoke it directly to execute arbitrary commands with the function's execution role permissions.",
+ description="Detect principals who can update the code of existing Lambda functions and invoke them. By replacing a Lambda function's code with malicious code and invoking it directly, the attacker executes arbitrary commands with the privileges of the function's execution role immediately, without waiting for automatic triggers.",
+ attribution=AttackPathsQueryAttribution(
+ text="pathfinding.cloud - LAMBDA-004 - lambda:UpdateFunctionCode + lambda:InvokeFunction",
+ link="https://pathfinding.cloud/paths/lambda-004",
+ ),
+ provider="aws",
+ cypher=f"""
+ // Find principals with lambda:UpdateFunctionCode permission
+ MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(policy:AWSPolicy)--(stmt:AWSPolicyStatement)
+ WHERE stmt.effect = 'Allow'
+ AND any(action IN stmt.action WHERE
+ toLower(action) = 'lambda:updatefunctioncode'
+ OR toLower(action) = 'lambda:*'
+ OR action = '*'
+ )
+
+ // Find lambda:InvokeFunction permission
+ MATCH (principal)--(policy2:AWSPolicy)--(stmt2:AWSPolicyStatement)
+ WHERE stmt2.effect = 'Allow'
+ AND any(action IN stmt2.action WHERE
+ toLower(action) = 'lambda:invokefunction'
+ OR toLower(action) = 'lambda:*'
+ OR action = '*'
+ )
+
+ // Find existing Lambda functions with execution roles
+ MATCH path_target = (aws)-[:RESOURCE]->(lambda_fn:AWSLambda)-[:STS_ASSUMEROLE_ALLOW]->(target_role:AWSRole)
+ WHERE any(resource IN stmt.resource WHERE
+ resource = '*'
+ OR lambda_fn.arn CONTAINS resource
+ OR resource CONTAINS lambda_fn.name
+ )
+ AND any(resource IN stmt2.resource WHERE
+ resource = '*'
+ OR lambda_fn.arn CONTAINS resource
+ OR resource CONTAINS lambda_fn.name
+ )
+
+ WITH collect(path_principal) + collect(path_target) AS paths
+ UNWIND paths AS p
+ UNWIND nodes(p) AS n
+
+ WITH paths, collect(DISTINCT n) AS unique_nodes
+ UNWIND unique_nodes AS n
+
+ OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}})
+
+ RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr
+ """,
+ parameters=[],
+)
+
+# LAMBDA-005
+AWS_LAMBDA_PRIVESC_UPDATE_FUNCTION_CODE_ADD_PERMISSION = AttackPathsQueryDefinition(
+ id="aws-lambda-privesc-update-function-code-add-permission",
+ name="Lambda Function Code Injection with Resource Policy Grant (LAMBDA-005)",
+ short_description="Modify the code of an existing Lambda function and grant yourself invocation permission via its resource-based policy to execute code with the function's execution role.",
+ description="Detect principals who can update the code of existing Lambda functions and add permissions to their resource-based policies. By replacing a Lambda function's code and granting themselves invoke access through the resource-based policy, the attacker executes malicious code with the function's execution role without needing lambda:InvokeFunction as an IAM permission.",
+ attribution=AttackPathsQueryAttribution(
+ text="pathfinding.cloud - LAMBDA-005 - lambda:UpdateFunctionCode + lambda:AddPermission",
+ link="https://pathfinding.cloud/paths/lambda-005",
+ ),
+ provider="aws",
+ cypher=f"""
+ // Find principals with lambda:UpdateFunctionCode permission
+ MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(policy:AWSPolicy)--(stmt:AWSPolicyStatement)
+ WHERE stmt.effect = 'Allow'
+ AND any(action IN stmt.action WHERE
+ toLower(action) = 'lambda:updatefunctioncode'
+ OR toLower(action) = 'lambda:*'
+ OR action = '*'
+ )
+
+ // Find lambda:AddPermission permission
+ MATCH (principal)--(policy2:AWSPolicy)--(stmt2:AWSPolicyStatement)
+ WHERE stmt2.effect = 'Allow'
+ AND any(action IN stmt2.action WHERE
+ toLower(action) = 'lambda:addpermission'
+ OR toLower(action) = 'lambda:*'
+ OR action = '*'
+ )
+
+ // Find existing Lambda functions with execution roles
+ MATCH path_target = (aws)-[:RESOURCE]->(lambda_fn:AWSLambda)-[:STS_ASSUMEROLE_ALLOW]->(target_role:AWSRole)
+ WHERE any(resource IN stmt.resource WHERE
+ resource = '*'
+ OR lambda_fn.arn CONTAINS resource
+ OR resource CONTAINS lambda_fn.name
+ )
+ AND any(resource IN stmt2.resource WHERE
+ resource = '*'
+ OR lambda_fn.arn CONTAINS resource
+ OR resource CONTAINS lambda_fn.name
+ )
+
+ WITH collect(path_principal) + collect(path_target) AS paths
+ UNWIND paths AS p
+ UNWIND nodes(p) AS n
+
+ WITH paths, collect(DISTINCT n) AS unique_nodes
+ UNWIND unique_nodes AS n
+
+ OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}})
+
+ RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr
+ """,
+ parameters=[],
+)
+
+# LAMBDA-006
+AWS_LAMBDA_PRIVESC_PASSROLE_CREATE_FUNCTION_ADD_PERMISSION = AttackPathsQueryDefinition(
+ id="aws-lambda-privesc-passrole-create-function-add-permission",
+ name="Lambda Function Creation with Resource Policy Invocation (LAMBDA-006)",
+ short_description="Create a Lambda function with a privileged IAM role and grant yourself invocation permission via its resource-based policy to execute code with the role's permissions.",
+ description="Detect principals who can create Lambda functions with privileged IAM roles and add permissions to their resource-based policies. By passing a privileged role to a new Lambda function and granting themselves invoke access through the resource-based policy, the attacker executes malicious code with elevated privileges without needing lambda:InvokeFunction as an IAM permission.",
+ attribution=AttackPathsQueryAttribution(
+ text="pathfinding.cloud - LAMBDA-006 - iam:PassRole + lambda:CreateFunction + lambda:AddPermission",
+ link="https://pathfinding.cloud/paths/lambda-006",
+ ),
+ provider="aws",
+ cypher=f"""
+ // Find principals with iam:PassRole permission
+ MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(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 lambda:CreateFunction permission
+ MATCH (principal)--(create_policy:AWSPolicy)--(stmt_create:AWSPolicyStatement)
+ WHERE stmt_create.effect = 'Allow'
+ AND any(action IN stmt_create.action WHERE
+ toLower(action) = 'lambda:createfunction'
+ OR toLower(action) = 'lambda:*'
+ OR action = '*'
+ )
+
+ // Find lambda:AddPermission permission
+ MATCH (principal)--(perm_policy:AWSPolicy)--(stmt_perm:AWSPolicyStatement)
+ WHERE stmt_perm.effect = 'Allow'
+ AND any(action IN stmt_perm.action WHERE
+ toLower(action) = 'lambda:addpermission'
+ OR toLower(action) = 'lambda:*'
+ OR action = '*'
+ )
+
+ // Find roles that trust Lambda service (can be passed to Lambda)
+ MATCH path_target = (aws)--(target_role:AWSRole)-[:TRUSTS_AWS_PRINCIPAL]->(:AWSPrincipal {{arn: 'lambda.amazonaws.com'}})
+ WHERE any(resource IN stmt_passrole.resource WHERE
+ resource = '*'
+ OR target_role.arn CONTAINS resource
+ OR resource CONTAINS target_role.name
+ )
+
+ WITH collect(path_principal) + collect(path_target) AS paths
+ UNWIND paths AS p
+ UNWIND nodes(p) AS n
+
+ WITH paths, collect(DISTINCT n) AS unique_nodes
+ UNWIND unique_nodes AS n
+
+ OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}})
+
+ RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr
+ """,
+ parameters=[],
+)
+
+# SAGEMAKER-001
+AWS_SAGEMAKER_PRIVESC_PASSROLE_CREATE_NOTEBOOK = AttackPathsQueryDefinition(
+ id="aws-sagemaker-privesc-passrole-create-notebook",
+ name="SageMaker Notebook Creation with Privileged Role (SAGEMAKER-001)",
+ short_description="Create a SageMaker notebook instance with a privileged IAM role to execute arbitrary code with the role's permissions via the Jupyter environment.",
+ description="Detect principals who can create SageMaker notebook instances with privileged IAM roles. By passing a privileged role to a new notebook instance, the attacker gains shell access through the Jupyter environment and can execute arbitrary commands with the role's permissions.",
+ attribution=AttackPathsQueryAttribution(
+ text="pathfinding.cloud - SAGEMAKER-001 - iam:PassRole + sagemaker:CreateNotebookInstance",
+ link="https://pathfinding.cloud/paths/sagemaker-001",
+ ),
+ provider="aws",
+ cypher=f"""
+ // Find principals with iam:PassRole permission
+ MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(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 sagemaker:CreateNotebookInstance permission
+ MATCH (principal)--(sm_policy:AWSPolicy)--(stmt_sm:AWSPolicyStatement)
+ WHERE stmt_sm.effect = 'Allow'
+ AND any(action IN stmt_sm.action WHERE
+ toLower(action) = 'sagemaker:createnotebookinstance'
+ OR toLower(action) = 'sagemaker:*'
+ OR action = '*'
+ )
+
+ // Find roles that trust SageMaker service (can be passed to SageMaker)
+ MATCH path_target = (aws)--(target_role:AWSRole)-[:TRUSTS_AWS_PRINCIPAL]->(:AWSPrincipal {{arn: 'sagemaker.amazonaws.com'}})
+ WHERE any(resource IN stmt_passrole.resource WHERE
+ resource = '*'
+ OR target_role.arn CONTAINS resource
+ OR resource CONTAINS target_role.name
+ )
+
+ WITH collect(path_principal) + collect(path_target) AS paths
+ UNWIND paths AS p
+ UNWIND nodes(p) AS n
+
+ WITH paths, collect(DISTINCT n) AS unique_nodes
+ UNWIND unique_nodes AS n
+
+ OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}})
+
+ RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr
+ """,
+ parameters=[],
+)
+
+# SAGEMAKER-002
+AWS_SAGEMAKER_PRIVESC_PASSROLE_CREATE_TRAINING_JOB = AttackPathsQueryDefinition(
+ id="aws-sagemaker-privesc-passrole-create-training-job",
+ name="SageMaker Training Job Creation with Privileged Role (SAGEMAKER-002)",
+ short_description="Create a SageMaker training job with a privileged IAM role to execute arbitrary container code with the role's permissions.",
+ description="Detect principals who can create SageMaker training jobs with privileged IAM roles. By passing a privileged role to a new training job with a malicious training script or container, the attacker executes code with elevated privileges and can exfiltrate credentials or modify AWS resources.",
+ attribution=AttackPathsQueryAttribution(
+ text="pathfinding.cloud - SAGEMAKER-002 - iam:PassRole + sagemaker:CreateTrainingJob",
+ link="https://pathfinding.cloud/paths/sagemaker-002",
+ ),
+ provider="aws",
+ cypher=f"""
+ // Find principals with iam:PassRole permission
+ MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(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 sagemaker:CreateTrainingJob permission
+ MATCH (principal)--(sm_policy:AWSPolicy)--(stmt_sm:AWSPolicyStatement)
+ WHERE stmt_sm.effect = 'Allow'
+ AND any(action IN stmt_sm.action WHERE
+ toLower(action) = 'sagemaker:createtrainingjob'
+ OR toLower(action) = 'sagemaker:*'
+ OR action = '*'
+ )
+
+ // Find roles that trust SageMaker service (can be passed to SageMaker)
+ MATCH path_target = (aws)--(target_role:AWSRole)-[:TRUSTS_AWS_PRINCIPAL]->(:AWSPrincipal {{arn: 'sagemaker.amazonaws.com'}})
+ WHERE any(resource IN stmt_passrole.resource WHERE
+ resource = '*'
+ OR target_role.arn CONTAINS resource
+ OR resource CONTAINS target_role.name
+ )
+
+ WITH collect(path_principal) + collect(path_target) AS paths
+ UNWIND paths AS p
+ UNWIND nodes(p) AS n
+
+ WITH paths, collect(DISTINCT n) AS unique_nodes
+ UNWIND unique_nodes AS n
+
+ OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}})
+
+ RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr
+ """,
+ parameters=[],
+)
+
+# SAGEMAKER-003
+AWS_SAGEMAKER_PRIVESC_PASSROLE_CREATE_PROCESSING_JOB = AttackPathsQueryDefinition(
+ id="aws-sagemaker-privesc-passrole-create-processing-job",
+ name="SageMaker Processing Job Creation with Privileged Role (SAGEMAKER-003)",
+ short_description="Create a SageMaker processing job with a privileged IAM role to execute arbitrary container code with the role's permissions.",
+ description="Detect principals who can create SageMaker processing jobs with privileged IAM roles. By passing a privileged role to a new processing job with a malicious script or container, the attacker executes code with elevated privileges and can exfiltrate credentials or modify AWS resources.",
+ attribution=AttackPathsQueryAttribution(
+ text="pathfinding.cloud - SAGEMAKER-003 - iam:PassRole + sagemaker:CreateProcessingJob",
+ link="https://pathfinding.cloud/paths/sagemaker-003",
+ ),
+ provider="aws",
+ cypher=f"""
+ // Find principals with iam:PassRole permission
+ MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(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 sagemaker:CreateProcessingJob permission
+ MATCH (principal)--(sm_policy:AWSPolicy)--(stmt_sm:AWSPolicyStatement)
+ WHERE stmt_sm.effect = 'Allow'
+ AND any(action IN stmt_sm.action WHERE
+ toLower(action) = 'sagemaker:createprocessingjob'
+ OR toLower(action) = 'sagemaker:*'
+ OR action = '*'
+ )
+
+ // Find roles that trust SageMaker service (can be passed to SageMaker)
+ MATCH path_target = (aws)--(target_role:AWSRole)-[:TRUSTS_AWS_PRINCIPAL]->(:AWSPrincipal {{arn: 'sagemaker.amazonaws.com'}})
+ WHERE any(resource IN stmt_passrole.resource WHERE
+ resource = '*'
+ OR target_role.arn CONTAINS resource
+ OR resource CONTAINS target_role.name
+ )
+
+ WITH collect(path_principal) + collect(path_target) AS paths
+ UNWIND paths AS p
+ UNWIND nodes(p) AS n
+
+ WITH paths, collect(DISTINCT n) AS unique_nodes
+ UNWIND unique_nodes AS n
+
+ OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}})
+
+ RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr
+ """,
+ parameters=[],
+)
+
+# SAGEMAKER-004
+AWS_SAGEMAKER_PRIVESC_PRESIGNED_NOTEBOOK_URL = AttackPathsQueryDefinition(
+ id="aws-sagemaker-privesc-presigned-notebook-url",
+ name="SageMaker Presigned Notebook URL for Privilege Escalation (SAGEMAKER-004)",
+ short_description="Generate a presigned URL to access an existing SageMaker notebook instance and execute code with its execution role's permissions.",
+ description="Detect principals who can generate presigned URLs to access existing SageMaker notebook instances. By accessing the Jupyter environment via a presigned URL, the attacker can execute arbitrary code with the permissions of the notebook's execution role without creating any new resources.",
+ attribution=AttackPathsQueryAttribution(
+ text="pathfinding.cloud - SAGEMAKER-004 - sagemaker:CreatePresignedNotebookInstanceUrl",
+ link="https://pathfinding.cloud/paths/sagemaker-004",
+ ),
+ provider="aws",
+ cypher=f"""
+ // Find principals with sagemaker:CreatePresignedNotebookInstanceUrl permission
+ MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(policy:AWSPolicy)--(stmt:AWSPolicyStatement)
+ WHERE stmt.effect = 'Allow'
+ AND any(action IN stmt.action WHERE
+ toLower(action) = 'sagemaker:createpresignednotebookinstanceurl'
+ OR toLower(action) = 'sagemaker:*'
+ OR action = '*'
+ )
+
+ // Find existing SageMaker notebook instances with execution roles
+ MATCH path_target = (aws)-[:RESOURCE]->(notebook:AWSSageMakerNotebookInstance)-[:HAS_EXECUTION_ROLE]->(target_role:AWSRole)
+ WHERE any(resource IN stmt.resource WHERE
+ resource = '*'
+ OR notebook.arn CONTAINS resource
+ OR resource CONTAINS notebook.notebook_instance_name
+ )
+
+ WITH collect(path_principal) + collect(path_target) AS paths
+ UNWIND paths AS p
+ UNWIND nodes(p) AS n
+
+ WITH paths, collect(DISTINCT n) AS unique_nodes
+ UNWIND unique_nodes AS n
+
+ OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}})
+
+ RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr
+ """,
+ parameters=[],
+)
+
+# SAGEMAKER-005
+AWS_SAGEMAKER_PRIVESC_LIFECYCLE_CONFIG_NOTEBOOK = AttackPathsQueryDefinition(
+ id="aws-sagemaker-privesc-lifecycle-config-notebook",
+ name="SageMaker Notebook Lifecycle Config Injection (SAGEMAKER-005)",
+ short_description="Inject a malicious lifecycle configuration into an existing SageMaker notebook to execute code with the notebook's execution role during startup.",
+ description="Detect principals who can inject malicious lifecycle configurations into existing SageMaker notebook instances. By stopping a notebook, attaching a malicious lifecycle config, and restarting it, the attacker executes arbitrary code with the notebook's execution role permissions during startup.",
+ attribution=AttackPathsQueryAttribution(
+ text="pathfinding.cloud - SAGEMAKER-005 - sagemaker:CreateNotebookInstanceLifecycleConfig + sagemaker:StopNotebookInstance + sagemaker:UpdateNotebookInstance + sagemaker:StartNotebookInstance",
+ link="https://pathfinding.cloud/paths/sagemaker-005",
+ ),
+ provider="aws",
+ cypher=f"""
+ // Find principals with sagemaker:CreateNotebookInstanceLifecycleConfig permission
+ MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(policy:AWSPolicy)--(stmt:AWSPolicyStatement)
+ WHERE stmt.effect = 'Allow'
+ AND any(action IN stmt.action WHERE
+ toLower(action) = 'sagemaker:createnotebookinstancelifecycleconfig'
+ OR toLower(action) = 'sagemaker:*'
+ OR action = '*'
+ )
+
+ // Find sagemaker:UpdateNotebookInstance permission
+ MATCH (principal)--(policy2:AWSPolicy)--(stmt2:AWSPolicyStatement)
+ WHERE stmt2.effect = 'Allow'
+ AND any(action IN stmt2.action WHERE
+ toLower(action) = 'sagemaker:updatenotebookinstance'
+ OR toLower(action) = 'sagemaker:*'
+ OR action = '*'
+ )
+
+ // Find sagemaker:StopNotebookInstance permission
+ MATCH (principal)--(policy3:AWSPolicy)--(stmt3:AWSPolicyStatement)
+ WHERE stmt3.effect = 'Allow'
+ AND any(action IN stmt3.action WHERE
+ toLower(action) = 'sagemaker:stopnotebookinstance'
+ OR toLower(action) = 'sagemaker:*'
+ OR action = '*'
+ )
+
+ // Find sagemaker:StartNotebookInstance permission
+ MATCH (principal)--(policy4:AWSPolicy)--(stmt4:AWSPolicyStatement)
+ WHERE stmt4.effect = 'Allow'
+ AND any(action IN stmt4.action WHERE
+ toLower(action) = 'sagemaker:startnotebookinstance'
+ OR toLower(action) = 'sagemaker:*'
+ OR action = '*'
+ )
+
+ // Find existing SageMaker notebook instances with execution roles
+ MATCH path_target = (aws)-[:RESOURCE]->(notebook:AWSSageMakerNotebookInstance)-[:HAS_EXECUTION_ROLE]->(target_role:AWSRole)
+ WHERE any(resource IN stmt2.resource WHERE
+ resource = '*'
+ OR notebook.arn CONTAINS resource
+ OR resource CONTAINS notebook.notebook_instance_name
+ )
+
+ WITH collect(path_principal) + collect(path_target) AS paths
+ UNWIND paths AS p
+ UNWIND nodes(p) AS n
+
+ WITH paths, collect(DISTINCT n) AS unique_nodes
+ UNWIND unique_nodes AS n
+
+ OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}})
+
+ RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr
+ """,
+ parameters=[],
+)
+
+# SSM-001
+AWS_SSM_PRIVESC_START_SESSION = AttackPathsQueryDefinition(
+ id="aws-ssm-privesc-start-session",
+ name="SSM Session Access for EC2 Role Credentials (SSM-001)",
+ short_description="Start an SSM session on an EC2 instance to access its attached role credentials through IMDS.",
+ description="Detect principals who can start SSM sessions on EC2 instances. This allows establishing a shell session on a running EC2 instance and retrieving the attached IAM role's temporary credentials from the Instance Metadata Service (IMDS), gaining that role's permissions.",
+ attribution=AttackPathsQueryAttribution(
+ text="pathfinding.cloud - SSM-001 - ssm:StartSession",
+ link="https://pathfinding.cloud/paths/ssm-001",
+ ),
+ provider="aws",
+ cypher=f"""
+ // Find principals with ssm:StartSession permission
+ MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(policy:AWSPolicy)--(stmt:AWSPolicyStatement)
+ WHERE stmt.effect = 'Allow'
+ AND any(action IN stmt.action WHERE
+ toLower(action) = 'ssm:startsession'
+ OR toLower(action) = 'ssm:*'
+ OR action = '*'
+ )
+
+ // Find EC2 instances with attached roles (targets for credential theft via IMDS)
+ MATCH path_target = (aws)--(ec2:EC2Instance)-[:STS_ASSUMEROLE_ALLOW]->(target_role:AWSRole)
+
+ WITH collect(path_principal) + collect(path_target) AS paths
+ UNWIND paths AS p
+ UNWIND nodes(p) AS n
+
+ WITH paths, collect(DISTINCT n) AS unique_nodes
+ UNWIND unique_nodes AS n
+
+ OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}})
+
+ RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr
+ """,
+ parameters=[],
+)
+
+# SSM-002
+AWS_SSM_PRIVESC_SEND_COMMAND = AttackPathsQueryDefinition(
+ id="aws-ssm-privesc-send-command",
+ name="SSM Send Command for EC2 Role Credentials (SSM-002)",
+ short_description="Execute commands on an EC2 instance via SSM Run Command to access its attached role credentials through IMDS.",
+ description="Detect principals who can send SSM commands to EC2 instances. This allows executing arbitrary commands on a running EC2 instance and retrieving the attached IAM role's temporary credentials from the Instance Metadata Service (IMDS), gaining that role's permissions.",
+ attribution=AttackPathsQueryAttribution(
+ text="pathfinding.cloud - SSM-002 - ssm:SendCommand",
+ link="https://pathfinding.cloud/paths/ssm-002",
+ ),
+ provider="aws",
+ cypher=f"""
+ // Find principals with ssm:SendCommand permission
+ MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(policy:AWSPolicy)--(stmt:AWSPolicyStatement)
+ WHERE stmt.effect = 'Allow'
+ AND any(action IN stmt.action WHERE
+ toLower(action) = 'ssm:sendcommand'
+ OR toLower(action) = 'ssm:*'
+ OR action = '*'
+ )
+
+ // Find EC2 instances with attached roles (targets for credential theft via IMDS)
+ MATCH path_target = (aws)--(ec2:EC2Instance)-[:STS_ASSUMEROLE_ALLOW]->(target_role:AWSRole)
+
+ WITH collect(path_principal) + collect(path_target) AS paths
+ UNWIND paths AS p
+ UNWIND nodes(p) AS n
+
+ WITH paths, collect(DISTINCT n) AS unique_nodes
+ UNWIND unique_nodes AS n
+
+ OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}})
+
+ RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr
+ """,
+ parameters=[],
+)
+
+# STS-001
+AWS_STS_PRIVESC_ASSUME_ROLE = AttackPathsQueryDefinition(
+ id="aws-sts-privesc-assume-role",
+ name="Role Assumption for Privilege Escalation (STS-001)",
+ short_description="Assume IAM roles with elevated permissions by exploiting bidirectional trust between the starting principal and the target role.",
+ description="Detect principals who can assume other IAM roles via sts:AssumeRole. When a principal has sts:AssumeRole permission and the target role's trust policy allows the principal to assume it (bidirectional trust), the attacker gains all permissions of the target role. This enables privilege escalation when the target role has higher privileges than the starting principal.",
+ attribution=AttackPathsQueryAttribution(
+ text="pathfinding.cloud - STS-001 - sts:AssumeRole",
+ link="https://pathfinding.cloud/paths/sts-001",
+ ),
+ provider="aws",
+ cypher=f"""
+ // Find principals with sts:AssumeRole permission
+ MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)--(policy:AWSPolicy)--(stmt:AWSPolicyStatement)
+ WHERE stmt.effect = 'Allow'
+ AND any(action IN stmt.action WHERE
+ toLower(action) = 'sts:assumerole'
+ OR toLower(action) = 'sts:*'
+ OR action = '*'
+ )
+
+ // Find target roles the principal can assume (bidirectional trust via Cartography)
+ MATCH path_target = (aws)--(target_role:AWSRole)<-[:STS_ASSUMEROLE_ALLOW]-(principal)
+ WHERE any(resource IN stmt.resource WHERE
+ resource = '*'
+ OR target_role.arn CONTAINS resource
+ OR resource CONTAINS target_role.name
+ )
+
+ WITH collect(path_principal) + collect(path_target) AS paths
+ UNWIND paths AS p
+ UNWIND nodes(p) AS n
+
+ WITH paths, collect(DISTINCT n) AS unique_nodes
+ UNWIND unique_nodes AS n
+
+ OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}})
+
+ RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr
+ """,
+ parameters=[],
+)
+
+# AWS Queries List
+# ----------------
+
+AWS_DEPRECATED_QUERIES: list[AttackPathsQueryDefinition] = [
+ AWS_INTERNET_EXPOSED_EC2_SENSITIVE_S3_ACCESS,
+ AWS_RDS_INSTANCES,
+ AWS_RDS_UNENCRYPTED_STORAGE,
+ AWS_S3_ANONYMOUS_ACCESS_BUCKETS,
+ AWS_IAM_STATEMENTS_ALLOW_ALL_ACTIONS,
+ AWS_IAM_STATEMENTS_ALLOW_DELETE_POLICY,
+ AWS_IAM_STATEMENTS_ALLOW_CREATE_ACTIONS,
+ AWS_EC2_INSTANCES_INTERNET_EXPOSED,
+ AWS_SECURITY_GROUPS_OPEN_INTERNET_FACING,
+ AWS_CLASSIC_ELB_INTERNET_EXPOSED,
+ AWS_ELBV2_INTERNET_EXPOSED,
+ AWS_PUBLIC_IP_RESOURCE_LOOKUP,
+ AWS_APPRUNNER_PRIVESC_PASSROLE_CREATE_SERVICE,
+ AWS_APPRUNNER_PRIVESC_UPDATE_SERVICE,
+ AWS_BEDROCK_PRIVESC_PASSROLE_CODE_INTERPRETER,
+ AWS_BEDROCK_PRIVESC_INVOKE_CODE_INTERPRETER,
+ AWS_CLOUDFORMATION_PRIVESC_PASSROLE_CREATE_STACK,
+ AWS_CLOUDFORMATION_PRIVESC_UPDATE_STACK,
+ AWS_CLOUDFORMATION_PRIVESC_PASSROLE_CREATE_STACKSET,
+ AWS_CLOUDFORMATION_PRIVESC_PASSROLE_UPDATE_STACKSET,
+ AWS_CLOUDFORMATION_PRIVESC_CHANGESET,
+ AWS_CODEBUILD_PRIVESC_PASSROLE_CREATE_PROJECT,
+ AWS_CODEBUILD_PRIVESC_START_BUILD,
+ AWS_CODEBUILD_PRIVESC_START_BUILD_BATCH,
+ AWS_CODEBUILD_PRIVESC_PASSROLE_CREATE_PROJECT_BATCH,
+ AWS_DATAPIPELINE_PRIVESC_PASSROLE_CREATE_PIPELINE,
+ AWS_EC2_PRIVESC_PASSROLE_IAM,
+ AWS_EC2_PRIVESC_MODIFY_INSTANCE_ATTRIBUTE,
+ AWS_EC2_PRIVESC_PASSROLE_SPOT_INSTANCES,
+ AWS_EC2_PRIVESC_LAUNCH_TEMPLATE,
+ AWS_EC2INSTANCECONNECT_PRIVESC_SEND_SSH_PUBLIC_KEY,
+ AWS_ECS_PRIVESC_PASSROLE_CREATE_SERVICE,
+ AWS_ECS_PRIVESC_PASSROLE_RUN_TASK,
+ AWS_ECS_PRIVESC_PASSROLE_CREATE_SERVICE_EXISTING_CLUSTER,
+ AWS_ECS_PRIVESC_PASSROLE_RUN_TASK_EXISTING_CLUSTER,
+ AWS_ECS_PRIVESC_PASSROLE_START_TASK_EXISTING_CLUSTER,
+ AWS_ECS_PRIVESC_EXECUTE_COMMAND,
+ AWS_GLUE_PRIVESC_PASSROLE_DEV_ENDPOINT,
+ AWS_GLUE_PRIVESC_UPDATE_DEV_ENDPOINT,
+ AWS_GLUE_PRIVESC_PASSROLE_CREATE_JOB,
+ AWS_GLUE_PRIVESC_PASSROLE_CREATE_JOB_TRIGGER,
+ AWS_GLUE_PRIVESC_PASSROLE_UPDATE_JOB,
+ AWS_GLUE_PRIVESC_PASSROLE_UPDATE_JOB_TRIGGER,
+ AWS_IAM_PRIVESC_CREATE_POLICY_VERSION,
+ AWS_IAM_PRIVESC_CREATE_ACCESS_KEY,
+ AWS_IAM_PRIVESC_DELETE_CREATE_ACCESS_KEY,
+ AWS_IAM_PRIVESC_CREATE_LOGIN_PROFILE,
+ AWS_IAM_PRIVESC_PUT_ROLE_POLICY,
+ AWS_IAM_PRIVESC_UPDATE_LOGIN_PROFILE,
+ AWS_IAM_PRIVESC_PUT_USER_POLICY,
+ AWS_IAM_PRIVESC_ATTACH_USER_POLICY,
+ AWS_IAM_PRIVESC_ATTACH_ROLE_POLICY,
+ AWS_IAM_PRIVESC_ATTACH_GROUP_POLICY,
+ AWS_IAM_PRIVESC_PUT_GROUP_POLICY,
+ AWS_IAM_PRIVESC_UPDATE_ASSUME_ROLE_POLICY,
+ AWS_IAM_PRIVESC_ADD_USER_TO_GROUP,
+ AWS_IAM_PRIVESC_ATTACH_ROLE_POLICY_ASSUME_ROLE,
+ AWS_IAM_PRIVESC_ATTACH_USER_POLICY_CREATE_ACCESS_KEY,
+ AWS_IAM_PRIVESC_CREATE_POLICY_VERSION_ASSUME_ROLE,
+ AWS_IAM_PRIVESC_PUT_ROLE_POLICY_ASSUME_ROLE,
+ AWS_IAM_PRIVESC_PUT_USER_POLICY_CREATE_ACCESS_KEY,
+ AWS_IAM_PRIVESC_ATTACH_ROLE_POLICY_UPDATE_ASSUME_ROLE,
+ AWS_IAM_PRIVESC_CREATE_POLICY_VERSION_UPDATE_ASSUME_ROLE,
+ AWS_IAM_PRIVESC_PUT_ROLE_POLICY_UPDATE_ASSUME_ROLE,
+ AWS_LAMBDA_PRIVESC_PASSROLE_CREATE_FUNCTION,
+ AWS_LAMBDA_PRIVESC_PASSROLE_CREATE_FUNCTION_EVENT_SOURCE,
+ AWS_LAMBDA_PRIVESC_UPDATE_FUNCTION_CODE,
+ AWS_LAMBDA_PRIVESC_UPDATE_FUNCTION_CODE_INVOKE,
+ AWS_LAMBDA_PRIVESC_UPDATE_FUNCTION_CODE_ADD_PERMISSION,
+ AWS_LAMBDA_PRIVESC_PASSROLE_CREATE_FUNCTION_ADD_PERMISSION,
+ AWS_SAGEMAKER_PRIVESC_PASSROLE_CREATE_NOTEBOOK,
+ AWS_SAGEMAKER_PRIVESC_PASSROLE_CREATE_TRAINING_JOB,
+ AWS_SAGEMAKER_PRIVESC_PASSROLE_CREATE_PROCESSING_JOB,
+ AWS_SAGEMAKER_PRIVESC_PRESIGNED_NOTEBOOK_URL,
+ AWS_SAGEMAKER_PRIVESC_LIFECYCLE_CONFIG_NOTEBOOK,
+ AWS_SSM_PRIVESC_START_SESSION,
+ AWS_SSM_PRIVESC_SEND_COMMAND,
+ AWS_STS_PRIVESC_ASSUME_ROLE,
+]
diff --git a/api/src/backend/api/attack_paths/queries/registry.py b/api/src/backend/api/attack_paths/queries/registry.py
index c683b2cb80..358b1d6aed 100644
--- a/api/src/backend/api/attack_paths/queries/registry.py
+++ b/api/src/backend/api/attack_paths/queries/registry.py
@@ -1,13 +1,14 @@
-from api.attack_paths.queries.types import AttackPathsQueryDefinition
from api.attack_paths.queries.aws import AWS_QUERIES
+# TODO: drop after Neptune cutover
+from api.attack_paths.queries.aws_deprecated import AWS_DEPRECATED_QUERIES
+from api.attack_paths.queries.types import AttackPathsQueryDefinition
-# Query definitions organized by provider
+# Query definitions for scans synced with the current schema.
_QUERY_DEFINITIONS: dict[str, list[AttackPathsQueryDefinition]] = {
"aws": AWS_QUERIES,
}
-# Flat lookup by query ID for O(1) access
_QUERIES_BY_ID: dict[str, AttackPathsQueryDefinition] = {
definition.id: definition
for definitions in _QUERY_DEFINITIONS.values()
@@ -15,11 +16,45 @@ _QUERIES_BY_ID: dict[str, AttackPathsQueryDefinition] = {
}
-def get_queries_for_provider(provider: str) -> list[AttackPathsQueryDefinition]:
- """Get all attack path queries for a specific provider."""
- return _QUERY_DEFINITIONS.get(provider, [])
+# TODO: drop after Neptune cutover
+#
+# Query definitions for pre-cutover scans (`AttackPathsScan.is_migrated=False`)
+# whose graph data was written under the previous schema. Both maps expose the
+# same query IDs so the API contract is identical regardless of which set is
+# routed to.
+_DEPRECATED_QUERY_DEFINITIONS: dict[str, list[AttackPathsQueryDefinition]] = {
+ "aws": AWS_DEPRECATED_QUERIES,
+}
+
+_DEPRECATED_QUERIES_BY_ID: dict[str, AttackPathsQueryDefinition] = {
+ definition.id: definition
+ for definitions in _DEPRECATED_QUERY_DEFINITIONS.values()
+ for definition in definitions
+}
-def get_query_by_id(query_id: str) -> AttackPathsQueryDefinition | None:
- """Get a specific attack path query by its ID."""
- return _QUERIES_BY_ID.get(query_id)
+def get_queries_for_provider(
+ provider: str,
+ is_migrated: bool = True,
+) -> list[AttackPathsQueryDefinition]:
+ """Get all attack path queries for a provider.
+
+ `is_migrated` selects the catalog: True for scans synced with the current
+ schema, False for pre-cutover scans still using the legacy graph shape.
+ # TODO: drop the `is_migrated` parameter after Neptune cutover
+ """
+ catalog = _QUERY_DEFINITIONS if is_migrated else _DEPRECATED_QUERY_DEFINITIONS
+ return catalog.get(provider, [])
+
+
+def get_query_by_id(
+ query_id: str,
+ is_migrated: bool = True,
+) -> AttackPathsQueryDefinition | None:
+ """Get a specific attack path query by ID.
+
+ `is_migrated` selects the catalog (see `get_queries_for_provider`).
+ # TODO: drop the `is_migrated` parameter after Neptune cutover
+ """
+ by_id = _QUERIES_BY_ID if is_migrated else _DEPRECATED_QUERIES_BY_ID
+ return by_id.get(query_id)
diff --git a/api/src/backend/api/attack_paths/retryable_session.py b/api/src/backend/api/attack_paths/retryable_session.py
index 8723fe3ec9..e7e78e9798 100644
--- a/api/src/backend/api/attack_paths/retryable_session.py
+++ b/api/src/backend/api/attack_paths/retryable_session.py
@@ -1,5 +1,6 @@
import logging
-
+import random
+import time
from collections.abc import Callable
from typing import Any
@@ -9,18 +10,44 @@ import neo4j.exceptions
logger = logging.getLogger(__name__)
+class RetryExhaustedError(Exception):
+ def __init__(
+ self,
+ *,
+ retry_context: str,
+ method_name: str,
+ attempts: int,
+ elapsed_seconds: float,
+ last_error: Exception,
+ ) -> None:
+ self.retry_context = retry_context
+ self.method_name = method_name
+ self.attempts = attempts
+ self.elapsed_seconds = elapsed_seconds
+ self.last_error = last_error
+ last_message = getattr(last_error, "message", None) or str(last_error)
+ super().__init__(
+ f"{retry_context} {method_name} failed after {attempts} attempts over "
+ f"{elapsed_seconds:.3f}s. Last error: {last_message}"
+ )
+
+
class RetryableSession:
- """
- Wrapper around `neo4j.Session` that retries `neo4j.exceptions.ServiceUnavailable` errors.
- """
+ """Wrapper around ``neo4j.Session`` with a refreshable retry policy."""
def __init__(
self,
session_factory: Callable[[], neo4j.Session],
max_retries: int,
+ retry_if: Callable[[Exception], bool] | None = None,
+ initial_retry_delay_seconds: float = 0,
+ retry_context: str | None = None,
) -> None:
self._session_factory = session_factory
self._max_retries = max(0, max_retries)
+ self._retry_if = retry_if
+ self._initial_retry_delay_seconds = max(0.0, initial_retry_delay_seconds)
+ self._retry_context = retry_context
self._session = self._session_factory()
def close(self) -> None:
@@ -51,30 +78,75 @@ class RetryableSession:
def _call_with_retry(self, method_name: str, *args: Any, **kwargs: Any) -> Any:
attempt = 0
last_exc: Exception | None = None
+ started_at = time.monotonic()
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
+ except Exception as exc:
+ if not self._should_retry(exc):
+ raise
+
last_exc = exc
attempt += 1
if attempt > self._max_retries:
+ if self._retry_context is not None:
+ raise RetryExhaustedError(
+ retry_context=self._retry_context,
+ method_name=method_name,
+ attempts=attempt,
+ elapsed_seconds=time.monotonic() - started_at,
+ last_error=exc,
+ ) from exc
raise
- logger.warning(
- f"Neo4j session {method_name} failed with {type(exc).__name__} ({attempt}/{self._max_retries} attempts). Retrying..."
- )
+ delay = self._retry_delay(attempt)
+ if self._retry_context is not None:
+ error_message = getattr(exc, "message", None) or str(exc)
+ logger.warning(
+ "%s %s failed with %s: %s; retry %s/%s in %.3fs",
+ self._retry_context,
+ method_name,
+ type(exc).__name__,
+ error_message,
+ attempt,
+ self._max_retries,
+ delay,
+ )
+ else:
+ logger.warning(
+ "Graph session %s failed with %s; retry %s/%s in %.3fs",
+ method_name,
+ type(exc).__name__,
+ attempt,
+ self._max_retries,
+ delay,
+ )
self._refresh_session()
+ if delay:
+ time.sleep(delay)
raise last_exc if last_exc else RuntimeError("Unexpected retry loop exit")
+ def _should_retry(self, exc: Exception) -> bool:
+ if isinstance(
+ exc,
+ (
+ BrokenPipeError,
+ ConnectionResetError,
+ neo4j.exceptions.ServiceUnavailable,
+ ),
+ ):
+ return True
+ return self._retry_if(exc) if self._retry_if else False
+
+ def _retry_delay(self, attempt: int) -> float:
+ max_delay = self._initial_retry_delay_seconds * (2**attempt)
+ return random.uniform(max_delay / 2, max_delay) if max_delay else 0
+
def _refresh_session(self) -> None:
if self._session is not None:
try:
diff --git a/api/src/backend/api/attack_paths/sink/__init__.py b/api/src/backend/api/attack_paths/sink/__init__.py
new file mode 100644
index 0000000000..b90fd6e442
--- /dev/null
+++ b/api/src/backend/api/attack_paths/sink/__init__.py
@@ -0,0 +1,28 @@
+"""Attack-paths sink database layer.
+
+The sink is the persistent store where attack-paths graphs live after a scan
+finishes. Currently selectable between Neo4j (OSS / local dev default) and
+AWS Neptune (hosted dev/staging/prod). Backend is picked by the
+`ATTACK_PATHS_SINK_DATABASE` setting at process init.
+
+This package exposes the public factory API; the implementation lives in
+`api.attack_paths.sink.factory`.
+"""
+
+from api.attack_paths.sink.factory import (
+ SinkBackend,
+ close,
+ get_backend,
+ get_backend_for_name,
+ get_backend_for_scan,
+ init,
+)
+
+__all__ = [
+ "SinkBackend",
+ "close",
+ "get_backend",
+ "get_backend_for_name",
+ "get_backend_for_scan",
+ "init",
+]
diff --git a/api/src/backend/api/attack_paths/sink/base.py b/api/src/backend/api/attack_paths/sink/base.py
new file mode 100644
index 0000000000..0134e0a41f
--- /dev/null
+++ b/api/src/backend/api/attack_paths/sink/base.py
@@ -0,0 +1,94 @@
+"""Protocol every sink backend must implement."""
+
+from contextlib import AbstractContextManager
+from typing import Any, Protocol
+
+import neo4j
+
+
+class SinkDatabase(Protocol):
+ """Contract for the persistent attack-paths graph store.
+
+ The `database` argument is an opaque identifier passed through from the
+ legacy `database.py` API surface. On Neo4j it is the per-tenant database
+ name (e.g. `db-tenant-{uuid}`). On Neptune it is ignored (the cluster
+ has a single graph, and isolation is label-based).
+ """
+
+ sync_batch_size: int
+
+ def init(self) -> None: ...
+
+ def close(self) -> None: ...
+
+ def verify_connectivity(self) -> None:
+ """Raise if the backend the API read path uses is unreachable.
+
+ Neo4j verifies its single driver. Neptune verifies the reader
+ driver (the endpoint the API serves reads from); on single-endpoint
+ clusters the reader aliases the writer, so that path is covered too.
+ Used by the readiness probe; must not block longer than the caller's
+ probe budget.
+ """
+ ...
+
+ def get_session(
+ self,
+ database: str | None = None,
+ default_access_mode: str | None = None,
+ ) -> AbstractContextManager: ...
+
+ def execute_read_query(
+ self,
+ database: str,
+ cypher: str,
+ parameters: dict[str, Any] | None = None,
+ ) -> neo4j.graph.Graph: ...
+
+ def create_database(self, database: str) -> None: ...
+
+ def drop_database(self, database: str) -> None: ...
+
+ def drop_subgraph(self, database: str, provider_id: str) -> int: ...
+
+ def has_provider_data(self, database: str, provider_id: str) -> bool: ...
+
+ def clear_cache(self, database: str) -> None: ...
+
+ def ensure_sync_indexes(self, database: str) -> None:
+ """Create any index needed for the sync write path.
+
+ Called once at the start of each provider sync; must be idempotent.
+ Neo4j creates a `_provider_element_id` index on `_ProviderResource`;
+ Neptune is a no-op (its `~id` lookup needs no index).
+ """
+ ...
+
+ def write_nodes(
+ self,
+ database: str,
+ labels: str,
+ rows: list[dict[str, Any]],
+ ) -> None:
+ """Upsert a batch of nodes into the sink.
+
+ `labels` is a pre-rendered Cypher label string ready to drop after
+ the node variable (e.g. `` `AWSUser`:`_ProviderResource`:`_Tenant_x` ``).
+ Each row carries `provider_element_id` and `props`.
+ """
+ ...
+
+ def write_relationships(
+ self,
+ database: str,
+ rel_type: str,
+ provider_id: str,
+ rows: list[dict[str, Any]],
+ ) -> None:
+ """Upsert a batch of relationships into the sink.
+
+ Each row carries `start_element_id`, `end_element_id`,
+ `provider_element_id` and `props`. `rel_type` is the relationship
+ type (already a valid Cypher identifier).
+ """
+ ...
diff --git a/api/src/backend/api/attack_paths/sink/drop.py b/api/src/backend/api/attack_paths/sink/drop.py
new file mode 100644
index 0000000000..be13a394f5
--- /dev/null
+++ b/api/src/backend/api/attack_paths/sink/drop.py
@@ -0,0 +1,81 @@
+"""Shared batched deletion helpers for sink backends."""
+
+import logging
+import time
+from typing import Any
+
+RELATIONSHIP_DELETE_QUERY_TEMPLATES = {
+ "outgoing relationship": """
+ MATCH (n:`{provider_label}`)-[r]->()
+ WITH r LIMIT $batch_size
+ DELETE r
+ RETURN COUNT(r) AS deleted_rels_count
+ """,
+ "incoming relationship": """
+ MATCH (n:`{provider_label}`)<-[r]-()
+ WITH r LIMIT $batch_size
+ DELETE r
+ RETURN COUNT(r) AS deleted_rels_count
+ """,
+}
+
+NODE_DELETE_QUERY_TEMPLATE = """
+ MATCH (n:{provider_resource_label}:`{provider_label}`)
+ WITH n LIMIT $batch_size
+ DELETE n
+ RETURN COUNT(n) AS deleted_nodes_count
+ """
+
+
+def delete_batches(
+ *,
+ session: Any,
+ logger: logging.Logger,
+ log_target: str,
+ provider_id: str,
+ query: str,
+ phase: str,
+ count_key: str,
+ total_key: str,
+ deleted_key: str,
+ initial_total: int,
+ batch_size: int,
+ drop_t0: float,
+) -> tuple[int, int]:
+ def delete_batch(tx: Any) -> int:
+ record = tx.run(query, {"batch_size": batch_size}).single()
+ return (record[count_key] if record else 0) or 0
+
+ deleted_total = initial_total
+ batches = 0
+ while True:
+ logger.info(
+ "Deleting %s batch from %s "
+ "(provider=%s, batch=%s, total_%s=%s, elapsed=%.3fs)",
+ phase,
+ log_target,
+ provider_id,
+ batches + 1,
+ total_key,
+ deleted_total,
+ time.perf_counter() - drop_t0,
+ )
+ deleted = session.execute_write(delete_batch)
+ if deleted == 0:
+ return deleted_total, batches
+
+ batches += 1
+ deleted_total += deleted
+ logger.info(
+ "Deleted %s batch from %s "
+ "(provider=%s, batch=%s, %s=%s, total_%s=%s, elapsed=%.3fs)",
+ phase,
+ log_target,
+ provider_id,
+ batches,
+ deleted_key,
+ deleted,
+ total_key,
+ deleted_total,
+ time.perf_counter() - drop_t0,
+ )
diff --git a/api/src/backend/api/attack_paths/sink/factory.py b/api/src/backend/api/attack_paths/sink/factory.py
new file mode 100644
index 0000000000..ad2116fa40
--- /dev/null
+++ b/api/src/backend/api/attack_paths/sink/factory.py
@@ -0,0 +1,134 @@
+"""Sink backend factory and process-wide handle cache.
+
+Picks the active backend from `settings.ATTACK_PATHS_SINK_DATABASE` at first
+use, holds the active backend plus any secondary backends needed to serve
+scans written under the previous configuration, and tears them all down on
+process shutdown. Imported via `from api.attack_paths import sink as
+sink_module`.
+"""
+
+import threading
+from enum import StrEnum, auto
+
+from api.attack_paths.sink.base import SinkDatabase
+from api.models import AttackPathsScan
+from django.conf import settings
+
+# Backend names
+
+
+class SinkBackend(StrEnum):
+ NEO4J = auto()
+ NEPTUNE = auto()
+
+
+# Backend cache
+
+_backend: SinkDatabase | None = None
+_secondary_backends: dict[SinkBackend, SinkDatabase] = {}
+_lock = threading.Lock()
+
+
+def _resolve_setting() -> SinkBackend:
+ raw = settings.ATTACK_PATHS_SINK_DATABASE.lower()
+ try:
+ return SinkBackend(raw)
+
+ except ValueError:
+ valid = sorted(b.value for b in SinkBackend)
+ raise RuntimeError(
+ f"ATTACK_PATHS_SINK_DATABASE must be one of {valid}; got {raw!r}"
+ )
+
+
+def _build_backend(name: SinkBackend) -> SinkDatabase:
+ if name is SinkBackend.NEO4J:
+ from api.attack_paths.sink.neo4j import Neo4jSink
+
+ return Neo4jSink()
+
+ if name is SinkBackend.NEPTUNE:
+ from api.attack_paths.sink.neptune import NeptuneSink
+
+ return NeptuneSink()
+
+ raise RuntimeError(f"Unknown sink backend {name!r}")
+
+
+# Lifecycle
+
+
+def init(name: SinkBackend | str | None = None) -> SinkDatabase:
+ """Initialize the configured sink backend. Idempotent."""
+ global _backend
+ if _backend is not None:
+ return _backend
+
+ with _lock:
+ if _backend is None:
+ resolved = SinkBackend(name) if name else _resolve_setting()
+ backend = _build_backend(resolved)
+ backend.init()
+ _backend = backend
+
+ return _backend
+
+
+def close() -> None:
+ """Close the active backend and every cached secondary backend."""
+ global _backend
+ with _lock:
+ backends = [
+ b for b in (_backend, *_secondary_backends.values()) if b is not None
+ ]
+ _backend = None
+ _secondary_backends.clear()
+
+ for backend in backends:
+ try:
+ backend.close()
+
+ except Exception: # pragma: no cover - best-effort
+ pass
+
+
+def get_backend() -> SinkDatabase:
+ """Return the active sink. Initializes on first call."""
+ return init()
+
+
+# Per-scan routing
+
+
+def get_backend_for_scan(scan: AttackPathsScan) -> SinkDatabase:
+ """Route reads by the sink that stores this scan's graph."""
+ raw_backend = getattr(scan, "sink_backend", SinkBackend.NEO4J.value)
+ if not isinstance(raw_backend, str):
+ raw_backend = SinkBackend.NEO4J.value
+ return get_backend_for_name(raw_backend)
+
+
+def get_backend_for_name(name: SinkBackend | str) -> SinkDatabase:
+ """Return the backend named by persisted scan metadata."""
+ resolved = SinkBackend(name)
+ if resolved is _resolve_setting():
+ return get_backend()
+
+ return _build_backend_cached(resolved)
+
+
+def _build_backend_cached(name: SinkBackend) -> SinkDatabase:
+ # TODO: drop after Neptune cutover
+ # Needed only during cutover to serve Neo4j-written scans from a Neptune-
+ # configured API pod (and vice versa). Once every scan is on Neptune,
+ # `get_backend_for_scan` becomes a one-liner returning `get_backend()`.
+ if name in _secondary_backends:
+ return _secondary_backends[name]
+
+ with _lock:
+ if name not in _secondary_backends:
+ backend = _build_backend(name)
+ backend.init()
+ _secondary_backends[name] = backend
+
+ return _secondary_backends[name]
diff --git a/api/src/backend/api/attack_paths/sink/neo4j.py b/api/src/backend/api/attack_paths/sink/neo4j.py
new file mode 100644
index 0000000000..c820ed9d93
--- /dev/null
+++ b/api/src/backend/api/attack_paths/sink/neo4j.py
@@ -0,0 +1,419 @@
+"""Neo4j sink implementation.
+
+Owns a Neo4j driver independent from the staging driver. On OSS and local dev
+this is the only sink; on hosted deployments it runs only as a legacy read
+path while phase-1 drains tenant DBs.
+"""
+
+import atexit
+import logging
+import threading
+import time
+from collections.abc import Iterator
+from contextlib import AbstractContextManager, contextmanager
+from typing import Any
+
+import neo4j
+import neo4j.exceptions
+from api.attack_paths.retryable_session import RetryableSession
+from api.attack_paths.sink.base import SinkDatabase
+from api.attack_paths.sink.drop import (
+ NODE_DELETE_QUERY_TEMPLATE,
+ RELATIONSHIP_DELETE_QUERY_TEMPLATES,
+ delete_batches,
+)
+from config.env import env
+from django.conf import settings
+
+logging.getLogger("neo4j").setLevel(logging.ERROR)
+logging.getLogger("neo4j").propagate = False
+
+logger = logging.getLogger(__name__)
+
+SERVICE_UNAVAILABLE_MAX_RETRIES = env.int(
+ "ATTACK_PATHS_SERVICE_UNAVAILABLE_MAX_RETRIES", default=3
+)
+READ_QUERY_TIMEOUT_SECONDS = env.int(
+ "ATTACK_PATHS_READ_QUERY_TIMEOUT_SECONDS", default=30
+)
+CONN_ACQUISITION_TIMEOUT = env.int("NEO4J_CONN_ACQUISITION_TIMEOUT", default=15)
+# TCP connect timeout, ordered below the acquisition timeout so an unreachable
+# host can't pin a request or the readiness probe longer than this.
+CONNECTION_TIMEOUT = env.int("NEO4J_CONNECTION_TIMEOUT", default=5)
+MAX_CONNECTION_LIFETIME = env.int("NEO4J_MAX_CONNECTION_LIFETIME", default=7200)
+MAX_CONNECTION_POOL_SIZE = env.int("NEO4J_MAX_CONNECTION_POOL_SIZE", default=50)
+
+READ_EXCEPTION_CODES = [
+ "Neo.ClientError.Statement.AccessMode",
+ "Neo.ClientError.Procedure.ProcedureNotFound",
+]
+CLIENT_STATEMENT_EXCEPTION_PREFIX = "Neo.ClientError.Statement."
+DATABASE_NOT_FOUND_CODE = "Neo.ClientError.Database.DatabaseNotFound"
+
+
+class Neo4jSink(SinkDatabase):
+ """Neo4j-backed sink. Multi-database cluster; tenant isolation is physical."""
+
+ sync_batch_size = env.int("ATTACK_PATHS_NEO4J_SYNC_BATCH_SIZE", default=1000)
+
+ def __init__(self) -> None:
+ self._driver: neo4j.Driver | None = None
+ self._lock = threading.Lock()
+ self._atexit_registered = False
+
+ # Driver
+
+ def _config(self) -> dict:
+ return settings.DATABASES["neo4j"]
+
+ def _uri(self) -> str:
+ cfg = self._config()
+ host = cfg["HOST"]
+ port = cfg["PORT"]
+ if not host or not port:
+ raise RuntimeError(
+ "NEO4J_HOST / NEO4J_PORT must be set when ATTACK_PATHS_SINK_DATABASE=neo4j"
+ )
+ return f"bolt://{host}:{port}"
+
+ def init(self) -> neo4j.Driver:
+ if self._driver is not None:
+ return self._driver
+ with self._lock:
+ if self._driver is None:
+ cfg = self._config()
+ self._driver = neo4j.GraphDatabase.driver(
+ self._uri(),
+ auth=(cfg["USER"], cfg["PASSWORD"]),
+ keep_alive=True,
+ max_connection_lifetime=MAX_CONNECTION_LIFETIME,
+ connection_timeout=CONNECTION_TIMEOUT,
+ connection_acquisition_timeout=CONN_ACQUISITION_TIMEOUT,
+ max_connection_pool_size=MAX_CONNECTION_POOL_SIZE,
+ )
+ # Eager connectivity check is best-effort:
+ # A Neo4j that is down at boot must not crash the process, same degradation model as Postgres
+ # The driver reconnects lazily on first use
+ # /health/ready surfaces the outage until it recovers
+ try:
+ self._driver.verify_connectivity()
+
+ except Exception:
+ logger.warning(
+ "Neo4j sink unreachable at init; continuing with a lazily-reconnecting driver",
+ exc_info=True,
+ )
+
+ if not self._atexit_registered:
+ atexit.register(self.close)
+ self._atexit_registered = True
+ return self._driver
+
+ def _get_driver(self) -> neo4j.Driver:
+ return self.init()
+
+ def verify_connectivity(self) -> None:
+ self._get_driver().verify_connectivity()
+
+ def close(self) -> None:
+ with self._lock:
+ if self._driver is not None:
+ try:
+ self._driver.close()
+ finally:
+ self._driver = None
+
+ # Sessions
+
+ @contextmanager
+ def get_session(
+ self,
+ database: str | None = None,
+ default_access_mode: str | None = None,
+ ) -> Iterator[RetryableSession]:
+ from api.attack_paths.database import (
+ ClientStatementException,
+ GraphDatabaseQueryException,
+ WriteQueryNotAllowedException,
+ )
+
+ session_wrapper: RetryableSession | None = None
+ try:
+ session_wrapper = RetryableSession(
+ session_factory=lambda: self._get_driver().session(
+ database=database, default_access_mode=default_access_mode
+ ),
+ max_retries=SERVICE_UNAVAILABLE_MAX_RETRIES,
+ )
+ yield session_wrapper
+
+ except neo4j.exceptions.Neo4jError as exc:
+ if (
+ default_access_mode == neo4j.READ_ACCESS
+ and exc.code
+ and exc.code in READ_EXCEPTION_CODES
+ ):
+ raise WriteQueryNotAllowedException(
+ message="Read query not allowed", code=READ_EXCEPTION_CODES[0]
+ )
+
+ message = exc.message if exc.message is not None else str(exc)
+ if exc.code and exc.code.startswith(CLIENT_STATEMENT_EXCEPTION_PREFIX):
+ raise ClientStatementException(message=message, code=exc.code)
+ raise GraphDatabaseQueryException(message=message, code=exc.code)
+
+ finally:
+ if session_wrapper is not None:
+ session_wrapper.close()
+
+ # Operations
+
+ def execute_read_query(
+ self,
+ database: str,
+ cypher: str,
+ parameters: dict[str, Any] | None = None,
+ ) -> neo4j.graph.Graph:
+ with self.get_session(
+ database, default_access_mode=neo4j.READ_ACCESS
+ ) as session:
+
+ def _run(tx: neo4j.ManagedTransaction) -> neo4j.graph.Graph:
+ result = tx.run(
+ cypher, parameters or {}, timeout=READ_QUERY_TIMEOUT_SECONDS
+ )
+ return result.graph()
+
+ return session.execute_read(_run)
+
+ def create_database(self, database: str) -> None:
+ with self.get_session() as session:
+ session.run(
+ "CREATE DATABASE $database IF NOT EXISTS", {"database": database}
+ )
+
+ def drop_database(self, database: str) -> None:
+ with self.get_session() as session:
+ session.run(f"DROP DATABASE `{database}` IF EXISTS DESTROY DATA")
+
+ def drop_subgraph(self, database: str, provider_id: str) -> int:
+ """Delete all nodes for a provider from a tenant database, batched.
+
+ Deletes relationships then nodes in batches (not `DETACH DELETE`) so a
+ dense provider's graph cannot exceed Neo4j's transaction memory limit.
+ Silently returns 0 if the database doesn't exist.
+ """
+ from api.attack_paths.database import GraphDatabaseQueryException
+ from tasks.jobs.attack_paths.config import (
+ GRAPH_MUTATION_BATCH_SIZE,
+ PROVIDER_RESOURCE_LABEL,
+ get_provider_label,
+ )
+
+ provider_label = get_provider_label(provider_id)
+ deleted_nodes = deleted_relationships = 0
+ relationship_batches = node_batches = 0
+ drop_t0 = time.perf_counter()
+
+ logger.info(
+ "Dropping provider graph from Neo4j sink database %s "
+ "(provider=%s, provider_label=%s)",
+ database,
+ provider_id,
+ provider_label,
+ )
+
+ try:
+ logger.info(
+ "Opening Neo4j sink session for provider graph drop "
+ "(database=%s, provider=%s)",
+ database,
+ provider_id,
+ )
+ with self.get_session(database) as session:
+ logger.info(
+ "Opened Neo4j sink session for provider graph drop "
+ "(database=%s, provider=%s)",
+ database,
+ provider_id,
+ )
+ log_target = f"Neo4j sink database {database}"
+ for (
+ phase,
+ query_template,
+ ) in RELATIONSHIP_DELETE_QUERY_TEMPLATES.items():
+ deleted_relationships, phase_batches = delete_batches(
+ session=session,
+ logger=logger,
+ log_target=log_target,
+ provider_id=provider_id,
+ query=query_template.format(provider_label=provider_label),
+ phase=phase,
+ count_key="deleted_rels_count",
+ total_key="rels",
+ deleted_key="deleted_rels",
+ initial_total=deleted_relationships,
+ batch_size=GRAPH_MUTATION_BATCH_SIZE,
+ drop_t0=drop_t0,
+ )
+ relationship_batches += phase_batches
+
+ deleted_nodes, node_batches = delete_batches(
+ session=session,
+ logger=logger,
+ log_target=log_target,
+ provider_id=provider_id,
+ query=NODE_DELETE_QUERY_TEMPLATE.format(
+ provider_label=provider_label,
+ provider_resource_label=PROVIDER_RESOURCE_LABEL,
+ ),
+ phase="node",
+ count_key="deleted_nodes_count",
+ total_key="nodes",
+ deleted_key="deleted_nodes",
+ initial_total=0,
+ batch_size=GRAPH_MUTATION_BATCH_SIZE,
+ drop_t0=drop_t0,
+ )
+
+ except GraphDatabaseQueryException as exc:
+ if exc.code == DATABASE_NOT_FOUND_CODE:
+ logger.info(
+ "Skipped provider graph drop from Neo4j sink database %s "
+ "(provider=%s, reason=database_not_found, elapsed=%.3fs)",
+ database,
+ provider_id,
+ time.perf_counter() - drop_t0,
+ )
+ return 0
+ raise
+
+ logger.info(
+ "Finished dropping provider graph from Neo4j sink database %s "
+ "(provider=%s, relationship_batches=%s, deleted_rels=%s, "
+ "node_batches=%s, deleted_nodes=%s, elapsed=%.3fs)",
+ database,
+ provider_id,
+ relationship_batches,
+ deleted_relationships,
+ node_batches,
+ deleted_nodes,
+ time.perf_counter() - drop_t0,
+ )
+ return deleted_nodes
+
+ def has_provider_data(self, database: str, provider_id: str) -> bool:
+ from api.attack_paths.database import GraphDatabaseQueryException
+ from tasks.jobs.attack_paths.config import (
+ PROVIDER_RESOURCE_LABEL,
+ get_provider_label,
+ )
+
+ provider_label = get_provider_label(provider_id)
+ query = (
+ f"MATCH (n:{PROVIDER_RESOURCE_LABEL}:`{provider_label}`) RETURN 1 LIMIT 1"
+ )
+ try:
+ with self.get_session(
+ database, default_access_mode=neo4j.READ_ACCESS
+ ) as session:
+ result = session.run(query)
+ return result.single() is not None
+
+ except GraphDatabaseQueryException as exc:
+ if exc.code == DATABASE_NOT_FOUND_CODE:
+ return False
+ raise
+
+ def clear_cache(self, database: str) -> None:
+ from api.attack_paths.database import GraphDatabaseQueryException
+
+ try:
+ with self.get_session(database) as session:
+ session.run("CALL db.clearQueryCaches()")
+ except GraphDatabaseQueryException as exc:
+ logger.warning(
+ f"Failed to clear query cache for database `{database}`: {exc}"
+ )
+
+ # Sync write path
+
+ def ensure_sync_indexes(self, database: str) -> None:
+ """Create the `_provider_element_id` lookup index on `_ProviderResource`.
+
+ Every synced node carries the `_ProviderResource` label, so a single
+ index covers both node-upserts and relationship endpoint MATCHes.
+ Without this index the rel sync degrades to a label scan per row and
+ large provider syncs become unworkable.
+ """
+ from tasks.jobs.attack_paths.config import (
+ PROVIDER_ELEMENT_ID_PROPERTY,
+ PROVIDER_RESOURCE_LABEL,
+ )
+
+ query = (
+ f"CREATE INDEX provider_element_id_idx IF NOT EXISTS "
+ f"FOR (n:`{PROVIDER_RESOURCE_LABEL}`) "
+ f"ON (n.`{PROVIDER_ELEMENT_ID_PROPERTY}`)"
+ )
+ with self.get_session(database) as session:
+ session.execute_write(lambda tx: tx.run(query).consume())
+
+ def write_nodes(
+ self,
+ database: str,
+ labels: str,
+ rows: list[dict[str, Any]],
+ ) -> None:
+ if not rows:
+ return
+ from tasks.jobs.attack_paths.config import (
+ PROVIDER_ELEMENT_ID_PROPERTY,
+ PROVIDER_RESOURCE_LABEL,
+ )
+
+ query = f"""
+ UNWIND $rows AS row
+ MERGE (n:`{PROVIDER_RESOURCE_LABEL}` {{`{PROVIDER_ELEMENT_ID_PROPERTY}`: row.provider_element_id}})
+ SET n:{labels}
+ SET n += row.props
+ """
+ with self.get_session(database) as session:
+ session.execute_write(lambda tx: tx.run(query, {"rows": rows}).consume())
+
+ def write_relationships(
+ self,
+ database: str,
+ rel_type: str,
+ provider_id: str,
+ rows: list[dict[str, Any]],
+ ) -> None:
+ if not rows:
+ return
+ from tasks.jobs.attack_paths.config import (
+ PROVIDER_ELEMENT_ID_PROPERTY,
+ PROVIDER_RESOURCE_LABEL,
+ get_provider_label,
+ )
+
+ provider_label = get_provider_label(provider_id)
+ query = f"""
+ UNWIND $rows AS row
+ MATCH (s:`{PROVIDER_RESOURCE_LABEL}`:`{provider_label}` {{`{PROVIDER_ELEMENT_ID_PROPERTY}`: row.start_element_id}})
+ MATCH (t:`{PROVIDER_RESOURCE_LABEL}`:`{provider_label}` {{`{PROVIDER_ELEMENT_ID_PROPERTY}`: row.end_element_id}})
+ MERGE (s)-[r:`{rel_type}` {{`{PROVIDER_ELEMENT_ID_PROPERTY}`: row.provider_element_id}}]->(t)
+ SET r += row.props
+ """
+ with self.get_session(database) as session:
+ session.execute_write(lambda tx: tx.run(query, {"rows": rows}).consume())
+
+ # For compatibility with test harnesses that patch the concrete driver
+ def get_driver(self) -> neo4j.Driver:
+ return self._get_driver()
+
+
+# Helper for tests / external callers that want a writer session specifically
+def get_read_session(
+ sink: Neo4jSink, database: str
+) -> AbstractContextManager[RetryableSession]:
+ return sink.get_session(database, default_access_mode=neo4j.READ_ACCESS)
diff --git a/api/src/backend/api/attack_paths/sink/neptune.py b/api/src/backend/api/attack_paths/sink/neptune.py
new file mode 100644
index 0000000000..022e3c8669
--- /dev/null
+++ b/api/src/backend/api/attack_paths/sink/neptune.py
@@ -0,0 +1,519 @@
+"""AWS Neptune sink implementation.
+
+Dual Bolt drivers: one against the writer endpoint for workers, one against
+the reader endpoint for the API read path. If `NEPTUNE_READER_ENDPOINT` is
+unset the reader falls back to the writer driver so single-node clusters work.
+
+Neptune is single-database. The `database` argument on the SinkDatabase
+protocol is ignored; tenant / provider isolation is enforced by labels that
+the sync step already writes on every node (see tasks/jobs/attack_paths/sync.py).
+
+SigV4 auth lives at the bottom of this file as `neptune_auth_provider`. The
+neo4j driver invokes the returned callable on each token refresh.
+"""
+
+import atexit
+import datetime
+import json
+import logging
+import threading
+import time
+from collections.abc import Callable, Iterator
+from contextlib import contextmanager
+from typing import Any
+from urllib.parse import urlsplit
+
+import neo4j
+import neo4j.exceptions
+from api.attack_paths.retryable_session import RetryableSession, RetryExhaustedError
+from api.attack_paths.sink.base import SinkDatabase
+from api.attack_paths.sink.drop import (
+ NODE_DELETE_QUERY_TEMPLATE,
+ RELATIONSHIP_DELETE_QUERY_TEMPLATES,
+ delete_batches,
+)
+from botocore.auth import SigV4Auth
+from botocore.awsrequest import AWSRequest
+from botocore.session import Session as BotoSession
+from config.env import env
+from django.conf import settings
+from neo4j.auth_management import AuthManagers, ExpiringAuth
+
+logging.getLogger("neo4j").setLevel(logging.ERROR)
+logging.getLogger("neo4j").propagate = False
+
+logger = logging.getLogger(__name__)
+
+SERVICE_UNAVAILABLE_MAX_RETRIES = env.int(
+ "ATTACK_PATHS_SERVICE_UNAVAILABLE_MAX_RETRIES", default=3
+)
+READ_QUERY_TIMEOUT_SECONDS = env.int(
+ "ATTACK_PATHS_READ_QUERY_TIMEOUT_SECONDS", default=30
+)
+# Neptune serverless cold-start can be >30s; give the driver room
+CONN_ACQUISITION_TIMEOUT = env.int("NEPTUNE_CONN_ACQUISITION_TIMEOUT", default=60)
+# TCP connect timeout, ordered below the acquisition timeout so an unreachable
+# endpoint can't pin a request or the readiness probe longer than this. Kept
+# generous: cold-start delays query execution, not the socket connect.
+CONNECTION_TIMEOUT = env.int("NEPTUNE_CONNECTION_TIMEOUT", default=10)
+# Roll connections hourly so SigV4 rotations and cert refreshes don't strand long-lived pool entries
+MAX_CONNECTION_LIFETIME = env.int("NEPTUNE_MAX_CONNECTION_LIFETIME", default=3600)
+MAX_CONNECTION_POOL_SIZE = env.int("NEPTUNE_MAX_CONNECTION_POOL_SIZE", default=50)
+NEPTUNE_WRITE_RETRY_DELAY_SECONDS = 2
+
+READ_EXCEPTION_CODES = [
+ "Neo.ClientError.Statement.AccessMode",
+ "Neo.ClientError.Procedure.ProcedureNotFound",
+]
+CLIENT_STATEMENT_EXCEPTION_PREFIX = "Neo.ClientError.Statement."
+RETRYABLE_WRITE_ERROR_FRAGMENTS = (
+ "Operation failed due to conflicting concurrent operations",
+ "Operation terminated (deadline exceeded)",
+)
+
+# Refresh 60s before the 5-minute SigV4 window closes
+SIGV4_TOKEN_LIFETIME_MINUTES = 4
+
+
+def _is_retryable_write_error(exc: Exception) -> bool:
+ if not isinstance(exc, neo4j.exceptions.Neo4jError):
+ return False
+ message = exc.message or ""
+ return any(fragment in message for fragment in RETRYABLE_WRITE_ERROR_FRAGMENTS)
+
+
+class NeptuneSink(SinkDatabase):
+ """Neptune-backed sink. Single database; isolation is label-based."""
+
+ sync_batch_size = env.int("ATTACK_PATHS_NEPTUNE_SYNC_BATCH_SIZE", default=500)
+
+ def __init__(self) -> None:
+ self._writer: neo4j.Driver | None = None
+ self._reader: neo4j.Driver | None = None
+ self._lock = threading.Lock()
+ self._atexit_registered = False
+
+ # Config
+
+ def _config(self) -> dict:
+ return settings.DATABASES["neptune"]
+
+ def _bolt_uri(self, endpoint: str, port: str) -> str:
+ return f"bolt+s://{endpoint}:{port}"
+
+ def _https_url(self, endpoint: str, port: str) -> str:
+ return f"https://{endpoint}:{port}"
+
+ def _build_driver(self, endpoint: str) -> neo4j.Driver:
+ cfg = self._config()
+ port = cfg["PORT"]
+ region = cfg["REGION"]
+ if not endpoint or not region:
+ raise RuntimeError(
+ "NEPTUNE_WRITER_ENDPOINT and AWS_REGION must be set when "
+ "ATTACK_PATHS_SINK_DATABASE=neptune"
+ )
+ return neo4j.GraphDatabase.driver(
+ self._bolt_uri(endpoint, port),
+ auth=AuthManagers.bearer(
+ neptune_auth_provider(region, self._https_url(endpoint, port))
+ ),
+ keep_alive=True,
+ max_connection_lifetime=MAX_CONNECTION_LIFETIME,
+ connection_timeout=CONNECTION_TIMEOUT,
+ connection_acquisition_timeout=CONN_ACQUISITION_TIMEOUT,
+ max_connection_pool_size=MAX_CONNECTION_POOL_SIZE,
+ max_transaction_retry_time=0,
+ )
+
+ # Lifecycle
+
+ def init(self) -> None:
+ if self._writer is not None:
+ return
+ with self._lock:
+ if self._writer is None:
+ cfg = self._config()
+ writer_endpoint = cfg["WRITER_ENDPOINT"]
+ reader_endpoint = cfg["READER_ENDPOINT"] or writer_endpoint
+
+ # Eager connectivity checks are best-effort
+ # A Neptune that is down at boot must not crash the process, same degradation model as Postgres
+ # Drivers reconnect lazily on first use
+ # /health/ready surfaces the outage until it recovers
+ self._writer = self._build_driver(writer_endpoint)
+ self._verify_best_effort(self._writer, "writer")
+
+ if reader_endpoint == writer_endpoint:
+ self._reader = self._writer
+
+ else:
+ self._reader = self._build_driver(reader_endpoint)
+ self._verify_best_effort(self._reader, "reader")
+
+ if not self._atexit_registered:
+ atexit.register(self.close)
+ self._atexit_registered = True
+
+ def close(self) -> None:
+ with self._lock:
+ # `Driver.close()` is idempotent, so closing the same driver twice
+ # (when reader aliases writer on single-endpoint configs) is safe
+ for driver in (self._reader, self._writer):
+ if driver is None:
+ continue
+ try:
+ driver.close()
+ except Exception: # pragma: no cover - best-effort
+ pass
+ self._writer = None
+ self._reader = None
+
+ # Sessions
+
+ def _get_writer(self) -> neo4j.Driver:
+ self.init()
+ assert self._writer is not None
+ return self._writer
+
+ def _get_reader(self) -> neo4j.Driver:
+ self.init()
+ assert self._reader is not None
+ return self._reader
+
+ @staticmethod
+ def _verify_best_effort(driver: neo4j.Driver, role: str) -> None:
+ try:
+ driver.verify_connectivity()
+
+ except Exception:
+ logger.warning(
+ "Neptune %s endpoint unreachable at init; continuing with a lazily-reconnecting driver",
+ role,
+ exc_info=True,
+ )
+
+ def verify_connectivity(self) -> None:
+ # The API read path uses the reader driver
+ # On single-endpoint clusters it aliases the writer, so this also covers the writer
+ # A writer-only outage is a workers' concern (no HTTP probe there) and deliberately does not fail API readiness
+ self._get_reader().verify_connectivity()
+
+ @contextmanager
+ def get_session(
+ self,
+ database: str | None = None, # noqa: ARG002 - ignored on Neptune
+ default_access_mode: str | None = None,
+ ) -> Iterator[RetryableSession]:
+ from api.attack_paths.database import (
+ ClientStatementException,
+ GraphDatabaseQueryException,
+ NeptuneWriteRetryExhaustedException,
+ WriteQueryNotAllowedException,
+ )
+
+ driver = (
+ self._get_reader()
+ if default_access_mode == neo4j.READ_ACCESS
+ else self._get_writer()
+ )
+
+ session_wrapper: RetryableSession | None = None
+ try:
+ is_write_session = default_access_mode != neo4j.READ_ACCESS
+ session_wrapper = RetryableSession(
+ session_factory=lambda: driver.session(
+ default_access_mode=default_access_mode
+ ),
+ max_retries=SERVICE_UNAVAILABLE_MAX_RETRIES,
+ retry_if=_is_retryable_write_error if is_write_session else None,
+ initial_retry_delay_seconds=(
+ NEPTUNE_WRITE_RETRY_DELAY_SECONDS if is_write_session else 0
+ ),
+ retry_context="Neptune write" if is_write_session else None,
+ )
+ yield session_wrapper
+
+ except RetryExhaustedError as exc:
+ last_error = exc.last_error
+ raise NeptuneWriteRetryExhaustedException(
+ message=str(exc),
+ code=getattr(last_error, "code", None),
+ ) from last_error
+
+ except neo4j.exceptions.Neo4jError as exc:
+ if (
+ default_access_mode == neo4j.READ_ACCESS
+ and exc.code
+ and exc.code in READ_EXCEPTION_CODES
+ ):
+ raise WriteQueryNotAllowedException(
+ message="Read query not allowed", code=READ_EXCEPTION_CODES[0]
+ )
+
+ message = exc.message if exc.message is not None else str(exc)
+ if exc.code and exc.code.startswith(CLIENT_STATEMENT_EXCEPTION_PREFIX):
+ raise ClientStatementException(message=message, code=exc.code)
+ raise GraphDatabaseQueryException(message=message, code=exc.code)
+
+ finally:
+ if session_wrapper is not None:
+ session_wrapper.close()
+
+ # Operations
+
+ def execute_read_query(
+ self,
+ database: str, # noqa: ARG002 - ignored on Neptune
+ cypher: str,
+ parameters: dict[str, Any] | None = None,
+ ) -> neo4j.graph.Graph:
+ with self.get_session(default_access_mode=neo4j.READ_ACCESS) as session:
+
+ def _run(tx: neo4j.ManagedTransaction) -> neo4j.graph.Graph:
+ result = tx.run(
+ cypher, parameters or {}, timeout=READ_QUERY_TIMEOUT_SECONDS
+ )
+ return result.graph()
+
+ return session.execute_read(_run)
+
+ def create_database(self, database: str) -> None: # noqa: ARG002
+ # Neptune clusters are single-database; there is nothing to create.
+ return None
+
+ def drop_database(self, database: str) -> None: # noqa: ARG002
+ # Neptune clusters are single-database; there is nothing to drop.
+ return None
+
+ def drop_subgraph(self, database: str, provider_id: str) -> int: # noqa: ARG002
+ """Delete a provider's subgraph in two bounded phases.
+
+ Neptune write transactions are capped at ~2 minutes. A naive
+ `DETACH DELETE` on a label-scanned batch grows unbounded with graph
+ density (one node can drag thousands of relationships into the same
+ transaction). Instead:
+
+ 1. Delete relationships incident to provider nodes, one fixed-size
+ batch per transaction.
+ 2. Delete the now-orphaned nodes, one fixed-size batch per transaction.
+
+ Each transaction does work proportional to `batch_size`, never to the
+ graph's branching factor.
+ """
+ from tasks.jobs.attack_paths.config import (
+ GRAPH_MUTATION_BATCH_SIZE,
+ PROVIDER_RESOURCE_LABEL,
+ get_provider_label,
+ )
+
+ provider_label = get_provider_label(provider_id)
+ deleted_relationships = 0
+ relationship_batches = 0
+ node_batches = 0
+ drop_t0 = time.perf_counter()
+
+ logger.info(
+ "Dropping provider graph from Neptune sink "
+ "(provider=%s, provider_label=%s)",
+ provider_id,
+ provider_label,
+ )
+
+ logger.info(
+ "Opening Neptune writer session for provider graph drop (provider=%s)",
+ provider_id,
+ )
+ with self.get_session() as session:
+ logger.info(
+ "Opened Neptune writer session for provider graph drop (provider=%s)",
+ provider_id,
+ )
+ for phase, query_template in RELATIONSHIP_DELETE_QUERY_TEMPLATES.items():
+ deleted_relationships, phase_batches = delete_batches(
+ session=session,
+ logger=logger,
+ log_target="Neptune sink",
+ provider_id=provider_id,
+ query=query_template.format(provider_label=provider_label),
+ phase=phase,
+ count_key="deleted_rels_count",
+ total_key="rels",
+ deleted_key="deleted_rels",
+ initial_total=deleted_relationships,
+ batch_size=GRAPH_MUTATION_BATCH_SIZE,
+ drop_t0=drop_t0,
+ )
+ relationship_batches += phase_batches
+
+ deleted_nodes, node_batches = delete_batches(
+ session=session,
+ logger=logger,
+ log_target="Neptune sink",
+ provider_id=provider_id,
+ query=NODE_DELETE_QUERY_TEMPLATE.format(
+ provider_label=provider_label,
+ provider_resource_label=PROVIDER_RESOURCE_LABEL,
+ ),
+ phase="node",
+ count_key="deleted_nodes_count",
+ total_key="nodes",
+ deleted_key="deleted_nodes",
+ initial_total=0,
+ batch_size=GRAPH_MUTATION_BATCH_SIZE,
+ drop_t0=drop_t0,
+ )
+
+ logger.info(
+ "Finished dropping provider graph from Neptune sink "
+ "(provider=%s, relationship_batches=%s, deleted_rels=%s, "
+ "node_batches=%s, deleted_nodes=%s, elapsed=%.3fs)",
+ provider_id,
+ relationship_batches,
+ deleted_relationships,
+ node_batches,
+ deleted_nodes,
+ time.perf_counter() - drop_t0,
+ )
+ return deleted_nodes
+
+ def has_provider_data(self, database: str, provider_id: str) -> bool: # noqa: ARG002
+ from tasks.jobs.attack_paths.config import (
+ PROVIDER_RESOURCE_LABEL,
+ get_provider_label,
+ )
+
+ provider_label = get_provider_label(provider_id)
+ query = (
+ f"MATCH (n:{PROVIDER_RESOURCE_LABEL}:`{provider_label}`) RETURN 1 LIMIT 1"
+ )
+ with self.get_session(default_access_mode=neo4j.READ_ACCESS) as session:
+ result = session.run(query)
+ return result.single() is not None
+
+ def clear_cache(self, database: str) -> None: # noqa: ARG002
+ # Neptune has no user-facing cache-clear procedure; no-op.
+ return None
+
+ # Sync write path
+
+ def ensure_sync_indexes(self, database: str) -> None: # noqa: ARG002
+ # Neptune routes node and relationship lookups through `~id`, which is the cluster's primary key
+ # No additional index is needed or supported
+ return None
+
+ def write_nodes(
+ self,
+ database: str, # noqa: ARG002
+ labels: str,
+ rows: list[dict[str, Any]],
+ ) -> None:
+ if not rows:
+ return
+ from tasks.jobs.attack_paths.config import (
+ PROVIDER_ELEMENT_ID_PROPERTY,
+ PROVIDER_RESOURCE_LABEL,
+ )
+
+ # MERGE on `~id` is the documented and engine-optimized idempotent
+ # upsert pattern for Neptune openCypher. The label inside the MERGE
+ # matters: Neptune assigns a default `vertex` label to any node
+ # created without an explicit one, so we pin `_ProviderResource`
+ # (which every synced node carries anyway) at MERGE-time. Additional
+ # labels are added after
+ #
+ # We also write `_provider_element_id` as a regular property so
+ # non-sync code (drop_subgraph, query helpers) keeps a stable contract
+ # that doesn't know about `~id`
+ query = f"""
+ UNWIND $rows AS row
+ MERGE (n:`{PROVIDER_RESOURCE_LABEL}` {{`~id`: row.provider_element_id}})
+ SET n:{labels}
+ SET n += row.props
+ SET n.`{PROVIDER_ELEMENT_ID_PROPERTY}` = row.provider_element_id
+ """
+ with self.get_session() as session:
+ session.execute_write(lambda tx: tx.run(query, {"rows": rows}).consume())
+
+ def write_relationships(
+ self,
+ database: str, # noqa: ARG002
+ rel_type: str,
+ provider_id: str, # noqa: ARG002 - encoded in start/end `~id` already
+ rows: list[dict[str, Any]],
+ ) -> None:
+ if not rows:
+ return
+ from tasks.jobs.attack_paths.config import PROVIDER_ELEMENT_ID_PROPERTY
+
+ # `id(n) = $value` is Neptune's parameterized fast path; both endpoint
+ # MATCHes resolve in O(1) via the system `~id`, so per-row work stays
+ # bounded regardless of batch size
+ query = f"""
+ UNWIND $rows AS row
+ MATCH (s) WHERE id(s) = row.start_element_id
+ MATCH (e) WHERE id(e) = row.end_element_id
+ MERGE (s)-[r:`{rel_type}` {{`{PROVIDER_ELEMENT_ID_PROPERTY}`: row.provider_element_id}}]->(e)
+ SET r += row.props
+ """
+ with self.get_session() as session:
+ session.execute_write(lambda tx: tx.run(query, {"rows": rows}).consume())
+
+ # Test helpers
+
+ def get_writer(self) -> neo4j.Driver:
+ return self._get_writer()
+
+ def get_reader(self) -> neo4j.Driver:
+ return self._get_reader()
+
+
+# SigV4 auth provider
+
+
+class _NeptuneAuthToken(neo4j.Auth):
+ """Neo4j Auth backed by a SigV4-signed GET to `/opencypher`."""
+
+ def __init__(self, region: str, url: str) -> None:
+ session = BotoSession()
+ credentials = session.get_credentials()
+ if credentials is None:
+ raise RuntimeError(
+ "No AWS credentials available for Neptune SigV4 signing. "
+ "Ensure the boto3 credential chain can resolve."
+ )
+ credentials = credentials.get_frozen_credentials()
+
+ request = AWSRequest(method="GET", url=url + "/opencypher")
+ # SigV4 canonical Host must carry the real `host:port`
+ # Neptune runs on a non-default port (8182), so `.hostname` would drop it and break signing
+ request.headers.add_header("Host", urlsplit(url).netloc)
+ SigV4Auth(credentials, "neptune-db", region).add_auth(request)
+
+ auth_obj = {
+ header: request.headers[header]
+ for header in (
+ "Authorization",
+ "X-Amz-Date",
+ "X-Amz-Security-Token",
+ "Host",
+ )
+ if header in request.headers
+ }
+ auth_obj["HttpMethod"] = "GET"
+
+ super().__init__("basic", "username", json.dumps(auth_obj))
+
+
+def neptune_auth_provider(region: str, https_url: str) -> Callable[[], ExpiringAuth]:
+ """Return a callable the neo4j driver can invoke to refresh credentials."""
+
+ def _provider() -> ExpiringAuth:
+ token = _NeptuneAuthToken(region, https_url)
+ expires_at = (
+ datetime.datetime.now(datetime.UTC)
+ + datetime.timedelta(minutes=SIGV4_TOKEN_LIFETIME_MINUTES)
+ ).timestamp()
+ return ExpiringAuth(auth=token, expires_at=expires_at)
+
+ return _provider
diff --git a/api/src/backend/api/attack_paths/views_helpers.py b/api/src/backend/api/attack_paths/views_helpers.py
index 201527885e..1c1d3e7888 100644
--- a/api/src/backend/api/attack_paths/views_helpers.py
+++ b/api/src/backend/api/attack_paths/views_helpers.py
@@ -1,12 +1,11 @@
import logging
-
-from typing import Any, Iterable
+from collections.abc import Iterable
+from typing import Any
import neo4j
-
-from rest_framework.exceptions import APIException, PermissionDenied, ValidationError
-
-from api.attack_paths import database as graph_database, AttackPathsQueryDefinition
+from api.attack_paths import AttackPathsQueryDefinition
+from api.attack_paths import database as graph_database
+from api.attack_paths import sink as sink_module
from api.attack_paths.cypher_sanitizer import (
inject_provider_label,
validate_custom_query,
@@ -16,7 +15,10 @@ from api.attack_paths.queries.schema import (
RAW_SCHEMA_URL,
get_cartography_schema_query,
)
+from api.models import AttackPathsScan
from config.custom_logging import BackendLogger
+from config.env import env
+from rest_framework.exceptions import APIException, PermissionDenied, ValidationError
from tasks.jobs.attack_paths.config import (
INTERNAL_LABELS,
INTERNAL_PROPERTIES,
@@ -27,6 +29,10 @@ from tasks.jobs.attack_paths.config import (
logger = logging.getLogger(BackendLogger.API)
+def _custom_query_timeout_ms() -> int:
+ return env.int("ATTACK_PATHS_READ_QUERY_TIMEOUT_SECONDS", default=30) * 1000
+
+
# Predefined query helpers
@@ -103,13 +109,13 @@ def execute_query(
definition: AttackPathsQueryDefinition,
parameters: dict[str, Any],
provider_id: str,
+ scan: AttackPathsScan,
) -> dict[str, Any]:
try:
- graph = graph_database.execute_read_query(
- database=database_name,
- cypher=definition.cypher,
- parameters=parameters,
- )
+ # TODO: drop after Neptune cutover
+ # Route reads by the scan row's recorded sink, not by current settings.
+ backend = sink_module.get_backend_for_scan(scan)
+ graph = backend.execute_read_query(database_name, definition.cypher, parameters)
return _serialize_graph(graph, provider_id)
except graph_database.WriteQueryNotAllowedException:
@@ -143,22 +149,31 @@ def execute_custom_query(
database_name: str,
cypher: str,
provider_id: str,
+ scan: AttackPathsScan,
) -> dict[str, Any]:
# Defense-in-depth for custom queries:
- # 1. neo4j.READ_ACCESS — prevents mutations at the driver level
- # 2. inject_provider_label() — regex-based label injection scopes node patterns
- # 3. _serialize_graph() — post-query filter drops nodes without the provider label
+ # 1. `neo4j.READ_ACCESS` - prevents mutations at the driver level
+ # 2. `inject_provider_label()` - regex-based label injection scopes node patterns
+ # 3. `_serialize_graph()` - post-query filter drops nodes without the provider label
+ # 4. `USING QUERY:TIMEOUTMILLISECONDS` on Neptune - server-side runaway cutoff
#
# Layer 2 is best-effort (regex can't fully parse Cypher);
# layer 3 is the safety net that guarantees provider isolation.
validate_custom_query(cypher)
cypher = inject_provider_label(cypher, provider_id)
+ # TODO: drop after Neptune cutover
+ backend = sink_module.get_backend_for_scan(scan)
+
+ # Neptune enforces a cluster-level query timeout; prepending the hint
+ # makes the limit explicit and matches the client-side read timeout.
+ # Applies only when the scan's graph lives in Neptune.
+ if getattr(scan, "sink_backend", None) == "neptune":
+ timeout_ms = _custom_query_timeout_ms()
+ cypher = f"USING QUERY:TIMEOUTMILLISECONDS {timeout_ms}\n{cypher}"
+
try:
- graph = graph_database.execute_read_query(
- database=database_name,
- cypher=cypher,
- )
+ graph = backend.execute_read_query(database_name, cypher, None)
serialized = _serialize_graph(graph, provider_id)
return _truncate_graph(serialized)
@@ -181,10 +196,11 @@ def execute_custom_query(
def get_cartography_schema(
- database_name: str, provider_id: str
+ database_name: str, provider_id: str, scan: AttackPathsScan
) -> dict[str, str] | None:
try:
- with graph_database.get_session(
+ backend = sink_module.get_backend_for_scan(scan)
+ with backend.get_session(
database_name, default_access_mode=neo4j.READ_ACCESS
) as session:
result = session.run(get_cartography_schema_query(provider_id))
diff --git a/api/src/backend/api/authentication.py b/api/src/backend/api/authentication.py
index 499e290bb7..755bd64e39 100644
--- a/api/src/backend/api/authentication.py
+++ b/api/src/backend/api/authentication.py
@@ -1,18 +1,19 @@
-from typing import Optional, Tuple
+from math import isfinite
from uuid import UUID
+from api.db_router import MainRouter
+from api.models import TenantAPIKey, TenantAPIKeyManager
from cryptography.fernet import InvalidToken
+from django.core.exceptions import ObjectDoesNotExist
from django.utils import timezone
from drf_simple_apikey.backends import APIKeyAuthentication as BaseAPIKeyAuth
from drf_simple_apikey.crypto import get_crypto
+from drf_simple_apikey.settings import package_settings
from rest_framework.authentication import BaseAuthentication
from rest_framework.exceptions import AuthenticationFailed
from rest_framework.request import Request
from rest_framework_simplejwt.authentication import JWTAuthentication
-from api.db_router import MainRouter
-from api.models import TenantAPIKey, TenantAPIKeyManager
-
class TenantAPIKeyAuthentication(BaseAPIKeyAuth):
model = TenantAPIKey
@@ -23,18 +24,49 @@ class TenantAPIKeyAuthentication(BaseAPIKeyAuth):
def _authenticate_credentials(self, request, key):
"""
Override to use admin connection, bypassing RLS during authentication.
- Delegates to parent after temporarily routing model queries to admin DB.
"""
- # Temporarily point the model's manager to admin database
- original_objects = self.model.objects
- self.model.objects = self.model.objects.using(MainRouter.admin_db)
+ try:
+ payload = self.key_crypto.decrypt(key)
+ except ValueError:
+ raise AuthenticationFailed("Invalid API Key.")
+
+ if not isinstance(payload, dict):
+ raise AuthenticationFailed("Invalid API Key.")
+
+ payload_pk = payload.get("_pk")
+ payload_exp = payload.get("_exp")
+ if (
+ not isinstance(payload_pk, str)
+ or isinstance(payload_exp, bool)
+ or not isinstance(payload_exp, (int, float))
+ or not isfinite(payload_exp)
+ ):
+ raise AuthenticationFailed("Invalid API Key.")
try:
- # Call parent method which will now use admin database
- return super()._authenticate_credentials(request, key)
- finally:
- # Restore original manager
- self.model.objects = original_objects
+ api_key_pk = UUID(payload_pk)
+ except ValueError:
+ raise AuthenticationFailed("Invalid API Key.")
+
+ if payload_exp < timezone.now().timestamp():
+ raise AuthenticationFailed("API Key has already expired.")
+
+ try:
+ api_key = self.model.objects.using(MainRouter.admin_db).get(id=api_key_pk)
+ except ObjectDoesNotExist:
+ raise AuthenticationFailed("No entity matching this api key.")
+
+ if api_key.revoked:
+ raise AuthenticationFailed("This API Key has been revoked.")
+
+ client_ip = request.META.get(package_settings.IP_ADDRESS_HEADER)
+ if api_key.blacklisted_ips and client_ip in api_key.blacklisted_ips:
+ raise AuthenticationFailed("Access denied from blacklisted IP.")
+
+ if api_key.whitelisted_ips and client_ip not in api_key.whitelisted_ips:
+ raise AuthenticationFailed("Access restricted to specific IP addresses.")
+
+ return api_key.entity, key
def authenticate(self, request: Request):
prefixed_key = self.get_key(request)
@@ -81,7 +113,7 @@ class CombinedJWTOrAPIKeyAuthentication(BaseAuthentication):
jwt_auth = JWTAuthentication()
api_key_auth = TenantAPIKeyAuthentication()
- def authenticate(self, request: Request) -> Optional[Tuple[object, dict]]:
+ def authenticate(self, request: Request) -> tuple[object, dict] | None:
auth_header = request.headers.get("Authorization", "")
# Prioritize JWT authentication if both are present
@@ -93,3 +125,30 @@ class CombinedJWTOrAPIKeyAuthentication(BaseAuthentication):
# Default fallback
return self.jwt_auth.authenticate(request)
+
+
+class SSEAuthentication(CombinedJWTOrAPIKeyAuthentication):
+ """JWT/API-Key auth that also accepts `?access_token=`.
+
+ Browser `EventSource` is the only widely available SSE client API
+ and it cannot set the `Authorization` header (its constructor takes
+ only a URL and `withCredentials`). To keep browser SSE clients on
+ the same auth stack as the rest of the API, SSE endpoints additionally
+ accept a JWT via the `?access_token=` query parameter — the
+ standard parameter name defined in RFC 6750 Section 2.3 for bearer tokens.
+ """
+
+ def authenticate(self, request: Request):
+ auth_header = request.headers.get("Authorization", "")
+ if auth_header:
+ return super().authenticate(request)
+
+ raw_token = request.query_params.get("access_token")
+ if not raw_token:
+ # No header and no query token — let the default path raise
+ # the canonical AuthenticationFailed via the parent class.
+ return super().authenticate(request)
+
+ validated_token = self.jwt_auth.get_validated_token(raw_token)
+ user = self.jwt_auth.get_user(validated_token)
+ return user, validated_token
diff --git a/api/src/backend/api/base_views.py b/api/src/backend/api/base_views.py
index b14cc39529..e8dd728cb9 100644
--- a/api/src/backend/api/base_views.py
+++ b/api/src/backend/api/base_views.py
@@ -1,3 +1,9 @@
+from api.authentication import CombinedJWTOrAPIKeyAuthentication
+from api.db_router import MainRouter, reset_read_db_alias, set_read_db_alias
+from api.db_utils import POSTGRES_USER_VAR, rls_transaction
+from api.filters import CustomDjangoFilterBackend
+from api.models import Role, UserRoleRelationship
+from api.rbac.permissions import HasPermissions
from django.conf import settings
from django.db import transaction
from rest_framework import permissions
@@ -8,13 +14,6 @@ from rest_framework.response import Response
from rest_framework_json_api import filters
from rest_framework_json_api.views import ModelViewSet
-from api.authentication import CombinedJWTOrAPIKeyAuthentication
-from api.db_router import MainRouter, reset_read_db_alias, set_read_db_alias
-from api.db_utils import POSTGRES_USER_VAR, rls_transaction
-from api.filters import CustomDjangoFilterBackend
-from api.models import Role, UserRoleRelationship
-from api.rbac.permissions import HasPermissions
-
class BaseViewSet(ModelViewSet):
authentication_classes = [CombinedJWTOrAPIKeyAuthentication]
diff --git a/api/src/backend/api/compliance.py b/api/src/backend/api/compliance.py
index 25b8fb6735..854c7d8ba6 100644
--- a/api/src/backend/api/compliance.py
+++ b/api/src/backend/api/compliance.py
@@ -1,11 +1,26 @@
+import logging
+import threading
from collections.abc import Iterable, Mapping
from api.models import Provider
-from prowler.lib.check.compliance_models import Compliance
+from prowler.lib.check.compliance_models import (
+ get_bulk_compliance_frameworks_universal,
+)
from prowler.lib.check.models import CheckMetadata
+logger = logging.getLogger(__name__)
+
AVAILABLE_COMPLIANCE_FRAMEWORKS = {}
+# Per-process readiness flags for the background compliance warm-up.
+# `STARTED` is set as soon as warming begins (only happens under Gunicorn via
+# the post_fork hook); `WARMED` is set when it finishes. The attributes
+# endpoint checks both: it returns 503 only while warming is in progress.
+# Under `runserver` warming never runs, so `STARTED` stays clear and the
+# endpoint keeps lazy-loading as before.
+COMPLIANCE_WARMING_STARTED = threading.Event()
+COMPLIANCE_WARMED = threading.Event()
+
class LazyComplianceTemplate(Mapping):
"""Lazy-load compliance templates per provider on first access."""
@@ -94,25 +109,22 @@ PROWLER_CHECKS = LazyChecksMapping()
def get_compliance_frameworks(provider_type: Provider.ProviderChoices) -> list[str]:
- """List compliance frameworks the API can load for `provider_type`.
+ """List compliance framework identifiers available for `provider_type`.
- The list is sourced from `Compliance.get_bulk` so that the names
- returned here are guaranteed to be loadable by the bulk loader. This
- prevents downstream key mismatches (e.g. CSV report generation iterating
- framework names and looking them up in the bulk dict).
+ Includes both per-provider frameworks and universal top-level frameworks
+ (e.g. ``dora_2022_2554``, ``csa_ccm_4.0``).
Args:
- provider_type (Provider.ProviderChoices): The cloud provider type for which to retrieve
- available compliance frameworks (e.g., "aws", "azure", "gcp", "m365").
+ provider_type (Provider.ProviderChoices): The cloud provider type
+ (e.g., "aws", "azure", "gcp", "m365").
Returns:
- list[str]: A list of framework identifiers (e.g., "cis_1.4_aws", "mitre_attack_azure") available
- for the given provider.
+ list[str]: Framework identifiers (e.g., "cis_1.4_aws", "dora_2022_2554").
"""
global AVAILABLE_COMPLIANCE_FRAMEWORKS
if provider_type not in AVAILABLE_COMPLIANCE_FRAMEWORKS:
AVAILABLE_COMPLIANCE_FRAMEWORKS[provider_type] = list(
- Compliance.get_bulk(provider_type).keys()
+ get_bulk_compliance_frameworks_universal(provider_type).keys()
)
return AVAILABLE_COMPLIANCE_FRAMEWORKS[provider_type]
@@ -139,18 +151,14 @@ def get_prowler_provider_compliance(provider_type: Provider.ProviderChoices) ->
"""
Retrieve the Prowler compliance data for a specified provider type.
- This function fetches the compliance frameworks and their associated
- requirements for the given cloud provider.
-
Args:
provider_type (Provider.ProviderChoices): The provider type
(e.g., 'aws', 'azure') for which to retrieve compliance data.
Returns:
- dict: A dictionary mapping compliance framework names to their respective
- Compliance objects for the specified provider.
+ dict: Mapping of framework name to `ComplianceFramework` for the provider.
"""
- return Compliance.get_bulk(provider_type)
+ return get_bulk_compliance_frameworks_universal(provider_type)
def _load_provider_assets(provider_type: Provider.ProviderChoices) -> tuple[dict, dict]:
@@ -179,6 +187,56 @@ def _ensure_provider_loaded(provider_type: Provider.ProviderChoices) -> None:
PROWLER_CHECKS._cache[provider_type] = checks
+def warm_compliance_caches(
+ provider_types: Iterable[str] | None = None,
+) -> list[str]:
+ """
+ Eagerly populate the per-process compliance caches at server startup.
+
+ Moves the cold-cache catalog load off the request thread so the first
+ request does not trip the Gunicorn worker timeout. Reads only on-disk
+ metadata (no database access). Each provider is warmed in isolation;
+ failures are logged and fall back to lazy loading.
+
+ Args:
+ provider_types (Iterable[str] | None): Subset to warm. Defaults to all.
+
+ Returns:
+ list[str]: Provider types that could not be warmed.
+ """
+ if provider_types is None:
+ provider_types = Provider.ProviderChoices.values
+ provider_types = list(provider_types)
+
+ COMPLIANCE_WARMING_STARTED.set()
+ logger.info("Compliance cache warm-up started for providers: %s", provider_types)
+
+ failed = []
+ for provider_type in provider_types:
+ try:
+ get_compliance_frameworks(provider_type)
+ _ensure_provider_loaded(provider_type)
+ # Prowler check loading may sys.exit (SystemExit, not Exception).
+ except (Exception, SystemExit):
+ logger.warning(
+ "Failed to warm compliance caches for provider '%s'; "
+ "loading lazily on first request",
+ provider_type,
+ exc_info=True,
+ )
+ failed.append(provider_type)
+
+ # Mark as warmed even when some providers failed: a failed provider falls
+ # back to a single-provider lazy load, which stays under the worker timeout.
+ COMPLIANCE_WARMED.set()
+ logger.info(
+ "Compliance cache warm-up finished (providers warmed: %d, failed: %s)",
+ len(provider_types) - len(failed),
+ failed,
+ )
+ return failed
+
+
def load_prowler_checks(
prowler_compliance, provider_types: Iterable[str] | None = None
):
@@ -209,8 +267,8 @@ def load_prowler_checks(
for compliance_name, compliance_data in prowler_compliance.get(
provider_type, {}
).items():
- for requirement in compliance_data.Requirements:
- for check in requirement.Checks:
+ for requirement in compliance_data.requirements:
+ for check in requirement.checks.get(provider_type, []):
try:
checks[provider_type][check].add(compliance_name)
except KeyError:
@@ -290,24 +348,40 @@ def generate_compliance_overview_template(
requirements_status = {"passed": 0, "failed": 0, "manual": 0}
total_requirements = 0
- for requirement in compliance_data.Requirements:
+ for requirement in compliance_data.requirements:
total_requirements += 1
- total_checks = len(requirement.Checks)
- checks_dict = {check: None for check in requirement.Checks}
+ provider_check_list = list(requirement.checks.get(provider_type, []))
+ total_checks = len(provider_check_list)
+ checks_dict = dict.fromkeys(provider_check_list)
req_status_val = "MANUAL" if total_checks == 0 else "PASS"
+ # MITRE attrs are wrapped under `_raw_attributes` by the
+ # universal adapter — unwrap so consumers see the flat list.
+ requirement_attributes = requirement.attributes
+ if (
+ isinstance(requirement_attributes, dict)
+ and "_raw_attributes" in requirement_attributes
+ ):
+ attributes_payload = list(requirement_attributes["_raw_attributes"])
+ elif isinstance(requirement_attributes, dict):
+ attributes_payload = (
+ [dict(requirement_attributes)] if requirement_attributes else []
+ )
+ else:
+ attributes_payload = [
+ dict(attribute) for attribute in requirement_attributes
+ ]
+
# Build requirement dictionary
requirement_dict = {
- "name": requirement.Name or requirement.Id,
- "description": requirement.Description,
- "tactics": getattr(requirement, "Tactics", []),
- "subtechniques": getattr(requirement, "SubTechniques", []),
- "platforms": getattr(requirement, "Platforms", []),
- "technique_url": getattr(requirement, "TechniqueURL", ""),
- "attributes": [
- dict(attribute) for attribute in requirement.Attributes
- ],
+ "name": requirement.name or requirement.id,
+ "description": requirement.description,
+ "tactics": requirement.tactics or [],
+ "subtechniques": requirement.sub_techniques or [],
+ "platforms": requirement.platforms or [],
+ "technique_url": requirement.technique_url or "",
+ "attributes": attributes_payload,
"checks": checks_dict,
"checks_status": {
"pass": 0,
@@ -325,15 +399,15 @@ def generate_compliance_overview_template(
requirements_status["passed"] += 1
# Add requirement to compliance requirements
- compliance_requirements[requirement.Id] = requirement_dict
+ compliance_requirements[requirement.id] = requirement_dict
# Build compliance dictionary
compliance_dict = {
- "framework": compliance_data.Framework,
- "name": compliance_data.Name,
- "version": compliance_data.Version,
+ "framework": compliance_data.framework,
+ "name": compliance_data.name,
+ "version": compliance_data.version,
"provider": provider_type,
- "description": compliance_data.Description,
+ "description": compliance_data.description,
"requirements": compliance_requirements,
"requirements_status": requirements_status,
"total_requirements": total_requirements,
diff --git a/api/src/backend/api/db_utils.py b/api/src/backend/api/db_utils.py
index e3b11d7084..b6d3fdada1 100644
--- a/api/src/backend/api/db_utils.py
+++ b/api/src/backend/api/db_utils.py
@@ -2,9 +2,15 @@ import re
import secrets
import time
import uuid
-from contextlib import contextmanager
-from datetime import datetime, timedelta, timezone
+from contextlib import ExitStack, contextmanager, nullcontext
+from datetime import UTC, datetime, timedelta
+from api.db_router import (
+ READ_REPLICA_ALIAS,
+ get_read_db_alias,
+ reset_read_db_alias,
+ set_read_db_alias,
+)
from celery.utils.log import get_task_logger
from config.env import env
from django.conf import settings
@@ -22,13 +28,6 @@ from psycopg2 import sql as psycopg2_sql
from psycopg2.extensions import AsIs, new_type, register_adapter, register_type
from rest_framework_json_api.serializers import ValidationError
-from api.db_router import (
- READ_REPLICA_ALIAS,
- get_read_db_alias,
- reset_read_db_alias,
- set_read_db_alias,
-)
-
logger = get_task_logger(__name__)
DB_USER = settings.DATABASES["default"]["USER"] if not settings.TESTING else "test"
@@ -49,6 +48,140 @@ REPLICA_MAX_ATTEMPTS = env.int("POSTGRES_REPLICA_MAX_ATTEMPTS", default=3)
REPLICA_RETRY_BASE_DELAY = env.float("POSTGRES_REPLICA_RETRY_BASE_DELAY", default=0.5)
SET_CONFIG_QUERY = "SELECT set_config(%s, %s::text, TRUE);"
+SET_TRANSACTION_READ_ONLY_QUERY = "SET TRANSACTION READ ONLY;"
+
+REPLICA_CONNECTION_SQLSTATE_PREFIXES = ("08",)
+REPLICA_CONNECTION_SQLSTATES = {"57P01", "57P02", "57P03"}
+REPLICA_NON_FAILOVER_SQLSTATES = {"57014", "40001", "40P01"}
+REPLICA_CONNECTION_ERROR_MESSAGES = (
+ "ssl syscall",
+ "eof detected",
+ "server closed the connection",
+ "connection already closed",
+ "connection not open",
+ "could not connect to server",
+ "connection refused",
+ "connection reset",
+ "connection timed out",
+ "lost synchronization",
+ "terminating connection",
+ "database system is starting up",
+ "database system is shutting down",
+ "database system is in recovery mode",
+)
+REPLICA_NON_FAILOVER_ERROR_MESSAGES = (
+ "canceling statement due to user request",
+ "deadlock detected",
+ "could not serialize access",
+)
+
+
+def _iter_exception_chain(error: BaseException):
+ seen = set()
+ pending = [error]
+ while pending:
+ current = pending.pop(0)
+ if current is None or id(current) in seen:
+ continue
+ seen.add(id(current))
+ yield current
+
+ cause = getattr(current, "__cause__", None)
+ context = getattr(current, "__context__", None)
+ if cause is not None:
+ pending.append(cause)
+ if context is not None:
+ pending.append(context)
+ for arg in getattr(current, "args", ()):
+ if isinstance(arg, BaseException):
+ pending.append(arg)
+
+
+def _get_exception_sqlstate(error: BaseException) -> str | None:
+ for attr in ("pgcode", "sqlstate"):
+ sqlstate = getattr(error, attr, None)
+ if sqlstate:
+ return sqlstate
+
+ diag = getattr(error, "diag", None)
+ if diag is not None:
+ sqlstate = getattr(diag, "sqlstate", None)
+ if sqlstate:
+ return sqlstate
+ return None
+
+
+def _is_replica_connection_failure(error: BaseException) -> bool:
+ """
+ Return True only for replica failures where retrying on primary is safe.
+
+ Query cancellations, serialization failures, and deadlocks should surface to
+ callers because replaying them can hide real query or concurrency problems.
+ """
+ messages = []
+ sqlstates = set()
+
+ for chained_error in _iter_exception_chain(error):
+ sqlstate = _get_exception_sqlstate(chained_error)
+ if sqlstate:
+ sqlstates.add(sqlstate)
+ messages.append(str(chained_error).lower())
+
+ if sqlstates & REPLICA_NON_FAILOVER_SQLSTATES:
+ return False
+ if any(
+ sqlstate.startswith(REPLICA_CONNECTION_SQLSTATE_PREFIXES)
+ or sqlstate in REPLICA_CONNECTION_SQLSTATES
+ for sqlstate in sqlstates
+ ):
+ return True
+
+ message = " ".join(messages)
+ if any(marker in message for marker in REPLICA_NON_FAILOVER_ERROR_MESSAGES):
+ return False
+
+ return any(marker in message for marker in REPLICA_CONNECTION_ERROR_MESSAGES)
+
+
+def _strip_leading_sql_comments(sql: str) -> str:
+ if not isinstance(sql, str):
+ return ""
+
+ sql_text = sql.lstrip()
+ while True:
+ if sql_text.startswith("--"):
+ newline_index = sql_text.find("\n")
+ if newline_index == -1:
+ return ""
+ sql_text = sql_text[newline_index + 1 :].lstrip()
+ continue
+
+ if sql_text.startswith("/*"):
+ comment_end_index = sql_text.find("*/", 2)
+ if comment_end_index == -1:
+ return ""
+ sql_text = sql_text[comment_end_index + 2 :].lstrip()
+ continue
+
+ return sql_text
+
+
+def _is_safe_primary_replay(sql: str, many: bool) -> bool:
+ if many:
+ return False
+
+ sql_text = _strip_leading_sql_comments(sql)
+ if not re.match(r"(?is)^SELECT\b", sql_text):
+ return False
+
+ return not any(
+ re.search(pattern, sql_text, re.IGNORECASE | re.DOTALL)
+ for pattern in (
+ r"\bINTO\b",
+ r"\bFOR\s+(?:NO\s+KEY\s+)?UPDATE\b",
+ r"\bFOR\s+(?:KEY\s+)?SHARE\b",
+ )
+ )
@contextmanager
@@ -78,14 +211,36 @@ def rls_transaction(
retry_on_replica: bool = True,
):
"""
- Creates a new database transaction setting the given configuration value for Postgres RLS. It validates the
- if the value is a valid UUID.
+ Context manager that opens an RLS-scoped database transaction.
+
+ Sets a Postgres configuration variable (``set_config``) so that Row-Level
+ Security policies can filter by tenant. When *using* points to a read
+ replica and *retry_on_replica* is True, replica failures are handled in two
+ places:
+
+ 1. **Pre-yield** (connection-setup failures): the function retries
+ up to ``REPLICA_MAX_ATTEMPTS`` times on the replica, then falls
+ back to the primary DB.
+ 2. **Post-yield** (mid-query failures): an ``execute_wrapper``
+ intercepts connection-level ``OperationalError`` during
+ ``cursor.execute()`` calls and falls back directly to the primary DB
+ for single ``SELECT`` statements. The primary fallback transaction is
+ read-only, and unsafe statements keep raising the original error.
+ The wrapper swaps the inner cursor so ``fetchall()`` / ``fetchone()``
+ read from the new connection transparently.
+
+ Limitation: server-side cursors (``.iterator()``) fetch rows via
+ ``fetchmany()``, which the wrapper does not intercept. Call sites
+ that iterate large result sets with ``.iterator()`` on the replica
+ should add their own retry logic.
Args:
- value (str): Database configuration parameter value.
- parameter (str): Database configuration parameter name, by default is 'api.tenant_id'.
- using (str | None): Optional database alias to run the transaction against. Defaults to the
- active read alias (if any) or Django's default connection.
+ value: Database configuration parameter value (must be a valid UUID).
+ parameter: Database configuration parameter name.
+ using: Optional database alias. Defaults to the active read
+ alias or Django's default connection.
+ retry_on_replica: Whether replica setup failures can retry and
+ connection-level mid-query failures can fall back to primary.
"""
requested_alias = using or get_read_db_alias()
db_alias = requested_alias or DEFAULT_DB_ALIAS
@@ -93,54 +248,121 @@ def rls_transaction(
db_alias = DEFAULT_DB_ALIAS
alias = db_alias
- is_replica = READ_REPLICA_ALIAS and alias == READ_REPLICA_ALIAS
- max_attempts = REPLICA_MAX_ATTEMPTS if is_replica and retry_on_replica else 1
+ is_replica = bool(READ_REPLICA_ALIAS and alias == READ_REPLICA_ALIAS)
+ can_failover = is_replica and retry_on_replica
+ replica_alias = alias # captured before the loop mutates alias
+ max_attempts = (REPLICA_MAX_ATTEMPTS + 1) if can_failover else 1
- for attempt in range(1, max_attempts + 1):
- router_token = None
- yielded_cursor = False
+ # State shared between the generator and the _query_failover closure.
+ # The fallback transaction.atomic() is registered into fallback_stack
+ # via enter_context so its __exit__ runs when the outer with-ExitStack
+ # block exits, with the right exc_info. No manual __enter__/__exit__.
+ _fallback = {"succeeded": False, "token": None, "caller_exited_cleanly": False}
- # On final attempt, fallback to primary
- if attempt == max_attempts and is_replica:
- logger.warning(
- f"RLS transaction failed after {attempt - 1} attempts on replica, "
- f"falling back to primary DB"
- )
- alias = DEFAULT_DB_ALIAS
+ with ExitStack() as fallback_stack:
- conn = connections[alias]
- try:
- if alias != DEFAULT_DB_ALIAS:
- router_token = set_read_db_alias(alias)
+ def _query_failover(execute, sql, params, many, context):
+ """execute_wrapper: replay failed replica queries on the primary DB."""
+ try:
+ return execute(sql, params, many, context)
+ except OperationalError as err:
+ if not _is_replica_connection_failure(err):
+ raise
+ if not _is_safe_primary_replay(sql, many):
+ raise
- with transaction.atomic(using=alias):
- with conn.cursor() as cursor:
- try:
- # just in case the value is a UUID object
- uuid.UUID(str(value))
- except ValueError:
- raise ValidationError("Must be a valid UUID")
- cursor.execute(SET_CONFIG_QUERY, [parameter, value])
- yielded_cursor = True
- yield cursor
- return
- except OperationalError as e:
- if yielded_cursor:
- raise
- # If on primary or max attempts reached, raise
- if not is_replica or attempt == max_attempts:
- raise
+ try:
+ connections[replica_alias].close()
+ except Exception:
+ pass # Best-effort; connection may already be dead
- # Retry with exponential backoff
- delay = REPLICA_RETRY_BASE_DELAY * (2 ** (attempt - 1))
- logger.info(
- f"RLS transaction failed on replica (attempt {attempt}/{max_attempts}), "
- f"retrying in {delay}s. Error: {e}"
- )
- time.sleep(delay)
- finally:
- if router_token is not None:
- reset_read_db_alias(router_token)
+ logger.warning(
+ "Mid-query replica connection failure, falling back to primary DB"
+ )
+ primary = connections[DEFAULT_DB_ALIAS]
+ primary.ensure_connection()
+ fallback_stack.enter_context(transaction.atomic(using=DEFAULT_DB_ALIAS))
+
+ fallback_cursor = primary.cursor()
+ fallback_stack.callback(fallback_cursor.close)
+ fallback_cursor.execute(SET_TRANSACTION_READ_ONLY_QUERY)
+ fallback_cursor.execute(SET_CONFIG_QUERY, [parameter, value])
+ _fallback["token"] = set_read_db_alias(DEFAULT_DB_ALIAS)
+
+ fallback_cursor.execute(sql, params)
+
+ context["cursor"].db = primary
+ context["cursor"].cursor = fallback_cursor.cursor
+ _fallback["succeeded"] = True
+ return None
+
+ for attempt in range(1, max_attempts + 1):
+ router_token = None
+ yielded_cursor = False
+
+ # On final attempt, fall back to primary
+ if attempt == max_attempts and can_failover:
+ if attempt > 1:
+ logger.warning(
+ f"RLS transaction failed after {attempt - 1} attempts on replica, "
+ f"falling back to primary DB"
+ )
+ alias = DEFAULT_DB_ALIAS
+
+ conn = connections[alias]
+ try:
+ if alias != DEFAULT_DB_ALIAS:
+ router_token = set_read_db_alias(alias)
+
+ with transaction.atomic(using=alias):
+ with conn.cursor() as cursor:
+ try:
+ uuid.UUID(str(value))
+ except ValueError:
+ raise ValidationError("Must be a valid UUID")
+ cursor.execute(SET_CONFIG_QUERY, [parameter, value])
+
+ wrapper_cm = (
+ conn.execute_wrapper(_query_failover)
+ if can_failover and alias == replica_alias
+ else nullcontext()
+ )
+ with wrapper_cm:
+ yielded_cursor = True
+ yield cursor
+ _fallback["caller_exited_cleanly"] = True
+ return
+ except OperationalError as e:
+ if yielded_cursor:
+ if _fallback["succeeded"] and _fallback["caller_exited_cleanly"]:
+ # Caller's queries succeeded on primary via failover.
+ # This error is transaction.atomic() cleanup on the
+ # dead replica connection, suppress it.
+ return
+ raise
+
+ if not can_failover or attempt == max_attempts:
+ raise
+
+ try:
+ connections[alias].close()
+ except Exception:
+ pass # Best-effort; connection may already be dead
+
+ # Retry with exponential backoff
+ delay = REPLICA_RETRY_BASE_DELAY * (2 ** (attempt - 1))
+ logger.info(
+ f"RLS transaction failed on replica (attempt {attempt}/{max_attempts}), "
+ f"retrying in {delay}s. Error: {e}"
+ )
+ time.sleep(delay)
+ finally:
+ if _fallback["token"] is not None:
+ reset_read_db_alias(_fallback["token"])
+ _fallback["token"] = None
+
+ if router_token is not None:
+ reset_read_db_alias(router_token)
class CustomUserManager(BaseUserManager):
@@ -170,7 +392,7 @@ def one_week_from_now():
"""
Return a datetime object with a date one week from now.
"""
- return datetime.now(timezone.utc) + timedelta(days=7)
+ return datetime.now(UTC) + timedelta(days=7)
def generate_random_token(length: int = 14, symbols: str | None = None) -> str:
@@ -405,10 +627,10 @@ def _should_create_index_on_partition(
# Unknown month abbreviation, include it to be safe
return True
- partition_date = datetime(year, month, 1, tzinfo=timezone.utc)
+ partition_date = datetime(year, month, 1, tzinfo=UTC)
# Get current month start
- now = datetime.now(timezone.utc)
+ now = datetime.now(UTC)
current_month_start = now.replace(
day=1, hour=0, minute=0, second=0, microsecond=0
)
diff --git a/api/src/backend/api/decorators.py b/api/src/backend/api/decorators.py
index f9b165ef20..2dd2d5fea4 100644
--- a/api/src/backend/api/decorators.py
+++ b/api/src/backend/api/decorators.py
@@ -1,14 +1,14 @@
import uuid
from functools import wraps
-from django.core.exceptions import ObjectDoesNotExist
-from django.db import DatabaseError, connection, transaction
-from rest_framework_json_api.serializers import ValidationError
-
+from api.attack_paths.database import GraphDatabaseQueryException
from api.db_router import READ_REPLICA_ALIAS
from api.db_utils import POSTGRES_TENANT_VAR, SET_CONFIG_QUERY, rls_transaction
from api.exceptions import ProviderDeletedException
-from api.models import Provider, Scan
+from api.models import Membership, Provider, Scan, Tenant
+from django.core.exceptions import ObjectDoesNotExist
+from django.db import DEFAULT_DB_ALIAS, DatabaseError, connection, transaction
+from rest_framework_json_api.serializers import ValidationError
def set_tenant(func=None, *, keep_tenant=False):
@@ -76,9 +76,11 @@ def handle_provider_deletion(func):
"""
Decorator that raises `ProviderDeletedException` if provider was deleted during execution.
- Catches `ObjectDoesNotExist` and `DatabaseError` (including `IntegrityError`), checks if
- provider still exists, and raises `ProviderDeletedException` if not. Otherwise,
- re-raises original exception.
+ Catches `ObjectDoesNotExist`, `DatabaseError` (including `IntegrityError`), and
+ `GraphDatabaseQueryException`, checks if provider still exists, and raises
+ `ProviderDeletedException` if not. Graph database errors also check whether the
+ tenant still exists and has memberships. Otherwise, re-raises the original
+ exception.
Requires `tenant_id` and `provider_id` in kwargs.
@@ -93,11 +95,16 @@ def handle_provider_deletion(func):
def wrapper(*args, **kwargs):
try:
return func(*args, **kwargs)
- except (ObjectDoesNotExist, DatabaseError):
+ except (ObjectDoesNotExist, DatabaseError, GraphDatabaseQueryException) as exc:
tenant_id = kwargs.get("tenant_id")
provider_id = kwargs.get("provider_id")
+ database_alias = (
+ DEFAULT_DB_ALIAS
+ if isinstance(exc, GraphDatabaseQueryException)
+ else READ_REPLICA_ALIAS
+ )
- with rls_transaction(tenant_id, using=READ_REPLICA_ALIAS):
+ with rls_transaction(tenant_id, using=database_alias):
if provider_id is None:
scan_id = kwargs.get("scan_id")
if scan_id is None:
@@ -114,6 +121,13 @@ def handle_provider_deletion(func):
raise ProviderDeletedException(
f"Provider '{provider_id}' was deleted during the scan"
) from None
+ if isinstance(exc, GraphDatabaseQueryException) and (
+ not Tenant.objects.filter(pk=tenant_id).exists()
+ or not Membership.objects.filter(tenant_id=tenant_id).exists()
+ ):
+ raise ProviderDeletedException(
+ f"Tenant '{tenant_id}' was deleted during the scan"
+ ) from None
raise
return wrapper
diff --git a/api/src/backend/api/exceptions.py b/api/src/backend/api/exceptions.py
index 78f8c64c7d..4f6f26c2ea 100644
--- a/api/src/backend/api/exceptions.py
+++ b/api/src/backend/api/exceptions.py
@@ -187,6 +187,32 @@ class UpstreamServiceUnavailableError(APIException):
)
+class ComplianceWarmingError(APIException):
+ """Compliance catalog is still warming (503 Service Unavailable).
+
+ Returned by the compliance attributes endpoint while the per-process
+ catalog warm-up is in progress, so the request thread never triggers the
+ slow cold load that would trip the Gunicorn worker timeout.
+ """
+
+ status_code = status.HTTP_503_SERVICE_UNAVAILABLE
+ default_detail = (
+ "Compliance data is still loading. Please try again in a few seconds."
+ )
+ default_code = "compliance_warming"
+
+ 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).
diff --git a/api/src/backend/api/filters.py b/api/src/backend/api/filters.py
index c787d714b1..a7f888b453 100644
--- a/api/src/backend/api/filters.py
+++ b/api/src/backend/api/filters.py
@@ -1,19 +1,4 @@
-from datetime import date, datetime, timedelta, timezone
-
-from dateutil.parser import parse
-from django.conf import settings
-from django.db.models import F, Q
-from django_filters.rest_framework import (
- BaseInFilter,
- BooleanFilter,
- CharFilter,
- ChoiceFilter,
- DateFilter,
- FilterSet,
- UUIDFilter,
-)
-from rest_framework_json_api.django_filters.backends import DjangoFilterBackend
-from rest_framework_json_api.serializers import ValidationError
+from datetime import UTC, date, datetime, timedelta
from api.constants import SEVERITY_ORDER
from api.db_utils import (
@@ -68,6 +53,21 @@ from api.uuid_utils import (
uuid7_start,
)
from api.v1.serializers import TaskBase
+from dateutil.parser import parse
+from django.conf import settings
+from django.db.models import F, Q
+from django_filters.rest_framework import (
+ BaseInFilter,
+ BooleanFilter,
+ CharFilter,
+ ChoiceFilter,
+ DateFilter,
+ FilterSet,
+ UUIDFilter,
+)
+from rest_framework_json_api.django_filters.backends import DjangoFilterBackend
+from rest_framework_json_api.serializers import ValidationError
+from uuid6 import UUID
class CustomDjangoFilterBackend(DjangoFilterBackend):
@@ -102,7 +102,7 @@ class BaseProviderFilter(FilterSet):
"""
Abstract base filter for models with direct FK to Provider.
- Provides standard provider_id and provider_type filters.
+ Provides standard provider_id, provider_type, and provider_groups filters.
Subclasses must define Meta.model.
"""
@@ -116,6 +116,16 @@ class BaseProviderFilter(FilterSet):
choices=Provider.ProviderChoices.choices,
lookup_expr="in",
)
+ provider_groups = UUIDFilter(
+ field_name="provider__provider_groups__id",
+ lookup_expr="exact",
+ distinct=True,
+ )
+ provider_groups__in = UUIDInFilter(
+ field_name="provider__provider_groups__id",
+ lookup_expr="in",
+ distinct=True,
+ )
class Meta:
abstract = True
@@ -126,7 +136,7 @@ class BaseScanProviderFilter(FilterSet):
"""
Abstract base filter for models with FK to Scan (and Scan has FK to Provider).
- Provides standard provider_id and provider_type filters via scan relationship.
+ Provides standard provider_id, provider_type, and provider_groups filters via scan relationship.
Subclasses must define Meta.model.
"""
@@ -140,6 +150,16 @@ class BaseScanProviderFilter(FilterSet):
choices=Provider.ProviderChoices.choices,
lookup_expr="in",
)
+ provider_groups = UUIDFilter(
+ field_name="scan__provider__provider_groups__id",
+ lookup_expr="exact",
+ distinct=True,
+ )
+ provider_groups__in = UUIDInFilter(
+ field_name="scan__provider__provider_groups__id",
+ lookup_expr="in",
+ distinct=True,
+ )
class Meta:
abstract = True
@@ -160,6 +180,16 @@ class CommonFindingFilters(FilterSet):
provider_type__in = ChoiceInFilter(
choices=Provider.ProviderChoices.choices, field_name="scan__provider__provider"
)
+ provider_groups = UUIDFilter(
+ field_name="scan__provider__provider_groups__id",
+ lookup_expr="exact",
+ distinct=True,
+ )
+ provider_groups__in = UUIDInFilter(
+ field_name="scan__provider__provider_groups__id",
+ lookup_expr="in",
+ distinct=True,
+ )
provider_uid = CharFilter(field_name="scan__provider__uid", lookup_expr="exact")
provider_uid__in = CharInFilter(field_name="scan__provider__uid", lookup_expr="in")
provider_uid__icontains = CharFilter(
@@ -370,6 +400,12 @@ class ProviderFilter(FilterSet):
choices=Provider.ProviderChoices.choices,
lookup_expr="in",
)
+ provider_groups = UUIDFilter(
+ field_name="provider_groups__id", lookup_expr="exact", distinct=True
+ )
+ provider_groups__in = UUIDInFilter(
+ field_name="provider_groups__id", lookup_expr="in", distinct=True
+ )
class Meta:
model = Provider
@@ -395,6 +431,16 @@ class ProviderRelationshipFilterSet(FilterSet):
provider_type__in = ChoiceInFilter(
choices=Provider.ProviderChoices.choices, field_name="provider__provider"
)
+ provider_groups = UUIDFilter(
+ field_name="provider__provider_groups__id",
+ lookup_expr="exact",
+ distinct=True,
+ )
+ provider_groups__in = UUIDInFilter(
+ field_name="provider__provider_groups__id",
+ lookup_expr="in",
+ distinct=True,
+ )
provider_uid = CharFilter(field_name="provider__uid", lookup_expr="exact")
provider_uid__in = CharInFilter(field_name="provider__uid", lookup_expr="in")
provider_uid__icontains = CharFilter(
@@ -552,12 +598,12 @@ class ResourceFilter(ProviderRelationshipFilterSet):
gte_date = (
parse(self.data.get("updated_at__gte")).date()
if self.data.get("updated_at__gte")
- else datetime.now(timezone.utc).date()
+ else datetime.now(UTC).date()
)
lte_date = (
parse(self.data.get("updated_at__lte")).date()
if self.data.get("updated_at__lte")
- else datetime.now(timezone.utc).date()
+ else datetime.now(UTC).date()
)
if abs(lte_date - gte_date) > timedelta(
@@ -627,35 +673,32 @@ class LatestResourceFilter(ProviderRelationshipFilterSet):
return queryset.filter(tags__text_search=value)
-class FindingFilter(CommonFindingFilters):
+FINDING_BASE_FILTER_FIELDS = {
+ "id": ["exact", "in"],
+ "uid": ["exact", "in"],
+ "scan": ["exact", "in"],
+ "delta": ["exact", "in"],
+ "status": ["exact", "in"],
+ "severity": ["exact", "in"],
+ "impact": ["exact", "in"],
+ "check_id": ["exact", "in", "icontains"],
+}
+
+
+class BaseFindingFilter(CommonFindingFilters):
+ DATE_FILTER_FIELDS = ()
+ DATE_FILTER_NAMES = ()
+ DATE_RANGE_HELP_TEXT = (
+ f"Maximum date range is {settings.FINDINGS_MAX_DAYS_IN_RANGE} days."
+ )
+ DATE_FILTER_REQUIRED_DETAIL = "At least one date filter is required."
+
scan = UUIDFilter(method="filter_scan_id")
scan__in = UUIDInFilter(method="filter_scan_id_in")
- inserted_at = DateFilter(method="filter_inserted_at", lookup_expr="date")
- inserted_at__date = DateFilter(method="filter_inserted_at", lookup_expr="date")
- inserted_at__gte = DateFilter(
- method="filter_inserted_at_gte",
- help_text=f"Maximum date range is {settings.FINDINGS_MAX_DAYS_IN_RANGE} days.",
- )
- inserted_at__lte = DateFilter(
- method="filter_inserted_at_lte",
- help_text=f"Maximum date range is {settings.FINDINGS_MAX_DAYS_IN_RANGE} days.",
- )
-
class Meta:
model = Finding
- fields = {
- "id": ["exact", "in"],
- "uid": ["exact", "in"],
- "scan": ["exact", "in"],
- "delta": ["exact", "in"],
- "status": ["exact", "in"],
- "severity": ["exact", "in"],
- "impact": ["exact", "in"],
- "check_id": ["exact", "in", "icontains"],
- "inserted_at": ["date", "gte", "lte"],
- "updated_at": ["gte", "lte"],
- }
+ fields = FINDING_BASE_FILTER_FIELDS
filter_overrides = {
FindingDeltaEnumField: {
"filter_class": CharFilter,
@@ -678,17 +721,13 @@ class FindingFilter(CommonFindingFilters):
return queryset.filter(resource_services__contains=[value])
def filter_queryset(self, queryset):
- if not (self.data.get("scan") or self.data.get("scan__in")) and not (
- self.data.get("inserted_at")
- or self.data.get("inserted_at__date")
- or self.data.get("inserted_at__gte")
- or self.data.get("inserted_at__lte")
+ if not (self.data.get("scan") or self.data.get("scan__in")) and not any(
+ self.data.get(filter_name) for filter_name in self.DATE_FILTER_NAMES
):
raise ValidationError(
[
{
- "detail": "At least one date filter is required: filter[inserted_at], filter[inserted_at.gte], "
- "or filter[inserted_at.lte].",
+ "detail": self.DATE_FILTER_REQUIRED_DETAIL,
"status": 400,
"source": {"pointer": "/data/attributes/inserted_at"},
"code": "required",
@@ -697,31 +736,42 @@ class FindingFilter(CommonFindingFilters):
)
cleaned = self.form.cleaned_data
- exact_date = cleaned.get("inserted_at") or cleaned.get("inserted_at__date")
- gte_date = cleaned.get("inserted_at__gte") or exact_date
- lte_date = cleaned.get("inserted_at__lte") or exact_date
-
- if gte_date is None:
- gte_date = datetime.now(timezone.utc).date()
- if lte_date is None:
- lte_date = datetime.now(timezone.utc).date()
-
- if abs(lte_date - gte_date) > timedelta(
- days=settings.FINDINGS_MAX_DAYS_IN_RANGE
- ):
- raise ValidationError(
- [
- {
- "detail": f"The date range cannot exceed {settings.FINDINGS_MAX_DAYS_IN_RANGE} days.",
- "status": 400,
- "source": {"pointer": "/data/attributes/inserted_at"},
- "code": "invalid",
- }
- ]
- )
+ for field_name in self.DATE_FILTER_FIELDS:
+ self.validate_datetime_filter_range(cleaned, field_name)
return super().filter_queryset(queryset)
+ def validate_datetime_filter_range(self, cleaned, field_name):
+ exact_value = cleaned.get(field_name) or cleaned.get(f"{field_name}__date")
+ gte_value = cleaned.get(f"{field_name}__gte") or exact_value
+ lte_value = cleaned.get(f"{field_name}__lte") or exact_value
+
+ if not (exact_value or gte_value or lte_value):
+ return
+
+ default_value = datetime.now(UTC).date()
+ gte_value = gte_value or default_value
+ lte_value = lte_value or default_value
+
+ gte_datetime = self.filter_value_to_datetime(gte_value, field_name)
+ lte_datetime = self.filter_value_to_datetime(lte_value, field_name)
+
+ if abs(lte_datetime - gte_datetime) <= timedelta(
+ days=settings.FINDINGS_MAX_DAYS_IN_RANGE
+ ):
+ return
+
+ raise ValidationError(
+ [
+ {
+ "detail": f"The date range cannot exceed {settings.FINDINGS_MAX_DAYS_IN_RANGE} days.",
+ "status": 400,
+ "source": {"pointer": f"/data/attributes/{field_name}"},
+ "code": "invalid",
+ }
+ ]
+ )
+
# Convert filter values to UUIDv7 values for use with partitioning
def filter_scan_id(self, queryset, name, value):
try:
@@ -779,27 +829,169 @@ class FindingFilter(CommonFindingFilters):
datetime_value = self.maybe_date_to_datetime(value)
start = uuid7_start(datetime_to_uuid7(datetime_value))
end = uuid7_start(datetime_to_uuid7(datetime_value + timedelta(days=1)))
-
return queryset.filter(id__gte=start, id__lt=end)
def filter_inserted_at_gte(self, queryset, name, value):
datetime_value = self.maybe_date_to_datetime(value)
start = uuid7_start(datetime_to_uuid7(datetime_value))
-
return queryset.filter(id__gte=start)
def filter_inserted_at_lte(self, queryset, name, value):
datetime_value = self.maybe_date_to_datetime(value)
end = uuid7_start(datetime_to_uuid7(datetime_value + timedelta(days=1)))
-
return queryset.filter(id__lt=end)
@staticmethod
def maybe_date_to_datetime(value):
- dt = value
+ if isinstance(value, datetime):
+ return value
if isinstance(value, date):
- dt = datetime.combine(value, datetime.min.time(), tzinfo=timezone.utc)
- return dt
+ return datetime.combine(value, datetime.min.time(), tzinfo=UTC)
+ if isinstance(value, str):
+ return parse(value)
+ return value
+
+ @classmethod
+ def filter_value_to_datetime(cls, value, field_name):
+ try:
+ datetime_value = cls.maybe_date_to_datetime(value)
+ except (TypeError, ValueError, OverflowError):
+ raise ValidationError(
+ [
+ {
+ "detail": "Enter a valid date or datetime.",
+ "status": 400,
+ "source": {"pointer": f"/data/attributes/{field_name}"},
+ "code": "invalid",
+ }
+ ]
+ )
+
+ if datetime_value.tzinfo is None:
+ return datetime_value.replace(tzinfo=UTC)
+ return datetime_value.astimezone(UTC)
+
+
+class FindingFilter(BaseFindingFilter):
+ DATE_FILTER_FIELDS = ("inserted_at", "updated_at")
+ DATE_FILTER_NAMES = (
+ "inserted_at",
+ "inserted_at__date",
+ "inserted_at__gte",
+ "inserted_at__lte",
+ "updated_at",
+ "updated_at__date",
+ "updated_at__gte",
+ "updated_at__lte",
+ )
+ DATE_FILTER_REQUIRED_DETAIL = (
+ "At least one date filter is required: filter[inserted_at], filter[updated_at], "
+ "filter[inserted_at.gte], filter[updated_at.gte], filter[inserted_at.lte], "
+ "or filter[updated_at.lte]."
+ )
+
+ inserted_at = CharFilter(method="filter_inserted_at")
+ inserted_at__date = DateFilter(method="filter_inserted_at", lookup_expr="date")
+ inserted_at__gte = CharFilter(
+ method="filter_inserted_at",
+ help_text=BaseFindingFilter.DATE_RANGE_HELP_TEXT,
+ )
+ inserted_at__lte = CharFilter(
+ method="filter_inserted_at",
+ help_text=BaseFindingFilter.DATE_RANGE_HELP_TEXT,
+ )
+ updated_at = CharFilter(method="filter_updated_at")
+ updated_at__date = DateFilter(method="filter_updated_at", lookup_expr="date")
+ updated_at__gte = CharFilter(
+ method="filter_updated_at",
+ help_text=BaseFindingFilter.DATE_RANGE_HELP_TEXT,
+ )
+ updated_at__lte = CharFilter(
+ method="filter_updated_at",
+ help_text=BaseFindingFilter.DATE_RANGE_HELP_TEXT,
+ )
+
+ class Meta(BaseFindingFilter.Meta):
+ fields = FINDING_BASE_FILTER_FIELDS | {
+ "inserted_at": ["date", "gte", "lte"],
+ "updated_at": ["date", "gte", "lte"],
+ }
+
+ def filter_inserted_at(self, queryset, name, value):
+ start, end = self.filter_value_to_datetime_bounds(value, "inserted_at")
+
+ if name.endswith("__gte"):
+ return queryset.filter(id__gte=self.datetime_to_uuid7_boundary(start))
+ if name.endswith("__lte"):
+ return queryset.filter(id__lt=self.datetime_to_uuid7_boundary(end))
+
+ return queryset.filter(
+ id__gte=self.datetime_to_uuid7_boundary(start),
+ id__lt=self.datetime_to_uuid7_boundary(end),
+ )
+
+ def filter_updated_at(self, queryset, name, value):
+ start, end = self.filter_value_to_datetime_bounds(value, "updated_at")
+
+ if name.endswith("__gte"):
+ return queryset.filter(updated_at__gte=start)
+ if name.endswith("__lte"):
+ return queryset.filter(updated_at__lt=end)
+
+ return queryset.filter(updated_at__gte=start, updated_at__lt=end)
+
+ @classmethod
+ def filter_value_to_datetime_bounds(cls, value, field_name):
+ start = cls.filter_value_to_datetime(value, field_name)
+ if cls.is_date_filter_value(value):
+ return start, start + timedelta(days=1)
+ return start, start + timedelta(milliseconds=1)
+
+ @staticmethod
+ def datetime_to_uuid7_boundary(datetime_value):
+ timestamp_ms = int(datetime_value.timestamp() * 1000) & 0xFFFFFFFFFFFF
+ uuid_int = timestamp_ms << 80
+ uuid_int |= 0x7 << 76
+ uuid_int |= 0x2 << 62
+ return UUID(int=uuid_int)
+
+ @staticmethod
+ def is_date_filter_value(value):
+ if isinstance(value, datetime):
+ return False
+ if isinstance(value, date):
+ return True
+ return isinstance(value, str) and len(value.strip()) == 10
+
+
+class FindingMetadataFilter(BaseFindingFilter):
+ DATE_FILTER_FIELDS = ("inserted_at",)
+ DATE_FILTER_NAMES = (
+ "inserted_at",
+ "inserted_at__date",
+ "inserted_at__gte",
+ "inserted_at__lte",
+ )
+ DATE_FILTER_REQUIRED_DETAIL = (
+ "At least one date filter is required: filter[inserted_at], filter[inserted_at.gte], "
+ "or filter[inserted_at.lte]."
+ )
+
+ inserted_at = DateFilter(method="filter_inserted_at", lookup_expr="date")
+ inserted_at__date = DateFilter(method="filter_inserted_at", lookup_expr="date")
+ inserted_at__gte = DateFilter(
+ method="filter_inserted_at_gte",
+ help_text=BaseFindingFilter.DATE_RANGE_HELP_TEXT,
+ )
+ inserted_at__lte = DateFilter(
+ method="filter_inserted_at_lte",
+ help_text=BaseFindingFilter.DATE_RANGE_HELP_TEXT,
+ )
+
+ class Meta(BaseFindingFilter.Meta):
+ fields = FINDING_BASE_FILTER_FIELDS | {
+ "inserted_at": ["date", "gte", "lte"],
+ }
class LatestFindingFilter(CommonFindingFilters):
@@ -887,9 +1079,9 @@ class FindingGroupFilter(CommonFindingFilters):
lte_date = cleaned.get("inserted_at__lte") or exact_date
if gte_date is None:
- gte_date = datetime.now(timezone.utc).date()
+ gte_date = datetime.now(UTC).date()
if lte_date is None:
- lte_date = datetime.now(timezone.utc).date()
+ lte_date = datetime.now(UTC).date()
if abs(lte_date - gte_date) > timedelta(
days=settings.FINDINGS_MAX_DAYS_IN_RANGE
@@ -931,7 +1123,7 @@ class FindingGroupFilter(CommonFindingFilters):
"""Convert date to datetime if needed."""
dt = value
if isinstance(value, date):
- dt = datetime.combine(value, datetime.min.time(), tzinfo=timezone.utc)
+ dt = datetime.combine(value, datetime.min.time(), tzinfo=UTC)
return dt
@@ -1001,6 +1193,16 @@ class FindingGroupSummaryFilter(_CheckTitleToCheckIdMixin, FilterSet):
field_name="provider__provider", choices=Provider.ProviderChoices.choices
)
provider_type__in = CharInFilter(field_name="provider__provider", lookup_expr="in")
+ provider_groups = UUIDFilter(
+ field_name="provider__provider_groups__id",
+ lookup_expr="exact",
+ distinct=True,
+ )
+ provider_groups__in = UUIDInFilter(
+ field_name="provider__provider_groups__id",
+ lookup_expr="in",
+ distinct=True,
+ )
class Meta:
model = FindingGroupDailySummary
@@ -1035,9 +1237,9 @@ class FindingGroupSummaryFilter(_CheckTitleToCheckIdMixin, FilterSet):
lte_date = cleaned.get("inserted_at__lte") or exact_date
if gte_date is None:
- gte_date = datetime.now(timezone.utc).date()
+ gte_date = datetime.now(UTC).date()
if lte_date is None:
- lte_date = datetime.now(timezone.utc).date()
+ lte_date = datetime.now(UTC).date()
if abs(lte_date - gte_date) > timedelta(
days=settings.FINDINGS_MAX_DAYS_IN_RANGE
@@ -1076,7 +1278,7 @@ class FindingGroupSummaryFilter(_CheckTitleToCheckIdMixin, FilterSet):
def _maybe_date_to_datetime(value):
dt = value
if isinstance(value, date):
- dt = datetime.combine(value, datetime.min.time(), tzinfo=timezone.utc)
+ dt = datetime.combine(value, datetime.min.time(), tzinfo=UTC)
return dt
@@ -1101,6 +1303,16 @@ class LatestFindingGroupSummaryFilter(_CheckTitleToCheckIdMixin, FilterSet):
field_name="provider__provider", choices=Provider.ProviderChoices.choices
)
provider_type__in = CharInFilter(field_name="provider__provider", lookup_expr="in")
+ provider_groups = UUIDFilter(
+ field_name="provider__provider_groups__id",
+ lookup_expr="exact",
+ distinct=True,
+ )
+ provider_groups__in = UUIDInFilter(
+ field_name="provider__provider_groups__id",
+ lookup_expr="in",
+ distinct=True,
+ )
class Meta:
model = FindingGroupDailySummary
@@ -1280,12 +1492,19 @@ class RoleFilter(FilterSet):
}
-class ComplianceOverviewFilter(FilterSet):
+class ComplianceOverviewFilter(BaseScanProviderFilter):
+ """
+ Keep provider filters in the schema while runtime filtering resolves scans first.
+
+ Compliance overview provider filters are applied to the latest completed scans
+ in the viewset, then this filterset handles the remaining compliance fields.
+ """
+
inserted_at = DateFilter(field_name="inserted_at", lookup_expr="date")
- scan_id = UUIDFilter(field_name="scan_id", required=True)
+ scan_id = UUIDFilter(field_name="scan_id")
region = CharFilter(field_name="region")
- class Meta:
+ class Meta(BaseScanProviderFilter.Meta):
model = ComplianceRequirementOverview
fields = {
"inserted_at": ["date", "gte", "lte"],
@@ -1306,6 +1525,16 @@ class ScanSummaryFilter(FilterSet):
provider_type__in = ChoiceInFilter(
field_name="scan__provider__provider", choices=Provider.ProviderChoices.choices
)
+ provider_groups = UUIDFilter(
+ field_name="scan__provider__provider_groups__id",
+ lookup_expr="exact",
+ distinct=True,
+ )
+ provider_groups__in = UUIDInFilter(
+ field_name="scan__provider__provider_groups__id",
+ lookup_expr="in",
+ distinct=True,
+ )
region = CharFilter(field_name="region")
class Meta:
@@ -1329,6 +1558,16 @@ class DailySeveritySummaryFilter(FilterSet):
provider_type__in = ChoiceInFilter(
field_name="provider__provider", choices=Provider.ProviderChoices.choices
)
+ provider_groups = UUIDFilter(
+ field_name="provider__provider_groups__id",
+ lookup_expr="exact",
+ distinct=True,
+ )
+ provider_groups__in = UUIDInFilter(
+ field_name="provider__provider_groups__id",
+ lookup_expr="in",
+ distinct=True,
+ )
date_from = DateFilter(method="filter_noop")
date_to = DateFilter(method="filter_noop")
@@ -1585,6 +1824,16 @@ class ThreatScoreSnapshotFilter(FilterSet):
choices=Provider.ProviderChoices.choices,
lookup_expr="in",
)
+ provider_groups = UUIDFilter(
+ field_name="provider__provider_groups__id",
+ lookup_expr="exact",
+ distinct=True,
+ )
+ provider_groups__in = UUIDInFilter(
+ field_name="provider__provider_groups__id",
+ lookup_expr="in",
+ distinct=True,
+ )
compliance_id = CharFilter(field_name="compliance_id", lookup_expr="exact")
compliance_id__in = CharInFilter(field_name="compliance_id", lookup_expr="in")
@@ -1628,6 +1877,16 @@ class ResourceGroupOverviewFilter(FilterSet):
choices=Provider.ProviderChoices.choices,
lookup_expr="in",
)
+ provider_groups = UUIDFilter(
+ field_name="scan__provider__provider_groups__id",
+ lookup_expr="exact",
+ distinct=True,
+ )
+ provider_groups__in = UUIDInFilter(
+ field_name="scan__provider__provider_groups__id",
+ lookup_expr="in",
+ distinct=True,
+ )
resource_group = CharFilter(field_name="resource_group", lookup_expr="exact")
resource_group__in = CharInFilter(field_name="resource_group", lookup_expr="in")
diff --git a/api/src/backend/api/health.py b/api/src/backend/api/health.py
index 8ca3936f94..cca3bcef72 100644
--- a/api/src/backend/api/health.py
+++ b/api/src/backend/api/health.py
@@ -2,8 +2,9 @@
Format (draft-inadarei-api-health-check-06).
Liveness reports only process status. Readiness verifies that PostgreSQL,
-Valkey and Neo4j are reachable and returns per-dependency detail when any
-of them is unreachable.
+Valkey and the attack-paths graph store (Neo4j or Neptune, per
+``ATTACK_PATHS_SINK_DATABASE``) are reachable and returns per-dependency
+detail when any of them is unreachable.
"""
from __future__ import annotations
@@ -11,8 +12,10 @@ from __future__ import annotations
import logging
import threading
import time
+from concurrent.futures import ThreadPoolExecutor
+from concurrent.futures import TimeoutError as FuturesTimeoutError
from contextlib import suppress
-from datetime import datetime, timezone
+from datetime import UTC, datetime
from typing import Any
import redis
@@ -37,9 +40,28 @@ STATUS_FAIL = "fail"
STATUS_WARN = "warn"
# Short socket timeout so a stuck Valkey cannot stall the probe.
-# Neo4j inherits its driver-level ``connection_acquisition_timeout``.
VALKEY_PROBE_TIMEOUT_SECONDS = 2
+# Probe-scoped budget for the graph database.
+# ``Driver.verify_connectivity()`` takes no timeout; its only bound is the
+# driver-level ``connection_acquisition_timeout`` (60s on Neptune). The
+# probe needs its own budget, independent of the workload driver, so a
+# graph-database outage cannot pin a worker thread (and the readiness lock)
+# for a minute.
+GRAPH_DB_PROBE_TIMEOUT_SECONDS = 5
+
+# Bounded pool that enforces ``GRAPH_DB_PROBE_TIMEOUT_SECONDS``. If the
+# graph database is unreachable the probe call blocks until the driver's
+# own acquisition timeout fires; we abandon the future after the budget and
+# report ``fail``. Orphaned tasks are capped by ``max_workers`` plus the 3s
+# readiness cache plus the per-IP throttle, so they cannot pile up: worst
+# case during a graph-database outage is every readiness call failing fast
+# in ``GRAPH_DB_PROBE_TIMEOUT_SECONDS`` with at most 2 background threads
+# stuck for <= the driver acquisition timeout.
+_graph_db_probe_executor = ThreadPoolExecutor(
+ max_workers=2, thread_name_prefix="health-graph-db-probe"
+)
+
# Brief cache window so high-frequency probes (ALB target groups, scrapers)
# do not stampede the actual dependency checks.
CACHE_CONTROL_HEADER = "max-age=3, must-revalidate"
@@ -62,11 +84,7 @@ class HealthJSONRenderer(JSONRenderer):
def _now_iso() -> str:
- return (
- datetime.now(timezone.utc)
- .isoformat(timespec="milliseconds")
- .replace("+00:00", "Z")
- )
+ return datetime.now(UTC).isoformat(timespec="milliseconds").replace("+00:00", "Z")
def _measure(name: str, check_fn) -> tuple[dict[str, Any], float]:
@@ -113,11 +131,24 @@ def _probe_valkey() -> None:
client.close()
-def _probe_neo4j() -> None:
- # Lazy import: avoids pulling attack_paths into the boot import graph.
- from api.attack_paths.database import get_driver
+def _graph_db_component_id() -> str:
+ """Return the active graph database name for the ``componentId`` field."""
+ return settings.ATTACK_PATHS_SINK_DATABASE.strip().lower()
- get_driver().verify_connectivity()
+
+def _probe_graph_db() -> None:
+ # Lazy import: avoids pulling attack_paths into the boot import graph
+ from api.attack_paths.database import verify_connectivity
+
+ future = _graph_db_probe_executor.submit(verify_connectivity)
+ try:
+ future.result(timeout=GRAPH_DB_PROBE_TIMEOUT_SECONDS)
+ except FuturesTimeoutError as exc:
+ # Do not wait for the abandoned task; it ends when the driver's own acquisition timeout fires
+ future.cancel()
+ raise TimeoutError(
+ f"graph-db probe exceeded {GRAPH_DB_PROBE_TIMEOUT_SECONDS}s"
+ ) from exc
def _build_check_entry(
@@ -180,14 +211,18 @@ def _readiness_payload() -> tuple[dict[str, Any], int]:
):
return snapshot[1], snapshot[2]
+ graph_db_component_id = _graph_db_component_id()
+
postgres_result, postgres_ms = _measure("postgres", _probe_postgres)
valkey_result, valkey_ms = _measure("valkey", _probe_valkey)
- neo4j_result, neo4j_ms = _measure("neo4j", _probe_neo4j)
+ graph_db_result, graph_db_ms = _measure(graph_db_component_id, _probe_graph_db)
entries = [
_build_check_entry("postgres", "datastore", postgres_result, postgres_ms),
_build_check_entry("valkey", "datastore", valkey_result, valkey_ms),
- _build_check_entry("neo4j", "datastore", neo4j_result, neo4j_ms),
+ _build_check_entry(
+ graph_db_component_id, "datastore", graph_db_result, graph_db_ms
+ ),
]
overall = _aggregate_status(entries)
@@ -195,7 +230,7 @@ def _readiness_payload() -> tuple[dict[str, Any], int]:
payload["checks"] = {
"postgres:responseTime": [entries[0]],
"valkey:responseTime": [entries[1]],
- "neo4j:responseTime": [entries[2]],
+ "graphdb:responseTime": [entries[2]],
}
http_status = (
@@ -237,10 +272,10 @@ class LivenessView(APIView):
class ReadinessView(APIView):
"""Readiness probe.
- Returns 200 when PostgreSQL, Valkey and Neo4j all respond, or 503 with
- per-dependency detail when any of them is unreachable. Per-IP throttle
- plus the short in-process result cache cap the real dependency hits
- regardless of inbound traffic shape.
+ Returns 200 when PostgreSQL, Valkey and the attack-paths graph store
+ all respond, or 503 with per-dependency detail when any of them is
+ unreachable. Per-IP throttle plus the short in-process result cache cap
+ the real dependency hits regardless of inbound traffic shape.
"""
authentication_classes: list = []
diff --git a/api/src/backend/api/management/commands/findings.py b/api/src/backend/api/management/commands/findings.py
index e62f8f8e8e..f5348dbfc6 100644
--- a/api/src/backend/api/management/commands/findings.py
+++ b/api/src/backend/api/management/commands/findings.py
@@ -1,11 +1,8 @@
import random
-from datetime import datetime, timezone
+from datetime import UTC, datetime
from math import ceil
from uuid import uuid4
-from django.core.management.base import BaseCommand
-from tqdm import tqdm
-
from api.db_utils import rls_transaction
from api.models import (
Finding,
@@ -16,7 +13,9 @@ from api.models import (
Scan,
StatusChoices,
)
+from django.core.management.base import BaseCommand
from prowler.lib.check.models import CheckMetadata
+from tqdm import tqdm
class Command(BaseCommand):
@@ -116,7 +115,7 @@ class Command(BaseCommand):
trigger="manual",
state="executing",
progress=0,
- started_at=datetime.now(timezone.utc),
+ started_at=datetime.now(UTC),
)
scan_state = "completed"
@@ -272,10 +271,8 @@ class Command(BaseCommand):
self.stdout.write(self.style.ERROR(f"Failed to populate test data: {e}"))
scan_state = "failed"
finally:
- scan.completed_at = datetime.now(timezone.utc)
- scan.duration = int(
- (datetime.now(timezone.utc) - scan.started_at).total_seconds()
- )
+ scan.completed_at = datetime.now(UTC)
+ scan.duration = int((datetime.now(UTC) - scan.started_at).total_seconds())
scan.progress = 100
scan.state = scan_state
scan.unique_resource_count = num_resources
diff --git a/api/src/backend/api/management/commands/reconcile_orphan_tasks.py b/api/src/backend/api/management/commands/reconcile_orphan_tasks.py
new file mode 100644
index 0000000000..7d84e29dde
--- /dev/null
+++ b/api/src/backend/api/management/commands/reconcile_orphan_tasks.py
@@ -0,0 +1,58 @@
+from django.core.management.base import BaseCommand
+from tasks.jobs.orphan_recovery import reconcile_orphans
+
+
+class Command(BaseCommand):
+ help = (
+ "Recover orphaned allowlisted Celery tasks whose worker is gone and mark "
+ "other stale task results terminal. Single-flight via a Postgres advisory lock."
+ )
+
+ def add_arguments(self, parser):
+ parser.add_argument(
+ "--grace-minutes",
+ type=int,
+ default=2,
+ help="Skip tasks started within this window (worker may still register).",
+ )
+ parser.add_argument(
+ "--max-attempts",
+ type=int,
+ default=3,
+ help="Give up re-running a task after this many recovery attempts; it is then left terminal instead of re-enqueued.",
+ )
+ parser.add_argument(
+ "--dry-run",
+ action="store_true",
+ help="Detect and report orphans without revoking or re-enqueuing.",
+ )
+
+ def handle(self, *args, **options):
+ result = reconcile_orphans(
+ grace_minutes=options["grace_minutes"],
+ max_attempts=options["max_attempts"],
+ dry_run=options["dry_run"],
+ )
+
+ if not result.get("acquired"):
+ self.stdout.write("Reconcile skipped: another run holds the lock.")
+ return
+
+ if result.get("enabled") is False:
+ message = (
+ "Task recovery is disabled (DJANGO_TASK_RECOVERY_ENABLED is off); "
+ "no orphans were recovered."
+ )
+ if result.get("attack_paths") is not None:
+ message += " Attack-paths stale cleanup still ran."
+ self.stdout.write(message)
+ return
+
+ self.stdout.write(
+ self.style.SUCCESS(
+ "Orphan reconcile complete: "
+ f"recovered={len(result.get('recovered', []))} "
+ f"failed={len(result.get('failed', []))} "
+ f"skipped(in-flight)={len(result.get('skipped', []))}"
+ )
+ )
diff --git a/api/src/backend/api/middleware.py b/api/src/backend/api/middleware.py
index 63f2fc630b..82ae8dcf67 100644
--- a/api/src/backend/api/middleware.py
+++ b/api/src/backend/api/middleware.py
@@ -2,6 +2,31 @@ import logging
import time
from config.custom_logging import BackendLogger
+from django.core.handlers.asgi import ASGIRequest
+from django.db import connections
+
+
+class CloseDBConnectionsMiddleware:
+ """
+ Close request-scoped DB connections at the end of each ASGI request.
+
+ Under the ASGI worker, connections opened by sync views are not released
+ by Django's normal request-boundary cleanup, so they accumulate idle until
+ Postgres runs out of slots. Only ASGI requests are handled; the sync WSGI
+ test client manages its own connections and must be left alone.
+ """
+
+ def __init__(self, get_response):
+ self.get_response = get_response
+
+ def __call__(self, request):
+ try:
+ return self.get_response(request)
+ finally:
+ if isinstance(request, ASGIRequest):
+ for conn in connections.all(initialized_only=True):
+ if not conn.in_atomic_block:
+ conn.close_if_unusable_or_obsolete()
def extract_auth_info(request) -> dict:
diff --git a/api/src/backend/api/migrations/0001_initial.py b/api/src/backend/api/migrations/0001_initial.py
index 6288cf4093..d4f931622f 100644
--- a/api/src/backend/api/migrations/0001_initial.py
+++ b/api/src/backend/api/migrations/0001_initial.py
@@ -1,26 +1,13 @@
import uuid
from functools import partial
+import api.rls
import django.contrib.auth.models
import django.contrib.postgres.indexes
import django.contrib.postgres.search
import django.core.validators
import django.db.models.deletion
import django.utils.timezone
-from django.conf import settings
-from django.db import migrations, models
-from psqlextra.backend.migrations.operations.add_default_partition import (
- PostgresAddDefaultPartition,
-)
-from psqlextra.backend.migrations.operations.create_partitioned_model import (
- PostgresCreatePartitionedModel,
-)
-from psqlextra.manager.manager import PostgresManager
-from psqlextra.models.partitioned import PostgresPartitionedModel
-from psqlextra.types import PostgresPartitioningMethod
-from uuid6 import uuid7
-
-import api.rls
from api.db_utils import (
DB_PROWLER_PASSWORD,
DB_PROWLER_USER,
@@ -53,6 +40,18 @@ from api.models import (
StateChoices,
StatusChoices,
)
+from django.conf import settings
+from django.db import migrations, models
+from psqlextra.backend.migrations.operations.add_default_partition import (
+ PostgresAddDefaultPartition,
+)
+from psqlextra.backend.migrations.operations.create_partitioned_model import (
+ PostgresCreatePartitionedModel,
+)
+from psqlextra.manager.manager import PostgresManager
+from psqlextra.models.partitioned import PostgresPartitionedModel
+from psqlextra.types import PostgresPartitioningMethod
+from uuid6 import uuid7
DB_NAME = settings.DATABASES["default"]["NAME"]
diff --git a/api/src/backend/api/migrations/0002_token_migrations.py b/api/src/backend/api/migrations/0002_token_migrations.py
index 754403c62f..c7ba732fa3 100644
--- a/api/src/backend/api/migrations/0002_token_migrations.py
+++ b/api/src/backend/api/migrations/0002_token_migrations.py
@@ -1,8 +1,7 @@
+from api.db_utils import DB_PROWLER_USER
from django.conf import settings
from django.db import migrations
-from api.db_utils import DB_PROWLER_USER
-
DB_NAME = settings.DATABASES["default"]["NAME"]
diff --git a/api/src/backend/api/migrations/0004_rbac.py b/api/src/backend/api/migrations/0004_rbac.py
index 4453b1b588..efac385041 100644
--- a/api/src/backend/api/migrations/0004_rbac.py
+++ b/api/src/backend/api/migrations/0004_rbac.py
@@ -2,12 +2,11 @@
import uuid
+import api.rls
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
-import api.rls
-
class Migration(migrations.Migration):
dependencies = [
diff --git a/api/src/backend/api/migrations/0005_rbac_missing_admin_roles.py b/api/src/backend/api/migrations/0005_rbac_missing_admin_roles.py
index 0392145063..5ab8978b31 100644
--- a/api/src/backend/api/migrations/0005_rbac_missing_admin_roles.py
+++ b/api/src/backend/api/migrations/0005_rbac_missing_admin_roles.py
@@ -1,6 +1,5 @@
-from django.db import migrations
-
from api.db_router import MainRouter
+from django.db import migrations
def create_admin_role(apps, schema_editor):
diff --git a/api/src/backend/api/migrations/0008_daily_scheduled_tasks_update.py b/api/src/backend/api/migrations/0008_daily_scheduled_tasks_update.py
index 7f059ea2b8..d6e8bb7adb 100644
--- a/api/src/backend/api/migrations/0008_daily_scheduled_tasks_update.py
+++ b/api/src/backend/api/migrations/0008_daily_scheduled_tasks_update.py
@@ -1,12 +1,11 @@
import json
-from datetime import datetime, timedelta, timezone
+from datetime import UTC, datetime, timedelta
import django.db.models.deletion
-from django.db import migrations, models
-from django_celery_beat.models import PeriodicTask
-
from api.db_utils import rls_transaction
from api.models import Scan, StateChoices
+from django.db import migrations, models
+from django_celery_beat.models import PeriodicTask
def migrate_daily_scheduled_scan_tasks(apps, schema_editor):
@@ -17,11 +16,11 @@ def migrate_daily_scheduled_scan_tasks(apps, schema_editor):
tenant_id = task_kwargs["tenant_id"]
provider_id = task_kwargs["provider_id"]
- current_time = datetime.now(timezone.utc)
+ current_time = datetime.now(UTC)
scheduled_time_today = datetime.combine(
current_time.date(),
daily_scheduled_scan_task.start_time.time(),
- tzinfo=timezone.utc,
+ tzinfo=UTC,
)
if current_time < scheduled_time_today:
diff --git a/api/src/backend/api/migrations/0013_integrations_enum.py b/api/src/backend/api/migrations/0013_integrations_enum.py
index 524ecbbb3d..7f2905b844 100644
--- a/api/src/backend/api/migrations/0013_integrations_enum.py
+++ b/api/src/backend/api/migrations/0013_integrations_enum.py
@@ -2,10 +2,9 @@
from functools import partial
-from django.db import migrations
-
from api.db_utils import IntegrationTypeEnum, PostgresEnumMigration, register_enum
from api.models import Integration
+from django.db import migrations
IntegrationTypeEnumMigration = PostgresEnumMigration(
enum_name="integration_type",
diff --git a/api/src/backend/api/migrations/0014_integrations.py b/api/src/backend/api/migrations/0014_integrations.py
index 2fb3d76880..1b63c1fc8c 100644
--- a/api/src/backend/api/migrations/0014_integrations.py
+++ b/api/src/backend/api/migrations/0014_integrations.py
@@ -2,12 +2,11 @@
import uuid
-import django.db.models.deletion
-from django.db import migrations, models
-
import api.db_utils
import api.rls
+import django.db.models.deletion
from api.rls import RowLevelSecurityConstraint
+from django.db import migrations, models
class Migration(migrations.Migration):
diff --git a/api/src/backend/api/migrations/0015_finding_muted.py b/api/src/backend/api/migrations/0015_finding_muted.py
index 3cb20f871b..5bc408cb44 100644
--- a/api/src/backend/api/migrations/0015_finding_muted.py
+++ b/api/src/backend/api/migrations/0015_finding_muted.py
@@ -1,8 +1,7 @@
# Generated by Django 5.1.5 on 2025-03-25 11:29
-from django.db import migrations, models
-
import api.db_utils
+from django.db import migrations, models
class Migration(migrations.Migration):
diff --git a/api/src/backend/api/migrations/0017_m365_provider.py b/api/src/backend/api/migrations/0017_m365_provider.py
index 62817560c5..7e9face021 100644
--- a/api/src/backend/api/migrations/0017_m365_provider.py
+++ b/api/src/backend/api/migrations/0017_m365_provider.py
@@ -1,8 +1,7 @@
# Generated by Django 5.1.7 on 2025-04-16 08:47
-from django.db import migrations
-
import api.db_utils
+from django.db import migrations
class Migration(migrations.Migration):
diff --git a/api/src/backend/api/migrations/0018_resource_scan_summaries.py b/api/src/backend/api/migrations/0018_resource_scan_summaries.py
index e9e2ffbe69..0e402cd7c6 100644
--- a/api/src/backend/api/migrations/0018_resource_scan_summaries.py
+++ b/api/src/backend/api/migrations/0018_resource_scan_summaries.py
@@ -2,12 +2,11 @@
import uuid
+import api.rls
import django.db.models.deletion
import uuid6
from django.db import migrations, models
-import api.rls
-
class Migration(migrations.Migration):
dependencies = [
diff --git a/api/src/backend/api/migrations/0020_findings_new_performance_indexes_partitions.py b/api/src/backend/api/migrations/0020_findings_new_performance_indexes_partitions.py
index eef7e10b99..544d9dee01 100644
--- a/api/src/backend/api/migrations/0020_findings_new_performance_indexes_partitions.py
+++ b/api/src/backend/api/migrations/0020_findings_new_performance_indexes_partitions.py
@@ -1,8 +1,7 @@
from functools import partial
-from django.db import migrations
-
from api.db_utils import create_index_on_partitions, drop_index_on_partitions
+from django.db import migrations
class Migration(migrations.Migration):
diff --git a/api/src/backend/api/migrations/0024_findings_uid_index_partitions.py b/api/src/backend/api/migrations/0024_findings_uid_index_partitions.py
index d0e237453e..0bec532752 100644
--- a/api/src/backend/api/migrations/0024_findings_uid_index_partitions.py
+++ b/api/src/backend/api/migrations/0024_findings_uid_index_partitions.py
@@ -1,8 +1,7 @@
from functools import partial
-from django.db import migrations
-
from api.db_utils import create_index_on_partitions, drop_index_on_partitions
+from django.db import migrations
class Migration(migrations.Migration):
diff --git a/api/src/backend/api/migrations/0027_compliance_requirement_overviews.py b/api/src/backend/api/migrations/0027_compliance_requirement_overviews.py
index 82bbb136a5..831e1137d4 100644
--- a/api/src/backend/api/migrations/0027_compliance_requirement_overviews.py
+++ b/api/src/backend/api/migrations/0027_compliance_requirement_overviews.py
@@ -2,12 +2,11 @@
import uuid
-import django.db.models.deletion
-from django.db import migrations, models
-
import api.db_utils
import api.rls
+import django.db.models.deletion
from api.rls import RowLevelSecurityConstraint
+from django.db import migrations, models
class Migration(migrations.Migration):
diff --git a/api/src/backend/api/migrations/0028_findings_check_index_partitions.py b/api/src/backend/api/migrations/0028_findings_check_index_partitions.py
index ad61f3004f..5fc1c9dc84 100644
--- a/api/src/backend/api/migrations/0028_findings_check_index_partitions.py
+++ b/api/src/backend/api/migrations/0028_findings_check_index_partitions.py
@@ -1,8 +1,7 @@
from functools import partial
-from django.db import migrations
-
from api.db_utils import create_index_on_partitions, drop_index_on_partitions
+from django.db import migrations
class Migration(migrations.Migration):
diff --git a/api/src/backend/api/migrations/0030_lighthouseconfiguration.py b/api/src/backend/api/migrations/0030_lighthouseconfiguration.py
index b12b8cac01..f00a7a9ae6 100644
--- a/api/src/backend/api/migrations/0030_lighthouseconfiguration.py
+++ b/api/src/backend/api/migrations/0030_lighthouseconfiguration.py
@@ -2,12 +2,11 @@
import uuid
+import api.rls
import django.core.validators
import django.db.models.deletion
from django.db import migrations, models
-import api.rls
-
class Migration(migrations.Migration):
dependencies = [
diff --git a/api/src/backend/api/migrations/0032_saml.py b/api/src/backend/api/migrations/0032_saml.py
index f1481e104b..7fe71179d5 100644
--- a/api/src/backend/api/migrations/0032_saml.py
+++ b/api/src/backend/api/migrations/0032_saml.py
@@ -2,13 +2,12 @@
import uuid
+import api.db_utils
+import api.rls
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
-import api.db_utils
-import api.rls
-
class Migration(migrations.Migration):
dependencies = [
diff --git a/api/src/backend/api/migrations/0033_processors_enum.py b/api/src/backend/api/migrations/0033_processors_enum.py
index 7dbad72241..8a4fdef08f 100644
--- a/api/src/backend/api/migrations/0033_processors_enum.py
+++ b/api/src/backend/api/migrations/0033_processors_enum.py
@@ -2,10 +2,9 @@
from functools import partial
-from django.db import migrations
-
from api.db_utils import PostgresEnumMigration, ProcessorTypeEnum, register_enum
from api.models import Processor
+from django.db import migrations
ProcessorTypeEnumMigration = PostgresEnumMigration(
enum_name="processor_type",
diff --git a/api/src/backend/api/migrations/0034_processors.py b/api/src/backend/api/migrations/0034_processors.py
index 3df4eaf53b..00efd0a283 100644
--- a/api/src/backend/api/migrations/0034_processors.py
+++ b/api/src/backend/api/migrations/0034_processors.py
@@ -2,12 +2,11 @@
import uuid
-import django.db.models.deletion
-from django.db import migrations, models
-
import api.db_utils
import api.rls
+import django.db.models.deletion
from api.rls import RowLevelSecurityConstraint
+from django.db import migrations, models
class Migration(migrations.Migration):
diff --git a/api/src/backend/api/migrations/0036_rfm_tenant_finding_index_partitions.py b/api/src/backend/api/migrations/0036_rfm_tenant_finding_index_partitions.py
index cd360eb7e4..e346a63bb7 100644
--- a/api/src/backend/api/migrations/0036_rfm_tenant_finding_index_partitions.py
+++ b/api/src/backend/api/migrations/0036_rfm_tenant_finding_index_partitions.py
@@ -1,8 +1,7 @@
from functools import partial
-from django.db import migrations
-
from api.db_utils import create_index_on_partitions, drop_index_on_partitions
+from django.db import migrations
class Migration(migrations.Migration):
diff --git a/api/src/backend/api/migrations/0040_rfm_tenant_resource_index_partitions.py b/api/src/backend/api/migrations/0040_rfm_tenant_resource_index_partitions.py
index 431d656376..5fd165d26c 100644
--- a/api/src/backend/api/migrations/0040_rfm_tenant_resource_index_partitions.py
+++ b/api/src/backend/api/migrations/0040_rfm_tenant_resource_index_partitions.py
@@ -1,8 +1,7 @@
from functools import partial
-from django.db import migrations
-
from api.db_utils import create_index_on_partitions, drop_index_on_partitions
+from django.db import migrations
class Migration(migrations.Migration):
diff --git a/api/src/backend/api/migrations/0043_github_provider.py b/api/src/backend/api/migrations/0043_github_provider.py
index 3607ce4af3..3a54bfbcc6 100644
--- a/api/src/backend/api/migrations/0043_github_provider.py
+++ b/api/src/backend/api/migrations/0043_github_provider.py
@@ -1,8 +1,7 @@
# Generated by Django 5.1.7 on 2025-07-09 14:44
-from django.db import migrations
-
import api.db_utils
+from django.db import migrations
class Migration(migrations.Migration):
diff --git a/api/src/backend/api/migrations/0048_api_key.py b/api/src/backend/api/migrations/0048_api_key.py
index c3142ecda1..a66448325b 100644
--- a/api/src/backend/api/migrations/0048_api_key.py
+++ b/api/src/backend/api/migrations/0048_api_key.py
@@ -2,15 +2,14 @@
import uuid
+import api.db_utils
+import api.rls
import django.core.validators
import django.db.models.deletion
import drf_simple_apikey.models
from django.conf import settings
from django.db import migrations, models
-import api.db_utils
-import api.rls
-
class Migration(migrations.Migration):
dependencies = [
diff --git a/api/src/backend/api/migrations/0050_lighthouse_multi_llm.py b/api/src/backend/api/migrations/0050_lighthouse_multi_llm.py
index 99a9353327..c236f9efea 100644
--- a/api/src/backend/api/migrations/0050_lighthouse_multi_llm.py
+++ b/api/src/backend/api/migrations/0050_lighthouse_multi_llm.py
@@ -4,15 +4,14 @@ import json
import logging
import uuid
+import api.rls
import django.db.models.deletion
+from api.db_router import MainRouter
from config.custom_logging import BackendLogger
from cryptography.fernet import Fernet
from django.conf import settings
from django.db import migrations, models
-import api.rls
-from api.db_router import MainRouter
-
logger = logging.getLogger(BackendLogger.API)
diff --git a/api/src/backend/api/migrations/0051_oraclecloud_provider.py b/api/src/backend/api/migrations/0051_oraclecloud_provider.py
index 022c022ea6..5688d0a764 100644
--- a/api/src/backend/api/migrations/0051_oraclecloud_provider.py
+++ b/api/src/backend/api/migrations/0051_oraclecloud_provider.py
@@ -1,8 +1,7 @@
# Generated by Django 5.1.7 on 2025-10-14 00:00
-from django.db import migrations
-
import api.db_utils
+from django.db import migrations
class Migration(migrations.Migration):
diff --git a/api/src/backend/api/migrations/0052_mute_rules.py b/api/src/backend/api/migrations/0052_mute_rules.py
index 56a3ff516f..358402321b 100644
--- a/api/src/backend/api/migrations/0052_mute_rules.py
+++ b/api/src/backend/api/migrations/0052_mute_rules.py
@@ -2,14 +2,13 @@
import uuid
+import api.rls
import django.contrib.postgres.fields
import django.core.validators
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
-import api.rls
-
class Migration(migrations.Migration):
dependencies = [
diff --git a/api/src/backend/api/migrations/0054_iac_provider.py b/api/src/backend/api/migrations/0054_iac_provider.py
index 03c29e33b6..05350c251f 100644
--- a/api/src/backend/api/migrations/0054_iac_provider.py
+++ b/api/src/backend/api/migrations/0054_iac_provider.py
@@ -1,8 +1,7 @@
# Generated by Django 5.1.10 on 2025-09-09 09:25
-from django.db import migrations
-
import api.db_utils
+from django.db import migrations
class Migration(migrations.Migration):
diff --git a/api/src/backend/api/migrations/0055_mongodbatlas_provider.py b/api/src/backend/api/migrations/0055_mongodbatlas_provider.py
index d250a0bffd..1703decdb4 100644
--- a/api/src/backend/api/migrations/0055_mongodbatlas_provider.py
+++ b/api/src/backend/api/migrations/0055_mongodbatlas_provider.py
@@ -1,8 +1,7 @@
# Generated by Django 5.1.13 on 2025-11-05 08:37
-from django.db import migrations
-
import api.db_utils
+from django.db import migrations
class Migration(migrations.Migration):
diff --git a/api/src/backend/api/migrations/0057_threatscoresnapshot.py b/api/src/backend/api/migrations/0057_threatscoresnapshot.py
index ee3530a5b6..171b94c2d5 100644
--- a/api/src/backend/api/migrations/0057_threatscoresnapshot.py
+++ b/api/src/backend/api/migrations/0057_threatscoresnapshot.py
@@ -2,11 +2,10 @@
import uuid
+import api.rls
import django.db.models.deletion
from django.db import migrations, models
-import api.rls
-
class Migration(migrations.Migration):
dependencies = [
diff --git a/api/src/backend/api/migrations/0059_compliance_overview_summary.py b/api/src/backend/api/migrations/0059_compliance_overview_summary.py
index d2d57a34ae..06873abef1 100644
--- a/api/src/backend/api/migrations/0059_compliance_overview_summary.py
+++ b/api/src/backend/api/migrations/0059_compliance_overview_summary.py
@@ -2,11 +2,10 @@
import uuid
+import api.rls
import django.db.models.deletion
from django.db import migrations, models
-import api.rls
-
class Migration(migrations.Migration):
dependencies = [
diff --git a/api/src/backend/api/migrations/0060_attack_surface_overview.py b/api/src/backend/api/migrations/0060_attack_surface_overview.py
index 8007d49a70..a93cf5b38f 100644
--- a/api/src/backend/api/migrations/0060_attack_surface_overview.py
+++ b/api/src/backend/api/migrations/0060_attack_surface_overview.py
@@ -2,11 +2,10 @@
import uuid
+import api.rls
import django.db.models.deletion
from django.db import migrations, models
-import api.rls
-
class Migration(migrations.Migration):
dependencies = [
diff --git a/api/src/backend/api/migrations/0061_daily_severity_summary.py b/api/src/backend/api/migrations/0061_daily_severity_summary.py
index 7e4074cf7f..3aa89133bd 100644
--- a/api/src/backend/api/migrations/0061_daily_severity_summary.py
+++ b/api/src/backend/api/migrations/0061_daily_severity_summary.py
@@ -2,11 +2,10 @@
import uuid
+import api.rls
import django.db.models.deletion
from django.db import migrations, models
-import api.rls
-
class Migration(migrations.Migration):
dependencies = [
diff --git a/api/src/backend/api/migrations/0062_backfill_daily_severity_summaries.py b/api/src/backend/api/migrations/0062_backfill_daily_severity_summaries.py
index f893ac4305..92e53bc4ad 100644
--- a/api/src/backend/api/migrations/0062_backfill_daily_severity_summaries.py
+++ b/api/src/backend/api/migrations/0062_backfill_daily_severity_summaries.py
@@ -1,10 +1,9 @@
# Generated by Django 5.1.14 on 2025-12-10
-from django.db import migrations
-from tasks.tasks import backfill_daily_severity_summaries_task
-
from api.db_router import MainRouter
from api.rls import Tenant
+from django.db import migrations
+from tasks.tasks import backfill_daily_severity_summaries_task
def trigger_backfill_task(apps, schema_editor):
diff --git a/api/src/backend/api/migrations/0063_scan_category_summary.py b/api/src/backend/api/migrations/0063_scan_category_summary.py
index 6ee67bf4db..25ca790c8d 100644
--- a/api/src/backend/api/migrations/0063_scan_category_summary.py
+++ b/api/src/backend/api/migrations/0063_scan_category_summary.py
@@ -1,10 +1,9 @@
import uuid
-import django.db.models.deletion
-from django.db import migrations, models
-
import api.db_utils
import api.rls
+import django.db.models.deletion
+from django.db import migrations, models
class Migration(migrations.Migration):
diff --git a/api/src/backend/api/migrations/0065_alibabacloud_provider.py b/api/src/backend/api/migrations/0065_alibabacloud_provider.py
index 6ad542b643..d9f4250304 100644
--- a/api/src/backend/api/migrations/0065_alibabacloud_provider.py
+++ b/api/src/backend/api/migrations/0065_alibabacloud_provider.py
@@ -1,8 +1,7 @@
# Generated by Django migration for Alibaba Cloud provider support
-from django.db import migrations
-
import api.db_utils
+from django.db import migrations
class Migration(migrations.Migration):
diff --git a/api/src/backend/api/migrations/0066_provider_compliance_score.py b/api/src/backend/api/migrations/0066_provider_compliance_score.py
index f9a6483e4f..2649d8fbdf 100644
--- a/api/src/backend/api/migrations/0066_provider_compliance_score.py
+++ b/api/src/backend/api/migrations/0066_provider_compliance_score.py
@@ -1,10 +1,9 @@
import uuid
-import django.db.models.deletion
-from django.db import migrations, models
-
import api.db_utils
import api.rls
+import django.db.models.deletion
+from django.db import migrations, models
class Migration(migrations.Migration):
diff --git a/api/src/backend/api/migrations/0067_tenant_compliance_summary.py b/api/src/backend/api/migrations/0067_tenant_compliance_summary.py
index bd753ca575..92973320bc 100644
--- a/api/src/backend/api/migrations/0067_tenant_compliance_summary.py
+++ b/api/src/backend/api/migrations/0067_tenant_compliance_summary.py
@@ -1,10 +1,9 @@
import uuid
+import api.rls
import django.db.models.deletion
from django.db import migrations, models
-import api.rls
-
class Migration(migrations.Migration):
dependencies = [
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
index 932a2a6c85..c13ada78e2 100644
--- a/api/src/backend/api/migrations/0068_finding_resource_group_scangroupsummary.py
+++ b/api/src/backend/api/migrations/0068_finding_resource_group_scangroupsummary.py
@@ -1,10 +1,9 @@
import uuid
-import django.db.models.deletion
-from django.db import migrations, models
-
import api.db_utils
import api.rls
+import django.db.models.deletion
+from django.db import migrations, models
class Migration(migrations.Migration):
diff --git a/api/src/backend/api/migrations/0070_attack_paths_scan.py b/api/src/backend/api/migrations/0070_attack_paths_scan.py
index 3e63d3353b..557b04a9ce 100644
--- a/api/src/backend/api/migrations/0070_attack_paths_scan.py
+++ b/api/src/backend/api/migrations/0070_attack_paths_scan.py
@@ -1,12 +1,10 @@
# Generated by Django 5.1.13 on 2025-11-06 16:20
+import api.rls
import django.db.models.deletion
-
from django.db import migrations, models
from uuid6 import uuid7
-import api.rls
-
class Migration(migrations.Migration):
dependencies = [
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
index 671fdf5ef6..06f04f2734 100644
--- 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
@@ -1,8 +1,7 @@
from functools import partial
-from django.db import migrations
-
from api.db_utils import create_index_on_partitions, drop_index_on_partitions
+from django.db import migrations
class Migration(migrations.Migration):
diff --git a/api/src/backend/api/migrations/0075_cloudflare_provider.py b/api/src/backend/api/migrations/0075_cloudflare_provider.py
index 28fdbdb2a9..dcfffe83c6 100644
--- a/api/src/backend/api/migrations/0075_cloudflare_provider.py
+++ b/api/src/backend/api/migrations/0075_cloudflare_provider.py
@@ -1,8 +1,7 @@
# Generated by Django migration for Cloudflare provider support
-from django.db import migrations
-
import api.db_utils
+from django.db import migrations
class Migration(migrations.Migration):
diff --git a/api/src/backend/api/migrations/0076_openstack_provider.py b/api/src/backend/api/migrations/0076_openstack_provider.py
index 9cc80707ea..680cc4310a 100644
--- a/api/src/backend/api/migrations/0076_openstack_provider.py
+++ b/api/src/backend/api/migrations/0076_openstack_provider.py
@@ -1,8 +1,7 @@
# Generated by Django migration for OpenStack provider support
-from django.db import migrations
-
import api.db_utils
+from django.db import migrations
class Migration(migrations.Migration):
diff --git a/api/src/backend/api/migrations/0080_backfill_attack_paths_graph_data_ready.py b/api/src/backend/api/migrations/0080_backfill_attack_paths_graph_data_ready.py
index f780059bd2..542b117a22 100644
--- a/api/src/backend/api/migrations/0080_backfill_attack_paths_graph_data_ready.py
+++ b/api/src/backend/api/migrations/0080_backfill_attack_paths_graph_data_ready.py
@@ -2,9 +2,8 @@
# on different database connections, causing a deadlock when combined with RunPython
# in the same migration.
-from django.db import migrations
-
from api.db_router import MainRouter
+from django.db import migrations
def backfill_graph_data_ready(apps, schema_editor):
diff --git a/api/src/backend/api/migrations/0081_finding_group_daily_summary.py b/api/src/backend/api/migrations/0081_finding_group_daily_summary.py
index 31c09c464f..e4685cea5f 100644
--- a/api/src/backend/api/migrations/0081_finding_group_daily_summary.py
+++ b/api/src/backend/api/migrations/0081_finding_group_daily_summary.py
@@ -2,14 +2,13 @@
import uuid
+import api.rls
import django.db.models.deletion
from django.contrib.postgres.indexes import GinIndex, OpClass
from django.db import migrations, models
from django.db.models.functions import Upper
from django.utils import timezone
-import api.rls
-
class Migration(migrations.Migration):
dependencies = [
diff --git a/api/src/backend/api/migrations/0082_backfill_finding_group_summaries.py b/api/src/backend/api/migrations/0082_backfill_finding_group_summaries.py
index 38cc07f43d..ef3e9c49a9 100644
--- a/api/src/backend/api/migrations/0082_backfill_finding_group_summaries.py
+++ b/api/src/backend/api/migrations/0082_backfill_finding_group_summaries.py
@@ -1,10 +1,9 @@
# Generated by Django 5.1.14 on 2026-02-02
-from django.db import migrations
-from tasks.tasks import backfill_finding_group_summaries_task
-
from api.db_router import MainRouter
from api.rls import Tenant
+from django.db import migrations
+from tasks.tasks import backfill_finding_group_summaries_task
def trigger_backfill_task(apps, schema_editor):
diff --git a/api/src/backend/api/migrations/0083_image_provider.py b/api/src/backend/api/migrations/0083_image_provider.py
index 936fae2219..6f2b5a9d6b 100644
--- a/api/src/backend/api/migrations/0083_image_provider.py
+++ b/api/src/backend/api/migrations/0083_image_provider.py
@@ -1,6 +1,5 @@
-from django.db import migrations
-
import api.db_utils
+from django.db import migrations
class Migration(migrations.Migration):
diff --git a/api/src/backend/api/migrations/0084_googleworkspace_provider.py b/api/src/backend/api/migrations/0084_googleworkspace_provider.py
index eb704bb6b5..e7971e2568 100644
--- a/api/src/backend/api/migrations/0084_googleworkspace_provider.py
+++ b/api/src/backend/api/migrations/0084_googleworkspace_provider.py
@@ -1,6 +1,5 @@
-from django.db import migrations
-
import api.db_utils
+from django.db import migrations
class Migration(migrations.Migration):
diff --git a/api/src/backend/api/migrations/0086_attack_paths_cleanup_periodic_task.py b/api/src/backend/api/migrations/0086_attack_paths_cleanup_periodic_task.py
index 1c550f283a..2bf7bf2fb4 100644
--- a/api/src/backend/api/migrations/0086_attack_paths_cleanup_periodic_task.py
+++ b/api/src/backend/api/migrations/0086_attack_paths_cleanup_periodic_task.py
@@ -1,6 +1,5 @@
from django.db import migrations
-
TASK_NAME = "attack-paths-cleanup-stale-scans"
INTERVAL_HOURS = 1
diff --git a/api/src/backend/api/migrations/0087_vercel_provider.py b/api/src/backend/api/migrations/0087_vercel_provider.py
index 84a07b3194..92063fb6da 100644
--- a/api/src/backend/api/migrations/0087_vercel_provider.py
+++ b/api/src/backend/api/migrations/0087_vercel_provider.py
@@ -1,6 +1,5 @@
-from django.db import migrations
-
import api.db_utils
+from django.db import migrations
class Migration(migrations.Migration):
diff --git a/api/src/backend/api/migrations/0089_backfill_finding_group_status_muted.py b/api/src/backend/api/migrations/0089_backfill_finding_group_status_muted.py
index 501fcf3cb4..3df6b4b167 100644
--- a/api/src/backend/api/migrations/0089_backfill_finding_group_status_muted.py
+++ b/api/src/backend/api/migrations/0089_backfill_finding_group_status_muted.py
@@ -1,8 +1,7 @@
-from django.db import migrations
-from tasks.tasks import backfill_finding_group_summaries_task
-
from api.db_router import MainRouter
from api.rls import Tenant
+from django.db import migrations
+from tasks.tasks import backfill_finding_group_summaries_task
def trigger_backfill_task(apps, schema_editor):
diff --git a/api/src/backend/api/migrations/0091_findings_arrays_gin_index_partitions.py b/api/src/backend/api/migrations/0091_findings_arrays_gin_index_partitions.py
index fc5716f828..6c4e978b28 100644
--- a/api/src/backend/api/migrations/0091_findings_arrays_gin_index_partitions.py
+++ b/api/src/backend/api/migrations/0091_findings_arrays_gin_index_partitions.py
@@ -1,8 +1,7 @@
from functools import partial
-from django.db import migrations
-
from api.db_utils import create_index_on_partitions, drop_index_on_partitions
+from django.db import migrations
class Migration(migrations.Migration):
diff --git a/api/src/backend/api/migrations/0093_okta_provider.py b/api/src/backend/api/migrations/0093_okta_provider.py
new file mode 100644
index 0000000000..cc28c45fdb
--- /dev/null
+++ b/api/src/backend/api/migrations/0093_okta_provider.py
@@ -0,0 +1,40 @@
+import api.db_utils
+from django.db import migrations
+
+
+class Migration(migrations.Migration):
+ dependencies = [
+ ("api", "0092_findings_arrays_gin_index_parent"),
+ ]
+
+ operations = [
+ migrations.AlterField(
+ model_name="provider",
+ name="provider",
+ field=api.db_utils.ProviderEnumField(
+ choices=[
+ ("aws", "AWS"),
+ ("azure", "Azure"),
+ ("gcp", "GCP"),
+ ("kubernetes", "Kubernetes"),
+ ("m365", "M365"),
+ ("github", "GitHub"),
+ ("mongodbatlas", "MongoDB Atlas"),
+ ("iac", "IaC"),
+ ("oraclecloud", "Oracle Cloud Infrastructure"),
+ ("alibabacloud", "Alibaba Cloud"),
+ ("cloudflare", "Cloudflare"),
+ ("openstack", "OpenStack"),
+ ("image", "Image"),
+ ("googleworkspace", "Google Workspace"),
+ ("vercel", "Vercel"),
+ ("okta", "Okta"),
+ ],
+ default="aws",
+ ),
+ ),
+ migrations.RunSQL(
+ "ALTER TYPE provider ADD VALUE IF NOT EXISTS 'okta';",
+ reverse_sql=migrations.RunSQL.noop,
+ ),
+ ]
diff --git a/api/src/backend/api/migrations/0095_reconcile_orphan_tasks_periodic_task.py b/api/src/backend/api/migrations/0095_reconcile_orphan_tasks_periodic_task.py
new file mode 100644
index 0000000000..6ba2fa758a
--- /dev/null
+++ b/api/src/backend/api/migrations/0095_reconcile_orphan_tasks_periodic_task.py
@@ -0,0 +1,48 @@
+from django.db import migrations
+
+TASK_NAME = "reconcile-orphan-tasks"
+INTERVAL_MINUTES = 2
+
+
+def create_periodic_task(apps, schema_editor):
+ IntervalSchedule = apps.get_model("django_celery_beat", "IntervalSchedule")
+ PeriodicTask = apps.get_model("django_celery_beat", "PeriodicTask")
+
+ schedule, _ = IntervalSchedule.objects.get_or_create(
+ every=INTERVAL_MINUTES,
+ period="minutes",
+ )
+
+ PeriodicTask.objects.update_or_create(
+ name=TASK_NAME,
+ defaults={
+ "task": TASK_NAME,
+ "interval": schedule,
+ "enabled": True,
+ },
+ )
+
+
+def delete_periodic_task(apps, schema_editor):
+ IntervalSchedule = apps.get_model("django_celery_beat", "IntervalSchedule")
+ PeriodicTask = apps.get_model("django_celery_beat", "PeriodicTask")
+
+ PeriodicTask.objects.filter(name=TASK_NAME).delete()
+
+ # Clean up the schedule if no other task references it
+ IntervalSchedule.objects.filter(
+ every=INTERVAL_MINUTES,
+ period="minutes",
+ periodictask__isnull=True,
+ ).delete()
+
+
+class Migration(migrations.Migration):
+ dependencies = [
+ ("api", "0093_okta_provider"),
+ ("django_celery_beat", "0019_alter_periodictasks_options"),
+ ]
+
+ operations = [
+ migrations.RunPython(create_periodic_task, delete_periodic_task),
+ ]
diff --git a/api/src/backend/api/migrations/0096_attack_paths_scan_is_migrated.py b/api/src/backend/api/migrations/0096_attack_paths_scan_is_migrated.py
new file mode 100644
index 0000000000..75b3e2cac7
--- /dev/null
+++ b/api/src/backend/api/migrations/0096_attack_paths_scan_is_migrated.py
@@ -0,0 +1,24 @@
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+ dependencies = [
+ ("api", "0095_reconcile_orphan_tasks_periodic_task"),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name="attackpathsscan",
+ name="is_migrated",
+ field=models.BooleanField(default=False),
+ ),
+ migrations.AddField(
+ model_name="attackpathsscan",
+ name="sink_backend",
+ field=models.CharField(
+ choices=[("neo4j", "Neo4j"), ("neptune", "Neptune")],
+ default="neo4j",
+ max_length=16,
+ ),
+ ),
+ ]
diff --git a/api/src/backend/api/migrations/0097_attack_paths_scan_db_defaults.py b/api/src/backend/api/migrations/0097_attack_paths_scan_db_defaults.py
new file mode 100644
index 0000000000..8bcb43d50c
--- /dev/null
+++ b/api/src/backend/api/migrations/0097_attack_paths_scan_db_defaults.py
@@ -0,0 +1,25 @@
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+ dependencies = [
+ ("api", "0096_attack_paths_scan_is_migrated"),
+ ]
+
+ operations = [
+ migrations.AlterField(
+ model_name="attackpathsscan",
+ name="is_migrated",
+ field=models.BooleanField(db_default=False, default=False),
+ ),
+ migrations.AlterField(
+ model_name="attackpathsscan",
+ name="sink_backend",
+ field=models.CharField(
+ choices=[("neo4j", "Neo4j"), ("neptune", "Neptune")],
+ db_default="neo4j",
+ default="neo4j",
+ max_length=16,
+ ),
+ ),
+ ]
diff --git a/api/src/backend/api/models.py b/api/src/backend/api/models.py
index 17d38e82f0..a280708d53 100644
--- a/api/src/backend/api/models.py
+++ b/api/src/backend/api/models.py
@@ -1,37 +1,11 @@
import json
import logging
import re
-from datetime import datetime, timedelta, timezone
+from datetime import UTC, datetime, timedelta
from uuid import UUID, uuid4
import defusedxml
from allauth.socialaccount.models import SocialApp
-from config.custom_logging import BackendLogger
-from config.settings.social_login import SOCIALACCOUNT_PROVIDERS
-from cryptography.fernet import Fernet, InvalidToken
-from defusedxml import ElementTree as ET
-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, OpClass
-from django.contrib.postgres.search import SearchVector, SearchVectorField
-from django.contrib.sites.models import Site
-from django.core.exceptions import ValidationError
-from django.core.validators import MinLengthValidator
-from django.db import models
-from django.db.models import Q
-from django.db.models.functions import Upper
-from django.utils import timezone as django_timezone
-from django.utils.translation import gettext_lazy as _
-from django_celery_beat.models import PeriodicTask
-from django_celery_results.models import TaskResult
-from drf_simple_apikey.crypto import get_crypto
-from drf_simple_apikey.models import AbstractAPIKey, AbstractAPIKeyManager
-from psqlextra.manager import PostgresManager
-from psqlextra.models import PostgresPartitionedModel
-from psqlextra.types import PostgresPartitioningMethod
-from uuid6 import uuid7
-
from api.db_router import MainRouter
from api.db_utils import (
CustomUserManager,
@@ -58,7 +32,32 @@ from api.rls import (
RowLevelSecurityProtectedModel,
Tenant,
)
+from config.custom_logging import BackendLogger
+from config.settings.social_login import SOCIALACCOUNT_PROVIDERS
+from cryptography.fernet import Fernet, InvalidToken
+from defusedxml import ElementTree as ET
+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, OpClass
+from django.contrib.postgres.search import SearchVector, SearchVectorField
+from django.contrib.sites.models import Site
+from django.core.exceptions import ValidationError
+from django.core.validators import MinLengthValidator
+from django.db import models
+from django.db.models import Q
+from django.db.models.functions import Upper
+from django.utils import timezone as django_timezone
+from django.utils.translation import gettext_lazy as _
+from django_celery_beat.models import PeriodicTask
+from django_celery_results.models import TaskResult
+from drf_simple_apikey.crypto import get_crypto
+from drf_simple_apikey.models import AbstractAPIKey, AbstractAPIKeyManager
from prowler.lib.check.models import Severity
+from psqlextra.manager import PostgresManager
+from psqlextra.models import PostgresPartitionedModel
+from psqlextra.types import PostgresPartitioningMethod
+from uuid6 import uuid7
fernet = Fernet(settings.SECRETS_ENCRYPTION_KEY.encode())
@@ -296,6 +295,7 @@ class Provider(RowLevelSecurityProtectedModel):
IMAGE = "image", _("Image")
GOOGLEWORKSPACE = "googleworkspace", _("Google Workspace")
VERCEL = "vercel", _("Vercel")
+ OKTA = "okta", _("Okta")
@staticmethod
def validate_aws_uid(value):
@@ -354,6 +354,26 @@ class Provider(RowLevelSecurityProtectedModel):
pointer="/data/attributes/uid",
)
+ @staticmethod
+ def validate_okta_uid(value):
+ if not re.match(
+ r"^[a-z0-9][a-z0-9-]*\.("
+ r"okta\.com|oktapreview\.com|okta-emea\.com|"
+ r"okta-gov\.com|okta\.mil|okta-miltest\.com|trex-govcloud\.com"
+ r")$",
+ value,
+ ):
+ raise ModelValidationError(
+ detail=(
+ "Okta provider ID must be a valid Okta-managed org domain "
+ "(e.g., acme.okta.com, also .oktapreview.com / .okta-emea.com "
+ "/ .okta-gov.com / .okta.mil / .okta-miltest.com / "
+ ".trex-govcloud.com), without scheme or path."
+ ),
+ code="okta-uid",
+ pointer="/data/attributes/uid",
+ )
+
@staticmethod
def validate_kubernetes_uid(value):
if not re.match(
@@ -480,6 +500,12 @@ class Provider(RowLevelSecurityProtectedModel):
def clean(self):
super().clean()
+ if self.provider == self.ProviderChoices.OKTA and self.uid:
+ # Mirror the SDK, which lowercases the org domain before connecting.
+ # Without this the API would reject Acme.okta.com even though the
+ # SDK would accept it, and stored uids could disagree with the
+ # authenticated org domain.
+ self.uid = self.uid.strip().lower()
getattr(self, f"validate_{self.provider}_uid")(self.uid)
def save(self, *args, **kwargs):
@@ -731,6 +757,10 @@ class Scan(RowLevelSecurityProtectedModel):
class AttackPathsScan(RowLevelSecurityProtectedModel):
+ class SinkBackendChoices(models.TextChoices):
+ NEO4J = "neo4j", "Neo4j"
+ NEPTUNE = "neptune", "Neptune"
+
objects = ActiveProviderManager()
all_objects = models.Manager()
@@ -779,6 +809,19 @@ class AttackPathsScan(RowLevelSecurityProtectedModel):
)
ingestion_exceptions = models.JSONField(default=dict, null=True, blank=True)
+ # True when the scan was synced with the current schema (list-typed
+ # properties materialised as child item nodes). False for pre-cutover scans
+ # still using the previous graph shape. Query catalog selection uses this
+ # flag; physical read routing uses sink_backend below.
+ # TODO: drop after Neptune cutover
+ is_migrated = models.BooleanField(default=False, db_default=False)
+ sink_backend = models.CharField(
+ choices=SinkBackendChoices.choices,
+ db_default=SinkBackendChoices.NEO4J,
+ default=SinkBackendChoices.NEO4J,
+ max_length=16,
+ )
+
class Meta(RowLevelSecurityProtectedModel.Meta):
db_table = "attack_paths_scans"
@@ -1400,8 +1443,8 @@ class Role(RowLevelSecurityProtectedModel):
@classmethod
def filter_by_permission_state(cls, queryset, value):
- q_all_true = Q(**{field: True for field in cls.PERMISSION_FIELDS})
- q_all_false = Q(**{field: False for field in cls.PERMISSION_FIELDS})
+ q_all_true = Q(**dict.fromkeys(cls.PERMISSION_FIELDS, True))
+ q_all_false = Q(**dict.fromkeys(cls.PERMISSION_FIELDS, False))
if value == PermissionChoices.UNLIMITED:
return queryset.filter(q_all_true)
@@ -1984,11 +2027,11 @@ class SAMLToken(models.Model):
def save(self, *args, **kwargs):
if not self.expires_at:
- self.expires_at = datetime.now(timezone.utc) + timedelta(seconds=15)
+ self.expires_at = datetime.now(UTC) + timedelta(seconds=15)
super().save(*args, **kwargs)
def is_expired(self) -> bool:
- return datetime.now(timezone.utc) >= self.expires_at
+ return datetime.now(UTC) >= self.expires_at
class SAMLDomainIndex(models.Model):
diff --git a/api/src/backend/api/partitions.py b/api/src/backend/api/partitions.py
index 92390ffdec..8903c4504b 100644
--- a/api/src/backend/api/partitions.py
+++ b/api/src/backend/api/partitions.py
@@ -1,21 +1,20 @@
-from datetime import datetime, timezone
-from typing import Generator, Optional
-
-from dateutil.relativedelta import relativedelta
-from django.conf import settings
-from psqlextra.partitioning import (
- PostgresPartitioningManager,
- PostgresRangePartition,
- PostgresRangePartitioningStrategy,
- PostgresTimePartitionSize,
- PostgresPartitioningError,
-)
-from psqlextra.partitioning.config import PostgresPartitioningConfig
-from uuid6 import UUID
+from collections.abc import Generator
+from datetime import UTC, datetime
from api.models import Finding, ResourceFindingMapping
from api.rls import RowLevelSecurityConstraint
from api.uuid_utils import datetime_to_uuid7
+from dateutil.relativedelta import relativedelta
+from django.conf import settings
+from psqlextra.partitioning import (
+ PostgresPartitioningError,
+ PostgresPartitioningManager,
+ PostgresRangePartition,
+ PostgresRangePartitioningStrategy,
+ PostgresTimePartitionSize,
+)
+from psqlextra.partitioning.config import PostgresPartitioningConfig
+from uuid6 import UUID
class PostgresUUIDv7RangePartition(PostgresRangePartition):
@@ -24,7 +23,7 @@ class PostgresUUIDv7RangePartition(PostgresRangePartition):
from_values: UUID,
to_values: UUID,
size: PostgresTimePartitionSize,
- name_format: Optional[str] = None,
+ name_format: str | None = None,
**kwargs,
) -> None:
self.from_values = from_values
@@ -38,9 +37,7 @@ class PostgresUUIDv7RangePartition(PostgresRangePartition):
start_timestamp_ms = self.from_values.time
- self.start_datetime = datetime.fromtimestamp(
- start_timestamp_ms / 1000, timezone.utc
- )
+ self.start_datetime = datetime.fromtimestamp(start_timestamp_ms / 1000, UTC)
def name(self) -> str:
if not self.name_format:
@@ -82,8 +79,8 @@ class PostgresUUIDv7PartitioningStrategy(PostgresRangePartitioningStrategy):
size: PostgresTimePartitionSize,
count: int,
start_date: datetime = None,
- max_age: Optional[relativedelta] = None,
- name_format: Optional[str] = None,
+ max_age: relativedelta | None = None,
+ name_format: str | None = None,
**kwargs,
) -> None:
self.start_date = start_date.replace(
@@ -151,7 +148,7 @@ class PostgresUUIDv7PartitioningStrategy(PostgresRangePartitioningStrategy):
Returns:
datetime: A `datetime` object representing the start of the current month in UTC.
"""
- return datetime.now(timezone.utc).replace(
+ return datetime.now(UTC).replace(
day=1, hour=0, minute=0, second=0, microsecond=0
)
@@ -171,7 +168,7 @@ manager = PostgresPartitioningManager(
PostgresPartitioningConfig(
model=Finding,
strategy=PostgresUUIDv7PartitioningStrategy(
- start_date=datetime.now(timezone.utc),
+ start_date=datetime.now(UTC),
size=PostgresTimePartitionSize(
months=settings.FINDINGS_TABLE_PARTITION_MONTHS
),
@@ -187,7 +184,7 @@ manager = PostgresPartitioningManager(
PostgresPartitioningConfig(
model=ResourceFindingMapping,
strategy=PostgresUUIDv7PartitioningStrategy(
- start_date=datetime.now(timezone.utc),
+ start_date=datetime.now(UTC),
size=PostgresTimePartitionSize(
months=settings.FINDINGS_TABLE_PARTITION_MONTHS
),
diff --git a/api/src/backend/api/rbac/permissions.py b/api/src/backend/api/rbac/permissions.py
index cfbabf6c0b..3458346a5f 100644
--- a/api/src/backend/api/rbac/permissions.py
+++ b/api/src/backend/api/rbac/permissions.py
@@ -1,11 +1,10 @@
from enum import Enum
-from django.db.models import QuerySet
-from rest_framework.exceptions import PermissionDenied
-from rest_framework.permissions import BasePermission
-
from api.db_router import MainRouter
from api.models import Provider, Role, User
+from django.db.models import QuerySet
+from rest_framework.exceptions import PermissionDenied
+from rest_framework.permissions import BasePermission
class Permissions(Enum):
@@ -35,7 +34,7 @@ class HasPermissions(BasePermission):
if not tenant_id:
return False
- user_roles = (
+ user_roles = list(
User.objects.using(MainRouter.admin_db)
.get(id=request.user.id)
.roles.using(MainRouter.admin_db)
@@ -44,11 +43,10 @@ class HasPermissions(BasePermission):
if not user_roles:
return False
- for perm in required_permissions:
- if not getattr(user_roles[0], perm.value, False):
- return False
-
- return True
+ return all(
+ any(getattr(role, permission.value, False) for role in user_roles)
+ for permission in required_permissions
+ )
def get_role(user: User, tenant_id: str) -> Role:
diff --git a/api/src/backend/api/renderers.py b/api/src/backend/api/renderers.py
index 77349540ce..e0fafac3c4 100644
--- a/api/src/backend/api/renderers.py
+++ b/api/src/backend/api/renderers.py
@@ -1,10 +1,9 @@
from contextlib import nullcontext
+from api.db_utils import rls_transaction
from rest_framework.renderers import BaseRenderer
from rest_framework_json_api.renderers import JSONRenderer
-from api.db_utils import rls_transaction
-
class PlainTextRenderer(BaseRenderer):
media_type = "text/plain"
diff --git a/api/src/backend/api/rls.py b/api/src/backend/api/rls.py
index 285b06a974..9e4754c842 100644
--- a/api/src/backend/api/rls.py
+++ b/api/src/backend/api/rls.py
@@ -1,12 +1,11 @@
from typing import Any
from uuid import uuid4
+from api.db_utils import DB_USER, POSTGRES_TENANT_VAR
from django.core.exceptions import ValidationError
from django.db import DEFAULT_DB_ALIAS, models
from django.db.backends.ddl_references import Statement, Table
-from api.db_utils import DB_USER, POSTGRES_TENANT_VAR
-
class Tenant(models.Model):
"""
diff --git a/api/src/backend/api/signals.py b/api/src/backend/api/signals.py
index 7bca0da0a6..790779f087 100644
--- a/api/src/backend/api/signals.py
+++ b/api/src/backend/api/signals.py
@@ -1,10 +1,3 @@
-from celery import states
-from celery.signals import before_task_publish
-from config.celery import celery_app
-from django.db.models.signals import post_delete, pre_delete
-from django.dispatch import receiver
-from django_celery_results.backends.database import DatabaseBackend
-
from api.db_utils import delete_related_daily_task
from api.models import (
LighthouseProviderConfiguration,
@@ -14,6 +7,12 @@ from api.models import (
TenantAPIKey,
User,
)
+from celery import states
+from celery.signals import before_task_publish
+from config.celery import celery_app
+from django.db.models.signals import post_delete, pre_delete
+from django.dispatch import receiver
+from django_celery_results.backends.database import DatabaseBackend
def create_task_result_on_publish(sender=None, headers=None, **kwargs): # noqa: F841
diff --git a/api/src/backend/api/specs/v1.yaml b/api/src/backend/api/specs/v1.yaml
index 658119f1ba..420ebb27cf 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.29.0
+ version: 1.37.0
description: |-
Prowler API specification.
@@ -9,7 +9,7 @@ info:
paths:
/api/v1/api-keys:
get:
- operationId: api_keys_list
+ operationId: api_v1_api_keys_list
description: Retrieve a list of API keys for the tenant, with filtering support.
summary: List API keys
parameters:
@@ -141,7 +141,7 @@ paths:
$ref: '#/components/schemas/PaginatedTenantApiKeyList'
description: ''
post:
- operationId: api_keys_create
+ operationId: api_v1_api_keys_create
description: Create a new API key for the tenant.
summary: Create a new API key
tags:
@@ -169,7 +169,7 @@ paths:
description: ''
/api/v1/api-keys/{id}:
get:
- operationId: api_keys_retrieve
+ operationId: api_v1_api_keys_retrieve
description: Fetch detailed information about a specific API key by its ID.
summary: Retrieve API key details
parameters:
@@ -220,7 +220,7 @@ paths:
$ref: '#/components/schemas/TenantApiKeyResponse'
description: ''
patch:
- operationId: api_keys_partial_update
+ operationId: api_v1_api_keys_partial_update
description: Modify certain fields of an existing API key without affecting
other settings.
summary: Partially update an API key
@@ -257,7 +257,7 @@ paths:
description: ''
/api/v1/api-keys/{id}/revoke:
delete:
- operationId: api_keys_revoke_destroy
+ operationId: api_v1_api_keys_revoke_destroy
description: Revoke an API key by its ID. This action is irreversible and will
prevent the key from being used.
summary: Revoke an API key
@@ -282,7 +282,7 @@ paths:
description: API key was successfully revoked
/api/v1/attack-paths-scans:
get:
- operationId: attack_paths_scans_list
+ operationId: api_v1_attack_paths_scans_list
description: Retrieve Attack Paths scans for the tenant with support for filtering,
ordering, and pagination.
summary: List Attack Paths scans
@@ -352,11 +352,26 @@ paths:
description: Multiple values may be separated by commas.
explode: false
style: form
+ - in: query
+ name: filter[provider_groups]
+ schema:
+ type: string
+ format: uuid
+ - in: query
+ name: filter[provider_groups__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: 91f917e0c3ab97e8
+ x-spec-enum-id: 203afc16daac9b64
enum:
- alibabacloud
- aws
@@ -370,6 +385,7 @@ paths:
- kubernetes
- m365
- mongodbatlas
+ - okta
- openstack
- oraclecloud
- vercel
@@ -389,13 +405,14 @@ paths:
* `image` - Image
* `googleworkspace` - Google Workspace
* `vercel` - Vercel
+ * `okta` - Okta
- in: query
name: filter[provider_type__in]
schema:
type: array
items:
type: string
- x-spec-enum-id: 91f917e0c3ab97e8
+ x-spec-enum-id: 203afc16daac9b64
enum:
- alibabacloud
- aws
@@ -409,6 +426,7 @@ paths:
- kubernetes
- m365
- mongodbatlas
+ - okta
- openstack
- oraclecloud
- vercel
@@ -430,6 +448,7 @@ paths:
* `image` - Image
* `googleworkspace` - Google Workspace
* `vercel` - Vercel
+ * `okta` - Okta
explode: false
style: form
- in: query
@@ -571,7 +590,7 @@ paths:
description: ''
/api/v1/attack-paths-scans/{id}:
get:
- operationId: attack_paths_scans_retrieve
+ operationId: api_v1_attack_paths_scans_retrieve
description: Fetch full details for a specific Attack Paths scan.
summary: Retrieve Attack Paths scan details
parameters:
@@ -631,7 +650,7 @@ paths:
description: ''
/api/v1/attack-paths-scans/{id}/queries:
get:
- operationId: attack_paths_scans_queries_retrieve
+ operationId: api_v1_attack_paths_scans_queries_retrieve
description: Retrieve the catalog of Attack Paths queries available for this
Attack Paths scan.
summary: List Attack Paths queries
@@ -694,7 +713,7 @@ paths:
description: No queries found for the selected provider
/api/v1/attack-paths-scans/{id}/queries/custom:
post:
- operationId: attack_paths_scans_queries_custom_create
+ operationId: api_v1_attack_paths_scans_queries_custom_create
description: Execute a raw openCypher query against the Attack Paths graph.
Results are filtered to the scan's provider and truncated to a maximum node
count.
@@ -741,7 +760,7 @@ paths:
description: Query execution failed due to a database error
/api/v1/attack-paths-scans/{id}/queries/run:
post:
- operationId: attack_paths_scans_queries_run_create
+ operationId: api_v1_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
@@ -788,7 +807,7 @@ paths:
description: Attack Paths query execution failed due to a database error
/api/v1/attack-paths-scans/{id}/schema:
get:
- operationId: attack_paths_scans_schema_retrieve
+ operationId: api_v1_attack_paths_scans_schema_retrieve
description: Return the cartography provider, version, and links to the schema
documentation for the cloud provider associated with this Attack Paths scan.
summary: Retrieve cartography schema metadata
@@ -835,9 +854,11 @@ paths:
description: Unable to retrieve cartography schema due to a database error
/api/v1/compliance-overviews:
get:
- operationId: compliance_overviews_list
- description: Retrieve an overview of all the compliance in a given scan.
- summary: List compliance overviews for a scan
+ operationId: api_v1_compliance_overviews_list
+ description: Retrieve compliance overview data for a scan. When provider filters
+ are provided, the endpoint uses the latest completed scan for each matching
+ provider.
+ summary: List compliance overviews
parameters:
- in: query
name: fields[compliance-overviews]
@@ -896,6 +917,120 @@ paths:
schema:
type: string
format: date-time
+ - in: query
+ name: filter[provider_groups]
+ schema:
+ type: string
+ format: uuid
+ - in: query
+ name: filter[provider_groups__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_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: 203afc16daac9b64
+ enum:
+ - alibabacloud
+ - aws
+ - azure
+ - cloudflare
+ - gcp
+ - github
+ - googleworkspace
+ - iac
+ - image
+ - kubernetes
+ - m365
+ - mongodbatlas
+ - okta
+ - openstack
+ - oraclecloud
+ - vercel
+ 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
+ * `cloudflare` - Cloudflare
+ * `openstack` - OpenStack
+ * `image` - Image
+ * `googleworkspace` - Google Workspace
+ * `vercel` - Vercel
+ * `okta` - Okta
+ - in: query
+ name: filter[provider_type__in]
+ schema:
+ type: array
+ items:
+ type: string
+ x-spec-enum-id: 203afc16daac9b64
+ enum:
+ - alibabacloud
+ - aws
+ - azure
+ - cloudflare
+ - gcp
+ - github
+ - googleworkspace
+ - iac
+ - image
+ - kubernetes
+ - m365
+ - mongodbatlas
+ - okta
+ - openstack
+ - oraclecloud
+ - vercel
+ 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
+ * `cloudflare` - Cloudflare
+ * `openstack` - OpenStack
+ * `image` - Image
+ * `googleworkspace` - Google Workspace
+ * `vercel` - Vercel
+ * `okta` - Okta
+ explode: false
+ style: form
- in: query
name: filter[region]
schema:
@@ -918,8 +1053,7 @@ paths:
schema:
type: string
format: uuid
- description: Related scan ID.
- required: true
+ description: Related scan ID. Required unless a provider filter is provided.
- name: filter[search]
required: false
in: query
@@ -994,7 +1128,7 @@ paths:
description: Compliance overviews generation task failed
/api/v1/compliance-overviews/attributes:
get:
- operationId: compliance_overviews_attributes_retrieve
+ operationId: api_v1_compliance_overviews_attributes_retrieve
description: Retrieve detailed attribute information for all requirements in
a specific compliance framework along with the associated check IDs for each
requirement.
@@ -1024,6 +1158,14 @@ paths:
type: string
description: Compliance framework ID to get attributes for.
required: true
+ - in: query
+ name: filter[scan_id]
+ schema:
+ type: string
+ format: uuid
+ description: Scan ID used to resolve the provider for multi-provider universal
+ frameworks (e.g. CSA CCM), so the returned check IDs match the scan's provider.
+ When omitted, the first provider that declares the framework is used.
tags:
- Compliance Overview
security:
@@ -1037,9 +1179,10 @@ paths:
description: Compliance attributes obtained successfully
/api/v1/compliance-overviews/metadata:
get:
- operationId: compliance_overviews_metadata_retrieve
- description: Fetch unique metadata values from a set of compliance overviews.
- This is useful for dynamic filtering.
+ operationId: api_v1_compliance_overviews_metadata_retrieve
+ description: Fetch unique metadata values from compliance overviews. This is
+ useful for dynamic filtering. When provider filters are provided, metadata
+ is computed from the latest completed scan for each matching provider.
summary: Retrieve metadata values from compliance overviews
parameters:
- in: query
@@ -1053,13 +1196,209 @@ 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[compliance_id]
+ schema:
+ type: string
+ - in: query
+ name: filter[compliance_id__icontains]
+ schema:
+ type: string
+ - in: query
+ name: filter[framework]
+ schema:
+ type: string
+ - in: query
+ name: filter[framework__icontains]
+ schema:
+ type: string
+ - in: query
+ name: filter[framework__iexact]
+ schema:
+ type: string
+ - in: query
+ name: filter[inserted_at]
+ schema:
+ type: string
+ format: date
+ - in: query
+ name: filter[inserted_at__date]
+ schema:
+ type: string
+ format: date
+ - in: query
+ name: filter[inserted_at__gte]
+ schema:
+ type: string
+ format: date-time
+ - in: query
+ name: filter[inserted_at__lte]
+ schema:
+ type: string
+ format: date-time
+ - in: query
+ name: filter[provider_groups]
+ schema:
+ type: string
+ format: uuid
+ - in: query
+ name: filter[provider_groups__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_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: 203afc16daac9b64
+ enum:
+ - alibabacloud
+ - aws
+ - azure
+ - cloudflare
+ - gcp
+ - github
+ - googleworkspace
+ - iac
+ - image
+ - kubernetes
+ - m365
+ - mongodbatlas
+ - okta
+ - openstack
+ - oraclecloud
+ - vercel
+ 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
+ * `cloudflare` - Cloudflare
+ * `openstack` - OpenStack
+ * `image` - Image
+ * `googleworkspace` - Google Workspace
+ * `vercel` - Vercel
+ * `okta` - Okta
+ - in: query
+ name: filter[provider_type__in]
+ schema:
+ type: array
+ items:
+ type: string
+ x-spec-enum-id: 203afc16daac9b64
+ enum:
+ - alibabacloud
+ - aws
+ - azure
+ - cloudflare
+ - gcp
+ - github
+ - googleworkspace
+ - iac
+ - image
+ - kubernetes
+ - m365
+ - mongodbatlas
+ - okta
+ - openstack
+ - oraclecloud
+ - vercel
+ 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
+ * `cloudflare` - Cloudflare
+ * `openstack` - OpenStack
+ * `image` - Image
+ * `googleworkspace` - Google Workspace
+ * `vercel` - Vercel
+ * `okta` - Okta
+ explode: false
+ style: form
+ - in: query
+ name: filter[region]
+ schema:
+ type: string
+ - in: query
+ name: filter[region__icontains]
+ schema:
+ type: string
+ - in: query
+ name: filter[region__in]
+ schema:
+ type: array
+ items:
+ type: string
+ description: Multiple values may be separated by commas.
+ explode: false
+ style: form
- in: query
name: filter[scan_id]
schema:
type: string
format: uuid
- description: Related scan ID.
- required: true
+ description: Related scan ID. Required unless a provider filter is provided.
+ - name: filter[search]
+ required: false
+ in: query
+ description: A search term.
+ schema:
+ type: string
+ - in: query
+ name: filter[version]
+ schema:
+ type: string
+ - in: query
+ name: filter[version__icontains]
+ schema:
+ type: string
+ - 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:
+ - compliance_id
+ - -compliance_id
+ explode: false
tags:
- Compliance Overview
security:
@@ -1096,11 +1435,12 @@ paths:
description: Compliance overviews generation task failed
/api/v1/compliance-overviews/requirements:
get:
- operationId: compliance_overviews_requirements_retrieve
- description: Retrieve a detailed overview of compliance requirements in a given
- scan, grouped by compliance framework. This endpoint provides requirement-level
- details and aggregates status across regions.
- summary: List compliance requirements overview for a scan
+ operationId: api_v1_compliance_overviews_requirements_retrieve
+ description: Retrieve a detailed overview of compliance requirements, grouped
+ by compliance framework. This endpoint provides requirement-level details
+ and aggregates status across regions. When provider filters are provided,
+ the endpoint uses the latest completed scan for each matching provider.
+ summary: List compliance requirements overview
parameters:
- in: query
name: fields[compliance-requirements-details]
@@ -1159,6 +1499,120 @@ paths:
schema:
type: string
format: date-time
+ - in: query
+ name: filter[provider_groups]
+ schema:
+ type: string
+ format: uuid
+ - in: query
+ name: filter[provider_groups__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_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: 203afc16daac9b64
+ enum:
+ - alibabacloud
+ - aws
+ - azure
+ - cloudflare
+ - gcp
+ - github
+ - googleworkspace
+ - iac
+ - image
+ - kubernetes
+ - m365
+ - mongodbatlas
+ - okta
+ - openstack
+ - oraclecloud
+ - vercel
+ 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
+ * `cloudflare` - Cloudflare
+ * `openstack` - OpenStack
+ * `image` - Image
+ * `googleworkspace` - Google Workspace
+ * `vercel` - Vercel
+ * `okta` - Okta
+ - in: query
+ name: filter[provider_type__in]
+ schema:
+ type: array
+ items:
+ type: string
+ x-spec-enum-id: 203afc16daac9b64
+ enum:
+ - alibabacloud
+ - aws
+ - azure
+ - cloudflare
+ - gcp
+ - github
+ - googleworkspace
+ - iac
+ - image
+ - kubernetes
+ - m365
+ - mongodbatlas
+ - okta
+ - openstack
+ - oraclecloud
+ - vercel
+ 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
+ * `cloudflare` - Cloudflare
+ * `openstack` - OpenStack
+ * `image` - Image
+ * `googleworkspace` - Google Workspace
+ * `vercel` - Vercel
+ * `okta` - Okta
+ explode: false
+ style: form
- in: query
name: filter[region]
schema:
@@ -1181,8 +1635,7 @@ paths:
schema:
type: string
format: uuid
- description: Related scan ID.
- required: true
+ description: Related scan ID. Required unless a provider filter is provided.
- name: filter[search]
required: false
in: query
@@ -1245,7 +1698,7 @@ paths:
description: Compliance overviews generation task failed
/api/v1/finding-groups:
get:
- operationId: finding_groups_list
+ operationId: api_v1_finding_groups_list
description: "\n Retrieve aggregated findings grouped by check_id.\n\n\
\ Each group shows:\n - Aggregated status (FAIL if any non-muted\
\ failure)\n - Maximum severity across all findings\n - Resource\
@@ -1418,6 +1871,21 @@ paths:
description: Multiple values may be separated by commas.
explode: false
style: form
+ - in: query
+ name: filter[provider_groups]
+ schema:
+ type: string
+ format: uuid
+ - in: query
+ name: filter[provider_groups__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_id]
schema:
@@ -1450,6 +1918,7 @@ paths:
- kubernetes
- m365
- mongodbatlas
+ - okta
- openstack
- oraclecloud
- vercel
@@ -1469,6 +1938,7 @@ paths:
* `image` - Image
* `googleworkspace` - Google Workspace
* `vercel` - Vercel
+ * `okta` - Okta
- in: query
name: filter[provider_type__in]
schema:
@@ -1488,6 +1958,7 @@ paths:
- kubernetes
- m365
- mongodbatlas
+ - okta
- openstack
- oraclecloud
- vercel
@@ -1509,6 +1980,7 @@ paths:
* `image` - Image
* `googleworkspace` - Google Workspace
* `vercel` - Vercel
+ * `okta` - Okta
explode: false
style: form
- in: query
@@ -1791,7 +2263,7 @@ paths:
description: ''
/api/v1/finding-groups/{id}/resources:
get:
- operationId: finding_groups_resources_retrieve
+ operationId: api_v1_finding_groups_resources_retrieve
description: "\n Retrieve resources affected by a specific check (finding\
\ group).\n\n Returns individual resources with their current status,\
\ severity,\n and timing information including how long they have been\
@@ -1962,6 +2434,21 @@ paths:
description: Multiple values may be separated by commas.
explode: false
style: form
+ - in: query
+ name: filter[provider_groups]
+ schema:
+ type: string
+ format: uuid
+ - in: query
+ name: filter[provider_groups__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_id]
schema:
@@ -1994,6 +2481,7 @@ paths:
- kubernetes
- m365
- mongodbatlas
+ - okta
- openstack
- oraclecloud
- vercel
@@ -2013,6 +2501,7 @@ paths:
* `image` - Image
* `googleworkspace` - Google Workspace
* `vercel` - Vercel
+ * `okta` - Okta
- in: query
name: filter[provider_type__in]
schema:
@@ -2032,6 +2521,7 @@ paths:
- kubernetes
- m365
- mongodbatlas
+ - okta
- openstack
- oraclecloud
- vercel
@@ -2053,6 +2543,7 @@ paths:
* `image` - Image
* `googleworkspace` - Google Workspace
* `vercel` - Vercel
+ * `okta` - Okta
explode: false
style: form
- in: query
@@ -2330,12 +2821,12 @@ paths:
description: ''
/api/v1/finding-groups/latest:
get:
- operationId: finding_groups_latest_retrieve
+ operationId: api_v1_finding_groups_latest_retrieve
description: "\n Retrieve the latest available state for each finding\
\ group (check_id).\n\n This endpoint returns finding groups without\
\ requiring date filters,\n automatically using the latest available\
- \ data per check_id.\n All other filters (provider_id, provider_type,\
- \ check_id) are still supported.\n "
+ \ data per check_id.\n Provider, provider group, check, and computed\
+ \ filters are still supported.\n "
summary: List latest finding groups
parameters:
- in: query
@@ -2382,6 +2873,471 @@ 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[category]
+ schema:
+ type: string
+ - in: query
+ name: filter[category__in]
+ schema:
+ type: array
+ items:
+ type: string
+ description: Multiple values may be separated by commas.
+ explode: false
+ style: form
+ - in: query
+ name: filter[check_id]
+ schema:
+ type: string
+ - in: query
+ name: filter[check_id__icontains]
+ schema:
+ type: string
+ - in: query
+ name: filter[check_id__in]
+ schema:
+ type: array
+ items:
+ type: string
+ description: Multiple values may be separated by commas.
+ explode: false
+ style: form
+ - in: query
+ name: filter[check_title__icontains]
+ schema:
+ type: string
+ - in: query
+ name: filter[delta]
+ schema:
+ type: string
+ enum:
+ - changed
+ - new
+ description: |-
+ * `new` - New
+ * `changed` - Changed
+ - in: query
+ name: filter[impact]
+ schema:
+ type: string
+ enum:
+ - critical
+ - high
+ - informational
+ - low
+ - medium
+ description: |-
+ * `critical` - Critical
+ * `high` - High
+ * `medium` - Medium
+ * `low` - Low
+ * `informational` - Informational
+ - in: query
+ name: filter[muted]
+ schema:
+ type: boolean
+ description: If this filter is not provided, muted and non-muted findings
+ will be returned.
+ - 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_groups]
+ schema:
+ type: string
+ format: uuid
+ - in: query
+ name: filter[provider_groups__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_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
+ - cloudflare
+ - gcp
+ - github
+ - googleworkspace
+ - iac
+ - image
+ - kubernetes
+ - m365
+ - mongodbatlas
+ - okta
+ - openstack
+ - oraclecloud
+ - vercel
+ 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
+ * `cloudflare` - Cloudflare
+ * `openstack` - OpenStack
+ * `image` - Image
+ * `googleworkspace` - Google Workspace
+ * `vercel` - Vercel
+ * `okta` - Okta
+ - in: query
+ name: filter[provider_type__in]
+ schema:
+ type: array
+ items:
+ type: string
+ enum:
+ - alibabacloud
+ - aws
+ - azure
+ - cloudflare
+ - gcp
+ - github
+ - googleworkspace
+ - iac
+ - image
+ - kubernetes
+ - m365
+ - mongodbatlas
+ - okta
+ - openstack
+ - oraclecloud
+ - vercel
+ 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
+ * `cloudflare` - Cloudflare
+ * `openstack` - OpenStack
+ * `image` - Image
+ * `googleworkspace` - Google Workspace
+ * `vercel` - Vercel
+ * `okta` - Okta
+ 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[region]
+ schema:
+ type: string
+ - in: query
+ name: filter[region__icontains]
+ schema:
+ type: string
+ - in: query
+ name: filter[region__in]
+ schema:
+ type: array
+ items:
+ type: string
+ 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:
+ type: string
+ - in: query
+ name: filter[resource_name__icontains]
+ schema:
+ type: string
+ - in: query
+ name: filter[resource_name__in]
+ schema:
+ type: array
+ items:
+ type: string
+ description: Multiple values may be separated by commas.
+ explode: false
+ style: form
+ - in: query
+ name: filter[resource_type]
+ schema:
+ type: string
+ - in: query
+ name: filter[resource_type__icontains]
+ schema:
+ type: string
+ - in: query
+ name: filter[resource_type__in]
+ schema:
+ type: array
+ items:
+ type: string
+ description: Multiple values may be separated by commas.
+ explode: false
+ style: form
+ - in: query
+ name: filter[resource_uid]
+ schema:
+ type: string
+ - in: query
+ name: filter[resource_uid__icontains]
+ schema:
+ type: string
+ - in: query
+ name: filter[resource_uid__in]
+ schema:
+ type: array
+ items:
+ type: string
+ description: Multiple values may be separated by commas.
+ explode: false
+ style: form
+ - in: query
+ name: filter[resources]
+ schema:
+ type: array
+ items:
+ type: string
+ format: uuid
+ 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
+ - in: query
+ name: filter[service]
+ schema:
+ type: string
+ - in: query
+ name: filter[service__icontains]
+ schema:
+ type: string
+ - in: query
+ name: filter[service__in]
+ schema:
+ type: array
+ items:
+ type: string
+ description: Multiple values may be separated by commas.
+ explode: false
+ style: form
+ - in: query
+ name: filter[severity]
+ schema:
+ type: string
+ enum:
+ - critical
+ - high
+ - informational
+ - low
+ - medium
+ description: |-
+ * `critical` - Critical
+ * `high` - High
+ * `medium` - Medium
+ * `low` - Low
+ * `informational` - Informational
+ - in: query
+ name: filter[status]
+ schema:
+ type: string
+ enum:
+ - FAIL
+ - MANUAL
+ - PASS
+ description: |-
+ * `FAIL` - Fail
+ * `PASS` - Pass
+ * `MANUAL` - Manual
+ - in: query
+ name: filter[uid]
+ schema:
+ type: string
+ - in: query
+ name: filter[updated_at]
+ schema:
+ type: string
+ format: date
+ - 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
+ - check_id
+ - -check_id
+ - check_title
+ - -check_title
+ - check_description
+ - -check_description
+ - severity
+ - -severity
+ - status
+ - -status
+ - muted
+ - -muted
+ - impacted_providers
+ - -impacted_providers
+ - resources_fail
+ - -resources_fail
+ - resources_total
+ - -resources_total
+ - pass_count
+ - -pass_count
+ - fail_count
+ - -fail_count
+ - manual_count
+ - -manual_count
+ - pass_muted_count
+ - -pass_muted_count
+ - fail_muted_count
+ - -fail_muted_count
+ - manual_muted_count
+ - -manual_muted_count
+ - muted_count
+ - -muted_count
+ - new_count
+ - -new_count
+ - changed_count
+ - -changed_count
+ - new_fail_count
+ - -new_fail_count
+ - new_fail_muted_count
+ - -new_fail_muted_count
+ - new_pass_count
+ - -new_pass_count
+ - new_pass_muted_count
+ - -new_pass_muted_count
+ - new_manual_count
+ - -new_manual_count
+ - new_manual_muted_count
+ - -new_manual_muted_count
+ - changed_fail_count
+ - -changed_fail_count
+ - changed_fail_muted_count
+ - -changed_fail_muted_count
+ - changed_pass_count
+ - -changed_pass_count
+ - changed_pass_muted_count
+ - -changed_pass_muted_count
+ - changed_manual_count
+ - -changed_manual_count
+ - changed_manual_muted_count
+ - -changed_manual_muted_count
+ - first_seen_at
+ - -first_seen_at
+ - last_seen_at
+ - -last_seen_at
+ - failing_since
+ - -failing_since
+ explode: false
tags:
- Finding Groups
security:
@@ -2395,7 +3351,7 @@ paths:
description: ''
/api/v1/finding-groups/latest/{check_id}/resources:
get:
- operationId: finding_groups_latest_resources_retrieve
+ operationId: api_v1_finding_groups_latest_resources_retrieve
description: "\n Retrieve resources affected by a specific check (finding\
\ group) from the\n latest completed scan for each provider.\n\n \
\ Returns individual resources with their current status, severity,\n\
@@ -2549,6 +3505,21 @@ paths:
description: Multiple values may be separated by commas.
explode: false
style: form
+ - in: query
+ name: filter[provider_groups]
+ schema:
+ type: string
+ format: uuid
+ - in: query
+ name: filter[provider_groups__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_id]
schema:
@@ -2581,6 +3552,7 @@ paths:
- kubernetes
- m365
- mongodbatlas
+ - okta
- openstack
- oraclecloud
- vercel
@@ -2600,6 +3572,7 @@ paths:
* `image` - Image
* `googleworkspace` - Google Workspace
* `vercel` - Vercel
+ * `okta` - Okta
- in: query
name: filter[provider_type__in]
schema:
@@ -2619,6 +3592,7 @@ paths:
- kubernetes
- m365
- mongodbatlas
+ - okta
- openstack
- oraclecloud
- vercel
@@ -2640,6 +3614,7 @@ paths:
* `image` - Image
* `googleworkspace` - Google Workspace
* `vercel` - Vercel
+ * `okta` - Okta
explode: false
style: form
- in: query
@@ -2910,7 +3885,7 @@ paths:
description: ''
/api/v1/findings:
get:
- operationId: findings_list
+ operationId: api_v1_findings_list
description: Retrieve a list of all findings with options for filtering by various
criteria.
summary: List all findings
@@ -3052,13 +4027,11 @@ paths:
name: filter[inserted_at__gte]
schema:
type: string
- format: date
description: Maximum date range is 7 days.
- in: query
name: filter[inserted_at__lte]
schema:
type: string
- format: date
description: Maximum date range is 7 days.
- in: query
name: filter[muted]
@@ -3098,6 +4071,21 @@ paths:
description: Multiple values may be separated by commas.
explode: false
style: form
+ - in: query
+ name: filter[provider_groups]
+ schema:
+ type: string
+ format: uuid
+ - in: query
+ name: filter[provider_groups__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_id]
schema:
@@ -3117,7 +4105,7 @@ paths:
name: filter[provider_type]
schema:
type: string
- x-spec-enum-id: 91f917e0c3ab97e8
+ x-spec-enum-id: 203afc16daac9b64
enum:
- alibabacloud
- aws
@@ -3131,6 +4119,7 @@ paths:
- kubernetes
- m365
- mongodbatlas
+ - okta
- openstack
- oraclecloud
- vercel
@@ -3150,13 +4139,14 @@ paths:
* `image` - Image
* `googleworkspace` - Google Workspace
* `vercel` - Vercel
+ * `okta` - Okta
- in: query
name: filter[provider_type__in]
schema:
type: array
items:
type: string
- x-spec-enum-id: 91f917e0c3ab97e8
+ x-spec-enum-id: 203afc16daac9b64
enum:
- alibabacloud
- aws
@@ -3170,6 +4160,7 @@ paths:
- kubernetes
- m365
- mongodbatlas
+ - okta
- openstack
- oraclecloud
- vercel
@@ -3191,6 +4182,7 @@ paths:
* `image` - Image
* `googleworkspace` - Google Workspace
* `vercel` - Vercel
+ * `okta` - Okta
explode: false
style: form
- in: query
@@ -3402,6 +4394,10 @@ paths:
style: form
- in: query
name: filter[updated_at]
+ schema:
+ type: string
+ - in: query
+ name: filter[updated_at__date]
schema:
type: string
format: date
@@ -3409,12 +4405,12 @@ paths:
name: filter[updated_at__gte]
schema:
type: string
- format: date-time
+ description: Maximum date range is 7 days.
- in: query
name: filter[updated_at__lte]
schema:
type: string
- format: date-time
+ description: Maximum date range is 7 days.
- in: query
name: include
schema:
@@ -3472,7 +4468,7 @@ paths:
description: ''
/api/v1/findings/{id}:
get:
- operationId: findings_retrieve
+ operationId: api_v1_findings_retrieve
description: Fetch detailed information about a specific finding by its ID.
summary: Retrieve data from a specific finding
parameters:
@@ -3536,7 +4532,7 @@ paths:
description: ''
/api/v1/findings/findings_services_regions:
get:
- operationId: findings_findings_services_regions_retrieve
+ operationId: api_v1_findings_findings_services_regions_retrieve
description: Fetch services and regions affected in findings.
summary: Retrieve the services and regions that are impacted by findings
parameters:
@@ -3648,7 +4644,6 @@ paths:
name: filter[inserted_at]
schema:
type: string
- format: date
- in: query
name: filter[inserted_at__date]
schema:
@@ -3658,13 +4653,11 @@ paths:
name: filter[inserted_at__gte]
schema:
type: string
- format: date
description: Maximum date range is 7 days.
- in: query
name: filter[inserted_at__lte]
schema:
type: string
- format: date
description: Maximum date range is 7 days.
- in: query
name: filter[muted]
@@ -3704,6 +4697,21 @@ paths:
description: Multiple values may be separated by commas.
explode: false
style: form
+ - in: query
+ name: filter[provider_groups]
+ schema:
+ type: string
+ format: uuid
+ - in: query
+ name: filter[provider_groups__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_id]
schema:
@@ -3723,7 +4731,7 @@ paths:
name: filter[provider_type]
schema:
type: string
- x-spec-enum-id: 91f917e0c3ab97e8
+ x-spec-enum-id: 203afc16daac9b64
enum:
- alibabacloud
- aws
@@ -3737,6 +4745,7 @@ paths:
- kubernetes
- m365
- mongodbatlas
+ - okta
- openstack
- oraclecloud
- vercel
@@ -3756,13 +4765,14 @@ paths:
* `image` - Image
* `googleworkspace` - Google Workspace
* `vercel` - Vercel
+ * `okta` - Okta
- in: query
name: filter[provider_type__in]
schema:
type: array
items:
type: string
- x-spec-enum-id: 91f917e0c3ab97e8
+ x-spec-enum-id: 203afc16daac9b64
enum:
- alibabacloud
- aws
@@ -3776,6 +4786,7 @@ paths:
- kubernetes
- m365
- mongodbatlas
+ - okta
- openstack
- oraclecloud
- vercel
@@ -3797,6 +4808,7 @@ paths:
* `image` - Image
* `googleworkspace` - Google Workspace
* `vercel` - Vercel
+ * `okta` - Okta
explode: false
style: form
- in: query
@@ -4008,6 +5020,10 @@ paths:
style: form
- in: query
name: filter[updated_at]
+ schema:
+ type: string
+ - in: query
+ name: filter[updated_at__date]
schema:
type: string
format: date
@@ -4015,12 +5031,12 @@ paths:
name: filter[updated_at__gte]
schema:
type: string
- format: date-time
+ description: Maximum date range is 7 days.
- in: query
name: filter[updated_at__lte]
schema:
type: string
- format: date-time
+ description: Maximum date range is 7 days.
- name: sort
required: false
in: query
@@ -4055,7 +5071,7 @@ paths:
description: ''
/api/v1/findings/latest:
get:
- operationId: findings_latest_retrieve
+ operationId: api_v1_findings_latest_retrieve
description: Retrieve a list of the latest findings from the latest scans for
each provider with options for filtering by various criteria.
summary: List the latest findings
@@ -4218,6 +5234,21 @@ paths:
description: Multiple values may be separated by commas.
explode: false
style: form
+ - in: query
+ name: filter[provider_groups]
+ schema:
+ type: string
+ format: uuid
+ - in: query
+ name: filter[provider_groups__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_id]
schema:
@@ -4237,7 +5268,7 @@ paths:
name: filter[provider_type]
schema:
type: string
- x-spec-enum-id: 91f917e0c3ab97e8
+ x-spec-enum-id: 203afc16daac9b64
enum:
- alibabacloud
- aws
@@ -4251,6 +5282,7 @@ paths:
- kubernetes
- m365
- mongodbatlas
+ - okta
- openstack
- oraclecloud
- vercel
@@ -4270,13 +5302,14 @@ paths:
* `image` - Image
* `googleworkspace` - Google Workspace
* `vercel` - Vercel
+ * `okta` - Okta
- in: query
name: filter[provider_type__in]
schema:
type: array
items:
type: string
- x-spec-enum-id: 91f917e0c3ab97e8
+ x-spec-enum-id: 203afc16daac9b64
enum:
- alibabacloud
- aws
@@ -4290,6 +5323,7 @@ paths:
- kubernetes
- m365
- mongodbatlas
+ - okta
- openstack
- oraclecloud
- vercel
@@ -4311,6 +5345,7 @@ paths:
* `image` - Image
* `googleworkspace` - Google Workspace
* `vercel` - Vercel
+ * `okta` - Okta
explode: false
style: form
- in: query
@@ -4555,7 +5590,7 @@ paths:
description: ''
/api/v1/findings/metadata:
get:
- operationId: findings_metadata_retrieve
+ operationId: api_v1_findings_metadata_retrieve
description: Fetch unique metadata values from a set of findings. This is useful
for dynamic filtering.
summary: Retrieve metadata values from findings
@@ -4730,6 +5765,21 @@ paths:
description: Multiple values may be separated by commas.
explode: false
style: form
+ - in: query
+ name: filter[provider_groups]
+ schema:
+ type: string
+ format: uuid
+ - in: query
+ name: filter[provider_groups__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_id]
schema:
@@ -4749,7 +5799,7 @@ paths:
name: filter[provider_type]
schema:
type: string
- x-spec-enum-id: 91f917e0c3ab97e8
+ x-spec-enum-id: 203afc16daac9b64
enum:
- alibabacloud
- aws
@@ -4763,6 +5813,7 @@ paths:
- kubernetes
- m365
- mongodbatlas
+ - okta
- openstack
- oraclecloud
- vercel
@@ -4782,13 +5833,14 @@ paths:
* `image` - Image
* `googleworkspace` - Google Workspace
* `vercel` - Vercel
+ * `okta` - Okta
- in: query
name: filter[provider_type__in]
schema:
type: array
items:
type: string
- x-spec-enum-id: 91f917e0c3ab97e8
+ x-spec-enum-id: 203afc16daac9b64
enum:
- alibabacloud
- aws
@@ -4802,6 +5854,7 @@ paths:
- kubernetes
- m365
- mongodbatlas
+ - okta
- openstack
- oraclecloud
- vercel
@@ -4823,6 +5876,7 @@ paths:
* `image` - Image
* `googleworkspace` - Google Workspace
* `vercel` - Vercel
+ * `okta` - Okta
explode: false
style: form
- in: query
@@ -5080,7 +6134,7 @@ paths:
description: ''
/api/v1/findings/metadata/latest:
get:
- operationId: findings_metadata_latest_retrieve
+ operationId: api_v1_findings_metadata_latest_retrieve
description: Fetch unique metadata values from a set of findings from the latest
scans for each provider. This is useful for dynamic filtering.
summary: Retrieve metadata values from the latest findings
@@ -5230,6 +6284,21 @@ paths:
description: Multiple values may be separated by commas.
explode: false
style: form
+ - in: query
+ name: filter[provider_groups]
+ schema:
+ type: string
+ format: uuid
+ - in: query
+ name: filter[provider_groups__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_id]
schema:
@@ -5249,7 +6318,7 @@ paths:
name: filter[provider_type]
schema:
type: string
- x-spec-enum-id: 91f917e0c3ab97e8
+ x-spec-enum-id: 203afc16daac9b64
enum:
- alibabacloud
- aws
@@ -5263,6 +6332,7 @@ paths:
- kubernetes
- m365
- mongodbatlas
+ - okta
- openstack
- oraclecloud
- vercel
@@ -5282,13 +6352,14 @@ paths:
* `image` - Image
* `googleworkspace` - Google Workspace
* `vercel` - Vercel
+ * `okta` - Okta
- in: query
name: filter[provider_type__in]
schema:
type: array
items:
type: string
- x-spec-enum-id: 91f917e0c3ab97e8
+ x-spec-enum-id: 203afc16daac9b64
enum:
- alibabacloud
- aws
@@ -5302,6 +6373,7 @@ paths:
- kubernetes
- m365
- mongodbatlas
+ - okta
- openstack
- oraclecloud
- vercel
@@ -5323,6 +6395,7 @@ paths:
* `image` - Image
* `googleworkspace` - Google Workspace
* `vercel` - Vercel
+ * `okta` - Okta
explode: false
style: form
- in: query
@@ -5555,7 +6628,7 @@ paths:
description: ''
/api/v1/integrations:
get:
- operationId: integrations_list
+ operationId: api_v1_integrations_list
description: Retrieve a list of all configured integrations with options for
filtering by various criteria.
summary: List all integrations
@@ -5706,7 +6779,7 @@ paths:
$ref: '#/components/schemas/PaginatedIntegrationList'
description: ''
post:
- operationId: integrations_create
+ operationId: api_v1_integrations_create
description: Register a new integration with the system, providing necessary
configuration details.
summary: Create a new integration
@@ -5735,7 +6808,7 @@ paths:
description: ''
/api/v1/integrations/{integration_pk}/jira/dispatches:
post:
- operationId: integrations_jira_dispatches_create
+ operationId: api_v1_integrations_jira_dispatches_create
description: |-
Send a set of filtered findings to the given integration. At least one finding filter must be provided.
@@ -5808,7 +6881,7 @@ paths:
description: ''
/api/v1/integrations/{integration_pk}/jira/issue_types:
get:
- operationId: integrations_jira_issue_types_retrieve
+ operationId: api_v1_integrations_jira_issue_types_retrieve
description: Fetch the available issue types from Jira for a given project key
and update the integration configuration.
summary: Get available issue types for a Jira project
@@ -5849,7 +6922,7 @@ paths:
description: ''
/api/v1/integrations/{id}:
get:
- operationId: integrations_retrieve
+ operationId: api_v1_integrations_retrieve
description: Fetch detailed information about a specific integration by its
ID.
summary: Retrieve integration details
@@ -5903,7 +6976,7 @@ paths:
$ref: '#/components/schemas/IntegrationResponse'
description: ''
patch:
- operationId: integrations_partial_update
+ operationId: api_v1_integrations_partial_update
description: Modify certain fields of an existing integration without affecting
other settings.
summary: Partially update an integration
@@ -5939,7 +7012,7 @@ paths:
$ref: '#/components/schemas/IntegrationUpdateResponse'
description: ''
delete:
- operationId: integrations_destroy
+ operationId: api_v1_integrations_destroy
description: Remove an integration from the system by its ID.
summary: Delete an integration
parameters:
@@ -5959,7 +7032,7 @@ paths:
description: No response body
/api/v1/integrations/{id}/connection:
post:
- operationId: integrations_connection_create
+ operationId: api_v1_integrations_connection_create
description: Try to verify integration connection
summary: Check integration connection
parameters:
@@ -5998,7 +7071,7 @@ paths:
description: ''
/api/v1/invitations/accept:
post:
- operationId: invitations_accept_create
+ operationId: api_v1_invitations_accept_create
description: Accept an invitation to an existing tenant. This invitation cannot
be expired and the emails must match.
summary: Accept an invitation
@@ -6027,7 +7100,7 @@ paths:
description: ''
/api/v1/lighthouse-configurations:
get:
- operationId: lighthouse_configurations_list
+ operationId: api_v1_lighthouse_configurations_list
description: Retrieve a list of all Lighthouse AI configurations.
summary: List all Lighthouse AI configurations
parameters:
@@ -6100,7 +7173,7 @@ paths:
$ref: '#/components/schemas/PaginatedLighthouseConfigList'
description: ''
post:
- operationId: lighthouse_configurations_create
+ operationId: api_v1_lighthouse_configurations_create
description: Create a new Lighthouse AI configuration with the specified details.
summary: Create a new Lighthouse AI configuration
tags:
@@ -6129,7 +7202,7 @@ paths:
description: ''
/api/v1/lighthouse-configurations/{id}:
patch:
- operationId: lighthouse_configurations_partial_update
+ operationId: api_v1_lighthouse_configurations_partial_update
description: Update certain fields of an existing Lighthouse AI configuration.
summary: Partially update a Lighthouse AI configuration
parameters:
@@ -6163,7 +7236,7 @@ paths:
$ref: '#/components/schemas/LighthouseConfigUpdateResponse'
description: ''
delete:
- operationId: lighthouse_configurations_destroy
+ operationId: api_v1_lighthouse_configurations_destroy
description: Remove a Lighthouse AI configuration by its ID.
summary: Delete a Lighthouse AI configuration
parameters:
@@ -6182,7 +7255,7 @@ paths:
description: No response body
/api/v1/lighthouse-configurations/{id}/connection:
post:
- operationId: lighthouse_configurations_connection_create
+ operationId: api_v1_lighthouse_configurations_connection_create
description: Verify the connection to the OpenAI API for a specific Lighthouse
AI configuration.
summary: Check the connection to the OpenAI API
@@ -6221,7 +7294,7 @@ paths:
description: ''
/api/v1/lighthouse/configuration:
get:
- operationId: lighthouse_configuration_list
+ operationId: api_v1_lighthouse_configuration_list
description: Retrieve current tenant-level Lighthouse AI settings. Returns a
single configuration object.
summary: Get Lighthouse AI Tenant config
@@ -6296,7 +7369,7 @@ paths:
$ref: '#/components/schemas/PaginatedLighthouseTenantConfigList'
description: ''
patch:
- operationId: lighthouse_configuration_partial_update
+ operationId: api_v1_lighthouse_configuration_partial_update
description: Update tenant-level settings. Validates that the default provider
is configured and active and that default model IDs exist for the chosen providers.
Auto-creates configuration if it doesn't exist.
@@ -6326,7 +7399,7 @@ paths:
description: ''
/api/v1/lighthouse/models:
get:
- operationId: lighthouse_models_list
+ operationId: api_v1_lighthouse_models_list
description: List available LLM models per configured provider for the current
tenant.
summary: List all LLM models
@@ -6456,7 +7529,7 @@ paths:
description: ''
/api/v1/lighthouse/models/{id}:
get:
- operationId: lighthouse_models_retrieve
+ operationId: api_v1_lighthouse_models_retrieve
description: Get details for a specific LLM model.
summary: Retrieve LLM model details
parameters:
@@ -6497,7 +7570,7 @@ paths:
description: ''
/api/v1/lighthouse/providers:
get:
- operationId: lighthouse_providers_list
+ operationId: api_v1_lighthouse_providers_list
description: Retrieve all LLM provider configurations for the current tenant
summary: List all LLM provider configurations
parameters:
@@ -6612,7 +7685,7 @@ paths:
$ref: '#/components/schemas/PaginatedLighthouseProviderConfigList'
description: ''
post:
- operationId: lighthouse_providers_create
+ operationId: api_v1_lighthouse_providers_create
description: Create a per-tenant configuration for an LLM provider. Only one
configuration per provider type is allowed per tenant.
summary: Create LLM provider configuration
@@ -6641,7 +7714,7 @@ paths:
description: ''
/api/v1/lighthouse/providers/{id}:
get:
- operationId: lighthouse_providers_retrieve
+ operationId: api_v1_lighthouse_providers_retrieve
description: Get details for a specific provider configuration in the current
tenant.
summary: Retrieve LLM provider configuration
@@ -6682,7 +7755,7 @@ paths:
$ref: '#/components/schemas/LighthouseProviderConfigResponse'
description: ''
patch:
- operationId: lighthouse_providers_partial_update
+ operationId: api_v1_lighthouse_providers_partial_update
description: Partially update a provider configuration (e.g., base_url, is_active).
summary: Update LLM provider configuration
parameters:
@@ -6717,7 +7790,7 @@ paths:
$ref: '#/components/schemas/LighthouseProviderConfigUpdateResponse'
description: ''
delete:
- operationId: lighthouse_providers_destroy
+ operationId: api_v1_lighthouse_providers_destroy
description: Delete a provider configuration. Any tenant defaults that reference
this provider are cleared during deletion.
summary: Delete LLM provider configuration
@@ -6738,7 +7811,7 @@ paths:
description: No response body
/api/v1/lighthouse/providers/{id}/connection:
post:
- operationId: lighthouse_providers_connection_create
+ operationId: api_v1_lighthouse_providers_connection_create
description: Validate provider credentials asynchronously and toggle is_active.
summary: Check LLM provider connection
parameters:
@@ -6777,7 +7850,7 @@ paths:
description: ''
/api/v1/lighthouse/providers/{id}/refresh-models:
post:
- operationId: lighthouse_providers_refresh_models_create
+ operationId: api_v1_lighthouse_providers_refresh_models_create
description: Fetch available models for this provider configuration and upsert
into catalog. Supports OpenAI, OpenAI-compatible, and AWS Bedrock providers.
summary: Refresh LLM models catalog
@@ -6817,7 +7890,7 @@ paths:
description: ''
/api/v1/mute-rules:
get:
- operationId: mute_rules_list
+ operationId: api_v1_mute_rules_list
description: Retrieve a list of all mute rules with filtering options.
summary: List all mute rules
parameters:
@@ -6963,7 +8036,7 @@ paths:
$ref: '#/components/schemas/PaginatedMuteRuleList'
description: ''
post:
- operationId: mute_rules_create
+ operationId: api_v1_mute_rules_create
description: Create a new mute rule by providing finding IDs, name, and reason.
The rule will immediately mute the selected findings and launch a background
task to mute all historical findings with matching UIDs.
@@ -6993,7 +8066,7 @@ paths:
description: ''
/api/v1/mute-rules/{id}:
get:
- operationId: mute_rules_retrieve
+ operationId: api_v1_mute_rules_retrieve
description: Fetch detailed information about a specific mute rule by ID.
summary: Retrieve a mute rule
parameters:
@@ -7044,7 +8117,7 @@ paths:
$ref: '#/components/schemas/MuteRuleResponse'
description: ''
patch:
- operationId: mute_rules_partial_update
+ operationId: api_v1_mute_rules_partial_update
description: Update certain fields of an existing mute rule (e.g., name, reason,
enabled).
summary: Partially update a mute rule
@@ -7080,7 +8153,7 @@ paths:
$ref: '#/components/schemas/SerializerMetaclassResponse'
description: ''
delete:
- operationId: mute_rules_destroy
+ operationId: api_v1_mute_rules_destroy
description: 'Remove a mute rule from the system. Note: Previously muted findings
remain muted.'
summary: Delete a mute rule
@@ -7101,7 +8174,7 @@ paths:
description: No response body
/api/v1/overviews/attack-surfaces:
get:
- operationId: overviews_attack_surfaces_list
+ operationId: api_v1_overviews_attack_surfaces_list
description: Retrieve aggregated attack surface metrics from latest completed
scans per provider.
summary: Get attack surface overview
@@ -7120,6 +8193,21 @@ 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_groups]
+ schema:
+ type: string
+ format: uuid
+ - in: query
+ name: filter[provider_groups__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_id]
schema:
@@ -7139,7 +8227,7 @@ paths:
name: filter[provider_type]
schema:
type: string
- x-spec-enum-id: 91f917e0c3ab97e8
+ x-spec-enum-id: 203afc16daac9b64
enum:
- alibabacloud
- aws
@@ -7153,6 +8241,7 @@ paths:
- kubernetes
- m365
- mongodbatlas
+ - okta
- openstack
- oraclecloud
- vercel
@@ -7172,13 +8261,14 @@ paths:
* `image` - Image
* `googleworkspace` - Google Workspace
* `vercel` - Vercel
+ * `okta` - Okta
- in: query
name: filter[provider_type__in]
schema:
type: array
items:
type: string
- x-spec-enum-id: 91f917e0c3ab97e8
+ x-spec-enum-id: 203afc16daac9b64
enum:
- alibabacloud
- aws
@@ -7192,6 +8282,7 @@ paths:
- kubernetes
- m365
- mongodbatlas
+ - okta
- openstack
- oraclecloud
- vercel
@@ -7213,6 +8304,7 @@ paths:
* `image` - Image
* `googleworkspace` - Google Workspace
* `vercel` - Vercel
+ * `okta` - Okta
explode: false
style: form
- name: filter[search]
@@ -7264,7 +8356,7 @@ paths:
description: ''
/api/v1/overviews/categories:
get:
- operationId: overviews_categories_list
+ operationId: api_v1_overviews_categories_list
description: 'Retrieve aggregated category metrics from latest completed scans
per provider. Returns one row per category with total, failed, and new failed
findings counts, plus a severity breakdown showing failed findings per severity
@@ -7299,6 +8391,21 @@ paths:
description: Multiple values may be separated by commas.
explode: false
style: form
+ - in: query
+ name: filter[provider_groups]
+ schema:
+ type: string
+ format: uuid
+ - in: query
+ name: filter[provider_groups__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_id]
schema:
@@ -7318,7 +8425,7 @@ paths:
name: filter[provider_type]
schema:
type: string
- x-spec-enum-id: 91f917e0c3ab97e8
+ x-spec-enum-id: 203afc16daac9b64
enum:
- alibabacloud
- aws
@@ -7332,6 +8439,7 @@ paths:
- kubernetes
- m365
- mongodbatlas
+ - okta
- openstack
- oraclecloud
- vercel
@@ -7351,13 +8459,14 @@ paths:
* `image` - Image
* `googleworkspace` - Google Workspace
* `vercel` - Vercel
+ * `okta` - Okta
- in: query
name: filter[provider_type__in]
schema:
type: array
items:
type: string
- x-spec-enum-id: 91f917e0c3ab97e8
+ x-spec-enum-id: 203afc16daac9b64
enum:
- alibabacloud
- aws
@@ -7371,6 +8480,7 @@ paths:
- kubernetes
- m365
- mongodbatlas
+ - okta
- openstack
- oraclecloud
- vercel
@@ -7392,6 +8502,7 @@ paths:
* `image` - Image
* `googleworkspace` - Google Workspace
* `vercel` - Vercel
+ * `okta` - Okta
explode: false
style: form
- name: filter[search]
@@ -7445,7 +8556,7 @@ paths:
description: ''
/api/v1/overviews/compliance-watchlist:
get:
- operationId: overviews_compliance_watchlist_list
+ operationId: api_v1_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
@@ -7468,6 +8579,21 @@ 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_groups]
+ schema:
+ type: string
+ format: uuid
+ - in: query
+ name: filter[provider_groups__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_id]
schema:
@@ -7500,6 +8626,7 @@ paths:
- kubernetes
- m365
- mongodbatlas
+ - okta
- openstack
- oraclecloud
- vercel
@@ -7519,6 +8646,7 @@ paths:
* `image` - Image
* `googleworkspace` - Google Workspace
* `vercel` - Vercel
+ * `okta` - Okta
- in: query
name: filter[provider_type__in]
schema:
@@ -7538,6 +8666,7 @@ paths:
- kubernetes
- m365
- mongodbatlas
+ - okta
- openstack
- oraclecloud
- vercel
@@ -7559,6 +8688,7 @@ paths:
* `image` - Image
* `googleworkspace` - Google Workspace
* `vercel` - Vercel
+ * `okta` - Okta
explode: false
style: form
- name: filter[search]
@@ -7614,7 +8744,7 @@ paths:
description: ''
/api/v1/overviews/findings:
get:
- operationId: overviews_findings_retrieve
+ operationId: api_v1_overviews_findings_retrieve
description: Fetch aggregated findings data across all providers, grouped by
various metrics such as passed, failed, muted, and total findings. This endpoint
calculates summary statistics based on the latest scans for each provider
@@ -7666,6 +8796,21 @@ paths:
schema:
type: string
format: date-time
+ - in: query
+ name: filter[provider_groups]
+ schema:
+ type: string
+ format: uuid
+ - in: query
+ name: filter[provider_groups__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_id]
schema:
@@ -7685,7 +8830,7 @@ paths:
name: filter[provider_type]
schema:
type: string
- x-spec-enum-id: 91f917e0c3ab97e8
+ x-spec-enum-id: 203afc16daac9b64
enum:
- alibabacloud
- aws
@@ -7699,6 +8844,7 @@ paths:
- kubernetes
- m365
- mongodbatlas
+ - okta
- openstack
- oraclecloud
- vercel
@@ -7718,13 +8864,14 @@ paths:
* `image` - Image
* `googleworkspace` - Google Workspace
* `vercel` - Vercel
+ * `okta` - Okta
- in: query
name: filter[provider_type__in]
schema:
type: array
items:
type: string
- x-spec-enum-id: 91f917e0c3ab97e8
+ x-spec-enum-id: 203afc16daac9b64
enum:
- alibabacloud
- aws
@@ -7738,6 +8885,7 @@ paths:
- kubernetes
- m365
- mongodbatlas
+ - okta
- openstack
- oraclecloud
- vercel
@@ -7759,6 +8907,7 @@ paths:
* `image` - Image
* `googleworkspace` - Google Workspace
* `vercel` - Vercel
+ * `okta` - Okta
explode: false
style: form
- in: query
@@ -7835,7 +8984,7 @@ paths:
description: ''
/api/v1/overviews/findings_severity:
get:
- operationId: overviews_findings_severity_retrieve
+ operationId: api_v1_overviews_findings_severity_retrieve
description: Retrieve an aggregated summary of findings grouped by severity
levels, such as low, medium, high, and critical. The response includes the
total count of findings for each severity, considering only the latest scans
@@ -7879,6 +9028,21 @@ paths:
schema:
type: string
format: date-time
+ - in: query
+ name: filter[provider_groups]
+ schema:
+ type: string
+ format: uuid
+ - in: query
+ name: filter[provider_groups__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_id]
schema:
@@ -7898,7 +9062,7 @@ paths:
name: filter[provider_type]
schema:
type: string
- x-spec-enum-id: 91f917e0c3ab97e8
+ x-spec-enum-id: 203afc16daac9b64
enum:
- alibabacloud
- aws
@@ -7912,6 +9076,7 @@ paths:
- kubernetes
- m365
- mongodbatlas
+ - okta
- openstack
- oraclecloud
- vercel
@@ -7931,13 +9096,14 @@ paths:
* `image` - Image
* `googleworkspace` - Google Workspace
* `vercel` - Vercel
+ * `okta` - Okta
- in: query
name: filter[provider_type__in]
schema:
type: array
items:
type: string
- x-spec-enum-id: 91f917e0c3ab97e8
+ x-spec-enum-id: 203afc16daac9b64
enum:
- alibabacloud
- aws
@@ -7951,6 +9117,7 @@ paths:
- kubernetes
- m365
- mongodbatlas
+ - okta
- openstack
- oraclecloud
- vercel
@@ -7972,6 +9139,7 @@ paths:
* `image` - Image
* `googleworkspace` - Google Workspace
* `vercel` - Vercel
+ * `okta` - Okta
explode: false
style: form
- in: query
@@ -8051,7 +9219,7 @@ paths:
description: ''
/api/v1/overviews/findings_severity/timeseries:
get:
- operationId: overviews_findings_severity_timeseries_retrieve
+ operationId: api_v1_overviews_findings_severity_timeseries_retrieve
description: Retrieve daily aggregated findings data grouped by severity levels
over a date range. Returns one data point per day with counts of failed findings
by severity (critical, high, medium, low, informational) and muted findings.
@@ -8087,6 +9255,21 @@ paths:
schema:
type: string
format: date
+ - in: query
+ name: filter[provider_groups]
+ schema:
+ type: string
+ format: uuid
+ - in: query
+ name: filter[provider_groups__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_id]
schema:
@@ -8119,6 +9302,7 @@ paths:
- kubernetes
- m365
- mongodbatlas
+ - okta
- openstack
- oraclecloud
- vercel
@@ -8138,6 +9322,7 @@ paths:
* `image` - Image
* `googleworkspace` - Google Workspace
* `vercel` - Vercel
+ * `okta` - Okta
- in: query
name: filter[provider_type__in]
schema:
@@ -8157,6 +9342,7 @@ paths:
- kubernetes
- m365
- mongodbatlas
+ - okta
- openstack
- oraclecloud
- vercel
@@ -8178,6 +9364,7 @@ paths:
* `image` - Image
* `googleworkspace` - Google Workspace
* `vercel` - Vercel
+ * `okta` - Okta
explode: false
style: form
- name: filter[search]
@@ -8225,7 +9412,7 @@ paths:
description: ''
/api/v1/overviews/providers:
get:
- operationId: overviews_providers_retrieve
+ operationId: api_v1_overviews_providers_retrieve
description: Retrieve an aggregated overview of findings and resources grouped
by providers. The response includes the count of passed, failed, and manual
findings, along with the total number of resources managed by each provider.
@@ -8259,7 +9446,7 @@ paths:
description: ''
/api/v1/overviews/providers/count:
get:
- operationId: overviews_providers_count_retrieve
+ operationId: api_v1_overviews_providers_count_retrieve
description: Retrieve the number of providers grouped by provider type. This
endpoint counts every provider in the tenant, including those without completed
scans.
@@ -8290,7 +9477,7 @@ paths:
description: ''
/api/v1/overviews/regions:
get:
- operationId: overviews_regions_retrieve
+ operationId: api_v1_overviews_regions_retrieve
description: Retrieve an aggregated summary of findings grouped by region. The
response includes the total, passed, failed, and muted findings for each region
based on the latest completed scans per provider. Standard overview filters
@@ -8334,6 +9521,21 @@ paths:
schema:
type: string
format: date-time
+ - in: query
+ name: filter[provider_groups]
+ schema:
+ type: string
+ format: uuid
+ - in: query
+ name: filter[provider_groups__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_id]
schema:
@@ -8353,7 +9555,7 @@ paths:
name: filter[provider_type]
schema:
type: string
- x-spec-enum-id: 91f917e0c3ab97e8
+ x-spec-enum-id: 203afc16daac9b64
enum:
- alibabacloud
- aws
@@ -8367,6 +9569,7 @@ paths:
- kubernetes
- m365
- mongodbatlas
+ - okta
- openstack
- oraclecloud
- vercel
@@ -8386,13 +9589,14 @@ paths:
* `image` - Image
* `googleworkspace` - Google Workspace
* `vercel` - Vercel
+ * `okta` - Okta
- in: query
name: filter[provider_type__in]
schema:
type: array
items:
type: string
- x-spec-enum-id: 91f917e0c3ab97e8
+ x-spec-enum-id: 203afc16daac9b64
enum:
- alibabacloud
- aws
@@ -8406,6 +9610,7 @@ paths:
- kubernetes
- m365
- mongodbatlas
+ - okta
- openstack
- oraclecloud
- vercel
@@ -8427,6 +9632,7 @@ paths:
* `image` - Image
* `googleworkspace` - Google Workspace
* `vercel` - Vercel
+ * `okta` - Okta
explode: false
style: form
- in: query
@@ -8489,7 +9695,7 @@ paths:
description: ''
/api/v1/overviews/resource-groups:
get:
- operationId: overviews_resource_groups_list
+ operationId: api_v1_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
@@ -8512,6 +9718,21 @@ 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_groups]
+ schema:
+ type: string
+ format: uuid
+ - in: query
+ name: filter[provider_groups__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_id]
schema:
@@ -8531,7 +9752,7 @@ paths:
name: filter[provider_type]
schema:
type: string
- x-spec-enum-id: 91f917e0c3ab97e8
+ x-spec-enum-id: 203afc16daac9b64
enum:
- alibabacloud
- aws
@@ -8545,6 +9766,7 @@ paths:
- kubernetes
- m365
- mongodbatlas
+ - okta
- openstack
- oraclecloud
- vercel
@@ -8564,13 +9786,14 @@ paths:
* `image` - Image
* `googleworkspace` - Google Workspace
* `vercel` - Vercel
+ * `okta` - Okta
- in: query
name: filter[provider_type__in]
schema:
type: array
items:
type: string
- x-spec-enum-id: 91f917e0c3ab97e8
+ x-spec-enum-id: 203afc16daac9b64
enum:
- alibabacloud
- aws
@@ -8584,6 +9807,7 @@ paths:
- kubernetes
- m365
- mongodbatlas
+ - okta
- openstack
- oraclecloud
- vercel
@@ -8605,6 +9829,7 @@ paths:
* `image` - Image
* `googleworkspace` - Google Workspace
* `vercel` - Vercel
+ * `okta` - Okta
explode: false
style: form
- in: query
@@ -8673,7 +9898,7 @@ paths:
description: ''
/api/v1/overviews/services:
get:
- operationId: overviews_services_retrieve
+ operationId: api_v1_overviews_services_retrieve
description: Retrieve an aggregated summary of findings grouped by service.
The response includes the total count of findings for each service, as long
as there are at least one finding for that service.
@@ -8714,6 +9939,21 @@ paths:
schema:
type: string
format: date-time
+ - in: query
+ name: filter[provider_groups]
+ schema:
+ type: string
+ format: uuid
+ - in: query
+ name: filter[provider_groups__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_id]
schema:
@@ -8733,7 +9973,7 @@ paths:
name: filter[provider_type]
schema:
type: string
- x-spec-enum-id: 91f917e0c3ab97e8
+ x-spec-enum-id: 203afc16daac9b64
enum:
- alibabacloud
- aws
@@ -8747,6 +9987,7 @@ paths:
- kubernetes
- m365
- mongodbatlas
+ - okta
- openstack
- oraclecloud
- vercel
@@ -8766,13 +10007,14 @@ paths:
* `image` - Image
* `googleworkspace` - Google Workspace
* `vercel` - Vercel
+ * `okta` - Okta
- in: query
name: filter[provider_type__in]
schema:
type: array
items:
type: string
- x-spec-enum-id: 91f917e0c3ab97e8
+ x-spec-enum-id: 203afc16daac9b64
enum:
- alibabacloud
- aws
@@ -8786,6 +10028,7 @@ paths:
- kubernetes
- m365
- mongodbatlas
+ - okta
- openstack
- oraclecloud
- vercel
@@ -8807,6 +10050,7 @@ paths:
* `image` - Image
* `googleworkspace` - Google Workspace
* `vercel` - Vercel
+ * `okta` - Okta
explode: false
style: form
- in: query
@@ -8865,7 +10109,7 @@ paths:
description: ''
/api/v1/overviews/threatscore:
get:
- operationId: overviews_threatscore_retrieve
+ operationId: api_v1_overviews_threatscore_retrieve
description: Retrieve ThreatScore metrics. By default, returns the latest snapshot
for each provider. Use snapshot_id to retrieve a specific historical snapshot.
summary: Get ThreatScore snapshots
@@ -8896,6 +10140,38 @@ 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_groups]
+ schema:
+ type: string
+ format: uuid
+ description: Filter by provider group ID
+ - in: query
+ name: filter[provider_groups__in]
+ schema:
+ type: string
+ description: Filter by multiple provider group IDs (comma-separated UUIDs)
+ - in: query
+ name: filter[provider_id]
+ schema:
+ type: string
+ format: uuid
+ description: Filter by specific provider ID
+ - in: query
+ name: filter[provider_id__in]
+ schema:
+ type: string
+ description: Filter by multiple provider IDs (comma-separated UUIDs)
+ - in: query
+ name: filter[provider_type]
+ schema:
+ type: string
+ description: Filter by provider type (aws, azure, gcp, etc.)
+ - in: query
+ name: filter[provider_type__in]
+ schema:
+ type: string
+ description: Filter by multiple provider types (comma-separated)
- in: query
name: include
schema:
@@ -8908,27 +10184,6 @@ paths:
description: include query parameter to allow the client to customize which
related resources should be returned.
explode: false
- - in: query
- name: provider_id
- schema:
- type: string
- format: uuid
- description: Filter by specific provider ID
- - in: query
- name: provider_id__in
- schema:
- type: string
- description: Filter by multiple provider IDs (comma-separated UUIDs)
- - in: query
- name: provider_type
- schema:
- type: string
- description: Filter by provider type (aws, azure, gcp, etc.)
- - in: query
- name: provider_type__in
- schema:
- type: string
- description: Filter by multiple provider types (comma-separated)
- in: query
name: snapshot_id
schema:
@@ -8949,7 +10204,7 @@ paths:
description: ''
/api/v1/processors:
get:
- operationId: processors_list
+ operationId: api_v1_processors_list
description: Retrieve a list of all configured processors with options for filtering
by various criteria.
summary: List all processors
@@ -9044,7 +10299,7 @@ paths:
$ref: '#/components/schemas/PaginatedProcessorList'
description: ''
post:
- operationId: processors_create
+ operationId: api_v1_processors_create
description: Register a new processor with the system, providing necessary configuration
details. There can only be one processor of each type per tenant.
summary: Create a new processor
@@ -9073,7 +10328,7 @@ paths:
description: ''
/api/v1/processors/{id}:
get:
- operationId: processors_retrieve
+ operationId: api_v1_processors_retrieve
description: Fetch detailed information about a specific processor by its ID.
summary: Retrieve processor details
parameters:
@@ -9111,7 +10366,7 @@ paths:
$ref: '#/components/schemas/ProcessorResponse'
description: ''
patch:
- operationId: processors_partial_update
+ operationId: api_v1_processors_partial_update
description: Modify certain fields of an existing processor without affecting
other settings.
summary: Partially update a processor
@@ -9147,7 +10402,7 @@ paths:
$ref: '#/components/schemas/ProcessorUpdateResponse'
description: ''
delete:
- operationId: processors_destroy
+ operationId: api_v1_processors_destroy
description: Remove a processor from the system by its ID.
summary: Delete a processor
parameters:
@@ -9167,7 +10422,7 @@ paths:
description: No response body
/api/v1/provider-groups:
get:
- operationId: provider_groups_list
+ operationId: api_v1_provider_groups_list
description: Retrieve a list of all provider groups with options for filtering
by various criteria.
summary: List all provider groups
@@ -9300,7 +10555,7 @@ paths:
$ref: '#/components/schemas/PaginatedProviderGroupList'
description: ''
post:
- operationId: provider_groups_create
+ operationId: api_v1_provider_groups_create
description: Add a new provider group to the system by providing the required
provider group details.
summary: Create a new provider group
@@ -9329,7 +10584,7 @@ paths:
description: ''
/api/v1/provider-groups/{id}:
get:
- operationId: provider_groups_retrieve
+ operationId: api_v1_provider_groups_retrieve
description: Fetch detailed information about a specific provider group by their
ID.
summary: Retrieve data from a provider group
@@ -9369,7 +10624,7 @@ paths:
$ref: '#/components/schemas/ProviderGroupResponse'
description: ''
patch:
- operationId: provider_groups_partial_update
+ operationId: api_v1_provider_groups_partial_update
description: Update certain fields of an existing provider group's information
without affecting other fields.
summary: Partially update a provider group
@@ -9405,7 +10660,7 @@ paths:
$ref: '#/components/schemas/SerializerMetaclassResponse'
description: ''
delete:
- operationId: provider_groups_destroy
+ operationId: api_v1_provider_groups_destroy
description: Remove a provider group from the system by their ID.
summary: Delete a provider group
parameters:
@@ -9425,7 +10680,7 @@ paths:
description: No response body
/api/v1/provider-groups/{id}/relationships/providers:
post:
- operationId: provider_groups_relationships_providers_create
+ operationId: api_v1_provider_groups_relationships_providers_create
description: Add a new provider_group-providers relationship to the system by
providing the required provider_group-providers details.
summary: Create a new provider_group-providers relationship
@@ -9451,7 +10706,7 @@ paths:
'400':
description: Bad request (e.g., relationship already exists)
patch:
- operationId: provider_groups_relationships_providers_partial_update
+ operationId: api_v1_provider_groups_relationships_providers_partial_update
description: Update the provider_group-providers relationship information without
affecting other fields.
summary: Partially update a provider_group-providers relationship
@@ -9475,7 +10730,7 @@ paths:
'204':
description: Relationship updated successfully
delete:
- operationId: provider_groups_relationships_providers_destroy
+ operationId: api_v1_provider_groups_relationships_providers_destroy
description: Remove the provider_group-providers relationship from the system
by their ID.
summary: Delete a provider_group-providers relationship
@@ -9488,7 +10743,7 @@ paths:
description: Relationship deleted successfully
/api/v1/providers:
get:
- operationId: providers_list
+ operationId: api_v1_providers_list
description: Retrieve a list of all providers with options for filtering by
various criteria.
summary: List all providers
@@ -9576,7 +10831,7 @@ paths:
name: filter[provider]
schema:
type: string
- x-spec-enum-id: 91f917e0c3ab97e8
+ x-spec-enum-id: 203afc16daac9b64
enum:
- alibabacloud
- aws
@@ -9590,6 +10845,7 @@ paths:
- kubernetes
- m365
- mongodbatlas
+ - okta
- openstack
- oraclecloud
- vercel
@@ -9609,13 +10865,14 @@ paths:
* `image` - Image
* `googleworkspace` - Google Workspace
* `vercel` - Vercel
+ * `okta` - Okta
- in: query
name: filter[provider__in]
schema:
type: array
items:
type: string
- x-spec-enum-id: 91f917e0c3ab97e8
+ x-spec-enum-id: 203afc16daac9b64
enum:
- alibabacloud
- aws
@@ -9629,6 +10886,7 @@ paths:
- kubernetes
- m365
- mongodbatlas
+ - okta
- openstack
- oraclecloud
- vercel
@@ -9650,13 +10908,29 @@ paths:
* `image` - Image
* `googleworkspace` - Google Workspace
* `vercel` - Vercel
+ * `okta` - Okta
+ explode: false
+ style: form
+ - in: query
+ name: filter[provider_groups]
+ schema:
+ type: string
+ format: uuid
+ - in: query
+ name: filter[provider_groups__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: 91f917e0c3ab97e8
+ x-spec-enum-id: 203afc16daac9b64
enum:
- alibabacloud
- aws
@@ -9670,6 +10944,7 @@ paths:
- kubernetes
- m365
- mongodbatlas
+ - okta
- openstack
- oraclecloud
- vercel
@@ -9689,13 +10964,14 @@ paths:
* `image` - Image
* `googleworkspace` - Google Workspace
* `vercel` - Vercel
+ * `okta` - Okta
- in: query
name: filter[provider_type__in]
schema:
type: array
items:
type: string
- x-spec-enum-id: 91f917e0c3ab97e8
+ x-spec-enum-id: 203afc16daac9b64
enum:
- alibabacloud
- aws
@@ -9709,6 +10985,7 @@ paths:
- kubernetes
- m365
- mongodbatlas
+ - okta
- openstack
- oraclecloud
- vercel
@@ -9730,6 +11007,7 @@ paths:
* `image` - Image
* `googleworkspace` - Google Workspace
* `vercel` - Vercel
+ * `okta` - Okta
explode: false
style: form
- name: filter[search]
@@ -9830,7 +11108,7 @@ paths:
$ref: '#/components/schemas/PaginatedProviderList'
description: ''
post:
- operationId: providers_create
+ operationId: api_v1_providers_create
description: Add a new provider to the system by providing the required provider
details.
summary: Create a new provider
@@ -9859,7 +11137,7 @@ paths:
description: ''
/api/v1/providers/{id}:
get:
- operationId: providers_retrieve
+ operationId: api_v1_providers_retrieve
description: Fetch detailed information about a specific provider by their ID.
summary: Retrieve data from a provider
parameters:
@@ -9912,7 +11190,7 @@ paths:
$ref: '#/components/schemas/ProviderResponse'
description: ''
patch:
- operationId: providers_partial_update
+ operationId: api_v1_providers_partial_update
description: Update certain fields of an existing provider's information without
affecting other fields.
summary: Partially update a provider
@@ -9948,7 +11226,7 @@ paths:
$ref: '#/components/schemas/SerializerMetaclassResponse'
description: ''
delete:
- operationId: providers_destroy
+ operationId: api_v1_providers_destroy
description: Remove a provider from the system by their ID.
summary: Delete a provider
parameters:
@@ -9987,7 +11265,7 @@ paths:
description: ''
/api/v1/providers/{id}/connection:
post:
- operationId: providers_connection_create
+ operationId: api_v1_providers_connection_create
description: Try to verify connection. For instance, Role & Credentials are
set correctly
summary: Check connection
@@ -10027,7 +11305,7 @@ paths:
description: ''
/api/v1/providers/secrets:
get:
- operationId: providers_secrets_list
+ operationId: api_v1_providers_secrets_list
description: Retrieve a list of all secrets with options for filtering by various
criteria.
summary: List all secrets
@@ -10119,7 +11397,7 @@ paths:
$ref: '#/components/schemas/PaginatedProviderSecretList'
description: ''
post:
- operationId: providers_secrets_create
+ operationId: api_v1_providers_secrets_create
description: Add a new secret to the system by providing the required secret
details.
summary: Create a new secret
@@ -10148,7 +11426,7 @@ paths:
description: ''
/api/v1/providers/secrets/{id}:
get:
- operationId: providers_secrets_retrieve
+ operationId: api_v1_providers_secrets_retrieve
description: Fetch detailed information about a specific secret by their ID.
summary: Retrieve data from a secret
parameters:
@@ -10186,7 +11464,7 @@ paths:
$ref: '#/components/schemas/ProviderSecretResponse'
description: ''
patch:
- operationId: providers_secrets_partial_update
+ operationId: api_v1_providers_secrets_partial_update
description: Update certain fields of an existing secret's information without
affecting other fields.
summary: Partially update a secret
@@ -10221,7 +11499,7 @@ paths:
$ref: '#/components/schemas/ProviderSecretUpdateResponse'
description: ''
delete:
- operationId: providers_secrets_destroy
+ operationId: api_v1_providers_secrets_destroy
description: Remove a secret from the system by their ID.
summary: Delete a secret
parameters:
@@ -10240,7 +11518,7 @@ paths:
description: No response body
/api/v1/resources:
get:
- operationId: resources_list
+ operationId: api_v1_resources_list
description: Retrieve a list of all resources with options for filtering by
various criteria. Resources are objects that are discovered by Prowler. They
can be anything from a single host to a whole VPC.
@@ -10364,6 +11642,21 @@ paths:
description: Multiple values may be separated by commas.
explode: false
style: form
+ - in: query
+ name: filter[provider_groups]
+ schema:
+ type: string
+ format: uuid
+ - in: query
+ name: filter[provider_groups__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_id]
schema:
@@ -10383,7 +11676,7 @@ paths:
name: filter[provider_type]
schema:
type: string
- x-spec-enum-id: 91f917e0c3ab97e8
+ x-spec-enum-id: 203afc16daac9b64
enum:
- alibabacloud
- aws
@@ -10397,6 +11690,7 @@ paths:
- kubernetes
- m365
- mongodbatlas
+ - okta
- openstack
- oraclecloud
- vercel
@@ -10416,13 +11710,14 @@ paths:
* `image` - Image
* `googleworkspace` - Google Workspace
* `vercel` - Vercel
+ * `okta` - Okta
- in: query
name: filter[provider_type__in]
schema:
type: array
items:
type: string
- x-spec-enum-id: 91f917e0c3ab97e8
+ x-spec-enum-id: 203afc16daac9b64
enum:
- alibabacloud
- aws
@@ -10436,6 +11731,7 @@ paths:
- kubernetes
- m365
- mongodbatlas
+ - okta
- openstack
- oraclecloud
- vercel
@@ -10457,6 +11753,7 @@ paths:
* `image` - Image
* `googleworkspace` - Google Workspace
* `vercel` - Vercel
+ * `okta` - Okta
explode: false
style: form
- in: query
@@ -10662,7 +11959,7 @@ paths:
description: ''
/api/v1/resources/{id}:
get:
- operationId: resources_retrieve
+ operationId: api_v1_resources_retrieve
description: Fetch detailed information about a specific resource by their ID.
A Resource is an object that is discovered by Prowler. It can be anything
from a single host to a whole VPC.
@@ -10726,7 +12023,7 @@ paths:
description: ''
/api/v1/resources/{id}/events:
get:
- operationId: resources_events_list
+ operationId: api_v1_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.
@@ -10807,7 +12104,7 @@ paths:
description: Provider service unavailable
/api/v1/resources/latest:
get:
- operationId: resources_latest_retrieve
+ operationId: api_v1_resources_latest_retrieve
description: Retrieve a list of the latest resources from the latest scans for
each provider with options for filtering by various criteria.
summary: List the latest resources
@@ -10915,6 +12212,21 @@ paths:
description: Multiple values may be separated by commas.
explode: false
style: form
+ - in: query
+ name: filter[provider_groups]
+ schema:
+ type: string
+ format: uuid
+ - in: query
+ name: filter[provider_groups__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_id]
schema:
@@ -10934,7 +12246,7 @@ paths:
name: filter[provider_type]
schema:
type: string
- x-spec-enum-id: 91f917e0c3ab97e8
+ x-spec-enum-id: 203afc16daac9b64
enum:
- alibabacloud
- aws
@@ -10948,6 +12260,7 @@ paths:
- kubernetes
- m365
- mongodbatlas
+ - okta
- openstack
- oraclecloud
- vercel
@@ -10967,13 +12280,14 @@ paths:
* `image` - Image
* `googleworkspace` - Google Workspace
* `vercel` - Vercel
+ * `okta` - Okta
- in: query
name: filter[provider_type__in]
schema:
type: array
items:
type: string
- x-spec-enum-id: 91f917e0c3ab97e8
+ x-spec-enum-id: 203afc16daac9b64
enum:
- alibabacloud
- aws
@@ -10987,6 +12301,7 @@ paths:
- kubernetes
- m365
- mongodbatlas
+ - okta
- openstack
- oraclecloud
- vercel
@@ -11008,6 +12323,7 @@ paths:
* `image` - Image
* `googleworkspace` - Google Workspace
* `vercel` - Vercel
+ * `okta` - Okta
explode: false
style: form
- in: query
@@ -11168,7 +12484,7 @@ paths:
description: ''
/api/v1/resources/metadata:
get:
- operationId: resources_metadata_retrieve
+ operationId: api_v1_resources_metadata_retrieve
description: Fetch unique metadata values from a set of resources. This is useful
for dynamic filtering.
summary: Retrieve metadata values from resources
@@ -11279,6 +12595,21 @@ paths:
description: Multiple values may be separated by commas.
explode: false
style: form
+ - in: query
+ name: filter[provider_groups]
+ schema:
+ type: string
+ format: uuid
+ - in: query
+ name: filter[provider_groups__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_id]
schema:
@@ -11298,7 +12629,7 @@ paths:
name: filter[provider_type]
schema:
type: string
- x-spec-enum-id: 91f917e0c3ab97e8
+ x-spec-enum-id: 203afc16daac9b64
enum:
- alibabacloud
- aws
@@ -11312,6 +12643,7 @@ paths:
- kubernetes
- m365
- mongodbatlas
+ - okta
- openstack
- oraclecloud
- vercel
@@ -11331,13 +12663,14 @@ paths:
* `image` - Image
* `googleworkspace` - Google Workspace
* `vercel` - Vercel
+ * `okta` - Okta
- in: query
name: filter[provider_type__in]
schema:
type: array
items:
type: string
- x-spec-enum-id: 91f917e0c3ab97e8
+ x-spec-enum-id: 203afc16daac9b64
enum:
- alibabacloud
- aws
@@ -11351,6 +12684,7 @@ paths:
- kubernetes
- m365
- mongodbatlas
+ - okta
- openstack
- oraclecloud
- vercel
@@ -11372,6 +12706,7 @@ paths:
* `image` - Image
* `googleworkspace` - Google Workspace
* `vercel` - Vercel
+ * `okta` - Okta
explode: false
style: form
- in: query
@@ -11553,7 +12888,7 @@ paths:
description: ''
/api/v1/resources/metadata/latest:
get:
- operationId: resources_metadata_latest_retrieve
+ operationId: api_v1_resources_metadata_latest_retrieve
description: Fetch unique metadata values from a set of resources from the latest
scans for each provider. This is useful for dynamic filtering.
summary: Retrieve metadata values from the latest resources
@@ -11649,6 +12984,21 @@ paths:
description: Multiple values may be separated by commas.
explode: false
style: form
+ - in: query
+ name: filter[provider_groups]
+ schema:
+ type: string
+ format: uuid
+ - in: query
+ name: filter[provider_groups__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_id]
schema:
@@ -11668,7 +13018,7 @@ paths:
name: filter[provider_type]
schema:
type: string
- x-spec-enum-id: 91f917e0c3ab97e8
+ x-spec-enum-id: 203afc16daac9b64
enum:
- alibabacloud
- aws
@@ -11682,6 +13032,7 @@ paths:
- kubernetes
- m365
- mongodbatlas
+ - okta
- openstack
- oraclecloud
- vercel
@@ -11701,13 +13052,14 @@ paths:
* `image` - Image
* `googleworkspace` - Google Workspace
* `vercel` - Vercel
+ * `okta` - Okta
- in: query
name: filter[provider_type__in]
schema:
type: array
items:
type: string
- x-spec-enum-id: 91f917e0c3ab97e8
+ x-spec-enum-id: 203afc16daac9b64
enum:
- alibabacloud
- aws
@@ -11721,6 +13073,7 @@ paths:
- kubernetes
- m365
- mongodbatlas
+ - okta
- openstack
- oraclecloud
- vercel
@@ -11742,6 +13095,7 @@ paths:
* `image` - Image
* `googleworkspace` - Google Workspace
* `vercel` - Vercel
+ * `okta` - Okta
explode: false
style: form
- in: query
@@ -11890,7 +13244,7 @@ paths:
description: ''
/api/v1/roles:
get:
- operationId: roles_list
+ operationId: api_v1_roles_list
description: Retrieve a list of all roles with options for filtering by various
criteria.
summary: List all roles
@@ -12059,7 +13413,7 @@ paths:
$ref: '#/components/schemas/PaginatedRoleList'
description: ''
post:
- operationId: roles_create
+ operationId: api_v1_roles_create
description: Add a new role to the system by providing the required role details.
summary: Create a new role
tags:
@@ -12087,7 +13441,7 @@ paths:
description: ''
/api/v1/roles/{id}:
get:
- operationId: roles_retrieve
+ operationId: api_v1_roles_retrieve
description: Fetch detailed information about a specific role by their ID.
summary: Retrieve data from a role
parameters:
@@ -12134,7 +13488,7 @@ paths:
$ref: '#/components/schemas/RoleResponse'
description: ''
patch:
- operationId: roles_partial_update
+ operationId: api_v1_roles_partial_update
description: Update selected fields on an existing role. When changing the `users`
relationship of a role that grants MANAGE_ACCOUNT, the API blocks attempts
that would leave the tenant without any MANAGE_ACCOUNT assignees and prevents
@@ -12172,7 +13526,7 @@ paths:
$ref: '#/components/schemas/SerializerMetaclassResponse'
description: ''
delete:
- operationId: roles_destroy
+ operationId: api_v1_roles_destroy
description: Delete the specified role. The API rejects deletion of the last
role in the tenant that grants MANAGE_ACCOUNT.
summary: Delete a role
@@ -12193,7 +13547,7 @@ paths:
description: No response body
/api/v1/roles/{id}/relationships/provider_groups:
post:
- operationId: roles_relationships_provider_groups_create
+ operationId: api_v1_roles_relationships_provider_groups_create
description: Add a new role-provider_groups relationship to the system by providing
the required role-provider_groups details.
summary: Create a new role-provider_groups relationship
@@ -12219,7 +13573,7 @@ paths:
'400':
description: Bad request (e.g., relationship already exists)
patch:
- operationId: roles_relationships_provider_groups_partial_update
+ operationId: api_v1_roles_relationships_provider_groups_partial_update
description: Update the role-provider_groups relationship information without
affecting other fields.
summary: Partially update a role-provider_groups relationship
@@ -12243,7 +13597,7 @@ paths:
'204':
description: Relationship updated successfully
delete:
- operationId: roles_relationships_provider_groups_destroy
+ operationId: api_v1_roles_relationships_provider_groups_destroy
description: Remove the role-provider_groups relationship from the system by
their ID.
summary: Delete a role-provider_groups relationship
@@ -12256,7 +13610,7 @@ paths:
description: Relationship deleted successfully
/api/v1/saml-config:
get:
- operationId: saml_config_list
+ operationId: api_v1_saml_config_list
description: Returns all the SAML-based SSO configurations associated with the
current tenant.
summary: List all SSO configurations
@@ -12325,7 +13679,7 @@ paths:
$ref: '#/components/schemas/PaginatedSAMLConfigurationList'
description: ''
post:
- operationId: saml_config_create
+ operationId: api_v1_saml_config_create
description: Creates a new SAML SSO configuration for the current tenant, including
email domain and metadata XML.
summary: Create the SSO configuration
@@ -12354,7 +13708,7 @@ paths:
description: ''
/api/v1/saml-config/{id}:
get:
- operationId: saml_config_retrieve
+ operationId: api_v1_saml_config_retrieve
description: Returns the details of a specific SAML configuration belonging
to the current tenant.
summary: Retrieve SSO configuration details
@@ -12392,7 +13746,7 @@ paths:
$ref: '#/components/schemas/SAMLConfigurationResponse'
description: ''
patch:
- operationId: saml_config_partial_update
+ operationId: api_v1_saml_config_partial_update
description: Partially updates an existing SAML SSO configuration. Supports
changes to email domain and metadata XML.
summary: Update the SSO configuration
@@ -12428,7 +13782,7 @@ paths:
$ref: '#/components/schemas/SAMLConfigurationResponse'
description: ''
delete:
- operationId: saml_config_destroy
+ operationId: api_v1_saml_config_destroy
description: Deletes an existing SAML SSO configuration associated with the
current tenant.
summary: Delete the SSO configuration
@@ -12449,7 +13803,7 @@ paths:
description: No response body
/api/v1/scans:
get:
- operationId: scans_list
+ operationId: api_v1_scans_list
description: Retrieve a list of all scans with options for filtering by various
criteria.
summary: List all scans
@@ -12559,11 +13913,26 @@ paths:
description: Multiple values may be separated by commas.
explode: false
style: form
+ - in: query
+ name: filter[provider_groups]
+ schema:
+ type: string
+ format: uuid
+ - in: query
+ name: filter[provider_groups__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: 91f917e0c3ab97e8
+ x-spec-enum-id: 203afc16daac9b64
enum:
- alibabacloud
- aws
@@ -12577,6 +13946,7 @@ paths:
- kubernetes
- m365
- mongodbatlas
+ - okta
- openstack
- oraclecloud
- vercel
@@ -12596,13 +13966,14 @@ paths:
* `image` - Image
* `googleworkspace` - Google Workspace
* `vercel` - Vercel
+ * `okta` - Okta
- in: query
name: filter[provider_type__in]
schema:
type: array
items:
type: string
- x-spec-enum-id: 91f917e0c3ab97e8
+ x-spec-enum-id: 203afc16daac9b64
enum:
- alibabacloud
- aws
@@ -12616,6 +13987,7 @@ paths:
- kubernetes
- m365
- mongodbatlas
+ - okta
- openstack
- oraclecloud
- vercel
@@ -12637,6 +14009,7 @@ paths:
* `image` - Image
* `googleworkspace` - Google Workspace
* `vercel` - Vercel
+ * `okta` - Okta
explode: false
style: form
- in: query
@@ -12789,7 +14162,7 @@ paths:
$ref: '#/components/schemas/PaginatedScanList'
description: ''
post:
- operationId: scans_create
+ operationId: api_v1_scans_create
description: Trigger a manual scan by providing the required scan details. If
`scanner_args` are not provided, the system will automatically use the default
settings from the associated provider. If you do provide `scanner_args`, these
@@ -12837,7 +14210,7 @@ paths:
description: ''
/api/v1/scans/{id}:
get:
- operationId: scans_retrieve
+ operationId: api_v1_scans_retrieve
description: Fetch detailed information about a specific scan by its ID.
summary: Retrieve data from a specific scan
parameters:
@@ -12896,7 +14269,7 @@ paths:
$ref: '#/components/schemas/ScanResponse'
description: ''
patch:
- operationId: scans_partial_update
+ operationId: api_v1_scans_partial_update
description: Update certain fields of an existing scan without affecting other
fields.
summary: Partially update a scan
@@ -12933,7 +14306,7 @@ paths:
description: ''
/api/v1/scans/{id}/cis:
get:
- operationId: scans_cis_retrieve
+ operationId: api_v1_scans_cis_retrieve
description: Download the CIS Benchmark compliance report as a PDF file. When
a provider ships multiple CIS versions, the report is generated for the highest
available version.
@@ -13000,7 +14373,7 @@ paths:
has not started yet
/api/v1/scans/{id}/compliance/{name}:
get:
- operationId: scans_compliance_retrieve
+ operationId: api_v1_scans_compliance_retrieve
description: Download a specific compliance report (e.g., 'cis_1.4_aws') as
a CSV file.
summary: Retrieve compliance report as CSV
@@ -13037,11 +14410,63 @@ paths:
responses:
'200':
description: CSV file containing the compliance report
+ '202':
+ description: The task is in progress
+ '403':
+ description: There is a problem with credentials
'404':
- description: Compliance report not found
+ description: Compliance report not found, or the scan has no reports yet
+ /api/v1/scans/{id}/compliance/{name}/ocsf:
+ get:
+ operationId: api_v1_scans_compliance_ocsf_retrieve
+ description: Download a specific compliance report as an OCSF JSON file. Only
+ universal frameworks that declare an output configuration produce this artifact
+ (currently 'dora_2022_2554', 'csa_ccm_4.0' and 'cis_controls_8.1'); any other
+ framework returns 404.
+ summary: Retrieve compliance report as OCSF JSON
+ parameters:
+ - in: query
+ name: fields[scan-reports]
+ schema:
+ type: array
+ items:
+ type: string
+ enum:
+ - id
+ - name
+ description: endpoint return only specific fields in the response on a per-type
+ basis by including a fields[TYPE] query parameter.
+ explode: false
+ - in: path
+ name: id
+ schema:
+ type: string
+ format: uuid
+ description: A UUID string identifying this scan.
+ required: true
+ - in: path
+ name: name
+ schema:
+ type: string
+ description: The compliance report name, like 'dora_2022_2554'
+ required: true
+ tags:
+ - Scan
+ security:
+ - JWT or API Key: []
+ responses:
+ '200':
+ description: OCSF JSON file containing the compliance report
+ '202':
+ description: The task is in progress
+ '403':
+ description: There is a problem with credentials
+ '404':
+ description: Compliance report not found, the framework does not provide
+ an OCSF export, or the scan has no reports yet
/api/v1/scans/{id}/csa:
get:
- operationId: scans_csa_retrieve
+ operationId: api_v1_scans_csa_retrieve
description: Download CSA Cloud Controls Matrix (CCM) v4.0 compliance report
as a PDF file.
summary: Retrieve CSA CCM compliance report
@@ -13107,7 +14532,7 @@ paths:
task has not started yet
/api/v1/scans/{id}/ens:
get:
- operationId: scans_ens_retrieve
+ operationId: api_v1_scans_ens_retrieve
description: Download ENS RD2022 compliance report (e.g., 'ens_rd2022_aws')
as a PDF file.
summary: Retrieve ENS RD2022 compliance report
@@ -13173,7 +14598,7 @@ paths:
has not started yet
/api/v1/scans/{id}/nis2:
get:
- operationId: scans_nis2_retrieve
+ operationId: api_v1_scans_nis2_retrieve
description: Download NIS2 compliance report (Directive (EU) 2022/2555) as a
PDF file.
summary: Retrieve NIS2 compliance report
@@ -13239,7 +14664,7 @@ paths:
task has not started yet
/api/v1/scans/{id}/report:
get:
- operationId: scans_report_retrieve
+ operationId: api_v1_scans_report_retrieve
description: Returns a ZIP file containing the requested report
summary: Download ZIP report
parameters:
@@ -13277,7 +14702,7 @@ paths:
not started yet
/api/v1/scans/{id}/threatscore:
get:
- operationId: scans_threatscore_retrieve
+ operationId: api_v1_scans_threatscore_retrieve
description: Download a specific threatscore report (e.g., 'prowler_threatscore_aws')
as a PDF file.
summary: Retrieve threatscore report
@@ -13343,7 +14768,7 @@ paths:
generation task has not started yet
/api/v1/schedules/daily:
post:
- operationId: schedules_daily_create
+ operationId: api_v1_schedules_daily_create
description: Schedules a daily scan for the specified provider. This endpoint
creates a periodic task that will execute a scan every 24 hours.
summary: Create a daily schedule scan for a given provider
@@ -13387,7 +14812,7 @@ paths:
description: ''
/api/v1/tasks:
get:
- operationId: tasks_list
+ operationId: api_v1_tasks_list
description: Retrieve a list of all tasks with options for filtering by name,
state, and other criteria.
summary: List all tasks
@@ -13487,7 +14912,7 @@ paths:
description: ''
/api/v1/tasks/{id}:
get:
- operationId: tasks_retrieve
+ operationId: api_v1_tasks_retrieve
description: Fetch detailed information about a specific task by its ID.
summary: Retrieve data from a specific task
parameters:
@@ -13527,7 +14952,7 @@ paths:
$ref: '#/components/schemas/TaskResponse'
description: ''
delete:
- operationId: tasks_destroy
+ operationId: api_v1_tasks_destroy
description: Try to revoke a task using its ID. Only tasks that are not yet
in progress can be revoked.
summary: Revoke a task
@@ -13567,7 +14992,7 @@ paths:
description: ''
/api/v1/tenants:
get:
- operationId: tenants_list
+ operationId: api_v1_tenants_list
description: Retrieve a list of all tenants with options for filtering by various
criteria.
summary: List all tenants
@@ -13673,7 +15098,7 @@ paths:
$ref: '#/components/schemas/PaginatedTenantList'
description: ''
post:
- operationId: tenants_create
+ operationId: api_v1_tenants_create
description: Add a new tenant to the system by providing the required tenant
details.
summary: Create a new tenant
@@ -13702,7 +15127,7 @@ paths:
description: ''
/api/v1/tenants/{id}:
get:
- operationId: tenants_retrieve
+ operationId: api_v1_tenants_retrieve
description: Fetch detailed information about a specific tenant by their ID.
summary: Retrieve data from a tenant
parameters:
@@ -13737,7 +15162,7 @@ paths:
$ref: '#/components/schemas/TenantResponse'
description: ''
patch:
- operationId: tenants_partial_update
+ operationId: api_v1_tenants_partial_update
description: Update certain fields of an existing tenant's information without
affecting other fields.
summary: Partially update a tenant
@@ -13773,7 +15198,7 @@ paths:
$ref: '#/components/schemas/TenantResponse'
description: ''
delete:
- operationId: tenants_destroy
+ operationId: api_v1_tenants_destroy
description: Remove a tenant from the system by their ID.
summary: Delete a tenant
parameters:
@@ -13793,7 +15218,7 @@ paths:
description: No response body
/api/v1/tenants/{tenant_pk}/memberships:
get:
- operationId: tenants_memberships_list
+ operationId: api_v1_tenants_memberships_list
description: List the membership details of users in a tenant you are a part
of.
summary: List tenant memberships
@@ -13911,7 +15336,7 @@ paths:
description: ''
/api/v1/tenants/{tenant_pk}/memberships/{id}:
delete:
- operationId: tenants_memberships_destroy
+ operationId: api_v1_tenants_memberships_destroy
description: 'Delete a user''s membership from a tenant. This action: (1) removes
the membership, (2) revokes all refresh tokens for the expelled user, (3)
removes their role grants for this tenant, (4) cleans up orphaned roles, and
@@ -13942,7 +15367,7 @@ paths:
description: No response body
/api/v1/tenants/invitations:
get:
- operationId: tenants_invitations_list
+ operationId: api_v1_tenants_invitations_list
description: Retrieve a list of all tenant invitations with options for filtering
by various criteria.
summary: List all invitations
@@ -14124,7 +15549,7 @@ paths:
$ref: '#/components/schemas/PaginatedInvitationList'
description: ''
post:
- operationId: tenants_invitations_create
+ operationId: api_v1_tenants_invitations_create
description: Add a new tenant invitation to the system by providing the required
invitation details. The invited user will have to accept the invitations or
create an account using the given code.
@@ -14154,7 +15579,7 @@ paths:
description: ''
/api/v1/tenants/invitations/{id}:
get:
- operationId: tenants_invitations_retrieve
+ operationId: api_v1_tenants_invitations_retrieve
description: Fetch detailed information about a specific invitation by its ID.
summary: Retrieve data from a tenant invitation
parameters:
@@ -14195,7 +15620,7 @@ paths:
$ref: '#/components/schemas/InvitationResponse'
description: ''
patch:
- operationId: tenants_invitations_partial_update
+ operationId: api_v1_tenants_invitations_partial_update
description: Update certain fields of an existing tenant invitation's information
without affecting other fields.
summary: Partially update a tenant invitation
@@ -14230,7 +15655,7 @@ paths:
$ref: '#/components/schemas/InvitationUpdateResponse'
description: ''
delete:
- operationId: tenants_invitations_destroy
+ operationId: api_v1_tenants_invitations_destroy
description: Revoke a tenant invitation from the system by their ID.
summary: Revoke a tenant invitation
parameters:
@@ -14249,7 +15674,7 @@ paths:
description: No response body
/api/v1/tokens:
post:
- operationId: tokens_create
+ operationId: api_v1_tokens_create
description: Obtain a token by providing valid credentials and an optional tenant
ID.
summary: Obtain a token
@@ -14279,7 +15704,7 @@ paths:
description: ''
/api/v1/tokens/refresh:
post:
- operationId: tokens_refresh_create
+ operationId: api_v1_tokens_refresh_create
description: Refresh an access token by providing a valid refresh token. Former
refresh tokens are invalidated when a new one is issued.
summary: Refresh a token
@@ -14309,7 +15734,7 @@ paths:
description: ''
/api/v1/tokens/switch:
post:
- operationId: tokens_switch_create
+ operationId: api_v1_tokens_switch_create
description: Switch tenant by providing a valid tenant ID. The authenticated
user must belong to the tenant.
summary: Switch tenant using a valid tenant ID
@@ -14338,7 +15763,7 @@ paths:
description: ''
/api/v1/users:
get:
- operationId: users_list
+ operationId: api_v1_users_list
description: Retrieve a list of all users with options for filtering by various
criteria.
summary: List all users
@@ -14469,7 +15894,7 @@ paths:
$ref: '#/components/schemas/PaginatedUserList'
description: ''
post:
- operationId: users_create
+ operationId: api_v1_users_create
description: Create a new user account by providing the necessary registration
details.
summary: Register a new user
@@ -14506,7 +15931,7 @@ paths:
description: ''
/api/v1/users/{id}:
get:
- operationId: users_retrieve
+ operationId: api_v1_users_retrieve
description: Fetch detailed information about an authenticated user.
summary: Retrieve a user's information
parameters:
@@ -14557,7 +15982,7 @@ paths:
$ref: '#/components/schemas/UserResponse'
description: ''
patch:
- operationId: users_partial_update
+ operationId: api_v1_users_partial_update
description: Partially update information about a user.
summary: Update user information
parameters:
@@ -14592,7 +16017,7 @@ paths:
$ref: '#/components/schemas/UserUpdateResponse'
description: ''
delete:
- operationId: users_destroy
+ operationId: api_v1_users_destroy
description: Remove the current user account from the system.
summary: Delete the user account
parameters:
@@ -14612,7 +16037,7 @@ paths:
description: No response body
/api/v1/users/{id}/relationships/roles:
post:
- operationId: users_relationships_roles_create
+ operationId: api_v1_users_relationships_roles_create
description: Add a new user-roles relationship to the system by providing the
required user-roles details.
summary: Create a new user-roles relationship
@@ -14638,7 +16063,7 @@ paths:
'400':
description: Bad request (e.g., relationship already exists)
patch:
- operationId: users_relationships_roles_partial_update
+ operationId: api_v1_users_relationships_roles_partial_update
description: Update the user-roles relationship information without affecting
other fields. If the update would remove MANAGE_ACCOUNT from the last remaining
user in the tenant, the API rejects the request with a 400 response.
@@ -14663,7 +16088,7 @@ paths:
'204':
description: Relationship updated successfully
delete:
- operationId: users_relationships_roles_destroy
+ operationId: api_v1_users_relationships_roles_destroy
description: Remove the user-roles relationship from the system by their ID.
If removing MANAGE_ACCOUNT would take it away from the last remaining user
in the tenant, the API rejects the request with a 400 response. Users also
@@ -14679,7 +16104,7 @@ paths:
description: Relationship deleted successfully
/api/v1/users/{user_pk}/memberships:
get:
- operationId: users_memberships_list
+ operationId: api_v1_users_memberships_list
description: Retrieve a list of all user memberships with options for filtering
by various criteria.
summary: List user memberships
@@ -14792,7 +16217,7 @@ paths:
description: ''
/api/v1/users/{user_pk}/memberships/{id}:
get:
- operationId: users_memberships_retrieve
+ operationId: api_v1_users_memberships_retrieve
description: Fetch detailed information about a specific user membership by
their ID.
summary: Retrieve membership data from the user
@@ -14837,7 +16262,7 @@ paths:
description: ''
/api/v1/users/me:
get:
- operationId: users_me_retrieve
+ operationId: api_v1_users_me_retrieve
description: Fetch detailed information about the authenticated user.
summary: Retrieve the current user's information
parameters:
@@ -20115,6 +21540,27 @@ components:
required:
- clouds_yaml_content
- clouds_yaml_cloud
+ - type: object
+ title: Okta OAuth Credentials
+ properties:
+ okta_client_id:
+ type: string
+ description: Client ID of the Okta API Services app used for
+ OAuth 2.0 private-key JWT authentication.
+ okta_private_key:
+ type: string
+ description: PEM-encoded private key whose matching public
+ key (JWK) is registered on the Okta service app.
+ okta_scopes:
+ type: array
+ items:
+ type: string
+ description: OAuth scopes to request. Optional; defaults to
+ the minimum set required to run the currently enabled Okta
+ checks.
+ required:
+ - okta_client_id
+ - okta_private_key
- type: object
title: Vercel API Token
properties:
@@ -21127,6 +22573,7 @@ components:
- image
- googleworkspace
- vercel
+ - okta
type: string
description: |-
* `aws` - AWS
@@ -21144,7 +22591,8 @@ components:
* `image` - Image
* `googleworkspace` - Google Workspace
* `vercel` - Vercel
- x-spec-enum-id: 91f917e0c3ab97e8
+ * `okta` - Okta
+ x-spec-enum-id: 203afc16daac9b64
uid:
type: string
title: Unique identifier for the provider, set by the provider
@@ -21265,8 +22713,9 @@ components:
- image
- googleworkspace
- vercel
+ - okta
type: string
- x-spec-enum-id: 91f917e0c3ab97e8
+ x-spec-enum-id: 203afc16daac9b64
description: |-
Type of provider to create.
@@ -21285,6 +22734,7 @@ components:
* `image` - Image
* `googleworkspace` - Google Workspace
* `vercel` - Vercel
+ * `okta` - Okta
uid:
type: string
title: Unique identifier for the provider, set by the provider
@@ -21337,8 +22787,9 @@ components:
- image
- googleworkspace
- vercel
+ - okta
type: string
- x-spec-enum-id: 91f917e0c3ab97e8
+ x-spec-enum-id: 203afc16daac9b64
description: |-
Type of provider to create.
@@ -21357,6 +22808,7 @@ components:
* `image` - Image
* `googleworkspace` - Google Workspace
* `vercel` - Vercel
+ * `okta` - Okta
uid:
type: string
minLength: 3
@@ -22206,6 +23658,26 @@ components:
required:
- clouds_yaml_content
- clouds_yaml_cloud
+ - type: object
+ title: Okta OAuth Credentials
+ properties:
+ okta_client_id:
+ type: string
+ description: Client ID of the Okta API Services app used for OAuth
+ 2.0 private-key JWT authentication.
+ okta_private_key:
+ type: string
+ description: PEM-encoded private key whose matching public key
+ (JWK) is registered on the Okta service app.
+ okta_scopes:
+ type: array
+ items:
+ type: string
+ description: OAuth scopes to request. Optional; defaults to the
+ minimum set required to run the currently enabled Okta checks.
+ required:
+ - okta_client_id
+ - okta_private_key
- type: object
title: Vercel API Token
properties:
@@ -22631,6 +24103,27 @@ components:
required:
- clouds_yaml_content
- clouds_yaml_cloud
+ - type: object
+ title: Okta OAuth Credentials
+ properties:
+ okta_client_id:
+ type: string
+ description: Client ID of the Okta API Services app used for
+ OAuth 2.0 private-key JWT authentication.
+ okta_private_key:
+ type: string
+ description: PEM-encoded private key whose matching public
+ key (JWK) is registered on the Okta service app.
+ okta_scopes:
+ type: array
+ items:
+ type: string
+ description: OAuth scopes to request. Optional; defaults to
+ the minimum set required to run the currently enabled Okta
+ checks.
+ required:
+ - okta_client_id
+ - okta_private_key
- type: object
title: Vercel API Token
properties:
@@ -23066,6 +24559,26 @@ components:
required:
- clouds_yaml_content
- clouds_yaml_cloud
+ - type: object
+ title: Okta OAuth Credentials
+ properties:
+ okta_client_id:
+ type: string
+ description: Client ID of the Okta API Services app used for OAuth
+ 2.0 private-key JWT authentication.
+ okta_private_key:
+ type: string
+ description: PEM-encoded private key whose matching public key
+ (JWK) is registered on the Okta service app.
+ okta_scopes:
+ type: array
+ items:
+ type: string
+ description: OAuth scopes to request. Optional; defaults to the
+ minimum set required to run the currently enabled Okta checks.
+ required:
+ - okta_client_id
+ - okta_private_key
- type: object
title: Vercel API Token
properties:
diff --git a/api/src/backend/api/sse/__init__.py b/api/src/backend/api/sse/__init__.py
new file mode 100644
index 0000000000..dd31d16430
--- /dev/null
+++ b/api/src/backend/api/sse/__init__.py
@@ -0,0 +1,13 @@
+"""Platform Server-Sent Events (SSE) infrastructure.
+
+Wires `django-eventstream` into the API: a base viewset features
+subclass to expose an SSE endpoint
+(:class:`api.sse.base_views.BaseSSEViewSet`), the channel manager that
+enforces the tenant gate (:class:`api.sse.channelmanager.SSEChannelManager`),
+and the channel-name helpers (:func:`api.sse.utils.make_channel_name`).
+"""
+
+from api.sse.base_views import BaseSSEViewSet
+from api.sse.utils import make_channel_name
+
+__all__ = ["BaseSSEViewSet", "make_channel_name"]
diff --git a/api/src/backend/api/sse/base_views.py b/api/src/backend/api/sse/base_views.py
new file mode 100644
index 0000000000..c4a24540ee
--- /dev/null
+++ b/api/src/backend/api/sse/base_views.py
@@ -0,0 +1,46 @@
+"""Base view class for SSE endpoints."""
+
+from api.authentication import SSEAuthentication
+from api.base_views import BaseRLSViewSet
+from django_eventstream.renderers import SSEEventRenderer
+from django_eventstream.views import events
+
+
+class BaseSSEViewSet(BaseRLSViewSet):
+ """Base class for platform SSE endpoints.
+
+ Subclasses override method `get_channels` to declare the channel
+ names the connection should subscribe to — the same way a regular
+ DRF viewset overrides method `get_queryset`. The channel manager
+ reads the result from `request.sse_channels`; there is no other
+ coupling between platform and feature.
+ """
+
+ authentication_classes = [SSEAuthentication]
+ # Pin the SSE renderer so content negotiation accepts the browser's
+ # `Accept: text/event-stream`.
+ renderer_classes = [SSEEventRenderer]
+
+ def get_channels(self) -> set[str]:
+ """Return the channels this connection subscribes to.
+
+ Implementations MUST raise the relevant DRF exceptions
+ (`NotAuthenticated`, `PermissionDenied`, `NotFound`) when
+ authorization fails. Returning an empty set would surface as
+ django-eventstream's "No channels specified" which masks the
+ real cause.
+ """
+ raise NotImplementedError
+
+ def get_queryset(self):
+ # Most SSE viewsets only need `get_channels` and never call
+ # `get_queryset` (the SSE list path bypasses serialization
+ # entirely). Subclasses that perform their own queryset lookup
+ # inside `get_channels` should override; the default raises
+ # the same error a missing override on a ModelViewSet would.
+ raise NotImplementedError
+
+ def list(self, request, *_args, **kwargs):
+ """Resolve channels under the regular DRF stack and stream."""
+ request.sse_channels = self.get_channels()
+ return events(request, **kwargs)
diff --git a/api/src/backend/api/sse/channelmanager.py b/api/src/backend/api/sse/channelmanager.py
new file mode 100644
index 0000000000..84a9362a19
--- /dev/null
+++ b/api/src/backend/api/sse/channelmanager.py
@@ -0,0 +1,75 @@
+"""Channel manager that wires `django-eventstream` to platform SSE views."""
+
+from __future__ import annotations
+
+from typing import TYPE_CHECKING
+from uuid import UUID
+
+from api.sse.utils import tenant_id_from_channel
+from django_eventstream.channelmanager import DefaultChannelManager
+from rest_framework.request import Request
+
+if TYPE_CHECKING:
+ from api.models import User
+
+
+class SSEChannelManager(DefaultChannelManager):
+ """Connect `django-eventstream` to the platform's SSE viewsets."""
+
+ def get_channels_for_request(self, request: Request, view_kwargs: dict) -> set[str]:
+ """Return the request's channels scoped to the active JWT tenant.
+
+ Args:
+ request: The authenticated DRF request, carrying `tenant_id`
+ (set by `BaseRLSViewSet`) and `sse_channels` (set by
+ `BaseSSEViewSet.list`).
+ view_kwargs: URL keyword arguments from django-eventstream;
+ unused because channels are resolved on the request.
+
+ Returns:
+ The subset of `request.sse_channels` whose embedded tenant
+ matches the active request tenant.
+ """
+ _ = view_kwargs
+ try:
+ request_tenant_id = UUID(str(getattr(request, "tenant_id", None)))
+ except (TypeError, ValueError):
+ return set()
+ return {
+ channel
+ for channel in getattr(request, "sse_channels", set())
+ if tenant_id_from_channel(channel) == request_tenant_id
+ }
+
+ def can_read_channel(self, user: User | None, channel: str) -> bool:
+ """Re-verify tenant membership once the stream is established.
+
+ Args:
+ user: The connection's authenticated `User`, or `None` for an
+ anonymous connection — django-eventstream passes `None`
+ rather than an `AnonymousUser`.
+ channel: The channel name being read, in the canonical
+ `::` format.
+
+ Returns:
+ `True` only when `user` is authenticated and a member of the
+ tenant embedded in `channel`; `False` otherwise, including for
+ anonymous connections and malformed channel names.
+ """
+ if user is None or not user.is_authenticated:
+ return False
+ tenant_id = tenant_id_from_channel(channel)
+ if tenant_id is None:
+ return False
+ return user.is_member_of_tenant(tenant_id)
+
+ def is_channel_reliable(self, channel: str) -> bool:
+ """Report whether the channel keeps a server-side replay buffer.
+
+ Args:
+ channel: The channel name being queried.
+
+ Returns:
+ `False`, unconditionally. Replay storage is not configured
+ """
+ return False
diff --git a/api/src/backend/api/sse/utils.py b/api/src/backend/api/sse/utils.py
new file mode 100644
index 0000000000..a30ed26311
--- /dev/null
+++ b/api/src/backend/api/sse/utils.py
@@ -0,0 +1,51 @@
+"""Channel-name convention shared by SSE publishers, consumers, and the
+channel manager. The format is `::`.
+"""
+
+from __future__ import annotations
+
+import uuid
+
+CHANNEL_SEPARATOR = ":"
+
+
+def make_channel_name(
+ prefix: str,
+ tenant_id: str | uuid.UUID,
+ resource_id: str | uuid.UUID,
+) -> str:
+ """Build the canonical channel name for a resource.
+
+ Args:
+ prefix: Feature-owned prefix (e.g. `"lighthouse-session"`).
+ tenant_id: Tenant the resource belongs to.
+ resource_id: Resource identifier within the tenant.
+
+ Raises:
+ ValueError: If any segment contains `CHANNEL_SEPARATOR`, which
+ would break the `::` contract
+ and let a crafted name smuggle extra segments past the parser.
+ """
+ segments = (str(prefix), str(tenant_id), str(resource_id))
+ if any(CHANNEL_SEPARATOR in segment for segment in segments):
+ raise ValueError(
+ f"Channel segments must not contain '{CHANNEL_SEPARATOR}': {segments!r}"
+ )
+ return CHANNEL_SEPARATOR.join(segments)
+
+
+def tenant_id_from_channel(channel: str) -> uuid.UUID | None:
+ """Return the tenant UUID embedded in *channel*, or `None` if
+ *channel* does not follow the platform convention.
+
+ A `None` result MUST be treated by callers as "not authorized" or
+ a malformed channel cannot be safely read.
+ """
+ segments = channel.split(CHANNEL_SEPARATOR)
+ if len(segments) != 3:
+ # Reject non-canonical names
+ return None
+ try:
+ return uuid.UUID(segments[1])
+ except ValueError:
+ return None
diff --git a/api/src/backend/api/tests/integration/test_authentication.py b/api/src/backend/api/tests/integration/test_authentication.py
index 061c2efac0..67f498b238 100644
--- a/api/src/backend/api/tests/integration/test_authentication.py
+++ b/api/src/backend/api/tests/integration/test_authentication.py
@@ -1,14 +1,31 @@
+import json
import time
-from datetime import datetime, timedelta, timezone
+from datetime import UTC, datetime, timedelta
from uuid import uuid4
import pytest
+from api.models import Membership, Role, TenantAPIKey, User, UserRoleRelationship
from conftest import TEST_PASSWORD, get_api_tokens, get_authorization_header
from django.urls import reverse
from drf_simple_apikey.crypto import get_crypto
from rest_framework.test import APIClient
+from rest_framework_simplejwt.token_blacklist.models import (
+ BlacklistedToken,
+ OutstandingToken,
+)
-from api.models import Membership, Role, TenantAPIKey, User, UserRoleRelationship
+PASSWORD_CHANGE_PASSWORD = "InitialSecret123@"
+
+
+@pytest.fixture
+def password_change_user(tenants_fixture):
+ user = User.objects.create_user(
+ name="password_change_user",
+ email=f"password-change-{uuid4()}@prowler.com",
+ password=PASSWORD_CHANGE_PASSWORD,
+ )
+ Membership.objects.create(user=user, tenant=tenants_fixture[0])
+ return user
@pytest.mark.django_db
@@ -104,6 +121,120 @@ def test_refresh_token(create_test_user, tenants_fixture):
assert new_refresh_response.status_code == 200
+@pytest.mark.django_db
+def test_password_change_invalidates_existing_tokens(password_change_user):
+ client = APIClient()
+ new_password = "ChangedSecret123@"
+
+ access_token, refresh_token = get_api_tokens(
+ client, password_change_user.email, PASSWORD_CHANGE_PASSWORD
+ )
+ auth_headers = get_authorization_header(access_token)
+ outstanding_token_ids = list(
+ OutstandingToken.objects.filter(user=password_change_user).values_list(
+ "id", flat=True
+ )
+ )
+ assert outstanding_token_ids
+ assert not BlacklistedToken.objects.filter(
+ token_id__in=outstanding_token_ids
+ ).exists()
+
+ password_change_payload = {
+ "data": {
+ "type": "users",
+ "id": str(password_change_user.id),
+ "attributes": {"password": new_password},
+ }
+ }
+ password_change_response = client.patch(
+ reverse("user-detail", kwargs={"pk": password_change_user.id}),
+ data=json.dumps(password_change_payload),
+ headers=auth_headers,
+ content_type="application/vnd.api+json",
+ )
+ assert password_change_response.status_code == 200, password_change_response.json()
+ assert BlacklistedToken.objects.filter(
+ token_id__in=outstanding_token_ids
+ ).count() == len(outstanding_token_ids)
+
+ old_access_response = client.get(reverse("user-me"), headers=auth_headers)
+ assert old_access_response.status_code == 401
+
+ old_refresh_response = client.post(
+ reverse("token-refresh"),
+ data={
+ "data": {
+ "type": "tokens-refresh",
+ "attributes": {"refresh": refresh_token},
+ }
+ },
+ format="vnd.api+json",
+ )
+ assert old_refresh_response.status_code == 400
+
+ new_access_token, _ = get_api_tokens(
+ client, password_change_user.email, new_password
+ )
+ new_access_response = client.get(
+ reverse("user-me"), headers=get_authorization_header(new_access_token)
+ )
+ assert new_access_response.status_code == 200
+
+
+@pytest.mark.django_db
+def test_password_change_invalidates_rotated_refresh_token(
+ password_change_user,
+):
+ client = APIClient()
+ new_password = "ChangedSecret123@"
+
+ access_token, refresh_token = get_api_tokens(
+ client, password_change_user.email, PASSWORD_CHANGE_PASSWORD
+ )
+ rotated_refresh_response = client.post(
+ reverse("token-refresh"),
+ data={
+ "data": {
+ "type": "tokens-refresh",
+ "attributes": {"refresh": refresh_token},
+ }
+ },
+ format="vnd.api+json",
+ )
+ assert rotated_refresh_response.status_code == 200
+ rotated_refresh_token = rotated_refresh_response.json()["data"]["attributes"][
+ "refresh"
+ ]
+
+ password_change_payload = {
+ "data": {
+ "type": "users",
+ "id": str(password_change_user.id),
+ "attributes": {"password": new_password},
+ }
+ }
+ password_change_response = client.patch(
+ reverse("user-detail", kwargs={"pk": password_change_user.id}),
+ data=json.dumps(password_change_payload),
+ headers=get_authorization_header(access_token),
+ content_type="application/vnd.api+json",
+ )
+ assert password_change_response.status_code == 200, password_change_response.json()
+
+ old_rotated_refresh_response = client.post(
+ reverse("token-refresh"),
+ data={
+ "data": {
+ "type": "tokens-refresh",
+ "attributes": {"refresh": rotated_refresh_token},
+ }
+ },
+ format="vnd.api+json",
+ )
+ assert old_rotated_refresh_response.status_code == 400
+
+
@pytest.mark.django_db
def test_user_me_when_inviting_users(create_test_user, tenants_fixture, roles_fixture):
client = APIClient()
@@ -188,8 +319,9 @@ def test_user_me_when_inviting_users(create_test_user, tenants_fixture, roles_fi
@pytest.mark.django_db
class TestTokenSwitchTenant:
- def test_switch_tenant_with_valid_token(self, tenants_fixture, providers_fixture):
+ def test_switch_tenant_with_valid_token(self, tenants_fixture, aws_provider):
client = APIClient()
+ assert aws_provider
test_user = "test_email@prowler.com"
test_password = "Test_password1@"
@@ -468,7 +600,7 @@ class TestAPIKeyErrors:
name="Expired Key",
tenant_id=tenants_fixture[0].id,
entity=create_test_user,
- expiry_date=datetime.now(timezone.utc) - timedelta(days=1),
+ expiry_date=datetime.now(UTC) - timedelta(days=1),
)
api_key_headers = get_api_key_header(raw_key)
@@ -500,7 +632,7 @@ class TestAPIKeyErrors:
# Create a valid-looking key with non-existent UUID
crypto = get_crypto()
fake_uuid = str(uuid4())
- fake_expiry = (datetime.now(timezone.utc) + timedelta(days=30)).timestamp()
+ fake_expiry = (datetime.now(UTC) + timedelta(days=30)).timestamp()
payload = {"_pk": fake_uuid, "_exp": fake_expiry}
encrypted_payload = crypto.generate(payload)
@@ -723,7 +855,7 @@ class TestAPIKeyLifecycle:
assert created_data["attributes"]["revoked"] is False
# Create API key with expiry
- future_expiry = (datetime.now(timezone.utc) + timedelta(days=90)).isoformat()
+ future_expiry = (datetime.now(UTC) + timedelta(days=90)).isoformat()
create_with_expiry_response = client.post(
reverse("api-key-list"),
data={
@@ -927,9 +1059,9 @@ class TestAPIKeyLifecycle:
auth_response = client.get(reverse("provider-list"), headers=api_key_headers)
# Must return 401 Unauthorized, not 500 Internal Server Error
- assert (
- auth_response.status_code == 401
- ), f"Expected 401 but got {auth_response.status_code}: {auth_response.json()}"
+ assert auth_response.status_code == 401, (
+ f"Expected 401 but got {auth_response.status_code}: {auth_response.json()}"
+ )
# Verify error message is present
response_json = auth_response.json()
@@ -1267,7 +1399,7 @@ class TestAPIKeyRLSBypass:
name="Expired Test Key",
tenant_id=tenant.id,
entity=create_test_user,
- expiry_date=datetime.now(timezone.utc) - timedelta(days=1),
+ expiry_date=datetime.now(UTC) - timedelta(days=1),
)
api_key_headers = get_api_key_header(raw_key)
@@ -1397,13 +1529,14 @@ class TestAPIKeyMultiTenantWorkflows:
assert me_response2.json()["data"]["id"] == str(user.id)
def test_api_key_cannot_access_different_tenant_resources(
- self, tenants_fixture, providers_fixture
+ self, tenants_fixture, aws_provider
):
"""API key from one tenant cannot access resources from another tenant.
Verifies RLS enforcement after authentication ensures tenant isolation.
"""
client = APIClient()
+ assert aws_provider
user1 = User.objects.create_user(
name="tenant1_user",
diff --git a/api/src/backend/api/tests/integration/test_providers.py b/api/src/backend/api/tests/integration/test_providers.py
index 9c91ad2c07..e797160a15 100644
--- a/api/src/backend/api/tests/integration/test_providers.py
+++ b/api/src/backend/api/tests/integration/test_providers.py
@@ -1,12 +1,11 @@
from unittest.mock import Mock, patch
import pytest
+from api.models import Provider
from conftest import get_api_tokens, get_authorization_header
from django.urls import reverse
from rest_framework.test import APIClient
-from api.models import Provider
-
@patch("api.v1.views.Task.objects.get")
@patch("api.v1.views.delete_provider_task.delay")
diff --git a/api/src/backend/api/tests/integration/test_rls_transaction.py b/api/src/backend/api/tests/integration/test_rls_transaction.py
index 6731b39d71..ce026d4f58 100644
--- a/api/src/backend/api/tests/integration/test_rls_transaction.py
+++ b/api/src/backend/api/tests/integration/test_rls_transaction.py
@@ -1,10 +1,13 @@
"""Tests for rls_transaction retry and fallback logic."""
-import pytest
-from django.db import DEFAULT_DB_ALIAS
-from rest_framework_json_api.serializers import ValidationError
+from unittest.mock import patch
-from api.db_utils import rls_transaction
+import pytest
+from api.db_utils import POSTGRES_TENANT_VAR, rls_transaction
+from conftest import TEST_REPLICA_ALIAS
+from django.db import DEFAULT_DB_ALIAS, OperationalError, connections
+from psycopg2 import OperationalError as Psycopg2OperationalError
+from rest_framework_json_api.serializers import ValidationError
@pytest.mark.django_db
@@ -37,3 +40,35 @@ class TestRLSTransaction:
cursor.execute("SELECT current_setting(%s, true)", [custom_param])
result = cursor.fetchone()
assert result == (str(tenant.id),)
+
+ @pytest.mark.requires_test_replica_alias
+ @pytest.mark.django_db(
+ transaction=True, databases=[DEFAULT_DB_ALIAS, TEST_REPLICA_ALIAS]
+ )
+ def test_mid_query_replica_connection_loss_falls_back_to_primary(self, tenant):
+ """Real Django connection state: closed replica atomic falls back to primary."""
+ replica = connections[TEST_REPLICA_ALIAS]
+ sql = "SELECT current_setting(%s, true), %s"
+ params = [POSTGRES_TENANT_VAR, 42]
+ failed_once = {"value": False}
+
+ def close_replica_and_raise(execute, sql_arg, params_arg, many, context):
+ if not failed_once["value"] and sql_arg == sql:
+ failed_once["value"] = True
+ replica.close()
+ try:
+ raise Psycopg2OperationalError("SSL SYSCALL error: EOF detected")
+ except Psycopg2OperationalError as psycopg_error:
+ raise OperationalError(
+ "SSL SYSCALL error: EOF detected"
+ ) from psycopg_error
+ return execute(sql_arg, params_arg, many, context)
+
+ with patch("api.db_utils.READ_REPLICA_ALIAS", TEST_REPLICA_ALIAS):
+ with rls_transaction(str(tenant.id), using=TEST_REPLICA_ALIAS) as cursor:
+ with replica.execute_wrapper(close_replica_and_raise):
+ cursor.execute(sql, params)
+ result = cursor.fetchone()
+
+ assert failed_once["value"]
+ assert result == (str(tenant.id), 42)
diff --git a/api/src/backend/api/tests/integration/test_tenants.py b/api/src/backend/api/tests/integration/test_tenants.py
index e14226164a..4d5dd8a523 100644
--- a/api/src/backend/api/tests/integration/test_tenants.py
+++ b/api/src/backend/api/tests/integration/test_tenants.py
@@ -1,10 +1,9 @@
from unittest.mock import patch
import pytest
+from conftest import TEST_PASSWORD, TEST_USER, get_api_tokens, get_authorization_header
from django.urls import reverse
-from conftest import TEST_USER, TEST_PASSWORD, get_api_tokens, get_authorization_header
-
@patch("api.v1.views.schedule_provider_scan")
@pytest.mark.django_db
diff --git a/api/src/backend/api/tests/test_adapters.py b/api/src/backend/api/tests/test_adapters.py
index 22b44b3506..c86d3f5620 100644
--- a/api/src/backend/api/tests/test_adapters.py
+++ b/api/src/backend/api/tests/test_adapters.py
@@ -1,13 +1,113 @@
+from types import SimpleNamespace
from unittest.mock import MagicMock, patch
import pytest
-from allauth.socialaccount.models import SocialLogin
-from django.contrib.auth import get_user_model
-
+from allauth.account import app_settings as account_app_settings
+from allauth.account.models import EmailAddress
+from allauth.core import context
+from allauth.core.exceptions import ImmediateHttpResponse
+from allauth.socialaccount import app_settings as socialaccount_app_settings
+from allauth.socialaccount.internal.flows.login import complete_login
+from allauth.socialaccount.models import SocialAccount, SocialLogin
from api.adapters import ProwlerSocialAccountAdapter
+from api.db_router import MainRouter
+from api.models import Invitation, Membership, SAMLConfiguration, Tenant
+from django.contrib.auth import get_user_model
+from django.core import mail
User = get_user_model()
+# Minimal, well-formed IdP metadata accepted by SAMLConfiguration._parse_metadata.
+VALID_METADATA = """
+
+
+
+
+
+ FAKECERTDATA
+
+
+
+
+
+
+"""
+
+
+def _saml_request(rf, organization_slug):
+ """Build an ACS request whose resolver_match carries the organization slug,
+ mirroring how Django populates it after routing the SAML ACS URL."""
+ request = rf.post(f"/api/v1/accounts/saml/{organization_slug}/acs/finish/")
+ request.resolver_match = SimpleNamespace(
+ kwargs={"organization_slug": organization_slug}
+ )
+ return request
+
+
+def _saml_sociallogin(user):
+ sociallogin = MagicMock(spec=SocialLogin)
+ sociallogin.account = MagicMock()
+ sociallogin.account.pk = None
+ sociallogin.provider = MagicMock()
+ sociallogin.provider.id = "saml"
+ sociallogin.account.extra_data = {}
+ sociallogin.user = user
+ sociallogin.connect = MagicMock()
+ return sociallogin
+
+
+def _oauth_sociallogin(
+ user,
+ *,
+ provider="google",
+ provider_email_verified=True,
+ include_extra_email=True,
+):
+ sociallogin = MagicMock(spec=SocialLogin)
+ sociallogin.account = MagicMock()
+ sociallogin.account.pk = None
+ sociallogin.provider = MagicMock()
+ sociallogin.provider.id = provider
+ sociallogin.account.extra_data = (
+ {"email": user.email} if include_extra_email else {}
+ )
+ sociallogin.email_addresses = [
+ EmailAddress(
+ email=user.email,
+ verified=provider_email_verified,
+ primary=True,
+ )
+ ]
+ sociallogin.user = user
+ sociallogin.connect = MagicMock()
+ return sociallogin
+
+
+def _real_oauth_sociallogin(user, uid):
+ provider = MagicMock()
+ provider.id = "google"
+ provider.app = None
+ provider.get_settings.return_value = {}
+ return SocialLogin(
+ user=user,
+ account=SocialAccount(
+ provider="google",
+ uid=uid,
+ extra_data={"email": user.email},
+ ),
+ email_addresses=[EmailAddress(email=user.email, verified=True, primary=True)],
+ provider=provider,
+ )
+
+
+def _verify_local_email(user):
+ return EmailAddress.objects.create(
+ user=user,
+ email=user.email,
+ verified=True,
+ primary=True,
+ )
+
@pytest.mark.django_db
class TestProwlerSocialAccountAdapter:
@@ -20,41 +120,268 @@ class TestProwlerSocialAccountAdapter:
adapter = ProwlerSocialAccountAdapter()
assert adapter.get_user_by_email("notfound@example.com") is None
- def test_pre_social_login_links_existing_user(self, create_test_user, rf):
+ def test_pre_social_login_links_member_of_saml_tenant(
+ self, create_test_user, tenants_fixture, rf
+ ):
+ """A SAML login links to an existing account only when that user is
+ already a member of the tenant that owns the asserted email domain."""
adapter = ProwlerSocialAccountAdapter()
+ # create_test_user (dev@prowler.com) is a member of tenant1.
+ domain = create_test_user.email.rsplit("@", 1)[-1]
+ SAMLConfiguration.objects.using(MainRouter.admin_db).create(
+ email_domain=domain,
+ metadata_xml=VALID_METADATA,
+ tenant=tenants_fixture[0],
+ )
- sociallogin = MagicMock(spec=SocialLogin)
- sociallogin.account = MagicMock()
- sociallogin.provider = MagicMock()
- sociallogin.provider.id = "saml"
- sociallogin.account.extra_data = {}
- sociallogin.user = create_test_user
- sociallogin.connect = MagicMock()
-
- adapter.pre_social_login(rf.get("/"), sociallogin)
+ sociallogin = _saml_sociallogin(create_test_user)
+ adapter.pre_social_login(_saml_request(rf, domain), sociallogin)
call_args = sociallogin.connect.call_args
assert call_args is not None
-
- called_request, called_user = call_args[0]
- assert called_request.path == "/"
+ _, called_user = call_args[0]
assert called_user.email == create_test_user.email
+ def test_pre_social_login_blocks_cross_tenant_takeover(
+ self, create_test_user, tenants_fixture, rf
+ ):
+ """GHSA-h8m9-jgf8-vwvp: an attacker tenant that claims the victim's
+ email domain must NOT be able to link to the victim's account, because
+ the victim is not a member of the attacker's tenant."""
+ adapter = ProwlerSocialAccountAdapter()
+ domain = create_test_user.email.rsplit("@", 1)[-1]
+ # tenant3 is the attacker tenant; create_test_user is NOT a member of it.
+ attacker_tenant = tenants_fixture[2]
+ assert not create_test_user.is_member_of_tenant(str(attacker_tenant.id))
+ SAMLConfiguration.objects.using(MainRouter.admin_db).create(
+ email_domain=domain,
+ metadata_xml=VALID_METADATA,
+ tenant=attacker_tenant,
+ )
+
+ sociallogin = _saml_sociallogin(create_test_user)
+ adapter.pre_social_login(_saml_request(rf, domain), sociallogin)
+
+ sociallogin.connect.assert_not_called()
+
+ def test_pre_social_login_blocks_domain_slug_mismatch(
+ self, create_test_user, tenants_fixture, rf
+ ):
+ """The asserted email domain must match the ACS endpoint's slug, so an
+ assertion cannot be replayed through a different tenant's endpoint."""
+ adapter = ProwlerSocialAccountAdapter()
+ domain = create_test_user.email.rsplit("@", 1)[-1]
+ SAMLConfiguration.objects.using(MainRouter.admin_db).create(
+ email_domain=domain,
+ metadata_xml=VALID_METADATA,
+ tenant=tenants_fixture[0],
+ )
+
+ sociallogin = _saml_sociallogin(create_test_user)
+ # Slug points at a different domain than the asserted email.
+ adapter.pre_social_login(_saml_request(rf, "attacker.com"), sociallogin)
+
+ sociallogin.connect.assert_not_called()
+
+ def test_pre_social_login_blocks_when_no_saml_config(
+ self, create_test_user, tenants_fixture, rf
+ ):
+ """No SAML configuration for the domain means nothing to link against."""
+ adapter = ProwlerSocialAccountAdapter()
+ domain = create_test_user.email.rsplit("@", 1)[-1]
+
+ sociallogin = _saml_sociallogin(create_test_user)
+ adapter.pre_social_login(_saml_request(rf, domain), sociallogin)
+
+ sociallogin.connect.assert_not_called()
+
+ def test_pre_social_login_blocks_without_resolver_match(
+ self, create_test_user, tenants_fixture, rf
+ ):
+ """Fail closed: if the request has no resolver_match we cannot bind the
+ assertion to a tenant, so no linking happens."""
+ adapter = ProwlerSocialAccountAdapter()
+ domain = create_test_user.email.rsplit("@", 1)[-1]
+ SAMLConfiguration.objects.using(MainRouter.admin_db).create(
+ email_domain=domain,
+ metadata_xml=VALID_METADATA,
+ tenant=tenants_fixture[0],
+ )
+
+ sociallogin = _saml_sociallogin(create_test_user)
+ adapter.pre_social_login(rf.post("/"), sociallogin)
+
+ sociallogin.connect.assert_not_called()
+
def test_pre_social_login_no_link_if_email_missing(self, rf):
adapter = ProwlerSocialAccountAdapter()
sociallogin = MagicMock(spec=SocialLogin)
sociallogin.account = MagicMock()
+ sociallogin.account.pk = None
sociallogin.provider = MagicMock()
sociallogin.user = MagicMock()
+ sociallogin.user.email = ""
sociallogin.provider.id = "saml"
sociallogin.account.extra_data = {}
sociallogin.connect = MagicMock()
+ adapter.pre_social_login(_saml_request(rf, "prowler.com"), sociallogin)
+
+ sociallogin.connect.assert_not_called()
+
+ def test_pre_social_login_blocks_unverified_local_email(self, create_test_user, rf):
+ """A verified OAuth email must not claim an unverified local account."""
+ adapter = ProwlerSocialAccountAdapter()
+ sociallogin = _oauth_sociallogin(create_test_user)
+
+ with pytest.raises(ImmediateHttpResponse) as exc_info:
+ adapter.pre_social_login(rf.get("/"), sociallogin)
+
+ assert exc_info.value.response.status_code == 403
+ sociallogin.connect.assert_not_called()
+
+ def test_complete_oauth_login_does_not_link_unverified_local_email(
+ self, create_test_user, rf
+ ):
+ """Regression test for the complete pre-hijack account-linking flow."""
+ incoming_user = User(email=create_test_user.email)
+ incoming_user.set_unusable_password()
+ sociallogin = _real_oauth_sociallogin(
+ incoming_user,
+ uid="victim-google-account",
+ )
+ request = rf.get("/")
+ request.session = {}
+
+ with pytest.raises(ImmediateHttpResponse) as exc_info:
+ complete_login(request, sociallogin, raises=True)
+
+ assert exc_info.value.response.status_code == 403
+ assert not SocialAccount.objects.filter(
+ provider="google", uid="victim-google-account"
+ ).exists()
+
+ def test_pre_social_login_allows_already_connected_account(
+ self, create_test_user, rf
+ ):
+ """Existing provider bindings do not need to relink on every login."""
+ adapter = ProwlerSocialAccountAdapter()
+ sociallogin = _oauth_sociallogin(create_test_user)
+ sociallogin.account.pk = "existing-social-account"
+
adapter.pre_social_login(rf.get("/"), sociallogin)
sociallogin.connect.assert_not_called()
+ def test_pre_social_login_blocks_unverified_provider_email(
+ self, create_test_user, rf
+ ):
+ """An OAuth provider must prove ownership of the matching email."""
+ _verify_local_email(create_test_user)
+ adapter = ProwlerSocialAccountAdapter()
+ sociallogin = _oauth_sociallogin(
+ create_test_user,
+ provider="github",
+ provider_email_verified=False,
+ )
+
+ with pytest.raises(ImmediateHttpResponse) as exc_info:
+ adapter.pre_social_login(rf.get("/"), sociallogin)
+
+ assert exc_info.value.response.status_code == 403
+ sociallogin.connect.assert_not_called()
+
+ def test_pre_social_login_links_verified_emails(self, create_test_user, rf):
+ _verify_local_email(create_test_user)
+ adapter = ProwlerSocialAccountAdapter()
+ sociallogin = _oauth_sociallogin(create_test_user)
+ request = rf.get("/")
+
+ adapter.pre_social_login(request, sociallogin)
+
+ sociallogin.connect.assert_called_once_with(request, create_test_user)
+
+ def test_verified_social_account_link_does_not_send_notification(
+ self, create_test_user, rf
+ ):
+ _verify_local_email(create_test_user)
+ sociallogin = _real_oauth_sociallogin(
+ create_test_user,
+ uid="verified-google-account",
+ )
+
+ request = rf.get("/")
+ with context.request_context(request):
+ ProwlerSocialAccountAdapter().pre_social_login(request, sociallogin)
+
+ assert SocialAccount.objects.filter(
+ provider="google",
+ uid="verified-google-account",
+ user=create_test_user,
+ ).exists()
+ assert mail.outbox == []
+
+ def test_pre_social_login_uses_verified_email_missing_from_extra_data(
+ self, create_test_user, rf
+ ):
+ """GitHub can return its verified primary email outside extra_data."""
+ _verify_local_email(create_test_user)
+ adapter = ProwlerSocialAccountAdapter()
+ sociallogin = _oauth_sociallogin(
+ create_test_user,
+ provider="github",
+ include_extra_email=False,
+ )
+ request = rf.get("/")
+
+ adapter.pre_social_login(request, sociallogin)
+
+ sociallogin.connect.assert_called_once_with(request, create_test_user)
+
+ def test_social_account_linking_settings_are_fail_closed(self):
+ assert not socialaccount_app_settings.EMAIL_AUTHENTICATION
+ assert not socialaccount_app_settings.EMAIL_AUTHENTICATION_AUTO_CONNECT
+ assert not account_app_settings.EMAIL_NOTIFICATIONS
+
+ def test_save_user_social_with_invitation_joins_invited_tenant(
+ self, rf, create_test_user, tenants_fixture
+ ):
+ adapter = ProwlerSocialAccountAdapter()
+ invited_tenant = tenants_fixture[2]
+ invited_email = "frank-invited@example.com"
+ invitation = Invitation.objects.create(
+ tenant=invited_tenant,
+ email=invited_email,
+ inviter=create_test_user,
+ )
+ request = rf.post("/", data={"invitation_token": invitation.token})
+ request.session = {}
+
+ sociallogin = MagicMock(spec=SocialLogin)
+ sociallogin.provider = MagicMock()
+ sociallogin.provider.id = "google"
+ sociallogin.account = MagicMock()
+ sociallogin.account.extra_data = {"name": "Frank"}
+
+ real_user = User.objects.create_user(
+ name="Frank", email=invited_email, password="Secret123!"
+ )
+ tenants_before = Tenant.objects.count()
+
+ with patch("api.adapters.super") as mock_super:
+ mock_super.return_value.save_user.return_value = real_user
+ adapter.save_user(request, sociallogin)
+
+ invitation.refresh_from_db()
+ assert invitation.state == Invitation.State.ACCEPTED
+ assert Tenant.objects.count() == tenants_before
+ assert Membership.objects.filter(
+ user=real_user,
+ tenant=invited_tenant,
+ role=Membership.RoleChoices.MEMBER,
+ ).exists()
+
def test_save_user_saml_sets_session_flag(self, rf):
adapter = ProwlerSocialAccountAdapter()
request = rf.get("/")
diff --git a/api/src/backend/api/tests/test_apps.py b/api/src/backend/api/tests/test_apps.py
index 5889b4e2cb..93934df674 100644
--- a/api/src/backend/api/tests/test_apps.py
+++ b/api/src/backend/api/tests/test_apps.py
@@ -4,11 +4,9 @@ import types
from pathlib import Path
from unittest.mock import MagicMock, patch
-import pytest
-from django.conf import settings
-
import api
import api.apps as api_apps_module
+import pytest
from api.apps import (
PRIVATE_KEY_FILE,
PUBLIC_KEY_FILE,
@@ -16,6 +14,7 @@ from api.apps import (
VERIFYING_KEY_ENV,
ApiConfig,
)
+from django.conf import settings
@pytest.fixture(autouse=True)
@@ -182,23 +181,19 @@ def _make_app():
return ApiConfig("api", api)
-def test_ready_initializes_driver_for_api_process(monkeypatch):
+@pytest.mark.parametrize(
+ "argv",
+ [
+ ["gunicorn"],
+ ["celery", "-A", "api"],
+ ["manage.py", "migrate"],
+ ],
+ ids=["api", "celery", "manage_py"],
+)
+def test_ready_never_eagerly_initializes_neo4j_driver(monkeypatch, argv):
+ """ready() must never contact Neo4j; the driver is created lazily on first use."""
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_argv(monkeypatch, argv)
_set_testing(monkeypatch, False)
with (
@@ -208,31 +203,3 @@ def test_ready_skips_driver_for_celery(monkeypatch):
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
index 019b6aa1f2..77bc01d255 100644
--- a/api/src/backend/api/tests/test_attack_paths.py
+++ b/api/src/backend/api/tests/test_attack_paths.py
@@ -1,14 +1,12 @@
from types import SimpleNamespace
from unittest.mock import MagicMock, patch
-import pytest
import neo4j
import neo4j.exceptions
-
-from rest_framework.exceptions import APIException, PermissionDenied, ValidationError
-
+import pytest
from api.attack_paths import database as graph_database
from api.attack_paths import views_helpers
+from rest_framework.exceptions import APIException, PermissionDenied, ValidationError
from tasks.jobs.attack_paths.config import (
PROVIDER_ELEMENT_ID_PROPERTY,
get_provider_label,
@@ -94,7 +92,9 @@ def test_prepare_parameters_validates_cast(
def test_execute_query_serializes_graph(
- attack_paths_query_definition_factory, attack_paths_graph_stub_classes
+ attack_paths_query_definition_factory,
+ attack_paths_graph_stub_classes,
+ sink_backend_stub,
):
definition = attack_paths_query_definition_factory(
id="aws-rds",
@@ -137,18 +137,17 @@ def test_execute_query_serializes_graph(
database_name = "db-tenant-test-tenant-id"
- with patch(
- "api.attack_paths.views_helpers.graph_database.execute_read_query",
- return_value=graph_result,
- ) as mock_execute_read_query:
- result = views_helpers.execute_query(
- database_name, definition, parameters, provider_id=provider_id
- )
+ sink_backend_stub.execute_read_query.return_value = graph_result
+ result = views_helpers.execute_query(
+ database_name,
+ definition,
+ parameters,
+ provider_id=provider_id,
+ scan=MagicMock(is_migrated=False, sink_backend="neo4j"),
+ )
- mock_execute_read_query.assert_called_once_with(
- database=database_name,
- cypher=definition.cypher,
- parameters=parameters,
+ sink_backend_stub.execute_read_query.assert_called_once_with(
+ database_name, definition.cypher, parameters
)
assert result["nodes"][0]["id"] == "node-1"
assert result["nodes"][0]["properties"]["complex"]["items"][0] == "value"
@@ -157,6 +156,7 @@ def test_execute_query_serializes_graph(
def test_execute_query_wraps_graph_errors(
attack_paths_query_definition_factory,
+ sink_backend_stub,
):
definition = attack_paths_query_definition_factory(
id="aws-rds",
@@ -169,16 +169,17 @@ def test_execute_query_wraps_graph_errors(
database_name = "db-tenant-test-tenant-id"
parameters = {"provider_uid": "123"}
- with (
- patch(
- "api.attack_paths.views_helpers.graph_database.execute_read_query",
- side_effect=graph_database.GraphDatabaseQueryException("boom"),
- ),
- patch("api.attack_paths.views_helpers.logger") as mock_logger,
- ):
+ sink_backend_stub.execute_read_query.side_effect = (
+ graph_database.GraphDatabaseQueryException("boom")
+ )
+ with patch("api.attack_paths.views_helpers.logger") as mock_logger:
with pytest.raises(APIException):
views_helpers.execute_query(
- database_name, definition, parameters, provider_id="test-provider-123"
+ database_name,
+ definition,
+ parameters,
+ provider_id="test-provider-123",
+ scan=MagicMock(is_migrated=False, sink_backend="neo4j"),
)
mock_logger.error.assert_called_once()
@@ -186,6 +187,7 @@ def test_execute_query_wraps_graph_errors(
def test_execute_query_raises_permission_denied_on_read_only(
attack_paths_query_definition_factory,
+ sink_backend_stub,
):
definition = attack_paths_query_definition_factory(
id="aws-rds",
@@ -198,17 +200,20 @@ def test_execute_query_raises_permission_denied_on_read_only(
database_name = "db-tenant-test-tenant-id"
parameters = {"provider_uid": "123"}
- with patch(
- "api.attack_paths.views_helpers.graph_database.execute_read_query",
- side_effect=graph_database.WriteQueryNotAllowedException(
+ sink_backend_stub.execute_read_query.side_effect = (
+ graph_database.WriteQueryNotAllowedException(
message="Read query not allowed",
code="Neo.ClientError.Statement.AccessMode",
- ),
- ):
- with pytest.raises(PermissionDenied):
- views_helpers.execute_query(
- database_name, definition, parameters, provider_id="test-provider-123"
- )
+ )
+ )
+ with pytest.raises(PermissionDenied):
+ views_helpers.execute_query(
+ database_name,
+ definition,
+ parameters,
+ provider_id="test-provider-123",
+ scan=MagicMock(is_migrated=False, sink_backend="neo4j"),
+ )
def test_serialize_graph_filters_by_provider_label(attack_paths_graph_stub_classes):
@@ -442,6 +447,7 @@ def test_normalize_custom_query_payload_passthrough_for_flat_dict():
def test_execute_custom_query_serializes_graph(
attack_paths_graph_stub_classes,
+ sink_backend_stub,
):
provider_id = "test-provider-123"
plabel = get_provider_label(provider_id)
@@ -455,50 +461,73 @@ def test_execute_custom_query_serializes_graph(
graph_result.nodes = [node_1, node_2]
graph_result.relationships = [relationship]
- with patch(
- "api.attack_paths.views_helpers.graph_database.execute_read_query",
- return_value=graph_result,
- ) as mock_execute:
- result = views_helpers.execute_custom_query(
- "db-tenant-test", "MATCH (n) RETURN n", provider_id
- )
+ sink_backend_stub.execute_read_query.return_value = graph_result
+ result = views_helpers.execute_custom_query(
+ "db-tenant-test",
+ "MATCH (n) RETURN n",
+ provider_id,
+ scan=MagicMock(is_migrated=False, sink_backend="neo4j"),
+ )
- mock_execute.assert_called_once()
- call_kwargs = mock_execute.call_args[1]
- assert call_kwargs["database"] == "db-tenant-test"
+ sink_backend_stub.execute_read_query.assert_called_once()
+ call_args = sink_backend_stub.execute_read_query.call_args[0]
+ assert call_args[0] == "db-tenant-test"
# The cypher is rewritten with the provider label injection
- assert plabel in call_kwargs["cypher"]
+ assert plabel in call_args[1]
assert len(result["nodes"]) == 2
assert result["relationships"][0]["label"] == "OWNS"
assert result["truncated"] is False
assert result["total_nodes"] == 2
-def test_execute_custom_query_raises_permission_denied_on_write():
+def test_execute_custom_query_adds_timeout_for_neptune_scan(sink_backend_stub):
+ graph_result = MagicMock()
+ graph_result.nodes = []
+ graph_result.relationships = []
+ sink_backend_stub.execute_read_query.return_value = graph_result
+
with patch(
- "api.attack_paths.views_helpers.graph_database.execute_read_query",
- side_effect=graph_database.WriteQueryNotAllowedException(
+ "api.attack_paths.views_helpers.sink_module.get_backend_for_scan",
+ return_value=sink_backend_stub,
+ ):
+ views_helpers.execute_custom_query(
+ "db-tenant-test",
+ "MATCH (n) RETURN n",
+ "provider-1",
+ scan=MagicMock(is_migrated=True, sink_backend="neptune"),
+ )
+
+ cypher = sink_backend_stub.execute_read_query.call_args[0][1]
+ assert cypher.startswith("USING QUERY:TIMEOUTMILLISECONDS")
+
+
+def test_execute_custom_query_raises_permission_denied_on_write(sink_backend_stub):
+ sink_backend_stub.execute_read_query.side_effect = (
+ graph_database.WriteQueryNotAllowedException(
message="Read query not allowed",
code="Neo.ClientError.Statement.AccessMode",
- ),
- ):
- with pytest.raises(PermissionDenied):
- views_helpers.execute_custom_query(
- "db-tenant-test", "CREATE (n) RETURN n", "provider-1"
- )
+ )
+ )
+ with pytest.raises(PermissionDenied):
+ views_helpers.execute_custom_query(
+ "db-tenant-test",
+ "CREATE (n) RETURN n",
+ "provider-1",
+ scan=MagicMock(is_migrated=False, sink_backend="neo4j"),
+ )
-def test_execute_custom_query_wraps_graph_errors():
- with (
- patch(
- "api.attack_paths.views_helpers.graph_database.execute_read_query",
- side_effect=graph_database.GraphDatabaseQueryException("boom"),
- ),
- patch("api.attack_paths.views_helpers.logger") as mock_logger,
- ):
+def test_execute_custom_query_wraps_graph_errors(sink_backend_stub):
+ sink_backend_stub.execute_read_query.side_effect = (
+ graph_database.GraphDatabaseQueryException("boom")
+ )
+ with patch("api.attack_paths.views_helpers.logger") as mock_logger:
with pytest.raises(APIException):
views_helpers.execute_custom_query(
- "db-tenant-test", "MATCH (n) RETURN n", "provider-1"
+ "db-tenant-test",
+ "MATCH (n) RETURN n",
+ "provider-1",
+ scan=MagicMock(is_migrated=False, sink_backend="neo4j"),
)
mock_logger.error.assert_called_once()
@@ -563,13 +592,33 @@ def test_truncate_graph_empty_graph():
@pytest.fixture
def mock_neo4j_session():
- """Mock the Neo4j driver so execute_read_query uses a fake session."""
+ """Install a Neo4jSink with a mocked Bolt driver into the sink factory.
+
+ The yielded mock is the `neo4j.Session` that the Neo4jSink will obtain via
+ `driver.session(...)`. Tests configure `mock_neo4j_session.execute_read`
+ return values / side effects to exercise the read-mode error translation
+ path on the real `Neo4jSink.execute_read_query` and `get_session` code.
+ """
+ from api.attack_paths.sink import factory
+ from api.attack_paths.sink.neo4j import Neo4jSink
+
mock_session = MagicMock(spec=neo4j.Session)
mock_driver = MagicMock(spec=neo4j.Driver)
mock_driver.session.return_value = mock_session
- with patch("api.attack_paths.database.get_driver", return_value=mock_driver):
+ sink = Neo4jSink()
+ sink._driver = mock_driver
+
+ previous_backend = factory._backend
+ previous_secondary = dict(factory._secondary_backends)
+ factory._backend = sink
+ factory._secondary_backends.clear()
+ try:
yield mock_session
+ finally:
+ factory._backend = previous_backend
+ factory._secondary_backends.clear()
+ factory._secondary_backends.update(previous_secondary)
def test_execute_read_query_succeeds_with_select(mock_neo4j_session):
@@ -665,16 +714,20 @@ def test_execute_read_query_rejects_apoc_real_create(mock_neo4j_session, cypher)
@pytest.fixture
def mock_schema_session():
- """Mock get_session for cartography schema tests."""
+ """Mock the routed sink backend session for cartography schema tests."""
mock_result = MagicMock()
mock_session = MagicMock()
mock_session.run.return_value = mock_result
+ mock_backend = MagicMock()
with patch(
- "api.attack_paths.views_helpers.graph_database.get_session"
- ) as mock_get_session:
- mock_get_session.return_value.__enter__ = MagicMock(return_value=mock_session)
- mock_get_session.return_value.__exit__ = MagicMock(return_value=False)
+ "api.attack_paths.views_helpers.sink_module.get_backend_for_scan",
+ return_value=mock_backend,
+ ):
+ mock_backend.get_session.return_value.__enter__ = MagicMock(
+ return_value=mock_session
+ )
+ mock_backend.get_session.return_value.__exit__ = MagicMock(return_value=False)
yield mock_session, mock_result
@@ -685,7 +738,9 @@ def test_get_cartography_schema_returns_urls(mock_schema_session):
"module_version": "0.129.0",
}
- result = views_helpers.get_cartography_schema("db-tenant-test", "provider-123")
+ result = views_helpers.get_cartography_schema(
+ "db-tenant-test", "provider-123", MagicMock(sink_backend="neo4j")
+ )
mock_session.run.assert_called_once()
assert result["id"] == "aws-0.129.0"
@@ -701,7 +756,9 @@ def test_get_cartography_schema_returns_none_when_no_data(mock_schema_session):
_, mock_result = mock_schema_session
mock_result.single.return_value = None
- result = views_helpers.get_cartography_schema("db-tenant-test", "provider-123")
+ result = views_helpers.get_cartography_schema(
+ "db-tenant-test", "provider-123", MagicMock(sink_backend="neo4j")
+ )
assert result is None
@@ -723,21 +780,29 @@ def test_get_cartography_schema_extracts_provider(
"module_version": "1.0.0",
}
- result = views_helpers.get_cartography_schema("db-tenant-test", "provider-123")
+ result = views_helpers.get_cartography_schema(
+ "db-tenant-test", "provider-123", MagicMock(sink_backend="neo4j")
+ )
assert result["id"] == f"{expected_provider}-1.0.0"
assert result["provider"] == expected_provider
def test_get_cartography_schema_wraps_database_error():
+ mock_backend = MagicMock()
+ mock_backend.get_session.side_effect = graph_database.GraphDatabaseQueryException(
+ "boom"
+ )
with (
patch(
- "api.attack_paths.views_helpers.graph_database.get_session",
- side_effect=graph_database.GraphDatabaseQueryException("boom"),
+ "api.attack_paths.views_helpers.sink_module.get_backend_for_scan",
+ return_value=mock_backend,
),
patch("api.attack_paths.views_helpers.logger") as mock_logger,
):
with pytest.raises(APIException):
- views_helpers.get_cartography_schema("db-tenant-test", "provider-123")
+ views_helpers.get_cartography_schema(
+ "db-tenant-test", "provider-123", MagicMock(sink_backend="neo4j")
+ )
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
index 8828d23911..c4aca45928 100644
--- a/api/src/backend/api/tests/test_attack_paths_database.py
+++ b/api/src/backend/api/tests/test_attack_paths_database.py
@@ -1,513 +1,240 @@
-"""
-Tests for Neo4j database lazy initialization.
+"""Tests for the attack-paths database facade.
-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.
+After the Neptune port, `api.attack_paths.database` is a thin routing shim
+over `api.attack_paths.ingest` (cartography temp DB, always Neo4j) and
+`api.attack_paths.sink` (configurable Neo4j or Neptune). The facade's
+contract is routing by database-name prefix and the public exception
+hierarchy; sink-internal behavior is exercised in `test_sink.py`.
"""
-import threading
from unittest.mock import MagicMock, patch
-import neo4j
+import api.attack_paths.database as db_module
import pytest
-import api.attack_paths.database as db_module
+
+class TestDatabaseNameHelper:
+ def test_tenant_name_lowercases_uuid(self):
+ assert (
+ db_module.get_database_name("ABC-123", temporary=False)
+ == "db-tenant-abc-123"
+ )
+
+ def test_temporary_name_uses_tmp_scan_prefix(self):
+ assert (
+ db_module.get_database_name("XYZ-789", temporary=True)
+ == "db-tmp-scan-xyz-789"
+ )
-class TestLazyInitialization:
- """Test that Neo4j driver is initialized lazily on first use."""
+class TestExceptionHierarchy:
+ """`tasks/` and `api/v1/views.py` import these from the facade."""
- @pytest.fixture(autouse=True)
- def reset_module_state(self):
- """Reset module-level singleton state before each test."""
- original_driver = db_module._driver
+ def test_write_query_is_graph_database_exception(self):
+ assert issubclass(
+ db_module.WriteQueryNotAllowedException,
+ db_module.GraphDatabaseQueryException,
+ )
- db_module._driver = None
+ def test_client_statement_is_graph_database_exception(self):
+ assert issubclass(
+ db_module.ClientStatementException, db_module.GraphDatabaseQueryException
+ )
- yield
+ def test_exception_str_includes_code_when_set(self):
+ exc = db_module.GraphDatabaseQueryException(
+ message="boom", code="Neo.ClientError.X.Y"
+ )
+ assert str(exc) == "Neo.ClientError.X.Y: boom"
- db_module._driver = original_driver
-
- def test_driver_not_initialized_at_import(self):
- """Driver should be None after module import (no eager connection)."""
- 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."""
- 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."""
- 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."""
- 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()
+ def test_exception_str_falls_back_to_message_without_code(self):
+ exc = db_module.GraphDatabaseQueryException(message="boom")
+ assert str(exc) == "boom"
-class TestConnectionAcquisitionTimeout:
- """Test that the connection acquisition timeout is configurable."""
+class TestExecuteReadQueryRoutes:
+ def test_execute_read_query_delegates_to_sink(self, sink_backend_stub):
+ sink_backend_stub.execute_read_query.return_value = "graph"
- @pytest.fixture(autouse=True)
- def reset_module_state(self):
- original_driver = db_module._driver
- original_timeout = db_module.CONN_ACQUISITION_TIMEOUT
+ result = db_module.execute_read_query(
+ "db-tenant-abc", "MATCH (n) RETURN n", {"provider_uid": "123"}
+ )
- db_module._driver = None
+ sink_backend_stub.execute_read_query.assert_called_once_with(
+ "db-tenant-abc", "MATCH (n) RETURN n", {"provider_uid": "123"}
+ )
+ assert result == "graph"
- yield
+ def test_execute_read_query_defaults_parameters_to_none(self, sink_backend_stub):
+ db_module.execute_read_query("db-tenant-abc", "MATCH (n) RETURN n")
- db_module._driver = original_driver
- db_module.CONN_ACQUISITION_TIMEOUT = original_timeout
-
- @patch("api.attack_paths.database.settings")
- @patch("api.attack_paths.database.neo4j.GraphDatabase.driver")
- def test_driver_receives_configured_timeout(
- self, mock_driver_factory, mock_settings
- ):
- """init_driver() should pass CONN_ACQUISITION_TIMEOUT to the neo4j driver."""
- mock_driver_factory.return_value = MagicMock()
- mock_settings.DATABASES = {
- "neo4j": {
- "HOST": "localhost",
- "PORT": 7687,
- "USER": "neo4j",
- "PASSWORD": "password",
- }
- }
- db_module.CONN_ACQUISITION_TIMEOUT = 42
-
- db_module.init_driver()
-
- _, kwargs = mock_driver_factory.call_args
- assert kwargs["connection_acquisition_timeout"] == 42
+ sink_backend_stub.execute_read_query.assert_called_once_with(
+ "db-tenant-abc", "MATCH (n) RETURN n", None
+ )
-class TestAtexitRegistration:
- """Test that atexit cleanup handler is registered correctly."""
+class TestScanDatabaseAvailability:
+ def test_verify_scan_databases_available_checks_ingest_and_sink(self):
+ with (
+ patch("api.attack_paths.database.ingest") as mock_ingest,
+ patch("api.attack_paths.database.get_driver") as mock_get_driver,
+ ):
+ db_module.verify_scan_databases_available()
- @pytest.fixture(autouse=True)
- def reset_module_state(self):
- """Reset module-level singleton state before each test."""
- original_driver = db_module._driver
+ mock_ingest.get_driver.return_value.verify_connectivity.assert_called_once_with()
+ mock_get_driver.return_value.verify_connectivity.assert_called_once_with()
- 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."""
- 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).
- """
- 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."""
- 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."""
- 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."""
- 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."""
- 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 TestExecuteReadQuery:
- """Test read query execution helper."""
-
- def test_execute_read_query_calls_read_session_and_returns_result(self):
- tx = MagicMock()
- expected_graph = MagicMock()
- run_result = MagicMock()
- run_result.graph.return_value = expected_graph
- tx.run.return_value = run_result
-
- session = MagicMock()
-
- def execute_read_side_effect(fn):
- return fn(tx)
-
- session.execute_read.side_effect = execute_read_side_effect
-
- session_ctx = MagicMock()
- session_ctx.__enter__.return_value = session
- session_ctx.__exit__.return_value = False
-
- with patch(
- "api.attack_paths.database.get_session",
- return_value=session_ctx,
- ) as mock_get_session:
- result = db_module.execute_read_query(
- "db-tenant-test-tenant-id",
- "MATCH (n) RETURN n",
- {"provider_uid": "123"},
+ def test_verify_scan_databases_available_raises_when_ingest_is_down(self):
+ with (
+ patch("api.attack_paths.database.ingest") as mock_ingest,
+ patch("api.attack_paths.database.get_driver"),
+ ):
+ mock_ingest.get_driver.return_value.verify_connectivity.side_effect = (
+ RuntimeError("ingest down")
)
- mock_get_session.assert_called_once_with(
- "db-tenant-test-tenant-id",
- default_access_mode=neo4j.READ_ACCESS,
+ with pytest.raises(RuntimeError) as exc:
+ db_module.verify_scan_databases_available()
+
+ assert "Attack Paths graph database unavailable before scan start" in str(
+ exc.value
)
- session.execute_read.assert_called_once()
- tx.run.assert_called_once_with(
- "MATCH (n) RETURN n",
- {"provider_uid": "123"},
- timeout=db_module.READ_QUERY_TIMEOUT_SECONDS,
- )
- run_result.graph.assert_called_once_with()
- assert result is expected_graph
+ assert "ingest Neo4j: ingest down" in str(exc.value)
- def test_execute_read_query_defaults_parameters_to_empty_dict(self):
- tx = MagicMock()
- run_result = MagicMock()
- run_result.graph.return_value = MagicMock()
- tx.run.return_value = run_result
+ def test_verify_scan_databases_available_raises_when_sink_is_down(self, settings):
+ settings.ATTACK_PATHS_SINK_DATABASE = "neptune"
- session = MagicMock()
- session.execute_read.side_effect = lambda fn: fn(tx)
-
- session_ctx = MagicMock()
- session_ctx.__enter__.return_value = session
- session_ctx.__exit__.return_value = False
-
- with patch(
- "api.attack_paths.database.get_session",
- return_value=session_ctx,
+ with (
+ patch("api.attack_paths.database.ingest"),
+ patch("api.attack_paths.database.get_driver") as mock_get_driver,
):
- db_module.execute_read_query(
- "db-tenant-test-tenant-id",
- "MATCH (n) RETURN n",
+ mock_get_driver.return_value.verify_connectivity.side_effect = RuntimeError(
+ "writer down"
)
- tx.run.assert_called_once_with(
- "MATCH (n) RETURN n",
- {},
- timeout=db_module.READ_QUERY_TIMEOUT_SECONDS,
- )
- run_result.graph.assert_called_once_with()
+ with pytest.raises(RuntimeError) as exc:
+ db_module.verify_scan_databases_available()
+ assert "sink neptune: writer down" in str(exc.value)
-class TestGetSessionReadOnly:
- """Test that get_session translates Neo4j read-mode errors."""
+ def test_verify_scan_databases_available_reports_both_failures(self, settings):
+ settings.ATTACK_PATHS_SINK_DATABASE = "neo4j"
- @pytest.fixture(autouse=True)
- def reset_module_state(self):
- original_driver = db_module._driver
- db_module._driver = None
- yield
- db_module._driver = original_driver
-
- @pytest.mark.parametrize(
- "neo4j_code",
- [
- "Neo.ClientError.Statement.AccessMode",
- "Neo.ClientError.Procedure.ProcedureNotFound",
- ],
- )
- def test_get_session_raises_write_query_not_allowed(self, neo4j_code):
- """Read-mode Neo4j errors should raise `WriteQueryNotAllowedException`."""
- mock_session = MagicMock()
- neo4j_error = neo4j.exceptions.Neo4jError._hydrate_neo4j(
- code=neo4j_code,
- message="Write operations are not allowed",
- )
- mock_session.run.side_effect = neo4j_error
-
- mock_driver = MagicMock()
- mock_driver.session.return_value = mock_session
- db_module._driver = mock_driver
-
- with pytest.raises(db_module.WriteQueryNotAllowedException):
- with db_module.get_session(
- default_access_mode=neo4j.READ_ACCESS
- ) as session:
- session.run("CREATE (n) RETURN n")
-
- def test_get_session_raises_generic_exception_for_other_errors(self):
- """Non-read-mode Neo4j errors should raise GraphDatabaseQueryException."""
- mock_session = MagicMock()
- neo4j_error = neo4j.exceptions.Neo4jError._hydrate_neo4j(
- code="Neo.ClientError.Statement.SyntaxError",
- message="Invalid syntax",
- )
- mock_session.run.side_effect = neo4j_error
-
- mock_driver = MagicMock()
- mock_driver.session.return_value = mock_session
- db_module._driver = mock_driver
-
- with pytest.raises(db_module.GraphDatabaseQueryException):
- with db_module.get_session(
- default_access_mode=neo4j.READ_ACCESS
- ) as session:
- session.run("INVALID CYPHER")
-
-
-class TestThreadSafety:
- """Test thread-safe initialization."""
-
- @pytest.fixture(autouse=True)
- def reset_module_state(self):
- """Reset module-level singleton state before each test."""
- 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."""
- 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
-
-
-class TestHasProviderData:
- """Test has_provider_data helper for checking provider nodes in Neo4j."""
-
- def test_returns_true_when_nodes_exist(self):
- mock_session = MagicMock()
- mock_result = MagicMock()
- mock_result.single.return_value = MagicMock() # non-None record
- mock_session.run.return_value = mock_result
-
- session_ctx = MagicMock()
- session_ctx.__enter__.return_value = mock_session
- session_ctx.__exit__.return_value = False
-
- with patch(
- "api.attack_paths.database.get_session",
- return_value=session_ctx,
+ with (
+ patch("api.attack_paths.database.ingest") as mock_ingest,
+ patch("api.attack_paths.database.get_driver") as mock_get_driver,
):
- assert db_module.has_provider_data("db-tenant-abc", "provider-123") is True
-
- mock_session.run.assert_called_once()
-
- def test_returns_false_when_no_nodes(self):
- mock_session = MagicMock()
- mock_result = MagicMock()
- mock_result.single.return_value = None
- mock_session.run.return_value = mock_result
-
- session_ctx = MagicMock()
- session_ctx.__enter__.return_value = mock_session
- session_ctx.__exit__.return_value = False
-
- with patch(
- "api.attack_paths.database.get_session",
- return_value=session_ctx,
- ):
- assert db_module.has_provider_data("db-tenant-abc", "provider-123") is False
-
- def test_returns_false_when_database_not_found(self):
- session_ctx = MagicMock()
- session_ctx.__enter__.side_effect = db_module.GraphDatabaseQueryException(
- message="Database does not exist",
- code="Neo.ClientError.Database.DatabaseNotFound",
- )
-
- with patch(
- "api.attack_paths.database.get_session",
- return_value=session_ctx,
- ):
- assert (
- db_module.has_provider_data("db-tenant-gone", "provider-123") is False
+ mock_ingest.get_driver.return_value.verify_connectivity.side_effect = (
+ RuntimeError("ingest down")
+ )
+ mock_get_driver.return_value.verify_connectivity.side_effect = RuntimeError(
+ "sink down"
)
- def test_raises_on_other_errors(self):
- session_ctx = MagicMock()
- session_ctx.__enter__.side_effect = db_module.GraphDatabaseQueryException(
- message="Connection refused",
- code="Neo.TransientError.General.UnknownError",
+ with pytest.raises(RuntimeError) as exc:
+ db_module.verify_scan_databases_available()
+
+ assert "ingest Neo4j: ingest down" in str(exc.value)
+ assert "sink neo4j: sink down" in str(exc.value)
+
+
+class TestSinkOperationsDelegation:
+ def test_has_provider_data_delegates_to_sink(self, sink_backend_stub):
+ sink_backend_stub.has_provider_data.return_value = True
+
+ assert db_module.has_provider_data("db-tenant-abc", "provider-123") is True
+ sink_backend_stub.has_provider_data.assert_called_once_with(
+ "db-tenant-abc", "provider-123"
)
- with patch(
- "api.attack_paths.database.get_session",
- return_value=session_ctx,
- ):
- with pytest.raises(db_module.GraphDatabaseQueryException):
- db_module.has_provider_data("db-tenant-abc", "provider-123")
+ def test_drop_subgraph_delegates_to_sink(self, sink_backend_stub):
+ sink_backend_stub.drop_subgraph.return_value = 42
+
+ assert db_module.drop_subgraph("db-tenant-abc", "provider-123") == 42
+ sink_backend_stub.drop_subgraph.assert_called_once_with(
+ "db-tenant-abc", "provider-123"
+ )
+
+
+class TestRoutingByDatabasePrefix:
+ """`db-tmp-scan-*` and `None` route to ingest; everything else to sink."""
+
+ def test_create_database_routes_temp_to_ingest(self, sink_backend_stub):
+ with patch("api.attack_paths.database.ingest") as mock_ingest:
+ db_module.create_database("db-tmp-scan-uuid-1")
+
+ mock_ingest.create_database.assert_called_once_with("db-tmp-scan-uuid-1")
+ sink_backend_stub.create_database.assert_not_called()
+
+ def test_create_database_routes_tenant_to_sink(self, sink_backend_stub):
+ with patch("api.attack_paths.database.ingest") as mock_ingest:
+ db_module.create_database("db-tenant-abc")
+
+ sink_backend_stub.create_database.assert_called_once_with("db-tenant-abc")
+ mock_ingest.create_database.assert_not_called()
+
+ def test_drop_database_routes_temp_to_ingest(self, sink_backend_stub):
+ with patch("api.attack_paths.database.ingest") as mock_ingest:
+ db_module.drop_database("db-tmp-scan-uuid-1")
+
+ mock_ingest.drop_database.assert_called_once_with("db-tmp-scan-uuid-1")
+ sink_backend_stub.drop_database.assert_not_called()
+
+ def test_drop_database_routes_tenant_to_sink(self, sink_backend_stub):
+ with patch("api.attack_paths.database.ingest") as mock_ingest:
+ db_module.drop_database("db-tenant-abc")
+
+ sink_backend_stub.drop_database.assert_called_once_with("db-tenant-abc")
+ mock_ingest.drop_database.assert_not_called()
+
+ def test_clear_cache_routes_temp_to_ingest(self, sink_backend_stub):
+ with patch("api.attack_paths.database.ingest") as mock_ingest:
+ db_module.clear_cache("db-tmp-scan-uuid-1")
+
+ mock_ingest.clear_cache.assert_called_once_with("db-tmp-scan-uuid-1")
+ sink_backend_stub.clear_cache.assert_not_called()
+
+ def test_clear_cache_routes_tenant_to_sink(self, sink_backend_stub):
+ with patch("api.attack_paths.database.ingest") as mock_ingest:
+ db_module.clear_cache("db-tenant-abc")
+
+ sink_backend_stub.clear_cache.assert_called_once_with("db-tenant-abc")
+ mock_ingest.clear_cache.assert_not_called()
+
+ def test_get_session_routes_temp_to_ingest(self, sink_backend_stub):
+ sentinel = MagicMock()
+ with patch("api.attack_paths.database.ingest") as mock_ingest:
+ mock_ingest.get_session.return_value = sentinel
+
+ result = db_module.get_session("db-tmp-scan-uuid-1")
+
+ assert result is sentinel
+ mock_ingest.get_session.assert_called_once()
+ sink_backend_stub.get_session.assert_not_called()
+
+ def test_get_session_routes_none_to_ingest(self, sink_backend_stub):
+ sentinel = MagicMock()
+ with patch("api.attack_paths.database.ingest") as mock_ingest:
+ mock_ingest.get_session.return_value = sentinel
+
+ result = db_module.get_session(None)
+
+ assert result is sentinel
+ sink_backend_stub.get_session.assert_not_called()
+
+ def test_get_ingest_uri_delegates_to_ingest(self, sink_backend_stub):
+ with patch("api.attack_paths.database.ingest") as mock_ingest:
+ mock_ingest.get_uri.return_value = "bolt://neo4j:7687"
+
+ assert db_module.get_ingest_uri() == "bolt://neo4j:7687"
+
+ mock_ingest.get_uri.assert_called_once_with()
+
+ def test_get_session_routes_tenant_to_sink(self, sink_backend_stub):
+ sentinel = MagicMock()
+ sink_backend_stub.get_session.return_value = sentinel
+ with patch("api.attack_paths.database.ingest") as mock_ingest:
+ result = db_module.get_session("db-tenant-abc")
+
+ assert result is sentinel
+ mock_ingest.get_session.assert_not_called()
diff --git a/api/src/backend/api/tests/test_authentication.py b/api/src/backend/api/tests/test_authentication.py
index 6745c36e91..d05a55ce5a 100644
--- a/api/src/backend/api/tests/test_authentication.py
+++ b/api/src/backend/api/tests/test_authentication.py
@@ -1,15 +1,15 @@
import time
-from datetime import datetime, timedelta, timezone
-from unittest.mock import patch
+from datetime import UTC, datetime, timedelta
+from unittest.mock import MagicMock, patch
from uuid import uuid4
import pytest
-from django.test import RequestFactory
-from rest_framework.exceptions import AuthenticationFailed
-
-from api.authentication import TenantAPIKeyAuthentication
+from api.authentication import SSEAuthentication, TenantAPIKeyAuthentication
from api.db_router import MainRouter
from api.models import TenantAPIKey
+from django.db.models.query import QuerySet
+from django.test import RequestFactory
+from rest_framework.exceptions import AuthenticationFailed
@pytest.mark.django_db
@@ -65,6 +65,54 @@ class TestTenantAPIKeyAuthentication:
# Verify the manager was restored
assert TenantAPIKey.objects == original_manager
+ def test_authenticate_credentials_keeps_manager_during_lookup(
+ self, auth_backend, api_keys_fixture, request_factory
+ ):
+ """Authentication must not expose a QuerySet as the model manager."""
+ api_key = api_keys_fixture[0]
+ raw_key = api_key._raw_key
+ _, encrypted_key = raw_key.split(TenantAPIKey.objects.separator, 1)
+
+ original_get = QuerySet.get
+ manager_has_create_api_key = []
+
+ def observe_manager(queryset, *args, **kwargs):
+ manager_has_create_api_key.append(
+ hasattr(TenantAPIKey.objects, "create_api_key")
+ )
+ return original_get(queryset, *args, **kwargs)
+
+ request = request_factory.get("/")
+
+ with patch.object(QuerySet, "get", observe_manager):
+ auth_backend._authenticate_credentials(request, encrypted_key)
+
+ assert manager_has_create_api_key
+ assert all(manager_has_create_api_key)
+
+ @pytest.mark.parametrize(
+ "payload",
+ [
+ {"_pk": str(uuid4()), "_exp": "not-a-timestamp"},
+ {
+ "_pk": "not-a-uuid",
+ "_exp": (datetime.now(UTC) + timedelta(days=1)).timestamp(),
+ },
+ {"_pk": str(uuid4()), "_exp": True},
+ ],
+ )
+ def test_authenticate_credentials_rejects_malformed_payloads(
+ self, auth_backend, request_factory, payload
+ ):
+ """Malformed decrypted payloads fail as authentication errors."""
+ request = request_factory.get("/")
+ encrypted_key = auth_backend.key_crypto.generate(payload)
+
+ with pytest.raises(AuthenticationFailed) as exc_info:
+ auth_backend._authenticate_credentials(request, encrypted_key)
+
+ assert str(exc_info.value.detail) == "Invalid API Key."
+
def test_authenticate_credentials_restores_manager_on_exception(
self, auth_backend, request_factory
):
@@ -104,7 +152,7 @@ class TestTenantAPIKeyAuthentication:
# Verify that last_used_at was updated
api_key.refresh_from_db()
assert api_key.last_used_at is not None
- assert (datetime.now(timezone.utc) - api_key.last_used_at).seconds < 5
+ assert (datetime.now(UTC) - api_key.last_used_at).seconds < 5
def test_authenticate_valid_api_key_uses_admin_database(
self, auth_backend, api_keys_fixture, request_factory
@@ -195,7 +243,7 @@ class TestTenantAPIKeyAuthentication:
name="Expired API Key",
tenant_id=tenant.id,
entity=user,
- expiry_date=datetime.now(timezone.utc) - timedelta(days=1),
+ expiry_date=datetime.now(UTC) - timedelta(days=1),
)
request = request_factory.get("/")
@@ -217,7 +265,7 @@ class TestTenantAPIKeyAuthentication:
# Manually create an encrypted key with a non-existent ID
payload = {
"_pk": non_existent_uuid,
- "_exp": (datetime.now(timezone.utc) + timedelta(days=30)).timestamp(),
+ "_exp": (datetime.now(UTC) + timedelta(days=30)).timestamp(),
}
encrypted_key = auth_backend.key_crypto.generate(payload)
fake_key = f"{api_key.prefix}.{encrypted_key}"
@@ -368,7 +416,7 @@ class TestTenantAPIKeyAuthentication:
name="Short-lived API Key",
tenant_id=tenant.id,
entity=user,
- expiry_date=datetime.now(timezone.utc) + timedelta(seconds=1),
+ expiry_date=datetime.now(UTC) + timedelta(seconds=1),
)
# Wait for the key to expire
@@ -382,3 +430,62 @@ class TestTenantAPIKeyAuthentication:
auth_backend.authenticate(request)
assert str(exc_info.value.detail) == "API Key has already expired."
+
+
+class TestSSEAuthentication:
+ """`SSEAuthentication` adds an `?access_token=` fallback for
+ browser `EventSource` clients while keeping the standard
+ `Authorization` header as the authoritative source."""
+
+ def test_header_present_delegates_to_super(self):
+ request = MagicMock()
+ request.headers = {"Authorization": "Bearer header-token"}
+ with patch.object(
+ SSEAuthentication.__bases__[0], "authenticate", return_value=("user", "tok")
+ ) as super_auth:
+ result = SSEAuthentication().authenticate(request)
+ super_auth.assert_called_once_with(request)
+ assert result == ("user", "tok")
+
+ def test_no_header_no_query_token_delegates_to_super(self):
+ request = MagicMock()
+ request.headers = {}
+ request.query_params = {}
+ with patch.object(
+ SSEAuthentication.__bases__[0], "authenticate", return_value=None
+ ) as super_auth:
+ result = SSEAuthentication().authenticate(request)
+ super_auth.assert_called_once_with(request)
+ assert result is None
+
+ def test_query_token_used_only_as_fallback(self):
+ request = MagicMock()
+ request.headers = {}
+ request.query_params = {"access_token": "query-jwt"}
+
+ jwt_instance = MagicMock()
+ jwt_instance.get_validated_token.return_value = "validated"
+ jwt_instance.get_user.return_value = "query-user"
+
+ with patch.object(SSEAuthentication, "jwt_auth", jwt_instance):
+ user, token = SSEAuthentication().authenticate(request)
+
+ jwt_instance.get_validated_token.assert_called_once_with("query-jwt")
+ assert user == "query-user"
+ assert token == "validated"
+
+ def test_query_token_invalid_raises_authentication_failed(self):
+ request = MagicMock()
+ request.headers = {}
+ request.query_params = {"access_token": "bad-token"}
+
+ jwt_instance = MagicMock()
+ jwt_instance.get_validated_token.side_effect = AuthenticationFailed(
+ "Invalid token"
+ )
+
+ with patch.object(SSEAuthentication, "jwt_auth", jwt_instance):
+ with pytest.raises(AuthenticationFailed):
+ SSEAuthentication().authenticate(request)
+
+ jwt_instance.get_validated_token.assert_called_once_with("bad-token")
diff --git a/api/src/backend/api/tests/test_celery_settings.py b/api/src/backend/api/tests/test_celery_settings.py
index d4010796ee..dcd8930edc 100644
--- a/api/src/backend/api/tests/test_celery_settings.py
+++ b/api/src/backend/api/tests/test_celery_settings.py
@@ -41,3 +41,30 @@ class TestBuildCeleryBrokerUrl:
def test_invalid_scheme_raises_error(self):
with pytest.raises(ValueError, match="Invalid VALKEY_SCHEME 'http'"):
_build_celery_broker_url("http", "", "", "valkey", "6379", "0")
+
+
+class TestCeleryWorkerConcurrency:
+ def _reimport_settings(self):
+ """Fresh import — importlib.reload() doesn't clear the module namespace,
+ so an attribute set by a prior test would leak into the unset case."""
+ import sys
+
+ sys.modules.pop("config.settings.celery", None)
+ import config.settings.celery as celery_settings
+
+ return celery_settings
+
+ def test_unset_leaves_setting_absent(self, monkeypatch):
+ monkeypatch.delenv("DJANGO_CELERY_WORKER_CONCURRENCY", raising=False)
+ mod = self._reimport_settings()
+ assert not hasattr(mod, "CELERY_WORKER_CONCURRENCY")
+
+ def test_explicit_value_applied(self, monkeypatch):
+ monkeypatch.setenv("DJANGO_CELERY_WORKER_CONCURRENCY", "8")
+ mod = self._reimport_settings()
+ assert mod.CELERY_WORKER_CONCURRENCY == 8
+
+ def test_invalid_value_raises(self, monkeypatch):
+ monkeypatch.setenv("DJANGO_CELERY_WORKER_CONCURRENCY", "not-a-number")
+ with pytest.raises(ValueError):
+ self._reimport_settings()
diff --git a/api/src/backend/api/tests/test_compliance.py b/api/src/backend/api/tests/test_compliance.py
index ce30a3cc52..d613a2538e 100644
--- a/api/src/backend/api/tests/test_compliance.py
+++ b/api/src/backend/api/tests/test_compliance.py
@@ -1,7 +1,6 @@
from unittest.mock import MagicMock, patch
import pytest
-
from api import compliance as compliance_module
from api.compliance import (
generate_compliance_overview_template,
@@ -10,9 +9,12 @@ from api.compliance import (
get_prowler_provider_checks,
get_prowler_provider_compliance,
load_prowler_checks,
+ warm_compliance_caches,
)
from api.models import Provider
-from prowler.lib.check.compliance_models import Compliance
+from prowler.lib.check.compliance_models import (
+ get_bulk_compliance_frameworks_universal,
+)
class TestCompliance:
@@ -28,16 +30,16 @@ class TestCompliance:
assert set(checks) == {"check1", "check2", "check3"}
mock_check_metadata.get_bulk.assert_called_once_with(provider_type)
- @patch("api.compliance.Compliance")
- def test_get_prowler_provider_compliance(self, mock_compliance):
+ @patch("api.compliance.get_bulk_compliance_frameworks_universal")
+ def test_get_prowler_provider_compliance(self, mock_get_bulk):
provider_type = Provider.ProviderChoices.AWS
- mock_compliance.get_bulk.return_value = {
+ mock_get_bulk.return_value = {
"compliance1": MagicMock(),
"compliance2": MagicMock(),
}
compliance_data = get_prowler_provider_compliance(provider_type)
- assert compliance_data == mock_compliance.get_bulk.return_value
- mock_compliance.get_bulk.assert_called_once_with(provider_type)
+ assert compliance_data == mock_get_bulk.return_value
+ mock_get_bulk.assert_called_once_with(provider_type)
@patch("api.compliance.get_prowler_provider_checks")
@patch("api.models.Provider.ProviderChoices")
@@ -51,9 +53,9 @@ class TestCompliance:
prowler_compliance = {
"aws": {
"compliance1": MagicMock(
- Requirements=[
+ requirements=[
MagicMock(
- Checks=["check1", "check2"],
+ checks={"aws": ["check1", "check2"]},
),
],
),
@@ -167,35 +169,38 @@ class TestCompliance:
def test_generate_compliance_overview_template(self, mock_provider_choices):
mock_provider_choices.values = ["aws"]
+ # ``name`` is a reserved MagicMock kwarg (it labels the mock for repr,
+ # it does NOT set a ``.name`` attribute), so it must be assigned
+ # explicitly after construction.
requirement1 = MagicMock(
- Id="requirement1",
- Name="Requirement 1",
- Description="Description of requirement 1",
- Attributes=[],
- Checks=["check1", "check2"],
- Tactics=["tactic1"],
- SubTechniques=["subtechnique1"],
- Platforms=["platform1"],
- TechniqueURL="https://example.com",
+ id="requirement1",
+ description="Description of requirement 1",
+ attributes=[],
+ checks={"aws": ["check1", "check2"]},
+ tactics=["tactic1"],
+ sub_techniques=["subtechnique1"],
+ platforms=["platform1"],
+ technique_url="https://example.com",
)
+ requirement1.name = "Requirement 1"
requirement2 = MagicMock(
- Id="requirement2",
- Name="Requirement 2",
- Description="Description of requirement 2",
- Attributes=[],
- Checks=[],
- Tactics=[],
- SubTechniques=[],
- Platforms=[],
- TechniqueURL="",
+ id="requirement2",
+ description="Description of requirement 2",
+ attributes=[],
+ checks={"aws": []},
+ tactics=[],
+ sub_techniques=[],
+ platforms=[],
+ technique_url="",
)
+ requirement2.name = "Requirement 2"
compliance1 = MagicMock(
- Requirements=[requirement1, requirement2],
- Framework="Framework 1",
- Version="1.0",
- Description="Description of compliance1",
- Name="Compliance 1",
+ requirements=[requirement1, requirement2],
+ framework="Framework 1",
+ version="1.0",
+ description="Description of compliance1",
)
+ compliance1.name = "Compliance 1"
prowler_compliance = {"aws": {"compliance1": compliance1}}
template = generate_compliance_overview_template(prowler_compliance)
@@ -262,33 +267,43 @@ def reset_compliance_cache():
"""Reset the module-level cache so each test starts cold."""
previous = dict(compliance_module.AVAILABLE_COMPLIANCE_FRAMEWORKS)
compliance_module.AVAILABLE_COMPLIANCE_FRAMEWORKS.clear()
+ # The warming flags are module-global; clear them so they do not leak
+ # between tests that call warm_compliance_caches.
+ compliance_module.COMPLIANCE_WARMING_STARTED.clear()
+ compliance_module.COMPLIANCE_WARMED.clear()
try:
yield
finally:
compliance_module.AVAILABLE_COMPLIANCE_FRAMEWORKS.clear()
compliance_module.AVAILABLE_COMPLIANCE_FRAMEWORKS.update(previous)
+ compliance_module.COMPLIANCE_WARMING_STARTED.clear()
+ compliance_module.COMPLIANCE_WARMED.clear()
class TestGetComplianceFrameworks:
def test_returns_keys_from_compliance_get_bulk(self, reset_compliance_cache):
- with patch("api.compliance.Compliance") as mock_compliance:
- mock_compliance.get_bulk.return_value = {
+ with patch(
+ "api.compliance.get_bulk_compliance_frameworks_universal"
+ ) as mock_get_bulk:
+ mock_get_bulk.return_value = {
"cis_1.4_aws": MagicMock(),
"mitre_attack_aws": MagicMock(),
}
result = get_compliance_frameworks(Provider.ProviderChoices.AWS)
assert sorted(result) == ["cis_1.4_aws", "mitre_attack_aws"]
- mock_compliance.get_bulk.assert_called_once_with(Provider.ProviderChoices.AWS)
+ mock_get_bulk.assert_called_once_with(Provider.ProviderChoices.AWS)
def test_caches_result_per_provider(self, reset_compliance_cache):
- with patch("api.compliance.Compliance") as mock_compliance:
- mock_compliance.get_bulk.return_value = {"cis_1.4_aws": MagicMock()}
+ with patch(
+ "api.compliance.get_bulk_compliance_frameworks_universal"
+ ) as mock_get_bulk:
+ mock_get_bulk.return_value = {"cis_1.4_aws": MagicMock()}
get_compliance_frameworks(Provider.ProviderChoices.AWS)
get_compliance_frameworks(Provider.ProviderChoices.AWS)
# Cached after first call.
- assert mock_compliance.get_bulk.call_count == 1
+ assert mock_get_bulk.call_count == 1
@pytest.mark.parametrize(
"provider_type",
@@ -296,17 +311,105 @@ class TestGetComplianceFrameworks:
)
def test_listing_is_subset_of_bulk(self, reset_compliance_cache, provider_type):
"""Regression for CLOUD-API-40S: every name returned by
- ``get_compliance_frameworks`` must be loadable via ``Compliance.get_bulk``.
+ ``get_compliance_frameworks`` must be loadable via
+ ``get_bulk_compliance_frameworks_universal``.
A divergence here is what produced ``KeyError: 'csa_ccm_4.0'`` in
``generate_outputs_task`` after universal/multi-provider compliance
JSONs were introduced at the top-level ``prowler/compliance/`` path.
"""
- bulk_keys = set(Compliance.get_bulk(provider_type).keys())
+ bulk_keys = set(get_bulk_compliance_frameworks_universal(provider_type).keys())
listed = set(get_compliance_frameworks(provider_type))
missing = listed - bulk_keys
assert not missing, (
f"get_compliance_frameworks({provider_type!r}) returned names not "
- f"loadable by Compliance.get_bulk: {sorted(missing)}"
+ f"loadable by get_bulk_compliance_frameworks_universal: "
+ f"{sorted(missing)}"
)
+
+
+class TestWarmComplianceCaches:
+ def test_warms_all_provider_types_by_default(self, reset_compliance_cache):
+ provider_types = list(Provider.ProviderChoices.values)
+ with (
+ patch("api.compliance.get_compliance_frameworks") as mock_frameworks,
+ patch("api.compliance._ensure_provider_loaded") as mock_ensure,
+ ):
+ warm_compliance_caches()
+
+ warmed = {call.args[0] for call in mock_frameworks.call_args_list}
+ assert warmed == set(provider_types)
+ assert mock_frameworks.call_count == len(provider_types)
+ assert mock_ensure.call_count == len(provider_types)
+
+ def test_warms_only_requested_provider_types(self, reset_compliance_cache):
+ with (
+ patch("api.compliance.get_compliance_frameworks") as mock_frameworks,
+ patch("api.compliance._ensure_provider_loaded") as mock_ensure,
+ ):
+ warm_compliance_caches([Provider.ProviderChoices.AWS])
+
+ mock_frameworks.assert_called_once_with(Provider.ProviderChoices.AWS)
+ mock_ensure.assert_called_once_with(Provider.ProviderChoices.AWS)
+
+ def test_populates_module_cache(self, reset_compliance_cache):
+ with (
+ patch(
+ "api.compliance.get_bulk_compliance_frameworks_universal"
+ ) as mock_get_bulk,
+ patch("api.compliance._ensure_provider_loaded"),
+ ):
+ mock_get_bulk.return_value = {"cis_1.4_aws": MagicMock()}
+ warm_compliance_caches([Provider.ProviderChoices.AWS])
+
+ assert (
+ Provider.ProviderChoices.AWS
+ in compliance_module.AVAILABLE_COMPLIANCE_FRAMEWORKS
+ )
+
+ def test_failing_provider_does_not_abort_the_rest(self, reset_compliance_cache):
+ """A failing provider (even on SystemExit) is isolated; others warm."""
+ providers = [Provider.ProviderChoices.AWS, Provider.ProviderChoices.OKTA]
+
+ def fake_frameworks(provider_type):
+ if provider_type == Provider.ProviderChoices.OKTA:
+ raise SystemExit(1)
+ return []
+
+ with (
+ patch(
+ "api.compliance.get_compliance_frameworks", side_effect=fake_frameworks
+ ),
+ patch("api.compliance._ensure_provider_loaded") as mock_ensure,
+ ):
+ failed = warm_compliance_caches(providers)
+
+ assert failed == [Provider.ProviderChoices.OKTA]
+ mock_ensure.assert_called_once_with(Provider.ProviderChoices.AWS)
+
+ def test_sets_readiness_flags(self, reset_compliance_cache):
+ assert not compliance_module.COMPLIANCE_WARMING_STARTED.is_set()
+ assert not compliance_module.COMPLIANCE_WARMED.is_set()
+
+ with (
+ patch("api.compliance.get_compliance_frameworks"),
+ patch("api.compliance._ensure_provider_loaded"),
+ ):
+ warm_compliance_caches([Provider.ProviderChoices.AWS])
+
+ assert compliance_module.COMPLIANCE_WARMING_STARTED.is_set()
+ assert compliance_module.COMPLIANCE_WARMED.is_set()
+
+ def test_marks_warmed_even_when_a_provider_fails(self, reset_compliance_cache):
+ """A failed provider still leaves the caches flagged as warmed."""
+ with (
+ patch(
+ "api.compliance.get_compliance_frameworks",
+ side_effect=SystemExit(1),
+ ),
+ patch("api.compliance._ensure_provider_loaded"),
+ ):
+ warm_compliance_caches([Provider.ProviderChoices.AWS])
+
+ assert compliance_module.COMPLIANCE_WARMED.is_set()
diff --git a/api/src/backend/api/tests/test_cypher_sanitizer.py b/api/src/backend/api/tests/test_cypher_sanitizer.py
index a54afcd8bb..c0d4f9b7ff 100644
--- a/api/src/backend/api/tests/test_cypher_sanitizer.py
+++ b/api/src/backend/api/tests/test_cypher_sanitizer.py
@@ -3,13 +3,12 @@
from unittest.mock import patch
import pytest
-
-from rest_framework.exceptions import ValidationError
-
from api.attack_paths.cypher_sanitizer import (
+ inject_label,
inject_provider_label,
validate_custom_query,
)
+from rest_framework.exceptions import ValidationError
PROVIDER_ID = "019c41ee-7df3-7dec-a684-d839f95619f8"
LABEL = "_Provider_019c41ee7df37deca684d839f95619f8"
@@ -23,6 +22,13 @@ def _inject(cypher: str) -> str:
return inject_provider_label(cypher, PROVIDER_ID)
+def test_generic_inject_label_reuses_provider_injection_pipeline():
+ result = inject_label("MATCH (n:AWSRole)--(m) RETURN n, m", "_Tenant_test")
+
+ assert "(n:AWSRole:_Tenant_test)" in result
+ assert "(m:_Tenant_test)" in result
+
+
# ---------------------------------------------------------------------------
# Pass A - Labeled node patterns (all clauses)
# ---------------------------------------------------------------------------
@@ -202,9 +208,7 @@ class TestClauseSplitting:
def test_multiple_match_clauses(self):
cypher = (
- "MATCH (a:AWSAccount)--(b:AWSRole) "
- "MATCH (b)--(c:AWSPolicy) "
- "RETURN a, b, c"
+ "MATCH (a:AWSAccount)--(b:AWSRole) MATCH (b)--(c:AWSPolicy) RETURN a, b, c"
)
result = _inject(cypher)
assert f"(a:AWSAccount:{LABEL})" in result
@@ -265,9 +269,7 @@ class TestRealWorldQueries:
def test_custom_bare_query(self):
cypher = (
- "MATCH (a)-[:HAS_POLICY]->(b)\n"
- "WHERE a.name CONTAINS 'admin'\n"
- "RETURN a, b"
+ "MATCH (a)-[:HAS_POLICY]->(b)\nWHERE a.name CONTAINS 'admin'\nRETURN a, b"
)
result = _inject(cypher)
assert f"(a:{LABEL})" in result
@@ -344,9 +346,7 @@ class TestEdgeCases:
assert f"(outer:AWSAccount:{LABEL})" in result
def test_multiple_protected_regions(self):
- cypher = (
- "MATCH (n:X {a: 'hello'}) " 'WHERE n.b = "world" ' "// comment\n" "RETURN n"
- )
+ cypher = "MATCH (n:X {a: 'hello'}) WHERE n.b = \"world\" // comment\nRETURN n"
result = _inject(cypher)
assert "'hello'" in result
assert '"world"' in result
diff --git a/api/src/backend/api/tests/test_database.py b/api/src/backend/api/tests/test_database.py
index 46d3203414..8d328c6a91 100644
--- a/api/src/backend/api/tests/test_database.py
+++ b/api/src/backend/api/tests/test_database.py
@@ -1,12 +1,12 @@
-import pytest
-from django.conf import settings
-from django.db.migrations.recorder import MigrationRecorder
-from django.db.utils import ConnectionRouter
+from unittest.mock import patch
+import pytest
from api.db_router import MainRouter
from api.rls import Tenant
from config.django.base import DATABASE_ROUTERS as PROD_DATABASE_ROUTERS
-from unittest.mock import patch
+from django.conf import settings
+from django.db.migrations.recorder import MigrationRecorder
+from django.db.utils import ConnectionRouter
@patch("api.db_router.MainRouter.admin_db", new="admin")
diff --git a/api/src/backend/api/tests/test_db_connection_labels.py b/api/src/backend/api/tests/test_db_connection_labels.py
new file mode 100644
index 0000000000..a39e7d9051
--- /dev/null
+++ b/api/src/backend/api/tests/test_db_connection_labels.py
@@ -0,0 +1,55 @@
+from config.django.base import label_postgres_connections
+
+
+class TestLabelPostgresConnections:
+ def test_labels_postgres_and_skips_neo4j(self, monkeypatch):
+ monkeypatch.setenv("DJANGO_APP_COMPONENT", "scan")
+ databases = {
+ "default": {"ENGINE": "psqlextra.backend"},
+ "neo4j": {"HOST": "neo4j", "PORT": "7687"},
+ }
+
+ label_postgres_connections(databases)
+
+ assert databases["default"]["OPTIONS"]["application_name"] == "scan:default"
+ assert "OPTIONS" not in databases["neo4j"]
+
+ def test_labels_plain_postgresql_backend(self, monkeypatch):
+ monkeypatch.setenv("DJANGO_APP_COMPONENT", "api")
+ databases = {"saas": {"ENGINE": "django.db.backends.postgresql"}}
+
+ label_postgres_connections(databases)
+
+ assert databases["saas"]["OPTIONS"]["application_name"] == "api:saas"
+
+ def test_defaults_component_to_api_when_unset(self, monkeypatch):
+ monkeypatch.delenv("DJANGO_APP_COMPONENT", raising=False)
+ databases = {"default": {"ENGINE": "psqlextra.backend"}}
+
+ label_postgres_connections(databases)
+
+ assert databases["default"]["OPTIONS"]["application_name"] == "api:default"
+
+ def test_preserves_existing_options(self, monkeypatch):
+ monkeypatch.setenv("DJANGO_APP_COMPONENT", "worker")
+ databases = {
+ "replica": {
+ "ENGINE": "psqlextra.backend",
+ "OPTIONS": {"sslmode": "require"},
+ }
+ }
+
+ label_postgres_connections(databases)
+
+ assert databases["replica"]["OPTIONS"] == {
+ "sslmode": "require",
+ "application_name": "worker:replica",
+ }
+
+ def test_truncates_application_name_to_63_bytes(self, monkeypatch):
+ monkeypatch.setenv("DJANGO_APP_COMPONENT", "c" * 80)
+ databases = {"default": {"ENGINE": "psqlextra.backend"}}
+
+ label_postgres_connections(databases)
+
+ assert len(databases["default"]["OPTIONS"]["application_name"]) == 63
diff --git a/api/src/backend/api/tests/test_db_utils.py b/api/src/backend/api/tests/test_db_utils.py
index 18935b9a3e..347be2b64d 100644
--- a/api/src/backend/api/tests/test_db_utils.py
+++ b/api/src/backend/api/tests/test_db_utils.py
@@ -1,17 +1,16 @@
-from datetime import datetime, timezone
+from contextlib import contextmanager
+from datetime import UTC, datetime
from enum import Enum
-from unittest.mock import MagicMock, patch
+from unittest.mock import MagicMock, call, patch
import pytest
-from django.conf import settings
-from django.db import DEFAULT_DB_ALIAS, OperationalError
-from freezegun import freeze_time
-from psycopg2 import sql as psycopg2_sql
-from rest_framework_json_api.serializers import ValidationError
-
from api.db_utils import (
POSTGRES_TENANT_VAR,
+ SET_CONFIG_QUERY,
+ SET_TRANSACTION_READ_ONLY_QUERY,
PostgresEnumMigration,
+ _is_replica_connection_failure,
+ _is_safe_primary_replay,
_should_create_index_on_partition,
batch_delete,
create_objects_in_batches,
@@ -23,6 +22,11 @@ from api.db_utils import (
update_objects_in_batches,
)
from api.models import Provider
+from django.conf import settings
+from django.db import DEFAULT_DB_ALIAS, OperationalError
+from freezegun import freeze_time
+from psycopg2 import sql as psycopg2_sql
+from rest_framework_json_api.serializers import ValidationError
@pytest.fixture
@@ -94,18 +98,16 @@ class TestEnumToChoices:
class TestOneWeekFromNow:
def test_one_week_from_now(self):
with patch("api.db_utils.datetime") as mock_datetime:
- mock_datetime.now.return_value = datetime(2023, 1, 1, tzinfo=timezone.utc)
- expected_result = datetime(2023, 1, 8, tzinfo=timezone.utc)
+ mock_datetime.now.return_value = datetime(2023, 1, 1, tzinfo=UTC)
+ expected_result = datetime(2023, 1, 8, tzinfo=UTC)
result = one_week_from_now()
assert result == expected_result
def test_one_week_from_now_with_timezone(self):
with patch("api.db_utils.datetime") as mock_datetime:
- mock_datetime.now.return_value = datetime(
- 2023, 6, 15, 12, 0, tzinfo=timezone.utc
- )
- expected_result = datetime(2023, 6, 22, 12, 0, tzinfo=timezone.utc)
+ mock_datetime.now.return_value = datetime(2023, 6, 15, 12, 0, tzinfo=UTC)
+ expected_result = datetime(2023, 6, 22, 12, 0, tzinfo=UTC)
result = one_week_from_now()
assert result == expected_result
@@ -395,10 +397,23 @@ class TestRlsTransaction:
with patch("api.db_utils.get_read_db_alias", return_value=None):
with patch("api.db_utils.connections") as mock_connections:
- mock_conn = MagicMock()
- mock_cursor = MagicMock()
- mock_conn.cursor.return_value.__enter__.return_value = mock_cursor
- mock_connections.__getitem__.return_value = mock_conn
+ mock_replica_conn = MagicMock()
+ mock_replica_cursor = MagicMock()
+ mock_replica_conn.cursor.return_value.__enter__.return_value = (
+ mock_replica_cursor
+ )
+ mock_primary_conn = MagicMock()
+ mock_primary_cursor = MagicMock()
+ mock_primary_conn.cursor.return_value.__enter__.return_value = (
+ mock_primary_cursor
+ )
+
+ def connections_getitem(alias):
+ if alias == "replica":
+ return mock_replica_conn
+ return mock_primary_conn
+
+ mock_connections.__getitem__.side_effect = connections_getitem
mock_connections.__contains__.return_value = True
with patch("api.db_utils.transaction.atomic"):
@@ -528,7 +543,7 @@ class TestRlsTransaction:
def atomic_side_effect(*args, **kwargs):
nonlocal call_count
call_count += 1
- if call_count < 3:
+ if call_count < 4:
raise OperationalError("Connection error")
return MagicMock(
__enter__=MagicMock(return_value=None),
@@ -547,10 +562,11 @@ class TestRlsTransaction:
with rls_transaction(tenant_id):
pass
- assert mock_sleep.call_count == 2
+ assert mock_sleep.call_count == 3
mock_sleep.assert_any_call(0.5)
mock_sleep.assert_any_call(1.0)
- assert mock_logger.info.call_count == 2
+ mock_sleep.assert_any_call(2.0)
+ assert mock_logger.info.call_count == 3
def test_rls_transaction_operational_error_inside_context_no_retry(
self, tenants_fixture, enable_read_replica
@@ -581,11 +597,12 @@ class TestRlsTransaction:
raise OperationalError("Conflict with recovery")
mock_sleep.assert_not_called()
+ mock_conn.close.assert_not_called()
- def test_rls_transaction_max_three_attempts_for_replica(
+ def test_rls_transaction_max_attempts_for_replica(
self, tenants_fixture, enable_read_replica
):
- """Test maximum 3 attempts for replica database."""
+ """Test REPLICA_MAX_ATTEMPTS replica tries + 1 primary fallback."""
tenant = tenants_fixture[0]
tenant_id = str(tenant.id)
@@ -609,7 +626,11 @@ class TestRlsTransaction:
with rls_transaction(tenant_id):
pass
- assert mock_atomic.call_count == 3
+ assert mock_atomic.call_args_list[-1] == call(
+ using=DEFAULT_DB_ALIAS
+ )
+ # 3 replica + 1 primary = 4 total
+ assert mock_atomic.call_count == 4
def test_rls_transaction_replica_no_retry_when_disabled(
self, tenants_fixture, enable_read_replica
@@ -620,10 +641,23 @@ class TestRlsTransaction:
with patch("api.db_utils.get_read_db_alias", return_value=enable_read_replica):
with patch("api.db_utils.connections") as mock_connections:
- mock_conn = MagicMock()
- mock_cursor = MagicMock()
- mock_conn.cursor.return_value.__enter__.return_value = mock_cursor
- mock_connections.__getitem__.return_value = mock_conn
+ mock_replica_conn = MagicMock()
+ mock_replica_cursor = MagicMock()
+ mock_replica_conn.cursor.return_value.__enter__.return_value = (
+ mock_replica_cursor
+ )
+ mock_primary_conn = MagicMock()
+ mock_primary_cursor = MagicMock()
+ mock_primary_conn.cursor.return_value.__enter__.return_value = (
+ mock_primary_cursor
+ )
+
+ def connections_getitem(alias):
+ if alias == "replica":
+ return mock_replica_conn
+ return mock_primary_conn
+
+ mock_connections.__getitem__.side_effect = connections_getitem
mock_connections.__contains__.return_value = True
with patch("api.db_utils.transaction.atomic") as mock_atomic:
@@ -685,7 +719,7 @@ class TestRlsTransaction:
def atomic_side_effect(*args, **kwargs):
nonlocal call_count
call_count += 1
- if call_count < 3:
+ if call_count < 4:
raise OperationalError("Replica error")
return MagicMock(
__enter__=MagicMock(return_value=None),
@@ -694,7 +728,7 @@ class TestRlsTransaction:
with patch(
"api.db_utils.transaction.atomic", side_effect=atomic_side_effect
- ):
+ ) as mock_atomic:
with patch("api.db_utils.time.sleep"):
with patch(
"api.db_utils.set_read_db_alias", return_value="token"
@@ -704,6 +738,9 @@ class TestRlsTransaction:
with rls_transaction(tenant_id):
pass
+ assert mock_atomic.call_args_list[-1] == call(
+ using=DEFAULT_DB_ALIAS
+ )
mock_logger.warning.assert_called_once()
warning_msg = mock_logger.warning.call_args[0][0]
assert "falling back to primary DB" in warning_msg
@@ -728,7 +765,7 @@ class TestRlsTransaction:
def atomic_side_effect(*args, **kwargs):
nonlocal call_count
call_count += 1
- if call_count < 3:
+ if call_count < 4:
raise OperationalError("Replica error")
return MagicMock(
__enter__=MagicMock(return_value=None),
@@ -747,7 +784,7 @@ class TestRlsTransaction:
with rls_transaction(tenant_id):
pass
- assert mock_logger.info.call_count == 2
+ assert mock_logger.info.call_count == 3
assert mock_logger.warning.call_count == 1
def test_rls_transaction_operational_error_raised_immediately_on_primary(
@@ -913,6 +950,520 @@ class TestRlsTransaction:
result = cursor.fetchone()
assert result[0] == 1
+ # --- Mid-query failover tests ---
+
+ class _FakeDatabaseError(Exception):
+ def __init__(self, message, pgcode=None):
+ super().__init__(message)
+ self.pgcode = pgcode
+
+ def _install_execute_wrapper(self, connection):
+ connection.execute_wrappers = []
+
+ @contextmanager
+ def _execute_wrapper(fn):
+ connection.execute_wrappers.append(fn)
+ try:
+ yield
+ finally:
+ connection.execute_wrappers.remove(fn)
+
+ connection.execute_wrapper = _execute_wrapper
+
+ def _mock_replica_and_primary_connections(self, mock_connections):
+ mock_replica_conn = MagicMock()
+ self._install_execute_wrapper(mock_replica_conn)
+ mock_replica_cursor = MagicMock()
+ mock_replica_conn.cursor.return_value.__enter__.return_value = (
+ mock_replica_cursor
+ )
+
+ mock_primary_conn = MagicMock()
+ mock_primary_cursor = MagicMock()
+ mock_primary_raw_cursor = MagicMock()
+ mock_primary_cursor.cursor = mock_primary_raw_cursor
+ mock_primary_conn.cursor.return_value = mock_primary_cursor
+
+ def connections_getitem(alias):
+ if alias == "replica":
+ return mock_replica_conn
+ return mock_primary_conn
+
+ mock_connections.__getitem__.side_effect = connections_getitem
+ mock_connections.__contains__.return_value = True
+
+ return mock_replica_conn, mock_primary_conn, mock_primary_cursor
+
+ @pytest.mark.parametrize(
+ "error",
+ [
+ _FakeDatabaseError("connection lost", pgcode="08006"),
+ _FakeDatabaseError("terminating connection", pgcode="57P01"),
+ OperationalError("SSL SYSCALL error: EOF detected"),
+ OperationalError("server closed the connection unexpectedly"),
+ OperationalError("database system is starting up"),
+ ],
+ )
+ def test_replica_connection_failure_detection_allows_failover(self, error):
+ assert _is_replica_connection_failure(error)
+
+ @pytest.mark.parametrize(
+ "error",
+ [
+ _FakeDatabaseError("canceling statement", pgcode="57014"),
+ _FakeDatabaseError("could not serialize access", pgcode="40001"),
+ _FakeDatabaseError("deadlock detected", pgcode="40P01"),
+ OperationalError("deadlock detected"),
+ ],
+ )
+ def test_replica_connection_failure_detection_rejects_query_errors(self, error):
+ assert not _is_replica_connection_failure(error)
+
+ @pytest.mark.parametrize(
+ ("sql", "many", "expected"),
+ [
+ ("SELECT 1", False, True),
+ (" -- leading comment\nSELECT 1", False, True),
+ ("/* leading comment */ SELECT 1", False, True),
+ ("SELECT 1", True, False),
+ ("SELECTING 1", False, False),
+ ("INSERT INTO fake_table (name) VALUES (%s)", False, False),
+ ("WITH rows AS (SELECT 1) SELECT * FROM rows", False, False),
+ ("SELECT * INTO fake_table_copy FROM fake_table", False, False),
+ ("SELECT * FROM fake_table FOR UPDATE", False, False),
+ ("SELECT * FROM fake_table FOR SHARE", False, False),
+ ],
+ )
+ def test_primary_replay_safety_detection(self, sql, many, expected):
+ assert _is_safe_primary_replay(sql, many) is expected
+
+ def test_mid_query_failure_falls_directly_back_to_primary(
+ self, tenants_fixture, enable_read_replica
+ ):
+ """Mid-query replica connection loss is replayed once on primary."""
+ tenant = tenants_fixture[0]
+ tenant_id = str(tenant.id)
+
+ with patch("api.db_utils.get_read_db_alias", return_value=enable_read_replica):
+ with patch("api.db_utils.connections") as mock_connections:
+ (
+ mock_replica_conn,
+ mock_primary_conn,
+ mock_primary_cursor,
+ ) = self._mock_replica_and_primary_connections(mock_connections)
+
+ outer_atomic = MagicMock()
+ outer_atomic.__enter__ = MagicMock(return_value=None)
+ outer_atomic.__exit__ = MagicMock(return_value=False)
+ fallback_atomic = MagicMock()
+ fallback_atomic.__enter__ = MagicMock(return_value=None)
+ fallback_atomic.__exit__ = MagicMock(return_value=False)
+
+ with patch(
+ "api.db_utils.transaction.atomic",
+ side_effect=[outer_atomic, fallback_atomic],
+ ) as mock_atomic:
+ with patch("api.db_utils.time.sleep") as mock_sleep:
+ with patch(
+ "api.db_utils.set_read_db_alias",
+ side_effect=["replica-token", "primary-token"],
+ ) as mock_set_alias:
+ with patch(
+ "api.db_utils.reset_read_db_alias"
+ ) as mock_reset_alias:
+ with rls_transaction(tenant_id):
+ wrapper = mock_replica_conn.execute_wrappers[0]
+ context_cursor = MagicMock()
+ mock_execute = MagicMock(
+ side_effect=OperationalError(
+ "SSL SYSCALL error: EOF detected"
+ )
+ )
+
+ wrapper(
+ mock_execute,
+ "SELECT %s",
+ ["value"],
+ False,
+ {"cursor": context_cursor},
+ )
+
+ mock_sleep.assert_not_called()
+ (
+ mock_replica_conn.ensure_connection.assert_not_called()
+ )
+ mock_replica_conn.close.assert_called_once()
+ (
+ mock_primary_conn.ensure_connection.assert_called_once()
+ )
+ mock_primary_conn.cursor.assert_called_once_with()
+ mock_primary_cursor.execute.assert_has_calls(
+ [
+ call(SET_TRANSACTION_READ_ONLY_QUERY),
+ call(
+ SET_CONFIG_QUERY,
+ [POSTGRES_TENANT_VAR, tenant_id],
+ ),
+ call("SELECT %s", ["value"]),
+ ]
+ )
+ assert context_cursor.db == mock_primary_conn
+ assert (
+ context_cursor.cursor
+ == mock_primary_cursor.cursor
+ )
+
+ mock_set_alias.assert_has_calls(
+ [
+ call(enable_read_replica),
+ call(DEFAULT_DB_ALIAS),
+ ]
+ )
+ mock_reset_alias.assert_has_calls(
+ [call("primary-token"), call("replica-token")]
+ )
+ assert mock_atomic.call_args_list == [
+ call(using=enable_read_replica),
+ call(using=DEFAULT_DB_ALIAS),
+ ]
+ assert mock_replica_conn.execute_wrappers == []
+
+ @pytest.mark.parametrize(
+ ("sql", "params", "many"),
+ [
+ ("INSERT INTO fake_table (name) VALUES (%s)", [("one",), ("two",)], True),
+ ("INSERT INTO fake_table (name) VALUES (%s)", ["one"], False),
+ ("UPDATE fake_table SET name = %s", ["one"], False),
+ ("DELETE FROM fake_table WHERE id = %s", [1], False),
+ (
+ "WITH deleted AS (DELETE FROM fake_table RETURNING *) "
+ "SELECT * FROM deleted",
+ None,
+ False,
+ ),
+ ("SELECT * INTO fake_table_copy FROM fake_table", None, False),
+ ],
+ )
+ def test_mid_query_fallback_rejects_unsafe_replay(
+ self, tenants_fixture, enable_read_replica, sql, params, many
+ ):
+ """Only single SELECT statements are replayed on primary."""
+ tenant = tenants_fixture[0]
+ tenant_id = str(tenant.id)
+
+ with patch("api.db_utils.get_read_db_alias", return_value=enable_read_replica):
+ with patch("api.db_utils.connections") as mock_connections:
+ (
+ mock_replica_conn,
+ mock_primary_conn,
+ mock_primary_cursor,
+ ) = self._mock_replica_and_primary_connections(mock_connections)
+
+ with patch("api.db_utils.transaction.atomic") as mock_atomic:
+ mock_atomic.return_value.__enter__ = MagicMock(return_value=None)
+ mock_atomic.return_value.__exit__ = MagicMock(return_value=False)
+ with patch(
+ "api.db_utils.set_read_db_alias",
+ side_effect=["replica-token", "primary-token"],
+ ):
+ with patch("api.db_utils.reset_read_db_alias"):
+ with rls_transaction(tenant_id):
+ wrapper = mock_replica_conn.execute_wrappers[0]
+ mock_execute = MagicMock(
+ side_effect=OperationalError(
+ "server closed the connection"
+ )
+ )
+
+ with pytest.raises(OperationalError):
+ wrapper(
+ mock_execute,
+ sql,
+ params,
+ many,
+ {"cursor": MagicMock()},
+ )
+
+ mock_primary_conn.ensure_connection.assert_not_called()
+ mock_primary_conn.cursor.assert_not_called()
+ mock_primary_cursor.execute.assert_not_called()
+ mock_primary_cursor.executemany.assert_not_called()
+
+ def test_mid_query_non_connection_error_does_not_fall_back(
+ self, tenants_fixture, enable_read_replica
+ ):
+ """Query/concurrency errors are not replayed on primary."""
+ tenant = tenants_fixture[0]
+ tenant_id = str(tenant.id)
+
+ with patch("api.db_utils.get_read_db_alias", return_value=enable_read_replica):
+ with patch("api.db_utils.connections") as mock_connections:
+ (
+ mock_replica_conn,
+ mock_primary_conn,
+ _mock_primary_cursor,
+ ) = self._mock_replica_and_primary_connections(mock_connections)
+
+ with patch("api.db_utils.transaction.atomic") as mock_atomic:
+ mock_atomic.return_value.__enter__ = MagicMock(return_value=None)
+ mock_atomic.return_value.__exit__ = MagicMock(return_value=False)
+ with patch(
+ "api.db_utils.set_read_db_alias", return_value="replica-token"
+ ):
+ with patch("api.db_utils.reset_read_db_alias"):
+ with rls_transaction(tenant_id):
+ wrapper = mock_replica_conn.execute_wrappers[0]
+ mock_execute = MagicMock(
+ side_effect=OperationalError("deadlock detected")
+ )
+
+ with pytest.raises(OperationalError):
+ wrapper(
+ mock_execute,
+ "SELECT 1",
+ None,
+ False,
+ {"cursor": MagicMock()},
+ )
+
+ mock_replica_conn.close.assert_not_called()
+ (
+ mock_primary_conn.ensure_connection.assert_not_called()
+ )
+
+ def test_mid_query_primary_replay_failure_propagates(
+ self, tenants_fixture, enable_read_replica
+ ):
+ """Primary fallback errors propagate as Django OperationalError."""
+ tenant = tenants_fixture[0]
+ tenant_id = str(tenant.id)
+
+ with patch("api.db_utils.get_read_db_alias", return_value=enable_read_replica):
+ with patch("api.db_utils.connections") as mock_connections:
+ (
+ mock_replica_conn,
+ _mock_primary_conn,
+ mock_primary_cursor,
+ ) = self._mock_replica_and_primary_connections(mock_connections)
+ mock_primary_cursor.execute.side_effect = [
+ None,
+ None,
+ OperationalError("primary down"),
+ ]
+
+ with patch("api.db_utils.transaction.atomic") as mock_atomic:
+ mock_atomic.return_value.__enter__ = MagicMock(return_value=None)
+ mock_atomic.return_value.__exit__ = MagicMock(return_value=False)
+ with patch(
+ "api.db_utils.set_read_db_alias",
+ side_effect=["replica-token", "primary-token"],
+ ):
+ with patch("api.db_utils.reset_read_db_alias"):
+ with pytest.raises(OperationalError, match="primary down"):
+ with rls_transaction(tenant_id):
+ wrapper = mock_replica_conn.execute_wrappers[0]
+ mock_execute = MagicMock(
+ side_effect=OperationalError(
+ "server closed the connection"
+ )
+ )
+ wrapper(
+ mock_execute,
+ "SELECT 1",
+ None,
+ False,
+ {"cursor": MagicMock()},
+ )
+
+ mock_primary_cursor.close.assert_called_once()
+
+ def test_mid_query_fallback_suppresses_cleanup_error(
+ self, tenants_fixture, enable_read_replica
+ ):
+ """After successful primary fallback, replica cleanup error is suppressed."""
+ tenant = tenants_fixture[0]
+ tenant_id = str(tenant.id)
+
+ with patch("api.db_utils.get_read_db_alias", return_value=enable_read_replica):
+ with patch("api.db_utils.connections") as mock_connections:
+ (
+ mock_replica_conn,
+ _mock_primary_conn,
+ mock_primary_cursor,
+ ) = self._mock_replica_and_primary_connections(mock_connections)
+
+ # Replica's atomic.__exit__ raises on dead replica cleanup;
+ # primary's atomic.__exit__ returns False (healthy commit).
+ mock_outer_atomic = MagicMock()
+ mock_outer_atomic.__enter__ = MagicMock(return_value=None)
+ mock_outer_atomic.__exit__ = MagicMock(
+ side_effect=OperationalError("cleanup failed on dead replica")
+ )
+
+ mock_fallback_atomic = MagicMock()
+ mock_fallback_atomic.__enter__ = MagicMock(return_value=None)
+ mock_fallback_atomic.__exit__ = MagicMock(return_value=False)
+
+ atomic_call_count = 0
+
+ def atomic_side_effect(*args, **kwargs):
+ nonlocal atomic_call_count
+ atomic_call_count += 1
+ if atomic_call_count == 1:
+ return mock_outer_atomic
+ return mock_fallback_atomic
+
+ with patch(
+ "api.db_utils.transaction.atomic",
+ side_effect=atomic_side_effect,
+ ):
+ with patch(
+ "api.db_utils.set_read_db_alias",
+ side_effect=["replica-token", "primary-token"],
+ ):
+ with patch("api.db_utils.reset_read_db_alias"):
+ with rls_transaction(tenant_id):
+ wrapper = mock_replica_conn.execute_wrappers[0]
+ mock_execute = MagicMock(
+ side_effect=OperationalError(
+ "server closed the connection"
+ )
+ )
+ mock_context = {"cursor": MagicMock()}
+ wrapper(
+ mock_execute,
+ "SELECT 1",
+ None,
+ False,
+ mock_context,
+ )
+
+ mock_primary_cursor.execute.assert_has_calls(
+ [
+ call(SET_TRANSACTION_READ_ONLY_QUERY),
+ call(
+ SET_CONFIG_QUERY,
+ [POSTGRES_TENANT_VAR, tenant_id],
+ ),
+ call("SELECT 1", None),
+ ]
+ )
+
+ def test_wrapper_not_installed_on_primary(self, tenants_fixture):
+ """execute_wrapper is not installed when targeting primary DB."""
+ tenant = tenants_fixture[0]
+ tenant_id = str(tenant.id)
+
+ with patch("api.db_utils.get_read_db_alias", return_value=None):
+ with patch("api.db_utils.connections") as mock_connections:
+ mock_conn = MagicMock()
+ mock_conn.execute_wrappers = []
+ mock_cursor = MagicMock()
+ mock_conn.cursor.return_value.__enter__.return_value = mock_cursor
+ mock_connections.__getitem__.return_value = mock_conn
+ mock_connections.__contains__.return_value = True
+
+ with patch("api.db_utils.transaction.atomic") as mock_atomic:
+ mock_atomic.return_value.__enter__ = MagicMock(return_value=None)
+ mock_atomic.return_value.__exit__ = MagicMock(return_value=False)
+
+ with rls_transaction(tenant_id):
+ # No wrapper installed on primary
+ assert len(mock_conn.execute_wrappers) == 0
+
+ def test_stale_connection_closed_on_pre_yield_retry(
+ self, tenants_fixture, enable_read_replica
+ ):
+ """Stale connection is closed before each pre-yield retry."""
+ tenant = tenants_fixture[0]
+ tenant_id = str(tenant.id)
+
+ with patch("api.db_utils.get_read_db_alias", return_value=enable_read_replica):
+ with patch("api.db_utils.connections") as mock_connections:
+ mock_conn = MagicMock()
+ mock_conn.execute_wrappers = []
+ mock_cursor = MagicMock()
+ mock_conn.cursor.return_value.__enter__.return_value = mock_cursor
+ mock_connections.__getitem__.return_value = mock_conn
+ mock_connections.__contains__.return_value = True
+
+ call_count = 0
+
+ def atomic_side_effect(*args, **kwargs):
+ nonlocal call_count
+ call_count += 1
+ if call_count < 3:
+ raise OperationalError("Connection error")
+ return MagicMock(
+ __enter__=MagicMock(return_value=None),
+ __exit__=MagicMock(return_value=False),
+ )
+
+ with patch(
+ "api.db_utils.transaction.atomic", side_effect=atomic_side_effect
+ ):
+ with patch("api.db_utils.time.sleep"):
+ with patch(
+ "api.db_utils.set_read_db_alias", return_value="token"
+ ):
+ with patch("api.db_utils.reset_read_db_alias"):
+ with rls_transaction(tenant_id):
+ pass
+
+ # close() called for each failed pre-yield attempt
+ assert mock_conn.close.call_count == 2
+
+ def test_caller_error_propagates_after_successful_failover(
+ self, tenants_fixture, enable_read_replica
+ ):
+ """OperationalError raised by caller after failover is NOT suppressed."""
+ tenant = tenants_fixture[0]
+ tenant_id = str(tenant.id)
+
+ with patch("api.db_utils.get_read_db_alias", return_value=enable_read_replica):
+ with patch("api.db_utils.connections") as mock_connections:
+ (
+ mock_replica_conn,
+ _mock_primary_conn,
+ _mock_primary_cursor,
+ ) = self._mock_replica_and_primary_connections(mock_connections)
+
+ # Transaction cleanup succeeds so the caller error should surface.
+ mock_atomic_cm = MagicMock()
+ mock_atomic_cm.__enter__ = MagicMock(return_value=None)
+ mock_atomic_cm.__exit__ = MagicMock(return_value=False)
+
+ with patch(
+ "api.db_utils.transaction.atomic", return_value=mock_atomic_cm
+ ):
+ with patch("api.db_utils.time.sleep"):
+ with patch(
+ "api.db_utils.set_read_db_alias", return_value="token"
+ ):
+ with patch("api.db_utils.reset_read_db_alias"):
+ with pytest.raises(
+ OperationalError, match="caller error"
+ ):
+ with rls_transaction(tenant_id):
+ # Trigger failover (succeeds on primary)
+ wrapper = mock_replica_conn.execute_wrappers[0]
+ mock_execute = MagicMock(
+ side_effect=OperationalError(
+ "server closed the connection"
+ )
+ )
+ mock_context = {"cursor": MagicMock()}
+ wrapper(
+ mock_execute,
+ "SELECT 1",
+ None,
+ False,
+ mock_context,
+ )
+ # Caller errors after successful failover
+ # should still propagate.
+ raise OperationalError("caller error")
+
class TestPostgresEnumMigration:
"""
@@ -939,9 +1490,9 @@ class TestPostgresEnumMigration:
mock_cursor.execute.assert_called_once()
query_arg = mock_cursor.execute.call_args[0][0]
- assert isinstance(
- query_arg, psycopg2_sql.Composable
- ), "create_enum_type must pass a psycopg2.sql.Composable, not a raw string."
+ assert isinstance(query_arg, psycopg2_sql.Composable), (
+ "create_enum_type must pass a psycopg2.sql.Composable, not a raw string."
+ )
# Verify the composed SQL structure: CREATE TYPE AS ENUM ()
parts = query_arg.seq
assert parts[0] == psycopg2_sql.SQL("CREATE TYPE ")
@@ -962,9 +1513,9 @@ class TestPostgresEnumMigration:
mock_cursor.execute.assert_called_once()
query_arg = mock_cursor.execute.call_args[0][0]
- assert isinstance(
- query_arg, psycopg2_sql.Composable
- ), "drop_enum_type must pass a psycopg2.sql.Composable, not a raw string."
+ assert isinstance(query_arg, psycopg2_sql.Composable), (
+ "drop_enum_type must pass a psycopg2.sql.Composable, not a raw string."
+ )
# Verify the composed SQL structure: DROP TYPE
parts = query_arg.seq
assert parts[0] == psycopg2_sql.SQL("DROP TYPE ")
diff --git a/api/src/backend/api/tests/test_decorators.py b/api/src/backend/api/tests/test_decorators.py
index 2d09a40734..0bf58340a1 100644
--- a/api/src/backend/api/tests/test_decorators.py
+++ b/api/src/backend/api/tests/test_decorators.py
@@ -2,12 +2,12 @@ import uuid
from unittest.mock import call, patch
import pytest
-from django.core.exceptions import ObjectDoesNotExist
-from django.db import DatabaseError, IntegrityError
-
+from api.attack_paths.database import GraphDatabaseQueryException
from api.db_utils import POSTGRES_TENANT_VAR, SET_CONFIG_QUERY
from api.decorators import handle_provider_deletion, set_tenant
from api.exceptions import ProviderDeletedException
+from django.core.exceptions import ObjectDoesNotExist
+from django.db import DEFAULT_DB_ALIAS, DatabaseError, IntegrityError
@pytest.mark.django_db
@@ -41,10 +41,10 @@ class TestSetTenantDecorator:
@pytest.mark.django_db
class TestHandleProviderDeletionDecorator:
- def test_success_no_exception(self, tenants_fixture, providers_fixture):
+ def test_success_no_exception(self, tenants_fixture, aws_provider):
"""Decorated function runs normally when no exception is raised."""
tenant = tenants_fixture[0]
- provider = providers_fixture[0]
+ provider = aws_provider
@handle_provider_deletion
def task_func(**kwargs):
@@ -128,11 +128,11 @@ class TestHandleProviderDeletionDecorator:
@patch("api.decorators.rls_transaction")
@patch("api.decorators.Provider.objects.filter")
def test_provider_exists_reraises_original(
- self, mock_filter, mock_rls, tenants_fixture, providers_fixture
+ self, mock_filter, mock_rls, tenants_fixture, aws_provider
):
"""Re-raises original exception when provider still exists."""
tenant = tenants_fixture[0]
- provider = providers_fixture[0]
+ provider = aws_provider
mock_rls.return_value.__enter__ = lambda s: None
mock_rls.return_value.__exit__ = lambda s, *args: None
@@ -188,11 +188,11 @@ class TestHandleProviderDeletionDecorator:
@patch("api.decorators.rls_transaction")
@patch("api.decorators.Provider.objects.filter")
def test_database_error_provider_exists_reraises(
- self, mock_filter, mock_rls, tenants_fixture, providers_fixture
+ self, mock_filter, mock_rls, tenants_fixture, aws_provider
):
"""Re-raises original DatabaseError when provider still exists."""
tenant = tenants_fixture[0]
- provider = providers_fixture[0]
+ provider = aws_provider
mock_rls.return_value.__enter__ = lambda s: None
mock_rls.return_value.__exit__ = lambda s, *args: None
@@ -205,6 +205,106 @@ class TestHandleProviderDeletionDecorator:
with pytest.raises(DatabaseError):
task_func(tenant_id=str(tenant.id), provider_id=str(provider.id))
+ @patch("api.decorators.rls_transaction")
+ @patch("api.decorators.Provider.objects.filter")
+ def test_graph_database_error_provider_missing_or_soft_deleted(
+ self, mock_provider_filter, mock_rls, tenants_fixture
+ ):
+ tenant = tenants_fixture[0]
+ provider_id = str(uuid.uuid4())
+
+ mock_rls.return_value.__enter__ = lambda s: None
+ mock_rls.return_value.__exit__ = lambda s, *args: None
+ mock_provider_filter.return_value.exists.return_value = False
+
+ @handle_provider_deletion
+ def task_func(**kwargs):
+ raise GraphDatabaseQueryException("Temporary database not found")
+
+ with pytest.raises(ProviderDeletedException):
+ task_func(tenant_id=str(tenant.id), provider_id=provider_id)
+
+ @patch("api.decorators.rls_transaction")
+ @patch("api.decorators.Tenant.objects.filter")
+ @patch("api.decorators.Provider.objects.filter")
+ def test_graph_database_error_tenant_missing(
+ self, mock_provider_filter, mock_tenant_filter, mock_rls, tenants_fixture
+ ):
+ tenant = tenants_fixture[0]
+ provider_id = str(uuid.uuid4())
+
+ mock_rls.return_value.__enter__ = lambda s: None
+ mock_rls.return_value.__exit__ = lambda s, *args: None
+ mock_provider_filter.return_value.exists.return_value = True
+ mock_tenant_filter.return_value.exists.return_value = False
+
+ @handle_provider_deletion
+ def task_func(**kwargs):
+ raise GraphDatabaseQueryException("Temporary database not found")
+
+ with pytest.raises(ProviderDeletedException):
+ task_func(tenant_id=str(tenant.id), provider_id=provider_id)
+
+ @patch("api.decorators.rls_transaction")
+ @patch("api.decorators.Membership.objects.filter")
+ @patch("api.decorators.Tenant.objects.filter")
+ @patch("api.decorators.Provider.objects.filter")
+ def test_graph_database_error_tenant_without_memberships(
+ self,
+ mock_provider_filter,
+ mock_tenant_filter,
+ mock_membership_filter,
+ mock_rls,
+ tenants_fixture,
+ ):
+ tenant = tenants_fixture[0]
+ provider_id = str(uuid.uuid4())
+
+ mock_rls.return_value.__enter__ = lambda s: None
+ mock_rls.return_value.__exit__ = lambda s, *args: None
+ mock_provider_filter.return_value.exists.return_value = True
+ mock_tenant_filter.return_value.exists.return_value = True
+ mock_membership_filter.return_value.exists.return_value = False
+
+ @handle_provider_deletion
+ def task_func(**kwargs):
+ raise GraphDatabaseQueryException("Temporary database not found")
+
+ with pytest.raises(ProviderDeletedException):
+ task_func(tenant_id=str(tenant.id), provider_id=provider_id)
+
+ @patch("api.decorators.rls_transaction")
+ @patch("api.decorators.Membership.objects.filter")
+ @patch("api.decorators.Tenant.objects.filter")
+ @patch("api.decorators.Provider.objects.filter")
+ def test_graph_database_error_active_provider_and_tenant_reraises(
+ self,
+ mock_provider_filter,
+ mock_tenant_filter,
+ mock_membership_filter,
+ mock_rls,
+ tenants_fixture,
+ ):
+ tenant = tenants_fixture[0]
+ provider_id = str(uuid.uuid4())
+ graph_error = GraphDatabaseQueryException("Temporary database not found")
+
+ mock_rls.return_value.__enter__ = lambda s: None
+ mock_rls.return_value.__exit__ = lambda s, *args: None
+ mock_provider_filter.return_value.exists.return_value = True
+ mock_tenant_filter.return_value.exists.return_value = True
+ mock_membership_filter.return_value.exists.return_value = True
+
+ @handle_provider_deletion
+ def task_func(**kwargs):
+ raise graph_error
+
+ with pytest.raises(GraphDatabaseQueryException) as exc_info:
+ task_func(tenant_id=str(tenant.id), provider_id=provider_id)
+
+ assert exc_info.value is graph_error
+ mock_rls.assert_called_once_with(str(tenant.id), using=DEFAULT_DB_ALIAS)
+
def test_missing_provider_and_scan_raises_assertion(self, tenants_fixture):
"""Raises AssertionError when neither provider_id nor scan_id in kwargs."""
diff --git a/api/src/backend/api/tests/test_health.py b/api/src/backend/api/tests/test_health.py
index 76b72c0dc7..b7cd20f697 100644
--- a/api/src/backend/api/tests/test_health.py
+++ b/api/src/backend/api/tests/test_health.py
@@ -7,15 +7,13 @@ Cover the IETF response envelope, status code mapping (200 / 503), the
from unittest.mock import patch
import pytest
+from api import health
from config import version as config_version
from django.core.cache import cache
from django.urls import reverse
from rest_framework import status
from rest_framework.test import APIClient
-from api import health
-
-
HEALTH_MEDIA_TYPE = "application/health+json"
@@ -69,7 +67,7 @@ class TestLivenessEndpoint:
with (
patch("api.health._probe_postgres") as mock_pg,
patch("api.health._probe_valkey") as mock_vk,
- patch("api.health._probe_neo4j") as mock_neo,
+ patch("api.health._probe_graph_db") as mock_neo,
):
response = api_client.get(reverse("health-live"))
@@ -85,14 +83,14 @@ class TestReadinessEndpoint:
return (
patch("api.health._probe_postgres", return_value=None),
patch("api.health._probe_valkey", return_value=None),
- patch("api.health._probe_neo4j", return_value=None),
+ patch("api.health._probe_graph_db", return_value=None),
)
def test_returns_200_and_pass_when_all_dependencies_healthy(self, api_client):
with (
patch("api.health._probe_postgres"),
patch("api.health._probe_valkey"),
- patch("api.health._probe_neo4j"),
+ patch("api.health._probe_graph_db"),
):
response = api_client.get(reverse("health-ready"))
@@ -109,7 +107,7 @@ class TestReadinessEndpoint:
assert set(body["checks"].keys()) == {
"postgres:responseTime",
"valkey:responseTime",
- "neo4j:responseTime",
+ "graphdb:responseTime",
}
for key in body["checks"]:
entries = body["checks"][key]
@@ -124,6 +122,23 @@ class TestReadinessEndpoint:
# `output` must not leak when the check passed.
assert "output" not in entry
+ @pytest.mark.parametrize("sink", ["neo4j", "neptune"])
+ def test_graphdb_component_id_reflects_active_sink(self, api_client, sink):
+ from django.test import override_settings
+
+ with (
+ override_settings(ATTACK_PATHS_SINK_DATABASE=sink),
+ patch("api.health._probe_postgres"),
+ patch("api.health._probe_valkey"),
+ patch("api.health._probe_graph_db"),
+ ):
+ response = api_client.get(reverse("health-ready"))
+
+ assert response.status_code == status.HTTP_200_OK
+ entry = response.json()["checks"]["graphdb:responseTime"][0]
+ # Stable key, but the concrete store is named in componentId.
+ assert entry["componentId"] == sink
+
def test_returns_503_and_fail_when_postgres_is_down(self, api_client):
with (
patch(
@@ -131,7 +146,7 @@ class TestReadinessEndpoint:
side_effect=RuntimeError("connection refused"),
),
patch("api.health._probe_valkey"),
- patch("api.health._probe_neo4j"),
+ patch("api.health._probe_graph_db"),
):
response = api_client.get(reverse("health-ready"))
@@ -143,13 +158,13 @@ class TestReadinessEndpoint:
# Exception detail is never echoed in the response, only logged.
assert "output" not in pg_entry
assert body["checks"]["valkey:responseTime"][0]["status"] == "pass"
- assert body["checks"]["neo4j:responseTime"][0]["status"] == "pass"
+ assert body["checks"]["graphdb:responseTime"][0]["status"] == "pass"
def test_returns_503_and_fail_when_valkey_is_down(self, api_client):
with (
patch("api.health._probe_postgres"),
patch("api.health._probe_valkey", side_effect=ConnectionError("timeout")),
- patch("api.health._probe_neo4j"),
+ patch("api.health._probe_graph_db"),
):
response = api_client.get(reverse("health-ready"))
@@ -160,12 +175,12 @@ class TestReadinessEndpoint:
assert vk_entry["status"] == "fail"
assert "output" not in vk_entry
- def test_returns_503_and_fail_when_neo4j_is_down(self, api_client):
+ def test_returns_503_and_fail_when_graph_db_is_down(self, api_client):
with (
patch("api.health._probe_postgres"),
patch("api.health._probe_valkey"),
patch(
- "api.health._probe_neo4j",
+ "api.health._probe_graph_db",
side_effect=RuntimeError("ServiceUnavailable"),
),
):
@@ -174,15 +189,15 @@ class TestReadinessEndpoint:
assert response.status_code == status.HTTP_503_SERVICE_UNAVAILABLE
body = response.json()
assert body["status"] == "fail"
- neo_entry = body["checks"]["neo4j:responseTime"][0]
- assert neo_entry["status"] == "fail"
- assert "output" not in neo_entry
+ graph_db_entry = body["checks"]["graphdb:responseTime"][0]
+ assert graph_db_entry["status"] == "fail"
+ assert "output" not in graph_db_entry
def test_reports_all_failures_simultaneously(self, api_client):
with (
patch("api.health._probe_postgres", side_effect=RuntimeError("pg down")),
patch("api.health._probe_valkey", side_effect=RuntimeError("vk down")),
- patch("api.health._probe_neo4j", side_effect=RuntimeError("neo down")),
+ patch("api.health._probe_graph_db", side_effect=RuntimeError("neo down")),
):
response = api_client.get(reverse("health-ready"))
@@ -192,7 +207,7 @@ class TestReadinessEndpoint:
for key in (
"postgres:responseTime",
"valkey:responseTime",
- "neo4j:responseTime",
+ "graphdb:responseTime",
):
entry = body["checks"][key][0]
assert entry["status"] == "fail"
@@ -211,7 +226,7 @@ class TestReadinessEndpoint:
with (
patch("api.health._probe_postgres", side_effect=RuntimeError(sensitive)),
patch("api.health._probe_valkey"),
- patch("api.health._probe_neo4j"),
+ patch("api.health._probe_graph_db"),
):
response = api_client.get(reverse("health-ready"))
@@ -231,7 +246,7 @@ class TestReadinessEndpoint:
with (
patch("api.health._probe_postgres"),
patch("api.health._probe_valkey"),
- patch("api.health._probe_neo4j"),
+ patch("api.health._probe_graph_db"),
):
api_client.credentials()
response = api_client.get(reverse("health-ready"))
@@ -246,7 +261,7 @@ class TestReadinessCache:
with (
patch("api.health._probe_postgres") as pg,
patch("api.health._probe_valkey") as vk,
- patch("api.health._probe_neo4j") as neo,
+ patch("api.health._probe_graph_db") as neo,
):
r1 = api_client.get(reverse("health-ready"))
r2 = api_client.get(reverse("health-ready"))
@@ -264,7 +279,7 @@ class TestReadinessCache:
with (
patch("api.health._probe_postgres") as pg,
patch("api.health._probe_valkey"),
- patch("api.health._probe_neo4j"),
+ patch("api.health._probe_graph_db"),
):
api_client.get(reverse("health-ready"))
assert pg.call_count == 1
@@ -288,7 +303,7 @@ class TestReadinessCache:
with (
patch("api.health._probe_postgres", side_effect=RuntimeError("down")) as pg,
patch("api.health._probe_valkey"),
- patch("api.health._probe_neo4j"),
+ patch("api.health._probe_graph_db"),
):
r1 = api_client.get(reverse("health-ready"))
r2 = api_client.get(reverse("health-ready"))
@@ -322,7 +337,7 @@ class TestRateLimiting:
with (
patch("api.health._probe_postgres"),
patch("api.health._probe_valkey"),
- patch("api.health._probe_neo4j"),
+ patch("api.health._probe_graph_db"),
patch.object(ScopedRateThrottle, "parse_rate", return_value=(2, 60)),
):
statuses = [
@@ -416,19 +431,42 @@ class TestProbeImplementations:
with pytest.raises(RuntimeError, match="bug"):
health._probe_valkey()
- def test_neo4j_probe_calls_verify_connectivity(self):
- with patch("api.attack_paths.database.get_driver") as mock_get_driver:
- mock_get_driver.return_value.verify_connectivity.return_value = None
- assert health._probe_neo4j() is None
- mock_get_driver.return_value.verify_connectivity.assert_called_once_with()
+ def test_graph_db_probe_calls_verify_connectivity(self):
+ with patch("api.attack_paths.database.verify_connectivity") as mock_verify:
+ mock_verify.return_value = None
+ assert health._probe_graph_db() is None
+ mock_verify.assert_called_once_with()
- def test_neo4j_probe_propagates_driver_errors(self):
- with patch("api.attack_paths.database.get_driver") as mock_get_driver:
- mock_get_driver.return_value.verify_connectivity.side_effect = RuntimeError(
- "unreachable"
- )
+ def test_graph_db_probe_propagates_errors(self):
+ with patch(
+ "api.attack_paths.database.verify_connectivity",
+ side_effect=RuntimeError("unreachable"),
+ ):
with pytest.raises(RuntimeError, match="unreachable"):
- health._probe_neo4j()
+ health._probe_graph_db()
+
+ def test_graph_db_probe_times_out_when_check_exceeds_budget(self):
+ # A sink whose connectivity check blocks past the probe budget must
+ # surface as a failure fast, not pin the request thread for the
+ # driver's full acquisition timeout.
+ import time as _time
+
+ def _hang() -> None:
+ _time.sleep(2)
+
+ with (
+ patch("api.health.GRAPH_DB_PROBE_TIMEOUT_SECONDS", 0.2),
+ patch(
+ "api.attack_paths.database.verify_connectivity",
+ side_effect=_hang,
+ ),
+ ):
+ started = _time.perf_counter()
+ with pytest.raises(TimeoutError):
+ health._probe_graph_db()
+ elapsed = _time.perf_counter() - started
+
+ assert elapsed < health.GRAPH_DB_PROBE_TIMEOUT_SECONDS + 1
class TestStatusAggregation:
diff --git a/api/src/backend/api/tests/test_middleware.py b/api/src/backend/api/tests/test_middleware.py
index 07165987de..08136dd68f 100644
--- a/api/src/backend/api/tests/test_middleware.py
+++ b/api/src/backend/api/tests/test_middleware.py
@@ -1,11 +1,10 @@
from unittest.mock import MagicMock, patch
import pytest
+from api.middleware import APILoggingMiddleware
from django.http import HttpResponse
from django.test import RequestFactory
-from api.middleware import APILoggingMiddleware
-
@pytest.mark.django_db
@patch("logging.getLogger")
diff --git a/api/src/backend/api/tests/test_mixins.py b/api/src/backend/api/tests/test_mixins.py
index 7daf9d5ff6..b90bcf4480 100644
--- a/api/src/backend/api/tests/test_mixins.py
+++ b/api/src/backend/api/tests/test_mixins.py
@@ -2,10 +2,6 @@ import json
from uuid import uuid4
import pytest
-from django_celery_results.models import TaskResult
-from rest_framework import status
-from rest_framework.response import Response
-
from api.exceptions import (
TaskFailedException,
TaskInProgressException,
@@ -14,6 +10,9 @@ from api.exceptions import (
from api.models import Task, User
from api.rls import Tenant
from api.v1.mixins import PaginateByPkMixin, TaskManagementMixin
+from django_celery_results.models import TaskResult
+from rest_framework import status
+from rest_framework.response import Response
@pytest.mark.django_db
diff --git a/api/src/backend/api/tests/test_models.py b/api/src/backend/api/tests/test_models.py
index b8b7f61dd1..5095da3a0e 100644
--- a/api/src/backend/api/tests/test_models.py
+++ b/api/src/backend/api/tests/test_models.py
@@ -1,10 +1,7 @@
-from datetime import datetime, timezone
+from datetime import UTC, datetime
import pytest
from allauth.socialaccount.models import SocialApp
-from django.core.exceptions import ValidationError
-from django.db import IntegrityError
-
from api.db_router import MainRouter
from api.models import (
ProviderComplianceScore,
@@ -16,12 +13,14 @@ from api.models import (
StatusChoices,
TenantComplianceSummary,
)
+from django.core.exceptions import ValidationError
+from django.db import IntegrityError
@pytest.mark.django_db
class TestResourceModel:
- def test_setting_tags(self, providers_fixture):
- provider, *_ = providers_fixture
+ def test_setting_tags(self, aws_provider):
+ provider = aws_provider
tenant_id = provider.tenant_id
resource = Resource.objects.create(
@@ -112,9 +111,9 @@ class TestResourceModel:
# @pytest.mark.django_db
# class TestFindingModel:
# def test_add_finding_with_long_uid(
-# self, providers_fixture, scans_fixture, resources_fixture
+# self, aws_provider, scans_fixture, resources_fixture
# ):
-# provider, *_ = providers_fixture
+# provider = aws_provider
# tenant_id = provider.tenant_id
# long_uid = "1" * 500
@@ -373,10 +372,10 @@ class TestSAMLConfigurationModel:
@pytest.mark.django_db
class TestProviderComplianceScoreModel:
- def test_create_provider_compliance_score(self, providers_fixture, scans_fixture):
- provider = providers_fixture[0]
+ def test_create_provider_compliance_score(self, aws_provider, scans_fixture):
+ provider = aws_provider
scan = scans_fixture[0]
- scan.completed_at = datetime.now(timezone.utc)
+ scan.completed_at = datetime.now(UTC)
scan.save()
score = ProviderComplianceScore.objects.create(
@@ -394,11 +393,11 @@ class TestProviderComplianceScoreModel:
assert score.requirement_status == StatusChoices.PASS
def test_unique_constraint_per_provider_compliance_requirement(
- self, providers_fixture, scans_fixture
+ self, aws_provider, scans_fixture
):
- provider = providers_fixture[0]
+ provider = aws_provider
scan = scans_fixture[0]
- scan.completed_at = datetime.now(timezone.utc)
+ scan.completed_at = datetime.now(UTC)
scan.save()
ProviderComplianceScore.objects.create(
@@ -423,16 +422,16 @@ class TestProviderComplianceScoreModel:
)
def test_different_providers_same_requirement_allowed(
- self, providers_fixture, scans_fixture
+ self, aws_provider_pair, scans_fixture
):
- provider1, provider2, *_ = providers_fixture
+ provider1, provider2 = aws_provider_pair
scan1 = scans_fixture[0]
- scan1.completed_at = datetime.now(timezone.utc)
+ scan1.completed_at = datetime.now(UTC)
scan1.save()
scan2 = scans_fixture[2]
scan2.state = StateChoices.COMPLETED
- scan2.completed_at = datetime.now(timezone.utc)
+ scan2.completed_at = datetime.now(UTC)
scan2.save()
score1 = ProviderComplianceScore.objects.create(
diff --git a/api/src/backend/api/tests/test_rbac.py b/api/src/backend/api/tests/test_rbac.py
index 684a9e44b9..ed177138b2 100644
--- a/api/src/backend/api/tests/test_rbac.py
+++ b/api/src/backend/api/tests/test_rbac.py
@@ -2,10 +2,6 @@ import json
from unittest.mock import ANY, Mock, patch
import pytest
-from conftest import TEST_PASSWORD, TODAY
-from django.urls import reverse
-from rest_framework import status
-
from api.models import (
Membership,
ProviderGroup,
@@ -15,7 +11,11 @@ from api.models import (
User,
UserRoleRelationship,
)
+from api.rbac.permissions import HasPermissions, Permissions
from api.v1.serializers import TokenSerializer
+from conftest import TEST_PASSWORD, TODAY
+from django.urls import reverse
+from rest_framework import status
@pytest.mark.django_db
@@ -104,20 +104,84 @@ class TestUserViewSet:
assert response.json()["data"]["attributes"]["name"] == "Updated Name"
def test_partial_update_user_with_no_permissions(
- self, authenticated_client_no_permissions_rbac, create_test_user
+ self, authenticated_client_no_permissions_rbac, create_test_user_rbac_limited
):
updated_data = {
"data": {
"type": "users",
+ "id": str(create_test_user_rbac_limited.id),
"attributes": {"name": "Updated Name"},
}
}
response = authenticated_client_no_permissions_rbac.patch(
- reverse("user-detail", kwargs={"pk": create_test_user.id}),
+ reverse("user-detail", kwargs={"pk": create_test_user_rbac_limited.id}),
data=updated_data,
- format="vnd.api+json",
+ content_type="application/vnd.api+json",
)
- assert response.status_code == status.HTTP_403_FORBIDDEN
+ assert response.status_code == status.HTTP_200_OK
+ assert response.json()["data"]["attributes"]["name"] == "Updated Name"
+
+ def test_partial_update_other_user_with_no_permissions_denied(
+ self, authenticated_client_no_permissions_rbac, tenants_fixture
+ ):
+ original_email = "target-rbac-update@example.com"
+ original_password = "OriginalPassword123@"
+ target_user = User.objects.create_user(
+ name="target_rbac_update",
+ email=original_email,
+ password=original_password,
+ )
+ Membership.objects.create(user=target_user, tenant=tenants_fixture[0])
+ updated_data = {
+ "data": {
+ "type": "users",
+ "id": str(target_user.id),
+ "attributes": {
+ "email": "updated-target-rbac@example.com",
+ "password": "UpdatedPassword123@",
+ },
+ }
+ }
+
+ response = authenticated_client_no_permissions_rbac.patch(
+ reverse("user-detail", kwargs={"pk": target_user.id}),
+ data=updated_data,
+ content_type="application/vnd.api+json",
+ )
+
+ assert response.status_code == status.HTTP_400_BAD_REQUEST
+ target_user.refresh_from_db()
+ assert target_user.email == original_email
+ assert target_user.check_password(original_password)
+
+ def test_partial_update_other_user_with_manage_users_allowed(
+ self, authenticated_client_rbac_manage_users_only
+ ):
+ user = authenticated_client_rbac_manage_users_only.user
+ tenant = Membership.objects.filter(user=user).first().tenant
+ target_user = User.objects.create_user(
+ name="target_manage_users_update",
+ email="target-manage-users-update@example.com",
+ password="Password123@",
+ )
+ Membership.objects.create(user=target_user, tenant=tenant)
+ updated_data = {
+ "data": {
+ "type": "users",
+ "id": str(target_user.id),
+ "attributes": {"name": "Updated Target Name"},
+ }
+ }
+
+ response = authenticated_client_rbac_manage_users_only.patch(
+ reverse("user-detail", kwargs={"pk": target_user.id}),
+ data=updated_data,
+ content_type="application/vnd.api+json",
+ )
+
+ assert response.status_code == status.HTTP_200_OK
+ target_user.refresh_from_db()
+ assert target_user.name == "Updated Target Name"
def test_delete_user_with_all_permissions(
self, authenticated_client_rbac, create_test_user_rbac
@@ -371,11 +435,11 @@ class TestUserViewSet:
@pytest.mark.django_db
class TestProviderViewSet:
def test_list_providers_with_all_permissions(
- self, authenticated_client_rbac, providers_fixture
+ self, authenticated_client_rbac, aws_provider
):
response = authenticated_client_rbac.get(reverse("provider-list"))
assert response.status_code == status.HTTP_200_OK
- assert len(response.json()["data"]) == len(providers_fixture)
+ assert len(response.json()["data"]) == 1
def test_list_providers_with_no_permissions(
self, authenticated_client_no_permissions_rbac
@@ -387,9 +451,9 @@ class TestProviderViewSet:
assert len(response.json()["data"]) == 0
def test_retrieve_provider_with_all_permissions(
- self, authenticated_client_rbac, providers_fixture
+ self, authenticated_client_rbac, aws_provider
):
- provider = providers_fixture[0]
+ provider = aws_provider
response = authenticated_client_rbac.get(
reverse("provider-detail", kwargs={"pk": provider.id})
)
@@ -397,9 +461,9 @@ class TestProviderViewSet:
assert response.json()["data"]["attributes"]["alias"] == provider.alias
def test_retrieve_provider_with_no_permissions(
- self, authenticated_client_no_permissions_rbac, providers_fixture
+ self, authenticated_client_no_permissions_rbac, aws_provider
):
- provider = providers_fixture[0]
+ provider = aws_provider
response = authenticated_client_no_permissions_rbac.get(
reverse("provider-detail", kwargs={"pk": provider.id})
)
@@ -423,9 +487,9 @@ class TestProviderViewSet:
assert response.status_code == status.HTTP_403_FORBIDDEN
def test_partial_update_provider_with_all_permissions(
- self, authenticated_client_rbac, providers_fixture
+ self, authenticated_client_rbac, aws_provider
):
- provider = providers_fixture[0]
+ provider = aws_provider
payload = {
"data": {
"type": "providers",
@@ -442,9 +506,9 @@ class TestProviderViewSet:
assert response.json()["data"]["attributes"]["alias"] == "updated_alias"
def test_partial_update_provider_with_no_permissions(
- self, authenticated_client_no_permissions_rbac, providers_fixture
+ self, authenticated_client_no_permissions_rbac, aws_provider
):
- provider = providers_fixture[0]
+ provider = aws_provider
update_payload = {
"data": {
"type": "providers",
@@ -465,7 +529,7 @@ class TestProviderViewSet:
mock_delete_task,
mock_task_get,
authenticated_client_rbac,
- providers_fixture,
+ aws_provider,
tasks_fixture,
):
prowler_task = tasks_fixture[0]
@@ -474,7 +538,7 @@ class TestProviderViewSet:
mock_delete_task.return_value = task_mock
mock_task_get.return_value = prowler_task
- provider1, *_ = providers_fixture
+ provider1 = aws_provider
response = authenticated_client_rbac.delete(
reverse("provider-detail", kwargs={"pk": provider1.id})
)
@@ -486,9 +550,9 @@ class TestProviderViewSet:
assert response.headers["Content-Location"] == f"/api/v1/tasks/{task_mock.id}"
def test_delete_provider_with_no_permissions(
- self, authenticated_client_no_permissions_rbac, providers_fixture
+ self, authenticated_client_no_permissions_rbac, aws_provider
):
- provider = providers_fixture[0]
+ provider = aws_provider
response = authenticated_client_no_permissions_rbac.delete(
reverse("provider-detail", kwargs={"pk": provider.id})
)
@@ -501,7 +565,7 @@ class TestProviderViewSet:
mock_provider_connection,
mock_task_get,
authenticated_client_rbac,
- providers_fixture,
+ aws_provider,
tasks_fixture,
):
prowler_task = tasks_fixture[0]
@@ -511,7 +575,7 @@ class TestProviderViewSet:
mock_provider_connection.return_value = task_mock
mock_task_get.return_value = prowler_task
- provider1, *_ = providers_fixture
+ provider1 = aws_provider
assert provider1.connected is None
assert provider1.connection_last_checked_at is None
@@ -526,9 +590,9 @@ class TestProviderViewSet:
assert response.headers["Content-Location"] == f"/api/v1/tasks/{task_mock.id}"
def test_connection_with_no_permissions(
- self, authenticated_client_no_permissions_rbac, providers_fixture
+ self, authenticated_client_no_permissions_rbac, aws_provider
):
- provider = providers_fixture[0]
+ provider = aws_provider
response = authenticated_client_no_permissions_rbac.post(
reverse("provider-connection", kwargs={"pk": provider.id})
)
@@ -541,12 +605,10 @@ class TestLimitedVisibility:
TEST_PASSWORD = "Thisisapassword123@"
@pytest.fixture
- def limited_admin_user(
- self, django_db_setup, django_db_blocker, tenants_fixture, providers_fixture
- ):
+ def limited_admin_user(self, django_db_blocker, tenants_fixture, aws_provider):
with django_db_blocker.unblock():
tenant = tenants_fixture[0]
- provider = providers_fixture[0]
+ provider = aws_provider
user = User.objects.create_user(
name="testing",
email=self.TEST_EMAIL,
@@ -593,25 +655,17 @@ class TestLimitedVisibility:
@pytest.fixture
def authenticated_client_rbac_limited(
- self, limited_admin_user, tenants_fixture, client
+ self,
+ limited_admin_user,
+ tenants_fixture,
+ authenticated_client_for_tenant_factory,
):
- client.user = limited_admin_user
- tenant_id = tenants_fixture[0].id
- serializer = TokenSerializer(
- data={
- "type": "tokens",
- "email": self.TEST_EMAIL,
- "password": self.TEST_PASSWORD,
- "tenant_id": tenant_id,
- }
+ return authenticated_client_for_tenant_factory(
+ limited_admin_user, tenants_fixture[0]
)
- serializer.is_valid(raise_exception=True)
- access_token = serializer.validated_data["access"]
- client.defaults["HTTP_AUTHORIZATION"] = f"Bearer {access_token}"
- return client
def test_integrations(
- self, authenticated_client_rbac_limited, integrations_fixture, providers_fixture
+ self, authenticated_client_rbac_limited, integrations_fixture
):
# Integration 2 is related to provider1 and provider 2
# This user cannot see provider 2
@@ -627,11 +681,11 @@ class TestLimitedVisibility:
response.json()["data"]["relationships"]["providers"]["meta"]["count"] == 1
)
+ @pytest.mark.usefixtures("scan_summaries_fixture")
def test_overviews_providers(
self,
authenticated_client_rbac_limited,
- scan_summaries_fixture,
- providers_fixture,
+ provider_factory,
):
# By default, the associated provider is the one which has the overview data
response = authenticated_client_rbac_limited.get(reverse("overview-providers"))
@@ -641,7 +695,7 @@ class TestLimitedVisibility:
# Changing the provider visibility, no data should be returned
# Only the associated provider to that group is changed
- new_provider = providers_fixture[1]
+ new_provider = provider_factory()
ProviderGroupMembership.objects.all().update(provider=new_provider)
response = authenticated_client_rbac_limited.get(reverse("overview-providers"))
@@ -649,6 +703,7 @@ class TestLimitedVisibility:
assert response.status_code == status.HTTP_200_OK
assert len(response.json()["data"]) == 0
+ @pytest.mark.usefixtures("scan_summaries_fixture")
@pytest.mark.parametrize(
"endpoint_name",
[
@@ -660,8 +715,7 @@ class TestLimitedVisibility:
self,
endpoint_name,
authenticated_client_rbac_limited,
- scan_summaries_fixture,
- providers_fixture,
+ provider_factory,
):
# By default, the associated provider is the one which has the overview data
response = authenticated_client_rbac_limited.get(
@@ -674,7 +728,7 @@ class TestLimitedVisibility:
# Changing the provider visibility, no data should be returned
# Only the associated provider to that group is changed
- new_provider = providers_fixture[1]
+ new_provider = provider_factory()
ProviderGroupMembership.objects.all().update(provider=new_provider)
response = authenticated_client_rbac_limited.get(
@@ -685,11 +739,11 @@ class TestLimitedVisibility:
data = response.json()["data"]["attributes"].values()
assert all(value == 0 for value in data)
+ @pytest.mark.usefixtures("scan_summaries_fixture")
def test_overviews_services(
self,
authenticated_client_rbac_limited,
- scan_summaries_fixture,
- providers_fixture,
+ provider_factory,
):
# By default, the associated provider is the one which has the overview data
response = authenticated_client_rbac_limited.get(
@@ -701,7 +755,7 @@ class TestLimitedVisibility:
# Changing the provider visibility, no data should be returned
# Only the associated provider to that group is changed
- new_provider = providers_fixture[1]
+ new_provider = provider_factory()
ProviderGroupMembership.objects.all().update(provider=new_provider)
response = authenticated_client_rbac_limited.get(
@@ -763,6 +817,48 @@ class TestRolePermissions:
assert response.status_code == status.HTTP_403_FORBIDDEN
+@pytest.mark.django_db
+class TestHasPermissions:
+ def test_permissions_are_combined_across_roles(
+ self, create_test_user_rbac_no_roles
+ ):
+ user = create_test_user_rbac_no_roles
+ tenant = Membership.objects.get(user=user).tenant
+ manage_users_role = Role.objects.create(
+ name="manage_users_only",
+ tenant=tenant,
+ manage_users=True,
+ )
+ UserRoleRelationship.objects.create(
+ user=user,
+ role=manage_users_role,
+ tenant=tenant,
+ )
+ request = Mock(user=user, tenant_id=tenant.id)
+ view = Mock(
+ required_permissions=[
+ Permissions.MANAGE_USERS,
+ Permissions.MANAGE_ACCOUNT,
+ ]
+ )
+ permission = HasPermissions()
+
+ assert not permission.has_permission(request, view)
+
+ manage_account_role = Role.objects.create(
+ name="manage_account_only",
+ tenant=tenant,
+ manage_account=True,
+ )
+ UserRoleRelationship.objects.create(
+ user=user,
+ role=manage_account_role,
+ tenant=tenant,
+ )
+
+ assert permission.has_permission(request, view)
+
+
@pytest.mark.django_db
class TestUserRoleLinkPermissions:
def test_link_user_roles_with_manage_account_only_allowed(
diff --git a/api/src/backend/api/tests/test_retryable_session.py b/api/src/backend/api/tests/test_retryable_session.py
new file mode 100644
index 0000000000..09b184c223
--- /dev/null
+++ b/api/src/backend/api/tests/test_retryable_session.py
@@ -0,0 +1,165 @@
+from unittest.mock import MagicMock, patch
+
+import pytest
+from api.attack_paths.retryable_session import RetryableSession, RetryExhaustedError
+from neo4j.exceptions import ServiceUnavailable
+
+
+class TestRetryableSession:
+ @patch("api.attack_paths.retryable_session.time.sleep")
+ @patch("api.attack_paths.retryable_session.random.uniform", return_value=3.0)
+ def test_custom_retry_uses_backoff_and_a_fresh_session(
+ self, mock_uniform, mock_sleep
+ ):
+ retryable_error = RuntimeError("retryable")
+ first_session = MagicMock()
+ first_session.execute_write.side_effect = retryable_error
+ second_session = MagicMock()
+ second_session.execute_write.return_value = "success"
+ session_factory = MagicMock(side_effect=[first_session, second_session])
+ work = MagicMock()
+
+ session = RetryableSession(
+ session_factory=session_factory,
+ max_retries=3,
+ retry_if=lambda exc: exc is retryable_error,
+ initial_retry_delay_seconds=2,
+ retry_context="Neptune write",
+ )
+
+ assert session.execute_write(work) == "success"
+ assert session_factory.call_count == 2
+ first_session.close.assert_called_once_with()
+ mock_uniform.assert_called_once_with(2.0, 4.0)
+ mock_sleep.assert_called_once_with(3.0)
+
+ def test_connection_errors_remain_retryable(self):
+ first_session = MagicMock()
+ first_session.run.side_effect = ServiceUnavailable("unavailable")
+ second_session = MagicMock()
+ second_session.run.return_value = "success"
+ session_factory = MagicMock(side_effect=[first_session, second_session])
+
+ session = RetryableSession(session_factory=session_factory, max_retries=1)
+
+ assert session.run("RETURN 1") == "success"
+ first_session.close.assert_called_once_with()
+
+ def test_non_retryable_error_is_raised_without_refreshing_session(self):
+ error = RuntimeError("do not retry")
+ driver_session = MagicMock()
+ driver_session.execute_write.side_effect = error
+ session_factory = MagicMock(return_value=driver_session)
+ session = RetryableSession(
+ session_factory=session_factory,
+ max_retries=3,
+ retry_if=lambda _: False,
+ initial_retry_delay_seconds=2,
+ retry_context="Neptune write",
+ )
+
+ with pytest.raises(RuntimeError) as exc_info:
+ session.execute_write(MagicMock())
+
+ assert exc_info.value is error
+ session_factory.assert_called_once_with()
+ driver_session.close.assert_not_called()
+
+ def test_retry_exhaustion_raises_the_last_error(self):
+ error = RuntimeError("still retryable")
+ driver_sessions = [MagicMock() for _ in range(3)]
+ for driver_session in driver_sessions:
+ driver_session.execute_write.side_effect = error
+ session_factory = MagicMock(side_effect=driver_sessions)
+ session = RetryableSession(
+ session_factory=session_factory,
+ max_retries=2,
+ retry_if=lambda _: True,
+ )
+
+ with pytest.raises(RuntimeError) as exc_info:
+ session.execute_write(MagicMock())
+
+ assert exc_info.value is error
+ assert session_factory.call_count == 3
+ driver_sessions[0].close.assert_called_once_with()
+ driver_sessions[1].close.assert_called_once_with()
+ driver_sessions[2].close.assert_not_called()
+
+ def test_retry_exhaustion_with_context_reports_attempts_and_elapsed_time(self):
+ error = RuntimeError("still retryable")
+ driver_sessions = [MagicMock() for _ in range(3)]
+ for driver_session in driver_sessions:
+ driver_session.execute_write.side_effect = error
+ session = RetryableSession(
+ session_factory=MagicMock(side_effect=driver_sessions),
+ max_retries=2,
+ retry_if=lambda _: True,
+ retry_context="Neptune write",
+ )
+
+ with (
+ patch(
+ "api.attack_paths.retryable_session.time.monotonic",
+ side_effect=[100.0, 127.1234],
+ ),
+ pytest.raises(RetryExhaustedError) as exc_info,
+ ):
+ session.execute_write(MagicMock())
+
+ assert exc_info.value.method_name == "execute_write"
+ assert exc_info.value.attempts == 3
+ assert exc_info.value.elapsed_seconds == pytest.approx(27.1234)
+ assert exc_info.value.last_error is error
+ assert exc_info.value.__cause__ is error
+ assert str(exc_info.value) == (
+ "Neptune write execute_write failed after 3 attempts over 27.123s. "
+ "Last error: still retryable"
+ )
+
+ def test_retry_exhaustion_with_zero_retries_reports_one_attempt(self):
+ error = ServiceUnavailable("still unavailable")
+ driver_session = MagicMock()
+ driver_session.execute_write.side_effect = error
+ session = RetryableSession(
+ session_factory=MagicMock(return_value=driver_session),
+ max_retries=0,
+ retry_context="Neptune write",
+ )
+
+ with pytest.raises(RetryExhaustedError) as exc_info:
+ session.execute_write(MagicMock())
+
+ assert exc_info.value.attempts == 1
+
+ @patch("api.attack_paths.retryable_session.time.sleep")
+ @patch("api.attack_paths.retryable_session.random.uniform", return_value=3.0)
+ def test_contextual_retry_warning_includes_original_error(
+ self, _mock_uniform, _mock_sleep
+ ):
+ error = RuntimeError("retryable detail")
+ first_session = MagicMock()
+ first_session.execute_write.side_effect = error
+ second_session = MagicMock()
+ second_session.execute_write.return_value = "success"
+ session = RetryableSession(
+ session_factory=MagicMock(side_effect=[first_session, second_session]),
+ max_retries=1,
+ retry_if=lambda _: True,
+ initial_retry_delay_seconds=2,
+ retry_context="Neptune write",
+ )
+
+ with patch("api.attack_paths.retryable_session.logger.warning") as mock_warning:
+ assert session.execute_write(MagicMock()) == "success"
+
+ mock_warning.assert_called_once_with(
+ "%s %s failed with %s: %s; retry %s/%s in %.3fs",
+ "Neptune write",
+ "execute_write",
+ "RuntimeError",
+ "retryable detail",
+ 1,
+ 1,
+ 3.0,
+ )
diff --git a/api/src/backend/api/tests/test_sentry.py b/api/src/backend/api/tests/test_sentry.py
index fb7abaffb4..67ba1ee01e 100644
--- a/api/src/backend/api/tests/test_sentry.py
+++ b/api/src/backend/api/tests/test_sentry.py
@@ -1,9 +1,34 @@
import logging
-from unittest.mock import MagicMock
+from unittest.mock import MagicMock, patch
+import pytest
+from config.settings import sentry as sentry_settings
from config.settings.sentry import before_send
+def test_initialize_sentry_skips_without_dsn():
+ with (
+ patch.object(sentry_settings.env, "str", return_value=""),
+ patch.object(sentry_settings.sentry_sdk, "init") as mock_init,
+ ):
+ sentry_settings.initialize_sentry()
+
+ mock_init.assert_not_called()
+
+
+def test_initialize_sentry_uses_configured_dsn():
+ sentry_dsn = "https://fake-public-key@sentry.example.invalid/1"
+
+ with (
+ patch.object(sentry_settings.env, "str", return_value=sentry_dsn),
+ patch.object(sentry_settings.sentry_sdk, "init") as mock_init,
+ ):
+ sentry_settings.initialize_sentry()
+
+ assert mock_init.call_args.kwargs["dsn"] == sentry_dsn
+ assert mock_init.call_args.kwargs["before_send"] is sentry_settings.before_send
+
+
def _make_log_record(msg, level=logging.ERROR, name="test", args=None):
"""Build a real LogRecord so getMessage() works like in production."""
record = logging.LogRecord(
@@ -58,6 +83,45 @@ def test_before_send_passes_through_non_ignored_log():
assert result == event
+def test_before_send_ignores_cartography_missing_temporary_database_log():
+ log_record = _make_log_record(
+ msg="Cartography job failed with %s for database %s",
+ name="cartography.graph.job",
+ args=(
+ "Neo.ClientError.Database.DatabaseNotFound",
+ "db-tmp-scan-12345678",
+ ),
+ )
+
+ event = MagicMock()
+
+ assert before_send(event, {"log_record": log_record}) is None
+
+
+@pytest.mark.parametrize(
+ ("logger_name", "message"),
+ [
+ (
+ "cartography.graph.job.worker",
+ "Neo.ClientError.Database.DatabaseNotFound for db-tmp-scan-12345678",
+ ),
+ (
+ "cartography.graph.job",
+ "DatabaseNotFound for db-tmp-scan-12345678",
+ ),
+ (
+ "cartography.graph.job",
+ "Neo.ClientError.Database.DatabaseNotFound for db-tenant-12345678",
+ ),
+ ],
+)
+def test_before_send_passes_through_similar_cartography_logs(logger_name, message):
+ log_record = _make_log_record(msg=message, name=logger_name)
+ event = MagicMock()
+
+ assert before_send(event, {"log_record": log_record}) is event
+
+
def test_before_send_passes_through_non_ignored_exception():
"""Test that before_send passes through exceptions that don't contain ignored exceptions."""
exc_info = (Exception, Exception("Some other error message"), None)
diff --git a/api/src/backend/api/tests/test_serializers.py b/api/src/backend/api/tests/test_serializers.py
index 5810a97b63..8e77d63604 100644
--- a/api/src/backend/api/tests/test_serializers.py
+++ b/api/src/backend/api/tests/test_serializers.py
@@ -1,9 +1,16 @@
import pytest
+from api.v1.serializer_utils.integrations import (
+ JiraCredentialSerializer,
+ S3ConfigSerializer,
+)
+from api.v1.serializer_utils.providers import ProviderSecretField
+from api.v1.serializers import (
+ ImageProviderSecret,
+ KubernetesProviderSecret,
+ OracleCloudProviderSecret,
+)
from rest_framework.exceptions import ValidationError
-from api.v1.serializer_utils.integrations import S3ConfigSerializer
-from api.v1.serializers import ImageProviderSecret
-
class TestS3ConfigSerializer:
"""Test cases for S3ConfigSerializer validation."""
@@ -101,6 +108,59 @@ class TestS3ConfigSerializer:
assert "output_directory" in serializer.errors
+class TestJiraCredentialSerializer:
+ @pytest.mark.parametrize(
+ "domain",
+ (
+ "a",
+ "prowler",
+ "prowler-domain",
+ "A1-b2-C3",
+ "a" * 63,
+ ),
+ )
+ def test_valid_site_name(self, domain):
+ serializer = JiraCredentialSerializer(
+ data={
+ "user_mail": "testing@prowler.com",
+ "api_token": "fake-api-token",
+ "domain": domain,
+ }
+ )
+
+ assert serializer.is_valid(), serializer.errors
+
+ @pytest.mark.parametrize(
+ "domain",
+ (
+ "169.254.169.254#",
+ "internal/service",
+ "internal?target",
+ "internal\\target",
+ "internal:8000",
+ "user@internal",
+ "example.atlassian.net",
+ "-prowler",
+ "prowler-",
+ "a" * 64,
+ " prowler",
+ "prowler ",
+ "prowler\n",
+ ),
+ )
+ def test_invalid_site_name(self, domain):
+ serializer = JiraCredentialSerializer(
+ data={
+ "user_mail": "testing@prowler.com",
+ "api_token": "fake-api-token",
+ "domain": domain,
+ }
+ )
+
+ assert not serializer.is_valid()
+ assert "domain" in serializer.errors
+
+
class TestImageProviderSecret:
"""Test cases for ImageProviderSecret validation."""
@@ -133,3 +193,132 @@ class TestImageProviderSecret:
serializer = ImageProviderSecret(data={"registry_password": "pass"})
assert not serializer.is_valid()
assert "non_field_errors" in serializer.errors
+
+
+class TestOracleCloudProviderSecret:
+ def valid_secret(self, **overrides):
+ secret = {
+ "user": "ocid1.user.oc1..aaaaaaaexample",
+ "fingerprint": "aa:bb:cc:dd:ee:ff:00:11:22:33:44:55:66:77:88:99",
+ "key_content": "fake-base64-key-content",
+ "tenancy": "ocid1.tenancy.oc1..aaaaaaaexample",
+ }
+ secret.update(overrides)
+ return secret
+
+ def test_accepts_regionless_secret(self):
+ serializer = OracleCloudProviderSecret(data=self.valid_secret())
+
+ assert serializer.is_valid(), serializer.errors
+ assert "region" not in serializer.validated_data
+
+ def test_accepts_and_ignores_region_field(self):
+ secret = self.valid_secret(region="us-phoenix-1")
+ serializer = OracleCloudProviderSecret(data=secret)
+
+ assert serializer.is_valid(), serializer.errors
+
+ assert "region" not in serializer.validated_data
+
+ @pytest.mark.parametrize(
+ "legacy_field, legacy_value",
+ [
+ ("region", None),
+ ("region", ""),
+ ("region", {"name": "us-ashburn-1"}),
+ ],
+ )
+ def test_accepts_and_ignores_any_legacy_region_value(
+ self, legacy_field, legacy_value
+ ):
+ serializer = OracleCloudProviderSecret(
+ data=self.valid_secret(**{legacy_field: legacy_value})
+ )
+
+ assert serializer.is_valid(), serializer.errors
+
+ assert legacy_field not in serializer.validated_data
+
+
+class TestProviderSecretFieldSchema:
+ def test_oraclecloud_schema_includes_legacy_region_field(self):
+ schema = ProviderSecretField._spectacular_annotation["field"]
+ oraclecloud_schema = next(
+ credential_schema
+ for credential_schema in schema["oneOf"]
+ if credential_schema["title"]
+ == "Oracle Cloud Infrastructure (OCI) API Key Credentials"
+ )
+
+ assert oraclecloud_schema["properties"]["region"]["deprecated"] is True
+
+
+class TestKubernetesProviderSecret:
+ def test_valid_static_kubeconfig_is_accepted(self):
+ kubeconfig_content = """
+apiVersion: v1
+kind: Config
+clusters:
+ - name: test-cluster
+ cluster:
+ server: https://kubernetes.example.test
+users:
+ - name: test-user
+ user:
+ token: test-token
+contexts:
+ - name: test-context
+ context:
+ cluster: test-cluster
+ user: test-user
+current-context: test-context
+"""
+
+ serializer = KubernetesProviderSecret(
+ data={"kubeconfig_content": kubeconfig_content}
+ )
+
+ assert serializer.is_valid()
+
+ def test_kubeconfig_with_exec_authentication_is_rejected(self):
+ kubeconfig_content = """
+apiVersion: v1
+kind: Config
+clusters:
+ - name: test-cluster
+ cluster:
+ server: https://kubernetes.example.test
+users:
+ - name: test-user
+ user:
+ exec:
+ apiVersion: client.authentication.k8s.io/v1
+ command: kubectl
+contexts:
+ - name: test-context
+ context:
+ cluster: test-cluster
+ user: test-user
+current-context: test-context
+"""
+
+ serializer = KubernetesProviderSecret(
+ data={"kubeconfig_content": kubeconfig_content}
+ )
+
+ assert not serializer.is_valid()
+ assert "kubeconfig_content" in serializer.errors
+
+ def test_malformed_kubeconfig_is_rejected(self):
+ serializer = KubernetesProviderSecret(
+ data={"kubeconfig_content": "apiVersion: ["}
+ )
+
+ assert not serializer.is_valid()
+ assert "kubeconfig_content" in serializer.errors
+
+ def test_non_mapping_kubeconfig_is_rejected(self):
+ serializer = KubernetesProviderSecret(data={"kubeconfig_content": "[]"})
+
+ assert not serializer.is_valid()
+ assert "kubeconfig_content" in serializer.errors
diff --git a/api/src/backend/api/tests/test_sink.py b/api/src/backend/api/tests/test_sink.py
new file mode 100644
index 0000000000..c778626b62
--- /dev/null
+++ b/api/src/backend/api/tests/test_sink.py
@@ -0,0 +1,716 @@
+"""Tests for the attack-paths sink factory and Neo4j sink.
+
+The sink module picks a backend per ``settings.ATTACK_PATHS_SINK_DATABASE``.
+Neo4j is the default and preserves today's behavior; Neptune is opt-in and
+builds dual writer/reader Bolt drivers.
+"""
+
+import json
+from unittest.mock import MagicMock, patch
+
+import neo4j
+import pytest
+from api.attack_paths import sink as sink_module
+from api.attack_paths.database import (
+ GraphDatabaseQueryException,
+ NeptuneWriteRetryExhaustedException,
+)
+from api.attack_paths.retryable_session import RetryExhaustedError
+from api.attack_paths.sink import factory
+from api.attack_paths.sink.neo4j import DATABASE_NOT_FOUND_CODE, Neo4jSink
+from api.attack_paths.sink.neptune import (
+ NEPTUNE_WRITE_RETRY_DELAY_SECONDS,
+ NeptuneSink,
+ _is_retryable_write_error,
+ _NeptuneAuthToken,
+)
+
+
+@pytest.fixture(autouse=True)
+def reset_sink_state():
+ """Reset the module-level backend singletons around each test.
+
+ The cache lives in `api.attack_paths.sink.factory`, not on the package.
+ """
+ original_backend = factory._backend
+ original_secondary = dict(factory._secondary_backends)
+ factory._backend = None
+ factory._secondary_backends.clear()
+ yield
+ factory._backend = original_backend
+ factory._secondary_backends.clear()
+ factory._secondary_backends.update(original_secondary)
+
+
+class TestSinkFactory:
+ def test_default_resolves_to_neo4j(self, settings):
+ settings.ATTACK_PATHS_SINK_DATABASE = "neo4j"
+ assert factory._resolve_setting() == "neo4j"
+
+ def test_neptune_resolves_correctly(self, settings):
+ settings.ATTACK_PATHS_SINK_DATABASE = "neptune"
+ assert factory._resolve_setting() == "neptune"
+
+ def test_invalid_value_raises(self, settings):
+ settings.ATTACK_PATHS_SINK_DATABASE = "foo"
+ with pytest.raises(RuntimeError, match="ATTACK_PATHS_SINK_DATABASE"):
+ factory._resolve_setting()
+
+ @patch("api.attack_paths.sink.neo4j.neo4j.GraphDatabase.driver")
+ def test_init_builds_neo4j_backend_by_default(self, mock_driver, settings):
+ settings.ATTACK_PATHS_SINK_DATABASE = "neo4j"
+ settings.DATABASES = {
+ **settings.DATABASES,
+ "neo4j": {
+ "HOST": "localhost",
+ "PORT": "7687",
+ "USER": "neo4j",
+ "PASSWORD": "pw",
+ },
+ }
+ mock_driver.return_value = MagicMock()
+
+ backend = sink_module.init()
+
+ assert isinstance(backend, Neo4jSink)
+ mock_driver.assert_called_once()
+
+ @patch("api.attack_paths.sink.neptune.neptune_auth_provider")
+ @patch("api.attack_paths.sink.neptune.neo4j.GraphDatabase.driver")
+ def test_init_builds_neptune_backend(
+ self, mock_driver, mock_auth_provider, settings
+ ):
+ settings.ATTACK_PATHS_SINK_DATABASE = "neptune"
+ settings.DATABASES = {
+ **settings.DATABASES,
+ "neptune": {
+ "WRITER_ENDPOINT": "writer.example",
+ "READER_ENDPOINT": "reader.example",
+ "PORT": "8182",
+ "REGION": "eu-west-1",
+ },
+ }
+ mock_driver.return_value = MagicMock()
+ mock_auth_provider.return_value = lambda: None
+
+ backend = sink_module.init()
+
+ assert isinstance(backend, NeptuneSink)
+ # Writer + reader endpoints both trigger driver construction
+ assert mock_driver.call_count == 2
+ writer_uri = mock_driver.call_args_list[0][0][0]
+ reader_uri = mock_driver.call_args_list[1][0][0]
+ assert writer_uri == "bolt+s://writer.example:8182"
+ assert reader_uri == "bolt+s://reader.example:8182"
+
+ @patch("api.attack_paths.sink.neptune.neptune_auth_provider")
+ @patch("api.attack_paths.sink.neptune.neo4j.GraphDatabase.driver")
+ def test_neptune_reader_falls_back_to_writer(
+ self, mock_driver, mock_auth_provider, settings
+ ):
+ settings.ATTACK_PATHS_SINK_DATABASE = "neptune"
+ settings.DATABASES = {
+ **settings.DATABASES,
+ "neptune": {
+ "WRITER_ENDPOINT": "writer.example",
+ "READER_ENDPOINT": "",
+ "PORT": "8182",
+ "REGION": "eu-west-1",
+ },
+ }
+ mock_driver.return_value = MagicMock()
+ mock_auth_provider.return_value = lambda: None
+
+ sink_module.init()
+
+ # Only one driver call — reader aliases writer
+ assert mock_driver.call_count == 1
+
+
+def test_neo4j_sync_batch_size_defaults_to_1000():
+ assert Neo4jSink.sync_batch_size == 1000
+
+
+def test_neptune_sync_batch_size_defaults_to_500():
+ assert NeptuneSink.sync_batch_size == 500
+
+
+class TestGetBackendForScan:
+ """``get_backend_for_scan`` routes by the row's recorded sink backend."""
+
+ @patch("api.attack_paths.sink.neo4j.neo4j.GraphDatabase.driver")
+ def test_legacy_scan_in_neo4j_process_uses_active_backend(
+ self, mock_driver, settings
+ ):
+ settings.ATTACK_PATHS_SINK_DATABASE = "neo4j"
+ settings.DATABASES = {
+ **settings.DATABASES,
+ "neo4j": {
+ "HOST": "localhost",
+ "PORT": "7687",
+ "USER": "neo4j",
+ "PASSWORD": "pw",
+ },
+ }
+ mock_driver.return_value = MagicMock()
+
+ scan = MagicMock(sink_backend="neo4j")
+ backend = sink_module.get_backend_for_scan(scan)
+
+ assert backend is sink_module.get_backend()
+
+ def test_neptune_scan_on_neo4j_process_uses_neptune_secondary(self, settings):
+ settings.ATTACK_PATHS_SINK_DATABASE = "neo4j"
+ active_neo4j = MagicMock(name="neo4j-active")
+ factory._backend = active_neo4j
+
+ secondary_neptune = MagicMock(name="neptune-secondary")
+ with patch.object(factory, "_build_backend", return_value=secondary_neptune):
+ scan = MagicMock(sink_backend="neptune")
+ backend = factory.get_backend_for_scan(scan)
+
+ assert backend is secondary_neptune
+ assert backend is not active_neo4j
+
+
+def _session_ctx(session: MagicMock) -> MagicMock:
+ ctx = MagicMock()
+ ctx.__enter__ = MagicMock(return_value=session)
+ ctx.__exit__ = MagicMock(return_value=False)
+ return ctx
+
+
+def _count_result(key: str, count: int) -> MagicMock:
+ return MagicMock(single=MagicMock(return_value={key: count}))
+
+
+def _run_managed_write(session: MagicMock) -> MagicMock:
+ transaction = MagicMock()
+ session.execute_write.call_args.args[0](transaction)
+ return transaction
+
+
+def _managed_write_session(
+ results: list[MagicMock],
+) -> tuple[MagicMock, list[MagicMock]]:
+ session = MagicMock()
+ transactions: list[MagicMock] = []
+ result_iter = iter(results)
+
+ def execute_write(work):
+ transaction = MagicMock()
+ transaction.run.return_value = next(result_iter)
+ transactions.append(transaction)
+ return work(transaction)
+
+ session.execute_write.side_effect = execute_write
+ return session, transactions
+
+
+def _directed_drop_results(
+ outgoing_rels: int,
+ incoming_rels: int,
+ nodes: int,
+) -> list[MagicMock]:
+ return [
+ _count_result("deleted_rels_count", outgoing_rels),
+ _count_result("deleted_rels_count", 0),
+ _count_result("deleted_rels_count", incoming_rels),
+ _count_result("deleted_rels_count", 0),
+ _count_result("deleted_nodes_count", nodes),
+ _count_result("deleted_nodes_count", 0),
+ ]
+
+
+class TestNeo4jSinkSyncWrites:
+ def test_ensure_sync_indexes_runs_create_index_idempotent(self):
+ sink = Neo4jSink()
+ session = MagicMock()
+ with patch.object(sink, "get_session", return_value=_session_ctx(session)):
+ sink.ensure_sync_indexes("db-tenant-x")
+
+ transaction = _run_managed_write(session)
+ query = transaction.run.call_args.args[0]
+ assert "CREATE INDEX" in query
+ assert "IF NOT EXISTS" in query
+ assert "`_ProviderResource`" in query
+ assert "`_provider_element_id`" in query
+ transaction.run.return_value.consume.assert_called_once_with()
+
+ def test_write_nodes_skips_empty_batch(self):
+ sink = Neo4jSink()
+ with patch.object(sink, "get_session") as get_session:
+ sink.write_nodes("db-tenant-x", "`AWSUser`", [])
+ get_session.assert_not_called()
+
+ def test_write_nodes_merges_on_provider_resource_label(self):
+ sink = Neo4jSink()
+ session = MagicMock()
+ with patch.object(sink, "get_session", return_value=_session_ctx(session)):
+ sink.write_nodes(
+ "db-tenant-x",
+ "`AWSUser`:`_ProviderResource`",
+ [{"provider_element_id": "p:e", "props": {"k": "v"}}],
+ )
+
+ transaction = _run_managed_write(session)
+ query, params = transaction.run.call_args.args
+ assert "MERGE (n:`_ProviderResource`" in query
+ assert "`_provider_element_id`: row.provider_element_id" in query
+ assert "SET n:`AWSUser`:`_ProviderResource`" in query
+ assert params == {"rows": [{"provider_element_id": "p:e", "props": {"k": "v"}}]}
+ transaction.run.return_value.consume.assert_called_once_with()
+
+ def test_write_relationships_scopes_endpoints_by_provider_label(self):
+ sink = Neo4jSink()
+ session = MagicMock()
+ provider_id = "00000000-0000-0000-0000-000000000abc"
+ with patch.object(sink, "get_session", return_value=_session_ctx(session)):
+ sink.write_relationships(
+ "db-tenant-x",
+ "RESOURCE",
+ provider_id,
+ [
+ {
+ "start_element_id": "s",
+ "end_element_id": "e",
+ "provider_element_id": "pe",
+ "props": {},
+ }
+ ],
+ )
+
+ transaction = _run_managed_write(session)
+ query = transaction.run.call_args.args[0]
+ assert ":`_Provider_00000000000000000000000000000abc`" in query
+ assert ":RESOURCE" in query.replace("`", "")
+ assert "MERGE (s)-[r:`RESOURCE`" in query
+ transaction.run.return_value.consume.assert_called_once_with()
+
+
+class TestNeptuneSinkSyncWrites:
+ def test_ensure_sync_indexes_is_noop(self):
+ sink = NeptuneSink()
+ with patch.object(sink, "get_session") as get_session:
+ sink.ensure_sync_indexes("ignored")
+ get_session.assert_not_called()
+
+ def test_write_nodes_merges_on_neptune_id_with_provider_resource_label(self):
+ sink = NeptuneSink()
+ session = MagicMock()
+ with patch.object(sink, "get_session", return_value=_session_ctx(session)):
+ sink.write_nodes(
+ "ignored",
+ "`AWSUser`",
+ [{"provider_element_id": "p:e", "props": {"k": "v"}}],
+ )
+
+ transaction = _run_managed_write(session)
+ query = transaction.run.call_args.args[0]
+ # Neptune assigns a default `vertex` label to any unlabeled node,
+ # so the MERGE must pin a real label at creation time.
+ assert "MERGE (n:`_ProviderResource` {`~id`: row.provider_element_id})" in query
+ assert "SET n:`AWSUser`" in query
+ assert "SET n.`_provider_element_id` = row.provider_element_id" in query
+ transaction.run.return_value.consume.assert_called_once_with()
+
+ def test_write_relationships_matches_endpoints_by_id(self):
+ sink = NeptuneSink()
+ session = MagicMock()
+ with patch.object(sink, "get_session", return_value=_session_ctx(session)):
+ sink.write_relationships(
+ "ignored",
+ "RESOURCE",
+ "provider-1",
+ [
+ {
+ "start_element_id": "s",
+ "end_element_id": "e",
+ "provider_element_id": "pe",
+ "props": {},
+ }
+ ],
+ )
+
+ transaction = _run_managed_write(session)
+ query = transaction.run.call_args.args[0]
+ assert "MATCH (s) WHERE id(s) = row.start_element_id" in query
+ assert "MATCH (e) WHERE id(e) = row.end_element_id" in query
+ assert "MERGE (s)-[r:`RESOURCE`" in query
+ transaction.run.return_value.consume.assert_called_once_with()
+
+
+class TestNeptuneRetryPolicy:
+ @pytest.mark.parametrize(
+ "message",
+ [
+ "Unexpected server exception 'Operation failed due to conflicting "
+ "concurrent operations (please retry), 0 transactions are currently "
+ "rolling back.'",
+ "Unexpected server exception 'Operation terminated (deadline exceeded)'",
+ ],
+ )
+ def test_observed_transient_write_errors_are_retryable(self, message):
+ error = MagicMock(spec=neo4j.exceptions.Neo4jError)
+ error.message = message
+
+ assert _is_retryable_write_error(error) is True
+
+ def test_unrelated_database_error_is_not_retryable(self):
+ error = MagicMock(spec=neo4j.exceptions.Neo4jError)
+ error.message = (
+ "Unexpected server exception 'Operation terminated (out of memory)'"
+ )
+
+ assert _is_retryable_write_error(error) is False
+
+ def test_non_neo4j_error_is_not_retryable(self):
+ error = RuntimeError(
+ "Operation failed due to conflicting concurrent operations"
+ )
+
+ assert _is_retryable_write_error(error) is False
+
+ @patch("api.attack_paths.sink.neptune.RetryableSession")
+ def test_writer_session_enables_neptune_retry_policy(self, retryable_session):
+ sink = NeptuneSink()
+ driver = MagicMock()
+ with patch.object(sink, "_get_writer", return_value=driver):
+ with sink.get_session():
+ pass
+
+ kwargs = retryable_session.call_args.kwargs
+ assert kwargs["retry_if"] is _is_retryable_write_error
+ assert (
+ kwargs["initial_retry_delay_seconds"] == NEPTUNE_WRITE_RETRY_DELAY_SECONDS
+ )
+ assert kwargs["retry_context"] == "Neptune write"
+
+ @patch("api.attack_paths.sink.neptune.RetryableSession")
+ def test_reader_session_does_not_enable_write_retry_policy(self, retryable_session):
+ sink = NeptuneSink()
+ driver = MagicMock()
+ with patch.object(sink, "_get_reader", return_value=driver):
+ with sink.get_session(default_access_mode=neo4j.READ_ACCESS):
+ pass
+
+ kwargs = retryable_session.call_args.kwargs
+ assert kwargs["retry_if"] is None
+ assert kwargs["initial_retry_delay_seconds"] == 0
+ assert kwargs["retry_context"] is None
+
+ def test_writer_retry_exhaustion_preserves_neptune_error_details(self):
+ message = (
+ "Unexpected server exception 'Operation failed due to conflicting "
+ "concurrent operations (please retry), 0 transactions are currently "
+ "rolling back.'"
+ )
+ error = neo4j.exceptions.Neo4jError._hydrate_neo4j(
+ code="BoltProtocol.unexpectedException",
+ message=message,
+ )
+ retry_error = RetryExhaustedError(
+ retry_context="Neptune write",
+ method_name="execute_write",
+ attempts=4,
+ elapsed_seconds=27.1234,
+ last_error=error,
+ )
+ sink = NeptuneSink()
+ driver = MagicMock()
+ retryable_session = MagicMock()
+ retryable_session.execute_write.side_effect = retry_error
+
+ with (
+ patch.object(sink, "_get_writer", return_value=driver),
+ patch(
+ "api.attack_paths.sink.neptune.RetryableSession",
+ return_value=retryable_session,
+ ),
+ pytest.raises(NeptuneWriteRetryExhaustedException) as exc_info,
+ ):
+ with sink.get_session() as session:
+ session.execute_write(MagicMock())
+
+ assert exc_info.value.code == "BoltProtocol.unexpectedException"
+ assert str(exc_info.value) == (
+ "BoltProtocol.unexpectedException: Neptune write execute_write failed "
+ "after 4 attempts over 27.123s. Last error: "
+ f"{message}"
+ )
+ assert exc_info.value.__cause__ is error
+
+
+class TestNeptuneSinkDropSubgraph:
+ def test_drop_subgraph_deletes_directed_rels_before_nodes_in_bounded_batches(self):
+ sink = NeptuneSink()
+ session, transactions = _managed_write_session(
+ _directed_drop_results(
+ outgoing_rels=50,
+ incoming_rels=30,
+ nodes=10,
+ )
+ )
+
+ with patch.object(sink, "get_session", return_value=_session_ctx(session)):
+ deleted = sink.drop_subgraph("ignored", "provider-1")
+
+ assert deleted == 10
+ assert session.execute_write.call_count == 6
+ queries = [transaction.run.call_args.args[0] for transaction in transactions]
+
+ assert ")-[r]->()" in queries[0]
+ assert ")<-[r]-()" in queries[2]
+ assert "DELETE n" in queries[4]
+ assert all("DETACH DELETE" not in query for query in queries)
+ assert all("DISTINCT r" not in query for query in queries)
+
+ first_node = next(i for i, q in enumerate(queries) if "DELETE n" in q)
+ last_rel = max(i for i, q in enumerate(queries) if "DELETE r" in q)
+ assert last_rel < first_node
+
+
+class TestNeo4jSinkDropSubgraph:
+ """Neo4j drop deletes relationships then nodes in batches (no ``DETACH DELETE``)."""
+
+ def test_drop_subgraph_deletes_directed_rels_before_nodes_in_bounded_batches(self):
+ sink = Neo4jSink()
+ session, transactions = _managed_write_session(
+ _directed_drop_results(
+ outgoing_rels=50,
+ incoming_rels=30,
+ nodes=10,
+ )
+ )
+
+ provider_id = "00000000-0000-0000-0000-000000000abc"
+ with patch.object(sink, "get_session", return_value=_session_ctx(session)):
+ deleted = sink.drop_subgraph("db-tenant-x", provider_id)
+
+ # Only phase-2 node counts contribute to the return value.
+ assert deleted == 10
+ assert session.execute_write.call_count == 6
+
+ queries = [transaction.run.call_args.args[0] for transaction in transactions]
+ # Regression guard: the memory blow-up was caused by DETACH DELETE.
+ assert all("DETACH DELETE" not in query for query in queries)
+ assert all("DISTINCT r" not in query for query in queries)
+
+ first_query = queries[0]
+ assert "DELETE r" in first_query
+ assert ")-[r]->()" in first_query
+ assert ":`_Provider_00000000000000000000000000000abc`" in first_query
+
+ assert ")<-[r]-()" in queries[2]
+ assert "DELETE n" in queries[4]
+
+ # Relationships must be fully drained before nodes are deleted.
+ first_node = next(i for i, q in enumerate(queries) if "DELETE n" in q)
+ last_rel = max(i for i, q in enumerate(queries) if "DELETE r" in q)
+ assert last_rel < first_node
+
+ def test_drop_subgraph_returns_zero_when_database_does_not_exist(self):
+ sink = Neo4jSink()
+ session = MagicMock()
+ session.execute_write.side_effect = GraphDatabaseQueryException(
+ message="db missing", code=DATABASE_NOT_FOUND_CODE
+ )
+
+ with patch.object(sink, "get_session", return_value=_session_ctx(session)):
+ deleted = sink.drop_subgraph("db-tenant-missing", "provider-1")
+
+ assert deleted == 0
+
+
+class TestSinkHasProviderData:
+ """``has_provider_data`` is the read-path probe used by API views."""
+
+ def test_neo4j_returns_true_when_provider_node_exists(self):
+ sink = Neo4jSink()
+ session = MagicMock()
+ session.run.return_value.single.return_value = MagicMock()
+ with patch.object(sink, "get_session", return_value=_session_ctx(session)):
+ present = sink.has_provider_data(
+ "db-tenant-x", "00000000-0000-0000-0000-000000000abc"
+ )
+
+ assert present is True
+ query = session.run.call_args.args[0]
+ assert ":`_Provider_00000000000000000000000000000abc`" in query
+
+ def test_neo4j_returns_false_when_database_does_not_exist(self):
+ sink = Neo4jSink()
+ session = MagicMock()
+ session.run.side_effect = GraphDatabaseQueryException(
+ message="db missing", code=DATABASE_NOT_FOUND_CODE
+ )
+
+ with patch.object(sink, "get_session", return_value=_session_ctx(session)):
+ present = sink.has_provider_data("db-tenant-missing", "provider-1")
+
+ assert present is False
+
+ def test_neptune_returns_true_when_provider_node_exists(self):
+ sink = NeptuneSink()
+ session = MagicMock()
+ session.run.return_value.single.return_value = MagicMock()
+ with patch.object(sink, "get_session", return_value=_session_ctx(session)):
+ present = sink.has_provider_data("ignored", "provider-1")
+
+ assert present is True
+
+
+class TestGetBackendForScanCutover:
+ """``get_backend_for_scan`` keeps old-sink scans queryable after cutover."""
+
+ def test_legacy_scan_on_neptune_process_uses_neo4j_secondary(self, settings):
+ settings.ATTACK_PATHS_SINK_DATABASE = "neptune"
+ active_neptune = MagicMock(name="neptune-active")
+ factory._backend = active_neptune
+
+ secondary_neo4j = MagicMock(name="neo4j-secondary")
+ with patch.object(factory, "_build_backend", return_value=secondary_neo4j):
+ scan = MagicMock(sink_backend="neo4j")
+ backend = factory.get_backend_for_scan(scan)
+
+ assert backend is secondary_neo4j
+ assert backend is not active_neptune
+
+
+class TestSinkVerifyConnectivity:
+ """The readiness probe calls ``verify_connectivity`` through the shim.
+
+ Neo4j checks its single driver; Neptune checks the reader (the API read
+ path), which on single-endpoint clusters aliases the writer.
+ """
+
+ @patch("api.attack_paths.sink.neo4j.neo4j.GraphDatabase.driver")
+ def test_neo4j_verifies_its_driver(self, mock_driver, settings):
+ settings.DATABASES = {
+ **settings.DATABASES,
+ "neo4j": {
+ "HOST": "localhost",
+ "PORT": "7687",
+ "USER": "neo4j",
+ "PASSWORD": "pw",
+ },
+ }
+ driver = MagicMock()
+ mock_driver.return_value = driver
+
+ sink = Neo4jSink()
+ sink.init()
+ driver.verify_connectivity.reset_mock() # ignore the eager init check
+ sink.verify_connectivity()
+
+ driver.verify_connectivity.assert_called_once_with()
+
+ @patch("api.attack_paths.sink.neptune.neptune_auth_provider")
+ @patch("api.attack_paths.sink.neptune.neo4j.GraphDatabase.driver")
+ def test_neptune_verifies_reader_not_writer(
+ self, mock_driver, mock_auth_provider, settings
+ ):
+ settings.DATABASES = {
+ **settings.DATABASES,
+ "neptune": {
+ "WRITER_ENDPOINT": "writer.example",
+ "READER_ENDPOINT": "reader.example",
+ "PORT": "8182",
+ "REGION": "eu-west-1",
+ },
+ }
+ writer, reader = MagicMock(name="writer"), MagicMock(name="reader")
+ mock_driver.side_effect = [writer, reader]
+ mock_auth_provider.return_value = lambda: None
+
+ sink = NeptuneSink()
+ sink.init()
+ writer.verify_connectivity.reset_mock()
+ reader.verify_connectivity.reset_mock()
+
+ sink.verify_connectivity()
+
+ reader.verify_connectivity.assert_called_once_with()
+ writer.verify_connectivity.assert_not_called()
+
+
+class TestSinkInitToleratesUnreachableSink:
+ """Init must not crash the process when the sink is down at boot.
+
+ Same degradation model as Postgres: the driver is retained and
+ reconnects lazily; /health/ready surfaces the outage until it recovers.
+ """
+
+ @patch("api.attack_paths.sink.neo4j.neo4j.GraphDatabase.driver")
+ def test_neo4j_init_continues_when_verify_fails(self, mock_driver, settings):
+ settings.DATABASES = {
+ **settings.DATABASES,
+ "neo4j": {
+ "HOST": "localhost",
+ "PORT": "7687",
+ "USER": "neo4j",
+ "PASSWORD": "pw",
+ },
+ }
+ driver = MagicMock()
+ driver.verify_connectivity.side_effect = RuntimeError("unreachable")
+ mock_driver.return_value = driver
+
+ sink = Neo4jSink()
+ # Must not raise.
+ assert sink.init() is driver
+ assert sink._driver is driver
+
+ @patch("api.attack_paths.sink.neptune.neptune_auth_provider")
+ @patch("api.attack_paths.sink.neptune.neo4j.GraphDatabase.driver")
+ def test_neptune_init_continues_when_verify_fails(
+ self, mock_driver, mock_auth_provider, settings
+ ):
+ settings.DATABASES = {
+ **settings.DATABASES,
+ "neptune": {
+ "WRITER_ENDPOINT": "writer.example",
+ "READER_ENDPOINT": "reader.example",
+ "PORT": "8182",
+ "REGION": "eu-west-1",
+ },
+ }
+ driver = MagicMock()
+ driver.verify_connectivity.side_effect = RuntimeError("unreachable")
+ mock_driver.return_value = driver
+ mock_auth_provider.return_value = lambda: None
+
+ sink = NeptuneSink()
+ # Must not raise; both drivers retained.
+ sink.init()
+ assert sink._writer is not None
+ assert sink._reader is not None
+
+
+class TestNeptuneAdminNoOps:
+ """Neptune is single-database; admin DDL has no work to do."""
+
+ @pytest.mark.parametrize("method", ["create_database", "drop_database"])
+ def test_admin_ops_return_none_without_touching_a_session(self, method):
+ sink = NeptuneSink()
+ with patch.object(sink, "get_session") as get_session:
+ assert getattr(sink, method)("ignored") is None
+ get_session.assert_not_called()
+
+
+class TestNeptuneAuthToken:
+ """SigV4 signing for the Neptune Bolt endpoint."""
+
+ @patch("api.attack_paths.sink.neptune.SigV4Auth")
+ @patch("api.attack_paths.sink.neptune.BotoSession")
+ def test_host_header_includes_non_default_port(self, mock_boto, mock_sigv4):
+ # Neptune runs on 8182; the SigV4 canonical Host must keep the port or
+ # the signature is rejected.
+ credentials = MagicMock()
+ credentials.get_frozen_credentials.return_value = MagicMock()
+ mock_boto.return_value.get_credentials.return_value = credentials
+
+ token = _NeptuneAuthToken("eu-west-1", "https://writer.example:8182")
+
+ auth_obj = json.loads(token.credentials)
+ assert auth_obj["Host"] == "writer.example:8182"
diff --git a/api/src/backend/api/tests/test_sse.py b/api/src/backend/api/tests/test_sse.py
new file mode 100644
index 0000000000..e6fdf21130
--- /dev/null
+++ b/api/src/backend/api/tests/test_sse.py
@@ -0,0 +1,190 @@
+"""Tests for the platform SSE infrastructure (``api.sse``).
+
+Cover the two security-critical platform pieces — the channel-name
+convention (:mod:`api.sse.utils`) and the tenant gate enforced by
+:class:`api.sse.channelmanager.SSEChannelManager`. The SSE authentication
+class lives in :mod:`api.authentication` with the rest of the auth stack,
+so its tests live in ``test_authentication.py``. Per-feature SSE endpoints
+add their own tests on top of these.
+"""
+
+import uuid
+from unittest.mock import MagicMock
+
+import pytest
+from api.sse.base_views import BaseSSEViewSet
+from api.sse.channelmanager import SSEChannelManager
+from api.sse.utils import make_channel_name, tenant_id_from_channel
+from django.http import StreamingHttpResponse
+from rest_framework.test import APIRequestFactory, force_authenticate
+
+
+class TestMakeChannel:
+ def test_round_trips_tenant_id(self):
+ tenant_id = uuid.uuid4()
+ channel = make_channel_name("lighthouse-session", tenant_id, uuid.uuid4())
+ assert tenant_id_from_channel(channel) == tenant_id
+
+ def test_accepts_str_arguments(self):
+ tenant_id = uuid.uuid4()
+ channel = make_channel_name("lighthouse-session", str(tenant_id), "resource-1")
+ assert channel == f"lighthouse-session:{tenant_id}:resource-1"
+
+ def test_prefix_with_hyphen_is_not_split(self):
+ # Prefixes contain hyphens but never colons, so the tenant id is
+ # always the second colon-separated segment.
+ tenant_id = uuid.uuid4()
+ channel = make_channel_name("a-long-hyphenated-prefix", tenant_id, "res")
+ assert tenant_id_from_channel(channel) == tenant_id
+
+ @pytest.mark.parametrize(
+ "prefix, tenant_id, resource_id",
+ [
+ ("evil:prefix", uuid.uuid4(), "res"),
+ ("prefix", uuid.uuid4(), "res:extra"),
+ ("prefix", "tenant:smuggled", "res"),
+ ],
+ )
+ def test_rejects_separator_injection(self, prefix, tenant_id, resource_id):
+ # A colon in any segment would let a crafted name smuggle extra
+ # segments past the parser, so construction must fail loudly.
+ with pytest.raises(ValueError):
+ make_channel_name(prefix, tenant_id, resource_id)
+
+
+class TestTenantIdFromChannel:
+ def test_returns_none_for_too_few_segments(self):
+ assert tenant_id_from_channel("prefix:only") is None
+ assert tenant_id_from_channel("garbage") is None
+
+ def test_returns_none_for_too_many_segments(self):
+ # A valid tenant UUID in position 1 must not authorize a
+ # non-canonical name that carries extra segments.
+ tenant_id = uuid.uuid4()
+ assert tenant_id_from_channel(f"prefix:{tenant_id}:resource:extra") is None
+
+ def test_returns_none_for_non_uuid_tenant_segment(self):
+ assert tenant_id_from_channel("prefix:not-a-uuid:resource") is None
+
+ def test_parses_valid_channel(self):
+ tenant_id = uuid.uuid4()
+ assert tenant_id_from_channel(f"prefix:{tenant_id}:resource") == tenant_id
+
+
+@pytest.mark.django_db
+class TestSSEChannelManager:
+ def test_member_can_read_own_tenant_channel(
+ self, create_test_user, tenants_fixture
+ ):
+ tenant = tenants_fixture[0]
+ channel = make_channel_name("lighthouse-session", tenant.id, uuid.uuid4())
+ assert SSEChannelManager().can_read_channel(create_test_user, channel)
+
+ def test_non_member_cannot_read_other_tenant_channel(
+ self, create_test_user, tenants_fixture
+ ):
+ # create_test_user is a member of tenant1 and tenant2 but not tenant3.
+ foreign_tenant = tenants_fixture[2]
+ channel = make_channel_name(
+ "lighthouse-session", foreign_tenant.id, uuid.uuid4()
+ )
+ assert not SSEChannelManager().can_read_channel(create_test_user, channel)
+
+ def test_anonymous_user_is_rejected(self, tenants_fixture):
+ channel = make_channel_name(
+ "lighthouse-session", tenants_fixture[0].id, uuid.uuid4()
+ )
+ assert not SSEChannelManager().can_read_channel(None, channel)
+
+ anon = MagicMock(is_authenticated=False)
+ assert not SSEChannelManager().can_read_channel(anon, channel)
+
+ def test_malformed_channel_is_rejected(self, create_test_user, tenants_fixture):
+ assert not SSEChannelManager().can_read_channel(create_test_user, "garbage")
+
+ def test_get_channels_for_request_returns_active_tenant_channels(self):
+ tenant_id = uuid.uuid4()
+ own = make_channel_name("prefix", tenant_id, "resource")
+ request = MagicMock()
+ request.tenant_id = str(tenant_id)
+ request.sse_channels = {own}
+ assert SSEChannelManager().get_channels_for_request(request, {}) == {own}
+
+ def test_get_channels_for_request_drops_other_tenant_channels(self):
+ # Fail-closed: a channel for a tenant other than the active JWT
+ # tenant is dropped before reaching django-eventstream, even if the
+ # viewset mistakenly stashed it. This is the primary tenant gate that
+ # binds authorization to request.tenant_id, not just membership.
+ active_tenant = uuid.uuid4()
+ own = make_channel_name("prefix", active_tenant, "resource")
+ foreign = make_channel_name("prefix", uuid.uuid4(), "resource")
+ request = MagicMock()
+ request.tenant_id = str(active_tenant)
+ request.sse_channels = {own, foreign}
+ assert SSEChannelManager().get_channels_for_request(request, {}) == {own}
+
+ def test_get_channels_for_request_drops_malformed_channels(self):
+ request = MagicMock()
+ request.tenant_id = str(uuid.uuid4())
+ request.sse_channels = {"garbage", "prefix:not-a-uuid:resource"}
+ assert SSEChannelManager().get_channels_for_request(request, {}) == set()
+
+ def test_get_channels_for_request_without_tenant_returns_empty(self):
+ # No active tenant on the request (auth/RLS never ran) → fail closed,
+ # regardless of any channels stashed on it.
+ request = MagicMock(spec=[])
+ assert SSEChannelManager().get_channels_for_request(request, {}) == set()
+
+ def test_get_channels_for_request_defaults_to_empty(self):
+ # A request that never went through BaseSSEViewSet.list has no
+ # sse_channels attribute; the manager must not raise.
+ request = object()
+ assert SSEChannelManager().get_channels_for_request(request, {}) == set()
+
+ def test_channel_is_not_reliable(self):
+ # v1 ships without server-side replay storage.
+ assert (
+ SSEChannelManager().is_channel_reliable("prefix:tenant:resource") is False
+ )
+
+
+@pytest.mark.django_db
+class TestBaseSSEViewSet:
+ """End-to-end check that the base viewset opens a stream.
+
+ ``BaseSSEViewSet.list`` hands the DRF ``Request`` straight to
+ django-eventstream's ``events()``, which is written for a plain
+ Django request. This drives a real request through the full DRF
+ stack (authentication, RLS, content negotiation, channel manager)
+ and asserts the result is an SSE stream, so the DRF/Django request
+ mismatch cannot regress silently.
+ """
+
+ def test_list_opens_event_stream(self, create_test_user, tenants_fixture):
+ tenant = tenants_fixture[0]
+ channel = make_channel_name("test-sse", tenant.id, uuid.uuid4())
+ seen_tenant_ids = []
+
+ class _StreamingSSEViewSet(BaseSSEViewSet):
+ def get_channels(self):
+ # Reached only after dispatch/initial ran, so the RLS
+ # tenant context is already on the request.
+ seen_tenant_ids.append(self.request.tenant_id)
+ return {channel}
+
+ request = APIRequestFactory().get("/api/v1/test-sse/stream")
+ force_authenticate(
+ request, user=create_test_user, token={"tenant_id": str(tenant.id)}
+ )
+
+ view = _StreamingSSEViewSet.as_view({"get": "list"})
+ response = view(request)
+
+ # A StreamingHttpResponse (not the plain HttpResponse used for SSE
+ # error envelopes) means events() accepted the DRF request, the
+ # channel manager handed it a non-empty channel set, and the
+ # stream was opened end to end.
+ assert isinstance(response, StreamingHttpResponse)
+ assert response.status_code == 200
+ assert response["Content-Type"] == "text/event-stream"
+ assert seen_tenant_ids == [str(tenant.id)]
diff --git a/api/src/backend/api/tests/test_utils.py b/api/src/backend/api/tests/test_utils.py
index 86da5567da..4b5e8e1694 100644
--- a/api/src/backend/api/tests/test_utils.py
+++ b/api/src/backend/api/tests/test_utils.py
@@ -1,9 +1,7 @@
-from datetime import datetime, timedelta, timezone
+from datetime import UTC, datetime, timedelta
from unittest.mock import MagicMock, patch
import pytest
-from rest_framework.exceptions import NotFound, ValidationError
-
from api.db_router import MainRouter
from api.exceptions import InvitationTokenExpiredException
from api.models import Integration, Invitation, Provider
@@ -31,9 +29,11 @@ from prowler.providers.image.image_provider import ImageProvider
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.okta.okta_provider import OktaProvider
from prowler.providers.openstack.openstack_provider import OpenstackProvider
from prowler.providers.oraclecloud.oraclecloud_provider import OraclecloudProvider
from prowler.providers.vercel.vercel_provider import VercelProvider
+from rest_framework.exceptions import NotFound, ValidationError
class TestMergeDicts:
@@ -130,6 +130,7 @@ class TestReturnProwlerProvider:
(Provider.ProviderChoices.OPENSTACK.value, OpenstackProvider),
(Provider.ProviderChoices.IMAGE.value, ImageProvider),
(Provider.ProviderChoices.VERCEL.value, VercelProvider),
+ (Provider.ProviderChoices.OKTA.value, OktaProvider),
],
)
def test_return_prowler_provider(self, provider_type, expected_provider):
@@ -170,6 +171,53 @@ class TestInitializeProwlerProvider:
key="value", mutelist_content={"key": "value"}
)
+ @patch("api.utils.return_prowler_provider")
+ def test_initialize_oraclecloud_provider_removes_region_string(
+ self, mock_return_prowler_provider
+ ):
+ provider = MagicMock()
+ provider.provider = Provider.ProviderChoices.ORACLECLOUD.value
+ provider.secret.secret = {
+ "user": "ocid1.user.oc1..fake",
+ "fingerprint": "00:11:22:33:44:55:66:77",
+ "key_content": "fake-base64-key-content",
+ "tenancy": "ocid1.tenancy.oc1..fake",
+ "region": "us-ashburn-1",
+ }
+ mock_return_prowler_provider.return_value = MagicMock()
+
+ initialize_prowler_provider(provider)
+
+ mock_return_prowler_provider.return_value.assert_called_once_with(
+ user="ocid1.user.oc1..fake",
+ fingerprint="00:11:22:33:44:55:66:77",
+ key_content="fake-base64-key-content",
+ tenancy="ocid1.tenancy.oc1..fake",
+ )
+
+ @patch("api.utils.return_prowler_provider")
+ def test_initialize_oraclecloud_provider_without_region_omits_scan_filter(
+ self, mock_return_prowler_provider
+ ):
+ provider = MagicMock()
+ provider.provider = Provider.ProviderChoices.ORACLECLOUD.value
+ provider.secret.secret = {
+ "user": "ocid1.user.oc1..fake",
+ "fingerprint": "00:11:22:33:44:55:66:77",
+ "key_content": "fake-base64-key-content",
+ "tenancy": "ocid1.tenancy.oc1..fake",
+ }
+ mock_return_prowler_provider.return_value = MagicMock()
+
+ initialize_prowler_provider(provider)
+
+ mock_return_prowler_provider.return_value.assert_called_once_with(
+ user="ocid1.user.oc1..fake",
+ fingerprint="00:11:22:33:44:55:66:77",
+ key_content="fake-base64-key-content",
+ tenancy="ocid1.tenancy.oc1..fake",
+ )
+
class TestProwlerProviderConnectionTest:
@patch("api.utils.return_prowler_provider")
@@ -184,13 +232,44 @@ class TestProwlerProviderConnectionTest:
key="value", provider_id="1234567890", raise_on_exception=False
)
+ @patch("api.utils.return_prowler_provider")
+ def test_oraclecloud_connection_test_uses_direct_credentials_without_region(
+ self, mock_return_prowler_provider
+ ):
+ provider = MagicMock()
+ provider.uid = "ocid1.tenancy.oc1..aaaaaaaexample"
+ provider.provider = Provider.ProviderChoices.ORACLECLOUD.value
+ provider.secret.secret = {
+ "user": "ocid1.user.oc1..aaaaaaaexample",
+ "fingerprint": "00:11:22:33:44:55:66:77",
+ "key_content": "fake-base64-key-content",
+ "tenancy": "ocid1.tenancy.oc1..aaaaaaaexample",
+ }
+ mock_return_prowler_provider.return_value = MagicMock()
+
+ prowler_provider_connection_test(provider)
+
+ mock_return_prowler_provider.return_value.test_connection.assert_called_once_with(
+ user="ocid1.user.oc1..aaaaaaaexample",
+ fingerprint="00:11:22:33:44:55:66:77",
+ key_content="fake-base64-key-content",
+ tenancy="ocid1.tenancy.oc1..aaaaaaaexample",
+ region=getattr(
+ OraclecloudProvider,
+ "_bootstrap_region",
+ OraclecloudProvider._home_region,
+ ),
+ provider_id="ocid1.tenancy.oc1..aaaaaaaexample",
+ raise_on_exception=False,
+ )
+
@pytest.mark.django_db
@patch("api.utils.return_prowler_provider")
def test_prowler_provider_connection_test_without_secret(
- self, mock_return_prowler_provider, providers_fixture
+ self, mock_return_prowler_provider, aws_provider
):
mock_return_prowler_provider.return_value = MagicMock()
- connection = prowler_provider_connection_test(providers_fixture[0])
+ connection = prowler_provider_connection_test(aws_provider)
assert connection.is_connected is False
assert isinstance(connection.error, Provider.secret.RelatedObjectDoesNotExist)
@@ -238,6 +317,31 @@ class TestProwlerProviderConnectionTest:
raise_on_exception=False,
)
+ @patch("api.utils.return_prowler_provider")
+ def test_prowler_provider_connection_test_okta_provider(
+ self, mock_return_prowler_provider
+ ):
+ """Test connection test for Okta provider passes org domain and provider_id."""
+ provider = MagicMock()
+ provider.uid = "acme.okta.com"
+ provider.provider = Provider.ProviderChoices.OKTA.value
+ provider.secret.secret = {
+ "okta_client_id": "0oa123456789abcdef",
+ "okta_private_key": "-----BEGIN PRIVATE KEY-----\ntest\n-----END PRIVATE KEY-----",
+ "okta_scopes": ["okta.policies.read"],
+ }
+ mock_return_prowler_provider.return_value = MagicMock()
+
+ prowler_provider_connection_test(provider)
+ mock_return_prowler_provider.return_value.test_connection.assert_called_once_with(
+ okta_client_id="0oa123456789abcdef",
+ okta_private_key="-----BEGIN PRIVATE KEY-----\ntest\n-----END PRIVATE KEY-----",
+ okta_scopes=["okta.policies.read"],
+ okta_org_domain="acme.okta.com",
+ provider_id="acme.okta.com",
+ raise_on_exception=False,
+ )
+
@patch("api.utils.return_prowler_provider")
def test_prowler_provider_connection_test_image_provider_no_creds(
self, mock_return_prowler_provider
@@ -308,6 +412,10 @@ class TestGetProwlerProviderKwargs:
Provider.ProviderChoices.VERCEL.value,
{"team_id": "provider_uid"},
),
+ (
+ Provider.ProviderChoices.OKTA.value,
+ {"okta_org_domain": "provider_uid"},
+ ),
],
)
def test_get_prowler_provider_kwargs(self, provider_type, expected_extra_kwargs):
@@ -326,6 +434,35 @@ class TestGetProwlerProviderKwargs:
expected_result = {**secret_dict, **expected_extra_kwargs}
assert result == expected_result
+ def test_get_prowler_provider_kwargs_oraclecloud_removes_region(
+ self,
+ ):
+ secret_dict = {
+ "user": "ocid1.user.oc1..fake",
+ "fingerprint": "00:11:22:33:44:55:66:77",
+ "key_content": "-----BEGIN PRIVATE KEY-----\nfake\n-----END PRIVATE KEY-----",
+ "tenancy": "ocid1.tenancy.oc1..fake",
+ "region": "us-ashburn-1",
+ "pass_phrase": "fake-passphrase",
+ }
+ secret_mock = MagicMock()
+ secret_mock.secret = secret_dict
+
+ provider = MagicMock()
+ provider.provider = Provider.ProviderChoices.ORACLECLOUD.value
+ provider.secret = secret_mock
+ provider.uid = "ocid1.tenancy.oc1..fake"
+
+ result = get_prowler_provider_kwargs(provider)
+
+ assert result == {
+ "user": "ocid1.user.oc1..fake",
+ "fingerprint": "00:11:22:33:44:55:66:77",
+ "key_content": "-----BEGIN PRIVATE KEY-----\nfake\n-----END PRIVATE KEY-----",
+ "tenancy": "ocid1.tenancy.oc1..fake",
+ "pass_phrase": "fake-passphrase",
+ }
+
def test_get_prowler_provider_kwargs_with_mutelist(self):
provider_uid = "provider_uid"
secret_dict = {"key": "value"}
@@ -568,7 +705,7 @@ class TestValidateInvitation:
invitation = MagicMock(spec=Invitation)
invitation.token = "VALID_TOKEN"
invitation.email = "user@example.com"
- invitation.expires_at = datetime.now(timezone.utc) + timedelta(days=1)
+ invitation.expires_at = datetime.now(UTC) + timedelta(days=1)
invitation.state = Invitation.State.PENDING
invitation.tenant = MagicMock()
return invitation
@@ -616,7 +753,7 @@ class TestValidateInvitation:
)
def test_invitation_expired(self, invitation):
- expired_time = datetime.now(timezone.utc) - timedelta(days=1)
+ expired_time = datetime.now(UTC) - timedelta(days=1)
invitation.expires_at = expired_time
with (
@@ -625,7 +762,7 @@ class TestValidateInvitation:
):
mock_db = mock_using.return_value
mock_db.get.return_value = invitation
- mock_datetime.now.return_value = datetime.now(timezone.utc)
+ mock_datetime.now.return_value = datetime.now(UTC)
with pytest.raises(InvitationTokenExpiredException):
validate_invitation("VALID_TOKEN", "user@example.com")
@@ -670,7 +807,7 @@ class TestValidateInvitation:
invitation = MagicMock(spec=Invitation)
invitation.token = "VALID_TOKEN"
invitation.email = uppercase_email
- invitation.expires_at = datetime.now(timezone.utc) + timedelta(days=1)
+ invitation.expires_at = datetime.now(UTC) + timedelta(days=1)
invitation.state = Invitation.State.PENDING
invitation.tenant = MagicMock()
@@ -802,7 +939,7 @@ class TestProwlerIntegrationConnectionTest:
integration.credentials = {
"user_mail": "test@example.com",
"api_token": "test_api_token",
- "domain": "example.atlassian.net",
+ "domain": "example",
}
integration.configuration = {}
@@ -830,7 +967,7 @@ class TestProwlerIntegrationConnectionTest:
mock_jira_class.test_connection.assert_called_once_with(
user_mail="test@example.com",
api_token="test_api_token",
- domain="example.atlassian.net",
+ domain="example",
raise_on_exception=False,
)
@@ -863,7 +1000,7 @@ class TestProwlerIntegrationConnectionTest:
integration.credentials = {
"user_mail": "invalid@example.com",
"api_token": "invalid_token",
- "domain": "invalid.atlassian.net",
+ "domain": "invalid",
}
integration.configuration = {}
@@ -888,7 +1025,7 @@ class TestProwlerIntegrationConnectionTest:
mock_jira_class.test_connection.assert_called_once_with(
user_mail="invalid@example.com",
api_token="invalid_token",
- domain="invalid.atlassian.net",
+ domain="invalid",
raise_on_exception=False,
)
@@ -916,7 +1053,7 @@ class TestProwlerIntegrationConnectionTest:
integration.credentials = {
"user_mail": "test@example.com",
"api_token": "test_api_token",
- "domain": "example.atlassian.net",
+ "domain": "example",
}
integration.configuration = {
"issue_types": {"OLD_PROJ": ["Task"]}, # Existing configuration
diff --git a/api/src/backend/api/tests/test_uuid_utils.py b/api/src/backend/api/tests/test_uuid_utils.py
index e202d087f3..a69d71cee9 100644
--- a/api/src/backend/api/tests/test_uuid_utils.py
+++ b/api/src/backend/api/tests/test_uuid_utils.py
@@ -1,23 +1,22 @@
-from datetime import datetime, timezone
+from datetime import UTC, datetime
from uuid import uuid4
import pytest
+from api.uuid_utils import (
+ datetime_from_uuid7,
+ datetime_to_uuid7,
+ transform_into_uuid7,
+ uuid7_end,
+ uuid7_range,
+ uuid7_start,
+)
from dateutil.relativedelta import relativedelta
from rest_framework_json_api.serializers import ValidationError
from uuid6 import UUID
-from api.uuid_utils import (
- transform_into_uuid7,
- datetime_to_uuid7,
- datetime_from_uuid7,
- uuid7_start,
- uuid7_end,
- uuid7_range,
-)
-
def test_transform_into_uuid7_valid():
- uuid_v7 = datetime_to_uuid7(datetime.now(timezone.utc))
+ uuid_v7 = datetime_to_uuid7(datetime.now(UTC))
transformed_uuid = transform_into_uuid7(uuid_v7)
assert transformed_uuid == UUID(hex=uuid_v7.hex.upper())
assert transformed_uuid.version == 7
@@ -33,8 +32,8 @@ def test_transform_into_uuid7_invalid_version():
@pytest.mark.parametrize(
"input_datetime",
[
- datetime(2024, 9, 11, 7, 20, 27, tzinfo=timezone.utc),
- datetime(2023, 1, 1, 0, 0, 0, tzinfo=timezone.utc),
+ datetime(2024, 9, 11, 7, 20, 27, tzinfo=UTC),
+ datetime(2023, 1, 1, 0, 0, 0, tzinfo=UTC),
],
)
def test_datetime_to_uuid7(input_datetime):
@@ -48,8 +47,8 @@ def test_datetime_to_uuid7(input_datetime):
@pytest.mark.parametrize(
"input_datetime",
[
- datetime(2024, 9, 11, 7, 20, 27, tzinfo=timezone.utc),
- datetime(2023, 1, 1, 0, 0, 0, tzinfo=timezone.utc),
+ datetime(2024, 9, 11, 7, 20, 27, tzinfo=UTC),
+ datetime(2023, 1, 1, 0, 0, 0, tzinfo=UTC),
],
)
def test_datetime_from_uuid7(input_datetime):
@@ -65,7 +64,7 @@ def test_datetime_from_uuid7_invalid():
def test_uuid7_start():
- dt = datetime.now(timezone.utc)
+ dt = datetime.now(UTC)
uuid = datetime_to_uuid7(dt)
start_uuid = uuid7_start(uuid)
expected_dt = dt.replace(hour=0, minute=0, second=0, microsecond=0)
@@ -76,7 +75,7 @@ def test_uuid7_start():
@pytest.mark.parametrize("months_offset", [0, 1, 10, 30, 60])
def test_uuid7_end(months_offset):
- dt = datetime.now(timezone.utc)
+ dt = datetime.now(UTC)
uuid = datetime_to_uuid7(dt)
end_uuid = uuid7_end(uuid, months_offset)
expected_dt = dt.replace(day=1, hour=0, minute=0, second=0, microsecond=0)
@@ -87,7 +86,7 @@ def test_uuid7_end(months_offset):
def test_uuid7_range():
- dt_now = datetime.now(timezone.utc)
+ dt_now = datetime.now(UTC)
uuid_list = [
datetime_to_uuid7(dt_now),
datetime_to_uuid7(dt_now.replace(year=2023)),
diff --git a/api/src/backend/api/tests/test_validators.py b/api/src/backend/api/tests/test_validators.py
new file mode 100644
index 0000000000..a431a659c7
--- /dev/null
+++ b/api/src/backend/api/tests/test_validators.py
@@ -0,0 +1,246 @@
+import socket
+
+import pytest
+from api.validators import (
+ resolve_lighthouse_openai_compatible_host,
+ validate_lighthouse_openai_compatible_base_url,
+)
+from django.core.exceptions import ValidationError
+from django.test import override_settings
+
+
+def test_lighthouse_base_url_rejects_http_scheme():
+ with pytest.raises(ValidationError, match="HTTPS"):
+ validate_lighthouse_openai_compatible_base_url(
+ "http://openrouter.ai/api/v1",
+ resolve_dns=False,
+ )
+
+
+@pytest.mark.parametrize(
+ "base_url",
+ [
+ "https://openrouter.ai:0/api/v1",
+ "https://openrouter.ai:-1/api/v1",
+ "https://openrouter.ai:65536/api/v1",
+ "https://openrouter.ai:invalid/api/v1",
+ ],
+)
+def test_lighthouse_base_url_rejects_invalid_port(base_url):
+ with pytest.raises(ValidationError, match="port is invalid"):
+ validate_lighthouse_openai_compatible_base_url(
+ base_url,
+ resolve_dns=False,
+ )
+
+
+@pytest.mark.parametrize("port", [1, 65535])
+def test_lighthouse_base_url_accepts_valid_port_boundaries(port):
+ assert (
+ validate_lighthouse_openai_compatible_base_url(
+ f"https://openrouter.ai:{port}/api/v1",
+ resolve_dns=False,
+ )
+ is None
+ )
+
+
+def test_lighthouse_base_url_rejects_localhost():
+ with pytest.raises(ValidationError, match="external public endpoint"):
+ validate_lighthouse_openai_compatible_base_url(
+ "https://localhost/v1",
+ resolve_dns=False,
+ )
+
+
+@pytest.mark.parametrize("ip_address", ["10.0.0.1", "172.16.0.1", "192.168.1.1"])
+def test_lighthouse_base_url_rejects_private_ip_literal(ip_address):
+ with pytest.raises(ValidationError, match="external public endpoint"):
+ validate_lighthouse_openai_compatible_base_url(
+ f"https://{ip_address}/v1",
+ resolve_dns=False,
+ )
+
+
+def test_lighthouse_base_url_rejects_metadata_ip_literal():
+ with pytest.raises(ValidationError, match="external public endpoint"):
+ validate_lighthouse_openai_compatible_base_url(
+ "https://169.254.169.254/latest/meta-data",
+ resolve_dns=False,
+ )
+
+
+@pytest.mark.parametrize(
+ "base_url",
+ [
+ "https://[::ffff:169.254.169.254]/v1",
+ "https://[64:ff9b::a9fe:a9fe]/v1",
+ "https://[2002:a9fe:a9fe::]/v1",
+ ],
+)
+def test_lighthouse_base_url_rejects_embedded_non_global_ip(base_url):
+ with pytest.raises(ValidationError, match="external public endpoint"):
+ validate_lighthouse_openai_compatible_base_url(
+ base_url,
+ resolve_dns=False,
+ )
+
+
+@pytest.mark.parametrize(
+ "base_url",
+ [
+ "https://[::ffff:93.184.216.34]/v1",
+ "https://[64:ff9b::5db8:d822]/v1",
+ "https://[2002:5db8:d822::]/v1",
+ ],
+)
+def test_lighthouse_base_url_accepts_embedded_public_ip(base_url):
+ assert (
+ validate_lighthouse_openai_compatible_base_url(
+ base_url,
+ resolve_dns=False,
+ )
+ is None
+ )
+
+
+def test_lighthouse_base_url_accepts_hostname_without_dns_resolution():
+ assert (
+ validate_lighthouse_openai_compatible_base_url(
+ "https://openrouter.ai/api/v1",
+ resolve_dns=False,
+ )
+ is None
+ )
+
+
+def test_lighthouse_base_url_rejects_post_dns_internal_address(monkeypatch):
+ def resolve_to_metadata(*_args, **_kwargs):
+ return [
+ (
+ socket.AF_INET,
+ socket.SOCK_STREAM,
+ 6,
+ "",
+ ("169.254.169.254", 443),
+ )
+ ]
+
+ monkeypatch.setattr("api.validators.socket.getaddrinfo", resolve_to_metadata)
+
+ with pytest.raises(ValidationError, match="external public endpoint"):
+ validate_lighthouse_openai_compatible_base_url(
+ "https://metadata.example.test/v1"
+ )
+
+
+def test_lighthouse_base_url_accepts_public_resolved_address(monkeypatch):
+ def resolve_to_public(*_args, **_kwargs):
+ return [
+ (
+ socket.AF_INET,
+ socket.SOCK_STREAM,
+ 6,
+ "",
+ ("93.184.216.34", 443),
+ )
+ ]
+
+ monkeypatch.setattr("api.validators.socket.getaddrinfo", resolve_to_public)
+
+ assert (
+ validate_lighthouse_openai_compatible_base_url("https://openrouter.ai/api/v1")
+ is None
+ )
+
+
+@override_settings(
+ LIGHTHOUSE_AI_OPENAI_COMPATIBLE_ALLOWED_HOSTS=["custom-openai.internal"]
+)
+def test_lighthouse_base_url_accepts_allowlisted_host_without_resolution(monkeypatch):
+ def fail_resolution(*_args, **_kwargs):
+ raise AssertionError("allowlisted hosts must not be resolved")
+
+ monkeypatch.setattr("api.validators.socket.getaddrinfo", fail_resolution)
+
+ assert (
+ validate_lighthouse_openai_compatible_base_url(
+ "https://custom-openai.internal/v1"
+ )
+ is None
+ )
+
+
+@override_settings(
+ LIGHTHOUSE_AI_OPENAI_COMPATIBLE_ALLOWED_HOSTS=["custom-openai.internal"]
+)
+def test_lighthouse_resolve_returns_allowlisted_hostname_unpinned():
+ assert resolve_lighthouse_openai_compatible_host(
+ "Custom-OpenAI.internal.", 443
+ ) == ("custom-openai.internal",)
+
+
+@override_settings(LIGHTHOUSE_AI_OPENAI_COMPATIBLE_ALLOWED_HOSTS=["localhost"])
+def test_lighthouse_base_url_accepts_allowlisted_blocked_host():
+ assert (
+ validate_lighthouse_openai_compatible_base_url(
+ "https://localhost/v1",
+ resolve_dns=False,
+ )
+ is None
+ )
+
+
+@override_settings(LIGHTHOUSE_AI_OPENAI_COMPATIBLE_ALLOWED_HOSTS=["10.0.0.1"])
+def test_lighthouse_base_url_accepts_allowlisted_private_ip_literal():
+ assert (
+ validate_lighthouse_openai_compatible_base_url(
+ "https://10.0.0.1/v1",
+ resolve_dns=False,
+ )
+ is None
+ )
+
+
+@override_settings(
+ LIGHTHOUSE_AI_OPENAI_COMPATIBLE_ALLOWED_HOSTS=[" Custom-OpenAI.Internal. "]
+)
+def test_lighthouse_allowlist_entries_are_normalized():
+ assert (
+ validate_lighthouse_openai_compatible_base_url(
+ "https://custom-openai.internal/v1",
+ resolve_dns=False,
+ )
+ is None
+ )
+
+
+@override_settings(
+ LIGHTHOUSE_AI_OPENAI_COMPATIBLE_ALLOWED_HOSTS=["custom-openai.internal"]
+)
+def test_lighthouse_base_url_rejects_host_not_in_allowlist():
+ with pytest.raises(ValidationError, match="external public endpoint"):
+ validate_lighthouse_openai_compatible_base_url(
+ "https://localhost/v1",
+ resolve_dns=False,
+ )
+
+
+@override_settings(LIGHTHOUSE_AI_OPENAI_COMPATIBLE_ALLOWED_HOSTS=[""])
+def test_lighthouse_allowlist_ignores_empty_entries():
+ with pytest.raises(ValidationError, match="external public endpoint"):
+ validate_lighthouse_openai_compatible_base_url(
+ "https://localhost/v1",
+ resolve_dns=False,
+ )
+
+
+@override_settings(
+ LIGHTHOUSE_AI_OPENAI_COMPATIBLE_ALLOWED_HOSTS=["custom-openai.internal"]
+)
+def test_lighthouse_base_url_allowlisted_host_still_requires_https():
+ with pytest.raises(ValidationError, match="HTTPS"):
+ validate_lighthouse_openai_compatible_base_url(
+ "http://custom-openai.internal/v1",
+ resolve_dns=False,
+ )
diff --git a/api/src/backend/api/tests/test_views.py b/api/src/backend/api/tests/test_views.py
index 97796c97ca..96d366e847 100644
--- a/api/src/backend/api/tests/test_views.py
+++ b/api/src/backend/api/tests/test_views.py
@@ -3,7 +3,7 @@ import io
import json
import os
import tempfile
-from datetime import date, datetime, timedelta, timezone
+from datetime import UTC, date, datetime, timedelta
from decimal import Decimal
from pathlib import Path
from types import SimpleNamespace
@@ -15,29 +15,6 @@ import jwt
import pytest
from allauth.account.models import EmailAddress
from allauth.socialaccount.models import SocialAccount, SocialApp
-from botocore.exceptions import ClientError, NoCredentialsError
-from conftest import (
- API_JSON_CONTENT_TYPE,
- TEST_PASSWORD,
- TEST_USER,
- TODAY,
- today_after_n_days,
-)
-from django.conf import settings
-from django.db.models import Count
-from django.http import JsonResponse
-from django.test import RequestFactory
-from django.urls import reverse
-from django_celery_results.models import TaskResult
-from rest_framework import status
-from rest_framework.exceptions import PermissionDenied
-from rest_framework.response import Response
-from rest_framework_simplejwt.token_blacklist.models import (
- BlacklistedToken,
- OutstandingToken,
-)
-from rest_framework_simplejwt.tokens import RefreshToken
-
from api.attack_paths import (
AttackPathsQueryDefinition,
AttackPathsQueryParameterDefinition,
@@ -64,6 +41,7 @@ from api.models import (
ProviderSecret,
Resource,
ResourceFindingMapping,
+ ResourceTag,
Role,
RoleProviderGroupRelationship,
SAMLConfiguration,
@@ -79,10 +57,39 @@ from api.models import (
UserRoleRelationship,
)
from api.rls import Tenant
-from api.v1.serializers import TokenSerializer
-from api.v1.views import ComplianceOverviewViewSet, TenantFinishACSView
+from api.uuid_utils import datetime_to_uuid7
+from api.v1.views import (
+ ComplianceOverviewViewSet,
+ CustomSAMLLoginView,
+ TenantFinishACSView,
+)
+from botocore.exceptions import ClientError, NoCredentialsError
+from celery import states
+from conftest import (
+ API_JSON_CONTENT_TYPE,
+ TEST_PASSWORD,
+ TEST_USER,
+ TODAY,
+ today_after_n_days,
+)
+from django.conf import settings
+from django.db import connection
+from django.db.models import Count
+from django.http import JsonResponse
+from django.test import RequestFactory
+from django.test.utils import CaptureQueriesContext
+from django.urls import reverse
+from django_celery_results.models import TaskResult
from prowler.lib.check.models import Severity
from prowler.lib.outputs.finding import Status
+from rest_framework import status
+from rest_framework.exceptions import PermissionDenied
+from rest_framework.response import Response
+from rest_framework_simplejwt.token_blacklist.models import (
+ BlacklistedToken,
+ OutstandingToken,
+)
+from rest_framework_simplejwt.tokens import RefreshToken
class TestViewSet:
@@ -241,6 +248,63 @@ class TestUserViewSet:
create_test_user.refresh_from_db()
assert create_test_user.company_name == new_company_name
+ def test_users_partial_update_same_tenant_other_user_password_denied(
+ self, authenticated_client_no_permissions_rbac, tenants_fixture
+ ):
+ original_password = "OriginalPassword123@"
+ new_password = "UpdatedPassword123@"
+ target_user = User.objects.create_user(
+ password=original_password,
+ email="target-password-update@example.com",
+ )
+ Membership.objects.create(user=target_user, tenant=tenants_fixture[0])
+ payload = {
+ "data": {
+ "type": "users",
+ "id": str(target_user.id),
+ "attributes": {"password": new_password},
+ },
+ }
+
+ response = authenticated_client_no_permissions_rbac.patch(
+ reverse("user-detail", kwargs={"pk": target_user.id}),
+ data=payload,
+ content_type="application/vnd.api+json",
+ )
+
+ assert response.status_code == status.HTTP_400_BAD_REQUEST
+ target_user.refresh_from_db()
+ assert target_user.check_password(original_password)
+ assert not target_user.check_password(new_password)
+
+ def test_users_partial_update_same_tenant_other_user_email_denied(
+ self, authenticated_client_no_permissions_rbac, tenants_fixture
+ ):
+ original_email = "target-email-update@example.com"
+ new_email = "updated-target-email@example.com"
+ target_user = User.objects.create_user(
+ password="OriginalPassword123@",
+ email=original_email,
+ )
+ Membership.objects.create(user=target_user, tenant=tenants_fixture[0])
+ payload = {
+ "data": {
+ "type": "users",
+ "id": str(target_user.id),
+ "attributes": {"email": new_email},
+ },
+ }
+
+ response = authenticated_client_no_permissions_rbac.patch(
+ reverse("user-detail", kwargs={"pk": target_user.id}),
+ data=payload,
+ content_type="application/vnd.api+json",
+ )
+
+ assert response.status_code == status.HTTP_400_BAD_REQUEST
+ target_user.refresh_from_db()
+ assert target_user.email == original_email
+
def test_users_partial_update_invalid_content_type(
self, authenticated_client, create_test_user
):
@@ -786,21 +850,15 @@ class TestTenantViewSet:
assert response.json()["data"] == []
def test_tenants_list_memberships_as_member(
- self, authenticated_client, tenants_fixture, extra_users
+ self, authenticated_client_for_tenant_factory, tenants_fixture, extra_users
):
_, tenant2, _ = tenants_fixture
_, user3_membership = extra_users
user3, membership3 = user3_membership
- token_response = authenticated_client.post(
- reverse("token-obtain"),
- data={"email": user3.email, "password": TEST_PASSWORD},
- format="json",
- )
- access_token = token_response.json()["data"]["attributes"]["access"]
+ client = authenticated_client_for_tenant_factory(user3, tenant2)
- response = authenticated_client.get(
+ response = client.get(
reverse("tenant-membership-list", kwargs={"tenant_pk": tenant2.id}),
- headers={"Authorization": f"Bearer {access_token}"},
)
assert response.status_code == status.HTTP_200_OK
# User is a member and can only see its own membership
@@ -1363,23 +1421,29 @@ class TestMembershipViewSet:
class TestProviderViewSet:
@pytest.fixture(scope="function")
def create_provider_group_relationship(
- self, tenants_fixture, providers_fixture, provider_groups_fixture
+ self, tenants_fixture, aws_provider, provider_groups_fixture
):
tenant, *_ = tenants_fixture
- provider1, *_ = providers_fixture
+ provider1 = aws_provider
provider_group1, *_ = provider_groups_fixture
provider_group_membership = ProviderGroupMembership.objects.create(
tenant=tenant, provider=provider1, provider_group=provider_group1
)
return provider_group_membership
- def test_providers_list(self, authenticated_client, providers_fixture):
- response = authenticated_client.get(reverse("provider-list"))
+ def test_providers_list(self, authenticated_client, all_provider_types_fixture):
+ response = authenticated_client.get(
+ reverse("provider-list"), {"page[disable]": "true"}
+ )
assert response.status_code == status.HTTP_200_OK
- assert len(response.json()["data"]) == len(providers_fixture)
+ data = response.json()["data"]
+ assert len(data) == len(all_provider_types_fixture)
+ assert {item["attributes"]["provider"] for item in data} == {
+ provider.provider for provider in all_provider_types_fixture
+ }
def test_providers_filter_provider_type(
- self, authenticated_client, providers_fixture
+ self, authenticated_client, aws_provider_pair
):
response = authenticated_client.get(
reverse("provider-list"), {"filter[provider_type]": "aws"}
@@ -1390,7 +1454,7 @@ class TestProviderViewSet:
assert all(item["attributes"]["provider"] == "aws" for item in data)
def test_providers_filter_provider_type_in(
- self, authenticated_client, providers_fixture
+ self, authenticated_client, aws_provider_pair, gcp_provider
):
response = authenticated_client.get(
reverse("provider-list"), {"filter[provider_type__in]": "aws,gcp"}
@@ -1401,15 +1465,51 @@ class TestProviderViewSet:
assert {"aws", "gcp"} >= {item["attributes"]["provider"] for item in data}
def test_providers_filter_provider_type_invalid(
- self, authenticated_client, providers_fixture
+ self, authenticated_client, aws_provider
):
response = authenticated_client.get(
reverse("provider-list"), {"filter[provider_type]": "invalid"}
)
assert response.status_code == status.HTTP_400_BAD_REQUEST
+ def test_providers_filter_provider_groups(
+ self,
+ authenticated_client,
+ tenants_fixture,
+ aws_provider_pair,
+ provider_groups_fixture,
+ ):
+ tenant = tenants_fixture[0]
+ provider1, provider2 = aws_provider_pair
+ group1, group2, *_ = provider_groups_fixture
+ ProviderGroupMembership.objects.create(
+ tenant=tenant, provider=provider1, provider_group=group1
+ )
+ ProviderGroupMembership.objects.create(
+ tenant=tenant, provider=provider1, provider_group=group2
+ )
+ ProviderGroupMembership.objects.create(
+ tenant=tenant, provider=provider2, provider_group=group2
+ )
+
+ response = authenticated_client.get(
+ reverse("provider-list"), {"filter[provider_groups]": str(group1.id)}
+ )
+ assert response.status_code == status.HTTP_200_OK
+ data = response.json()["data"]
+ assert [item["id"] for item in data] == [str(provider1.id)]
+
+ response = authenticated_client.get(
+ reverse("provider-list"),
+ {"filter[provider_groups__in]": f"{group1.id},{group2.id}"},
+ )
+ assert response.status_code == status.HTTP_200_OK
+ provider_ids = {item["id"] for item in response.json()["data"]}
+ assert provider_ids == {str(provider1.id), str(provider2.id)}
+ assert len(response.json()["data"]) == 2
+
def test_providers_disable_pagination(
- self, authenticated_client, providers_fixture, tenants_fixture
+ self, authenticated_client, aws_provider, tenants_fixture
):
tenant, *_ = tenants_fixture
existing_count = Provider.objects.filter(tenant_id=tenant.id).count()
@@ -1452,29 +1552,29 @@ class TestProviderViewSet:
("provider_groups", ["provider-groups"]),
],
)
+ @pytest.mark.usefixtures("create_provider_group_relationship")
def test_providers_list_include(
self,
include_values,
expected_resources,
authenticated_client,
- providers_fixture,
- create_provider_group_relationship,
+ aws_provider,
):
response = authenticated_client.get(
reverse("provider-list"), {"include": include_values}
)
assert response.status_code == status.HTTP_200_OK
- assert len(response.json()["data"]) == len(providers_fixture)
+ assert len(response.json()["data"]) == 1
assert "included" in response.json()
included_data = response.json()["included"]
for expected_type in expected_resources:
- assert any(
- d.get("type") == expected_type for d in included_data
- ), f"Expected type '{expected_type}' not found in included data"
+ assert any(d.get("type") == expected_type for d in included_data), (
+ f"Expected type '{expected_type}' not found in included data"
+ )
- def test_providers_retrieve(self, authenticated_client, providers_fixture):
- provider1, *_ = providers_fixture
+ def test_providers_retrieve(self, authenticated_client, aws_provider):
+ provider1 = aws_provider
response = authenticated_client.get(
reverse("provider-detail", kwargs={"pk": provider1.id}),
)
@@ -1625,6 +1725,26 @@ class TestProviderViewSet:
"uid": "C12",
"alias": "Google Workspace Minimum Length",
},
+ {
+ "provider": "image",
+ "uid": "registry.example.com/prowler/test:latest",
+ "alias": "Container Image",
+ },
+ {
+ "provider": "okta",
+ "uid": "acme.okta.com",
+ "alias": "Okta Org",
+ },
+ {
+ "provider": "okta",
+ "uid": "agency.okta-gov.com",
+ "alias": "Okta Gov Org",
+ },
+ {
+ "provider": "okta",
+ "uid": "agency.okta.mil",
+ "alias": "Okta Mil Org",
+ },
]
),
)
@@ -2143,6 +2263,24 @@ class TestProviderViewSet:
"googleworkspace-uid",
"uid",
),
+ (
+ {
+ "provider": "okta",
+ "uid": "https://acme.okta.com",
+ "alias": "test",
+ },
+ "okta-uid",
+ "uid",
+ ),
+ (
+ {
+ "provider": "okta",
+ "uid": "acme.example.com",
+ "alias": "test",
+ },
+ "okta-uid",
+ "uid",
+ ),
]
),
)
@@ -2163,8 +2301,27 @@ class TestProviderViewSet:
== f"/data/attributes/{error_pointer}"
)
- def test_providers_partial_update(self, authenticated_client, providers_fixture):
- provider1, *_ = providers_fixture
+ @pytest.mark.parametrize(
+ "input_uid,stored_uid",
+ [
+ ("Acme.okta.com", "acme.okta.com"),
+ (" ACME.OKTA.COM ", "acme.okta.com"),
+ ("Agency.Okta-Gov.com", "agency.okta-gov.com"),
+ ],
+ )
+ def test_providers_create_okta_uid_normalized(
+ self, authenticated_client, input_uid, stored_uid
+ ):
+ response = authenticated_client.post(
+ reverse("provider-list"),
+ data={"provider": "okta", "uid": input_uid, "alias": "Okta"},
+ format="json",
+ )
+ assert response.status_code == status.HTTP_201_CREATED
+ assert Provider.objects.get().uid == stored_uid
+
+ def test_providers_partial_update(self, authenticated_client, aws_provider):
+ provider1 = aws_provider
new_alias = "This is the new name"
payload = {
"data": {
@@ -2183,9 +2340,11 @@ class TestProviderViewSet:
assert provider1.alias == new_alias
def test_providers_partial_update_invalid_content_type(
- self, authenticated_client, providers_fixture
+ self,
+ authenticated_client,
+ aws_provider,
):
- provider1, *_ = providers_fixture
+ provider1 = aws_provider
response = authenticated_client.patch(
reverse("provider-detail", kwargs={"pk": provider1.id}),
data={},
@@ -2193,9 +2352,11 @@ class TestProviderViewSet:
assert response.status_code == status.HTTP_415_UNSUPPORTED_MEDIA_TYPE
def test_providers_partial_update_invalid_content(
- self, authenticated_client, providers_fixture
+ self,
+ authenticated_client,
+ aws_provider,
):
- provider1, *_ = providers_fixture
+ provider1 = aws_provider
new_name = "This is the new name"
payload = {"alias": new_name}
response = authenticated_client.patch(
@@ -2215,11 +2376,11 @@ class TestProviderViewSet:
def test_providers_partial_update_invalid_fields(
self,
authenticated_client,
- providers_fixture,
+ aws_provider,
attribute_key,
attribute_value,
):
- provider1, *_ = providers_fixture
+ provider1 = aws_provider
payload = {
"data": {
"type": "providers",
@@ -2241,7 +2402,7 @@ class TestProviderViewSet:
mock_delete_task,
mock_task_get,
authenticated_client,
- providers_fixture,
+ aws_provider,
tasks_fixture,
):
prowler_task = tasks_fixture[0]
@@ -2250,7 +2411,7 @@ class TestProviderViewSet:
mock_delete_task.return_value = task_mock
mock_task_get.return_value = prowler_task
- provider1, *_ = providers_fixture
+ provider1 = aws_provider
response = authenticated_client.delete(
reverse("provider-detail", kwargs={"pk": provider1.id})
)
@@ -2274,7 +2435,7 @@ class TestProviderViewSet:
mock_provider_connection,
mock_task_get,
authenticated_client,
- providers_fixture,
+ aws_provider,
tasks_fixture,
):
prowler_task = tasks_fixture[0]
@@ -2284,7 +2445,7 @@ class TestProviderViewSet:
mock_provider_connection.return_value = task_mock
mock_task_get.return_value = prowler_task
- provider1, *_ = providers_fixture
+ provider1 = aws_provider
assert provider1.connected is None
assert provider1.connection_last_checked_at is None
@@ -2299,7 +2460,8 @@ class TestProviderViewSet:
assert response.headers["Content-Location"] == f"/api/v1/tasks/{task_mock.id}"
def test_providers_connection_invalid_provider(
- self, authenticated_client, providers_fixture
+ self,
+ authenticated_client,
):
response = authenticated_client.post(
reverse("provider-connection", kwargs={"pk": "random_id"})
@@ -2307,42 +2469,24 @@ class TestProviderViewSet:
assert response.status_code == status.HTTP_404_NOT_FOUND
@pytest.mark.parametrize(
- "filter_name, filter_value, expected_count",
+ "filter_name, filter_value",
(
[
- ("provider", "aws", 2),
- ("provider.in", "azure,gcp", 2),
- ("uid", "123456789012", 1),
- (
- "uid.icontains",
- "1",
- 12,
- ),
- ("alias", "aws_testing_1", 1),
- ("alias.icontains", "aws", 2),
- ("inserted_at", TODAY, 13),
- (
- "inserted_at.gte",
- "2024-01-01",
- 13,
- ),
- ("inserted_at.lte", "2024-01-01", 0),
- (
- "updated_at.gte",
- "2024-01-01",
- 13,
- ),
- ("updated_at.lte", "2024-01-01", 0),
+ ("uid", "123456789012"),
+ ("uid.icontains", "1"),
+ ("alias", "aws_testing_1"),
+ ("inserted_at", TODAY),
+ ("inserted_at.gte", "2024-01-01"),
+ ("updated_at.gte", "2024-01-01"),
]
),
)
- def test_providers_filters(
+ def test_providers_filters_single_aws_provider(
self,
authenticated_client,
- providers_fixture,
+ aws_provider,
filter_name,
filter_value,
- expected_count,
):
response = authenticated_client.get(
reverse("provider-list"),
@@ -2350,7 +2494,69 @@ class TestProviderViewSet:
)
assert response.status_code == status.HTTP_200_OK
- assert len(response.json()["data"]) == expected_count
+ assert len(response.json()["data"]) == 1
+
+ @pytest.mark.parametrize(
+ "filter_name, filter_value",
+ (
+ [
+ ("inserted_at.lte", "2024-01-01"),
+ ("updated_at.lte", "2024-01-01"),
+ ]
+ ),
+ )
+ def test_providers_filters_single_aws_provider_no_results(
+ self,
+ authenticated_client,
+ aws_provider,
+ filter_name,
+ filter_value,
+ ):
+ response = authenticated_client.get(
+ reverse("provider-list"),
+ {f"filter[{filter_name}]": filter_value},
+ )
+
+ assert response.status_code == status.HTTP_200_OK
+ assert len(response.json()["data"]) == 0
+
+ @pytest.mark.parametrize(
+ "filter_name, filter_value",
+ (
+ [
+ ("provider", "aws"),
+ ("alias.icontains", "aws"),
+ ]
+ ),
+ )
+ def test_providers_filters_two_aws_providers(
+ self,
+ authenticated_client,
+ aws_provider_pair,
+ filter_name,
+ filter_value,
+ ):
+ response = authenticated_client.get(
+ reverse("provider-list"),
+ {f"filter[{filter_name}]": filter_value},
+ )
+
+ assert response.status_code == status.HTTP_200_OK
+ assert len(response.json()["data"]) == 2
+
+ def test_providers_filters_provider_in(
+ self,
+ authenticated_client,
+ azure_provider,
+ gcp_provider,
+ ):
+ response = authenticated_client.get(
+ reverse("provider-list"),
+ {"filter[provider.in]": "azure,gcp"},
+ )
+
+ assert response.status_code == status.HTTP_200_OK
+ assert len(response.json()["data"]) == 2
@pytest.mark.parametrize(
"filter_name",
@@ -2544,9 +2750,9 @@ class TestProviderGroupViewSet:
assert response.status_code == status.HTTP_405_METHOD_NOT_ALLOWED
def test_provider_group_create_with_relationships(
- self, authenticated_client, providers_fixture, roles_fixture
+ self, authenticated_client, aws_provider_pair, roles_fixture
):
- provider1, provider2, *_ = providers_fixture
+ provider1, provider2 = aws_provider_pair
role1, role2, *_ = roles_fixture
data = {
@@ -2587,12 +2793,13 @@ class TestProviderGroupViewSet:
self,
authenticated_client,
provider_groups_fixture,
- providers_fixture,
+ gcp_provider,
+ kubernetes_provider,
roles_fixture,
):
group = provider_groups_fixture[0]
- provider3 = providers_fixture[2]
- provider4 = providers_fixture[3]
+ provider3 = gcp_provider
+ provider4 = kubernetes_provider
role3 = roles_fixture[2]
role4 = roles_fixture[3]
@@ -2629,11 +2836,15 @@ class TestProviderGroupViewSet:
assert set(group.roles.all()) == {role3, role4}
def test_provider_group_clear_relationships(
- self, authenticated_client, providers_fixture, provider_groups_fixture
+ self,
+ authenticated_client,
+ gcp_provider,
+ kubernetes_provider,
+ provider_groups_fixture,
):
group = provider_groups_fixture[0]
- provider3 = providers_fixture[2]
- provider4 = providers_fixture[3]
+ provider3 = gcp_provider
+ provider4 = kubernetes_provider
data = {
"data": {
@@ -2706,10 +2917,57 @@ class TestProviderGroupViewSet:
@pytest.mark.django_db
class TestProviderSecretViewSet:
+ @staticmethod
+ def _oraclecloud_secret(**overrides):
+ secret = {
+ "user": "ocid1.user.oc1..aaaaaaaakldibrbov4ubh25aqdeiroklxjngwka7u6w7no3glmdq3n5sxtkq",
+ "fingerprint": "aa:bb:cc:dd:ee:ff:00:11:22:33:44:55:66:77:88:99",
+ "key_content": "test-key-content",
+ "tenancy": "ocid1.tenancy.oc1..aaaaaaaa3dwoazoox4q7wrvriywpokp5grlhgnkwtyt6dmwyou7no6mdmzda",
+ }
+ secret.update(overrides)
+ return secret
+
+ def _create_oraclecloud_secret(
+ self,
+ authenticated_client,
+ oraclecloud_provider,
+ secret,
+ name="OCI Secret",
+ ):
+ data = {
+ "data": {
+ "type": "provider-secrets",
+ "attributes": {
+ "name": name,
+ "secret_type": ProviderSecret.TypeChoices.STATIC,
+ "secret": secret,
+ },
+ "relationships": {
+ "provider": {
+ "data": {
+ "type": "providers",
+ "id": str(oraclecloud_provider.id),
+ }
+ }
+ },
+ }
+ }
+ return authenticated_client.post(
+ reverse("providersecret-list"),
+ data=json.dumps(data),
+ content_type="application/vnd.api+json",
+ )
+
def test_provider_secrets_list(self, authenticated_client, provider_secret_fixture):
response = authenticated_client.get(reverse("providersecret-list"))
assert response.status_code == status.HTTP_200_OK
- assert len(response.json()["data"]) == len(provider_secret_fixture)
+ assert len(response.json()["data"]) == min(
+ settings.REST_FRAMEWORK["PAGE_SIZE"], len(provider_secret_fixture)
+ )
+ assert response.json()["meta"]["pagination"]["count"] == len(
+ provider_secret_fixture
+ )
def test_provider_secrets_retrieve(
self, authenticated_client, provider_secret_fixture
@@ -2807,7 +3065,24 @@ class TestProviderSecretViewSet:
Provider.ProviderChoices.KUBERNETES.value,
ProviderSecret.TypeChoices.STATIC,
{
- "kubeconfig_content": "kubeconfig-content",
+ "kubeconfig_content": """
+apiVersion: v1
+kind: Config
+clusters:
+ - name: test-cluster
+ cluster:
+ server: https://kubernetes.example.test
+users:
+ - name: test-user
+ user:
+ token: test-token
+contexts:
+ - name: test-context
+ context:
+ cluster: test-cluster
+ user: test-user
+current-context: test-context
+""",
},
),
# M365 client secret credentials
@@ -2843,7 +3118,6 @@ class TestProviderSecretViewSet:
"fingerprint": "aa:bb:cc:dd:ee:ff:00:11:22:33:44:55:66:77:88:99",
"key_content": "-----BEGIN RSA PRIVATE KEY-----\ntest-key-content\n-----END RSA PRIVATE KEY-----",
"tenancy": "ocid1.tenancy.oc1..aaaaaaaa3dwoazoox4q7wrvriywpokp5grlhgnkwtyt6dmwyou7no6mdmzda",
- "region": "us-ashburn-1",
},
),
# OCI with API key credentials (with key_file)
@@ -2855,7 +3129,6 @@ class TestProviderSecretViewSet:
"fingerprint": "aa:bb:cc:dd:ee:ff:00:11:22:33:44:55:66:77:88:99",
"key_file": "/path/to/oci_api_key.pem",
"tenancy": "ocid1.tenancy.oc1..aaaaaaaa3dwoazoox4q7wrvriywpokp5grlhgnkwtyt6dmwyou7no6mdmzda",
- "region": "us-ashburn-1",
},
),
# OCI with API key credentials (with passphrase)
@@ -2867,7 +3140,6 @@ class TestProviderSecretViewSet:
"fingerprint": "aa:bb:cc:dd:ee:ff:00:11:22:33:44:55:66:77:88:99",
"key_content": "-----BEGIN RSA PRIVATE KEY-----\ntest-encrypted-key\n-----END RSA PRIVATE KEY-----",
"tenancy": "ocid1.tenancy.oc1..aaaaaaaa3dwoazoox4q7wrvriywpokp5grlhgnkwtyt6dmwyou7no6mdmzda",
- "region": "us-ashburn-1",
"pass_phrase": "my-secure-passphrase",
},
),
@@ -2963,21 +3235,39 @@ class TestProviderSecretViewSet:
"api_token": "fake-vercel-api-token-for-testing",
},
),
+ # Image registry credentials
+ (
+ Provider.ProviderChoices.IMAGE.value,
+ ProviderSecret.TypeChoices.STATIC,
+ {
+ "registry_username": "user",
+ "registry_password": "pass",
+ },
+ ),
+ # Okta with inline private key credentials
+ (
+ Provider.ProviderChoices.OKTA.value,
+ ProviderSecret.TypeChoices.STATIC,
+ {
+ "okta_client_id": "0oa123456789abcdef",
+ "okta_private_key": "-----BEGIN PRIVATE KEY-----\ntest\n-----END PRIVATE KEY-----",
+ "okta_scopes": [
+ "okta.policies.read",
+ "okta.groups.read",
+ ],
+ },
+ ),
],
)
def test_provider_secrets_create_valid(
self,
authenticated_client,
- providers_fixture,
+ provider_factory,
provider_type,
secret_type,
secret_data,
):
- # Get the provider from the fixture and set its type
- try:
- provider = Provider.objects.filter(provider=provider_type)[0]
- except IndexError:
- print(f"Provider {provider_type} not found")
+ provider = provider_factory(provider_type)
data = {
"data": {
@@ -3007,6 +3297,103 @@ class TestProviderSecretViewSet:
== data["data"]["relationships"]["provider"]["data"]["id"]
)
+ def test_provider_secrets_create_oraclecloud_without_region_stores_no_region(
+ self,
+ authenticated_client,
+ oraclecloud_provider,
+ ):
+ response = self._create_oraclecloud_secret(
+ authenticated_client,
+ oraclecloud_provider,
+ self._oraclecloud_secret(),
+ )
+
+ assert response.status_code == status.HTTP_201_CREATED
+ provider_secret = ProviderSecret.objects.get()
+ assert "region" not in provider_secret.secret
+
+ def test_provider_secrets_create_oraclecloud_accepts_and_ignores_region(
+ self,
+ authenticated_client,
+ oraclecloud_provider,
+ ):
+ response = self._create_oraclecloud_secret(
+ authenticated_client,
+ oraclecloud_provider,
+ self._oraclecloud_secret(
+ key_content=" test-key-content ", region=" us-ashburn-1 "
+ ),
+ )
+
+ assert response.status_code == status.HTTP_201_CREATED
+ provider_secret = ProviderSecret.objects.get()
+ assert provider_secret.secret["key_content"] == "test-key-content"
+ assert "region" not in provider_secret.secret
+
+ def test_provider_secrets_update_oraclecloud_without_region_stores_no_region(
+ self,
+ authenticated_client,
+ oraclecloud_provider,
+ ):
+ create_response = self._create_oraclecloud_secret(
+ authenticated_client,
+ oraclecloud_provider,
+ self._oraclecloud_secret(),
+ )
+ provider_secret = ProviderSecret.objects.get(
+ id=create_response.json()["data"]["id"]
+ )
+ data = {
+ "data": {
+ "type": "provider-secrets",
+ "id": str(provider_secret.id),
+ "attributes": {"secret": self._oraclecloud_secret()},
+ }
+ }
+
+ response = authenticated_client.patch(
+ reverse("providersecret-detail", kwargs={"pk": provider_secret.id}),
+ data=json.dumps(data),
+ content_type="application/vnd.api+json",
+ )
+
+ assert response.status_code == status.HTTP_200_OK
+ provider_secret.refresh_from_db()
+ assert "region" not in provider_secret.secret
+
+ def test_provider_secrets_update_oraclecloud_accepts_and_ignores_region(
+ self,
+ authenticated_client,
+ oraclecloud_provider,
+ ):
+ create_response = self._create_oraclecloud_secret(
+ authenticated_client,
+ oraclecloud_provider,
+ self._oraclecloud_secret(),
+ )
+ provider_secret = ProviderSecret.objects.get(
+ id=create_response.json()["data"]["id"]
+ )
+ data = {
+ "data": {
+ "type": "provider-secrets",
+ "id": str(provider_secret.id),
+ "attributes": {
+ "secret": self._oraclecloud_secret(region=" us-ashburn-1 ")
+ },
+ }
+ }
+
+ response = authenticated_client.patch(
+ reverse("providersecret-detail", kwargs={"pk": provider_secret.id}),
+ data=json.dumps(data),
+ content_type="application/vnd.api+json",
+ )
+
+ assert response.status_code == status.HTTP_200_OK
+ provider_secret.refresh_from_db()
+ assert "region" not in provider_secret.secret
+
@pytest.mark.parametrize(
"attributes, error_code, error_pointer",
(
@@ -3047,13 +3434,13 @@ class TestProviderSecretViewSet:
)
def test_provider_secrets_invalid_create(
self,
- providers_fixture,
+ aws_provider,
authenticated_client,
attributes,
error_code,
error_pointer,
):
- provider, *_ = providers_fixture
+ provider = aws_provider
data = {
"data": {
"type": "provider-secrets",
@@ -3075,6 +3462,41 @@ class TestProviderSecretViewSet:
== f"/data/attributes/{error_pointer}"
)
+ def test_provider_secrets_invalid_create_okta_missing_private_key(
+ self,
+ okta_provider,
+ authenticated_client,
+ ):
+ data = {
+ "data": {
+ "type": "provider-secrets",
+ "attributes": {
+ "name": "Okta Secret",
+ "secret_type": ProviderSecret.TypeChoices.STATIC,
+ "secret": {
+ "okta_client_id": "0oa123456789abcdef",
+ },
+ },
+ "relationships": {
+ "provider": {
+ "data": {"type": "providers", "id": str(okta_provider.id)}
+ }
+ },
+ }
+ }
+
+ response = authenticated_client.post(
+ reverse("providersecret-list"),
+ data=json.dumps(data),
+ content_type="application/vnd.api+json",
+ )
+
+ assert response.status_code == status.HTTP_400_BAD_REQUEST
+ assert response.json()["errors"][0]["code"] == "required"
+ assert response.json()["errors"][0]["source"]["pointer"] == (
+ "/data/attributes/secret/okta_private_key"
+ )
+
def test_provider_secrets_partial_update(
self, authenticated_client, provider_secret_fixture
):
@@ -3168,30 +3590,43 @@ class TestProviderSecretViewSet:
)
assert response.status_code == status.HTTP_404_NOT_FOUND
- @pytest.mark.parametrize(
- "filter_name, filter_value, expected_count",
- (
- [
- ("name", "aws_testing_1", 1),
- ("name.icontains", "aws", 2),
- ]
- ),
- )
- def test_provider_secrets_filters(
+ def test_provider_secrets_filter_name(
self,
authenticated_client,
provider_secret_fixture,
- filter_name,
- filter_value,
- expected_count,
):
response = authenticated_client.get(
reverse("providersecret-list"),
- {f"filter[{filter_name}]": filter_value},
+ {"filter[name]": "aws_testing_1"},
)
assert response.status_code == status.HTTP_200_OK
- assert len(response.json()["data"]) == expected_count
+ assert len(response.json()["data"]) == 1
+
+ def test_provider_secrets_filter_name_icontains(
+ self,
+ authenticated_client,
+ provider_secret_fixture,
+ provider_factory,
+ ):
+ provider = provider_factory(
+ Provider.ProviderChoices.AWS.value, alias="aws_testing_extra"
+ )
+ ProviderSecret.objects.create(
+ tenant_id=provider.tenant_id,
+ provider=provider,
+ secret_type=ProviderSecret.TypeChoices.STATIC,
+ secret={"key": "value"},
+ name=provider.alias,
+ )
+
+ response = authenticated_client.get(
+ reverse("providersecret-list"),
+ {"filter[name.icontains]": "aws"},
+ )
+
+ assert response.status_code == status.HTTP_200_OK
+ assert len(response.json()["data"]) == 2
@pytest.mark.parametrize(
"filter_name",
@@ -3329,18 +3764,9 @@ class TestProviderSecretViewSet:
assert response.status_code == status.HTTP_400_BAD_REQUEST
def test_m365_provider_secrets_invalid_certificate_base64(
- self, authenticated_client, providers_fixture
+ self, authenticated_client, m365_provider
):
"""Test M365 provider secret creation with invalid base64 certificate content"""
- # Find M365 provider from fixture
- m365_provider = None
- for provider in providers_fixture:
- if provider.provider == Provider.ProviderChoices.M365.value:
- m365_provider = provider
- break
-
- assert m365_provider is not None, "M365 provider not found in fixture"
-
data = {
"data": {
"type": "provider-secrets",
@@ -3398,7 +3824,7 @@ class TestScanViewSet:
assert response.status_code == status.HTTP_404_NOT_FOUND
@pytest.mark.parametrize(
- "scan_json_payload, expected_scanner_args",
+ "scan_json_payload, _expected_scanner_args",
[
# Case 1: No scanner_args in payload (should use provider's scanner_args)
(
@@ -3439,22 +3865,16 @@ class TestScanViewSet:
),
],
)
- @patch("api.v1.views.Task.objects.get")
- @patch("api.v1.views.perform_scan_task.apply_async")
+ @patch("api.v1.views.enqueue_scan_execution_on_commit")
def test_scans_create_valid(
self,
- mock_perform_scan_task,
- mock_task_get,
+ mock_enqueue_scan_execution,
authenticated_client,
scan_json_payload,
- expected_scanner_args,
- providers_fixture,
- tasks_fixture,
+ _expected_scanner_args,
+ okta_provider,
):
- prowler_task = tasks_fixture[0]
- mock_perform_scan_task.return_value.id = prowler_task.id
- mock_task_get.return_value = prowler_task
- *_, provider5 = providers_fixture
+ provider5 = okta_provider
# Provider5 has these scanner_args
# scanner_args={"key1": "value1", "key2": {"key21": "value21"}}
@@ -3478,8 +3898,121 @@ class TestScanViewSet:
assert scan.name == scan_json_payload["data"]["attributes"]["name"]
assert scan.provider == provider5
assert scan.trigger == Scan.TriggerChoices.MANUAL
+ mock_enqueue_scan_execution.assert_called_once()
# assert scan.scanner_args == expected_scanner_args
+ @patch("tasks.tasks.perform_scan_task.apply_async")
+ def test_scans_create_queues_scan_when_provider_has_active_scan(
+ self,
+ mock_perform_scan_task,
+ authenticated_client,
+ aws_provider,
+ tenants_fixture,
+ django_capture_on_commit_callbacks,
+ ):
+ tenant, *_ = tenants_fixture
+ provider = aws_provider
+ task_result = TaskResult.objects.create(
+ task_id=str(uuid4()),
+ task_name="scan-perform",
+ status=states.PENDING,
+ )
+ prowler_task = Task.objects.create(
+ id=task_result.task_id,
+ tenant_id=tenant.id,
+ task_runner_task=task_result,
+ )
+ Scan.objects.create(
+ name="Active scan",
+ provider=provider,
+ trigger=Scan.TriggerChoices.MANUAL,
+ state=StateChoices.AVAILABLE,
+ tenant_id=tenant.id,
+ task=prowler_task,
+ )
+
+ with django_capture_on_commit_callbacks(execute=True):
+ response = authenticated_client.post(
+ reverse("scan-list"),
+ data={
+ "data": {
+ "type": "scans",
+ "attributes": {"name": "Duplicate Scan"},
+ "relationships": {
+ "provider": {
+ "data": {"type": "providers", "id": str(provider.id)}
+ }
+ },
+ }
+ },
+ content_type=API_JSON_CONTENT_TYPE,
+ )
+
+ assert response.status_code == status.HTTP_202_ACCEPTED
+ assert response.json()["data"]["id"] != str(prowler_task.id)
+ assert Scan.objects.count() == 2
+ queued_scan = Scan.objects.exclude(task=prowler_task).get()
+ assert queued_scan.trigger == Scan.TriggerChoices.MANUAL
+ assert queued_scan.state == StateChoices.AVAILABLE
+ assert queued_scan.task.task_runner_task.status == "QUEUED"
+ mock_perform_scan_task.assert_not_called()
+
+ @patch("tasks.tasks.perform_scan_task.apply_async")
+ def test_scans_create_queues_scan_when_scheduled_scan_is_claimed(
+ self,
+ mock_perform_scan_task,
+ authenticated_client,
+ aws_provider,
+ tenants_fixture,
+ django_capture_on_commit_callbacks,
+ ):
+ tenant, *_ = tenants_fixture
+ provider = aws_provider
+ task_result = TaskResult.objects.create(
+ task_id=str(uuid4()),
+ task_name="scan-perform-scheduled",
+ status=states.STARTED,
+ )
+ prowler_task = Task.objects.create(
+ id=task_result.task_id,
+ tenant_id=tenant.id,
+ task_runner_task=task_result,
+ )
+ Scan.objects.create(
+ name="Claimed scheduled scan",
+ provider=provider,
+ trigger=Scan.TriggerChoices.SCHEDULED,
+ state=StateChoices.SCHEDULED,
+ tenant_id=tenant.id,
+ task=prowler_task,
+ )
+
+ with django_capture_on_commit_callbacks(execute=True):
+ response = authenticated_client.post(
+ reverse("scan-list"),
+ data={
+ "data": {
+ "type": "scans",
+ "attributes": {"name": "Manual Scan"},
+ "relationships": {
+ "provider": {
+ "data": {"type": "providers", "id": str(provider.id)}
+ }
+ },
+ }
+ },
+ content_type=API_JSON_CONTENT_TYPE,
+ )
+
+ assert response.status_code == status.HTTP_202_ACCEPTED
+ assert response.json()["data"]["id"] != str(prowler_task.id)
+ assert Scan.objects.count() == 2
+ queued_scan = Scan.objects.exclude(task=prowler_task).get()
+ assert queued_scan.trigger == Scan.TriggerChoices.MANUAL
+ assert queued_scan.state == StateChoices.AVAILABLE
+ assert queued_scan.task.task_runner_task.status == "QUEUED"
+ mock_perform_scan_task.assert_not_called()
+
@pytest.mark.parametrize(
"scan_json_payload, error_code",
[
@@ -3506,10 +4039,10 @@ class TestScanViewSet:
self,
authenticated_client,
scan_json_payload,
- providers_fixture,
+ aws_provider,
error_code,
):
- provider1, *_ = providers_fixture
+ provider1 = aws_provider
scan_json_payload["data"]["relationships"]["provider"]["data"]["id"] = str(
provider1.id
)
@@ -3607,6 +4140,41 @@ class TestScanViewSet:
assert response.status_code == status.HTTP_200_OK
assert len(response.json()["data"]) == expected_count
+ def test_scans_filter_provider_groups(
+ self,
+ authenticated_client,
+ tenants_fixture,
+ scans_fixture,
+ provider_groups_fixture,
+ ):
+ tenant = tenants_fixture[0]
+ scan1, scan2, *_ = scans_fixture
+ group1, group2, *_ = provider_groups_fixture
+ ProviderGroupMembership.objects.create(
+ tenant=tenant, provider=scan1.provider, provider_group=group1
+ )
+ ProviderGroupMembership.objects.create(
+ tenant=tenant, provider=scan1.provider, provider_group=group2
+ )
+ ProviderGroupMembership.objects.create(
+ tenant=tenant, provider=scan2.provider, provider_group=group2
+ )
+
+ response = authenticated_client.get(
+ reverse("scan-list"), {"filter[provider_groups]": str(group1.id)}
+ )
+ assert response.status_code == status.HTTP_200_OK
+ assert {item["id"] for item in response.json()["data"]} == {str(scan1.id)}
+
+ response = authenticated_client.get(
+ reverse("scan-list"),
+ {"filter[provider_groups__in]": f"{group1.id},{group2.id}"},
+ )
+ assert response.status_code == status.HTTP_200_OK
+ scan_ids = {item["id"] for item in response.json()["data"]}
+ assert scan_ids == {str(scan1.id), str(scan2.id), str(scans_fixture[2].id)}
+ assert len(response.json()["data"]) == 3
+
@pytest.mark.parametrize(
"filter_name",
[
@@ -3751,16 +4319,20 @@ class TestScanViewSet:
scan.output_location = "dummy"
scan.save()
- dummy_task = Task.objects.create(tenant_id=scan.tenant_id)
- dummy_task.id = "dummy-task-id"
- dummy_task_data = {"id": dummy_task.id, "state": StateChoices.EXECUTING}
+ task_result = TaskResult.objects.create(
+ task_id=str(uuid4()),
+ task_name="scan-report",
+ task_kwargs={"scan_id": str(scan.id)},
+ )
+ task = Task.objects.create(
+ tenant_id=scan.tenant_id,
+ task_runner_task=task_result,
+ )
+ dummy_task_data = {"id": str(task.id), "state": StateChoices.EXECUTING}
- with (
- patch("api.v1.views.Task.objects.get", return_value=dummy_task),
- patch(
- "api.v1.views.TaskSerializer",
- return_value=type("DummySerializer", (), {"data": dummy_task_data}),
- ),
+ with patch(
+ "api.v1.views.TaskSerializer",
+ return_value=type("DummySerializer", (), {"data": dummy_task_data}),
):
url = reverse("scan-report", kwargs={"pk": scan.id})
response = authenticated_client.get(url)
@@ -3887,7 +4459,7 @@ class TestScanViewSet:
monkeypatch.setattr(
"api.v1.views.env",
- type("env", (), {"str": lambda self, *args, **kwargs: "test-bucket"})(),
+ type("env", (), {"str": lambda self, *_args, **_kwargs: "test-bucket"})(),
)
presigned_url = (
@@ -3993,7 +4565,7 @@ class TestScanViewSet:
monkeypatch.setattr(
"api.v1.views.TaskSerializer",
- lambda *args, **kwargs: type("S", (), {"data": dummy}),
+ lambda *_args, **_kwargs: type("S", (), {"data": dummy}),
)
framework = get_compliance_frameworks(scan.provider.provider)[0]
@@ -4051,7 +4623,7 @@ class TestScanViewSet:
monkeypatch.setattr(
"api.v1.views.env",
- type("env", (), {"str": lambda self, *args, **kwargs: "test-bucket"})(),
+ type("env", (), {"str": lambda self, *_args, **_kwargs: "test-bucket"})(),
)
match_key = "path/compliance/mitre_attack_aws.csv"
@@ -4062,6 +4634,7 @@ class TestScanViewSet:
class FakeS3Client:
def list_objects_v2(self, Bucket, Prefix):
+ del Prefix
return {"Contents": [{"Key": match_key}]}
def generate_presigned_url(self, ClientMethod, Params, ExpiresIn):
@@ -4081,6 +4654,89 @@ class TestScanViewSet:
assert resp.status_code == status.HTTP_302_FOUND
assert resp["Location"] == presigned_url
+ def test_compliance_s3_returns_latest_match(
+ self, authenticated_client, scans_fixture, monkeypatch
+ ):
+ """When several files match, the most recently modified one is served."""
+ scan = scans_fixture[0]
+ bucket = "bucket"
+ scan.output_location = f"s3://{bucket}/path/scan.zip"
+ scan.state = StateChoices.COMPLETED
+ scan.save()
+
+ monkeypatch.setattr(
+ "api.v1.views.env",
+ type("env", (), {"str": lambda self, *_args, **_kwargs: "test-bucket"})(),
+ )
+
+ old_key = "path/compliance/prowler-output-aws-20240101000000_cis_1.4_aws.csv"
+ latest_key = "path/compliance/prowler-output-aws-20240202000000_cis_1.4_aws.csv"
+
+ class FakeS3Client:
+ def list_objects_v2(self, Bucket, Prefix):
+ del Prefix
+ return {
+ "Contents": [
+ {
+ "Key": old_key,
+ "LastModified": datetime(2024, 1, 1, tzinfo=UTC),
+ },
+ {
+ "Key": latest_key,
+ "LastModified": datetime(2024, 2, 2, tzinfo=UTC),
+ },
+ ]
+ }
+
+ def generate_presigned_url(self, ClientMethod, Params, ExpiresIn):
+ assert Params["Key"] == latest_key
+ return "https://test-bucket.s3.amazonaws.com/latest"
+
+ monkeypatch.setattr("api.v1.views.get_s3_client", lambda: FakeS3Client())
+
+ url = reverse("scan-compliance", kwargs={"pk": scan.id, "name": "cis_1.4_aws"})
+ resp = authenticated_client.get(url)
+ assert resp.status_code == status.HTTP_302_FOUND
+ assert resp["Location"].endswith("/latest")
+
+ def test_compliance_local_returns_latest_match(
+ self, authenticated_client, scans_fixture, monkeypatch
+ ):
+ """The local branch serves the most recently modified matching file."""
+ scan = scans_fixture[0]
+ scan.state = StateChoices.COMPLETED
+
+ with tempfile.TemporaryDirectory() as tmp:
+ comp_dir = Path(tmp) / "reports" / "compliance"
+ comp_dir.mkdir(parents=True, exist_ok=True)
+
+ old_file = comp_dir / "prowler-output-aws-20240101000000_cis_1.4_aws.csv"
+ old_file.write_bytes(b"old")
+ latest_file = comp_dir / "prowler-output-aws-20240202000000_cis_1.4_aws.csv"
+ latest_file.write_bytes(b"latest")
+ # Make `latest_file` newer regardless of creation order.
+ os.utime(old_file, (1_700_000_000, 1_700_000_000))
+ os.utime(latest_file, (1_700_000_100, 1_700_000_100))
+
+ scan.output_location = str(Path(tmp) / "reports" / "scan.zip")
+ scan.save()
+
+ monkeypatch.setattr(
+ glob,
+ "glob",
+ lambda p: [str(old_file), str(latest_file)],
+ )
+
+ url = reverse(
+ "scan-compliance", kwargs={"pk": scan.id, "name": "cis_1.4_aws"}
+ )
+ resp = authenticated_client.get(url)
+ assert resp.status_code == status.HTTP_200_OK
+ assert resp.content == b"latest"
+ assert resp["Content-Disposition"].endswith(
+ f'filename="{latest_file.name}"'
+ )
+
def test_compliance_s3_not_found(
self, authenticated_client, scans_fixture, monkeypatch
):
@@ -4092,11 +4748,12 @@ class TestScanViewSet:
monkeypatch.setattr(
"api.v1.views.env",
- type("env", (), {"str": lambda self, *args, **kwargs: "test-bucket"})(),
+ type("env", (), {"str": lambda self, *_args, **_kwargs: "test-bucket"})(),
)
class FakeS3Client:
def list_objects_v2(self, Bucket, Prefix):
+ del Prefix
return {"Contents": []}
def get_object(self, Bucket, Key):
@@ -4189,18 +4846,24 @@ class TestScanViewSet:
assert cd.startswith('attachment; filename="')
assert cd.endswith(f'filename="{fname.name}"')
- @patch("api.v1.views.Task.objects.get")
@patch("api.v1.views.TaskSerializer")
def test__get_task_status_returns_none_if_task_not_executing(
- self, mock_task_serializer, mock_task_get, authenticated_client, scans_fixture
+ self, mock_task_serializer, authenticated_client, scans_fixture
):
scan = scans_fixture[0]
scan.state = StateChoices.COMPLETED
scan.output_location = "dummy"
scan.save()
- task = Task.objects.create(tenant_id=scan.tenant_id)
- mock_task_get.return_value = task
+ task_result = TaskResult.objects.create(
+ task_id=str(uuid4()),
+ task_name="scan-report",
+ task_kwargs={"scan_id": str(scan.id)},
+ )
+ task = Task.objects.create(
+ tenant_id=scan.tenant_id,
+ task_runner_task=task_result,
+ )
mock_task_serializer.return_value.data = {
"id": str(task.id),
"state": StateChoices.COMPLETED,
@@ -4221,6 +4884,7 @@ class TestScanViewSet:
scan.save()
task_result = TaskResult.objects.create(
+ task_id=str(uuid4()),
task_name="scan-report",
task_kwargs={"scan_id": str(scan.id)},
)
@@ -4241,6 +4905,51 @@ class TestScanViewSet:
assert response.status_code == status.HTTP_202_ACCEPTED
assert response.data["id"] == str(task.id)
+ @patch("api.v1.views.TaskSerializer")
+ def test__get_task_status_returns_latest_task(
+ self, mock_task_serializer, authenticated_client, scans_fixture
+ ):
+ """With several scan-report tasks for the scan, the most recent is used."""
+ scan = scans_fixture[0]
+ scan.state = StateChoices.COMPLETED
+ scan.output_location = "dummy"
+ scan.save()
+
+ old_task = Task.objects.create(
+ tenant_id=scan.tenant_id,
+ task_runner_task=TaskResult.objects.create(
+ task_id=str(uuid4()),
+ task_name="scan-report",
+ task_kwargs={"scan_id": str(scan.id)},
+ ),
+ )
+ new_task = Task.objects.create(
+ tenant_id=scan.tenant_id,
+ task_runner_task=TaskResult.objects.create(
+ task_id=str(uuid4()),
+ task_name="scan-report",
+ task_kwargs={"scan_id": str(scan.id)},
+ ),
+ )
+ # `inserted_at` is `auto_now_add`, and within the test transaction the DB
+ # `now()` is constant, so force distinct timestamps to make order_by stable.
+ base = datetime(2024, 1, 1, tzinfo=UTC)
+ Task.objects.filter(pk=old_task.pk).update(inserted_at=base)
+ Task.objects.filter(pk=new_task.pk).update(
+ inserted_at=base + timedelta(hours=1)
+ )
+
+ mock_task_serializer.side_effect = lambda instance, *_a, **_k: SimpleNamespace(
+ data={"id": str(instance.id), "state": StateChoices.EXECUTING}
+ )
+
+ url = reverse("scan-report", kwargs={"pk": scan.id})
+ response = authenticated_client.get(url)
+
+ assert response.status_code == status.HTTP_202_ACCEPTED
+ assert str(new_task.id) in response["Content-Location"]
+ assert str(old_task.id) not in response["Content-Location"]
+
@patch("api.v1.views.get_s3_client")
@patch("api.v1.views.sentry_sdk.capture_exception")
def test_compliance_list_objects_client_error(
@@ -4393,12 +5102,13 @@ class TestAttackPathsScanViewSet:
def test_attack_paths_scans_list_returns_latest_entry_per_provider(
self,
authenticated_client,
- providers_fixture,
+ aws_provider,
scans_fixture,
create_attack_paths_scan,
+ aws_provider_pair,
):
- provider = providers_fixture[0]
- other_provider = providers_fixture[1]
+ provider = aws_provider
+ other_provider = aws_provider_pair[1]
older_scan = create_attack_paths_scan(
provider,
@@ -4438,19 +5148,78 @@ class TestAttackPathsScanViewSet:
assert first_attributes["provider_type"] == provider.provider
assert first_attributes["provider_uid"] == provider.uid
+ def test_attack_paths_scans_list_prefers_active_sink_scan_on_rollback(
+ self,
+ authenticated_client,
+ aws_provider,
+ scans_fixture,
+ create_attack_paths_scan,
+ settings,
+ ):
+ settings.ATTACK_PATHS_SINK_DATABASE = "neo4j"
+ provider = aws_provider
+
+ neo4j_scan = create_attack_paths_scan(
+ provider,
+ scan=scans_fixture[0],
+ state=StateChoices.COMPLETED,
+ graph_data_ready=True,
+ sink_backend="neo4j",
+ )
+ neptune_scan = create_attack_paths_scan(
+ provider,
+ scan=scans_fixture[0],
+ state=StateChoices.COMPLETED,
+ graph_data_ready=True,
+ sink_backend="neptune",
+ )
+
+ response = authenticated_client.get(reverse("attack-paths-scans-list"))
+
+ assert response.status_code == status.HTTP_200_OK
+ ids = {item["id"] for item in response.json()["data"]}
+ assert str(neo4j_scan.id) in ids
+ assert str(neptune_scan.id) not in ids
+
+ def test_attack_paths_scans_list_falls_back_when_active_sink_has_no_scan(
+ self,
+ authenticated_client,
+ aws_provider,
+ scans_fixture,
+ create_attack_paths_scan,
+ settings,
+ ):
+ settings.ATTACK_PATHS_SINK_DATABASE = "neptune"
+ provider = aws_provider
+
+ legacy_scan = create_attack_paths_scan(
+ provider,
+ scan=scans_fixture[0],
+ state=StateChoices.COMPLETED,
+ graph_data_ready=True,
+ sink_backend="neo4j",
+ )
+
+ response = authenticated_client.get(reverse("attack-paths-scans-list"))
+
+ assert response.status_code == status.HTTP_200_OK
+ ids = {item["id"] for item in response.json()["data"]}
+ assert str(legacy_scan.id) in ids
+
def test_attack_paths_scans_list_respects_provider_group_visibility(
self,
authenticated_client_no_permissions_rbac,
- providers_fixture,
+ aws_provider,
create_attack_paths_scan,
+ aws_provider_pair,
):
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_provider = aws_provider
+ denied_provider = aws_provider_pair[1]
allowed_scan = create_attack_paths_scan(allowed_provider)
create_attack_paths_scan(denied_provider)
@@ -4481,11 +5250,11 @@ class TestAttackPathsScanViewSet:
def test_attack_paths_scan_retrieve(
self,
authenticated_client,
- providers_fixture,
+ aws_provider,
scans_fixture,
create_attack_paths_scan,
):
- provider = providers_fixture[0]
+ provider = aws_provider
attack_paths_scan = create_attack_paths_scan(
provider,
scan=scans_fixture[0],
@@ -4524,11 +5293,11 @@ class TestAttackPathsScanViewSet:
def test_attack_paths_queries_returns_catalog(
self,
authenticated_client,
- providers_fixture,
+ aws_provider,
scans_fixture,
create_attack_paths_scan,
):
- provider = providers_fixture[0]
+ provider = aws_provider
attack_paths_scan = create_attack_paths_scan(
provider,
scan=scans_fixture[0],
@@ -4558,7 +5327,8 @@ class TestAttackPathsScanViewSet:
)
assert response.status_code == status.HTTP_200_OK
- mock_get_queries.assert_called_once_with(provider.provider)
+ # TODO: drop the is_migrated argument after Neptune cutover
+ mock_get_queries.assert_called_once_with(provider.provider, is_migrated=False)
payload = response.json()["data"]
assert len(payload) == 1
assert payload[0]["id"] == "aws-rds"
@@ -4568,11 +5338,11 @@ class TestAttackPathsScanViewSet:
def test_attack_paths_queries_returns_404_when_catalog_missing(
self,
authenticated_client,
- providers_fixture,
+ aws_provider,
scans_fixture,
create_attack_paths_scan,
):
- provider = providers_fixture[0]
+ provider = aws_provider
attack_paths_scan = create_attack_paths_scan(provider, scan=scans_fixture[0])
with patch("api.v1.views.get_queries_for_provider", return_value=[]):
@@ -4588,11 +5358,11 @@ class TestAttackPathsScanViewSet:
def test_run_attack_paths_query_returns_graph(
self,
authenticated_client,
- providers_fixture,
+ aws_provider,
scans_fixture,
create_attack_paths_scan,
):
- provider = providers_fixture[0]
+ provider = aws_provider
attack_paths_scan = create_attack_paths_scan(
provider,
scan=scans_fixture[0],
@@ -4658,7 +5428,8 @@ class TestAttackPathsScanViewSet:
)
assert response.status_code == status.HTTP_200_OK
- mock_get_query.assert_called_once_with("aws-rds")
+ # TODO: drop the is_migrated argument after Neptune cutover
+ mock_get_query.assert_called_once_with("aws-rds", is_migrated=False)
mock_get_db_name.assert_called_once_with(attack_paths_scan.provider.tenant_id)
provider_id = str(attack_paths_scan.provider_id)
mock_prepare.assert_called_once_with(
@@ -4672,6 +5443,7 @@ class TestAttackPathsScanViewSet:
query_definition,
prepared_parameters,
provider_id,
+ scan=attack_paths_scan,
)
result = response.json()["data"]
attributes = result["attributes"]
@@ -4681,11 +5453,11 @@ class TestAttackPathsScanViewSet:
def test_run_attack_paths_query_returns_text_when_accept_text_plain(
self,
authenticated_client,
- providers_fixture,
+ aws_provider,
scans_fixture,
create_attack_paths_scan,
):
- provider = providers_fixture[0]
+ provider = aws_provider
attack_paths_scan = create_attack_paths_scan(
provider,
scan=scans_fixture[0],
@@ -4748,11 +5520,11 @@ class TestAttackPathsScanViewSet:
def test_run_attack_paths_query_blocks_when_graph_data_not_ready(
self,
authenticated_client,
- providers_fixture,
+ aws_provider,
scans_fixture,
create_attack_paths_scan,
):
- provider = providers_fixture[0]
+ provider = aws_provider
attack_paths_scan = create_attack_paths_scan(
provider,
scan=scans_fixture[0],
@@ -4774,11 +5546,11 @@ class TestAttackPathsScanViewSet:
def test_run_attack_paths_query_allows_executing_scan_when_graph_data_ready(
self,
authenticated_client,
- providers_fixture,
+ aws_provider,
scans_fixture,
create_attack_paths_scan,
):
- provider = providers_fixture[0]
+ provider = aws_provider
attack_paths_scan = create_attack_paths_scan(
provider,
scan=scans_fixture[0],
@@ -4828,11 +5600,11 @@ class TestAttackPathsScanViewSet:
def test_run_attack_paths_query_allows_failed_scan_when_graph_data_ready(
self,
authenticated_client,
- providers_fixture,
+ aws_provider,
scans_fixture,
create_attack_paths_scan,
):
- provider = providers_fixture[0]
+ provider = aws_provider
attack_paths_scan = create_attack_paths_scan(
provider,
scan=scans_fixture[0],
@@ -4882,11 +5654,11 @@ class TestAttackPathsScanViewSet:
def test_run_attack_paths_query_unknown_query(
self,
authenticated_client,
- providers_fixture,
+ aws_provider,
scans_fixture,
create_attack_paths_scan,
):
- provider = providers_fixture[0]
+ provider = aws_provider
attack_paths_scan = create_attack_paths_scan(
provider,
scan=scans_fixture[0],
@@ -4909,11 +5681,11 @@ class TestAttackPathsScanViewSet:
def test_run_attack_paths_query_returns_404_when_no_nodes_found(
self,
authenticated_client,
- providers_fixture,
+ aws_provider,
scans_fixture,
create_attack_paths_scan,
):
- provider = providers_fixture[0]
+ provider = aws_provider
attack_paths_scan = create_attack_paths_scan(
provider,
scan=scans_fixture[0],
@@ -4976,11 +5748,11 @@ class TestAttackPathsScanViewSet:
def test_run_custom_query_returns_graph(
self,
authenticated_client,
- providers_fixture,
+ aws_provider,
scans_fixture,
create_attack_paths_scan,
):
- provider = providers_fixture[0]
+ provider = aws_provider
attack_paths_scan = create_attack_paths_scan(
provider,
scan=scans_fixture[0],
@@ -5023,6 +5795,7 @@ class TestAttackPathsScanViewSet:
"db-test",
"MATCH (n) RETURN n",
str(attack_paths_scan.provider_id),
+ scan=attack_paths_scan,
)
attributes = response.json()["data"]["attributes"]
assert len(attributes["nodes"]) == 1
@@ -5032,11 +5805,11 @@ class TestAttackPathsScanViewSet:
def test_run_custom_query_returns_text_when_accept_text_plain(
self,
authenticated_client,
- providers_fixture,
+ aws_provider,
scans_fixture,
create_attack_paths_scan,
):
- provider = providers_fixture[0]
+ provider = aws_provider
attack_paths_scan = create_attack_paths_scan(
provider,
scan=scans_fixture[0],
@@ -5085,11 +5858,11 @@ class TestAttackPathsScanViewSet:
def test_run_custom_query_returns_404_when_no_nodes(
self,
authenticated_client,
- providers_fixture,
+ aws_provider,
scans_fixture,
create_attack_paths_scan,
):
- provider = providers_fixture[0]
+ provider = aws_provider
attack_paths_scan = create_attack_paths_scan(
provider,
scan=scans_fixture[0],
@@ -5125,11 +5898,11 @@ class TestAttackPathsScanViewSet:
def test_run_custom_query_returns_400_when_graph_not_ready(
self,
authenticated_client,
- providers_fixture,
+ aws_provider,
scans_fixture,
create_attack_paths_scan,
):
- provider = providers_fixture[0]
+ provider = aws_provider
attack_paths_scan = create_attack_paths_scan(
provider,
scan=scans_fixture[0],
@@ -5151,11 +5924,11 @@ class TestAttackPathsScanViewSet:
def test_run_custom_query_returns_403_for_write_query(
self,
authenticated_client,
- providers_fixture,
+ aws_provider,
scans_fixture,
create_attack_paths_scan,
):
- provider = providers_fixture[0]
+ provider = aws_provider
attack_paths_scan = create_attack_paths_scan(
provider,
scan=scans_fixture[0],
@@ -5209,12 +5982,12 @@ class TestAttackPathsScanViewSet:
def test_run_custom_query_rejects_ssrf_patterns(
self,
authenticated_client,
- providers_fixture,
+ aws_provider,
scans_fixture,
create_attack_paths_scan,
cypher,
):
- provider = providers_fixture[0]
+ provider = aws_provider
attack_paths_scan = create_attack_paths_scan(
provider,
scan=scans_fixture[0],
@@ -5303,13 +6076,13 @@ class TestAttackPathsScanViewSet:
def test_run_custom_query_returns_401_unauthenticated(
self,
- providers_fixture,
+ aws_provider,
scans_fixture,
create_attack_paths_scan,
):
from rest_framework.test import APIClient
- provider = providers_fixture[0]
+ provider = aws_provider
attack_paths_scan = create_attack_paths_scan(
provider,
scan=scans_fixture[0],
@@ -5330,13 +6103,13 @@ class TestAttackPathsScanViewSet:
def test_cartography_schema_returns_401_unauthenticated(
self,
- providers_fixture,
+ aws_provider,
scans_fixture,
create_attack_paths_scan,
):
from rest_framework.test import APIClient
- provider = providers_fixture[0]
+ provider = aws_provider
attack_paths_scan = create_attack_paths_scan(
provider,
scan=scans_fixture[0],
@@ -5356,11 +6129,11 @@ class TestAttackPathsScanViewSet:
def test_run_custom_query_returns_403_no_manage_scans(
self,
authenticated_client_no_permissions_rbac,
- providers_fixture,
+ aws_provider,
scans_fixture,
create_attack_paths_scan,
):
- provider = providers_fixture[0]
+ provider = aws_provider
attack_paths_scan = create_attack_paths_scan(
provider,
scan=scans_fixture[0],
@@ -5383,13 +6156,13 @@ class TestAttackPathsScanViewSet:
def test_run_custom_query_does_not_leak_internals_on_error(
self,
authenticated_client,
- providers_fixture,
+ aws_provider,
scans_fixture,
create_attack_paths_scan,
):
from rest_framework.exceptions import APIException
- provider = providers_fixture[0]
+ provider = aws_provider
attack_paths_scan = create_attack_paths_scan(
provider,
scan=scans_fixture[0],
@@ -5427,11 +6200,11 @@ class TestAttackPathsScanViewSet:
def test_run_custom_query_throttled_after_limit(
self,
authenticated_client,
- providers_fixture,
+ aws_provider,
scans_fixture,
create_attack_paths_scan,
):
- provider = providers_fixture[0]
+ provider = aws_provider
attack_paths_scan = create_attack_paths_scan(
provider,
scan=scans_fixture[0],
@@ -5468,26 +6241,26 @@ class TestAttackPathsScanViewSet:
content_type=API_JSON_CONTENT_TYPE,
)
if i < 10:
- assert (
- response.status_code == status.HTTP_200_OK
- ), f"Request {i + 1} should succeed with 200 OK, got {response.status_code}"
+ assert response.status_code == status.HTTP_200_OK, (
+ f"Request {i + 1} should succeed with 200 OK, got {response.status_code}"
+ )
else:
- assert (
- response.status_code == status.HTTP_429_TOO_MANY_REQUESTS
- ), f"Request {i + 1} should be throttled"
+ assert response.status_code == status.HTTP_429_TOO_MANY_REQUESTS, (
+ f"Request {i + 1} should be throttled"
+ )
# -- Timeout simulation -------------------------------------------------------
def test_run_custom_query_returns_500_on_database_timeout(
self,
authenticated_client,
- providers_fixture,
+ aws_provider,
scans_fixture,
create_attack_paths_scan,
):
from rest_framework.exceptions import APIException
- provider = providers_fixture[0]
+ provider = aws_provider
attack_paths_scan = create_attack_paths_scan(
provider,
scan=scans_fixture[0],
@@ -5522,11 +6295,11 @@ class TestAttackPathsScanViewSet:
def test_cartography_schema_returns_urls(
self,
authenticated_client,
- providers_fixture,
+ aws_provider,
scans_fixture,
create_attack_paths_scan,
):
- provider = providers_fixture[0]
+ provider = aws_provider
attack_paths_scan = create_attack_paths_scan(
provider,
scan=scans_fixture[0],
@@ -5559,9 +6332,10 @@ class TestAttackPathsScanViewSet:
)
assert response.status_code == status.HTTP_200_OK
- mock_get_schema.assert_called_once_with(
- "db-test", str(attack_paths_scan.provider_id)
- )
+ mock_get_schema.assert_called_once()
+ schema_args = mock_get_schema.call_args[0]
+ assert schema_args[:2] == ("db-test", str(attack_paths_scan.provider_id))
+ assert schema_args[2].id == attack_paths_scan.id
attributes = response.json()["data"]["attributes"]
assert attributes["provider"] == "aws"
assert attributes["cartography_version"] == "0.129.0"
@@ -5571,11 +6345,11 @@ class TestAttackPathsScanViewSet:
def test_cartography_schema_returns_404_when_no_metadata(
self,
authenticated_client,
- providers_fixture,
+ aws_provider,
scans_fixture,
create_attack_paths_scan,
):
- provider = providers_fixture[0]
+ provider = aws_provider
attack_paths_scan = create_attack_paths_scan(
provider,
scan=scans_fixture[0],
@@ -5605,11 +6379,11 @@ class TestAttackPathsScanViewSet:
def test_cartography_schema_returns_400_when_graph_not_ready(
self,
authenticated_client,
- providers_fixture,
+ aws_provider,
scans_fixture,
create_attack_paths_scan,
):
- provider = providers_fixture[0]
+ provider = aws_provider
attack_paths_scan = create_attack_paths_scan(
provider,
scan=scans_fixture[0],
@@ -5677,9 +6451,9 @@ class TestResourceViewSet:
included_data = response.json()["included"]
for expected_type in expected_resources:
- assert any(
- d.get("type") == expected_type for d in included_data
- ), f"Expected type '{expected_type}' not found in included data"
+ assert any(d.get("type") == expected_type for d in included_data), (
+ f"Expected type '{expected_type}' not found in included data"
+ )
@pytest.mark.parametrize(
"filter_name, filter_value, expected_count",
@@ -5750,6 +6524,49 @@ class TestResourceViewSet:
assert response.status_code == status.HTTP_200_OK
assert len(response.json()["data"]) == expected_count
+ def test_resource_filter_provider_groups(
+ self,
+ authenticated_client,
+ tenants_fixture,
+ resources_fixture,
+ provider_groups_fixture,
+ ):
+ tenant = tenants_fixture[0]
+ resource1, resource2, resource3, *_ = resources_fixture
+ group1, group2, *_ = provider_groups_fixture
+ ProviderGroupMembership.objects.create(
+ tenant=tenant, provider=resource1.provider, provider_group=group1
+ )
+ ProviderGroupMembership.objects.create(
+ tenant=tenant, provider=resource1.provider, provider_group=group2
+ )
+ ProviderGroupMembership.objects.create(
+ tenant=tenant, provider=resource3.provider, provider_group=group2
+ )
+
+ response = authenticated_client.get(
+ reverse("resource-list"),
+ {"filter[updated_at]": TODAY, "filter[provider_groups]": str(group1.id)},
+ )
+ assert response.status_code == status.HTTP_200_OK
+ assert len(response.json()["data"]) == 2
+ assert {item["id"] for item in response.json()["data"]} == {
+ str(resource1.id),
+ str(resource2.id),
+ }
+
+ response = authenticated_client.get(
+ reverse("resource-list"),
+ {
+ "filter[updated_at]": TODAY,
+ "filter[provider_groups__in]": f"{group1.id},{group2.id}",
+ },
+ )
+ assert response.status_code == status.HTTP_200_OK
+ resource_ids = {item["id"] for item in response.json()["data"]}
+ assert resource_ids == {str(resource1.id), str(resource2.id), str(resource3.id)}
+ assert len(response.json()["data"]) == 3
+
def test_resource_filter_by_scan_id(
self, authenticated_client, resources_fixture, scans_fixture
):
@@ -5856,9 +6673,8 @@ class TestResourceViewSet:
)
assert response.status_code == status.HTTP_404_NOT_FOUND
- def test_resources_metadata_retrieve(
- self, authenticated_client, resources_fixture, backfill_scan_metadata_fixture
- ):
+ @pytest.mark.usefixtures("backfill_scan_metadata_fixture")
+ def test_resources_metadata_retrieve(self, authenticated_client, resources_fixture):
resource_1, *_ = resources_fixture
response = authenticated_client.get(
reverse("resource-metadata"),
@@ -5878,8 +6694,9 @@ class TestResourceViewSet:
assert set(data["data"]["attributes"]["types"]) == expected_resource_types
assert set(data["data"]["attributes"]["groups"]) == expected_groups
+ @pytest.mark.usefixtures("backfill_scan_metadata_fixture")
def test_resources_metadata_resource_filter_retrieve(
- self, authenticated_client, resources_fixture, backfill_scan_metadata_fixture
+ self, authenticated_client, resources_fixture
):
resource_1, *_ = resources_fixture
response = authenticated_client.get(
@@ -5985,10 +6802,13 @@ class TestResourceViewSet:
)
def test_resources_latest_filter_by_provider_id_in_multiple(
- self, authenticated_client, providers_fixture
+ self,
+ authenticated_client,
+ aws_provider,
+ aws_provider_pair,
):
"""Test that provider_id__in filter works with multiple provider IDs."""
- provider1, provider2 = providers_fixture[0], providers_fixture[1]
+ provider1, provider2 = aws_provider, aws_provider_pair[1]
tenant_id = str(provider1.tenant_id)
# Create completed scans for both providers
@@ -6057,12 +6877,14 @@ class TestResourceViewSet:
assert len(response.json()["data"]) == 0
# Events endpoint tests
- def test_events_non_aws_provider(self, authenticated_client, providers_fixture):
+ def test_events_non_aws_provider(
+ self,
+ authenticated_client,
+ azure_provider,
+ ):
"""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",
@@ -6098,7 +6920,7 @@ class TestResourceViewSet:
def test_events_invalid_lookback_days(
self,
authenticated_client,
- providers_fixture,
+ aws_provider,
lookback_days,
expected_status,
expected_code,
@@ -6107,8 +6929,6 @@ class TestResourceViewSet:
"""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",
@@ -6145,7 +6965,7 @@ class TestResourceViewSet:
def test_events_invalid_page_size(
self,
authenticated_client,
- providers_fixture,
+ aws_provider,
page_size,
expected_status,
expected_code,
@@ -6154,8 +6974,6 @@ class TestResourceViewSet:
"""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",
@@ -6193,15 +7011,13 @@ class TestResourceViewSet:
def test_events_invalid_query_parameter(
self,
authenticated_client,
- providers_fixture,
+ aws_provider,
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",
@@ -6228,9 +7044,9 @@ class TestResourceViewSet:
(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 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"]
@@ -6238,13 +7054,11 @@ class TestResourceViewSet:
def test_events_multiple_invalid_query_parameters(
self,
authenticated_client,
- providers_fixture,
+ aws_provider,
):
"""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",
@@ -6281,13 +7095,11 @@ class TestResourceViewSet:
mock_cloudtrail_timeline,
mock_initialize_provider,
authenticated_client,
- providers_fixture,
+ aws_provider,
):
"""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",
@@ -6373,13 +7185,11 @@ class TestResourceViewSet:
mock_cloudtrail_timeline,
mock_initialize_provider,
authenticated_client,
- providers_fixture,
+ aws_provider,
):
"""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",
@@ -6417,13 +7227,14 @@ class TestResourceViewSet:
@patch("api.v1.views.initialize_prowler_provider")
def test_events_no_credentials_error(
- self, mock_initialize_provider, authenticated_client, providers_fixture
+ self,
+ mock_initialize_provider,
+ authenticated_client,
+ aws_provider,
):
"""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",
@@ -6456,13 +7267,11 @@ class TestResourceViewSet:
mock_cloudtrail_timeline,
mock_initialize_provider,
authenticated_client,
- providers_fixture,
+ aws_provider,
):
"""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",
@@ -6507,13 +7316,11 @@ class TestResourceViewSet:
mock_cloudtrail_timeline,
mock_initialize_provider,
authenticated_client,
- providers_fixture,
+ aws_provider,
):
"""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",
@@ -6556,7 +7363,7 @@ class TestResourceViewSet:
self,
mock_initialize_provider,
authenticated_client,
- providers_fixture,
+ aws_provider,
):
"""Test events handles AWSAssumeRoleError during provider init.
@@ -6568,8 +7375,6 @@ class TestResourceViewSet:
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",
@@ -6613,17 +7418,14 @@ class TestResourceViewSet:
assert error["status"] == "502"
assert "detail" in error
- def test_events_unauthenticated_returns_401(self, providers_fixture):
+ def test_events_unauthenticated_returns_401(self, aws_provider):
"""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
+ from rest_framework.test import APIClient
resource = Resource.objects.create(
uid="arn:aws:ec2:us-east-1:123456789012:instance/i-unauth-test",
@@ -6687,18 +7489,15 @@ class TestResourceViewSet:
# 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):
+ def test_events_expired_token_returns_401(self, aws_provider, 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]
+ from rest_framework.test import APIClient
resource = Resource.objects.create(
uid="arn:aws:ec2:us-east-1:123456789012:instance/i-expired-test",
@@ -6714,9 +7513,8 @@ class TestResourceViewSet:
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),
+ "exp": datetime.now(UTC) - timedelta(hours=1), # Expired 1 hour ago
+ "iat": datetime.now(UTC) - timedelta(hours=2),
"jti": str(uuid4()),
"user_id": str(uuid4()),
"tenant_id": str(tenant.id),
@@ -6736,16 +7534,13 @@ class TestResourceViewSet:
"Expired tokens should return 401, not 404."
)
- def test_events_invalid_token_returns_401(self, providers_fixture):
+ def test_events_invalid_token_returns_401(self, aws_provider):
"""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]
+ from rest_framework.test import APIClient
resource = Resource.objects.create(
uid="arn:aws:ec2:us-east-1:123456789012:instance/i-invalid-test",
@@ -6762,16 +7557,16 @@ class TestResourceViewSet:
# 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}"
+ 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}"
+ assert response.status_code == status.HTTP_401_UNAUTHORIZED, (
+ f"Expected 401 for empty bearer token but got {response.status_code}"
+ )
@pytest.mark.django_db
@@ -6800,6 +7595,26 @@ class TestFindingViewSet:
assert response.status_code == status.HTTP_400_BAD_REQUEST
assert response.json()["errors"][0]["code"] == "invalid"
+ def test_findings_updated_at_range_too_large_with_inserted_at_filter(
+ self, authenticated_client
+ ):
+ response = authenticated_client.get(
+ reverse("finding-list"),
+ {
+ "filter[inserted_at]": TODAY,
+ "filter[updated_at.gte]": today_after_n_days(
+ -(settings.FINDINGS_MAX_DAYS_IN_RANGE + 1)
+ ),
+ "filter[updated_at.lte]": TODAY,
+ },
+ )
+
+ assert response.status_code == status.HTTP_400_BAD_REQUEST
+ assert response.json()["errors"][0]["code"] == "invalid"
+ assert response.json()["errors"][0]["source"]["pointer"] == (
+ "/data/attributes/updated_at"
+ )
+
def test_findings_list(self, authenticated_client, findings_fixture):
response = authenticated_client.get(
reverse("finding-list"), {"filter[inserted_at]": TODAY}
@@ -6811,6 +7626,244 @@ class TestFindingViewSet:
== findings_fixture[0].status
)
+ def test_findings_list_inserted_at_accepts_timestamp_precision_filters(
+ self, authenticated_client, scans_fixture
+ ):
+ scan, *_ = scans_fixture
+
+ def create_finding(uid, inserted_at):
+ finding = Finding.objects.create(
+ id=datetime_to_uuid7(inserted_at),
+ tenant_id=scan.tenant_id,
+ uid=uid,
+ scan=scan,
+ status=Status.FAIL,
+ status_extended="timestamp precision status",
+ impact=Severity.medium,
+ severity=Severity.medium,
+ check_id="timestamp_precision_check",
+ check_metadata={
+ "CheckId": "timestamp_precision_check",
+ "Description": "timestamp precision check",
+ "servicename": "ec2",
+ },
+ first_seen_at=inserted_at,
+ )
+ Finding.all_objects.filter(pk=finding.pk).update(
+ inserted_at=inserted_at,
+ updated_at=inserted_at,
+ )
+ finding.refresh_from_db()
+ return finding
+
+ create_finding(
+ "timestamp_precision_early",
+ datetime(2026, 1, 15, 10, 30, 0, 100000, tzinfo=UTC),
+ )
+ late_finding = create_finding(
+ "timestamp_precision_late",
+ datetime(2026, 1, 15, 10, 30, 0, 200000, tzinfo=UTC),
+ )
+
+ response = authenticated_client.get(
+ reverse("finding-list"),
+ {
+ "filter[inserted_at.gte]": "2026-01-15T10:30:00.150Z",
+ "filter[inserted_at.lte]": "2026-01-15T10:30:00.250Z",
+ },
+ )
+
+ assert response.status_code == status.HTTP_200_OK
+ returned_uids = {
+ finding["attributes"]["uid"] for finding in response.json()["data"]
+ }
+ assert returned_uids == {late_finding.uid}
+
+ response = authenticated_client.get(
+ reverse("finding-list"),
+ {"filter[inserted_at]": "2026-01-15T10:30:00.200Z"},
+ )
+
+ assert response.status_code == status.HTTP_200_OK
+ returned_uids = {
+ finding["attributes"]["uid"] for finding in response.json()["data"]
+ }
+ assert returned_uids == {late_finding.uid}
+
+ def test_findings_list_updated_at_accepts_timestamp_precision_filters(
+ self, authenticated_client, findings_fixture
+ ):
+ early_finding, late_finding, *_ = findings_fixture
+ early_updated_at = datetime(2026, 1, 15, 10, 30, 0, 100000, tzinfo=UTC)
+ late_updated_at = datetime(2026, 1, 15, 10, 30, 0, 200000, tzinfo=UTC)
+ Finding.all_objects.filter(pk=early_finding.pk).update(
+ updated_at=early_updated_at
+ )
+ Finding.all_objects.filter(pk=late_finding.pk).update(
+ updated_at=late_updated_at
+ )
+
+ response = authenticated_client.get(
+ reverse("finding-list"),
+ {
+ "filter[updated_at.gte]": "2026-01-15T10:30:00.150Z",
+ "filter[updated_at.lte]": "2026-01-15T10:30:00.250Z",
+ },
+ )
+
+ assert response.status_code == status.HTTP_200_OK
+ returned_uids = {
+ finding["attributes"]["uid"] for finding in response.json()["data"]
+ }
+ assert returned_uids == {late_finding.uid}
+
+ response = authenticated_client.get(
+ reverse("finding-list"),
+ {"filter[updated_at]": "2026-01-15T10:30:00.200Z"},
+ )
+
+ assert response.status_code == status.HTTP_200_OK
+ returned_uids = {
+ finding["attributes"]["uid"] for finding in response.json()["data"]
+ }
+ assert returned_uids == {late_finding.uid}
+
+ def test_findings_list_inserted_at_and_updated_at_filters_are_combined(
+ self, authenticated_client, scans_fixture
+ ):
+ scan, *_ = scans_fixture
+
+ def create_finding(uid, inserted_at, updated_at):
+ finding = Finding.objects.create(
+ id=datetime_to_uuid7(inserted_at),
+ tenant_id=scan.tenant_id,
+ uid=uid,
+ scan=scan,
+ status=Status.FAIL,
+ status_extended="timestamp precision status",
+ impact=Severity.medium,
+ severity=Severity.medium,
+ check_id="timestamp_precision_check",
+ check_metadata={
+ "CheckId": "timestamp_precision_check",
+ "Description": "timestamp precision check",
+ "servicename": "ec2",
+ },
+ first_seen_at=inserted_at,
+ )
+ Finding.all_objects.filter(pk=finding.pk).update(
+ inserted_at=inserted_at,
+ updated_at=updated_at,
+ )
+ finding.refresh_from_db()
+ return finding
+
+ matching_finding = create_finding(
+ "timestamp_precision_combined_match",
+ datetime(2026, 1, 15, 10, 30, 0, 200000, tzinfo=UTC),
+ datetime(2026, 1, 15, 11, 30, 0, 200000, tzinfo=UTC),
+ )
+ create_finding(
+ "timestamp_precision_combined_inserted_only",
+ datetime(2026, 1, 15, 10, 30, 0, 200000, tzinfo=UTC),
+ datetime(2026, 1, 15, 12, 30, 0, 200000, tzinfo=UTC),
+ )
+ create_finding(
+ "timestamp_precision_combined_updated_only",
+ datetime(2026, 1, 15, 9, 30, 0, 200000, tzinfo=UTC),
+ datetime(2026, 1, 15, 11, 30, 0, 200000, tzinfo=UTC),
+ )
+
+ response = authenticated_client.get(
+ reverse("finding-list"),
+ {
+ "filter[inserted_at.gte]": "2026-01-15T10:30:00.150Z",
+ "filter[inserted_at.lte]": "2026-01-15T10:30:00.250Z",
+ "filter[updated_at.gte]": "2026-01-15T11:30:00.150Z",
+ "filter[updated_at.lte]": "2026-01-15T11:30:00.250Z",
+ },
+ )
+
+ assert response.status_code == status.HTTP_200_OK
+ returned_uids = {
+ finding["attributes"]["uid"] for finding in response.json()["data"]
+ }
+ assert returned_uids == {matching_finding.uid}
+
+ def test_findings_list_resource_tags_no_n_plus_one(
+ self, authenticated_client, findings_fixture
+ ):
+ """Listing findings must load every resource's tags in a constant
+ number of queries, no matter how many findings/resources are returned.
+
+ This guards ``FindingViewSet._optimize_tags_loading`` against
+ regressions that would reintroduce one extra query per resource (the
+ N+1 the prefetch was added to remove).
+ """
+ scan = findings_fixture[0].scan
+ tenant_id = findings_fixture[0].tenant_id
+ provider = scan.provider
+
+ def _create_finding_with_tagged_resource(index):
+ resource = Resource.objects.create(
+ tenant_id=tenant_id,
+ provider=provider,
+ uid=f"arn:aws:ec2:us-east-1:123456789012:instance/n-plus-one-{index}",
+ name=f"N+1 Instance {index}",
+ region="us-east-1",
+ service="ec2",
+ type="prowler-test",
+ )
+ resource.upsert_or_delete_tags(
+ [
+ ResourceTag.objects.create(
+ tenant_id=tenant_id,
+ key=f"key-{index}",
+ value=f"value-{index}",
+ )
+ ]
+ )
+ finding = Finding.objects.create(
+ tenant_id=tenant_id,
+ uid=f"n_plus_one_finding_{index}",
+ scan=scan,
+ status=Status.FAIL,
+ status_extended="n+1 status",
+ impact=Severity.medium,
+ severity=Severity.medium,
+ check_id="test_check_id",
+ check_metadata={"CheckId": "test_check_id", "servicename": "ec2"},
+ first_seen_at="2024-01-02T00:00:00Z",
+ )
+ finding.add_resources([resource])
+ return finding
+
+ params = {"filter[inserted_at]": TODAY, "include": "resources"}
+
+ # Baseline: the two findings provided by the fixture.
+ with CaptureQueriesContext(connection) as baseline:
+ response = authenticated_client.get(reverse("finding-list"), params)
+ assert response.status_code == status.HTTP_200_OK
+
+ # Add more findings, each with its own resource carrying tags.
+ extra_findings = 5
+ for index in range(extra_findings):
+ _create_finding_with_tagged_resource(index)
+
+ with CaptureQueriesContext(connection) as scaled:
+ response = authenticated_client.get(reverse("finding-list"), params)
+ assert response.status_code == status.HTTP_200_OK
+ assert len(response.json()["data"]) == len(findings_fixture) + extra_findings
+
+ # The query count must not grow with the number of findings/resources.
+ assert len(scaled.captured_queries) == len(baseline.captured_queries), (
+ "Resource tags are not being prefetched: "
+ f"{len(baseline.captured_queries)} queries for {len(findings_fixture)} "
+ f"findings vs {len(scaled.captured_queries)} for "
+ f"{len(findings_fixture) + extra_findings}. Likely an N+1 regression "
+ "in FindingViewSet._optimize_tags_loading."
+ )
+
@pytest.mark.parametrize(
"include_values, expected_resources",
[
@@ -6832,9 +7885,9 @@ class TestFindingViewSet:
included_data = response.json()["included"]
for expected_type in expected_resources:
- assert any(
- d.get("type") == expected_type for d in included_data
- ), f"Expected type '{expected_type}' not found in included data"
+ assert any(d.get("type") == expected_type for d in included_data), (
+ f"Expected type '{expected_type}' not found in included data"
+ )
@pytest.mark.parametrize(
"filter_name, filter_value, expected_count",
@@ -6988,6 +8041,40 @@ class TestFindingViewSet:
assert response.status_code == status.HTTP_200_OK
assert len(response.json()["data"]) == 2
+ def test_finding_filter_provider_groups(
+ self,
+ authenticated_client,
+ tenants_fixture,
+ findings_fixture,
+ provider_groups_fixture,
+ ):
+ tenant = tenants_fixture[0]
+ finding1, finding2, *_ = findings_fixture
+ group1, group2, *_ = provider_groups_fixture
+ ProviderGroupMembership.objects.create(
+ tenant=tenant, provider=finding1.scan.provider, provider_group=group1
+ )
+ ProviderGroupMembership.objects.create(
+ tenant=tenant, provider=finding1.scan.provider, provider_group=group2
+ )
+
+ response = authenticated_client.get(
+ reverse("finding-list"),
+ {"filter[inserted_at]": TODAY, "filter[provider_groups]": str(group1.id)},
+ )
+ assert response.status_code == status.HTTP_200_OK
+ assert len(response.json()["data"]) == 2
+
+ response = authenticated_client.get(
+ reverse("finding-list"),
+ {
+ "filter[inserted_at]": TODAY,
+ "filter[provider_groups__in]": f"{group1.id},{group2.id}",
+ },
+ )
+ assert response.status_code == status.HTTP_200_OK
+ assert len(response.json()["data"]) == 2
+
@pytest.mark.parametrize(
"filter_name",
(
@@ -7053,15 +8140,40 @@ class TestFindingViewSet:
"id"
] == str(finding_1.resources.first().id)
+ def test_findings_retrieve_include_resource_metadata(
+ self, authenticated_client, findings_fixture
+ ):
+ finding_1, *_ = findings_fixture
+ resource = finding_1.resources.first()
+ resource.metadata = '{"VulnerabilityID": "CVE-2026-0001"}'
+ resource.details = "Python 3.12 base image"
+ resource.save()
+
+ response = authenticated_client.get(
+ reverse("finding-detail", kwargs={"pk": finding_1.id}),
+ {"include": "resources"},
+ )
+ assert response.status_code == status.HTTP_200_OK
+
+ included_resource = next(
+ item
+ for item in response.json()["included"]
+ if item["type"] == "resources" and item["id"] == str(resource.id)
+ )
+ assert (
+ included_resource["attributes"]["metadata"]
+ == '{"VulnerabilityID": "CVE-2026-0001"}'
+ )
+ assert included_resource["attributes"]["details"] == "Python 3.12 base image"
+
def test_findings_invalid_retrieve(self, authenticated_client):
response = authenticated_client.get(
reverse("finding-detail", kwargs={"pk": "random_id"}),
)
assert response.status_code == status.HTTP_404_NOT_FOUND
- def test_findings_metadata_retrieve(
- self, authenticated_client, findings_fixture, backfill_scan_metadata_fixture
- ):
+ @pytest.mark.usefixtures("backfill_scan_metadata_fixture")
+ def test_findings_metadata_retrieve(self, authenticated_client, findings_fixture):
finding_1, *_ = findings_fixture
response = authenticated_client.get(
reverse("finding-metadata"),
@@ -7084,8 +8196,9 @@ class TestFindingViewSet:
)
# assert data["data"]["attributes"]["tags"] == expected_tags
+ @pytest.mark.usefixtures("backfill_scan_metadata_fixture")
def test_findings_metadata_resource_filter_retrieve(
- self, authenticated_client, findings_fixture, backfill_scan_metadata_fixture
+ self, authenticated_client, findings_fixture
):
finding_1, *_ = findings_fixture
response = authenticated_client.get(
@@ -7142,6 +8255,23 @@ class TestFindingViewSet:
]
}
+ @pytest.mark.parametrize(
+ "filter_name",
+ ["inserted_at", "inserted_at.gte", "inserted_at.lte"],
+ )
+ def test_findings_metadata_rejects_timestamp_precision_filters(
+ self, authenticated_client, filter_name
+ ):
+ response = authenticated_client.get(
+ reverse("finding-metadata"),
+ {f"filter[{filter_name}]": "2048-01-01T10:30:00Z"},
+ )
+
+ assert response.status_code == status.HTTP_400_BAD_REQUEST
+ error = response.json()["errors"][0]
+ assert error["detail"] == "Enter a valid date."
+ assert error["code"] == "invalid"
+
def test_findings_metadata_backfill(
self, authenticated_client, scans_fixture, findings_fixture
):
@@ -7254,9 +8384,8 @@ class TestFindingViewSet:
attributes = response.json()["data"]["attributes"]
assert set(attributes["categories"]) == {"gen-ai", "security"}
- def test_findings_metadata_latest_categories(
- self, authenticated_client, latest_scan_finding_with_categories
- ):
+ @pytest.mark.usefixtures("latest_scan_finding_with_categories")
+ def test_findings_metadata_latest_categories(self, authenticated_client):
response = authenticated_client.get(
reverse("finding-metadata_latest"),
)
@@ -7264,9 +8393,8 @@ 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
- ):
+ @pytest.mark.usefixtures("latest_scan_finding_with_categories")
+ def test_findings_metadata_latest_groups(self, authenticated_client):
response = authenticated_client.get(
reverse("finding-metadata_latest"),
)
@@ -7367,15 +8495,21 @@ class TestFindingViewSet:
@pytest.mark.django_db
class TestJWTFields:
- def test_jwt_fields(self, authenticated_client, create_test_user):
- data = {"type": "tokens", "email": TEST_USER, "password": TEST_PASSWORD}
- response = authenticated_client.post(
- reverse("token-obtain"), data, format="json"
- )
+ def test_jwt_fields(self, create_test_user, tenants_fixture):
+ from rest_framework.test import APIClient
- assert (
- response.status_code == status.HTTP_200_OK
- ), f"Unexpected status code: {response.status_code}"
+ client = APIClient()
+ data = {
+ "data": {
+ "type": "tokens",
+ "attributes": {"email": TEST_USER, "password": TEST_PASSWORD},
+ }
+ }
+ response = client.post(reverse("token-obtain"), data, format="vnd.api+json")
+
+ assert response.status_code == status.HTTP_200_OK, (
+ f"Unexpected status code: {response.status_code}"
+ )
access_token = response.data["attributes"]["access"]
payload = jwt.decode(access_token, options={"verify_signature": False})
@@ -7389,28 +8523,28 @@ class TestJWTFields:
# Verify expected fields
for field in expected_fields:
assert field in payload, f"The field '{field}' is not in the JWT"
- assert (
- payload[field] == expected_fields[field]
- ), f"The value of '{field}' does not match"
+ assert payload[field] == expected_fields[field], (
+ f"The value of '{field}' does not match"
+ )
# Verify time fields are integers
for time_field in ["exp", "iat", "nbf"]:
assert time_field in payload, f"The field '{time_field}' is not in the JWT"
- assert isinstance(
- payload[time_field], int
- ), f"The field '{time_field}' is not an integer"
+ assert isinstance(payload[time_field], int), (
+ f"The field '{time_field}' is not an integer"
+ )
# Verify identification fields are non-empty strings
for id_field in ["jti", "sub", "tenant_id"]:
assert id_field in payload, f"The field '{id_field}' is not in the JWT"
- assert (
- isinstance(payload[id_field], str) and payload[id_field]
- ), f"The field '{id_field}' is not a valid string"
+ assert isinstance(payload[id_field], str) and payload[id_field], (
+ f"The field '{id_field}' is not a valid string"
+ )
@pytest.mark.django_db
class TestInvitationViewSet:
- TOMORROW = datetime.now(timezone.utc) + timedelta(days=1, hours=1)
+ TOMORROW = datetime.now(UTC) + timedelta(days=1, hours=1)
TOMORROW_ISO = TOMORROW.isoformat()
def test_invitations_list(self, authenticated_client, invitations_fixture):
@@ -7533,9 +8667,7 @@ class TestInvitationViewSet:
"type": "invitations",
"attributes": {
"email": "thisisarandomemail@prowler.com",
- "expires_at": (
- datetime.now(timezone.utc) + timedelta(hours=23)
- ).isoformat(),
+ "expires_at": (datetime.now(UTC) + timedelta(hours=23)).isoformat(),
},
}
}
@@ -7562,7 +8694,7 @@ class TestInvitationViewSet:
invitation, *_ = invitations_fixture
role1, role2, *_ = roles_fixture
new_email = "new_email@prowler.com"
- new_expires_at = datetime.now(timezone.utc) + timedelta(days=7)
+ new_expires_at = datetime.now(UTC) + timedelta(days=7)
new_expires_at_iso = new_expires_at.isoformat()
data = {
"data": {
@@ -7649,9 +8781,7 @@ class TestInvitationViewSet:
"id": str(invitation.id),
"type": "invitations",
"attributes": {
- "expires_at": (
- datetime.now(timezone.utc) + timedelta(hours=23)
- ).isoformat(),
+ "expires_at": (datetime.now(UTC) + timedelta(hours=23)).isoformat(),
},
}
}
@@ -7790,16 +8920,14 @@ class TestInvitationViewSet:
expires_at=self.TOMORROW,
)
- data = {
- "invitation_token": invitation.token,
- }
+ data = {"invitation_token": invitation.token}
assert not Membership.objects.filter(
user__email__iexact=user.email, tenant=tenant
).exists()
response = authenticated_client.post(
- reverse("invitation-accept"), data=data, format="json"
+ reverse("invitation-accept"), data=data, format="vnd.api+json"
)
assert response.status_code == status.HTTP_201_CREATED
invitation.refresh_from_db()
@@ -7808,13 +8936,46 @@ class TestInvitationViewSet:
).exists()
assert invitation.state == Invitation.State.ACCEPTED.value
- def test_invitations_accept_invitation_invalid_token(self, authenticated_client):
- data = {
- "invitation_token": "invalid_token",
- }
+ def test_invitations_accept_invitation_existing_membership(
+ self,
+ authenticated_client,
+ create_test_user,
+ tenants_fixture,
+ ):
+ *_, tenant = tenants_fixture
+ user = create_test_user
+
+ invitation = Invitation.objects.create(
+ tenant=tenant,
+ email=TEST_USER,
+ inviter=user,
+ expires_at=self.TOMORROW,
+ )
+ Membership.objects.create(user=user, tenant=tenant)
+
+ data = {"invitation_token": invitation.token}
response = authenticated_client.post(
- reverse("invitation-accept"), data=data, format="json"
+ reverse("invitation-accept"),
+ data=data,
+ format="vnd.api+json",
+ )
+
+ assert response.status_code == status.HTTP_201_CREATED
+ invitation.refresh_from_db()
+ assert invitation.state == Invitation.State.ACCEPTED.value
+ assert (
+ Membership.objects.filter(
+ user__email__iexact=user.email, tenant=tenant
+ ).count()
+ == 1
+ )
+
+ def test_invitations_accept_invitation_invalid_token(self, authenticated_client):
+ data = {"invitation_token": "invalid_token"}
+
+ response = authenticated_client.post(
+ reverse("invitation-accept"), data=data, format="vnd.api+json"
)
assert response.status_code == status.HTTP_404_NOT_FOUND
@@ -7824,16 +8985,14 @@ class TestInvitationViewSet:
self, authenticated_client, invitations_fixture
):
invitation, *_ = invitations_fixture
- invitation.expires_at = datetime.now(timezone.utc) - timedelta(days=1)
+ invitation.expires_at = datetime.now(UTC) - timedelta(days=1)
invitation.email = TEST_USER
invitation.save()
- data = {
- "invitation_token": invitation.token,
- }
+ data = {"invitation_token": invitation.token}
response = authenticated_client.post(
- reverse("invitation-accept"), data=data, format="json"
+ reverse("invitation-accept"), data=data, format="vnd.api+json"
)
assert response.status_code == status.HTTP_410_GONE
@@ -7843,7 +9002,7 @@ class TestInvitationViewSet:
):
new_email = "new_email@prowler.com"
invitation, *_ = invitations_fixture
- invitation.expires_at = datetime.now(timezone.utc) - timedelta(days=1)
+ invitation.expires_at = datetime.now(UTC) - timedelta(days=1)
invitation.email = new_email
invitation.save()
@@ -7869,12 +9028,10 @@ class TestInvitationViewSet:
invitation.email = TEST_USER
invitation.save()
- data = {
- "invitation_token": invitation.token,
- }
+ data = {"invitation_token": invitation.token}
response = authenticated_client.post(
- reverse("invitation-accept"), data=data, format="json"
+ reverse("invitation-accept"), data=data, format="vnd.api+json"
)
assert response.status_code == status.HTTP_400_BAD_REQUEST
@@ -7892,12 +9049,10 @@ class TestInvitationViewSet:
invitation.email = TEST_USER
invitation.save()
- data = {
- "invitation_token": invitation.token,
- }
+ data = {"invitation_token": invitation.token}
response = authenticated_client.post(
- reverse("invitation-accept"), data=data, format="json"
+ reverse("invitation-accept"), data=data, format="vnd.api+json"
)
assert response.status_code == status.HTTP_400_BAD_REQUEST
@@ -8416,20 +9571,22 @@ class TestUserRoleRelationshipViewSet:
assert added_role_ids.issubset(relationship_role_ids)
def test_create_relationship_already_exists(
- self, authenticated_client, roles_fixture, create_test_user
+ self, authenticated_client, roles_fixture, create_test_user_rbac_no_roles
):
- # Only add Role One (which has manage_account=True) to ensure
- # the second request has permission to add roles
data = {
"data": [
- {"type": "roles", "id": str(roles_fixture[0].id)},
+ {"type": "roles", "id": str(role.id)} for role in roles_fixture[:2]
]
}
- authenticated_client.post(
- reverse("user-roles-relationship", kwargs={"pk": create_test_user.id}),
+ setup_response = authenticated_client.post(
+ reverse(
+ "user-roles-relationship",
+ kwargs={"pk": create_test_user_rbac_no_roles.id},
+ ),
data=data,
content_type="application/vnd.api+json",
)
+ assert setup_response.status_code == status.HTTP_204_NO_CONTENT
data = {
"data": [
@@ -8437,7 +9594,10 @@ class TestUserRoleRelationshipViewSet:
]
}
response = authenticated_client.post(
- reverse("user-roles-relationship", kwargs={"pk": create_test_user.id}),
+ reverse(
+ "user-roles-relationship",
+ kwargs={"pk": create_test_user_rbac_no_roles.id},
+ ),
data=data,
content_type="application/vnd.api+json",
)
@@ -8459,9 +9619,15 @@ class TestUserRoleRelationshipViewSet:
content_type="application/vnd.api+json",
)
assert response.status_code == status.HTTP_204_NO_CONTENT
- relationships = UserRoleRelationship.objects.filter(user=create_test_user.id)
+ tenant = roles_fixture[2].tenant
+ relationships = UserRoleRelationship.objects.filter(
+ user=create_test_user.id, tenant=tenant
+ )
assert relationships.count() == 1
assert {rel.role.id for rel in relationships} == {roles_fixture[2].id}
+ assert (
+ UserRoleRelationship.objects.filter(user=create_test_user.id).count() == 2
+ )
data = {
"data": [
@@ -8475,12 +9641,66 @@ class TestUserRoleRelationshipViewSet:
content_type="application/vnd.api+json",
)
assert response.status_code == status.HTTP_204_NO_CONTENT
- relationships = UserRoleRelationship.objects.filter(user=create_test_user.id)
+ relationships = UserRoleRelationship.objects.filter(
+ user=create_test_user.id, tenant=tenant
+ )
assert relationships.count() == 2
assert {rel.role.id for rel in relationships} == {
roles_fixture[1].id,
roles_fixture[2].id,
}
+ assert (
+ UserRoleRelationship.objects.filter(user=create_test_user.id).count() == 3
+ )
+
+ def test_partial_update_relationship_preserves_foreign_tenant_roles(
+ self, authenticated_client, roles_fixture, tenants_fixture
+ ):
+ tenant_a, tenant_b, _ = tenants_fixture
+ tenant_a_role = roles_fixture[1]
+ replacement_role = roles_fixture[2]
+ foreign_role = Role.objects.create(
+ name=f"foreign-role-{uuid4()}",
+ tenant=tenant_b,
+ manage_users=False,
+ manage_account=False,
+ manage_billing=False,
+ manage_providers=False,
+ manage_integrations=False,
+ manage_scans=False,
+ unlimited_visibility=False,
+ )
+ shared_user = User.objects.create_user(
+ name="shared_user",
+ email=f"shared-user-{uuid4()}@prowler.com",
+ password="TmpPass123@",
+ )
+ Membership.objects.create(user=shared_user, tenant=tenant_a)
+ Membership.objects.create(user=shared_user, tenant=tenant_b)
+ UserRoleRelationship.objects.create(
+ user=shared_user, role=tenant_a_role, tenant=tenant_a
+ )
+ UserRoleRelationship.objects.create(
+ user=shared_user, role=foreign_role, tenant=tenant_b
+ )
+
+ data = {"data": [{"type": "roles", "id": str(replacement_role.id)}]}
+ response = authenticated_client.patch(
+ reverse("user-roles-relationship", kwargs={"pk": shared_user.id}),
+ data=data,
+ content_type="application/vnd.api+json",
+ )
+
+ assert response.status_code == status.HTTP_204_NO_CONTENT
+ tenant_a_relationships = UserRoleRelationship.objects.filter(
+ user=shared_user, tenant=tenant_a
+ )
+ assert tenant_a_relationships.count() == 1
+ assert {rel.role_id for rel in tenant_a_relationships} == {replacement_role.id}
+ assert UserRoleRelationship.objects.filter(
+ user=shared_user, tenant=tenant_b, role=foreign_role
+ ).exists()
+ assert UserRoleRelationship.objects.filter(user=shared_user).count() == 2
def test_destroy_relationship_other_user(
self, authenticated_client, roles_fixture, create_test_user, tenants_fixture
@@ -8570,7 +9790,7 @@ class TestUserRoleRelationshipViewSet:
assert response.status_code == status.HTTP_204_NO_CONTENT
def test_role_destroy_only_manage_account_blocked(
- self, authenticated_client, tenants_fixture
+ self, authenticated_client_for_tenant_factory, tenants_fixture
):
# Use a tenant without default admin role (tenant3)
tenant = tenants_fixture[2]
@@ -8592,24 +9812,10 @@ class TestUserRoleRelationshipViewSet:
)
# Assign the role to the user
UserRoleRelationship.objects.create(user=user, role=only_role, tenant=tenant)
-
- # Switch token to this tenant
- serializer = TokenSerializer(
- data={
- "type": "tokens",
- "email": TEST_USER,
- "password": TEST_PASSWORD,
- "tenant_id": str(tenant.id),
- }
- )
- serializer.is_valid(raise_exception=True)
- access_token = serializer.validated_data["access"]
- authenticated_client.defaults["HTTP_AUTHORIZATION"] = f"Bearer {access_token}"
+ client = authenticated_client_for_tenant_factory(user, tenant)
# Attempt to delete the only MANAGE_ACCOUNT role
- response = authenticated_client.delete(
- reverse("role-detail", kwargs={"pk": only_role.id})
- )
+ response = client.delete(reverse("role-detail", kwargs={"pk": only_role.id}))
assert response.status_code == status.HTTP_400_BAD_REQUEST
assert Role.objects.filter(id=only_role.id).exists()
@@ -8766,13 +9972,16 @@ class TestRoleProviderGroupRelationshipViewSet:
@pytest.mark.django_db
class TestProviderGroupMembershipViewSet:
def test_create_relationship(
- self, authenticated_client, providers_fixture, provider_groups_fixture
+ self,
+ authenticated_client,
+ provider_groups_fixture,
+ aws_provider_pair,
):
provider_group, *_ = provider_groups_fixture
data = {
"data": [
{"type": "provider", "id": str(provider.id)}
- for provider in providers_fixture[:2]
+ for provider in aws_provider_pair
]
}
response = authenticated_client.post(
@@ -8789,16 +9998,20 @@ class TestProviderGroupMembershipViewSet:
)
assert relationships.count() == 2
for relationship in relationships:
- assert relationship.provider.id in [p.id for p in providers_fixture[:2]]
+ assert relationship.provider.id in [p.id for p in aws_provider_pair]
def test_create_relationship_already_exists(
- self, authenticated_client, providers_fixture, provider_groups_fixture
+ self,
+ authenticated_client,
+ aws_provider,
+ provider_groups_fixture,
+ aws_provider_pair,
):
provider_group, *_ = provider_groups_fixture
data = {
"data": [
{"type": "provider", "id": str(provider.id)}
- for provider in providers_fixture[:2]
+ for provider in aws_provider_pair
]
}
authenticated_client.post(
@@ -8812,7 +10025,7 @@ class TestProviderGroupMembershipViewSet:
data = {
"data": [
- {"type": "provider", "id": str(providers_fixture[0].id)},
+ {"type": "provider", "id": str(aws_provider.id)},
]
}
response = authenticated_client.post(
@@ -8828,12 +10041,16 @@ class TestProviderGroupMembershipViewSet:
assert "already associated" in errors
def test_partial_update_relationship(
- self, authenticated_client, providers_fixture, provider_groups_fixture
+ self,
+ authenticated_client,
+ provider_groups_fixture,
+ aws_provider_pair,
+ gcp_provider,
):
provider_group, *_ = provider_groups_fixture
data = {
"data": [
- {"type": "provider", "id": str(providers_fixture[1].id)},
+ {"type": "provider", "id": str(aws_provider_pair[1].id)},
]
}
response = authenticated_client.patch(
@@ -8849,12 +10066,12 @@ class TestProviderGroupMembershipViewSet:
provider_group=provider_group.id
)
assert relationships.count() == 1
- assert {rel.provider.id for rel in relationships} == {providers_fixture[1].id}
+ assert {rel.provider.id for rel in relationships} == {aws_provider_pair[1].id}
data = {
"data": [
- {"type": "provider", "id": str(providers_fixture[1].id)},
- {"type": "provider", "id": str(providers_fixture[2].id)},
+ {"type": "provider", "id": str(aws_provider_pair[1].id)},
+ {"type": "provider", "id": str(gcp_provider.id)},
]
}
response = authenticated_client.patch(
@@ -8871,18 +10088,21 @@ class TestProviderGroupMembershipViewSet:
)
assert relationships.count() == 2
assert {rel.provider.id for rel in relationships} == {
- providers_fixture[1].id,
- providers_fixture[2].id,
+ aws_provider_pair[1].id,
+ gcp_provider.id,
}
def test_destroy_relationship(
- self, authenticated_client, providers_fixture, provider_groups_fixture
+ self,
+ authenticated_client,
+ provider_groups_fixture,
+ aws_provider_pair,
):
provider_group, *_ = provider_groups_fixture
data = {
"data": [
{"type": "provider", "id": str(provider.id)}
- for provider in providers_fixture[:2]
+ for provider in aws_provider_pair
]
}
response = authenticated_client.post(
@@ -8902,7 +10122,7 @@ class TestProviderGroupMembershipViewSet:
)
assert response.status_code == status.HTTP_204_NO_CONTENT
relationships = ProviderGroupMembership.objects.filter(
- provider_group=providers_fixture[0].id
+ provider_group=provider_group.id
)
assert relationships.count() == 0
@@ -8932,15 +10152,132 @@ class TestComplianceOverviewViewSet:
with patch("api.v1.views.backfill_compliance_summaries_task.delay") as mock:
yield mock
+ def _create_completed_scan(self, provider, name):
+ return Scan.objects.create(
+ name=name,
+ provider=provider,
+ trigger=Scan.TriggerChoices.MANUAL,
+ state=StateChoices.COMPLETED,
+ tenant_id=provider.tenant_id,
+ started_at=datetime.now(UTC),
+ completed_at=datetime.now(UTC),
+ )
+
+ def _create_requirement(
+ self,
+ scan,
+ requirement_id,
+ status_choice,
+ region="eu-west-1",
+ compliance_id="cis_1.4_aws",
+ ):
+ passed = 1 if status_choice == StatusChoices.PASS else 0
+ total = 1 if status_choice != StatusChoices.MANUAL else 0
+ return ComplianceRequirementOverview.objects.create(
+ tenant_id=scan.tenant_id,
+ scan=scan,
+ compliance_id=compliance_id,
+ framework="CIS-1.4-AWS",
+ version="1.4",
+ description="CIS AWS Foundations Benchmark v1.4.0",
+ region=region,
+ requirement_id=requirement_id,
+ requirement_status=status_choice,
+ passed_checks=passed,
+ failed_checks=0
+ if status_choice in (StatusChoices.PASS, StatusChoices.MANUAL)
+ else 1,
+ total_checks=total,
+ passed_findings=passed,
+ total_findings=total,
+ )
+
+ def _create_compliance_summary(
+ self,
+ scan,
+ *,
+ passed,
+ failed,
+ manual=0,
+ compliance_id="cis_1.4_aws",
+ ):
+ return ComplianceOverviewSummary.objects.create(
+ tenant_id=scan.tenant_id,
+ scan=scan,
+ compliance_id=compliance_id,
+ requirements_passed=passed,
+ requirements_failed=failed,
+ requirements_manual=manual,
+ total_requirements=passed + failed + manual,
+ )
+
+ def _overview_attrs_by_id(self, response):
+ assert response.status_code == status.HTTP_200_OK
+ return {item["id"]: item["attributes"] for item in response.json()["data"]}
+
+ def _prepare_latest_compliance_data(
+ self,
+ aws_provider_pair,
+ gcp_provider,
+ ):
+ provider1, provider2 = aws_provider_pair
+ provider3 = gcp_provider
+ old_scan = self._create_completed_scan(provider1, "old aws compliance scan")
+ latest_scan1 = self._create_completed_scan(
+ provider1, "latest aws compliance scan 1"
+ )
+ latest_scan2 = self._create_completed_scan(
+ provider2, "latest aws compliance scan 2"
+ )
+ latest_gcp_scan = self._create_completed_scan(
+ provider3, "latest gcp compliance scan"
+ )
+
+ self._create_requirement(old_scan, "1.1", StatusChoices.FAIL)
+ self._create_requirement(old_scan, "1.2", StatusChoices.FAIL)
+ self._create_compliance_summary(old_scan, passed=0, failed=2)
+
+ self._create_requirement(
+ latest_scan1, "1.1", StatusChoices.PASS, region="eu-west-1"
+ )
+ self._create_requirement(
+ latest_scan1, "1.2", StatusChoices.PASS, region="eu-west-1"
+ )
+ self._create_compliance_summary(latest_scan1, passed=2, failed=0)
+
+ self._create_requirement(
+ latest_scan2, "1.1", StatusChoices.FAIL, region="us-east-1"
+ )
+ self._create_requirement(
+ latest_scan2, "1.2", StatusChoices.PASS, region="us-east-1"
+ )
+ self._create_compliance_summary(latest_scan2, passed=1, failed=1)
+
+ self._create_requirement(
+ latest_gcp_scan,
+ "gcp-1.1",
+ StatusChoices.FAIL,
+ region="europe-west1",
+ compliance_id="cis_1.3_gcp",
+ )
+ self._create_compliance_summary(
+ latest_gcp_scan,
+ passed=0,
+ failed=1,
+ compliance_id="cis_1.3_gcp",
+ )
+
+ return old_scan, latest_scan1, latest_scan2, latest_gcp_scan
+
def test_compliance_overview_list_none(
self,
authenticated_client,
tenants_fixture,
- providers_fixture,
+ aws_provider,
mock_backfill_task,
):
tenant = tenants_fixture[0]
- provider = providers_fixture[0]
+ provider = aws_provider
scan = Scan.objects.create(
name="empty-compliance-scan",
provider=provider,
@@ -9002,11 +10339,11 @@ class TestComplianceOverviewViewSet:
self,
authenticated_client,
tenants_fixture,
- providers_fixture,
+ aws_provider,
mock_backfill_task,
):
tenant = tenants_fixture[0]
- provider = providers_fixture[0]
+ provider = aws_provider
scan = Scan.objects.create(
name="preaggregated-scan",
provider=provider,
@@ -9079,6 +10416,293 @@ class TestComplianceOverviewViewSet:
assert len(response.json()["data"]) >= 1
mock_backfill_task.assert_not_called()
+ def test_compliance_overview_provider_id_filter_uses_latest_scan(
+ self,
+ authenticated_client,
+ mock_backfill_task,
+ aws_provider_pair,
+ gcp_provider,
+ ):
+ _, latest_scan, *_ = self._prepare_latest_compliance_data(
+ aws_provider_pair, gcp_provider
+ )
+
+ response = authenticated_client.get(
+ reverse("complianceoverview-list"),
+ {"filter[provider_id]": str(latest_scan.provider_id)},
+ )
+
+ attrs_by_id = self._overview_attrs_by_id(response)
+ assert attrs_by_id["cis_1.4_aws"]["requirements_passed"] == 2
+ assert attrs_by_id["cis_1.4_aws"]["requirements_failed"] == 0
+ assert "cis_1.3_gcp" not in attrs_by_id
+ mock_backfill_task.assert_not_called()
+
+ def test_compliance_overview_provider_id_in_filter_aggregates_latest_scans(
+ self,
+ authenticated_client,
+ aws_provider_pair,
+ gcp_provider,
+ ):
+ _, latest_scan1, latest_scan2, *_ = self._prepare_latest_compliance_data(
+ aws_provider_pair, gcp_provider
+ )
+
+ response = authenticated_client.get(
+ reverse("complianceoverview-list"),
+ {
+ "filter[provider_id__in]": (
+ f"{latest_scan1.provider_id},{latest_scan2.provider_id}"
+ )
+ },
+ )
+
+ attrs_by_id = self._overview_attrs_by_id(response)
+ assert attrs_by_id["cis_1.4_aws"]["requirements_passed"] == 1
+ assert attrs_by_id["cis_1.4_aws"]["requirements_failed"] == 1
+ assert attrs_by_id["cis_1.4_aws"]["total_requirements"] == 2
+ assert "cis_1.3_gcp" not in attrs_by_id
+
+ def test_compliance_overview_provider_type_filter_uses_latest_scans(
+ self,
+ authenticated_client,
+ aws_provider_pair,
+ gcp_provider,
+ ):
+ self._prepare_latest_compliance_data(aws_provider_pair, gcp_provider)
+
+ response = authenticated_client.get(
+ reverse("complianceoverview-list"),
+ {"filter[provider_type]": Provider.ProviderChoices.AWS.value},
+ )
+
+ attrs_by_id = self._overview_attrs_by_id(response)
+ assert attrs_by_id["cis_1.4_aws"]["requirements_passed"] == 1
+ assert attrs_by_id["cis_1.4_aws"]["requirements_failed"] == 1
+ assert attrs_by_id["cis_1.4_aws"]["total_requirements"] == 2
+ assert "cis_1.3_gcp" not in attrs_by_id
+
+ def test_compliance_overview_provider_groups_filters_use_latest_scans(
+ self,
+ authenticated_client,
+ provider_groups_fixture,
+ tenants_fixture,
+ aws_provider_pair,
+ gcp_provider,
+ ):
+ tenant = tenants_fixture[0]
+ provider1, provider2 = aws_provider_pair
+ group1, group2, *_ = provider_groups_fixture
+ _, latest_scan1, latest_scan2, *_ = self._prepare_latest_compliance_data(
+ aws_provider_pair, gcp_provider
+ )
+ ProviderGroupMembership.objects.create(
+ tenant_id=tenant.id,
+ provider=provider1,
+ provider_group=group1,
+ )
+ ProviderGroupMembership.objects.create(
+ tenant_id=tenant.id,
+ provider=provider2,
+ provider_group=group2,
+ )
+
+ response = authenticated_client.get(
+ reverse("complianceoverview-list"),
+ {"filter[provider_groups]": str(group1.id)},
+ )
+
+ attrs_by_id = self._overview_attrs_by_id(response)
+ assert attrs_by_id["cis_1.4_aws"]["requirements_passed"] == 2
+ assert attrs_by_id["cis_1.4_aws"]["requirements_failed"] == 0
+
+ response = authenticated_client.get(
+ reverse("complianceoverview-list"),
+ {"filter[provider_groups__in]": f"{group1.id},{group2.id}"},
+ )
+
+ attrs_by_id = self._overview_attrs_by_id(response)
+ assert attrs_by_id["cis_1.4_aws"]["requirements_passed"] == 1
+ assert attrs_by_id["cis_1.4_aws"]["requirements_failed"] == 1
+ assert attrs_by_id["cis_1.4_aws"]["total_requirements"] == 2
+
+ def _assert_latest_provider_scan_task_response(
+ self,
+ authenticated_client,
+ endpoint,
+ scan,
+ query_params=None,
+ ):
+ query_params = {**(query_params or {})}
+ if not any(key.startswith("filter[provider_") for key in query_params):
+ query_params = {
+ "filter[provider_id]": str(scan.provider_id),
+ **query_params,
+ }
+
+ with patch.object(
+ ComplianceOverviewViewSet, "get_task_response_if_running"
+ ) as mock_task_response:
+ mock_task_response.return_value = Response(
+ {"detail": "Task is running"}, status=status.HTTP_202_ACCEPTED
+ )
+
+ response = authenticated_client.get(reverse(endpoint), query_params)
+
+ assert response.status_code == status.HTTP_202_ACCEPTED
+ mock_task_response.assert_called_once()
+ _, kwargs = mock_task_response.call_args
+ assert kwargs["task_name"] == "scan-compliance-overviews"
+ assert str(kwargs["task_kwargs"]["tenant_id"]) == str(scan.tenant_id)
+ assert str(kwargs["task_kwargs"]["scan_id"]) == str(scan.id)
+ assert kwargs["raise_on_not_found"] is False
+
+ def test_compliance_overview_provider_filter_returns_running_task_without_data(
+ self,
+ authenticated_client,
+ aws_provider,
+ ):
+ scan = self._create_completed_scan(
+ aws_provider, "latest scan without compliance data"
+ )
+
+ self._assert_latest_provider_scan_task_response(
+ authenticated_client,
+ "complianceoverview-list",
+ scan,
+ )
+
+ def test_compliance_overview_provider_filter_returns_running_task_for_partial_data(
+ self,
+ authenticated_client,
+ aws_provider_pair,
+ ):
+ provider_with_data, provider_without_data = aws_provider_pair
+ scan_with_data = self._create_completed_scan(
+ provider_with_data, "latest scan with compliance data"
+ )
+ scan_without_data = self._create_completed_scan(
+ provider_without_data, "latest scan without partial compliance data"
+ )
+ self._create_requirement(scan_with_data, "1.1", StatusChoices.PASS)
+
+ self._assert_latest_provider_scan_task_response(
+ authenticated_client,
+ "complianceoverview-list",
+ scan_without_data,
+ {
+ "filter[provider_id__in]": (
+ f"{provider_with_data.id},{provider_without_data.id}"
+ )
+ },
+ )
+
+ def test_compliance_overview_provider_filter_empty_response_uses_scan_data_presence(
+ self,
+ authenticated_client,
+ aws_provider,
+ ):
+ scan = self._create_completed_scan(
+ aws_provider, "latest scan with filtered compliance data"
+ )
+ self._create_requirement(scan, "1.1", StatusChoices.PASS, region="eu-west-1")
+
+ with patch.object(
+ ComplianceOverviewViewSet, "get_task_response_if_running"
+ ) as mock_task_response:
+ mock_task_response.return_value = Response(
+ {"detail": "Task is running"}, status=status.HTTP_202_ACCEPTED
+ )
+
+ response = authenticated_client.get(
+ reverse("complianceoverview-list"),
+ {
+ "filter[provider_id]": str(scan.provider_id),
+ "filter[region]": "us-east-1",
+ },
+ )
+
+ assert response.status_code == status.HTTP_200_OK
+ assert response.json()["data"] == []
+ mock_task_response.assert_not_called()
+
+ def test_compliance_overview_metadata_provider_filter_returns_running_task_without_data(
+ self,
+ authenticated_client,
+ aws_provider,
+ ):
+ scan = self._create_completed_scan(
+ aws_provider, "latest scan without compliance metadata"
+ )
+
+ self._assert_latest_provider_scan_task_response(
+ authenticated_client,
+ "complianceoverview-metadata",
+ scan,
+ )
+
+ def test_compliance_overview_requirements_provider_filter_returns_running_task_without_data(
+ self,
+ authenticated_client,
+ aws_provider,
+ ):
+ scan = self._create_completed_scan(
+ aws_provider, "latest scan without compliance requirements"
+ )
+
+ self._assert_latest_provider_scan_task_response(
+ authenticated_client,
+ "complianceoverview-requirements",
+ scan,
+ {"filter[compliance_id]": "cis_1.4_aws"},
+ )
+
+ def test_compliance_overview_metadata_accepts_provider_filters(
+ self,
+ authenticated_client,
+ aws_provider_pair,
+ gcp_provider,
+ ):
+ _, latest_scan, *_ = self._prepare_latest_compliance_data(
+ aws_provider_pair, gcp_provider
+ )
+
+ response = authenticated_client.get(
+ reverse("complianceoverview-metadata"),
+ {"filter[provider_id]": str(latest_scan.provider_id)},
+ )
+
+ assert response.status_code == status.HTTP_200_OK
+ regions = response.json()["data"]["attributes"]["regions"]
+ assert regions == ["eu-west-1"]
+
+ def test_compliance_overview_requirements_accepts_provider_filters(
+ self,
+ authenticated_client,
+ aws_provider_pair,
+ gcp_provider,
+ ):
+ _, latest_scan1, latest_scan2, *_ = self._prepare_latest_compliance_data(
+ aws_provider_pair, gcp_provider
+ )
+
+ response = authenticated_client.get(
+ reverse("complianceoverview-requirements"),
+ {
+ "filter[provider_id__in]": (
+ f"{latest_scan1.provider_id},{latest_scan2.provider_id}"
+ ),
+ "filter[compliance_id]": "cis_1.4_aws",
+ },
+ )
+
+ assert response.status_code == status.HTTP_200_OK
+ requirements_by_id = {
+ item["id"]: item["attributes"] for item in response.json()["data"]
+ }
+ assert requirements_by_id["1.1"]["status"] == "FAIL"
+ assert requirements_by_id["1.2"]["status"] == "PASS"
+
def test_compliance_overview_metadata(
self, authenticated_client, compliance_requirements_overviews_fixture
):
@@ -9214,6 +10838,201 @@ class TestComplianceOverviewViewSet:
assert "platforms" in attributes["attributes"]["technique_details"]
assert "technique_url" in attributes["attributes"]["technique_details"]
+ # Guard against the `_raw_attributes` wrapper leaking through —
+ # the UI reads metadata[i].Category / .AWSService directly.
+ metadata = attributes["attributes"]["metadata"]
+ assert isinstance(metadata, list) and len(metadata) > 0
+ first_attr = metadata[0]
+ assert isinstance(first_attr, dict)
+ assert "_raw_attributes" not in first_attr
+ assert "Category" in first_attr
+ assert "AWSService" in first_attr
+
+ def test_compliance_overview_attributes_resolves_provider_from_scan(
+ self,
+ authenticated_client,
+ tenants_fixture,
+ gcp_provider,
+ azure_provider,
+ ):
+ # csa_ccm_4.0 is a multi-provider universal framework: a single
+ # compliance_id whose requirements expose different checks per provider.
+ # Passing a scan must return the check IDs for that scan's provider,
+ # otherwise the endpoint defaults to the first provider that declares the
+ # framework and azure/gcp requirements end up with check IDs that match
+ # no findings.
+ tenant = tenants_fixture[0]
+ assert gcp_provider.provider == Provider.ProviderChoices.GCP.value
+ assert azure_provider.provider == Provider.ProviderChoices.AZURE.value
+
+ now = datetime.now(UTC)
+ gcp_scan = Scan.objects.create(
+ name="gcp scan",
+ provider=gcp_provider,
+ trigger=Scan.TriggerChoices.MANUAL,
+ state=StateChoices.COMPLETED,
+ tenant_id=tenant.id,
+ started_at=now,
+ completed_at=now,
+ )
+ azure_scan = Scan.objects.create(
+ name="azure scan",
+ provider=azure_provider,
+ trigger=Scan.TriggerChoices.MANUAL,
+ state=StateChoices.COMPLETED,
+ tenant_id=tenant.id,
+ started_at=now,
+ completed_at=now,
+ )
+
+ def request_attributes(scan_id=None):
+ params = {"filter[compliance_id]": "csa_ccm_4.0"}
+ if scan_id is not None:
+ params["filter[scan_id]"] = str(scan_id)
+ return authenticated_client.get(
+ reverse("complianceoverview-attributes"), params
+ )
+
+ def collect_check_ids(scan_id=None):
+ response = request_attributes(scan_id)
+ assert response.status_code == status.HTTP_200_OK
+ check_ids = set()
+ for item in response.json()["data"]:
+ check_ids.update(item["attributes"]["attributes"]["check_ids"])
+ return check_ids
+
+ gcp_check_ids = collect_check_ids(gcp_scan.id)
+ azure_check_ids = collect_check_ids(azure_scan.id)
+
+ # Each scan resolves to its own provider's checks, and they differ.
+ assert gcp_check_ids
+ assert azure_check_ids
+ assert gcp_check_ids != azure_check_ids
+
+ # The returned check IDs belong to the SDK's per-provider definition.
+ from api.compliance import get_prowler_provider_compliance
+
+ def expected_check_ids(provider_type):
+ framework = get_prowler_provider_compliance(provider_type)["csa_ccm_4.0"]
+ expected = set()
+ for requirement in framework.requirements:
+ expected.update(requirement.checks.get(provider_type, []))
+ return expected
+
+ assert gcp_check_ids <= expected_check_ids(Provider.ProviderChoices.GCP.value)
+ assert azure_check_ids <= expected_check_ids(
+ Provider.ProviderChoices.AZURE.value
+ )
+
+ # An explicit scan_id is authoritative: a non-existent scan must fail
+ # closed with 404 instead of silently falling back to another provider.
+ missing_response = request_attributes("00000000-0000-0000-0000-000000000000")
+ assert missing_response.status_code == status.HTTP_404_NOT_FOUND
+
+ # A malformed scan_id is rejected with 404 as well.
+ malformed_response = request_attributes("not-a-uuid")
+ assert malformed_response.status_code == status.HTTP_404_NOT_FOUND
+
+ # An empty value (filter[scan_id]=) must not fall back to the legacy
+ # provider picker: the explicit (if blank) selector fails closed.
+ empty_response = request_attributes("")
+ assert empty_response.status_code == status.HTTP_404_NOT_FOUND
+
+ # A scan belonging to another tenant is not visible (RLS), so it must
+ # return 404 rather than leaking the fallback provider's check IDs.
+ other_tenant = Tenant.objects.create(name="Other Compliance Tenant")
+ foreign_provider = Provider.objects.create(
+ provider="gcp",
+ uid="foreign-gcp-test",
+ alias="foreign_gcp",
+ tenant_id=other_tenant.id,
+ )
+ foreign_scan = Scan.objects.create(
+ name="foreign scan",
+ provider=foreign_provider,
+ trigger=Scan.TriggerChoices.MANUAL,
+ state=StateChoices.COMPLETED,
+ tenant_id=other_tenant.id,
+ started_at=now,
+ completed_at=now,
+ )
+ foreign_response = request_attributes(foreign_scan.id)
+ assert foreign_response.status_code == status.HTTP_404_NOT_FOUND
+
+ def test_compliance_overview_attributes_scan_scoped_by_provider_group(
+ self,
+ authenticated_client_no_permissions_rbac,
+ gcp_provider,
+ azure_provider,
+ ):
+ # A user with limited visibility (no UNLIMITED_VISIBILITY) must only be
+ # able to resolve scans for providers in its provider groups. Tenant RLS
+ # alone is not enough here: both scans belong to the same tenant, so the
+ # endpoint has to scope the scan lookup by provider group, otherwise a
+ # restricted user could read another provider's compliance metadata.
+ client = authenticated_client_no_permissions_rbac
+ limited_user = client.user
+ membership = Membership.objects.filter(user=limited_user).first()
+ tenant = membership.tenant
+
+ allowed_provider = gcp_provider
+ denied_provider = azure_provider
+ assert allowed_provider.provider == Provider.ProviderChoices.GCP.value
+ assert denied_provider.provider == Provider.ProviderChoices.AZURE.value
+
+ provider_group = ProviderGroup.objects.create(
+ name="limited-compliance-group",
+ tenant_id=tenant.id,
+ )
+ ProviderGroupMembership.objects.create(
+ tenant_id=tenant.id,
+ provider_group=provider_group,
+ provider=allowed_provider,
+ )
+ RoleProviderGroupRelationship.objects.create(
+ tenant_id=tenant.id,
+ role=limited_user.roles.first(),
+ provider_group=provider_group,
+ )
+
+ now = datetime.now(UTC)
+ allowed_scan = Scan.objects.create(
+ name="allowed scan",
+ provider=allowed_provider,
+ trigger=Scan.TriggerChoices.MANUAL,
+ state=StateChoices.COMPLETED,
+ tenant_id=tenant.id,
+ started_at=now,
+ completed_at=now,
+ )
+ denied_scan = Scan.objects.create(
+ name="denied scan",
+ provider=denied_provider,
+ trigger=Scan.TriggerChoices.MANUAL,
+ state=StateChoices.COMPLETED,
+ tenant_id=tenant.id,
+ started_at=now,
+ completed_at=now,
+ )
+
+ def request_attributes(scan_id):
+ return client.get(
+ reverse("complianceoverview-attributes"),
+ {
+ "filter[compliance_id]": "csa_ccm_4.0",
+ "filter[scan_id]": str(scan_id),
+ },
+ )
+
+ # The scan in the user's provider group resolves normally.
+ assert request_attributes(allowed_scan.id).status_code == status.HTTP_200_OK
+
+ # The scan outside the user's provider group is invisible, so it fails
+ # closed with 404 instead of leaking the other provider's check IDs.
+ assert (
+ request_attributes(denied_scan.id).status_code == status.HTTP_404_NOT_FOUND
+ )
+
def test_compliance_overview_attributes_missing_compliance_id(
self, authenticated_client
):
@@ -9222,6 +11041,39 @@ class TestComplianceOverviewViewSet:
)
assert response.status_code == status.HTTP_400_BAD_REQUEST
+ def test_compliance_overview_attributes_503_while_warming(
+ self, authenticated_client
+ ):
+ from api.compliance import COMPLIANCE_WARMED, COMPLIANCE_WARMING_STARTED
+
+ COMPLIANCE_WARMING_STARTED.set()
+ COMPLIANCE_WARMED.clear()
+ try:
+ response = authenticated_client.get(
+ reverse("complianceoverview-attributes"),
+ {"filter[compliance_id]": "aws_account_security_onboarding_aws"},
+ )
+ finally:
+ COMPLIANCE_WARMING_STARTED.clear()
+
+ assert response.status_code == status.HTTP_503_SERVICE_UNAVAILABLE
+ assert response.json()["errors"][0]["code"] == "compliance_warming"
+
+ def test_compliance_overview_attributes_serves_when_warming_not_started(
+ self, authenticated_client
+ ):
+ # Dev fallback: under runserver warming never runs, so the guard must
+ # not refuse — the endpoint lazily loads and serves as before.
+ from api.compliance import COMPLIANCE_WARMED, COMPLIANCE_WARMING_STARTED
+
+ COMPLIANCE_WARMING_STARTED.clear()
+ COMPLIANCE_WARMED.clear()
+ response = authenticated_client.get(
+ reverse("complianceoverview-attributes"),
+ {"filter[compliance_id]": "aws_account_security_onboarding_aws"},
+ )
+ assert response.status_code == status.HTTP_200_OK
+
def test_compliance_overview_task_management_integration(
self, authenticated_client, compliance_requirements_overviews_fixture
):
@@ -9359,9 +11211,8 @@ class TestOverviewViewSet:
response = authenticated_client.put(reverse("overview-list"))
assert response.status_code == status.HTTP_405_METHOD_NOT_ALLOWED
- def test_overview_providers_list(
- self, authenticated_client, scan_summaries_fixture, resources_fixture
- ):
+ @pytest.mark.usefixtures("scan_summaries_fixture")
+ def test_overview_providers_list(self, authenticated_client, resources_fixture):
response = authenticated_client.get(reverse("overview-providers"))
assert response.status_code == status.HTTP_200_OK
assert len(response.json()["data"]) == 1
@@ -9372,16 +11223,16 @@ class TestOverviewViewSet:
# Aggregated resources include all AWS providers present in the tenant
assert response.json()["data"][0]["attributes"]["resources"]["total"] == 3
+ @pytest.mark.usefixtures("scan_summaries_fixture")
def test_overview_providers_aggregates_same_provider_type(
self,
authenticated_client,
- scan_summaries_fixture,
resources_fixture,
- providers_fixture,
tenants_fixture,
+ aws_provider_pair,
):
tenant = tenants_fixture[0]
- _provider1, provider2, *_ = providers_fixture
+ _provider1, provider2 = aws_provider_pair
scan = Scan.objects.create(
name="overview scan aws account 2",
@@ -9426,12 +11277,12 @@ class TestOverviewViewSet:
assert attributes["findings"]["muted"] == 7
assert attributes["resources"]["total"] == 4
+ @pytest.mark.usefixtures("scan_summaries_fixture")
def test_overview_providers_count(
self,
authenticated_client,
- scan_summaries_fixture,
resources_fixture,
- providers_fixture,
+ aws_provider,
tenants_fixture,
):
tenant = tenants_fixture[0]
@@ -9460,8 +11311,43 @@ class TestOverviewViewSet:
for entry in grouped_data:
assert "findings" not in entry["attributes"]
+ def test_overview_providers_count_applies_limited_visibility(
+ self,
+ authenticated_client_no_permissions_rbac,
+ provider_groups_fixture,
+ tenants_fixture,
+ gcp_provider,
+ azure_provider,
+ ):
+ tenant = tenants_fixture[0]
+ client = authenticated_client_no_permissions_rbac
+ allowed_provider = gcp_provider
+ denied_provider = azure_provider
+ provider_group = provider_groups_fixture[0]
+
+ ProviderGroupMembership.objects.create(
+ tenant_id=tenant.id,
+ provider_group=provider_group,
+ provider=allowed_provider,
+ )
+ RoleProviderGroupRelationship.objects.create(
+ tenant_id=tenant.id,
+ role=client.user.roles.first(),
+ provider_group=provider_group,
+ )
+
+ response = client.get(reverse("overview-providers-count"))
+
+ assert response.status_code == status.HTTP_200_OK
+ aggregated = {
+ entry["id"]: entry["attributes"]["count"]
+ for entry in response.json()["data"]
+ }
+ assert aggregated == {allowed_provider.provider: 1}
+ assert denied_provider.provider not in aggregated
+
def _create_scan(self, tenant, provider, name, started_at=None):
- scan_started = started_at or datetime.now(timezone.utc) - timedelta(hours=1)
+ scan_started = started_at or datetime.now(UTC) - timedelta(hours=1)
return Scan.objects.create(
tenant=tenant,
provider=provider,
@@ -9510,10 +11396,13 @@ class TestOverviewViewSet:
)
def test_overview_threatscore_returns_weighted_aggregate_snapshot(
- self, authenticated_client, tenants_fixture, providers_fixture
+ self,
+ authenticated_client,
+ tenants_fixture,
+ aws_provider_pair,
):
tenant = tenants_fixture[0]
- provider1, provider2, *_ = providers_fixture
+ provider1, provider2 = aws_provider_pair
scan1 = self._create_scan(tenant, provider1, "agg-scan-one")
scan2 = self._create_scan(tenant, provider2, "agg-scan-two")
@@ -9604,8 +11493,8 @@ class TestOverviewViewSet:
failed_findings=35,
)
- older_inserted = datetime(2025, 1, 1, 12, 0, tzinfo=timezone.utc)
- newer_inserted = datetime(2025, 1, 2, 12, 0, tzinfo=timezone.utc)
+ older_inserted = datetime(2025, 1, 1, 12, 0, tzinfo=UTC)
+ newer_inserted = datetime(2025, 1, 2, 12, 0, tzinfo=UTC)
ThreatScoreSnapshot.objects.filter(id=snapshot1.id).update(
inserted_at=older_inserted
)
@@ -9683,10 +11572,13 @@ class TestOverviewViewSet:
assert attrs["critical_requirements"] == expected_critical
def test_overview_threatscore_weight_fallback_to_requirements(
- self, authenticated_client, tenants_fixture, providers_fixture
+ self,
+ authenticated_client,
+ tenants_fixture,
+ aws_provider_pair,
):
tenant = tenants_fixture[0]
- provider1, provider2, *_ = providers_fixture
+ provider1, provider2 = aws_provider_pair
scan1 = self._create_scan(tenant, provider1, "fallback-scan-1")
scan2 = self._create_scan(tenant, provider2, "fallback-scan-2")
@@ -9738,10 +11630,13 @@ class TestOverviewViewSet:
assert aggregate["section_scores"] == {"1. IAM": "62.22"}
def test_overview_threatscore_filter_by_scan_id_returns_snapshot(
- self, authenticated_client, tenants_fixture, providers_fixture
+ self,
+ authenticated_client,
+ tenants_fixture,
+ aws_provider,
):
tenant = tenants_fixture[0]
- provider1, *_ = providers_fixture
+ provider1 = aws_provider
scan = self._create_scan(tenant, provider1, "filter-scan")
snapshot = self._create_threatscore_snapshot(
@@ -9773,10 +11668,13 @@ class TestOverviewViewSet:
assert body["data"][0]["attributes"]["overall_score"] == "75.00"
def test_overview_threatscore_snapshot_id_returns_specific_snapshot(
- self, authenticated_client, tenants_fixture, providers_fixture
+ self,
+ authenticated_client,
+ tenants_fixture,
+ aws_provider,
):
tenant = tenants_fixture[0]
- provider1, *_ = providers_fixture
+ provider1 = aws_provider
scan = self._create_scan(tenant, provider1, "snapshot-id-scan")
snapshot = self._create_threatscore_snapshot(
@@ -9807,10 +11705,13 @@ class TestOverviewViewSet:
assert data["data"]["attributes"]["score_delta"] is None
def test_overview_threatscore_provider_filter_returns_unaggregated_snapshot(
- self, authenticated_client, tenants_fixture, providers_fixture
+ self,
+ authenticated_client,
+ tenants_fixture,
+ aws_provider_pair,
):
tenant = tenants_fixture[0]
- provider1, provider2, *_ = providers_fixture
+ provider1, provider2 = aws_provider_pair
scan1 = self._create_scan(tenant, provider1, "provider-filter-scan-1")
scan2 = self._create_scan(tenant, provider2, "provider-filter-scan-2")
@@ -9861,15 +11762,15 @@ class TestOverviewViewSet:
assert data[0]["id"] == str(snapshot1.id)
assert data[0]["attributes"]["overall_score"] == "55.55"
- def test_overview_services_list_no_required_filters(
- self, authenticated_client, scan_summaries_fixture
- ):
+ @pytest.mark.usefixtures("scan_summaries_fixture")
+ def test_overview_services_list_no_required_filters(self, authenticated_client):
response = authenticated_client.get(reverse("overview-services"))
assert response.status_code == status.HTTP_200_OK
# Should return services from latest scans
assert len(response.json()["data"]) == 2
- def test_overview_regions_list(self, authenticated_client, scan_summaries_fixture):
+ @pytest.mark.usefixtures("scan_summaries_fixture")
+ def test_overview_regions_list(self, authenticated_client):
response = authenticated_client.get(
reverse("overview-regions"), {"filter[inserted_at]": TODAY}
)
@@ -9895,7 +11796,8 @@ class TestOverviewViewSet:
assert regions["aws:region2"]["fail"] == 1
assert regions["aws:region2"]["muted"] == 3
- def test_overview_services_list(self, authenticated_client, scan_summaries_fixture):
+ @pytest.mark.usefixtures("scan_summaries_fixture")
+ def test_overview_services_list(self, authenticated_client):
response = authenticated_client.get(
reverse("overview-services"), {"filter[inserted_at]": TODAY}
)
@@ -9921,10 +11823,13 @@ class TestOverviewViewSet:
assert service2_data["attributes"]["muted"] == 1
def test_overview_findings_provider_id_in_filter(
- self, authenticated_client, tenants_fixture, providers_fixture
+ self,
+ authenticated_client,
+ tenants_fixture,
+ aws_provider_pair,
):
tenant = tenants_fixture[0]
- provider1, provider2, *_ = providers_fixture
+ provider1, provider2 = aws_provider_pair
scan1 = Scan.objects.create(
name="scan-one",
@@ -10007,11 +11912,95 @@ class TestOverviewViewSet:
assert combined_attributes["muted"] == 3
assert combined_attributes["total"] == 14
- def test_overview_findings_severity_provider_id_in_filter(
- self, authenticated_client, tenants_fixture, providers_fixture
+ def test_overview_findings_provider_groups_filter(
+ self,
+ authenticated_client,
+ tenants_fixture,
+ provider_groups_fixture,
+ aws_provider_pair,
):
tenant = tenants_fixture[0]
- provider1, provider2, *_ = providers_fixture
+ provider1, provider2 = aws_provider_pair
+ group1, group2, *_ = provider_groups_fixture
+ ProviderGroupMembership.objects.create(
+ tenant=tenant, provider=provider1, provider_group=group1
+ )
+ ProviderGroupMembership.objects.create(
+ tenant=tenant, provider=provider1, provider_group=group2
+ )
+ ProviderGroupMembership.objects.create(
+ tenant=tenant, provider=provider2, provider_group=group2
+ )
+
+ scan1 = Scan.objects.create(
+ name="scan-provider-group-one",
+ provider=provider1,
+ trigger=Scan.TriggerChoices.MANUAL,
+ state=StateChoices.COMPLETED,
+ tenant=tenant,
+ )
+ scan2 = Scan.objects.create(
+ name="scan-provider-group-two",
+ provider=provider2,
+ trigger=Scan.TriggerChoices.MANUAL,
+ state=StateChoices.COMPLETED,
+ tenant=tenant,
+ )
+ ScanSummary.objects.create(
+ tenant=tenant,
+ scan=scan1,
+ check_id="check-provider-group-one",
+ service="service-a",
+ severity="high",
+ region="region-a",
+ _pass=5,
+ fail=1,
+ muted=2,
+ total=8,
+ )
+ ScanSummary.objects.create(
+ tenant=tenant,
+ scan=scan2,
+ check_id="check-provider-group-two",
+ service="service-b",
+ severity="medium",
+ region="region-b",
+ _pass=2,
+ fail=3,
+ muted=1,
+ total=6,
+ )
+
+ response = authenticated_client.get(
+ reverse("overview-findings"),
+ {"filter[provider_groups]": str(group1.id)},
+ )
+ assert response.status_code == status.HTTP_200_OK
+ attributes = response.json()["data"]["attributes"]
+ assert attributes["pass"] == 5
+ assert attributes["fail"] == 1
+ assert attributes["muted"] == 2
+ assert attributes["total"] == 8
+
+ response = authenticated_client.get(
+ reverse("overview-findings"),
+ {"filter[provider_groups__in]": f"{group1.id},{group2.id}"},
+ )
+ assert response.status_code == status.HTTP_200_OK
+ attributes = response.json()["data"]["attributes"]
+ assert attributes["pass"] == 7
+ assert attributes["fail"] == 4
+ assert attributes["muted"] == 3
+ assert attributes["total"] == 14
+
+ def test_overview_findings_severity_provider_id_in_filter(
+ self,
+ authenticated_client,
+ tenants_fixture,
+ aws_provider_pair,
+ ):
+ tenant = tenants_fixture[0]
+ provider1, provider2 = aws_provider_pair
scan1 = Scan.objects.create(
name="severity-scan-one",
@@ -10131,10 +12120,13 @@ class TestOverviewViewSet:
assert item["attributes"]["scan_ids"] == []
def test_overview_findings_severity_timeseries_with_data(
- self, authenticated_client, tenants_fixture, providers_fixture
+ self,
+ authenticated_client,
+ tenants_fixture,
+ aws_provider_pair,
):
tenant = tenants_fixture[0]
- provider1, provider2, *_ = providers_fixture
+ provider1, provider2 = aws_provider_pair
# Create scan for day 1
scan1 = Scan.objects.create(
@@ -10143,7 +12135,7 @@ class TestOverviewViewSet:
trigger=Scan.TriggerChoices.MANUAL,
state=StateChoices.COMPLETED,
tenant=tenant,
- completed_at=datetime(2024, 1, 1, 12, 0, 0, tzinfo=timezone.utc),
+ completed_at=datetime(2024, 1, 1, 12, 0, 0, tzinfo=UTC),
)
# Create scan for day 3
@@ -10153,7 +12145,7 @@ class TestOverviewViewSet:
trigger=Scan.TriggerChoices.MANUAL,
state=StateChoices.COMPLETED,
tenant=tenant,
- completed_at=datetime(2024, 1, 3, 12, 0, 0, tzinfo=timezone.utc),
+ completed_at=datetime(2024, 1, 3, 12, 0, 0, tzinfo=UTC),
)
# Create DailySeveritySummary for day 1
@@ -10214,10 +12206,13 @@ class TestOverviewViewSet:
assert data[2]["attributes"]["scan_ids"] == [str(scan3.id)]
def test_overview_findings_severity_timeseries_aggregates_providers(
- self, authenticated_client, tenants_fixture, providers_fixture
+ self,
+ authenticated_client,
+ tenants_fixture,
+ aws_provider_pair,
):
tenant = tenants_fixture[0]
- provider1, provider2, *_ = providers_fixture
+ provider1, provider2 = aws_provider_pair
# Same day, different providers
scan1 = Scan.objects.create(
@@ -10226,7 +12221,7 @@ class TestOverviewViewSet:
trigger=Scan.TriggerChoices.MANUAL,
state=StateChoices.COMPLETED,
tenant=tenant,
- completed_at=datetime(2024, 2, 1, 12, 0, 0, tzinfo=timezone.utc),
+ completed_at=datetime(2024, 2, 1, 12, 0, 0, tzinfo=UTC),
)
scan2 = Scan.objects.create(
name="severity-over-time-scan-p2",
@@ -10234,7 +12229,7 @@ class TestOverviewViewSet:
trigger=Scan.TriggerChoices.MANUAL,
state=StateChoices.COMPLETED,
tenant=tenant,
- completed_at=datetime(2024, 2, 1, 14, 0, 0, tzinfo=timezone.utc),
+ completed_at=datetime(2024, 2, 1, 14, 0, 0, tzinfo=UTC),
)
# Create DailySeveritySummary for provider1
@@ -10287,10 +12282,13 @@ class TestOverviewViewSet:
assert set(data[0]["attributes"]["scan_ids"]) == {str(scan1.id), str(scan2.id)}
def test_overview_findings_severity_timeseries_provider_filter(
- self, authenticated_client, tenants_fixture, providers_fixture
+ self,
+ authenticated_client,
+ tenants_fixture,
+ aws_provider_pair,
):
tenant = tenants_fixture[0]
- provider1, provider2, *_ = providers_fixture
+ provider1, provider2 = aws_provider_pair
scan1 = Scan.objects.create(
name="severity-over-time-filter-scan-p1",
@@ -10298,7 +12296,7 @@ class TestOverviewViewSet:
trigger=Scan.TriggerChoices.MANUAL,
state=StateChoices.COMPLETED,
tenant=tenant,
- completed_at=datetime(2024, 3, 1, 12, 0, 0, tzinfo=timezone.utc),
+ completed_at=datetime(2024, 3, 1, 12, 0, 0, tzinfo=UTC),
)
scan2 = Scan.objects.create(
name="severity-over-time-filter-scan-p2",
@@ -10306,7 +12304,7 @@ class TestOverviewViewSet:
trigger=Scan.TriggerChoices.MANUAL,
state=StateChoices.COMPLETED,
tenant=tenant,
- completed_at=datetime(2024, 3, 1, 14, 0, 0, tzinfo=timezone.utc),
+ completed_at=datetime(2024, 3, 1, 14, 0, 0, tzinfo=UTC),
)
# Provider 1 - critical=100
@@ -10366,11 +12364,11 @@ class TestOverviewViewSet:
self,
authenticated_client,
tenants_fixture,
- providers_fixture,
+ aws_provider,
create_attack_surface_overview,
):
tenant = tenants_fixture[0]
- provider = providers_fixture[0]
+ provider = aws_provider
scan = Scan.objects.create(
name="attack-surface-scan",
@@ -10414,11 +12412,11 @@ class TestOverviewViewSet:
self,
authenticated_client,
tenants_fixture,
- providers_fixture,
create_attack_surface_overview,
+ aws_provider_pair,
):
tenant = tenants_fixture[0]
- provider1, provider2, *_ = providers_fixture
+ provider1, provider2 = aws_provider_pair
scan1 = Scan.objects.create(
name="attack-surface-scan-1",
@@ -10462,9 +12460,8 @@ class TestOverviewViewSet:
assert results_by_type["internet-exposed"]["total_findings"] == 10
assert results_by_type["internet-exposed"]["failed_findings"] == 5
- def test_overview_services_region_filter(
- self, authenticated_client, scan_summaries_fixture
- ):
+ @pytest.mark.usefixtures("scan_summaries_fixture")
+ def test_overview_services_region_filter(self, authenticated_client):
response = authenticated_client.get(
reverse("overview-services"),
{"filter[region]": "region1"},
@@ -10476,10 +12473,13 @@ class TestOverviewViewSet:
assert service_ids == {"service1", "service2"}
def test_overview_services_provider_type_filter(
- self, authenticated_client, tenants_fixture, providers_fixture
+ self,
+ authenticated_client,
+ tenants_fixture,
+ aws_provider,
+ gcp_provider,
):
tenant = tenants_fixture[0]
- aws_provider, _, gcp_provider, *_ = providers_fixture
aws_scan = Scan.objects.create(
name="aws-scan",
@@ -10532,7 +12532,7 @@ class TestOverviewViewSet:
assert "gcp-service" not in service_ids
@pytest.mark.parametrize(
- "status_filter,field_to_check",
+ "status_filter,_field_to_check",
[
("FAIL", "fail"),
("PASS", "_pass"),
@@ -10542,12 +12542,12 @@ class TestOverviewViewSet:
self,
authenticated_client,
tenants_fixture,
- providers_fixture,
+ aws_provider,
status_filter,
- field_to_check,
+ _field_to_check,
):
tenant = tenants_fixture[0]
- provider = providers_fixture[0]
+ provider = aws_provider
scan = Scan.objects.create(
name="status-filter-scan",
@@ -10599,10 +12599,13 @@ class TestOverviewViewSet:
assert attrs["medium"] == 8
def test_overview_threatscore_compliance_id_filter(
- self, authenticated_client, tenants_fixture, providers_fixture
+ self,
+ authenticated_client,
+ tenants_fixture,
+ aws_provider,
):
tenant = tenants_fixture[0]
- provider = providers_fixture[0]
+ provider = aws_provider
scan = self._create_scan(tenant, provider, "compliance-filter-scan")
self._create_threatscore_snapshot(
@@ -10651,10 +12654,13 @@ class TestOverviewViewSet:
assert data[0]["attributes"]["compliance_id"] == "prowler_threatscore_aws"
def test_overview_threatscore_provider_type_filter(
- self, authenticated_client, tenants_fixture, providers_fixture
+ self,
+ authenticated_client,
+ tenants_fixture,
+ aws_provider,
+ gcp_provider,
):
tenant = tenants_fixture[0]
- aws_provider, _, gcp_provider, *_ = providers_fixture
aws_scan = self._create_scan(tenant, aws_provider, "aws-threatscore-scan")
gcp_scan = self._create_scan(tenant, gcp_provider, "gcp-threatscore-scan")
@@ -10712,11 +12718,11 @@ class TestOverviewViewSet:
self,
authenticated_client,
tenants_fixture,
- providers_fixture,
+ aws_provider,
create_scan_category_summary,
):
tenant = tenants_fixture[0]
- provider = providers_fixture[0]
+ provider = aws_provider
scan = Scan.objects.create(
name="categories-scan",
@@ -10775,24 +12781,48 @@ class TestOverviewViewSet:
@pytest.mark.parametrize(
"filter_key,filter_value_fn,expected_total,expected_failed",
[
- ("filter[provider_id]", lambda p1, _: str(p1.id), 10, 5),
+ ("filter[provider_id]", lambda p1, *_: str(p1.id), 10, 5),
("filter[provider_type]", lambda *_: "aws", 10, 5),
("filter[provider_type__in]", lambda *_: "aws,gcp", 30, 20),
+ (
+ "filter[provider_groups]",
+ lambda p1, _, group1, __: str(group1.id),
+ 10,
+ 5,
+ ),
+ (
+ "filter[provider_groups__in]",
+ lambda p1, _, group1, group2: f"{group1.id},{group2.id}",
+ 30,
+ 20,
+ ),
],
)
def test_overview_categories_filters(
self,
authenticated_client,
tenants_fixture,
- providers_fixture,
+ aws_provider,
+ provider_groups_fixture,
create_scan_category_summary,
filter_key,
filter_value_fn,
expected_total,
expected_failed,
+ gcp_provider,
):
tenant = tenants_fixture[0]
- provider1, _, gcp_provider, *_ = providers_fixture
+ provider1 = aws_provider
+ group1, group2, *_ = provider_groups_fixture
+ ProviderGroupMembership.objects.create(
+ tenant=tenant, provider=provider1, provider_group=group1
+ )
+ ProviderGroupMembership.objects.create(
+ tenant=tenant, provider=provider1, provider_group=group2
+ )
+ ProviderGroupMembership.objects.create(
+ tenant=tenant, provider=gcp_provider, provider_group=group2
+ )
scan1 = Scan.objects.create(
name="categories-scan-1",
@@ -10818,7 +12848,7 @@ class TestOverviewViewSet:
response = authenticated_client.get(
reverse("overview-categories"),
- {filter_key: filter_value_fn(provider1, gcp_provider)},
+ {filter_key: filter_value_fn(provider1, gcp_provider, group1, group2)},
)
assert response.status_code == status.HTTP_200_OK
data = response.json()["data"]
@@ -10830,11 +12860,11 @@ class TestOverviewViewSet:
self,
authenticated_client,
tenants_fixture,
- providers_fixture,
+ aws_provider,
create_scan_category_summary,
):
tenant = tenants_fixture[0]
- provider = providers_fixture[0]
+ provider = aws_provider
scan = Scan.objects.create(
name="category-filter-scan",
@@ -10867,11 +12897,11 @@ class TestOverviewViewSet:
self,
authenticated_client,
tenants_fixture,
- providers_fixture,
create_scan_category_summary,
+ aws_provider_pair,
):
tenant = tenants_fixture[0]
- provider1, provider2, *_ = providers_fixture
+ provider1, provider2 = aws_provider_pair
scan1 = Scan.objects.create(
name="multi-provider-scan-1",
@@ -10925,11 +12955,11 @@ class TestOverviewViewSet:
self,
authenticated_client,
tenants_fixture,
- providers_fixture,
+ aws_provider,
create_scan_resource_group_summary,
):
tenant = tenants_fixture[0]
- provider = providers_fixture[0]
+ provider = aws_provider
scan = Scan.objects.create(
name="resource-groups-scan",
@@ -10992,26 +13022,49 @@ class TestOverviewViewSet:
@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),
+ ("filter[provider_id]", lambda p1, *_: str(p1.id), 10, 5),
+ ("filter[provider_id__in]", lambda p1, p2, *_: f"{p1.id},{p2.id}", 25, 12),
+ ("filter[provider_type]", lambda *_: "aws", 10, 5),
+ ("filter[provider_type__in]", lambda *_: "aws,gcp", 25, 12),
+ (
+ "filter[provider_groups]",
+ lambda p1, p2, group1, group2: str(group1.id),
+ 10,
+ 5,
+ ),
+ (
+ "filter[provider_groups__in]",
+ lambda p1, p2, group1, group2: f"{group1.id},{group2.id}",
+ 25,
+ 12,
+ ),
],
)
def test_overview_groups_provider_filters(
self,
authenticated_client,
tenants_fixture,
- providers_fixture,
+ aws_provider,
+ provider_groups_fixture,
create_scan_resource_group_summary,
filter_key,
filter_value_fn,
expected_total,
expected_failed,
+ gcp_provider,
):
tenant = tenants_fixture[0]
- provider1 = providers_fixture[0] # AWS
- gcp_provider = providers_fixture[2] # GCP
+ provider1 = aws_provider # AWS
+ group1, group2, *_ = provider_groups_fixture
+ ProviderGroupMembership.objects.create(
+ tenant=tenant, provider=provider1, provider_group=group1
+ )
+ ProviderGroupMembership.objects.create(
+ tenant=tenant, provider=provider1, provider_group=group2
+ )
+ ProviderGroupMembership.objects.create(
+ tenant=tenant, provider=gcp_provider, provider_group=group2
+ )
scan1 = Scan.objects.create(
name="aws-rg-scan",
@@ -11037,7 +13090,7 @@ class TestOverviewViewSet:
response = authenticated_client.get(
reverse("overview-resource-groups"),
- {filter_key: filter_value_fn(provider1, gcp_provider)},
+ {filter_key: filter_value_fn(provider1, gcp_provider, group1, group2)},
)
assert response.status_code == status.HTTP_200_OK
data = response.json()["data"]
@@ -11049,11 +13102,11 @@ class TestOverviewViewSet:
self,
authenticated_client,
tenants_fixture,
- providers_fixture,
+ aws_provider,
create_scan_resource_group_summary,
):
tenant = tenants_fixture[0]
- provider = providers_fixture[0]
+ provider = aws_provider
scan = Scan.objects.create(
name="rg-filter-scan",
@@ -11086,11 +13139,11 @@ class TestOverviewViewSet:
self,
authenticated_client,
tenants_fixture,
- providers_fixture,
create_scan_resource_group_summary,
+ aws_provider_pair,
):
tenant = tenants_fixture[0]
- provider1, provider2, *_ = providers_fixture
+ provider1, provider2 = aws_provider_pair
scan1 = Scan.objects.create(
name="multi-provider-rg-scan-1",
@@ -11138,8 +13191,9 @@ class TestOverviewViewSet:
assert data[0]["attributes"]["new_failed_findings"] == 5
assert data[0]["attributes"]["resources_count"] == 10
+ @pytest.mark.usefixtures("tenant_compliance_summary_fixture")
def test_compliance_watchlist_no_filters_uses_tenant_summary(
- self, authenticated_client, tenant_compliance_summary_fixture
+ self, authenticated_client
):
response = authenticated_client.get(reverse("overview-compliance-watchlist"))
assert response.status_code == status.HTTP_200_OK
@@ -11159,13 +13213,13 @@ class TestOverviewViewSet:
assert by_id["gdpr_aws"]["requirements_failed"] == 0
assert by_id["gdpr_aws"]["total_requirements"] == 7
+ @pytest.mark.usefixtures("provider_compliance_scores_fixture")
def test_compliance_watchlist_with_provider_filter_uses_provider_scores(
self,
authenticated_client,
- provider_compliance_scores_fixture,
- providers_fixture,
+ aws_provider,
):
- provider1 = providers_fixture[0]
+ provider1 = aws_provider
url = f"{reverse('overview-compliance-watchlist')}?filter[provider_id]={provider1.id}"
response = authenticated_client.get(url)
assert response.status_code == status.HTTP_200_OK
@@ -11179,9 +13233,8 @@ class TestOverviewViewSet:
assert by_id["aws_cis_2.0"]["requirements_manual"] == 1
assert by_id["aws_cis_2.0"]["total_requirements"] == 3
- def test_compliance_watchlist_fail_dominant_logic(
- self, authenticated_client, provider_compliance_scores_fixture
- ):
+ @pytest.mark.usefixtures("provider_compliance_scores_fixture")
+ def test_compliance_watchlist_fail_dominant_logic(self, authenticated_client):
response = authenticated_client.get(
f"{reverse('overview-compliance-watchlist')}?filter[provider_type]=aws"
)
@@ -11196,13 +13249,13 @@ class TestOverviewViewSet:
assert aws_cis["requirements_manual"] == 1
assert aws_cis["total_requirements"] == 3
+ @pytest.mark.usefixtures("provider_compliance_scores_fixture")
def test_compliance_watchlist_provider_id_in_filter(
self,
authenticated_client,
- provider_compliance_scores_fixture,
- providers_fixture,
+ aws_provider_pair,
):
- provider1, provider2, *_ = providers_fixture
+ provider1, provider2 = aws_provider_pair
url = (
f"{reverse('overview-compliance-watchlist')}"
f"?filter[provider_id__in]={provider1.id},{provider2.id}"
@@ -11212,6 +13265,49 @@ class TestOverviewViewSet:
data = response.json()["data"]
assert len(data) >= 1
+ @pytest.mark.usefixtures("provider_compliance_scores_fixture")
+ def test_compliance_watchlist_provider_groups_filter(
+ self,
+ authenticated_client,
+ provider_groups_fixture,
+ tenants_fixture,
+ aws_provider_pair,
+ ):
+ tenant = tenants_fixture[0]
+ provider1, provider2 = aws_provider_pair
+ group1, group2, *_ = provider_groups_fixture
+ ProviderGroupMembership.objects.create(
+ tenant=tenant, provider=provider1, provider_group=group1
+ )
+ ProviderGroupMembership.objects.create(
+ tenant=tenant, provider=provider1, provider_group=group2
+ )
+ ProviderGroupMembership.objects.create(
+ tenant=tenant, provider=provider2, provider_group=group2
+ )
+
+ response = authenticated_client.get(
+ reverse("overview-compliance-watchlist"),
+ {"filter[provider_groups]": str(group1.id)},
+ )
+ assert response.status_code == status.HTTP_200_OK
+ data = response.json()["data"]
+ by_id = {item["id"]: item["attributes"] for item in data}
+ assert by_id["aws_cis_2.0"]["requirements_passed"] == 1
+ assert by_id["aws_cis_2.0"]["requirements_failed"] == 1
+ assert by_id["aws_cis_2.0"]["requirements_manual"] == 1
+
+ response = authenticated_client.get(
+ reverse("overview-compliance-watchlist"),
+ {"filter[provider_groups__in]": f"{group1.id},{group2.id}"},
+ )
+ assert response.status_code == status.HTTP_200_OK
+ data = response.json()["data"]
+ by_id = {item["id"]: item["attributes"] for item in data}
+ assert by_id["aws_cis_2.0"]["requirements_passed"] == 0
+ assert by_id["aws_cis_2.0"]["requirements_failed"] == 2
+ assert by_id["aws_cis_2.0"]["requirements_manual"] == 1
+
def test_compliance_watchlist_empty_result(self, authenticated_client):
response = authenticated_client.get(reverse("overview-compliance-watchlist"))
assert response.status_code == status.HTTP_200_OK
@@ -11244,10 +13340,10 @@ class TestScheduleViewSet:
mock_schedule_scan,
mock_task_get,
authenticated_client,
- providers_fixture,
+ aws_provider,
tasks_fixture,
):
- provider, *_ = providers_fixture
+ provider = aws_provider
prowler_task = tasks_fixture[0]
mock_schedule_scan.return_value.id = prowler_task.id
mock_task_get.return_value = prowler_task
@@ -11275,10 +13371,10 @@ class TestScheduleViewSet:
mock_task_get,
mock_apply_async,
authenticated_client,
- providers_fixture,
+ aws_provider,
tasks_fixture,
):
- provider, *_ = providers_fixture
+ provider = aws_provider
prowler_task = tasks_fixture[0]
mock_task_get.return_value = prowler_task
mock_apply_async.return_value.id = prowler_task.id
@@ -11346,9 +13442,9 @@ class TestIntegrationViewSet:
included_data = response.json()["included"]
for expected_type in expected_resources:
- assert any(
- d.get("type") == expected_type for d in included_data
- ), f"Expected type '{expected_type}' not found in included data"
+ assert any(d.get("type") == expected_type for d in included_data), (
+ f"Expected type '{expected_type}' not found in included data"
+ )
@pytest.mark.parametrize(
"integration_type, configuration, credentials",
@@ -11379,7 +13475,7 @@ class TestIntegrationViewSet:
def test_integrations_create_valid(
self,
authenticated_client,
- providers_fixture,
+ aws_provider,
integration_type,
configuration,
credentials,
@@ -11463,12 +13559,51 @@ class TestIntegrationViewSet:
)
assert "credentials" not in response.json()["data"]["attributes"]
+ @pytest.mark.parametrize(
+ "domain",
+ (
+ "169.254.169.254#",
+ "internal/service",
+ "internal?target",
+ "internal\\target",
+ "internal:8000",
+ "user@internal",
+ ),
+ )
+ def test_integrations_create_jira_rejects_invalid_domain(
+ self, authenticated_client, domain
+ ):
+ data = {
+ "data": {
+ "type": "integrations",
+ "attributes": {
+ "integration_type": Integration.IntegrationChoices.JIRA,
+ "configuration": {},
+ "credentials": {
+ "domain": domain,
+ "api_token": "fake-api-token",
+ "user_mail": "testing@prowler.com",
+ },
+ "enabled": True,
+ },
+ }
+ }
+
+ response = authenticated_client.post(
+ reverse("integration-list"),
+ data=json.dumps(data),
+ content_type="application/vnd.api+json",
+ )
+
+ assert response.status_code == status.HTTP_400_BAD_REQUEST
+ assert Integration.objects.count() == 0
+
def test_integrations_create_valid_relationships(
self,
authenticated_client,
- providers_fixture,
+ aws_provider_pair,
):
- provider1, provider2, *_ = providers_fixture
+ provider1, provider2 = aws_provider_pair
data = {
"data": {
@@ -11791,9 +13926,11 @@ class TestIntegrationViewSet:
assert response.status_code == status.HTTP_400_BAD_REQUEST
def test_integrations_create_duplicate_amazon_s3(
- self, authenticated_client, providers_fixture
+ self,
+ authenticated_client,
+ aws_provider,
):
- provider = providers_fixture[0]
+ provider = aws_provider
# Create first S3 integration
data = {
@@ -12001,6 +14138,55 @@ class TestIntegrationViewSet:
assert "projects" in configuration
assert "issue_types" in configuration
+ def test_integrations_update_jira_rejects_invalid_domain(
+ self, authenticated_client
+ ):
+ create_data = {
+ "data": {
+ "type": "integrations",
+ "attributes": {
+ "integration_type": Integration.IntegrationChoices.JIRA,
+ "configuration": {},
+ "credentials": {
+ "user_mail": "test@example.com",
+ "api_token": "fake-api-token",
+ "domain": "original-domain",
+ },
+ "enabled": True,
+ },
+ }
+ }
+ create_response = authenticated_client.post(
+ reverse("integration-list"),
+ data=json.dumps(create_data),
+ content_type="application/vnd.api+json",
+ )
+ assert create_response.status_code == status.HTTP_201_CREATED
+ integration_id = create_response.json()["data"]["id"]
+
+ update_data = {
+ "data": {
+ "type": "integrations",
+ "id": integration_id,
+ "attributes": {
+ "credentials": {
+ "user_mail": "test@example.com",
+ "api_token": "fake-api-token",
+ "domain": "169.254.169.254#",
+ }
+ },
+ }
+ }
+ response = authenticated_client.patch(
+ reverse("integration-detail", kwargs={"pk": integration_id}),
+ data=json.dumps(update_data),
+ content_type="application/vnd.api+json",
+ )
+
+ assert response.status_code == status.HTTP_400_BAD_REQUEST
+ integration = Integration.objects.get(id=integration_id)
+ assert integration.credentials["domain"] == "original-domain"
+
@pytest.mark.django_db
class TestSAMLTokenValidation:
@@ -12013,7 +14199,7 @@ class TestSAMLTokenValidation:
saml_token = SAMLToken.objects.create(
token=valid_token_data,
user=user,
- expires_at=datetime.now(timezone.utc) + timedelta(seconds=10),
+ expires_at=datetime.now(UTC) + timedelta(seconds=10),
)
url = reverse("token-saml")
@@ -12039,7 +14225,7 @@ class TestSAMLTokenValidation:
saml_token = SAMLToken.objects.create(
token=expired_token_data,
user=user,
- expires_at=datetime.now(timezone.utc) - timedelta(seconds=1),
+ expires_at=datetime.now(UTC) - timedelta(seconds=1),
)
url = reverse("token-saml")
@@ -12058,7 +14244,7 @@ class TestSAMLTokenValidation:
saml_token = SAMLToken.objects.create(
token=token_data,
user=user,
- expires_at=datetime.now(timezone.utc) + timedelta(seconds=10),
+ expires_at=datetime.now(UTC) + timedelta(seconds=10),
)
url = reverse("token-saml")
@@ -12072,6 +14258,26 @@ class TestSAMLTokenValidation:
assert response2.status_code == status.HTTP_404_NOT_FOUND
+@pytest.mark.django_db
+class TestCustomSAMLLoginView:
+ def test_dispatch_clears_stale_callback_url_when_request_has_none(self):
+ request = RequestFactory().get("/api/v1/saml/login/testtenant/")
+ request.session = {
+ "saml_callback_url": "/invitation/accept?invitation_token=old-token"
+ }
+
+ with patch(
+ "allauth.socialaccount.providers.saml.views.LoginView.dispatch",
+ return_value=JsonResponse({}),
+ ):
+ response = CustomSAMLLoginView.as_view()(
+ request, organization_slug="testtenant"
+ )
+
+ assert response.status_code == status.HTTP_200_OK
+ assert "saml_callback_url" not in request.session
+
+
@pytest.mark.django_db
class TestSAMLInitiateAPIView:
def test_valid_email_domain_and_certificates(
@@ -12083,7 +14289,7 @@ class TestSAMLInitiateAPIView:
url = reverse("api_saml_initiate")
payload = {"email_domain": saml_setup["email"]}
- response = authenticated_client.post(url, data=payload, format="json")
+ response = authenticated_client.post(url, data=payload, format="vnd.api+json")
assert response.status_code == status.HTTP_302_FOUND
assert (
@@ -12092,11 +14298,42 @@ class TestSAMLInitiateAPIView:
)
assert "SAMLRequest" not in response.url
+ def test_valid_email_domain_preserves_safe_callback_url(
+ self, authenticated_client, saml_setup
+ ):
+ url = reverse("api_saml_initiate")
+ callback_url = "/invitation/accept?invitation_token=test-token"
+ payload = {
+ "email_domain": saml_setup["email"],
+ "callback_url": callback_url,
+ }
+
+ response = authenticated_client.post(url, data=payload, format="vnd.api+json")
+
+ assert response.status_code == status.HTTP_302_FOUND
+ query_params = parse_qs(urlparse(response.url).query)
+ assert query_params["callback_url"] == [callback_url]
+
+ def test_valid_email_domain_rejects_external_callback_url(
+ self, authenticated_client, saml_setup
+ ):
+ url = reverse("api_saml_initiate")
+ payload = {
+ "email_domain": saml_setup["email"],
+ "callback_url": "https://attacker.example/invitation",
+ }
+
+ response = authenticated_client.post(url, data=payload, format="vnd.api+json")
+
+ assert response.status_code == status.HTTP_302_FOUND
+ query_params = parse_qs(urlparse(response.url).query)
+ assert "callback_url" not in query_params
+
def test_invalid_email_domain(self, authenticated_client):
url = reverse("api_saml_initiate")
payload = {"email_domain": "user@unauthorized.com"}
- response = authenticated_client.post(url, data=payload, format="json")
+ response = authenticated_client.post(url, data=payload, format="vnd.api+json")
assert response.status_code == status.HTTP_403_FORBIDDEN
assert response.json()["errors"]["detail"] == "Unauthorized domain."
@@ -12269,15 +14506,18 @@ class TestTenantFinishACSView:
"firstName": ["John"],
"lastName": ["Doe"],
"organization": ["testing_company"],
- "userType": ["no_permissions"],
+ "userType": ["platform_team"],
},
)
request = RequestFactory().get(
- reverse("saml_finish_acs", kwargs={"organization_slug": "testtenant"})
+ reverse(
+ "saml_finish_acs", kwargs={"organization_slug": saml_setup["domain"]}
+ )
)
request.user = user
- request.session = {}
+ callback_url = "/invitation/accept?invitation_token=test-token"
+ request.session = {"saml_callback_url": callback_url}
with (
patch(
@@ -12294,18 +14534,23 @@ class TestTenantFinishACSView:
patch("api.models.User.objects.get") as mock_user_get,
):
mock_get_app_or_404.return_value = MagicMock(
- provider="saml", client_id="testtenant", name="Test App", settings={}
+ provider="saml",
+ client_id=saml_setup["domain"],
+ name="Test App",
+ settings={},
)
mock_sa_get.return_value = social_account
mock_socialapp_get.return_value = MagicMock(provider_id="saml")
mock_saml_domain_get.return_value = SimpleNamespace(
tenant_id=tenants_fixture[0].id
)
- mock_saml_config_get.return_value = MagicMock()
+ mock_saml_config_get.return_value = SimpleNamespace(
+ email_domain=saml_setup["domain"], tenant=tenants_fixture[0]
+ )
mock_user_get.return_value = user
view = TenantFinishACSView.as_view()
- response = view(request, organization_slug="testtenant")
+ response = view(request, organization_slug=saml_setup["domain"])
assert response.status_code == 302
@@ -12314,6 +14559,7 @@ class TestTenantFinishACSView:
assert parsed_url.netloc == expected_callback_host
query_params = parse_qs(parsed_url.query)
assert "id" in query_params
+ assert query_params["callbackUrl"] == [callback_url]
token_id = query_params["id"][0]
token_obj = SAMLToken.objects.get(id=token_id)
@@ -12324,12 +14570,21 @@ class TestTenantFinishACSView:
assert user.name == "John Doe"
assert user.company_name == "testing_company"
- role = Role.objects.using(MainRouter.admin_db).get(name="no_permissions")
+ role = Role.objects.using(MainRouter.admin_db).get(
+ name="platform_team", tenant=tenants_fixture[0]
+ )
assert role.tenant == tenants_fixture[0]
+ assert not role.manage_users
+ assert not role.manage_account
+ assert not role.manage_billing
+ assert not role.manage_providers
+ assert not role.manage_integrations
+ assert not role.manage_scans
+ assert role.unlimited_visibility
assert (
UserRoleRelationship.objects.using(MainRouter.admin_db)
- .filter(user=user, tenant_id=tenants_fixture[0].id)
+ .filter(user=user, role=role, tenant_id=tenants_fixture[0].id)
.exists()
)
@@ -12344,6 +14599,81 @@ class TestTenantFinishACSView:
user.company_name = original_company
user.save()
+ def test_dispatch_rejects_assertion_email_domain_that_differs_from_slug(
+ self, tenants_fixture, saml_setup, monkeypatch
+ ):
+ monkeypatch.setenv("AUTH_URL", "http://localhost")
+ monkeypatch.setenv("SAML_SSO_CALLBACK_URL", "http://localhost/sso-complete")
+ victim_tenant = tenants_fixture[0]
+ attacker_tenant = tenants_fixture[1]
+ attacker_domain = "attacker.com"
+
+ SAMLConfiguration.objects.using(MainRouter.admin_db).create(
+ email_domain=attacker_domain,
+ metadata_xml="""
+
+
+
+
+
+ TEST
+
+
+
+
+
+
+ """,
+ tenant=attacker_tenant,
+ )
+ user = User.objects.using(MainRouter.admin_db).create(
+ email=f"intruder@{saml_setup['domain']}", name="Intruder"
+ )
+ social_account = SocialAccount(
+ user=user,
+ provider="ATTACKER",
+ extra_data={
+ "firstName": ["Mallory"],
+ "lastName": ["Example"],
+ },
+ )
+ request = RequestFactory().get(
+ reverse("saml_finish_acs", kwargs={"organization_slug": attacker_domain})
+ )
+ request.user = user
+ request.session = {}
+
+ with (
+ patch(
+ "allauth.socialaccount.providers.saml.views.get_app_or_404"
+ ) as mock_get_app_or_404,
+ patch(
+ "allauth.socialaccount.models.SocialAccount.objects.get"
+ ) as mock_sa_get,
+ ):
+ mock_get_app_or_404.return_value = MagicMock(
+ provider="saml",
+ provider_id="ATTACKER",
+ client_id=attacker_domain,
+ name="Attacker App",
+ settings={},
+ )
+ mock_sa_get.return_value = social_account
+
+ view = TenantFinishACSView.as_view()
+ response = view(request, organization_slug=attacker_domain)
+
+ assert response.status_code == 302
+ assert "sso_saml_failed=true" in response.url
+ assert not (
+ Membership.objects.using(MainRouter.admin_db)
+ .filter(user=user, tenant=victim_tenant)
+ .exists()
+ )
+ assert (
+ not SAMLToken.objects.using(MainRouter.admin_db).filter(user=user).exists()
+ )
+
def test_rollback_saml_user_when_error_occurs(self, users_fixture, monkeypatch):
"""Test that a user is properly deleted when created during SAML flow and an error occurs"""
monkeypatch.setenv("AUTH_URL", "http://localhost")
@@ -12382,7 +14712,7 @@ class TestTenantFinishACSView:
assert response.status_code == 302
assert "sso_saml_failed=true" in response.url
- def test_dispatch_skips_role_mapping_when_single_manage_account_user(
+ def test_dispatch_keeps_existing_roles_when_usertype_missing(
self,
create_test_user,
tenants_fixture,
@@ -12391,7 +14721,7 @@ class TestTenantFinishACSView:
settings,
monkeypatch,
):
- """Test that role mapping is skipped when tenant has only one user with MANAGE_ACCOUNT role"""
+ """Test that roles are left untouched when the IdP does not send userType"""
monkeypatch.setenv("SAML_SSO_CALLBACK_URL", "http://localhost/sso-complete")
user = create_test_user
tenant = tenants_fixture[0]
@@ -12400,6 +14730,7 @@ class TestTenantFinishACSView:
UserRoleRelationship.objects.using(MainRouter.admin_db).create(
user=user, role=admin_role, tenant_id=tenant.id
)
+ roles_before = Role.objects.using(MainRouter.admin_db).count()
social_account = SocialAccount(
user=user,
@@ -12408,12 +14739,13 @@ class TestTenantFinishACSView:
"firstName": ["John"],
"lastName": ["Doe"],
"organization": ["testing_company"],
- "userType": ["no_permissions"], # This should be ignored
},
)
request = RequestFactory().get(
- reverse("saml_finish_acs", kwargs={"organization_slug": "testtenant"})
+ reverse(
+ "saml_finish_acs", kwargs={"organization_slug": saml_setup["domain"]}
+ )
)
request.user = user
request.session = {}
@@ -12433,37 +14765,194 @@ class TestTenantFinishACSView:
patch("api.models.User.objects.get") as mock_user_get,
):
mock_get_app_or_404.return_value = MagicMock(
- provider="saml", client_id="testtenant", name="Test App", settings={}
+ provider="saml",
+ client_id=saml_setup["domain"],
+ name="Test App",
+ settings={},
)
mock_sa_get.return_value = social_account
mock_socialapp_get.return_value = MagicMock(provider_id="saml")
mock_saml_domain_get.return_value = SimpleNamespace(tenant_id=tenant.id)
- mock_saml_config_get.return_value = MagicMock()
+ mock_saml_config_get.return_value = SimpleNamespace(
+ email_domain=saml_setup["domain"], tenant=tenant
+ )
mock_user_get.return_value = user
view = TenantFinishACSView.as_view()
- response = view(request, organization_slug="testtenant")
+ response = view(request, organization_slug=saml_setup["domain"])
assert response.status_code == 302
- # Verify the admin role is still assigned (not changed to no_permissions)
+ # Verify the existing role assignment was not modified
assert (
UserRoleRelationship.objects.using(MainRouter.admin_db)
.filter(user=user, role=admin_role, tenant_id=tenant.id)
.exists()
)
- # Verify no_permissions role was NOT created in the database
- assert (
- not Role.objects.using(MainRouter.admin_db)
- .filter(name="no_permissions", tenant=tenant)
+ # Verify no new role was created
+ assert Role.objects.using(MainRouter.admin_db).count() == roles_before
+
+ def test_dispatch_assigns_no_role_to_new_user_when_usertype_missing(
+ self,
+ create_test_user,
+ tenants_fixture,
+ saml_setup,
+ settings,
+ monkeypatch,
+ ):
+ """Test that a user without roles gets none assigned when userType is missing"""
+ monkeypatch.setenv("SAML_SSO_CALLBACK_URL", "http://localhost/sso-complete")
+ user = create_test_user
+ tenant = tenants_fixture[0]
+ roles_before = Role.objects.using(MainRouter.admin_db).count()
+
+ social_account = SocialAccount(
+ user=user,
+ provider="saml",
+ extra_data={
+ "firstName": ["John"],
+ "lastName": ["Doe"],
+ "organization": ["testing_company"],
+ },
+ )
+
+ request = RequestFactory().get(
+ reverse(
+ "saml_finish_acs", kwargs={"organization_slug": saml_setup["domain"]}
+ )
+ )
+ request.user = user
+ request.session = {}
+
+ with (
+ patch(
+ "allauth.socialaccount.providers.saml.views.get_app_or_404"
+ ) as mock_get_app_or_404,
+ patch(
+ "allauth.socialaccount.models.SocialApp.objects.get"
+ ) as mock_socialapp_get,
+ patch(
+ "allauth.socialaccount.models.SocialAccount.objects.get"
+ ) as mock_sa_get,
+ patch("api.models.SAMLDomainIndex.objects.get") as mock_saml_domain_get,
+ patch("api.models.SAMLConfiguration.objects.get") as mock_saml_config_get,
+ patch("api.models.User.objects.get") as mock_user_get,
+ ):
+ mock_get_app_or_404.return_value = MagicMock(
+ provider="saml",
+ client_id=saml_setup["domain"],
+ name="Test App",
+ settings={},
+ )
+ mock_sa_get.return_value = social_account
+ mock_socialapp_get.return_value = MagicMock(provider_id="saml")
+ mock_saml_domain_get.return_value = SimpleNamespace(tenant_id=tenant.id)
+ mock_saml_config_get.return_value = SimpleNamespace(
+ email_domain=saml_setup["domain"], tenant=tenant
+ )
+ mock_user_get.return_value = user
+
+ view = TenantFinishACSView.as_view()
+ response = view(request, organization_slug=saml_setup["domain"])
+
+ assert response.status_code == 302
+
+ # Verify no role was created or assigned
+ assert Role.objects.using(MainRouter.admin_db).count() == roles_before
+ assert not (
+ UserRoleRelationship.objects.using(MainRouter.admin_db)
+ .filter(user=user, tenant_id=tenant.id)
.exists()
)
- # Verify no_permissions role was NOT assigned to the user
- assert not (
+ # Membership is still created so the user belongs to the tenant
+ assert (
+ Membership.objects.using(MainRouter.admin_db)
+ .filter(user=user, tenant=tenant)
+ .exists()
+ )
+
+ def test_dispatch_skips_role_mapping_when_last_manage_account_user_maps_to_new_role(
+ self,
+ create_test_user,
+ tenants_fixture,
+ admin_role_fixture,
+ saml_setup,
+ settings,
+ monkeypatch,
+ ):
+ """Test that a new read-only role is neither created nor assigned if it would remove the last MANAGE_ACCOUNT user"""
+ monkeypatch.setenv("SAML_SSO_CALLBACK_URL", "http://localhost/sso-complete")
+ user = create_test_user
+ tenant = tenants_fixture[0]
+
+ admin_role = admin_role_fixture
+ UserRoleRelationship.objects.using(MainRouter.admin_db).create(
+ user=user, role=admin_role, tenant_id=tenant.id
+ )
+
+ social_account = SocialAccount(
+ user=user,
+ provider="saml",
+ extra_data={
+ "firstName": ["John"],
+ "lastName": ["Doe"],
+ "organization": ["testing_company"],
+ "userType": ["brand_new_role"],
+ },
+ )
+
+ request = RequestFactory().get(
+ reverse(
+ "saml_finish_acs", kwargs={"organization_slug": saml_setup["domain"]}
+ )
+ )
+ request.user = user
+ request.session = {}
+
+ with (
+ patch(
+ "allauth.socialaccount.providers.saml.views.get_app_or_404"
+ ) as mock_get_app_or_404,
+ patch(
+ "allauth.socialaccount.models.SocialApp.objects.get"
+ ) as mock_socialapp_get,
+ patch(
+ "allauth.socialaccount.models.SocialAccount.objects.get"
+ ) as mock_sa_get,
+ patch("api.models.SAMLDomainIndex.objects.get") as mock_saml_domain_get,
+ patch("api.models.SAMLConfiguration.objects.get") as mock_saml_config_get,
+ patch("api.models.User.objects.get") as mock_user_get,
+ ):
+ mock_get_app_or_404.return_value = MagicMock(
+ provider="saml",
+ client_id=saml_setup["domain"],
+ name="Test App",
+ settings={},
+ )
+ mock_sa_get.return_value = social_account
+ mock_socialapp_get.return_value = MagicMock(provider_id="saml")
+ mock_saml_domain_get.return_value = SimpleNamespace(tenant_id=tenant.id)
+ mock_saml_config_get.return_value = SimpleNamespace(
+ email_domain=saml_setup["domain"], tenant=tenant
+ )
+ mock_user_get.return_value = user
+
+ view = TenantFinishACSView.as_view()
+ response = view(request, organization_slug=saml_setup["domain"])
+
+ assert response.status_code == 302
+
+ # The admin role is still assigned and the new role was not created
+ assert (
UserRoleRelationship.objects.using(MainRouter.admin_db)
- .filter(user=user, role__name="no_permissions", tenant_id=tenant.id)
+ .filter(user=user, role=admin_role, tenant_id=tenant.id)
+ .exists()
+ )
+ assert (
+ not Role.objects.using(MainRouter.admin_db)
+ .filter(name="brand_new_role", tenant=tenant)
.exists()
)
@@ -12500,7 +14989,9 @@ class TestTenantFinishACSView:
)
request = RequestFactory().get(
- reverse("saml_finish_acs", kwargs={"organization_slug": "testtenant"})
+ reverse(
+ "saml_finish_acs", kwargs={"organization_slug": saml_setup["domain"]}
+ )
)
request.user = user
request.session = {}
@@ -12520,16 +15011,21 @@ class TestTenantFinishACSView:
patch("api.models.User.objects.get") as mock_user_get,
):
mock_get_app_or_404.return_value = MagicMock(
- provider="saml", client_id="testtenant", name="Test App", settings={}
+ provider="saml",
+ client_id=saml_setup["domain"],
+ name="Test App",
+ settings={},
)
mock_sa_get.return_value = social_account
mock_socialapp_get.return_value = MagicMock(provider_id="saml")
mock_saml_domain_get.return_value = SimpleNamespace(tenant_id=tenant.id)
- mock_saml_config_get.return_value = MagicMock()
+ mock_saml_config_get.return_value = SimpleNamespace(
+ email_domain=saml_setup["domain"], tenant=tenant
+ )
mock_user_get.return_value = user
view = TenantFinishACSView.as_view()
- response = view(request, organization_slug="testtenant")
+ response = view(request, organization_slug=saml_setup["domain"])
assert response.status_code == 302
@@ -12584,7 +15080,9 @@ class TestTenantFinishACSView:
)
request = RequestFactory().get(
- reverse("saml_finish_acs", kwargs={"organization_slug": "testtenant"})
+ reverse(
+ "saml_finish_acs", kwargs={"organization_slug": saml_setup["domain"]}
+ )
)
request.user = user
request.session = {}
@@ -12604,16 +15102,21 @@ class TestTenantFinishACSView:
patch("api.models.User.objects.get") as mock_user_get,
):
mock_get_app_or_404.return_value = MagicMock(
- provider="saml", client_id="testtenant", name="Test App", settings={}
+ provider="saml",
+ client_id=saml_setup["domain"],
+ name="Test App",
+ settings={},
)
mock_sa_get.return_value = social_account
mock_socialapp_get.return_value = MagicMock(provider_id="saml")
mock_saml_domain_get.return_value = SimpleNamespace(tenant_id=tenant.id)
- mock_saml_config_get.return_value = MagicMock()
+ mock_saml_config_get.return_value = SimpleNamespace(
+ email_domain=saml_setup["domain"], tenant=tenant
+ )
mock_user_get.return_value = user
view = TenantFinishACSView.as_view()
- response = view(request, organization_slug="testtenant")
+ response = view(request, organization_slug=saml_setup["domain"])
assert response.status_code == 302
@@ -12667,7 +15170,9 @@ class TestTenantFinishACSView:
)
request = RequestFactory().get(
- reverse("saml_finish_acs", kwargs={"organization_slug": "testtenant"})
+ reverse(
+ "saml_finish_acs", kwargs={"organization_slug": saml_setup["domain"]}
+ )
)
request.user = non_admin_user
request.session = {}
@@ -12687,16 +15192,21 @@ class TestTenantFinishACSView:
patch("api.models.User.objects.get") as mock_user_get,
):
mock_get_app_or_404.return_value = MagicMock(
- provider="saml", client_id="testtenant", name="Test App", settings={}
+ provider="saml",
+ client_id=saml_setup["domain"],
+ name="Test App",
+ settings={},
)
mock_sa_get.return_value = social_account
mock_socialapp_get.return_value = MagicMock(provider_id="saml")
mock_saml_domain_get.return_value = SimpleNamespace(tenant_id=tenant.id)
- mock_saml_config_get.return_value = MagicMock()
+ mock_saml_config_get.return_value = SimpleNamespace(
+ email_domain=saml_setup["domain"], tenant=tenant
+ )
mock_user_get.return_value = non_admin_user
view = TenantFinishACSView.as_view()
- response = view(request, organization_slug="testtenant")
+ response = view(request, organization_slug=saml_setup["domain"])
assert response.status_code == 302
@@ -12785,9 +15295,9 @@ class TestLighthouseConfigViewSet:
)
# Check that API key is masked with asterisks only
masked_api_key = data["attributes"]["api_key"]
- assert all(
- c == "*" for c in masked_api_key
- ), "API key should contain only asterisks"
+ assert all(c == "*" for c in masked_api_key), (
+ "API key should contain only asterisks"
+ )
@pytest.mark.parametrize(
"field_name, invalid_value",
@@ -15027,6 +17537,76 @@ class TestLighthouseProviderConfigViewSet:
error_detail = str(resp.json()).lower()
assert "base_url" in error_detail
+ @pytest.mark.parametrize(
+ "base_url",
+ [
+ "https://127.0.0.1/v1",
+ "https://169.254.169.254/latest/meta-data",
+ ],
+ )
+ def test_openai_compatible_rejects_internal_base_url_on_create(
+ self, authenticated_client, base_url
+ ):
+ payload = {
+ "data": {
+ "type": "lighthouse-providers",
+ "attributes": {
+ "provider_type": "openai_compatible",
+ "base_url": base_url,
+ "credentials": {"api_key": "compat-key"},
+ },
+ }
+ }
+
+ resp = authenticated_client.post(
+ reverse("lighthouse-providers-list"),
+ data=payload,
+ content_type=API_JSON_CONTENT_TYPE,
+ )
+
+ assert resp.status_code == status.HTTP_400_BAD_REQUEST
+ assert "base_url" in str(resp.json()).lower()
+
+ def test_openai_compatible_rejects_internal_base_url_on_update(
+ self, authenticated_client
+ ):
+ create_payload = {
+ "data": {
+ "type": "lighthouse-providers",
+ "attributes": {
+ "provider_type": "openai_compatible",
+ "base_url": "https://openrouter.ai/api/v1",
+ "credentials": {"api_key": "compat-key-123"},
+ },
+ }
+ }
+ create_resp = authenticated_client.post(
+ reverse("lighthouse-providers-list"),
+ data=create_payload,
+ content_type=API_JSON_CONTENT_TYPE,
+ )
+ assert create_resp.status_code == status.HTTP_201_CREATED
+ provider_id = create_resp.json()["data"]["id"]
+
+ patch_payload = {
+ "data": {
+ "type": "lighthouse-providers",
+ "id": provider_id,
+ "attributes": {
+ "base_url": "https://169.254.169.254/latest/meta-data",
+ },
+ }
+ }
+
+ patch_resp = authenticated_client.patch(
+ reverse("lighthouse-providers-detail", kwargs={"pk": provider_id}),
+ data=patch_payload,
+ content_type=API_JSON_CONTENT_TYPE,
+ )
+
+ assert patch_resp.status_code == status.HTTP_400_BAD_REQUEST
+ assert "base_url" in str(patch_resp.json()).lower()
+
def test_openai_compatible_invalid_credentials(self, authenticated_client):
payload = {
"data": {
@@ -15339,7 +17919,7 @@ class TestMuteRuleViewSet:
mock_task,
authenticated_client,
tenants_fixture,
- providers_fixture,
+ aws_provider,
scans_fixture,
):
"""Test that multiple findings with same UID result in only one UID in the rule."""
@@ -15790,6 +18370,12 @@ class TestFindingGroupViewSet:
assert attrs["fail_count"] == 0
assert attrs["resources_total"] == 1
assert attrs["resources_fail"] == 0
+ # check_title / check_description are resolved post-pagination from the
+ # summary table, not from the finding's check_metadata.
+ assert attrs["check_title"] == "Ensure EC2 instances do not have public IPs"
+ assert (
+ attrs["check_description"] == "EC2 instances should use private IPs only."
+ )
def test_finding_groups_status_pass_when_no_fail(
self, authenticated_client, finding_groups_fixture
@@ -16332,10 +18918,13 @@ class TestFindingGroupViewSet:
assert response.json()["errors"][0]["code"] == "invalid"
def test_finding_groups_provider_filter(
- self, authenticated_client, finding_groups_fixture, providers_fixture
+ self,
+ authenticated_client,
+ finding_groups_fixture,
+ aws_provider,
):
"""Test filtering by provider UUID."""
- provider = providers_fixture[0]
+ provider = aws_provider
response = authenticated_client.get(
reverse("finding-group-list"),
{"filter[inserted_at]": TODAY, "filter[provider_id]": str(provider.id)},
@@ -16358,6 +18947,44 @@ class TestFindingGroupViewSet:
# All fixture findings are from AWS provider
assert len(response.json()["data"]) == 5
+ def test_finding_groups_provider_groups_filter(
+ self,
+ authenticated_client,
+ tenants_fixture,
+ finding_groups_fixture,
+ provider_groups_fixture,
+ aws_provider_pair,
+ ):
+ tenant = tenants_fixture[0]
+ provider1, provider2 = aws_provider_pair
+ group1, group2, *_ = provider_groups_fixture
+ ProviderGroupMembership.objects.create(
+ tenant=tenant, provider=provider1, provider_group=group1
+ )
+ ProviderGroupMembership.objects.create(
+ tenant=tenant, provider=provider1, provider_group=group2
+ )
+ ProviderGroupMembership.objects.create(
+ tenant=tenant, provider=provider2, provider_group=group2
+ )
+
+ response = authenticated_client.get(
+ reverse("finding-group-list"),
+ {"filter[inserted_at]": TODAY, "filter[provider_groups]": str(group1.id)},
+ )
+ assert response.status_code == status.HTTP_200_OK
+ assert len(response.json()["data"]) == 4
+
+ response = authenticated_client.get(
+ reverse("finding-group-list"),
+ {
+ "filter[inserted_at]": TODAY,
+ "filter[provider_groups__in]": f"{group1.id},{group2.id}",
+ },
+ )
+ assert response.status_code == status.HTTP_200_OK
+ assert len(response.json()["data"]) == 5
+
def test_finding_groups_check_id_filter(
self, authenticated_client, finding_groups_fixture
):
@@ -16409,10 +19036,10 @@ class TestFindingGroupViewSet:
],
ids=["summary_path", "finding_level_path"],
)
+ @pytest.mark.usefixtures("finding_groups_title_variants_fixture")
def test_check_title_icontains_includes_all_title_variants(
self,
authenticated_client,
- finding_groups_title_variants_fixture,
extra_filters,
):
"""
@@ -16532,9 +19159,9 @@ class TestFindingGroupViewSet:
assert len(data) == 2
for item in data:
resource = item["attributes"]["resource"]
- assert (
- resource["resource_group"] == "storage"
- ), "resource_group must be 'storage'"
+ assert resource["resource_group"] == "storage", (
+ "resource_group must be 'storage'"
+ )
def test_resources_name_icontains(
self, authenticated_client, finding_groups_fixture
@@ -16820,7 +19447,11 @@ class TestFindingGroupViewSet:
# Test provider_id filter actually filters data
def test_finding_groups_provider_id_filter_actually_filters(
- self, authenticated_client, finding_groups_fixture, providers_fixture
+ self,
+ authenticated_client,
+ finding_groups_fixture,
+ aws_provider,
+ aws_provider_pair,
):
"""
Test that provider_id filter returns ONLY data from that provider.
@@ -16828,8 +19459,8 @@ class TestFindingGroupViewSet:
This is a critical test - it verifies the filter doesn't just return 200 OK,
but actually restricts the data to the specified provider.
"""
- provider1 = providers_fixture[0] # Has scan1 with 4 checks
- provider2 = providers_fixture[1] # Has scan2 with 1 check (cloudtrail_enabled)
+ provider1 = aws_provider # Has scan1 with 4 checks
+ provider2 = aws_provider_pair[1] # Has scan2 with 1 check (cloudtrail_enabled)
# Get ALL finding groups (without provider filter)
response_all = authenticated_client.get(
@@ -16848,12 +19479,12 @@ class TestFindingGroupViewSet:
assert response_p1.status_code == status.HTTP_200_OK
p1_check_ids = {item["id"] for item in response_p1.json()["data"]}
# Provider1 has scan1 with 4 checks
- assert (
- len(p1_check_ids) == 4
- ), f"Provider1 should have 4 checks, got {len(p1_check_ids)}"
- assert (
- "cloudtrail_enabled" not in p1_check_ids
- ), "cloudtrail_enabled should NOT be in provider1"
+ assert len(p1_check_ids) == 4, (
+ f"Provider1 should have 4 checks, got {len(p1_check_ids)}"
+ )
+ assert "cloudtrail_enabled" not in p1_check_ids, (
+ "cloudtrail_enabled should NOT be in provider1"
+ )
# Get finding groups for provider2 only
response_p2 = authenticated_client.get(
@@ -16863,12 +19494,12 @@ class TestFindingGroupViewSet:
assert response_p2.status_code == status.HTTP_200_OK
p2_check_ids = {item["id"] for item in response_p2.json()["data"]}
# Provider2 has scan2 with 1 check
- assert (
- len(p2_check_ids) == 1
- ), f"Provider2 should have 1 check, got {len(p2_check_ids)}"
- assert (
- "cloudtrail_enabled" in p2_check_ids
- ), "cloudtrail_enabled should be in provider2"
+ assert len(p2_check_ids) == 1, (
+ f"Provider2 should have 1 check, got {len(p2_check_ids)}"
+ )
+ assert "cloudtrail_enabled" in p2_check_ids, (
+ "cloudtrail_enabled should be in provider2"
+ )
# Test provider_type filter actually filters data
def test_finding_groups_provider_type_filter_actually_filters(
@@ -16891,9 +19522,9 @@ class TestFindingGroupViewSet:
{"filter[inserted_at]": TODAY, "filter[provider_type]": "gcp"},
)
assert response_gcp.status_code == status.HTTP_200_OK
- assert (
- len(response_gcp.json()["data"]) == 0
- ), "GCP filter should return 0 results"
+ assert len(response_gcp.json()["data"]) == 0, (
+ "GCP filter should return 0 results"
+ )
def test_finding_groups_pagination(
self, authenticated_client, finding_groups_fixture
@@ -16970,11 +19601,15 @@ class TestFindingGroupViewSet:
assert len(data) == 0
def test_finding_groups_latest_provider_id_filter(
- self, authenticated_client, finding_groups_fixture, providers_fixture
+ self,
+ authenticated_client,
+ finding_groups_fixture,
+ aws_provider,
+ aws_provider_pair,
):
"""Test /latest with provider_id filter returns only that provider's data."""
- provider1 = providers_fixture[0] # Has 4 checks
- provider2 = providers_fixture[1] # Has 1 check
+ provider1 = aws_provider # Has 4 checks
+ provider2 = aws_provider_pair[1] # Has 1 check
# Filter by provider1
response = authenticated_client.get(
@@ -17031,6 +19666,12 @@ class TestFindingGroupViewSet:
assert attrs["fail_count"] == 0
assert attrs["resources_total"] == 1
assert attrs["resources_fail"] == 0
+ # check_title / check_description are resolved post-pagination from the
+ # summary table, not from the finding's check_metadata.
+ assert attrs["check_title"] == "Ensure EC2 instances do not have public IPs"
+ assert (
+ attrs["check_description"] == "EC2 instances should use private IPs only."
+ )
def test_finding_groups_latest_status_in_filter(
self, authenticated_client, finding_groups_fixture
@@ -17132,8 +19773,9 @@ class TestFindingGroupViewSet:
def test_finding_groups_latest_aggregates_latest_per_provider(
self,
authenticated_client,
- providers_fixture,
+ aws_provider,
resources_fixture,
+ aws_provider_pair,
):
"""Test /latest keeps all findings from the latest scan per provider.
@@ -17141,8 +19783,8 @@ class TestFindingGroupViewSet:
same check_id (e.g. one per resource), all of them are included in the
aggregation — not just one.
"""
- provider1 = providers_fixture[0]
- provider2 = providers_fixture[1]
+ provider1 = aws_provider
+ provider2 = aws_provider_pair[1]
resource1 = resources_fixture[0]
resource2 = resources_fixture[1]
resource3 = resources_fixture[2]
@@ -17153,7 +19795,7 @@ class TestFindingGroupViewSet:
provider=provider1,
state=StateChoices.COMPLETED,
trigger=Scan.TriggerChoices.MANUAL,
- completed_at=datetime.now(timezone.utc),
+ completed_at=datetime.now(UTC),
)
latest_scan_provider2 = Scan.objects.create(
@@ -17161,7 +19803,7 @@ class TestFindingGroupViewSet:
provider=provider2,
state=StateChoices.COMPLETED,
trigger=Scan.TriggerChoices.MANUAL,
- completed_at=datetime.now(timezone.utc),
+ completed_at=datetime.now(UTC),
)
older_scan_provider1 = Scan.objects.create(
@@ -17169,7 +19811,7 @@ class TestFindingGroupViewSet:
provider=provider1,
state=StateChoices.COMPLETED,
trigger=Scan.TriggerChoices.MANUAL,
- completed_at=datetime.now(timezone.utc) - timedelta(days=1),
+ completed_at=datetime.now(UTC) - timedelta(days=1),
)
# Older scan — these should be excluded from /latest
@@ -17183,7 +19825,7 @@ class TestFindingGroupViewSet:
impact="high",
check_id=check_id,
check_metadata={"CheckId": check_id, "checktitle": "Cross provider check"},
- first_seen_at=datetime.now(timezone.utc) - timedelta(days=2),
+ first_seen_at=datetime.now(UTC) - timedelta(days=2),
muted=False,
)
@@ -17198,7 +19840,7 @@ class TestFindingGroupViewSet:
impact="high",
check_id=check_id,
check_metadata={"CheckId": check_id, "checktitle": "Cross provider check"},
- first_seen_at=datetime.now(timezone.utc) - timedelta(hours=1),
+ first_seen_at=datetime.now(UTC) - timedelta(hours=1),
muted=False,
)
latest_p1_pass.add_resources([resource1])
@@ -17213,7 +19855,7 @@ class TestFindingGroupViewSet:
impact="high",
check_id=check_id,
check_metadata={"CheckId": check_id, "checktitle": "Cross provider check"},
- first_seen_at=datetime.now(timezone.utc) - timedelta(hours=1),
+ first_seen_at=datetime.now(UTC) - timedelta(hours=1),
muted=False,
)
latest_p1_fail.add_resources([resource2])
@@ -17229,7 +19871,7 @@ class TestFindingGroupViewSet:
impact="high",
check_id=check_id,
check_metadata={"CheckId": check_id, "checktitle": "Cross provider check"},
- first_seen_at=datetime.now(timezone.utc) - timedelta(hours=1),
+ first_seen_at=datetime.now(UTC) - timedelta(hours=1),
muted=False,
)
latest_p2.add_resources([resource3])
@@ -17262,6 +19904,41 @@ class TestFindingGroupViewSet:
# All providers in fixture are AWS
assert len(data) == 5
+ def test_finding_groups_latest_provider_groups_filter(
+ self,
+ authenticated_client,
+ tenants_fixture,
+ finding_groups_fixture,
+ provider_groups_fixture,
+ aws_provider_pair,
+ ):
+ tenant = tenants_fixture[0]
+ provider1, provider2 = aws_provider_pair
+ group1, group2, *_ = provider_groups_fixture
+ ProviderGroupMembership.objects.create(
+ tenant=tenant, provider=provider1, provider_group=group1
+ )
+ ProviderGroupMembership.objects.create(
+ tenant=tenant, provider=provider1, provider_group=group2
+ )
+ ProviderGroupMembership.objects.create(
+ tenant=tenant, provider=provider2, provider_group=group2
+ )
+
+ response = authenticated_client.get(
+ reverse("finding-group-latest"),
+ {"filter[provider_groups]": str(group1.id)},
+ )
+ assert response.status_code == status.HTTP_200_OK
+ assert len(response.json()["data"]) == 4
+
+ response = authenticated_client.get(
+ reverse("finding-group-latest"),
+ {"filter[provider_groups__in]": f"{group1.id},{group2.id}"},
+ )
+ assert response.status_code == status.HTTP_200_OK
+ assert len(response.json()["data"]) == 5
+
def test_finding_groups_latest_check_id_filter(
self, authenticated_client, finding_groups_fixture
):
@@ -17288,18 +19965,20 @@ class TestFindingGroupViewSet:
check_ids = [item["id"] for item in data]
assert check_ids == sorted(check_ids)
- def test_finding_groups_latest_sort_by_check_title(
+ def test_finding_groups_latest_sort_by_check_title_not_supported(
self, authenticated_client, finding_groups_fixture
):
- """Test /latest supports sorting by check_title."""
+ """check_title is not a sortable field for finding groups.
+
+ Titles live in the TOASTed check_metadata blob and are resolved after
+ pagination from the summary table, so they cannot drive DB-level
+ ordering. Requesting that sort is rejected.
+ """
response = authenticated_client.get(
reverse("finding-group-latest"),
{"sort": "check_title"},
)
- assert response.status_code == status.HTTP_200_OK
- data = response.json()["data"]
- check_titles = [item["attributes"]["check_title"] for item in data]
- assert check_titles == sorted(check_titles)
+ assert response.status_code == status.HTTP_400_BAD_REQUEST
@pytest.mark.parametrize(
"endpoint_name", ["finding-group-list", "finding-group-latest"]
@@ -17712,7 +20391,7 @@ class TestFindingGroupViewSet:
self,
authenticated_client,
tenants_fixture,
- providers_fixture,
+ aws_provider,
resources_fixture,
):
"""Overlapping scans on the same provider must resolve to the scan
@@ -17722,11 +20401,11 @@ class TestFindingGroupViewSet:
different scans and reporting diverging delta/new counts.
"""
tenant = tenants_fixture[0]
- provider = providers_fixture[0]
+ provider = aws_provider
resource = resources_fixture[0]
check_id = "overlap_regression_check"
- t0 = datetime.now(timezone.utc) - timedelta(hours=5)
+ t0 = datetime.now(UTC) - timedelta(hours=5)
t1 = t0 + timedelta(hours=1)
t1_end = t1 + timedelta(minutes=30)
t2 = t0 + timedelta(hours=4)
diff --git a/api/src/backend/api/utils.py b/api/src/backend/api/utils.py
index b80d54b08a..8e73b96a39 100644
--- a/api/src/backend/api/utils.py
+++ b/api/src/backend/api/utils.py
@@ -1,22 +1,31 @@
from __future__ import annotations
-from datetime import datetime, timezone
+from datetime import UTC, datetime
from typing import TYPE_CHECKING
from allauth.socialaccount.providers.oauth2.client import OAuth2Client
-from django.contrib.postgres.aggregates import ArrayAgg
-from django.db.models import Subquery
-from rest_framework.exceptions import NotFound, ValidationError
-
from api.db_router import MainRouter
from api.db_utils import rls_transaction
from api.exceptions import InvitationTokenExpiredException
-from api.models import Integration, Invitation, Processor, Provider, Resource
+from api.models import (
+ Integration,
+ Invitation,
+ Membership,
+ Processor,
+ Provider,
+ Resource,
+ Role,
+ UserRoleRelationship,
+)
from api.v1.serializers import FindingMetadataSerializer
+from django.contrib.postgres.aggregates import ArrayAgg
+from django.db import transaction
+from django.db.models import Subquery
from prowler.lib.outputs.jira.jira import Jira, JiraBasicAuthError
from prowler.providers.aws.lib.s3.s3 import S3
from prowler.providers.aws.lib.security_hub.security_hub import SecurityHub
from prowler.providers.common.models import Connection
+from rest_framework.exceptions import NotFound, ValidationError
if TYPE_CHECKING:
from prowler.providers.alibabacloud.alibabacloud_provider import (
@@ -37,6 +46,7 @@ if TYPE_CHECKING:
from prowler.providers.mongodbatlas.mongodbatlas_provider import (
MongodbatlasProvider,
)
+ from prowler.providers.okta.okta_provider import OktaProvider
from prowler.providers.openstack.openstack_provider import OpenstackProvider
from prowler.providers.oraclecloud.oraclecloud_provider import OraclecloudProvider
from prowler.providers.vercel.vercel_provider import VercelProvider
@@ -93,6 +103,7 @@ def return_prowler_provider(
| KubernetesProvider
| M365Provider
| MongodbatlasProvider
+ | OktaProvider
| OpenstackProvider
| OraclecloudProvider
| VercelProvider
@@ -181,6 +192,10 @@ def return_prowler_provider(
from prowler.providers.vercel.vercel_provider import VercelProvider
prowler_provider = VercelProvider
+ case Provider.ProviderChoices.OKTA.value:
+ from prowler.providers.okta.okta_provider import OktaProvider
+
+ prowler_provider = OktaProvider
case _:
raise ValueError(f"Provider type {provider.provider} not supported")
return prowler_provider
@@ -246,6 +261,11 @@ def get_prowler_provider_kwargs(
**prowler_provider_kwargs,
"team_id": provider.uid,
}
+ elif provider.provider == Provider.ProviderChoices.OKTA.value:
+ prowler_provider_kwargs = {
+ **prowler_provider_kwargs,
+ "okta_org_domain": provider.uid,
+ }
elif provider.provider == Provider.ProviderChoices.IMAGE.value:
# Detect whether uid is a registry URL (e.g. "docker.io/andoniaf") or
# a concrete image reference (e.g. "docker.io/andoniaf/myimage:latest").
@@ -262,6 +282,11 @@ def get_prowler_provider_kwargs(
**{k: v for k, v in prowler_provider_kwargs.items() if v},
}
+ elif provider.provider == Provider.ProviderChoices.ORACLECLOUD.value:
+ prowler_provider_kwargs = _normalize_oraclecloud_provider_kwargs(
+ prowler_provider_kwargs
+ )
+
if mutelist_processor:
mutelist_content = mutelist_processor.configuration.get("Mutelist", {})
# IaC and Image providers don't support mutelist (both use Trivy's built-in logic)
@@ -274,6 +299,40 @@ def get_prowler_provider_kwargs(
return prowler_provider_kwargs
+def _normalize_oraclecloud_provider_kwargs(secret: dict) -> dict:
+ """Normalize external OCI secret fields into SDK provider kwargs."""
+ prowler_provider_kwargs = secret.copy()
+ prowler_provider_kwargs.pop("region", None)
+
+ return prowler_provider_kwargs
+
+
+def _normalize_oraclecloud_connection_test_kwargs(secret: dict) -> dict:
+ """Normalize external OCI secret fields into test_connection kwargs."""
+ from prowler.providers.oraclecloud.oraclecloud_provider import OraclecloudProvider
+
+ prowler_provider_kwargs = secret.copy()
+ prowler_provider_kwargs.pop("region", None)
+
+ if (
+ prowler_provider_kwargs.get("user")
+ and prowler_provider_kwargs.get("fingerprint")
+ and prowler_provider_kwargs.get("tenancy")
+ and (
+ prowler_provider_kwargs.get("key_content")
+ or prowler_provider_kwargs.get("key_file")
+ )
+ ):
+ # Connection validation needs one OCI endpoint, but scans remain unfiltered.
+ prowler_provider_kwargs["region"] = getattr(
+ OraclecloudProvider,
+ "_bootstrap_region",
+ OraclecloudProvider._home_region,
+ )
+
+ return prowler_provider_kwargs
+
+
def initialize_prowler_provider(
provider: Provider,
mutelist_processor: Processor | None = None,
@@ -290,6 +349,7 @@ def initialize_prowler_provider(
| KubernetesProvider
| M365Provider
| MongodbatlasProvider
+ | OktaProvider
| OpenstackProvider
| OraclecloudProvider
| VercelProvider
@@ -351,6 +411,14 @@ def prowler_provider_connection_test(provider: Provider) -> Connection:
"raise_on_exception": False,
}
return prowler_provider.test_connection(**vercel_kwargs)
+ elif provider.provider == Provider.ProviderChoices.OKTA.value:
+ okta_kwargs = {
+ **prowler_provider_kwargs,
+ "okta_org_domain": provider.uid,
+ "provider_id": provider.uid,
+ "raise_on_exception": False,
+ }
+ return prowler_provider.test_connection(**okta_kwargs)
elif provider.provider == Provider.ProviderChoices.IMAGE.value:
image_kwargs = {
"image": provider.uid,
@@ -367,6 +435,15 @@ def prowler_provider_connection_test(provider: Provider) -> Connection:
if prowler_provider_kwargs.get("registry_token"):
image_kwargs["registry_token"] = prowler_provider_kwargs["registry_token"]
return prowler_provider.test_connection(**image_kwargs)
+ elif provider.provider == Provider.ProviderChoices.ORACLECLOUD.value:
+ oraclecloud_kwargs = _normalize_oraclecloud_connection_test_kwargs(
+ prowler_provider_kwargs
+ )
+ return prowler_provider.test_connection(
+ **oraclecloud_kwargs,
+ provider_id=provider.uid,
+ raise_on_exception=False,
+ )
else:
return prowler_provider.test_connection(
**prowler_provider_kwargs,
@@ -416,8 +493,8 @@ def prowler_integration_connection_test(integration: Integration) -> Connection:
# Only save regions if connection is successful
if connection.is_connected:
- regions_status = {r: True for r in connection.enabled_regions}
- regions_status.update({r: False for r in connection.disabled_regions})
+ regions_status = dict.fromkeys(connection.enabled_regions, True)
+ regions_status.update(dict.fromkeys(connection.disabled_regions, False))
# Save regions information in the integration configuration
integration.configuration["regions"] = regions_status
@@ -499,7 +576,7 @@ def validate_invitation(
raise ValidationError({"invitation_token": "Invalid invitation code."})
# Check if the invitation has expired
- if invitation.expires_at < datetime.now(timezone.utc):
+ if invitation.expires_at < datetime.now(UTC):
invitation.state = Invitation.State.EXPIRED
invitation.save(using=MainRouter.admin_db)
raise InvitationTokenExpiredException()
@@ -513,6 +590,35 @@ def validate_invitation(
return invitation
+def accept_invitation_for_user(
+ *, user, invitation_token: str, raise_not_found: bool = False
+):
+ with transaction.atomic(using=MainRouter.admin_db):
+ invitation = validate_invitation(
+ invitation_token, user.email, raise_not_found=raise_not_found
+ )
+ with rls_transaction(str(invitation.tenant_id), using=MainRouter.admin_db):
+ membership, _ = Membership.objects.using(MainRouter.admin_db).get_or_create(
+ user=user,
+ tenant=invitation.tenant,
+ defaults={"role": Membership.RoleChoices.MEMBER},
+ )
+ invitation_roles = Role.objects.using(MainRouter.admin_db).filter(
+ invitations=invitation
+ )
+ for role in invitation_roles:
+ UserRoleRelationship.objects.using(MainRouter.admin_db).get_or_create(
+ user=user,
+ role=role,
+ defaults={"tenant": invitation.tenant},
+ )
+
+ invitation.state = Invitation.State.ACCEPTED
+ invitation.save(using=MainRouter.admin_db)
+
+ return invitation, membership
+
+
# ToRemove after removing the fallback mechanism in /findings/metadata
def get_findings_metadata_no_aggregations(tenant_id: str, filtered_queryset):
filtered_ids = filtered_queryset.order_by().values("id")
@@ -570,6 +676,6 @@ def initialize_prowler_integration(integration: Integration) -> Jira:
with rls_transaction(str(integration.tenant_id)):
integration.configuration["projects"] = {}
integration.connected = False
- integration.connection_last_checked_at = datetime.now(tz=timezone.utc)
+ integration.connection_last_checked_at = datetime.now(tz=UTC)
integration.save()
raise jira_auth_error
diff --git a/api/src/backend/api/uuid_utils.py b/api/src/backend/api/uuid_utils.py
index b1f33432ff..4b2c01d8b0 100644
--- a/api/src/backend/api/uuid_utils.py
+++ b/api/src/backend/api/uuid_utils.py
@@ -1,4 +1,4 @@
-from datetime import datetime, timezone
+from datetime import UTC, datetime
from random import getrandbits
from dateutil.relativedelta import relativedelta
@@ -81,7 +81,7 @@ def datetime_from_uuid7(uuid7: UUID) -> datetime:
A datetime object representing the timestamp encoded in the UUIDv7.
"""
timestamp_ms = uuid7.time
- return datetime.fromtimestamp(timestamp_ms / 1000, tz=timezone.utc)
+ return datetime.fromtimestamp(timestamp_ms / 1000, tz=UTC)
def uuid7_start(uuid_obj: UUID) -> UUID:
diff --git a/api/src/backend/api/v1/mixins.py b/api/src/backend/api/v1/mixins.py
index e1a5d3470f..7645c92f4c 100644
--- a/api/src/backend/api/v1/mixins.py
+++ b/api/src/backend/api/v1/mixins.py
@@ -1,15 +1,18 @@
-from django.urls import reverse
-from django_celery_results.models import TaskResult
-from rest_framework import status
-from rest_framework.response import Response
+import uuid
from api.exceptions import (
TaskFailedException,
TaskInProgressException,
TaskNotFoundException,
)
-from api.models import StateChoices, Task
+from api.models import Provider, StateChoices, Task
from api.v1.serializers import TaskSerializer
+from django.http import QueryDict
+from django.urls import reverse
+from django_celery_results.models import TaskResult
+from rest_framework import status
+from rest_framework.exceptions import ValidationError
+from rest_framework.response import Response
class DisablePaginationMixin:
@@ -74,6 +77,162 @@ class PaginateByPkMixin:
return self.get_paginated_response(serialized)
+class JsonApiFilterMixin:
+ """Shared helpers for manually applying django-filter to JSON:API params."""
+
+ jsonapi_filter_replace_dots = False
+
+ def _normalize_jsonapi_params(
+ self,
+ query_params,
+ exclude_keys=None,
+ replace_dots=None,
+ ):
+ exclude_keys = exclude_keys or set()
+ if replace_dots is None:
+ replace_dots = self.jsonapi_filter_replace_dots
+
+ normalized = QueryDict(mutable=True)
+ for key, values in query_params.lists():
+ normalized_key = (
+ key[7:-1] if key.startswith("filter[") and key.endswith("]") else key
+ )
+ if replace_dots:
+ normalized_key = normalized_key.replace(".", "__")
+ if normalized_key not in exclude_keys:
+ normalized.setlist(normalized_key, values)
+ return normalized
+
+ def _apply_filterset(
+ self,
+ queryset,
+ filterset_class,
+ exclude_keys=None,
+ replace_dots=None,
+ ):
+ normalized_params = self._normalize_jsonapi_params(
+ self.request.query_params,
+ exclude_keys=set(exclude_keys or []),
+ replace_dots=replace_dots,
+ )
+ filterset = filterset_class(normalized_params, queryset=queryset)
+ if not filterset.is_valid():
+ raise ValidationError(filterset.errors)
+ return filterset.qs
+
+
+class ProviderFilterParamsMixin(JsonApiFilterMixin):
+ """Shared extraction of provider filters from JSON:API query params."""
+
+ PROVIDER_FILTER_KEYS = frozenset(
+ {
+ "provider_id",
+ "provider_id__in",
+ "provider_type",
+ "provider_type__in",
+ "provider_groups",
+ "provider_groups__in",
+ }
+ )
+ PROVIDER_FILTER_DOT_ALIAS_KEYS = frozenset(
+ {
+ "provider_id.in",
+ "provider_type.in",
+ "provider_groups.in",
+ }
+ )
+ PROVIDER_FILTER_QUERY_KEYS = PROVIDER_FILTER_KEYS | PROVIDER_FILTER_DOT_ALIAS_KEYS
+
+ def _csv_filter_values(self, value):
+ return [item.strip() for item in value.split(",") if item.strip()]
+
+ def _validate_uuid_filter_values(self, field_name, values):
+ try:
+ for value in values:
+ uuid.UUID(str(value))
+ except (TypeError, ValueError, AttributeError):
+ raise ValidationError({field_name: ["Enter a valid UUID."]})
+
+ def _has_provider_filters(self, include_dot_aliases=False):
+ provider_filter_keys = (
+ self.PROVIDER_FILTER_QUERY_KEYS
+ if include_dot_aliases
+ else self.PROVIDER_FILTER_KEYS
+ )
+ return any(
+ self.request.query_params.get(f"filter[{key}]")
+ for key in provider_filter_keys
+ )
+
+ def _extract_provider_filters_from_params(
+ self,
+ *,
+ validate_uuids=False,
+ include_dot_aliases=False,
+ ):
+ params = self.request.query_params
+ filters = {}
+ valid_provider_types = {
+ choice[0] for choice in Provider.ProviderChoices.choices
+ }
+
+ provider_id = params.get("filter[provider_id]")
+ if provider_id:
+ if validate_uuids:
+ self._validate_uuid_filter_values("provider_id", [provider_id])
+ filters["provider_id"] = provider_id
+
+ provider_id_in = params.get("filter[provider_id__in]")
+ if include_dot_aliases:
+ provider_id_in = provider_id_in or params.get("filter[provider_id.in]")
+ if provider_id_in:
+ values = self._csv_filter_values(provider_id_in)
+ if validate_uuids:
+ self._validate_uuid_filter_values("provider_id__in", values)
+ filters["provider_id__in"] = values
+
+ provider_type = params.get("filter[provider_type]")
+ if provider_type:
+ if provider_type not in valid_provider_types:
+ raise ValidationError(
+ {"provider_type": f"Invalid choice: {provider_type}"}
+ )
+ filters["provider__provider"] = provider_type
+
+ provider_type_in = params.get("filter[provider_type__in]")
+ if include_dot_aliases:
+ provider_type_in = provider_type_in or params.get(
+ "filter[provider_type.in]"
+ )
+ if provider_type_in:
+ values = self._csv_filter_values(provider_type_in)
+ invalid = [value for value in values if value not in valid_provider_types]
+ if invalid:
+ raise ValidationError(
+ {"provider_type__in": f"Invalid choices: {', '.join(invalid)}"}
+ )
+ filters["provider__provider__in"] = values
+
+ provider_groups = params.get("filter[provider_groups]")
+ if provider_groups:
+ if validate_uuids:
+ self._validate_uuid_filter_values("provider_groups", [provider_groups])
+ filters["provider__provider_groups__id"] = provider_groups
+
+ provider_groups_in = params.get("filter[provider_groups__in]")
+ if include_dot_aliases:
+ provider_groups_in = provider_groups_in or params.get(
+ "filter[provider_groups.in]"
+ )
+ if provider_groups_in:
+ values = self._csv_filter_values(provider_groups_in)
+ if validate_uuids:
+ self._validate_uuid_filter_values("provider_groups__in", values)
+ filters["provider__provider_groups__id__in"] = values
+
+ return filters
+
+
class TaskManagementMixin:
"""
Mixin to manage task status checking.
diff --git a/api/src/backend/api/v1/serializer_utils/authentication.py b/api/src/backend/api/v1/serializer_utils/authentication.py
new file mode 100644
index 0000000000..840cb44a0a
--- /dev/null
+++ b/api/src/backend/api/v1/serializer_utils/authentication.py
@@ -0,0 +1,18 @@
+from api.db_router import MainRouter
+from rest_framework_simplejwt.token_blacklist.models import (
+ BlacklistedToken,
+ OutstandingToken,
+)
+
+
+def blacklist_user_refresh_tokens(user_id):
+ outstanding_token_ids = list(
+ OutstandingToken.objects.using(MainRouter.admin_db)
+ .filter(user_id=user_id)
+ .values_list("id", flat=True)
+ )
+ if outstanding_token_ids:
+ BlacklistedToken.objects.using(MainRouter.admin_db).bulk_create(
+ [BlacklistedToken(token_id=token_id) for token_id in outstanding_token_ids],
+ ignore_conflicts=True,
+ )
diff --git a/api/src/backend/api/v1/serializer_utils/integrations.py b/api/src/backend/api/v1/serializer_utils/integrations.py
index aaa0f4aa31..ac876d1d5b 100644
--- a/api/src/backend/api/v1/serializer_utils/integrations.py
+++ b/api/src/backend/api/v1/serializer_utils/integrations.py
@@ -1,10 +1,13 @@
import os
import re
+from api.v1.serializer_utils.base import BaseValidateSerializer
from drf_spectacular.utils import extend_schema_field
from rest_framework_json_api import serializers
-from api.v1.serializer_utils.base import BaseValidateSerializer
+ATLASSIAN_SITE_NAME_REGEX = re.compile(
+ r"\A[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\Z"
+)
class S3ConfigSerializer(BaseValidateSerializer):
@@ -98,7 +101,17 @@ class AWSCredentialSerializer(BaseValidateSerializer):
class JiraCredentialSerializer(BaseValidateSerializer):
user_mail = serializers.EmailField(required=True)
api_token = serializers.CharField(required=True)
- domain = serializers.CharField(required=True)
+ domain = serializers.RegexField(
+ regex=ATLASSIAN_SITE_NAME_REGEX,
+ required=True,
+ trim_whitespace=False,
+ error_messages={
+ "invalid": (
+ "Domain must be a valid Atlassian site name containing only "
+ "letters, numbers, and hyphens."
+ )
+ },
+ )
class Meta:
resource_name = "integrations"
@@ -171,7 +184,10 @@ class JiraCredentialSerializer(BaseValidateSerializer):
},
"domain": {
"type": "string",
- "description": "The JIRA domain/instance URL (e.g., 'your-domain.atlassian.net').",
+ "description": "The Jira site name without the '.atlassian.net' suffix (e.g., 'your-domain').",
+ "minLength": 1,
+ "maxLength": 63,
+ "pattern": "^[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?$",
},
},
"required": ["user_mail", "api_token", "domain"],
diff --git a/api/src/backend/api/v1/serializer_utils/processors.py b/api/src/backend/api/v1/serializer_utils/processors.py
index 4022f3f2bc..ee53aa8ccf 100644
--- a/api/src/backend/api/v1/serializer_utils/processors.py
+++ b/api/src/backend/api/v1/serializer_utils/processors.py
@@ -1,7 +1,5 @@
-from drf_spectacular.utils import extend_schema_field
-
from api.v1.serializer_utils.base import YamlOrJsonField
-
+from drf_spectacular.utils import extend_schema_field
from prowler.lib.mutelist.mutelist import mutelist_schema
diff --git a/api/src/backend/api/v1/serializer_utils/providers.py b/api/src/backend/api/v1/serializer_utils/providers.py
index f8d67f0e3b..49b593049f 100644
--- a/api/src/backend/api/v1/serializer_utils/providers.py
+++ b/api/src/backend/api/v1/serializer_utils/providers.py
@@ -213,7 +213,8 @@ from rest_framework_json_api import serializers
"properties": {
"kubeconfig_content": {
"type": "string",
- "description": "The content of the Kubernetes kubeconfig file, encoded as a string.",
+ "description": "The content of the Kubernetes kubeconfig file, encoded as a string. "
+ "Kubeconfig exec authentication is not supported in Prowler Cloud for security reasons.",
}
},
"required": ["kubeconfig_content"],
@@ -294,16 +295,21 @@ from rest_framework_json_api import serializers
"type": "string",
"description": "The OCID of the tenancy.",
},
- "region": {
- "type": "string",
- "description": "The OCI region identifier (e.g., us-ashburn-1, us-phoenix-1).",
- },
"pass_phrase": {
"type": "string",
"description": "The passphrase for the private key, if encrypted.",
},
+ "region": {
+ "type": "string",
+ "deprecated": True,
+ "description": "Legacy OCI region field accepted for backwards compatibility but ignored; OCI scans all regions.",
+ },
},
- "required": ["user", "fingerprint", "tenancy", "region"],
+ "required": ["user", "fingerprint", "tenancy"],
+ "anyOf": [
+ {"required": ["key_file"]},
+ {"required": ["key_content"]},
+ ],
},
{
"type": "object",
@@ -404,6 +410,26 @@ from rest_framework_json_api import serializers
},
"required": ["clouds_yaml_content", "clouds_yaml_cloud"],
},
+ {
+ "type": "object",
+ "title": "Okta OAuth Credentials",
+ "properties": {
+ "okta_client_id": {
+ "type": "string",
+ "description": "Client ID of the Okta API Services app used for OAuth 2.0 private-key JWT authentication.",
+ },
+ "okta_private_key": {
+ "type": "string",
+ "description": "PEM-encoded private key whose matching public key (JWK) is registered on the Okta service app.",
+ },
+ "okta_scopes": {
+ "type": "array",
+ "items": {"type": "string"},
+ "description": "OAuth scopes to request. Optional; defaults to the minimum set required to run the currently enabled Okta checks.",
+ },
+ },
+ "required": ["okta_client_id", "okta_private_key"],
+ },
{
"type": "object",
"title": "Vercel API Token",
diff --git a/api/src/backend/api/v1/serializers.py b/api/src/backend/api/v1/serializers.py
index 51cb95fbff..750174e7a8 100644
--- a/api/src/backend/api/v1/serializers.py
+++ b/api/src/backend/api/v1/serializers.py
@@ -1,24 +1,8 @@
import base64
import json
-from datetime import datetime, timedelta, timezone
-
-from django.conf import settings
-from django.contrib.auth import authenticate
-from django.contrib.auth.models import update_last_login
-from django.contrib.auth.password_validation import validate_password
-from django.core.exceptions import ValidationError as DjangoValidationError
-from django.db import IntegrityError
-from drf_spectacular.utils import extend_schema_field
-from jwt.exceptions import InvalidKeyError
-from rest_framework.reverse import reverse
-from rest_framework.validators import UniqueTogetherValidator
-from rest_framework_json_api import serializers
-from rest_framework_json_api.relations import SerializerMethodResourceRelatedField
-from rest_framework_json_api.serializers import ValidationError
-from rest_framework_simplejwt.exceptions import TokenError
-from rest_framework_simplejwt.serializers import TokenObtainPairSerializer
-from rest_framework_simplejwt.tokens import RefreshToken
+from datetime import UTC, datetime, timedelta
+import yaml
from api.db_router import MainRouter
from api.exceptions import ConflictException
from api.models import (
@@ -54,6 +38,7 @@ from api.models import (
UserRoleRelationship,
)
from api.rls import Tenant
+from api.v1.serializer_utils.authentication import blacklist_user_refresh_tokens
from api.v1.serializer_utils.integrations import (
AWSCredentialSerializer,
IntegrationConfigField,
@@ -72,11 +57,45 @@ from api.v1.serializer_utils.lighthouse import (
)
from api.v1.serializer_utils.processors import ProcessorConfigField
from api.v1.serializer_utils.providers import ProviderSecretField
+from api.validators import validate_lighthouse_openai_compatible_base_url
+from django.conf import settings
+from django.contrib.auth import authenticate
+from django.contrib.auth.models import update_last_login
+from django.contrib.auth.password_validation import validate_password
+from django.core.exceptions import ValidationError as DjangoValidationError
+from django.db import IntegrityError, transaction
+from drf_spectacular.utils import extend_schema_field
+from jwt.exceptions import InvalidKeyError
from prowler.lib.mutelist.mutelist import Mutelist
+from rest_framework.reverse import reverse
+from rest_framework.validators import UniqueTogetherValidator
+from rest_framework_json_api import serializers
+from rest_framework_json_api.relations import SerializerMethodResourceRelatedField
+from rest_framework_json_api.serializers import ValidationError
+from rest_framework_simplejwt.exceptions import TokenError
+from rest_framework_simplejwt.serializers import TokenObtainPairSerializer
+from rest_framework_simplejwt.settings import api_settings
+from rest_framework_simplejwt.tokens import RefreshToken
+from rest_framework_simplejwt.utils import get_md5_hash_password
# Base
+def _validate_lighthouse_base_url_without_dns(base_url: str) -> None:
+ try:
+ validate_lighthouse_openai_compatible_base_url(base_url, resolve_dns=False)
+ except DjangoValidationError as error:
+ raise ValidationError({"base_url": error.messages[0]}) from error
+
+
+def _reraise_lighthouse_credentials_errors(error: ValidationError) -> None:
+ details = error.detail.copy()
+ for key, value in details.items():
+ error.detail[f"credentials/{key}"] = value
+ del error.detail[key]
+ raise error
+
+
class BaseModelSerializerV1(serializers.ModelSerializer):
def get_root_meta(self, _resource, _many):
return {"version": "v1"}
@@ -232,6 +251,18 @@ class TokenRefreshSerializer(BaseSerializerV1):
try:
# Validate the refresh token
refresh = RefreshToken(refresh_token)
+ if api_settings.CHECK_REVOKE_TOKEN:
+ user_id = refresh.payload.get(api_settings.USER_ID_CLAIM)
+ try:
+ user = User.objects.using(MainRouter.admin_db).get(
+ **{api_settings.USER_ID_FIELD: user_id}
+ )
+ except User.DoesNotExist:
+ raise TokenError("User not found.") from None
+ if refresh.get(api_settings.REVOKE_TOKEN_CLAIM) != (
+ get_md5_hash_password(user.password)
+ ):
+ raise TokenError("The user's password has been changed.")
# Generate new access token
access_token = refresh.access_token
@@ -405,7 +436,13 @@ class UserUpdateSerializer(BaseWriteSerializer):
password = validated_data.pop("password", None)
if password:
validate_password(password, user=instance)
- instance.set_password(password)
+ with transaction.atomic(using=MainRouter.admin_db):
+ instance.set_password(password)
+ for attr, value in validated_data.items():
+ setattr(instance, attr, value)
+ blacklist_user_refresh_tokens(instance.id)
+ instance.save(using=MainRouter.admin_db)
+ return instance
return super().update(instance, validated_data)
@@ -444,8 +481,8 @@ class UserRoleRelationshipSerializer(RLSSerializer, BaseWriteSerializer):
def create(self, validated_data):
role_ids = [item["id"] for item in validated_data["roles"]]
- roles = Role.objects.filter(id__in=role_ids)
tenant_id = self.context.get("tenant_id")
+ roles = Role.objects.filter(id__in=role_ids, tenant_id=tenant_id)
new_relationships = [
UserRoleRelationship(
@@ -459,8 +496,8 @@ class UserRoleRelationshipSerializer(RLSSerializer, BaseWriteSerializer):
def update(self, instance, validated_data):
role_ids = [item["id"] for item in validated_data["roles"]]
- roles = Role.objects.filter(id__in=role_ids)
tenant_id = self.context.get("tenant_id")
+ roles = Role.objects.filter(id__in=role_ids, tenant_id=tenant_id)
# Safeguard: A tenant must always have at least one user with MANAGE_ACCOUNT.
# If the target roles do NOT include MANAGE_ACCOUNT, and the current user is
@@ -490,7 +527,7 @@ class UserRoleRelationshipSerializer(RLSSerializer, BaseWriteSerializer):
}
)
- instance.roles.clear()
+ UserRoleRelationship.objects.filter(user=instance, tenant_id=tenant_id).delete()
new_relationships = [
UserRoleRelationship(user=instance, role=r, tenant_id=tenant_id)
for r in roles
@@ -1397,6 +1434,7 @@ class ResourceIncludeSerializer(RLSSerializer):
"service",
"type_",
"tags",
+ "metadata",
"details",
"partition",
]
@@ -1404,6 +1442,7 @@ class ResourceIncludeSerializer(RLSSerializer):
"id": {"read_only": True},
"inserted_at": {"read_only": True},
"updated_at": {"read_only": True},
+ "metadata": {"read_only": True},
"details": {"read_only": True},
"partition": {"read_only": True},
}
@@ -1529,6 +1568,32 @@ class FindingMetadataSerializer(BaseSerializerV1):
# Provider secrets
+KUBERNETES_KUBECONFIG_EXEC_ERROR = (
+ "Kubernetes kubeconfig exec authentication is not supported in Prowler Cloud "
+ "for security reasons."
+)
+KUBERNETES_KUBECONFIG_INVALID_ERROR = "Invalid Kubernetes kubeconfig content."
+
+
+def kubeconfig_contains_exec_auth(kubeconfig: dict) -> bool:
+ users = kubeconfig.get("users", [])
+ if not isinstance(users, list):
+ raise ValidationError(KUBERNETES_KUBECONFIG_INVALID_ERROR)
+
+ for user_entry in users:
+ if not isinstance(user_entry, dict):
+ raise ValidationError(KUBERNETES_KUBECONFIG_INVALID_ERROR)
+
+ user = user_entry.get("user", {})
+ if not isinstance(user, dict):
+ raise ValidationError(KUBERNETES_KUBECONFIG_INVALID_ERROR)
+
+ if "exec" in user:
+ return True
+
+ return False
+
+
class BaseWriteProviderSecretSerializer(BaseWriteSerializer):
@staticmethod
def validate_secret_based_on_provider(
@@ -1543,6 +1608,8 @@ class BaseWriteProviderSecretSerializer(BaseWriteSerializer):
serializer = GCPProviderSecret(data=secret)
elif provider_type == Provider.ProviderChoices.GOOGLEWORKSPACE.value:
serializer = GoogleWorkspaceProviderSecret(data=secret)
+ elif provider_type == Provider.ProviderChoices.OKTA.value:
+ serializer = OktaProviderSecret(data=secret)
elif provider_type == Provider.ProviderChoices.GITHUB.value:
serializer = GithubProviderSecret(data=secret)
elif provider_type == Provider.ProviderChoices.IAC.value:
@@ -1605,6 +1672,7 @@ class BaseWriteProviderSecretSerializer(BaseWriteSerializer):
validation_error.detail[f"secret/{key}"] = value
del validation_error.detail[key]
raise validation_error
+ return serializer.validated_data
class AwsProviderSecret(serializers.Serializer):
@@ -1688,6 +1756,15 @@ class GoogleWorkspaceProviderSecret(serializers.Serializer):
resource_name = "provider-secrets"
+class OktaProviderSecret(serializers.Serializer):
+ okta_client_id = serializers.CharField()
+ okta_private_key = serializers.CharField()
+ okta_scopes = serializers.ListField(child=serializers.CharField(), required=False)
+
+ class Meta:
+ resource_name = "provider-secrets"
+
+
class MongoDBAtlasProviderSecret(serializers.Serializer):
atlas_public_key = serializers.CharField()
atlas_private_key = serializers.CharField()
@@ -1699,6 +1776,22 @@ class MongoDBAtlasProviderSecret(serializers.Serializer):
class KubernetesProviderSecret(serializers.Serializer):
kubeconfig_content = serializers.CharField()
+ def validate_kubeconfig_content(self, kubeconfig_content):
+ try:
+ kubeconfig = yaml.safe_load(kubeconfig_content)
+ except yaml.YAMLError as exc:
+ raise serializers.ValidationError(
+ KUBERNETES_KUBECONFIG_INVALID_ERROR
+ ) from exc
+
+ if not isinstance(kubeconfig, dict):
+ raise serializers.ValidationError(KUBERNETES_KUBECONFIG_INVALID_ERROR)
+
+ if kubeconfig_contains_exec_auth(kubeconfig):
+ raise serializers.ValidationError(KUBERNETES_KUBECONFIG_EXEC_ERROR)
+
+ return kubeconfig_content
+
class Meta:
resource_name = "provider-secrets"
@@ -1721,14 +1814,32 @@ class IacProviderSecret(serializers.Serializer):
resource_name = "provider-secrets"
+class LegacyOCIRegionField(serializers.Field):
+ def to_internal_value(self, data):
+ return data
+
+ def to_representation(self, value):
+ return value
+
+
class OracleCloudProviderSecret(serializers.Serializer):
user = serializers.CharField()
fingerprint = serializers.CharField()
key_file = serializers.CharField(required=False)
key_content = serializers.CharField(required=False)
tenancy = serializers.CharField()
- region = serializers.CharField()
pass_phrase = serializers.CharField(required=False)
+ region = LegacyOCIRegionField(required=False, allow_null=True)
+
+ def validate(self, attrs):
+ attrs.pop("region", None)
+
+ if "key_file" not in attrs and "key_content" not in attrs:
+ raise serializers.ValidationError(
+ {"key_file": "Either key_file or key_content must be provided."}
+ )
+
+ return attrs
class Meta:
resource_name = "provider-secrets"
@@ -1873,7 +1984,11 @@ class ProviderSecretCreateSerializer(RLSSerializer, BaseWriteProviderSecretSeria
secret = attrs.get("secret")
validated_attrs = super().validate(attrs)
- self.validate_secret_based_on_provider(provider.provider, secret_type, secret)
+ validated_secret = self.validate_secret_based_on_provider(
+ provider.provider, secret_type, secret
+ )
+ if provider.provider == Provider.ProviderChoices.ORACLECLOUD.value:
+ validated_attrs["secret"] = validated_secret
return validated_attrs
@@ -1905,7 +2020,11 @@ class ProviderSecretUpdateSerializer(BaseWriteProviderSecretSerializer):
secret = attrs.get("secret")
validated_attrs = super().validate(attrs)
- self.validate_secret_based_on_provider(provider.provider, secret_type, secret)
+ validated_secret = self.validate_secret_based_on_provider(
+ provider.provider, secret_type, secret
+ )
+ if provider.provider == Provider.ProviderChoices.ORACLECLOUD.value:
+ validated_attrs["secret"] = validated_secret
return validated_attrs
@@ -1968,7 +2087,7 @@ class InvitationBaseWriteSerializer(BaseWriteSerializer):
return value
def validate_expires_at(self, value):
- now = datetime.now(timezone.utc)
+ now = datetime.now(UTC)
if value and value < now + timedelta(hours=24):
raise ValidationError(
"Expiry date must be at least 24 hours in the future."
@@ -3135,6 +3254,9 @@ class ProcessorUpdateSerializer(BaseWriteSerializer):
class SamlInitiateSerializer(BaseSerializerV1):
email_domain = serializers.CharField()
+ callback_url = serializers.CharField(
+ required=False, allow_blank=True, max_length=2048
+ )
class JSONAPIMeta:
resource_name = "saml-initiate"
@@ -3566,11 +3688,7 @@ class LighthouseProviderConfigCreateSerializer(RLSSerializer, BaseWriteSerialize
raise_exception=True
)
except ValidationError as e:
- details = e.detail.copy()
- for key, value in details.items():
- e.detail[f"credentials/{key}"] = value
- del e.detail[key]
- raise e
+ _reraise_lighthouse_credentials_errors(e)
elif (
provider_type == LighthouseProviderConfiguration.LLMProviderChoices.BEDROCK
):
@@ -3579,27 +3697,20 @@ class LighthouseProviderConfigCreateSerializer(RLSSerializer, BaseWriteSerialize
raise_exception=True
)
except ValidationError as e:
- details = e.detail.copy()
- for key, value in details.items():
- e.detail[f"credentials/{key}"] = value
- del e.detail[key]
- raise e
+ _reraise_lighthouse_credentials_errors(e)
elif (
provider_type
== LighthouseProviderConfiguration.LLMProviderChoices.OPENAI_COMPATIBLE
):
if not base_url:
raise ValidationError({"base_url": "Base URL is required."})
+ _validate_lighthouse_base_url_without_dns(base_url)
try:
OpenAICompatibleCredentialsSerializer(data=credentials).is_valid(
raise_exception=True
)
except ValidationError as e:
- details = e.detail.copy()
- for key, value in details.items():
- e.detail[f"credentials/{key}"] = value
- del e.detail[key]
- raise e
+ _reraise_lighthouse_credentials_errors(e)
return super().validate(attrs)
@@ -3662,11 +3773,7 @@ class LighthouseProviderConfigUpdateSerializer(BaseWriteSerializer):
raise_exception=True
)
except ValidationError as e:
- details = e.detail.copy()
- for key, value in details.items():
- e.detail[f"credentials/{key}"] = value
- del e.detail[key]
- raise e
+ _reraise_lighthouse_credentials_errors(e)
elif (
credentials is not None
and provider_type
@@ -3690,11 +3797,7 @@ class LighthouseProviderConfigUpdateSerializer(BaseWriteSerializer):
raise_exception=True
)
except ValidationError as e:
- details = e.detail.copy()
- for key, value in details.items():
- e.detail[f"credentials/{key}"] = value
- del e.detail[key]
- raise e
+ _reraise_lighthouse_credentials_errors(e)
# Then enforce invariants about not changing the auth method
# If the existing config uses an API key, forbid introducing access keys.
@@ -3721,24 +3824,23 @@ class LighthouseProviderConfigUpdateSerializer(BaseWriteSerializer):
}
)
elif (
- credentials is not None
- and provider_type
+ provider_type
== LighthouseProviderConfiguration.LLMProviderChoices.OPENAI_COMPATIBLE
):
- if base_url is None:
- pass
- elif not base_url:
+ effective_base_url = (
+ base_url if "base_url" in attrs else getattr(self.instance, "base_url")
+ )
+ if not effective_base_url:
raise ValidationError({"base_url": "Base URL cannot be empty."})
- try:
- OpenAICompatibleCredentialsSerializer(data=credentials).is_valid(
- raise_exception=True
- )
- except ValidationError as e:
- details = e.detail.copy()
- for key, value in details.items():
- e.detail[f"credentials/{key}"] = value
- del e.detail[key]
- raise e
+ if "base_url" in attrs:
+ _validate_lighthouse_base_url_without_dns(effective_base_url)
+ if credentials is not None:
+ try:
+ OpenAICompatibleCredentialsSerializer(data=credentials).is_valid(
+ raise_exception=True
+ )
+ except ValidationError as e:
+ _reraise_lighthouse_credentials_errors(e)
return super().validate(attrs)
diff --git a/api/src/backend/api/v1/urls.py b/api/src/backend/api/v1/urls.py
index 533106d0e4..b53fe1c817 100644
--- a/api/src/backend/api/v1/urls.py
+++ b/api/src/backend/api/v1/urls.py
@@ -1,10 +1,4 @@
from allauth.socialaccount.providers.saml.views import ACSView, MetadataView, SLSView
-from django.http import JsonResponse
-from django.urls import include, path
-from django.views.decorators.csrf import csrf_exempt
-from drf_spectacular.views import SpectacularRedocView
-from rest_framework_nested import routers
-
from api.v1.views import (
AttackPathsScanViewSet,
ComplianceOverviewViewSet,
@@ -49,6 +43,11 @@ from api.v1.views import (
UserRoleRelationshipView,
UserViewSet,
)
+from django.http import JsonResponse
+from django.urls import include, path
+from django.views.decorators.csrf import csrf_exempt
+from drf_spectacular.views import SpectacularRedocView
+from rest_framework_nested import routers
# This helper view is used to block any endpoints that should not be available
diff --git a/api/src/backend/api/v1/views.py b/api/src/backend/api/v1/views.py
index b7b276a8fc..e392505818 100644
--- a/api/src/backend/api/v1/views.py
+++ b/api/src/backend/api/v1/views.py
@@ -7,120 +7,31 @@ import time
import uuid
from collections import defaultdict
from copy import deepcopy
-from datetime import datetime, timedelta, timezone
+from datetime import UTC, datetime, timedelta
from decimal import ROUND_HALF_UP, Decimal, InvalidOperation
-from urllib.parse import urljoin
+from urllib.parse import urlencode, urljoin
import sentry_sdk
from allauth.socialaccount.models import SocialAccount, SocialApp
from allauth.socialaccount.providers.github.views import GitHubOAuth2Adapter
from allauth.socialaccount.providers.google.views import GoogleOAuth2Adapter
from allauth.socialaccount.providers.saml.views import FinishACSView, LoginView
-from botocore.exceptions import ClientError, NoCredentialsError, ParamValidationError
-from celery import chain, states
-from celery.result import AsyncResult
-from config.custom_logging import BackendLogger
-from config.env import env
-from config.version import RELEASE_ID
-from config.settings.social_login import (
- GITHUB_OAUTH_CALLBACK_URL,
- GOOGLE_OAUTH_CALLBACK_URL,
-)
-from dj_rest_auth.registration.views import SocialLoginView
-from django.conf import settings as django_settings
-from django.contrib.postgres.aggregates import ArrayAgg, BoolAnd, StringAgg
-from django.contrib.postgres.search import SearchQuery
-from django.db import transaction
-from django.db.models import (
- BooleanField,
- Case,
- CharField,
- Count,
- DecimalField,
- Exists,
- ExpressionWrapper,
- F,
- IntegerField,
- Max,
- Min,
- OuterRef,
- Prefetch,
- Q,
- QuerySet,
- Subquery,
- Sum,
- Value,
- When,
- Window,
-)
-from django.db.models.fields.json import KeyTextTransform
-from django.db.models.functions import Cast, Coalesce, RowNumber
-from django.http import HttpResponse, HttpResponseBase, HttpResponseRedirect, QueryDict
-from django.shortcuts import redirect
-from django.urls import reverse
-from django.utils.dateparse import parse_date
-from django.utils.decorators import method_decorator
-from django.views.decorators.cache import cache_control
-from django_celery_beat.models import PeriodicTask
-from django_celery_results.models import TaskResult
-from drf_spectacular.settings import spectacular_settings
-from drf_spectacular.types import OpenApiTypes
-from drf_spectacular.utils import (
- OpenApiParameter,
- OpenApiResponse,
- extend_schema,
- extend_schema_view,
-)
-from drf_spectacular.views import SpectacularAPIView
-from drf_spectacular_jsonapi.schemas.openapi import JsonApiAutoSchema
-from rest_framework import permissions, status
-from rest_framework.decorators import action
-from rest_framework.exceptions import (
- MethodNotAllowed,
- NotFound,
- PermissionDenied,
- ValidationError,
-)
-from rest_framework.generics import GenericAPIView, get_object_or_404
-from rest_framework.permissions import SAFE_METHODS
-from rest_framework_json_api import filters as jsonapi_filters
-from rest_framework_json_api.views import RelationshipView, Response
-from rest_framework_simplejwt.exceptions import InvalidToken, TokenError
-from rest_framework_simplejwt.token_blacklist.models import (
- BlacklistedToken,
- OutstandingToken,
-)
-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,
- backfill_scan_resource_summaries_task,
- check_integration_connection_task,
- check_lighthouse_connection_task,
- check_lighthouse_provider_connection_task,
- check_provider_connection_task,
- delete_provider_task,
- delete_tenant_task,
- jira_integration_task,
- mute_historical_findings_task,
- perform_scan_task,
- reaggregate_all_finding_group_summaries_task,
- 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 (
+ COMPLIANCE_WARMED,
+ COMPLIANCE_WARMING_STARTED,
PROWLER_COMPLIANCE_OVERVIEW_TEMPLATE,
get_compliance_frameworks,
+ get_prowler_provider_compliance,
)
from api.constants import SEVERITY_ORDER
from api.db_router import MainRouter
from api.db_utils import rls_transaction
from api.exceptions import (
+ ComplianceWarmingError,
TaskFailedException,
UpstreamAccessDeniedError,
UpstreamAuthenticationError,
@@ -139,6 +50,7 @@ from api.filters import (
FindingGroupAggregatedComputedFilter,
FindingGroupFilter,
FindingGroupSummaryFilter,
+ FindingMetadataFilter,
IntegrationFilter,
IntegrationJiraFindingsFilter,
InvitationFilter,
@@ -217,13 +129,20 @@ from api.renderers import APIJSONRenderer, PlainTextRenderer
from api.rls import Tenant
from api.utils import (
CustomOAuth2Client,
+ accept_invitation_for_user,
get_findings_metadata_no_aggregations,
initialize_prowler_integration,
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.mixins import (
+ DisablePaginationMixin,
+ JsonApiFilterMixin,
+ PaginateByPkMixin,
+ ProviderFilterParamsMixin,
+ TaskManagementMixin,
+)
from api.v1.serializers import (
AttackPathsCartographySchemaSerializer,
AttackPathsCustomQueryRunRequestSerializer,
@@ -317,6 +236,63 @@ from api.v1.serializers import (
UserSerializer,
UserUpdateSerializer,
)
+from botocore.exceptions import ClientError, NoCredentialsError, ParamValidationError
+from celery import chain
+from celery.result import AsyncResult
+from config.custom_logging import BackendLogger
+from config.env import env
+from config.settings.social_login import (
+ GITHUB_OAUTH_CALLBACK_URL,
+ GOOGLE_OAUTH_CALLBACK_URL,
+)
+from config.version import RELEASE_ID
+from dj_rest_auth.registration.views import SocialLoginView
+from django.conf import settings as django_settings
+from django.contrib.postgres.aggregates import ArrayAgg, BoolAnd, StringAgg
+from django.contrib.postgres.search import SearchQuery
+from django.core.exceptions import ValidationError as DjangoValidationError
+from django.db import transaction
+from django.db.models import (
+ BooleanField,
+ Case,
+ CharField,
+ Count,
+ DecimalField,
+ Exists,
+ ExpressionWrapper,
+ F,
+ IntegerField,
+ Max,
+ Min,
+ OuterRef,
+ Prefetch,
+ Q,
+ QuerySet,
+ Subquery,
+ Sum,
+ Value,
+ When,
+ Window,
+)
+from django.db.models.fields.json import KeyTextTransform
+from django.db.models.functions import Cast, Coalesce, RowNumber
+from django.http import HttpResponse, HttpResponseBase, HttpResponseRedirect, QueryDict
+from django.shortcuts import redirect
+from django.urls import reverse
+from django.utils.dateparse import parse_date
+from django.utils.decorators import method_decorator
+from django.views.decorators.cache import cache_control
+from django_celery_beat.models import PeriodicTask
+from drf_spectacular.settings import spectacular_settings
+from drf_spectacular.types import OpenApiTypes
+from drf_spectacular.utils import (
+ OpenApiParameter,
+ OpenApiResponse,
+ extend_schema,
+ extend_schema_view,
+)
+from drf_spectacular.views import SpectacularAPIView
+from drf_spectacular_jsonapi.schemas.openapi import JsonApiAutoSchema
from prowler.providers.aws.exceptions.exceptions import (
AWSAssumeRoleError,
AWSCredentialsError,
@@ -324,6 +300,44 @@ from prowler.providers.aws.exceptions.exceptions import (
from prowler.providers.aws.lib.cloudtrail_timeline.cloudtrail_timeline import (
CloudTrailTimeline,
)
+from rest_framework import permissions, status
+from rest_framework.decorators import action
+from rest_framework.exceptions import (
+ MethodNotAllowed,
+ NotFound,
+ PermissionDenied,
+ ValidationError,
+)
+from rest_framework.generics import GenericAPIView, get_object_or_404
+from rest_framework.permissions import SAFE_METHODS
+from rest_framework_json_api import filters as jsonapi_filters
+from rest_framework_json_api.views import RelationshipView, Response
+from rest_framework_simplejwt.exceptions import InvalidToken, TokenError
+from rest_framework_simplejwt.token_blacklist.models import (
+ BlacklistedToken,
+ OutstandingToken,
+)
+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 (
+ QUEUED_SCAN_TASK_STATE,
+ backfill_compliance_summaries_task,
+ backfill_scan_resource_summaries_task,
+ check_integration_connection_task,
+ check_lighthouse_connection_task,
+ check_lighthouse_provider_connection_task,
+ check_provider_connection_task,
+ create_scan_task_record,
+ delete_provider_task,
+ delete_tenant_task,
+ enqueue_scan_execution_on_commit,
+ get_active_provider_scan,
+ jira_integration_task,
+ mute_historical_findings_task,
+ reaggregate_all_finding_group_summaries_task,
+ refresh_lighthouse_provider_models_task,
+)
logger = logging.getLogger(BackendLogger.API)
@@ -531,6 +545,46 @@ class SchemaView(SpectacularAPIView):
return super().get(request, *args, **kwargs)
+SAML_CALLBACK_SESSION_KEY = "saml_callback_url"
+
+
+def _safe_callback_path(value):
+ if not value or not isinstance(value, str):
+ return None
+ if not value.startswith("/") or value.startswith("//"):
+ return None
+ return value
+
+
+def _get_request_invitation_token(request):
+ for source_name in ("data", "POST"):
+ data = getattr(request, source_name, None) or {}
+ if not hasattr(data, "get"):
+ continue
+ invitation_token = data.get("invitation_token")
+ if invitation_token:
+ return invitation_token
+
+ wrapped_request = getattr(request, "_request", None)
+ if wrapped_request and wrapped_request is not request:
+ return _get_request_invitation_token(wrapped_request)
+
+ return None
+
+
+def _accept_social_invitation(request, user):
+ invitation_token = _get_request_invitation_token(request)
+ tenant_id = getattr(request, "prowler_invitation_tenant_id", None)
+ if invitation_token and not tenant_id:
+ invitation, _ = accept_invitation_for_user(
+ user=user,
+ invitation_token=invitation_token,
+ raise_not_found=True,
+ )
+ tenant_id = str(invitation.tenant_id)
+ return tenant_id
+
+
@extend_schema(exclude=True)
class GoogleSocialLoginView(SocialLoginView):
adapter_class = GoogleOAuth2Adapter
@@ -541,7 +595,11 @@ class GoogleSocialLoginView(SocialLoginView):
original_response = super().get_response()
if self.user and self.user.is_authenticated:
- serializer = TokenSocialLoginSerializer(data={"email": self.user.email})
+ tenant_id = _accept_social_invitation(self.request, self.user)
+ serializer_data = {"email": self.user.email}
+ if tenant_id:
+ serializer_data["tenant_id"] = tenant_id
+ serializer = TokenSocialLoginSerializer(data=serializer_data)
try:
serializer.is_valid(raise_exception=True)
except TokenError as e:
@@ -566,7 +624,11 @@ class GithubSocialLoginView(SocialLoginView):
original_response = super().get_response()
if self.user and self.user.is_authenticated:
- serializer = TokenSocialLoginSerializer(data={"email": self.user.email})
+ tenant_id = _accept_social_invitation(self.request, self.user)
+ serializer_data = {"email": self.user.email}
+ if tenant_id:
+ serializer_data["tenant_id"] = tenant_id
+ serializer = TokenSocialLoginSerializer(data=serializer_data)
try:
serializer.is_valid(raise_exception=True)
@@ -626,6 +688,10 @@ class CustomSAMLLoginView(LoginView):
This approach maintains security while providing better UX.
"""
+ callback_url = _safe_callback_path(request.GET.get("callback_url"))
+ request.session.pop(SAML_CALLBACK_SESSION_KEY, None)
+ if callback_url:
+ request.session[SAML_CALLBACK_SESSION_KEY] = callback_url
if request.method == "GET":
# Convert GET to POST while preserving parameters
request.method = "POST"
@@ -670,6 +736,11 @@ class SAMLInitiateAPIView(GenericAPIView):
"saml_login", kwargs={"organization_slug": config.email_domain}
)
login_url = urljoin(api_host, login_path)
+ callback_url = _safe_callback_path(
+ serializer.validated_data.get("callback_url")
+ )
+ if callback_url:
+ login_url = f"{login_url}?{urlencode({'callback_url': callback_url})}"
return redirect(login_url)
@@ -756,7 +827,10 @@ class TenantFinishACSView(FinishACSView):
try:
check = SAMLDomainIndex.objects.get(email_domain=organization_slug)
with rls_transaction(str(check.tenant_id)):
- SAMLConfiguration.objects.get(tenant_id=str(check.tenant_id))
+ saml_config = SAMLConfiguration.objects.select_related("tenant").get(
+ tenant_id=str(check.tenant_id)
+ )
+ tenant = saml_config.tenant
social_app = SocialApp.objects.get(
provider="saml", client_id=organization_slug
)
@@ -776,6 +850,15 @@ class TenantFinishACSView(FinishACSView):
callback_url = env.str("AUTH_URL")
return redirect(f"{callback_url}?sso_saml_failed=true")
+ requested_domain = organization_slug.lower()
+ configured_domain = saml_config.email_domain.lower()
+ email_domain = user.email.rsplit("@", 1)[-1].lower()
+ if configured_domain != requested_domain or email_domain != configured_domain:
+ logger.error("SAML email domain does not match requested organization")
+ self._rollback_saml_user(request)
+ callback_url = env.str("AUTH_URL")
+ return redirect(f"{callback_url}?sso_saml_failed=true")
+
extra = social_account.extra_data
user.first_name = (
extra.get("firstName", [""])[0] if extra.get("firstName") else ""
@@ -789,67 +872,70 @@ class TenantFinishACSView(FinishACSView):
user.name = "N/A"
user.save()
- email_domain = user.email.split("@")[-1]
- tenant = (
- SAMLConfiguration.objects.using(MainRouter.admin_db)
- .get(email_domain=email_domain)
- .tenant
- )
-
+ # Only remap roles when the IdP provides a userType attribute.
+ # Without it, the user's current roles are left untouched.
role_name = (
- extra.get("userType", ["no_permissions"])[0].strip()
- if extra.get("userType")
- else "no_permissions"
+ extra.get("userType", [""])[0].strip() if extra.get("userType") else ""
)
- role = (
- Role.objects.using(MainRouter.admin_db)
- .filter(name=role_name, tenant=tenant)
- .first()
- )
-
- # Only skip mapping if it would remove the last MANAGE_ACCOUNT user
- remaining_manage_account_users = (
- UserRoleRelationship.objects.using(MainRouter.admin_db)
- .filter(role__manage_account=True, tenant_id=tenant.id)
- .exclude(user_id=user_id)
- .values("user")
- .distinct()
- .count()
- )
- user_has_manage_account = (
- UserRoleRelationship.objects.using(MainRouter.admin_db)
- .filter(role__manage_account=True, tenant_id=tenant.id, user_id=user_id)
- .exists()
- )
- role_manage_account = role.manage_account if role else False
- would_remove_last_manage_account = (
- user_has_manage_account
- and remaining_manage_account_users == 0
- and not role_manage_account
- )
-
- if not would_remove_last_manage_account:
- if role is None:
- role = Role.objects.using(MainRouter.admin_db).create(
- name=role_name,
- tenant=tenant,
- manage_users=False,
- manage_account=False,
- manage_billing=False,
- manage_providers=False,
- manage_integrations=False,
- manage_scans=False,
- unlimited_visibility=False,
+ if role_name:
+ with transaction.atomic(using=MainRouter.admin_db):
+ role = (
+ Role.objects.using(MainRouter.admin_db)
+ .filter(name=role_name, tenant=tenant)
+ .first()
)
- UserRoleRelationship.objects.using(MainRouter.admin_db).filter(
- user=user,
- tenant_id=tenant.id,
- ).delete()
- UserRoleRelationship.objects.using(MainRouter.admin_db).create(
- user=user,
- role=role,
- tenant_id=tenant.id,
- )
+
+ # Only skip mapping if it would remove the last MANAGE_ACCOUNT user
+ remaining_manage_account_users = (
+ UserRoleRelationship.objects.using(MainRouter.admin_db)
+ .filter(role__manage_account=True, tenant_id=tenant.id)
+ .exclude(user_id=user_id)
+ .values("user")
+ .distinct()
+ .count()
+ )
+ user_has_manage_account = (
+ UserRoleRelationship.objects.using(MainRouter.admin_db)
+ .filter(
+ role__manage_account=True,
+ tenant_id=tenant.id,
+ user_id=user_id,
+ )
+ .exists()
+ )
+ role_manage_account = role.manage_account if role else False
+ would_remove_last_manage_account = (
+ user_has_manage_account
+ and remaining_manage_account_users == 0
+ and not role_manage_account
+ )
+
+ if not would_remove_last_manage_account:
+ if role is None:
+ # Roles auto-created from userType get read-only access:
+ # visibility over all providers, no management permissions
+ role, _ = Role.objects.using(MainRouter.admin_db).get_or_create(
+ name=role_name,
+ tenant=tenant,
+ defaults={
+ "manage_users": False,
+ "manage_account": False,
+ "manage_billing": False,
+ "manage_providers": False,
+ "manage_integrations": False,
+ "manage_scans": False,
+ "unlimited_visibility": True,
+ },
+ )
+ UserRoleRelationship.objects.using(MainRouter.admin_db).filter(
+ user=user,
+ tenant_id=tenant.id,
+ ).delete()
+ UserRoleRelationship.objects.using(MainRouter.admin_db).create(
+ user=user,
+ role=role,
+ tenant_id=tenant.id,
+ )
membership, _ = Membership.objects.using(MainRouter.admin_db).get_or_create(
user=user,
tenant=tenant,
@@ -870,7 +956,13 @@ class TenantFinishACSView(FinishACSView):
token=token_data, user=user
)
callback_url = env.str("SAML_SSO_CALLBACK_URL")
- redirect_url = f"{callback_url}?id={saml_token.id}"
+ redirect_params = {"id": str(saml_token.id)}
+ saml_callback_url = _safe_callback_path(
+ request.session.pop(SAML_CALLBACK_SESSION_KEY, None)
+ )
+ if saml_callback_url:
+ redirect_params["callbackUrl"] = saml_callback_url
+ redirect_url = f"{callback_url}?{urlencode(redirect_params)}"
request.session.pop("saml_user_created", None)
return redirect(redirect_url)
@@ -922,8 +1014,8 @@ class UserViewSet(BaseUserViewset):
"""
Returns the required permissions based on the request method.
"""
- if self.action == "me":
- # No permissions required for me request
+ if self.action in ["me", "partial_update"]:
+ # No permissions required for me and partial_update requests
self.required_permissions = []
else:
# Require permission for the rest of the requests
@@ -977,6 +1069,24 @@ class UserViewSet(BaseUserViewset):
status=status.HTTP_200_OK,
)
+ def partial_update(self, request, *args, **kwargs):
+ user = self.get_object()
+ if user.id != self.request.user.id:
+ role = get_role(self.request.user, self.request.tenant_id)
+ if not getattr(role, Permissions.MANAGE_USERS.value, False):
+ raise ValidationError(
+ "Only users with manage users permission can update other users."
+ )
+
+ serializer = self.get_serializer(user, data=request.data, partial=True)
+ serializer.is_valid(raise_exception=True)
+ self.perform_update(serializer)
+
+ if getattr(user, "_prefetched_objects_cache", None):
+ user._prefetched_objects_cache = {}
+
+ return Response(serializer.data)
+
def destroy(self, request, *args, **kwargs):
if kwargs["pk"] != str(self.request.user.id):
raise ValidationError("Only the current user can be deleted.")
@@ -1849,7 +1959,42 @@ class ProviderViewSet(DisablePaginationMixin, BaseRLSViewSet):
200: OpenApiResponse(
description="CSV file containing the compliance report"
),
- 404: OpenApiResponse(description="Compliance report not found"),
+ 202: OpenApiResponse(description="The task is in progress"),
+ 403: OpenApiResponse(description="There is a problem with credentials"),
+ 404: OpenApiResponse(
+ description="Compliance report not found, or the scan has no reports yet"
+ ),
+ },
+ request=None,
+ ),
+ compliance_ocsf=extend_schema(
+ tags=["Scan"],
+ summary="Retrieve compliance report as OCSF JSON",
+ description=(
+ "Download a specific compliance report as an OCSF JSON file. "
+ "Only universal frameworks that declare an output configuration "
+ "produce this artifact (currently 'dora_2022_2554', 'csa_ccm_4.0' "
+ "and 'cis_controls_8.1'); any other framework returns 404."
+ ),
+ parameters=[
+ OpenApiParameter(
+ name="name",
+ type=str,
+ location=OpenApiParameter.PATH,
+ required=True,
+ description="The compliance report name, like 'dora_2022_2554'",
+ ),
+ ],
+ responses={
+ 200: OpenApiResponse(
+ description="OCSF JSON file containing the compliance report"
+ ),
+ 202: OpenApiResponse(description="The task is in progress"),
+ 403: OpenApiResponse(description="There is a problem with credentials"),
+ 404: OpenApiResponse(
+ description="Compliance report not found, the framework does "
+ "not provide an OCSF export, or the scan has no reports yet"
+ ),
},
request=None,
),
@@ -1992,35 +2137,23 @@ class ScanViewSet(BaseRLSViewSet):
return queryset.select_related("provider", "task")
def get_serializer_class(self):
- if self.action == "create":
- if hasattr(self, "response_serializer_class"):
- return self.response_serializer_class
- return ScanCreateSerializer
- elif self.action == "partial_update":
+ if self.action == "partial_update":
return ScanUpdateSerializer
- elif self.action == "report":
- if hasattr(self, "response_serializer_class"):
- return self.response_serializer_class
- return ScanReportSerializer
- elif self.action == "compliance":
- if hasattr(self, "response_serializer_class"):
- return self.response_serializer_class
- return ScanComplianceReportSerializer
- elif self.action == "threatscore":
- if hasattr(self, "response_serializer_class"):
- return self.response_serializer_class
- elif self.action == "ens":
- if hasattr(self, "response_serializer_class"):
- return self.response_serializer_class
- elif self.action == "nis2":
- if hasattr(self, "response_serializer_class"):
- return self.response_serializer_class
- elif self.action == "csa":
- if hasattr(self, "response_serializer_class"):
- return self.response_serializer_class
- elif self.action == "cis":
+
+ action_defaults = {
+ "create": ScanCreateSerializer,
+ "report": ScanReportSerializer,
+ "compliance": ScanComplianceReportSerializer,
+ "compliance_ocsf": ScanComplianceReportSerializer,
+ }
+ response_only_actions = {"threatscore", "ens", "nis2", "csa", "cis"}
+
+ if self.action in action_defaults or self.action in response_only_actions:
if hasattr(self, "response_serializer_class"):
return self.response_serializer_class
+ if self.action in action_defaults:
+ return action_defaults[self.action]
+
return super().get_serializer_class()
def partial_update(self, request, *args, **kwargs):
@@ -2059,12 +2192,17 @@ class ScanViewSet(BaseRLSViewSet):
if scan_instance.state == StateChoices.EXECUTING and scan_instance.task:
task = scan_instance.task
else:
- try:
- task = Task.objects.get(
+ # A scan can have several `scan-report` tasks (e.g. re-runs); take the
+ # most recent one. `.first()` also avoids `MultipleObjectsReturned`.
+ task = (
+ Task.objects.filter(
task_runner_task__task_name="scan-report",
task_runner_task__task_kwargs__contains=str(scan_instance.id),
)
- except Task.DoesNotExist:
+ .order_by("-inserted_at")
+ .first()
+ )
+ if task is None:
return None
self.response_serializer_class = TaskSerializer
@@ -2139,27 +2277,32 @@ class ScanViewSet(BaseRLSViewSet):
status=status.HTTP_502_BAD_GATEWAY,
)
contents = resp.get("Contents", [])
- keys = []
+ matches = []
for obj in contents:
key = obj["Key"]
key_basename = os.path.basename(key)
if any(ch in suffix for ch in ("*", "?", "[")):
if fnmatch.fnmatch(key_basename, suffix):
- keys.append(key)
+ matches.append(obj)
elif key_basename == suffix:
- keys.append(key)
+ matches.append(obj)
elif key.endswith(suffix):
# Backward compatibility if suffix already includes directories
- keys.append(key)
- if not keys:
+ matches.append(obj)
+ if not matches:
return Response(
{
"detail": f"No compliance file found for name '{os.path.splitext(suffix)[0]}'."
},
status=status.HTTP_404_NOT_FOUND,
)
- # path_pattern here is prefix, but in compliance we build correct suffix check before
- key = keys[0]
+ # Return the most recently modified match (latest report) when
+ # several files share the prefix/suffix. `list_objects_v2` always
+ # returns `LastModified`; the fallback keeps ordering deterministic
+ # if it is ever absent.
+ key = max(matches, key=lambda o: (o.get("LastModified", ""), o["Key"]))[
+ "Key"
+ ]
else:
# path_pattern is exact key; HEAD before presigning to preserve the 404 contract.
key = path_pattern
@@ -2209,7 +2352,9 @@ class ScanViewSet(BaseRLSViewSet):
},
status=status.HTTP_404_NOT_FOUND,
)
- filepath = files[0]
+ # Return the most recently modified match (latest report) when the
+ # pattern resolves to several files.
+ filepath = max(files, key=os.path.getmtime)
with open(filepath, "rb") as f:
content = f.read()
filename = os.path.basename(filepath)
@@ -2257,20 +2402,16 @@ class ScanViewSet(BaseRLSViewSet):
content, filename = loader
return self._serve_file(content, filename, "application/x-zip-compressed")
- @action(
- detail=True,
- methods=["get"],
- url_path="compliance/(?P[^/]+)",
- url_name="compliance",
- )
- def compliance(self, request, pk=None, name=None):
- scan = self.get_object()
- if name not in get_compliance_frameworks(scan.provider.provider):
- return Response(
- {"detail": f"Compliance '{name}' not found."},
- status=status.HTTP_404_NOT_FOUND,
- )
+ def _serve_compliance_artifact(self, scan, name, file_extension, content_type):
+ """Resolve and serve a per-framework compliance artifact from disk/S3.
+ Shared by the CSV and OCSF compliance download actions. Both are
+ path-based (no query params) on purpose: ``get_object`` runs
+ ``filter_queryset``, which triggers JSON:API's
+ ``QueryParameterValidationFilter`` and 400s on any non-JSON:API
+ query param, so a ``?format=`` / ``?type=`` selector is not viable
+ here — the format is encoded in the route instead.
+ """
running_resp = self._get_task_status(scan)
if running_resp:
return running_resp
@@ -2287,25 +2428,66 @@ class ScanViewSet(BaseRLSViewSet):
bucket = env.str("DJANGO_OUTPUT_S3_AWS_OUTPUT_BUCKET", "")
key_prefix = scan.output_location.removeprefix(f"s3://{bucket}/")
prefix = os.path.join(
- os.path.dirname(key_prefix), "compliance", f"{name}.csv"
+ os.path.dirname(key_prefix), "compliance", f"{name}.{file_extension}"
)
loader = self._load_file(
prefix,
s3=True,
bucket=bucket,
list_objects=True,
- content_type="text/csv",
+ content_type=content_type,
)
else:
base = os.path.dirname(scan.output_location)
- pattern = os.path.join(base, "compliance", f"*_{name}.csv")
+ pattern = os.path.join(base, "compliance", f"*_{name}.{file_extension}")
loader = self._load_file(pattern, s3=False)
if isinstance(loader, HttpResponseBase):
return loader
content, filename = loader
- return self._serve_file(content, filename, "text/csv")
+ return self._serve_file(content, filename, content_type)
+
+ @action(
+ detail=True,
+ methods=["get"],
+ url_path="compliance/(?P[^/]+)",
+ url_name="compliance",
+ )
+ def compliance(self, request, pk=None, name=None):
+ scan = self.get_object()
+ if name not in get_compliance_frameworks(scan.provider.provider):
+ return Response(
+ {"detail": f"Compliance '{name}' not found."},
+ status=status.HTTP_404_NOT_FOUND,
+ )
+ return self._serve_compliance_artifact(scan, name, "csv", "text/csv")
+
+ @action(
+ detail=True,
+ methods=["get"],
+ url_path="compliance/(?P[^/]+)/ocsf",
+ url_name="compliance-ocsf",
+ )
+ def compliance_ocsf(self, request, pk=None, name=None):
+ scan = self.get_object()
+ if name not in get_compliance_frameworks(scan.provider.provider):
+ return Response(
+ {"detail": f"Compliance '{name}' not found."},
+ status=status.HTTP_404_NOT_FOUND,
+ )
+
+ universal_bulk = get_prowler_provider_compliance(scan.provider.provider)
+ framework_obj = universal_bulk.get(name)
+ if not (framework_obj and getattr(framework_obj, "outputs", None)):
+ return Response(
+ {"detail": f"Compliance '{name}' does not provide an OCSF export."},
+ status=status.HTTP_404_NOT_FOUND,
+ )
+
+ return self._serve_compliance_artifact(
+ scan, name, "ocsf.json", "application/json"
+ )
@action(
detail=True,
@@ -2537,12 +2719,23 @@ class ScanViewSet(BaseRLSViewSet):
def create(self, request, *args, **kwargs):
input_serializer = self.get_serializer(data=request.data)
input_serializer.is_valid(raise_exception=True)
+ provider = input_serializer.validated_data.get("provider")
+ active_scan = None
# Broker publish is deferred to on_commit so the worker cannot read
# Scan before BaseRLSViewSet's dispatch-wide atomic commits.
pre_task_id = str(uuid.uuid4())
with transaction.atomic():
+ if provider:
+ provider = Provider.objects.select_for_update().get(
+ id=provider.id,
+ tenant_id=self.request.tenant_id,
+ )
+ active_scan = get_active_provider_scan(
+ self.request.tenant_id, provider.id
+ )
+
scan = input_serializer.save()
scan.task_id = pre_task_id
scan.save(update_fields=["task_id"])
@@ -2553,29 +2746,18 @@ class ScanViewSet(BaseRLSViewSet):
provider_id=str(scan.provider_id),
)
- task_result, _ = TaskResult.objects.get_or_create(
- task_id=pre_task_id,
- defaults={"status": states.PENDING, "task_name": "scan-perform"},
- )
- prowler_task, _ = Task.objects.update_or_create(
- id=pre_task_id,
+ prowler_task = create_scan_task_record(
tenant_id=self.request.tenant_id,
- defaults={"task_runner_task": task_result},
+ task_id=pre_task_id,
+ task_status=(QUEUED_SCAN_TASK_STATE if active_scan else None),
)
- scan_kwargs = {
- "tenant_id": self.request.tenant_id,
- "scan_id": str(scan.id),
- "provider_id": str(scan.provider_id),
- # Disabled for now
- # checks_to_execute=scan.scanner_args.get("checks_to_execute")
- }
-
- transaction.on_commit(
- lambda: perform_scan_task.apply_async(
- kwargs=scan_kwargs, task_id=pre_task_id
+ if not active_scan:
+ enqueue_scan_execution_on_commit(
+ tenant_id=self.request.tenant_id,
+ scan=scan,
+ task_id=pre_task_id,
)
- )
self.response_serializer_class = TaskSerializer
output_serializer = self.get_serializer(prowler_task)
@@ -2779,13 +2961,22 @@ class AttackPathsScanViewSet(BaseRLSViewSet):
def list(self, request, *args, **kwargs):
queryset = self.filter_queryset(self.get_queryset())
+ active_sink_backend = django_settings.ATTACK_PATHS_SINK_DATABASE
latest_per_provider = queryset.annotate(
+ active_sink_rank=Case(
+ When(sink_backend=active_sink_backend, then=Value(0)),
+ default=Value(1),
+ output_field=IntegerField(),
+ ),
latest_scan_rank=Window(
expression=RowNumber(),
partition_by=[F("provider_id")],
- order_by=[F("inserted_at").desc()],
- )
+ order_by=[
+ F("active_sink_rank").asc(),
+ F("inserted_at").desc(),
+ ],
+ ),
).filter(latest_scan_rank=1)
page = self.paginate_queryset(latest_per_provider)
@@ -2812,7 +3003,11 @@ class AttackPathsScanViewSet(BaseRLSViewSet):
)
def attack_paths_queries(self, request, pk=None):
attack_paths_scan = self.get_object()
- queries = get_queries_for_provider(attack_paths_scan.provider.provider)
+ # TODO: drop the is_migrated argument after Neptune cutover
+ queries = get_queries_for_provider(
+ attack_paths_scan.provider.provider,
+ is_migrated=attack_paths_scan.is_migrated,
+ )
if not queries:
return Response(
@@ -2845,7 +3040,11 @@ class AttackPathsScanViewSet(BaseRLSViewSet):
serializer = AttackPathsQueryRunRequestSerializer(data=payload)
serializer.is_valid(raise_exception=True)
- query_definition = get_query_by_id(serializer.validated_data["id"])
+ # TODO: drop the is_migrated argument after Neptune cutover
+ query_definition = get_query_by_id(
+ serializer.validated_data["id"],
+ is_migrated=attack_paths_scan.is_migrated,
+ )
if (
query_definition is None
or query_definition.provider != attack_paths_scan.provider.provider
@@ -2871,6 +3070,7 @@ class AttackPathsScanViewSet(BaseRLSViewSet):
query_definition,
parameters,
provider_id,
+ scan=attack_paths_scan,
)
query_duration = time.monotonic() - start
@@ -2938,6 +3138,7 @@ class AttackPathsScanViewSet(BaseRLSViewSet):
database_name,
serializer.validated_data["query"],
provider_id,
+ scan=attack_paths_scan,
)
query_duration = time.monotonic() - start
@@ -2994,7 +3195,7 @@ class AttackPathsScanViewSet(BaseRLSViewSet):
provider_id = str(attack_paths_scan.provider_id)
schema = attack_paths_views_helpers.get_cartography_schema(
- database_name, provider_id
+ database_name, provider_id, attack_paths_scan
)
if not schema:
return Response(
@@ -3252,9 +3453,7 @@ class ResourceViewSet(PaginateByPkMixin, BaseRLSViewSet):
date_filters = {}
if exact:
date = parse_date(exact)
- datetime_start = datetime.combine(
- date, datetime.min.time(), tzinfo=timezone.utc
- )
+ datetime_start = datetime.combine(date, datetime.min.time(), tzinfo=UTC)
datetime_end = datetime_start + timedelta(days=1)
date_filters["scan_id__gte"] = uuid7_start(
datetime_to_uuid7(datetime_start)
@@ -3266,7 +3465,7 @@ class ResourceViewSet(PaginateByPkMixin, BaseRLSViewSet):
if gte:
date_start = parse_date(gte)
datetime_start = datetime.combine(
- date_start, datetime.min.time(), tzinfo=timezone.utc
+ date_start, datetime.min.time(), tzinfo=UTC
)
date_filters["scan_id__gte"] = uuid7_start(
datetime_to_uuid7(datetime_start)
@@ -3276,7 +3475,7 @@ class ResourceViewSet(PaginateByPkMixin, BaseRLSViewSet):
datetime_end = datetime.combine(
date_end + timedelta(days=1),
datetime.min.time(),
- tzinfo=timezone.utc,
+ tzinfo=UTC,
)
date_filters["scan_id__lt"] = uuid7_start(
datetime_to_uuid7(datetime_end)
@@ -3320,7 +3519,7 @@ class ResourceViewSet(PaginateByPkMixin, BaseRLSViewSet):
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)
+ {g for groups_list in all_groups if groups_list for g in groups_list}
)
result = {
@@ -3390,7 +3589,7 @@ class ResourceViewSet(PaginateByPkMixin, BaseRLSViewSet):
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)
+ {g for groups_list in all_groups if groups_list for g in groups_list}
)
result = {
@@ -3719,6 +3918,8 @@ class FindingViewSet(PaginateByPkMixin, BaseRLSViewSet):
def get_filterset_class(self):
if self.action in ["latest", "metadata_latest"]:
return LatestFindingFilter
+ if self.action == "metadata":
+ return FindingMetadataFilter
return FindingFilter
def get_queryset(self):
@@ -3749,6 +3950,16 @@ class FindingViewSet(PaginateByPkMixin, BaseRLSViewSet):
return queryset
return super().filter_queryset(queryset)
+ def _optimize_tags_loading(self, queryset):
+ """Prefetch resource tags to avoid N+1 queries when serializing findings"""
+ return queryset.prefetch_related(
+ Prefetch(
+ "resources__tags",
+ queryset=ResourceTag.objects.filter(tenant_id=self.request.tenant_id),
+ to_attr="prefetched_tags",
+ )
+ )
+
def list(self, request, *args, **kwargs):
filtered_queryset = self.filter_queryset(self.get_queryset())
return self.paginate_by_pk(
@@ -3807,9 +4018,7 @@ class FindingViewSet(PaginateByPkMixin, BaseRLSViewSet):
date_filters = {}
if exact:
date = parse_date(exact)
- datetime_start = datetime.combine(
- date, datetime.min.time(), tzinfo=timezone.utc
- )
+ datetime_start = datetime.combine(date, datetime.min.time(), tzinfo=UTC)
datetime_end = datetime_start + timedelta(days=1)
date_filters["scan_id__gte"] = uuid7_start(
datetime_to_uuid7(datetime_start)
@@ -3821,7 +4030,7 @@ class FindingViewSet(PaginateByPkMixin, BaseRLSViewSet):
if gte:
date_start = parse_date(gte)
datetime_start = datetime.combine(
- date_start, datetime.min.time(), tzinfo=timezone.utc
+ date_start, datetime.min.time(), tzinfo=UTC
)
date_filters["scan_id__gte"] = uuid7_start(
datetime_to_uuid7(datetime_start)
@@ -3831,7 +4040,7 @@ class FindingViewSet(PaginateByPkMixin, BaseRLSViewSet):
datetime_end = datetime.combine(
date_end + timedelta(days=1),
datetime.min.time(),
- tzinfo=timezone.utc,
+ tzinfo=UTC,
)
date_filters["scan_id__lt"] = uuid7_start(
datetime_to_uuid7(datetime_end)
@@ -4246,25 +4455,12 @@ class InvitationAcceptViewSet(BaseRLSViewSet):
invitation_token = serializer.validated_data["invitation_token"]
user_email = request.user.email
- invitation = validate_invitation(
- invitation_token, user_email, raise_not_found=True
- )
-
- # Proceed with accepting the invitation
user = User.objects.using(MainRouter.admin_db).get(email=user_email)
- membership = Membership.objects.using(MainRouter.admin_db).create(
+ invitation, membership = accept_invitation_for_user(
user=user,
- tenant=invitation.tenant,
+ invitation_token=invitation_token,
+ raise_not_found=True,
)
- user_role = []
- for role in invitation.roles.all():
- user_role.append(
- UserRoleRelationship.objects.using(MainRouter.admin_db).create(
- user=user, role=role, tenant=invitation.tenant
- )
- )
- invitation.state = Invitation.State.ACCEPTED
- invitation.save(using=MainRouter.admin_db)
self.response_serializer_class = MembershipSerializer
membership_serializer = self.get_serializer(membership)
@@ -4459,15 +4655,19 @@ class RoleProviderGroupRelationshipView(RelationshipView, BaseRLSViewSet):
@extend_schema_view(
list=extend_schema(
tags=["Compliance Overview"],
- summary="List compliance overviews for a scan",
- description="Retrieve an overview of all the compliance in a given scan.",
+ summary="List compliance overviews",
+ description=(
+ "Retrieve compliance overview data for a scan. When provider filters "
+ "are provided, the endpoint uses the latest completed scan for each "
+ "matching provider."
+ ),
parameters=[
OpenApiParameter(
name="filter[scan_id]",
- required=True,
+ required=False,
type=OpenApiTypes.UUID,
location=OpenApiParameter.QUERY,
- description="Related scan ID.",
+ description="Related scan ID. Required unless a provider filter is provided.",
),
],
responses={
@@ -4482,19 +4682,23 @@ class RoleProviderGroupRelationshipView(RelationshipView, BaseRLSViewSet):
description="Compliance overviews generation task failed"
),
},
+ filters=True,
),
metadata=extend_schema(
tags=["Compliance Overview"],
summary="Retrieve metadata values from compliance overviews",
- description="Fetch unique metadata values from a set of compliance overviews. This is useful for dynamic "
- "filtering.",
+ description=(
+ "Fetch unique metadata values from compliance overviews. This is useful "
+ "for dynamic filtering. When provider filters are provided, metadata is "
+ "computed from the latest completed scan for each matching provider."
+ ),
parameters=[
OpenApiParameter(
name="filter[scan_id]",
- required=True,
+ required=False,
type=OpenApiTypes.UUID,
location=OpenApiParameter.QUERY,
- description="Related scan ID.",
+ description="Related scan ID. Required unless a provider filter is provided.",
),
],
responses={
@@ -4509,19 +4713,24 @@ class RoleProviderGroupRelationshipView(RelationshipView, BaseRLSViewSet):
description="Compliance overviews generation task failed"
),
},
+ filters=True,
),
requirements=extend_schema(
tags=["Compliance Overview"],
- summary="List compliance requirements overview for a scan",
- description="Retrieve a detailed overview of compliance requirements in a given scan, grouped by compliance "
- "framework. This endpoint provides requirement-level details and aggregates status across regions.",
+ summary="List compliance requirements overview",
+ description=(
+ "Retrieve a detailed overview of compliance requirements, grouped by "
+ "compliance framework. This endpoint provides requirement-level details "
+ "and aggregates status across regions. When provider filters are provided, "
+ "the endpoint uses the latest completed scan for each matching provider."
+ ),
parameters=[
OpenApiParameter(
name="filter[scan_id]",
- required=True,
+ required=False,
type=OpenApiTypes.UUID,
location=OpenApiParameter.QUERY,
- description="Related scan ID.",
+ description="Related scan ID. Required unless a provider filter is provided.",
),
OpenApiParameter(
name="filter[compliance_id]",
@@ -4558,6 +4767,16 @@ class RoleProviderGroupRelationshipView(RelationshipView, BaseRLSViewSet):
location=OpenApiParameter.QUERY,
description="Compliance framework ID to get attributes for.",
),
+ OpenApiParameter(
+ name="filter[scan_id]",
+ required=False,
+ type=OpenApiTypes.UUID,
+ location=OpenApiParameter.QUERY,
+ description="Scan ID used to resolve the provider for "
+ "multi-provider universal frameworks (e.g. CSA CCM), so "
+ "the returned check IDs match the scan's provider. When omitted, "
+ "the first provider that declares the framework is used.",
+ ),
],
responses={
200: OpenApiResponse(
@@ -4570,7 +4789,10 @@ class RoleProviderGroupRelationshipView(RelationshipView, BaseRLSViewSet):
@method_decorator(CACHE_DECORATOR, name="list")
@method_decorator(CACHE_DECORATOR, name="requirements")
@method_decorator(CACHE_DECORATOR, name="attributes")
-class ComplianceOverviewViewSet(BaseRLSViewSet, TaskManagementMixin):
+class ComplianceOverviewViewSet(
+ ProviderFilterParamsMixin, BaseRLSViewSet, TaskManagementMixin
+):
+ jsonapi_filter_replace_dots = True
pagination_class = ComplianceOverviewPagination
queryset = ComplianceRequirementOverview.objects.all()
serializer_class = ComplianceOverviewSerializer
@@ -4584,28 +4806,22 @@ class ComplianceOverviewViewSet(BaseRLSViewSet, TaskManagementMixin):
required_permissions = []
def get_queryset(self):
+ if getattr(self, "swagger_fake_view", False):
+ return ComplianceRequirementOverview.objects.none()
+
role = get_role(self.request.user, self.request.tenant_id)
unlimited_visibility = getattr(
role, Permissions.UNLIMITED_VISIBILITY.value, False
)
- if unlimited_visibility:
- base_queryset = self.filter_queryset(
- ComplianceRequirementOverview.objects.filter(
- tenant_id=self.request.tenant_id
- )
- )
- else:
- providers = Provider.objects.filter(
- provider_groups__in=role.provider_groups.all()
- ).distinct()
- base_queryset = self.filter_queryset(
- ComplianceRequirementOverview.objects.filter(
- tenant_id=self.request.tenant_id, scan__provider__in=providers
- )
- )
+ base_queryset = ComplianceRequirementOverview.objects.filter(
+ tenant_id=self.request.tenant_id
+ )
- return base_queryset
+ if unlimited_visibility:
+ return base_queryset
+
+ return base_queryset.filter(scan__provider__in=get_providers(role))
def get_serializer_class(self):
if hasattr(self, "response_serializer_class"):
@@ -4643,6 +4859,72 @@ class ComplianceOverviewViewSet(BaseRLSViewSet, TaskManagementMixin):
return summaries
+ def _validate_scan_selection(self, scan_id, has_provider_filters):
+ if scan_id and has_provider_filters:
+ raise ValidationError(
+ [
+ {
+ "detail": "Use either filter[scan_id] or provider filters.",
+ "status": 400,
+ "source": {"pointer": "filter[scan_id]"},
+ "code": "invalid",
+ }
+ ]
+ )
+
+ if scan_id:
+ self._validate_uuid_filter_values("scan_id", [scan_id])
+ return
+
+ if has_provider_filters:
+ return
+
+ raise ValidationError(
+ [
+ {
+ "detail": "This query parameter is required unless a provider filter is provided.",
+ "status": 400,
+ "source": {"pointer": "filter[scan_id]"},
+ "code": "required",
+ }
+ ]
+ )
+
+ def _latest_scan_ids_for_provider_filters(self):
+ role = get_role(self.request.user, self.request.tenant_id)
+ scans = Scan.all_objects.filter(
+ tenant_id=self.request.tenant_id,
+ state=StateChoices.COMPLETED,
+ )
+
+ if not getattr(role, Permissions.UNLIMITED_VISIBILITY.value, False):
+ scans = scans.filter(provider__in=get_providers(role))
+
+ provider_filters = self._extract_provider_filters_from_params(
+ validate_uuids=True,
+ include_dot_aliases=True,
+ )
+ if provider_filters:
+ scans = scans.filter(**provider_filters)
+
+ return list(
+ scans.order_by("provider_id", "-inserted_at")
+ .distinct("provider_id")
+ .values_list("id", flat=True)
+ )
+
+ def _filtered_queryset_for_latest_provider_scans(self, latest_scan_ids=None):
+ if latest_scan_ids is None:
+ latest_scan_ids = self._latest_scan_ids_for_provider_filters()
+ queryset = self.get_queryset().filter(scan_id__in=latest_scan_ids)
+ # Provider filters stay on the filterset for OpenAPI docs, but runtime
+ # filtering happens on Scan first so compliance queries use scan IDs.
+ return self._apply_filterset(
+ queryset,
+ self.filterset_class,
+ exclude_keys=self.PROVIDER_FILTER_KEYS | {"scan_id"},
+ )
+
def _get_compliance_template(self, *, provider=None, scan_id=None):
"""Return the compliance template for the given provider or scan."""
if provider is None and scan_id is not None:
@@ -4758,6 +5040,36 @@ class ComplianceOverviewViewSet(BaseRLSViewSet, TaskManagementMixin):
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
)
+ def _task_response_for_latest_provider_scans(self, latest_scan_ids):
+ for scan_id in latest_scan_ids:
+ task_response = self._task_response_if_running(str(scan_id))
+ if task_response:
+ return task_response
+ return None
+
+ def _latest_provider_scan_ids_without_data(self, latest_scan_ids):
+ data_presence_queryset = self.get_queryset().filter(scan_id__in=latest_scan_ids)
+ scan_ids_with_data = {
+ str(scan_id)
+ for scan_id in data_presence_queryset.values_list(
+ "scan_id", flat=True
+ ).distinct()
+ }
+ return [
+ scan_id
+ for scan_id in latest_scan_ids
+ if str(scan_id) not in scan_ids_with_data
+ ]
+
+ def _task_response_for_latest_provider_scans_without_data(
+ self,
+ latest_scan_ids,
+ ):
+ scan_ids_to_check = self._latest_provider_scan_ids_without_data(
+ latest_scan_ids,
+ )
+ return self._task_response_for_latest_provider_scans(scan_ids_to_check)
+
def _list_with_region_filter(self, scan_id, region_filter):
"""
Fall back to detailed ComplianceRequirementOverview query when region filter is applied.
@@ -4798,8 +5110,25 @@ class ComplianceOverviewViewSet(BaseRLSViewSet, TaskManagementMixin):
return Response(data)
+ def _list_with_latest_provider_filters(self):
+ latest_scan_ids = self._latest_scan_ids_for_provider_filters()
+ queryset = self._filtered_queryset_for_latest_provider_scans(latest_scan_ids)
+ data = self._aggregate_compliance_overview(queryset)
+ task_response = self._task_response_for_latest_provider_scans_without_data(
+ latest_scan_ids,
+ )
+ if task_response:
+ return task_response
+
+ return Response(data)
+
def list(self, request, *args, **kwargs):
scan_id = request.query_params.get("filter[scan_id]")
+ has_provider_filters = self._has_provider_filters(include_dot_aliases=True)
+ self._validate_scan_selection(scan_id, has_provider_filters)
+
+ if has_provider_filters:
+ return self._list_with_latest_provider_filters()
# Specific scan requested - use optimized summaries with region support
region_filter = request.query_params.get(
@@ -4845,33 +5174,34 @@ class ComplianceOverviewViewSet(BaseRLSViewSet, TaskManagementMixin):
@action(detail=False, methods=["get"], url_name="metadata")
def metadata(self, request):
scan_id = request.query_params.get("filter[scan_id]")
- if not scan_id:
- raise ValidationError(
- [
- {
- "detail": "This query parameter is required.",
- "status": 400,
- "source": {"pointer": "filter[scan_id]"},
- "code": "required",
- }
- ]
+ has_provider_filters = self._has_provider_filters(include_dot_aliases=True)
+ self._validate_scan_selection(scan_id, has_provider_filters)
+
+ latest_scan_ids = None
+ if has_provider_filters:
+ latest_scan_ids = self._latest_scan_ids_for_provider_filters()
+ queryset = self._filtered_queryset_for_latest_provider_scans(
+ latest_scan_ids
)
+ else:
+ queryset = self._apply_filterset(self.get_queryset(), self.filterset_class)
+
regions = list(
- self.get_queryset()
- .filter(scan_id=scan_id)
- .values_list("region", flat=True)
- .order_by("region")
- .distinct()
+ queryset.values_list("region", flat=True).order_by("region").distinct()
)
result = {"regions": regions}
- if regions:
- serializer = self.get_serializer(data=result)
- serializer.is_valid(raise_exception=True)
- return Response(serializer.data, status=status.HTTP_200_OK)
+ task_response = None
+ if has_provider_filters:
+ task_response = self._task_response_for_latest_provider_scans_without_data(
+ latest_scan_ids,
+ )
+ elif not regions:
+ task_response = self._task_response_if_running(scan_id)
+ if task_response:
+ return task_response
- task_response = self._task_response_if_running(scan_id)
- if task_response:
+ if has_provider_filters and task_response:
return task_response
serializer = self.get_serializer(data=result)
@@ -4881,19 +5211,10 @@ class ComplianceOverviewViewSet(BaseRLSViewSet, TaskManagementMixin):
@action(detail=False, methods=["get"], url_name="requirements")
def requirements(self, request):
scan_id = request.query_params.get("filter[scan_id]")
+ has_provider_filters = self._has_provider_filters(include_dot_aliases=True)
compliance_id = request.query_params.get("filter[compliance_id]")
- if not scan_id:
- raise ValidationError(
- [
- {
- "detail": "This query parameter is required.",
- "status": 400,
- "source": {"pointer": "filter[scan_id]"},
- "code": "required",
- }
- ]
- )
+ self._validate_scan_selection(scan_id, has_provider_filters)
if not compliance_id:
raise ValidationError(
@@ -4906,7 +5227,16 @@ class ComplianceOverviewViewSet(BaseRLSViewSet, TaskManagementMixin):
}
]
)
- filtered_queryset = self.filter_queryset(self.get_queryset())
+ latest_scan_ids = None
+ if has_provider_filters:
+ latest_scan_ids = self._latest_scan_ids_for_provider_filters()
+ filtered_queryset = self._filtered_queryset_for_latest_provider_scans(
+ latest_scan_ids
+ )
+ else:
+ filtered_queryset = self._apply_filterset(
+ self.get_queryset(), self.filterset_class
+ )
all_requirements = filtered_queryset.values(
"requirement_id",
@@ -4965,17 +5295,33 @@ class ComplianceOverviewViewSet(BaseRLSViewSet, TaskManagementMixin):
requirements_summary, many=True
)
+ task_response = None
+ if has_provider_filters:
+ task_response = self._task_response_for_latest_provider_scans_without_data(
+ latest_scan_ids,
+ )
+ elif not requirements_summary:
+ task_response = self._task_response_if_running(scan_id)
+ if task_response:
+ return task_response
+
+ if has_provider_filters and task_response:
+ return task_response
+
if requirements_summary:
return Response(serializer.data, status=status.HTTP_200_OK)
- task_response = self._task_response_if_running(scan_id)
- if task_response:
- return task_response
-
return Response(serializer.data, status=status.HTTP_200_OK)
@action(detail=False, methods=["get"], url_name="attributes")
def attributes(self, request):
+ # While the background warm-up is in progress, refuse immediately
+ # instead of falling through to the slow cold load on the request
+ # thread (which would trip the Gunicorn worker timeout). `is_set()` is
+ # a non-blocking flag read, so this never touches the loader.
+ if COMPLIANCE_WARMING_STARTED.is_set() and not COMPLIANCE_WARMED.is_set():
+ raise ComplianceWarmingError()
+
compliance_id = request.query_params.get("filter[compliance_id]")
if not compliance_id:
raise ValidationError(
@@ -4991,7 +5337,51 @@ class ComplianceOverviewViewSet(BaseRLSViewSet, TaskManagementMixin):
provider_type = None
- # If we couldn't determine from database, try each provider type
+ # When a scan is provided, resolve the provider from it. Multi-provider
+ # universal frameworks (e.g. CSA CCM) share a single compliance_id
+ # across providers but expose different checks per provider, so the
+ # metadata (and therefore the check IDs the UI uses to fetch findings)
+ # must be returned for the scan's provider. Without this, the endpoint
+ # falls back to the first provider that declares the framework and
+ # returns its check IDs, leaving azure/gcp/... requirements with no
+ # matching findings.
+ scan_id = request.query_params.get("filter[scan_id]")
+ if "filter[scan_id]" in request.query_params:
+ # An explicit scan_id is authoritative: fail closed instead of
+ # falling back to another provider. Otherwise an invalid, empty
+ # (filter[scan_id]=) or inaccessible scan would silently return the
+ # first provider's check IDs, recreating the multi-provider mismatch
+ # this endpoint fixes.
+ if not scan_id:
+ raise NotFound(detail=f"Scan '{scan_id}' not found.")
+
+ # Tenant isolation is already enforced by Postgres RLS on the
+ # connection (see BaseRLSViewSet). Scope the lookup by provider
+ # group as well so a user with limited visibility can't resolve
+ # another provider's scan and read its compliance metadata, mirroring
+ # the RBAC scoping get_queryset() applies to the rest of the ViewSet.
+ role = get_role(request.user, request.tenant_id)
+ if getattr(role, Permissions.UNLIMITED_VISIBILITY.value, False):
+ scan_queryset = Scan.objects.filter(tenant_id=request.tenant_id)
+ else:
+ scan_queryset = Scan.objects.filter(provider__in=get_providers(role))
+
+ try:
+ scan = scan_queryset.select_related("provider").get(id=scan_id)
+ except (Scan.DoesNotExist, DjangoValidationError, ValueError):
+ raise NotFound(detail=f"Scan '{scan_id}' not found.")
+
+ provider_type = scan.provider.provider
+ if compliance_id not in get_compliance_frameworks(provider_type):
+ raise NotFound(
+ detail=(
+ f"Compliance framework '{compliance_id}' is not "
+ f"available for scan '{scan_id}'."
+ )
+ )
+
+ # Fall back to the first provider that declares the framework. Keeps the
+ # endpoint working for provider-agnostic callers that omit the scan.
if not provider_type:
for pt in Provider.ProviderChoices.values:
if compliance_id in get_compliance_frameworks(pt):
@@ -5159,7 +5549,7 @@ class ComplianceOverviewViewSet(BaseRLSViewSet, TaskManagementMixin):
),
)
@method_decorator(CACHE_DECORATOR, name="list")
-class OverviewViewSet(BaseRLSViewSet):
+class OverviewViewSet(ProviderFilterParamsMixin, BaseRLSViewSet):
queryset = ScanSummary.objects.all()
http_method_names = ["get"]
ordering = ["-inserted_at"]
@@ -5276,18 +5666,6 @@ class OverviewViewSet(BaseRLSViewSet):
tenant_id=tenant_id, scan_id__in=latest_scan_ids
)
- def _normalize_jsonapi_params(self, query_params, exclude_keys=None):
- """Convert JSON:API filter params (filter[X]) to flat params (X)."""
- exclude_keys = exclude_keys or set()
- normalized = QueryDict(mutable=True)
- for key, values in query_params.lists():
- normalized_key = (
- key[7:-1] if key.startswith("filter[") and key.endswith("]") else key
- )
- if normalized_key not in exclude_keys:
- normalized.setlist(normalized_key, values)
- return normalized
-
def _ensure_allowed_providers(self):
"""Populate allowed providers for RBAC-aware queries once per request."""
if getattr(self, "_providers_initialized", False):
@@ -5307,15 +5685,6 @@ class OverviewViewSet(BaseRLSViewSet):
return queryset.filter(**provider_filter)
return queryset
- def _apply_filterset(self, queryset, filterset_class, exclude_keys=None):
- normalized_params = self._normalize_jsonapi_params(
- self.request.query_params, exclude_keys=set(exclude_keys or [])
- )
- filterset = filterset_class(normalized_params, queryset=queryset)
- if not filterset.is_valid():
- raise ValidationError(filterset.errors)
- return filterset.qs
-
def _latest_scan_ids_for_allowed_providers(self, tenant_id, provider_filters=None):
provider_filter = self._get_provider_filter()
queryset = Scan.all_objects.filter(
@@ -5329,40 +5698,6 @@ class OverviewViewSet(BaseRLSViewSet):
.values_list("id", flat=True)
)
- def _extract_provider_filters_from_params(self):
- """Extract and validate provider filters from query params."""
- params = self.request.query_params
- filters = {}
- valid_provider_types = {c[0] for c in Provider.ProviderChoices.choices}
-
- provider_id = params.get("filter[provider_id]")
- if provider_id:
- filters["provider_id"] = provider_id
-
- provider_id_in = params.get("filter[provider_id__in]")
- if provider_id_in:
- filters["provider_id__in"] = provider_id_in.split(",")
-
- provider_type = params.get("filter[provider_type]")
- if provider_type:
- if provider_type not in valid_provider_types:
- raise ValidationError(
- {"provider_type": f"Invalid choice: {provider_type}"}
- )
- filters["provider__provider"] = provider_type
-
- provider_type_in = params.get("filter[provider_type__in]")
- if provider_type_in:
- types = provider_type_in.split(",")
- invalid = [t for t in types if t not in valid_provider_types]
- if invalid:
- raise ValidationError(
- {"provider_type__in": f"Invalid choices: {', '.join(invalid)}"}
- )
- filters["provider__provider__in"] = types
-
- return filters
-
@action(detail=False, methods=["get"], url_name="providers")
def providers(self, request):
tenant_id = self.request.tenant_id
@@ -5433,15 +5768,11 @@ class OverviewViewSet(BaseRLSViewSet):
tenant_id = self.request.tenant_id
providers_qs = Provider.objects.filter(tenant_id=tenant_id)
+ self._ensure_allowed_providers()
if hasattr(self, "allowed_providers"):
- allowed_ids = list(self.allowed_providers.values_list("id", flat=True))
- if not allowed_ids:
- overview = []
- return Response(
- self.get_serializer(overview, many=True).data,
- status=status.HTTP_200_OK,
- )
- providers_qs = providers_qs.filter(id__in=allowed_ids)
+ providers_qs = providers_qs.filter(
+ id__in=self.allowed_providers.values("id")
+ )
overview = (
providers_qs.values("provider")
@@ -5657,29 +5988,41 @@ class OverviewViewSet(BaseRLSViewSet):
description="Retrieve a specific snapshot by ID. If not provided, returns latest snapshots.",
),
OpenApiParameter(
- name="provider_id",
+ name="filter[provider_id]",
type=OpenApiTypes.UUID,
location=OpenApiParameter.QUERY,
description="Filter by specific provider ID",
),
OpenApiParameter(
- name="provider_id__in",
+ name="filter[provider_id__in]",
type=OpenApiTypes.STR,
location=OpenApiParameter.QUERY,
description="Filter by multiple provider IDs (comma-separated UUIDs)",
),
OpenApiParameter(
- name="provider_type",
+ name="filter[provider_type]",
type=OpenApiTypes.STR,
location=OpenApiParameter.QUERY,
description="Filter by provider type (aws, azure, gcp, etc.)",
),
OpenApiParameter(
- name="provider_type__in",
+ name="filter[provider_type__in]",
type=OpenApiTypes.STR,
location=OpenApiParameter.QUERY,
description="Filter by multiple provider types (comma-separated)",
),
+ OpenApiParameter(
+ name="filter[provider_groups]",
+ type=OpenApiTypes.UUID,
+ location=OpenApiParameter.QUERY,
+ description="Filter by provider group ID",
+ ),
+ OpenApiParameter(
+ name="filter[provider_groups__in]",
+ type=OpenApiTypes.STR,
+ location=OpenApiParameter.QUERY,
+ description="Filter by multiple provider group IDs (comma-separated UUIDs)",
+ ),
],
)
@action(detail=False, methods=["get"], url_name="threatscore")
@@ -6021,6 +6364,8 @@ class OverviewViewSet(BaseRLSViewSet):
"provider_id__in",
"provider_type",
"provider_type__in",
+ "provider_groups",
+ "provider_groups__in",
}
filtered_queryset = self._apply_filterset(
base_queryset, CategoryOverviewFilter, exclude_keys=provider_filter_keys
@@ -6090,6 +6435,8 @@ class OverviewViewSet(BaseRLSViewSet):
"provider_id__in",
"provider_type",
"provider_type__in",
+ "provider_groups",
+ "provider_groups__in",
}
filtered_queryset = self._apply_filterset(
base_queryset,
@@ -7140,7 +7487,7 @@ SEVERITY_ORDER_REVERSE = {v: k for k, v in SEVERITY_ORDER.items()}
),
retrieve=extend_schema(exclude=True),
)
-class FindingGroupViewSet(BaseRLSViewSet):
+class FindingGroupViewSet(JsonApiFilterMixin, BaseRLSViewSet):
"""
ViewSet for Finding Groups - aggregates findings by check_id.
@@ -7156,6 +7503,7 @@ class FindingGroupViewSet(BaseRLSViewSet):
queryset = FindingGroupDailySummary.objects.all()
serializer_class = FindingGroupSerializer
filterset_class = FindingGroupFilter
+ jsonapi_filter_replace_dots = True
filter_backends = [
jsonapi_filters.QueryParameterValidationFilter,
jsonapi_filters.OrderingFilter,
@@ -7206,18 +7554,6 @@ class FindingGroupViewSet(BaseRLSViewSet):
return queryset
- def _normalize_jsonapi_params(self, query_params):
- """Convert JSON:API filter params (filter[X]) to flat params (X)."""
- normalized = QueryDict(mutable=True)
- for key, values in query_params.lists():
- normalized_key = (
- key[7:-1] if key.startswith("filter[") and key.endswith("]") else key
- )
- # Convert JSON:API dot notation to Django double underscore
- normalized_key = normalized_key.replace(".", "__")
- normalized.setlist(normalized_key, values)
- return normalized
-
@extend_schema(exclude=True)
def retrieve(self, request, *args, **kwargs):
raise MethodNotAllowed(method="GET")
@@ -7369,6 +7705,15 @@ class FindingGroupViewSet(BaseRLSViewSet):
output_field=IntegerField(),
)
+ # `check_title` / `check_description` are intentionally NOT resolved
+ # here. They live in the large JSONB `check_metadata` blob (TOASTed),
+ # so reading them per finding row is very expensive, and pulling them
+ # in via a correlated subquery makes Django add the subquery to GROUP
+ # BY, which re-evaluates it once per input row. They are identical for
+ # every finding of a `check_id`, so `_post_process_aggregation` fills
+ # them from the summary table's plain columns in a single batched
+ # lookup scoped to the paginated page.
+
# `pass_count`, `fail_count` and `manual_count` only count non-muted
# findings. Muted findings are tracked separately via the
# `*_muted_count` fields.
@@ -7439,15 +7784,6 @@ class FindingGroupViewSet(BaseRLSViewSet):
agg_failing_since=Min(
"first_seen_at", filter=Q(status="FAIL", muted=False)
),
- check_title=Coalesce(
- Max(KeyTextTransform("checktitle", "check_metadata")),
- Max(KeyTextTransform("CheckTitle", "check_metadata")),
- Max(KeyTextTransform("Checktitle", "check_metadata")),
- ),
- check_description=Coalesce(
- Max(KeyTextTransform("description", "check_metadata")),
- Max(KeyTextTransform("Description", "check_metadata")),
- ),
)
.annotate(
# Group is muted only if it has zero non-muted findings.
@@ -7484,14 +7820,17 @@ class FindingGroupViewSet(BaseRLSViewSet):
def _get_latest_findings_per_provider(self, filtered_queryset):
"""Keep only findings from each provider's most recent completed scan."""
- latest_scan_ids = (
+ # Materialize to a literal IN list. Left as a subquery, Postgres can't
+ # estimate the match count and picks a serial nested loop on
+ # resource_finding_mappings when one scan dominates findings
+ latest_scan_ids = list(
Scan.objects.filter(
tenant_id=self.request.tenant_id,
state=StateChoices.COMPLETED,
)
.order_by("provider_id", "-completed_at", "-inserted_at")
.distinct("provider_id")
- .values("id")
+ .values_list("id", flat=True)
)
return filtered_queryset.filter(scan_id__in=latest_scan_ids)
@@ -7503,9 +7842,38 @@ class FindingGroupViewSet(BaseRLSViewSet):
- Computes aggregated status (FAIL > PASS > MANUAL); the orthogonal
``muted`` boolean is already on the row from the SQL aggregation
- Converts provider string to list
+ - Fills check_title / check_description for the findings path
"""
+ rows = list(aggregated_data)
+
+ # The findings-aggregation path omits check_title / check_description
+ # (they sit in TOASTed JSONB; see _aggregate_findings). Fill them from
+ # the summary table's plain columns in one query scoped to this page.
+ # The summary-aggregation path already carries them, so skip it there.
+ if rows and "check_title" not in rows[0]:
+ check_ids = [row["check_id"] for row in rows]
+ role = get_role(self.request.user, self.request.tenant_id)
+ summaries = FindingGroupDailySummary.objects.filter(
+ tenant_id=self.request.tenant_id,
+ check_id__in=check_ids,
+ )
+ # Scope to the user's providers, mirroring get_queryset(), so titles
+ # are read only from providers the user can see.
+ if not role.unlimited_visibility:
+ summaries = summaries.filter(provider__in=get_providers(role))
+ metadata_by_check = {
+ item["check_id"]: item
+ for item in summaries.order_by("check_id", "-inserted_at")
+ .distinct("check_id")
+ .values("check_id", "check_title", "check_description")
+ }
+ for row in rows:
+ metadata = metadata_by_check.get(row["check_id"], {})
+ row["check_title"] = metadata.get("check_title")
+ row["check_description"] = metadata.get("check_description")
+
results = []
- for row in aggregated_data:
+ for row in rows:
# Convert severity order back to string
severity_order = row.get("severity_order", 1)
row["severity"] = SEVERITY_ORDER_REVERSE.get(
@@ -7551,7 +7919,6 @@ class FindingGroupViewSet(BaseRLSViewSet):
_FINDING_GROUP_SORT_MAP = {
"check_id": "check_id",
- "check_title": "check_title",
"severity": "severity_order",
"status": "status_order",
"muted": "muted",
@@ -8305,9 +8672,10 @@ class FindingGroupViewSet(BaseRLSViewSet):
This endpoint returns finding groups without requiring date filters,
automatically using the latest available data per check_id.
- All other filters (provider_id, provider_type, check_id) are still supported.
+ Provider, provider group, check, and computed filters are still supported.
""",
tags=["Finding Groups"],
+ filters=True,
)
@action(detail=False, methods=["get"], url_name="latest")
def latest(self, request):
diff --git a/api/src/backend/api/validators.py b/api/src/backend/api/validators.py
index 543406f202..6ad7ccfe0f 100644
--- a/api/src/backend/api/validators.py
+++ b/api/src/backend/api/validators.py
@@ -1,14 +1,155 @@
+import ipaddress
+import socket
import string
+from urllib.parse import urlparse
+from django.conf import settings
from django.core.exceptions import ValidationError
from django.utils.translation import gettext as _
+LIGHTHOUSE_OPENAI_COMPATIBLE_ALLOWED_SCHEMES = frozenset({"https"})
+LIGHTHOUSE_NAT64_WELL_KNOWN_PREFIX = ipaddress.IPv6Network("64:ff9b::/96")
+LIGHTHOUSE_BLOCKED_METADATA_HOSTS = frozenset(
+ {
+ "169.254.169.254",
+ "169.254.170.2",
+ "fd00:ec2::254",
+ "localhost",
+ "metadata.google.internal",
+ }
+)
+
+
+def _normalize_hostname(hostname: str) -> str:
+ return hostname.rstrip(".").lower()
+
+
+def _lighthouse_openai_compatible_allowed_hosts() -> frozenset[str]:
+ return frozenset(
+ _normalize_hostname(allowed_host.strip())
+ for allowed_host in settings.LIGHTHOUSE_AI_OPENAI_COMPATIBLE_ALLOWED_HOSTS
+ if allowed_host and allowed_host.strip()
+ )
+
+
+def _validate_lighthouse_public_ip(address: str) -> None:
+ ip_address = ipaddress.ip_address(address)
+ if isinstance(ip_address, ipaddress.IPv6Address):
+ # Classify transition addresses by their effective IPv4 destination.
+ embedded_ip_address = ip_address.ipv4_mapped or ip_address.sixtofour
+ if (
+ embedded_ip_address is None
+ and ip_address in LIGHTHOUSE_NAT64_WELL_KNOWN_PREFIX
+ ):
+ embedded_ip_address = ipaddress.IPv4Address(int(ip_address) & 0xFFFFFFFF)
+ if embedded_ip_address is not None:
+ ip_address = embedded_ip_address
+ if not ip_address.is_global:
+ raise ValidationError(
+ _("Base URL must use an external public endpoint."),
+ code="lighthouse_base_url_not_public",
+ )
+
+
+def resolve_lighthouse_openai_compatible_host(
+ hostname: str,
+ port: int,
+ *,
+ resolve_dns: bool = True,
+) -> tuple[str, ...]:
+ """Return public IP addresses that are safe for Lighthouse outbound use."""
+ hostname = _normalize_hostname(hostname)
+ if hostname in _lighthouse_openai_compatible_allowed_hosts():
+ # Operator-allowlisted hosts skip the public-endpoint checks; returning
+ # the hostname makes the network backend connect through regular DNS
+ # resolution instead of pinned addresses.
+ return (hostname,)
+
+ if hostname in LIGHTHOUSE_BLOCKED_METADATA_HOSTS or hostname.endswith(".localhost"):
+ raise ValidationError(
+ _("Base URL must use an external public endpoint."),
+ code="lighthouse_base_url_blocked_host",
+ )
+
+ try:
+ _validate_lighthouse_public_ip(hostname)
+ except ValueError:
+ if not resolve_dns:
+ return ()
+ else:
+ return (hostname,)
+
+ try:
+ resolved_addresses = socket.getaddrinfo(hostname, port, type=socket.SOCK_STREAM)
+ except socket.gaierror as error:
+ raise ValidationError(
+ _("Base URL host could not be resolved."),
+ code="lighthouse_base_url_resolution_failed",
+ ) from error
+
+ if not resolved_addresses:
+ raise ValidationError(
+ _("Base URL host could not be resolved."),
+ code="lighthouse_base_url_resolution_failed",
+ )
+
+ public_addresses: list[str] = []
+ for resolved_address in resolved_addresses:
+ socket_address = resolved_address[4]
+ resolved_ip_address = socket_address[0]
+ _validate_lighthouse_public_ip(resolved_ip_address)
+ if resolved_ip_address not in public_addresses:
+ public_addresses.append(resolved_ip_address)
+
+ return tuple(public_addresses)
+
+
+def validate_lighthouse_openai_compatible_base_url(
+ base_url: str,
+ *,
+ resolve_dns: bool = True,
+) -> None:
+ """Validate an OpenAI-compatible Lighthouse base URL before outbound use."""
+ parsed = urlparse(str(base_url))
+ if parsed.scheme.lower() not in LIGHTHOUSE_OPENAI_COMPATIBLE_ALLOWED_SCHEMES:
+ raise ValidationError(
+ _("Base URL must use HTTPS."),
+ code="lighthouse_base_url_invalid_scheme",
+ )
+
+ if not parsed.hostname:
+ raise ValidationError(
+ _("Base URL must include a host."),
+ code="lighthouse_base_url_missing_host",
+ )
+
+ try:
+ port = parsed.port
+ except ValueError as error:
+ raise ValidationError(
+ _("Base URL port is invalid."),
+ code="lighthouse_base_url_invalid_port",
+ ) from error
+
+ if port is not None and not 1 <= port <= 65535:
+ raise ValidationError(
+ _("Base URL port is invalid."),
+ code="lighthouse_base_url_invalid_port",
+ )
+
+ resolve_lighthouse_openai_compatible_host(
+ parsed.hostname,
+ port or 443,
+ resolve_dns=resolve_dns,
+ )
+
class MaximumLengthValidator:
def __init__(self, max_length=72):
self.max_length = max_length
def validate(self, password, user=None):
+ del user
if len(password) > self.max_length:
raise ValidationError(
_(
@@ -31,6 +172,7 @@ class SpecialCharactersValidator:
self.min_special_characters = min_special_characters
def validate(self, password, user=None):
+ del user
if (
sum(1 for char in password if char in self.special_characters)
< self.min_special_characters
@@ -55,6 +197,7 @@ class UppercaseValidator:
self.min_uppercase = min_uppercase
def validate(self, password, user=None):
+ del user
if sum(1 for char in password if char.isupper()) < self.min_uppercase:
raise ValidationError(
_(
@@ -75,6 +218,7 @@ class LowercaseValidator:
self.min_lowercase = min_lowercase
def validate(self, password, user=None):
+ del user
if sum(1 for char in password if char.islower()) < self.min_lowercase:
raise ValidationError(
_(
@@ -95,6 +239,7 @@ class NumericValidator:
self.min_numeric = min_numeric
def validate(self, password, user=None):
+ del user
if sum(1 for char in password if char.isdigit()) < self.min_numeric:
raise ValidationError(
_(
diff --git a/api/src/backend/config/celery.py b/api/src/backend/config/celery.py
index c46c8a426c..9ac4f1e4ef 100644
--- a/api/src/backend/config/celery.py
+++ b/api/src/backend/config/celery.py
@@ -1,7 +1,6 @@
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
@@ -26,6 +25,62 @@ celery_app.conf.result_backend_transport_options = {
}
celery_app.conf.visibility_timeout = BROKER_VISIBILITY_TIMEOUT
+# Durable delivery: keep the message until the task finishes, so a worker killed
+# mid-task (deploy/OOM/eviction) does not silently drop it. Reserve one task at a
+# time so a crash exposes at most one extra reserved message.
+celery_app.conf.task_acks_late = True
+celery_app.conf.task_reject_on_worker_lost = True
+celery_app.conf.worker_prefetch_multiplier = env.int(
+ "DJANGO_CELERY_WORKER_PREFETCH_MULTIPLIER", default=1
+)
+# On SIGTERM, give the worker time to finish or re-queue in-flight tasks before
+# it is forcefully killed (Celery 5.5+ soft shutdown).
+celery_app.conf.worker_soft_shutdown_timeout = env.int(
+ "DJANGO_CELERY_WORKER_SOFT_SHUTDOWN_TIMEOUT", default=60
+)
+# Bound execution so a blocked task cannot pin a worker forever. Connection
+# checks get a tight limit; scans and provider/tenant deletions can legitimately
+# run for more than a day on large tenants, so they get a much higher cap.
+# The default for every other task is set as the global limit, not as a "*"
+# annotation: Celery applies the "*" entry AFTER the per-task one, so a "*" in
+# task_annotations would silently overwrite every specific limit defined below.
+_TASK_HARD_LIMIT = env.int("DJANGO_CELERY_TASK_TIME_LIMIT", default=6 * 60 * 60)
+_TASK_SOFT_LIMIT = env.int(
+ "DJANGO_CELERY_TASK_SOFT_TIME_LIMIT", default=_TASK_HARD_LIMIT - 600
+)
+_LONG_TASK_HARD_LIMIT = env.int(
+ "DJANGO_CELERY_LONG_TASK_TIME_LIMIT", default=48 * 60 * 60
+)
+_LONG_TASK_SOFT_LIMIT = env.int(
+ "DJANGO_CELERY_LONG_TASK_SOFT_TIME_LIMIT", default=_LONG_TASK_HARD_LIMIT - 600
+)
+celery_app.conf.task_time_limit = _TASK_HARD_LIMIT
+celery_app.conf.task_soft_time_limit = _TASK_SOFT_LIMIT
+celery_app.conf.task_annotations = {
+ **{
+ name: {"soft_time_limit": 60, "time_limit": 120}
+ for name in (
+ "provider-connection-check",
+ "integration-connection-check",
+ "lighthouse-connection-check",
+ "lighthouse-provider-connection-check",
+ )
+ },
+ **{
+ name: {
+ "soft_time_limit": _LONG_TASK_SOFT_LIMIT,
+ "time_limit": _LONG_TASK_HARD_LIMIT,
+ }
+ for name in (
+ "scan-perform",
+ "scan-perform-scheduled",
+ "attack-paths-scan-perform",
+ "provider-deletion",
+ "tenant-deletion",
+ )
+ },
+}
+
celery_app.autodiscover_tasks(["api"])
@@ -41,9 +96,8 @@ class RLSTask(Task):
shadow=None,
**options,
):
- from django_celery_results.models import TaskResult
-
from api.models import Task as APITask
+ from django_celery_results.models import TaskResult
result = super().apply_async(
args=args,
diff --git a/api/src/backend/config/custom_logging.py b/api/src/backend/config/custom_logging.py
index fe2a090ca6..a601c8e2cd 100644
--- a/api/src/backend/config/custom_logging.py
+++ b/api/src/backend/config/custom_logging.py
@@ -2,7 +2,6 @@ import json
import logging
from enum import StrEnum
-
from config.env import env
from django_guid.log_filters import CorrelationId
diff --git a/api/src/backend/config/django/base.py b/api/src/backend/config/django/base.py
index 317fe4fbfb..a079942600 100644
--- a/api/src/backend/config/django/base.py
+++ b/api/src/backend/config/django/base.py
@@ -3,6 +3,7 @@ from datetime import timedelta
from config.custom_logging import LOGGING # noqa
from config.env import BASE_DIR, env # noqa
from config.settings.celery import * # noqa
+from config.settings.eventstream import * # noqa
from config.settings.partitions import * # noqa
from config.settings.sentry import * # noqa
from config.settings.social_login import * # noqa
@@ -44,9 +45,11 @@ INSTALLED_APPS = [
"dj_rest_auth.registration",
"rest_framework.authtoken",
"drf_simple_apikey",
+ "django_eventstream",
]
MIDDLEWARE = [
+ "api.middleware.CloseDBConnectionsMiddleware",
"django_guid.middleware.guid_middleware",
"django.middleware.security.SecurityMiddleware",
"django.contrib.sessions.middleware.SessionMiddleware",
@@ -136,6 +139,7 @@ SPECTACULAR_SETTINGS = {
}
WSGI_APPLICATION = "config.wsgi.application"
+ASGI_APPLICATION = "config.asgi.application"
DJANGO_GUID = {
"GUID_HEADER_NAME": "Transaction-ID",
@@ -226,6 +230,7 @@ SIMPLE_JWT = {
"JTI_CLAIM": "jti",
"USER_ID_FIELD": "id",
"USER_ID_CLAIM": "sub",
+ "CHECK_REVOKE_TOKEN": True,
# Issuer and Audience claims, for the moment we will keep these values as default values, they may change in the
# future.
"AUDIENCE": env.str("DJANGO_JWT_AUDIENCE", "https://api.prowler.com"),
@@ -303,6 +308,52 @@ CSRF_COOKIE_SECURE = True
SESSION_COOKIE_SECURE = True
# Attack Paths
+ATTACK_PATHS_SCAN_INACTIVITY_THRESHOLD_MINUTES = env.int(
+ "ATTACK_PATHS_SCAN_INACTIVITY_THRESHOLD_MINUTES", 30
+)
ATTACK_PATHS_SCAN_STALE_THRESHOLD_MINUTES = env.int(
- "ATTACK_PATHS_SCAN_STALE_THRESHOLD_MINUTES", 2880
-) # 48h
+ "ATTACK_PATHS_SCAN_STALE_THRESHOLD_MINUTES", 960
+) # 16h
+
+# Selects where the persistent attack-paths graph is stored. The scan
+# temporary database is always Neo4j; only the sink is configurable.
+# Valid values: "neo4j" (default, OSS and local dev), "neptune" (hosted).
+ATTACK_PATHS_SINK_DATABASE = env.str("ATTACK_PATHS_SINK_DATABASE", default="neo4j")
+
+# Lighthouse AI
+# Comma-separated hostnames (or IP literals) that bypass the SSRF validation
+# applied to OpenAI-compatible provider base URLs, so self-hosted deployments
+# can point Lighthouse AI at internal endpoints. Empty by default: every base
+# URL must resolve to a public endpoint.
+LIGHTHOUSE_AI_OPENAI_COMPATIBLE_ALLOWED_HOSTS = env.list(
+ "LIGHTHOUSE_AI_OPENAI_COMPATIBLE_ALLOWED_HOSTS", default=[]
+)
+
+# Orphan task recovery feature flags. The master switch is OFF by default, so task
+# recovery is opt-in; enable it with DJANGO_TASK_RECOVERY_ENABLED=true. The per-group
+# toggles default to enabled, so once the master is on every group recovers unless a
+# group is explicitly turned off.
+TASK_RECOVERY_ENABLED = env.bool("DJANGO_TASK_RECOVERY_ENABLED", False)
+TASK_RECOVERY_SUMMARIES_ENABLED = env.bool(
+ "DJANGO_TASK_RECOVERY_SUMMARIES_ENABLED", True
+)
+TASK_RECOVERY_DELETIONS_ENABLED = env.bool(
+ "DJANGO_TASK_RECOVERY_DELETIONS_ENABLED", True
+)
+
+
+def label_postgres_connections(databases):
+ """Tag each Postgres connection with ``application_name=":"``
+ so connections are attributable by component in ``pg_stat_activity`` (and any
+ tooling that surfaces ``application_name``). The component (api / worker /
+ scan / ...) is injected per process by the container entrypoint via
+ ``DJANGO_APP_COMPONENT``; the alias distinguishes which pool inside the
+ process owns the connection. The neo4j entry is skipped (not a Postgres
+ backend). Postgres truncates ``application_name`` at 63 bytes.
+ """
+ component = env.str("DJANGO_APP_COMPONENT", default="api")
+ for alias, config in databases.items():
+ engine = config.get("ENGINE", "")
+ if engine.startswith("psqlextra") or "postgresql" in engine:
+ name = f"{component}:{alias}"[:63]
+ config.setdefault("OPTIONS", {})["application_name"] = name
diff --git a/api/src/backend/config/django/devel.py b/api/src/backend/config/django/devel.py
index 9c83557b77..5b3871aa8b 100644
--- a/api/src/backend/config/django/devel.py
+++ b/api/src/backend/config/django/devel.py
@@ -50,10 +50,18 @@ DATABASES = {
"USER": env.str("NEO4J_USER", "neo4j"),
"PASSWORD": env.str("NEO4J_PASSWORD", "neo4j_password"),
},
+ "neptune": {
+ "WRITER_ENDPOINT": env.str("NEPTUNE_WRITER_ENDPOINT", ""),
+ "READER_ENDPOINT": env.str("NEPTUNE_READER_ENDPOINT", ""),
+ "PORT": env.str("NEPTUNE_PORT", "8182"),
+ "REGION": env.str("AWS_REGION", ""),
+ },
}
DATABASES["default"] = DATABASES["prowler_user"]
+label_postgres_connections(DATABASES) # noqa: F405
+
REST_FRAMEWORK["DEFAULT_RENDERER_CLASSES"] = tuple( # noqa: F405
render_class
for render_class in REST_FRAMEWORK["DEFAULT_RENDERER_CLASSES"] # noqa: F405
diff --git a/api/src/backend/config/django/production.py b/api/src/backend/config/django/production.py
index 91bd50d0d1..79d8993b10 100644
--- a/api/src/backend/config/django/production.py
+++ b/api/src/backend/config/django/production.py
@@ -49,12 +49,21 @@ DATABASES = {
"HOST": env("POSTGRES_REPLICA_HOST", default=default_db_host),
"PORT": env("POSTGRES_REPLICA_PORT", default=default_db_port),
},
+ # TODO: drop after Neptune cutover just loosen defaults to `""`
"neo4j": {
"HOST": env.str("NEO4J_HOST"),
"PORT": env.str("NEO4J_PORT"),
"USER": env.str("NEO4J_USER"),
"PASSWORD": env.str("NEO4J_PASSWORD"),
},
+ "neptune": {
+ "WRITER_ENDPOINT": env.str("NEPTUNE_WRITER_ENDPOINT", default=""),
+ "READER_ENDPOINT": env.str("NEPTUNE_READER_ENDPOINT", default=""),
+ "PORT": env.str("NEPTUNE_PORT", default="8182"),
+ "REGION": env.str("AWS_REGION", default=""),
+ },
}
DATABASES["default"] = DATABASES["prowler_user"]
+
+label_postgres_connections(DATABASES) # noqa: F405
diff --git a/api/src/backend/config/django/testing.py b/api/src/backend/config/django/testing.py
index 75779f5a68..9951478bfd 100644
--- a/api/src/backend/config/django/testing.py
+++ b/api/src/backend/config/django/testing.py
@@ -34,3 +34,12 @@ DRF_API_KEY = {
# JWT
SIMPLE_JWT["ALGORITHM"] = "HS256" # noqa: F405
+# pyjwt >= 2.13.0 rejects an empty HMAC signing key, so HS256 tests need a real
+# key (>= 32 bytes also avoids the InsecureKeyLengthWarning). Production uses RS256.
+SIMPLE_JWT["SIGNING_KEY"] = env.str( # noqa: F405
+ "DJANGO_TOKEN_SIGNING_KEY", "insecure-testing-jwt-signing-key-do-not-use-in-prod"
+)
+
+# Tests don't need secure password hashing; PBKDF2 (~hundreds of ms per call)
+# dominates fixture setup time across every create_user()/check_password().
+PASSWORD_HASHERS = ["django.contrib.auth.hashers.MD5PasswordHasher"]
diff --git a/api/src/backend/config/guniconf.py b/api/src/backend/config/guniconf.py
index 536fd97abb..8b17ad669d 100644
--- a/api/src/backend/config/guniconf.py
+++ b/api/src/backend/config/guniconf.py
@@ -1,8 +1,10 @@
import logging
import multiprocessing
import os
+import threading
from config.env import env
+from uvicorn_worker import UvicornWorker
# Ensure the environment variable for Django settings is set
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.django.production")
@@ -11,18 +13,46 @@ os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.django.production")
import django # noqa: E402
django.setup()
-from config.django.production import LOGGING as DJANGO_LOGGERS, DEBUG # noqa: E402
+
+from api.compliance import warm_compliance_caches # noqa: E402
from config.custom_logging import BackendLogger # noqa: E402
+from config.django.production import DEBUG # noqa: E402
+from config.django.production import LOGGING as DJANGO_LOGGERS # noqa: E402
BIND_ADDRESS = env("DJANGO_BIND_ADDRESS", default="127.0.0.1")
PORT = env("DJANGO_PORT", default=8080)
+
+class ProwlerUvicornWorker(UvicornWorker):
+ CONFIG_KWARGS = {
+ # Keep-alive idle timeout. Must exceed the load balancer idle timeout.
+ "timeout_keep_alive": env.int("GUNICORN_KEEPALIVE", default=75),
+ "loop": "uvloop",
+ "lifespan": "off", # Django ASGIHandler doesn't handle lifespan scopes
+ }
+
+
+# Required so SSE endpoints can keep the event loop alive while waiting for events
+worker_class = env(
+ "DJANGO_WORKER_CLASS",
+ default="config.guniconf.ProwlerUvicornWorker",
+)
+
# Server settings
bind = f"{BIND_ADDRESS}:{PORT}"
workers = env.int("DJANGO_WORKERS", default=multiprocessing.cpu_count() * 2 + 1)
reload = DEBUG
+# Preload the application before forking workers in production: the app is
+# imported once in the master and workers fork from it. In development, disable
+# preload so the server restarts on code changes.
+preload_app = not DEBUG
+
+# Worker timeout in seconds. Increased from the default 30s to handle requests
+# that may take longer, such as complex API operations.
+timeout = env.int("GUNICORN_TIMEOUT", default=120)
+
# Logging
logconfig_dict = DJANGO_LOGGERS
gunicorn_logger = logging.getLogger(BackendLogger.GUNICORN)
@@ -41,3 +71,46 @@ def on_reload(_):
def when_ready(_):
gunicorn_logger.info("Gunicorn server is ready")
+
+
+def _warm_compliance_caches_in_background():
+ """Warm compliance caches off the request path and log the outcome."""
+ failed = warm_compliance_caches()
+ if failed:
+ gunicorn_logger.warning("Compliance caches warmed (skipped: %s)", failed)
+ else:
+ gunicorn_logger.info("Compliance caches warmed")
+
+
+def post_fork(_server, worker):
+ """Re-initialize attack-paths drivers and warm compliance caches per worker.
+
+ Neo4j / Neptune drivers spawn background IO threads that do not survive
+ ``fork()``. When the gunicorn master runs with ``preload_app=True``, the
+ child inherits driver objects whose pool references dead threads and
+ hangs on the first ``pool.acquire`` call until the watchdog kills the
+ worker. Re-initializing per worker guarantees each child owns its own
+ live threads. See GUNICORN_WORKER_TIMEOUTS_ANALYSIS.md for detail.
+
+ Compliance caches are then warmed in a background thread so the worker
+ becomes ready immediately. A request for a not-yet-warmed provider lazily
+ loads just that provider, which stays well under the worker timeout.
+ """
+ from api.attack_paths import database as graph_database
+
+ try:
+ graph_database.close_driver()
+ except Exception: # pragma: no cover - best-effort cleanup
+ gunicorn_logger.debug(
+ "Failed to close inherited Neo4j driver in post_fork for worker pid=%s",
+ worker.pid,
+ exc_info=True,
+ )
+ graph_database.init_driver()
+ gunicorn_logger.info(f"Attack-paths drivers initialized for worker {worker.pid}")
+
+ threading.Thread(
+ target=_warm_compliance_caches_in_background,
+ name="warm-compliance-caches",
+ daemon=True,
+ ).start()
diff --git a/api/src/backend/config/settings/celery.py b/api/src/backend/config/settings/celery.py
index b7030ebea4..b7105a548c 100644
--- a/api/src/backend/config/settings/celery.py
+++ b/api/src/backend/config/settings/celery.py
@@ -53,3 +53,8 @@ CELERY_TASK_TRACK_STARTED = True
CELERY_BROKER_CONNECTION_RETRY_ON_STARTUP = True
CELERY_DEADLOCK_ATTEMPTS = env.int("DJANGO_CELERY_DEADLOCK_ATTEMPTS", default=5)
+
+# Opt-in override for Celery's prefork pool size. When unset, Celery falls back
+# to its default (os.cpu_count()).
+if "DJANGO_CELERY_WORKER_CONCURRENCY" in env.ENVIRON:
+ CELERY_WORKER_CONCURRENCY = env.int("DJANGO_CELERY_WORKER_CONCURRENCY")
diff --git a/api/src/backend/config/settings/eventstream.py b/api/src/backend/config/settings/eventstream.py
new file mode 100644
index 0000000000..470062050b
--- /dev/null
+++ b/api/src/backend/config/settings/eventstream.py
@@ -0,0 +1,41 @@
+"""Server-Sent Events (SSE) configuration.
+
+Wires django-eventstream into the platform: Valkey Pub/Sub backend on a
+dedicated DB (separate from the Celery broker), the platform channel
+manager, and headers that match the existing CORS allowlist.
+"""
+
+from config.env import env
+from config.settings.celery import (
+ VALKEY_HOST,
+ VALKEY_PASSWORD,
+ VALKEY_PORT,
+ VALKEY_SCHEME,
+ VALKEY_USERNAME,
+)
+
+# Dedicated Valkey DB for the SSE Pub/Sub bus. Kept distinct from the
+# Celery broker DB so a noisy broker can't shoulder out streaming
+# traffic on the same keyspace.
+EVENTSTREAM_VALKEY_DB = env.int("EVENTSTREAM_VALKEY_DB", default=2)
+
+EVENTSTREAM_REDIS: dict = {
+ "host": VALKEY_HOST,
+ "port": int(VALKEY_PORT),
+ "db": EVENTSTREAM_VALKEY_DB,
+}
+if VALKEY_PASSWORD:
+ EVENTSTREAM_REDIS["password"] = VALKEY_PASSWORD
+if VALKEY_USERNAME:
+ EVENTSTREAM_REDIS["username"] = VALKEY_USERNAME
+if VALKEY_SCHEME == "rediss":
+ EVENTSTREAM_REDIS["ssl"] = True
+
+# Platform channel manager — performs the per-feature authorization and
+# rewrites the placeholder channel from the URL into the canonical
+# tenant-scoped channel name. See ``api.sse.channelmanager``.
+EVENTSTREAM_CHANNELMANAGER_CLASS = "api.sse.channelmanager.SSEChannelManager"
+
+# Headers a browser EventSource may legitimately send. Keep tight; the
+# stream itself reads no body, so no permissive defaults.
+EVENTSTREAM_ALLOW_HEADERS = "Cache-Control, Last-Event-ID"
diff --git a/api/src/backend/config/settings/sentry.py b/api/src/backend/config/settings/sentry.py
index 580821f7b2..a3acbe37bb 100644
--- a/api/src/backend/config/settings/sentry.py
+++ b/api/src/backend/config/settings/sentry.py
@@ -1,5 +1,4 @@
import sentry_sdk
-
from config.env import env
IGNORED_EXCEPTIONS = [
@@ -76,6 +75,8 @@ IGNORED_EXCEPTIONS = [
# PowerShell Errors in User Authentication
"Microsoft Teams User Auth connection failed: Please check your permissions and try again.",
"Exchange Online User Auth connection failed: Please check your permissions and try again.",
+ # ASGI: Client disconnected before the response finished (health-check probes on /health/live)
+ "RequestAborted",
]
@@ -90,6 +91,13 @@ def before_send(event, hint):
log_msg = log_record.getMessage()
log_lvl = log_record.levelno
+ if (
+ getattr(log_record, "name", "") == "cartography.graph.job"
+ and "Neo.ClientError.Database.DatabaseNotFound" in log_msg
+ and "db-tmp-scan-" in log_msg
+ ):
+ return None
+
# The Neo4j driver logs transient connection errors (defunct
# connections, resets) at ERROR level via the `neo4j.io` logger.
# `RetryableSession` handles these with retries. If all retries
@@ -114,19 +122,27 @@ def before_send(event, hint):
return event
-sentry_sdk.init(
- dsn=env.str("DJANGO_SENTRY_DSN", ""),
- # Add data like request headers and IP for users,
- # see https://docs.sentry.io/platforms/python/data-management/data-collected/ for more info
- before_send=before_send,
- send_default_pii=True,
- traces_sample_rate=env.float("DJANGO_SENTRY_TRACES_SAMPLE_RATE", default=0.02),
- _experiments={
- # Set continuous_profiling_auto_start to True
- # to automatically start the profiler on when
- # possible.
- "continuous_profiling_auto_start": True,
- },
- attach_stacktrace=True,
- ignore_errors=IGNORED_EXCEPTIONS,
-)
+def initialize_sentry():
+ sentry_dsn = env.str("DJANGO_SENTRY_DSN", "")
+ if not sentry_dsn:
+ return
+
+ sentry_sdk.init(
+ dsn=sentry_dsn,
+ # Add data like request headers and IP for users,
+ # see https://docs.sentry.io/platforms/python/data-management/data-collected/ for more info
+ before_send=before_send,
+ send_default_pii=True,
+ traces_sample_rate=env.float("DJANGO_SENTRY_TRACES_SAMPLE_RATE", default=0.02),
+ _experiments={
+ # Set continuous_profiling_auto_start to True
+ # to automatically start the profiler on when
+ # possible.
+ "continuous_profiling_auto_start": True,
+ },
+ attach_stacktrace=True,
+ ignore_errors=IGNORED_EXCEPTIONS,
+ )
+
+
+initialize_sentry()
diff --git a/api/src/backend/config/settings/social_login.py b/api/src/backend/config/settings/social_login.py
index d6b5b53df2..ffc6a4724e 100644
--- a/api/src/backend/config/settings/social_login.py
+++ b/api/src/backend/config/settings/social_login.py
@@ -13,16 +13,17 @@ GITHUB_OAUTH_CALLBACK_URL = env("SOCIAL_GITHUB_OAUTH_CALLBACK_URL", default="")
ACCOUNT_LOGIN_METHODS = {"email"} # Use Email / Password authentication
ACCOUNT_SIGNUP_FIELDS = ["email*", "password1*", "password2*"]
ACCOUNT_EMAIL_VERIFICATION = "none" # Do not require email confirmation
+ACCOUNT_EMAIL_NOTIFICATIONS = False
ACCOUNT_USER_MODEL_USERNAME_FIELD = None
REST_AUTH = {
"TOKEN_MODEL": None,
"REST_USE_JWT": True,
}
# django-allauth (social)
-# Authenticate if local account with this email address already exists
-SOCIALACCOUNT_EMAIL_AUTHENTICATION = True
-# Connect local account and social account if local account with that email address already exists
-SOCIALACCOUNT_EMAIL_AUTHENTICATION_AUTO_CONNECT = True
+# Email-based account matching is handled by ProwlerSocialAccountAdapter, which
+# verifies both the provider email and the existing account email before linking.
+SOCIALACCOUNT_EMAIL_AUTHENTICATION = False
+SOCIALACCOUNT_EMAIL_AUTHENTICATION_AUTO_CONNECT = False
SOCIALACCOUNT_ADAPTER = "api.adapters.ProwlerSocialAccountAdapter"
diff --git a/api/src/backend/config/urls.py b/api/src/backend/config/urls.py
index 113f8bf5fc..4e7a46e31c 100644
--- a/api/src/backend/config/urls.py
+++ b/api/src/backend/config/urls.py
@@ -1,6 +1,5 @@
-from django.urls import include, path
-
from api.health import LivenessView, ReadinessView
+from django.urls import include, path
urlpatterns = [
path("api/v1/", include("api.v1.urls")),
diff --git a/api/src/backend/conftest.py b/api/src/backend/conftest.py
index c5228c77be..daa0616971 100644
--- a/api/src/backend/conftest.py
+++ b/api/src/backend/conftest.py
@@ -1,23 +1,11 @@
import logging
-from datetime import datetime, timedelta, timezone
+from datetime import UTC, datetime, timedelta
from types import SimpleNamespace
from unittest.mock import MagicMock, patch
+from uuid import uuid4
import pytest
from allauth.socialaccount.models import SocialLogin
-from django.conf import settings
-from django.db import connection as django_connection
-from django.db import connections as django_connections
-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,
- aggregate_scan_category_summaries,
- aggregate_scan_resource_group_summaries,
-)
-
from api.attack_paths import (
AttackPathsQueryDefinition,
AttackPathsQueryParameterDefinition,
@@ -60,14 +48,130 @@ from api.models import (
)
from api.rls import Tenant
from api.v1.serializers import TokenSerializer
+from django.conf import settings
+from django.db import connection as django_connection
+from django.db import connections as django_connections
+from django.test import Client
+from django.urls import reverse
+from django_celery_results.models import TaskResult
from prowler.lib.check.models import Severity
from prowler.lib.outputs.finding import Status
+from rest_framework import status
+from rest_framework.test import APIClient
+from rest_framework_simplejwt.tokens import AccessToken
+from tasks.jobs.backfill import (
+ aggregate_scan_category_summaries,
+ aggregate_scan_resource_group_summaries,
+ backfill_resource_scan_summaries,
+)
TODAY = str(datetime.today().date())
API_JSON_CONTENT_TYPE = "application/vnd.api+json"
NO_TENANT_HTTP_STATUS = status.HTTP_401_UNAUTHORIZED
TEST_USER = "dev@prowler.com"
TEST_PASSWORD = "testing_psswd"
+TEST_REPLICA_ALIAS = "test_replica"
+
+
+def _install_compliance_catalog_test_cache() -> None:
+ """Memoize the heavy SDK catalog loaders for the whole test session.
+
+ ``get_bulk_compliance_frameworks_universal`` re-reads and Pydantic-validates
+ ~100 compliance JSONs (≈20 MB) and ``CheckMetadata.get_bulk`` re-reads ~1k
+ check metadata files on *every* call. Production amortizes this through the
+ per-process lazy caches (``PROWLER_CHECKS`` / ``PROWLER_COMPLIANCE_OVERVIEW_TEMPLATE``)
+ and ``warm_compliance_caches``, but the test suite parametrizes over every
+ provider and deliberately resets the API-level caches, so the same catalogs
+ were re-parsed dozens of times across the suite (≈3s/call locally, ≈19s under
+ coverage in CI).
+
+ The catalog files are immutable during a run and callers treat the parsed
+ objects as read-only, so caching the result per provider is safe. This is the
+ test-only equivalent of an ``lru_cache`` on the SDK functions, without
+ changing SDK behavior in production.
+
+ A second, lower-level cache memoizes ``load_compliance_framework_universal``
+ **per file path**. ``get_bulk_compliance_frameworks_universal`` parses *every*
+ compliance JSON and only then filters by provider, so a per-provider cache
+ still re-parses all ~100 files on the first load of each provider. The
+ per-path cache makes the first provider parse the files once and every other
+ provider/test reuse the already-parsed ``ComplianceFramework`` objects (only
+ the cheap ``listdir`` + filtering re-runs). ``_load_jsons_from_dir`` calls
+ ``load_compliance_framework_universal`` as a module global, so patching the
+ attribute is picked up without touching the SDK.
+
+ Installed at conftest import time (before test modules are collected) so that
+ even ``from ... import get_bulk_compliance_frameworks_universal`` bindings in
+ the test modules resolve to the cached wrapper.
+ """
+ import prowler.lib.check.compliance_models as compliance_models
+ from prowler.lib.check.models import CheckMetadata
+
+ original_bulk_frameworks = (
+ compliance_models.get_bulk_compliance_frameworks_universal
+ )
+ original_get_bulk = CheckMetadata.get_bulk
+ original_load = compliance_models.load_compliance_framework_universal
+
+ def cached_bulk_frameworks(provider):
+ if provider not in _COMPLIANCE_FRAMEWORK_CACHE:
+ _COMPLIANCE_FRAMEWORK_CACHE[provider] = original_bulk_frameworks(provider)
+ return _COMPLIANCE_FRAMEWORK_CACHE[provider]
+
+ def cached_get_bulk(provider):
+ if provider not in _COMPLIANCE_CHECKS_CACHE:
+ _COMPLIANCE_CHECKS_CACHE[provider] = original_get_bulk(provider)
+ return _COMPLIANCE_CHECKS_CACHE[provider]
+
+ def cached_load(path):
+ if path not in _COMPLIANCE_PATH_CACHE:
+ _COMPLIANCE_PATH_CACHE[path] = original_load(path)
+ return _COMPLIANCE_PATH_CACHE[path]
+
+ compliance_models.get_bulk_compliance_frameworks_universal = cached_bulk_frameworks
+ compliance_models.load_compliance_framework_universal = cached_load
+ CheckMetadata.get_bulk = staticmethod(cached_get_bulk)
+
+ # ``api.compliance`` does ``from ... import get_bulk_compliance_frameworks_universal``
+ # so it holds its own binding; patch it too in case it was imported first.
+ import api.compliance as api_compliance
+
+ api_compliance.get_bulk_compliance_frameworks_universal = cached_bulk_frameworks
+
+
+# Module-scoped so the ``_compliance_cache_guard`` fixture below can reset them.
+# Keeping them out of ``_install_compliance_catalog_test_cache``'s local scope is
+# what makes the caches resettable between tests; the wrappers above close over
+# these names, and the original loaders stay referenced so patched behaviour is
+# still honoured.
+_COMPLIANCE_FRAMEWORK_CACHE: dict[str, dict] = {}
+_COMPLIANCE_CHECKS_CACHE: dict[str, dict] = {}
+_COMPLIANCE_PATH_CACHE: dict[str, object] = {}
+
+
+_install_compliance_catalog_test_cache()
+
+
+@pytest.fixture(autouse=True)
+def _compliance_cache_guard(request):
+ """Reset the compliance catalog caches after any test that used ``monkeypatch``.
+
+ The session-wide caches in ``_install_compliance_catalog_test_cache`` let the
+ read-only, parametrized compliance tests parse the ~100 catalog JSONs once
+ instead of dozens of times. A test that swaps a loader (or mutates a returned
+ object) could otherwise leak that state into later tests through the shared
+ dicts. Using ``monkeypatch`` as the opt-in signal keeps the full speed-up for
+ catalog-reading tests while giving patching tests a clean slate afterwards;
+ the next test simply repopulates the caches from disk.
+ """
+ yield
+ if "monkeypatch" in request.fixturenames:
+ _COMPLIANCE_FRAMEWORK_CACHE.clear()
+ _COMPLIANCE_CHECKS_CACHE.clear()
+ _COMPLIANCE_PATH_CACHE.clear()
+ import api.compliance as api_compliance
+
+ api_compliance.AVAILABLE_COMPLIANCE_FRAMEWORKS.clear()
def today_after_n_days(n_days: int) -> str:
@@ -128,14 +232,15 @@ def create_test_user(_session_test_user, django_db_blocker):
"""Re-create the session-scoped test user when a TransactionTestCase
has truncated the users table."""
with django_db_blocker.unblock():
- if not User.objects.filter(pk=_session_test_user.pk).exists():
- User.objects.create_user(
+ user = User.objects.filter(pk=_session_test_user.pk).first()
+ if user is None:
+ user = User.objects.create_user(
id=_session_test_user.pk,
name="testing",
email=TEST_USER,
password=TEST_PASSWORD,
)
- return _session_test_user
+ return user
@pytest.fixture(scope="function")
@@ -258,22 +363,42 @@ def create_test_user_rbac_manage_account(django_db_setup, django_db_blocker):
return user
+def first_membership_tenant(user):
+ return user.memberships.order_by("date_joined").first().tenant
+
+
+def access_token_for_tenant(user, tenant):
+ access_token = AccessToken.for_user(user)
+ access_token["tenant_id"] = str(tenant.id)
+ access_token.payload["nbf"] = access_token["iat"]
+ return str(access_token)
+
+
+def authenticate_client_for_tenant(client, user, tenant):
+ client.user = user
+ client.defaults["HTTP_AUTHORIZATION"] = (
+ f"Bearer {access_token_for_tenant(user, tenant)}"
+ )
+ return client
+
+
+@pytest.fixture
+def authenticated_client_for_tenant_factory():
+ def create_authenticated_client(user, tenant):
+ return authenticate_client_for_tenant(Client(), user, tenant)
+
+ return create_authenticated_client
+
+
@pytest.fixture
def authenticated_client_rbac_manage_account(
- create_test_user_rbac_manage_account, tenants_fixture, client
+ create_test_user_rbac_manage_account, client
):
- client.user = create_test_user_rbac_manage_account
- serializer = TokenSerializer(
- data={
- "type": "tokens",
- "email": "rbac_manage_account@rbac.com",
- "password": TEST_PASSWORD,
- }
+ return authenticate_client_for_tenant(
+ client,
+ create_test_user_rbac_manage_account,
+ first_membership_tenant(create_test_user_rbac_manage_account),
)
- serializer.is_valid()
- access_token = serializer.validated_data["access"]
- client.defaults["HTTP_AUTHORIZATION"] = f"Bearer {access_token}"
- return client
@pytest.fixture(scope="function")
@@ -310,86 +435,43 @@ def create_test_user_rbac_manage_users_only(django_db_setup, django_db_blocker):
def authenticated_client_rbac_manage_users_only(
create_test_user_rbac_manage_users_only, client
):
- client.user = create_test_user_rbac_manage_users_only
- serializer = TokenSerializer(
- data={
- "type": "tokens",
- "email": "rbac_manage_users_only@rbac.com",
- "password": TEST_PASSWORD,
- }
+ return authenticate_client_for_tenant(
+ client,
+ create_test_user_rbac_manage_users_only,
+ first_membership_tenant(create_test_user_rbac_manage_users_only),
)
- serializer.is_valid()
- access_token = serializer.validated_data["access"]
- client.defaults["HTTP_AUTHORIZATION"] = f"Bearer {access_token}"
- return client
@pytest.fixture
def authenticated_client_rbac(create_test_user_rbac, tenants_fixture, client):
- client.user = create_test_user_rbac
- tenant_id = tenants_fixture[0].id
- serializer = TokenSerializer(
- data={
- "type": "tokens",
- "email": "rbac@rbac.com",
- "password": TEST_PASSWORD,
- "tenant_id": tenant_id,
- }
+ return authenticate_client_for_tenant(
+ client, create_test_user_rbac, tenants_fixture[0]
)
- serializer.is_valid(raise_exception=True)
- access_token = serializer.validated_data["access"]
- client.defaults["HTTP_AUTHORIZATION"] = f"Bearer {access_token}"
- return client
@pytest.fixture
def authenticated_client_rbac_noroles(
create_test_user_rbac_no_roles, tenants_fixture, client
):
- client.user = create_test_user_rbac_no_roles
- serializer = TokenSerializer(
- data={
- "type": "tokens",
- "email": "rbac_noroles@rbac.com",
- "password": TEST_PASSWORD,
- }
+ return authenticate_client_for_tenant(
+ client, create_test_user_rbac_no_roles, tenants_fixture[0]
)
- serializer.is_valid()
- access_token = serializer.validated_data["access"]
- client.defaults["HTTP_AUTHORIZATION"] = f"Bearer {access_token}"
- return client
@pytest.fixture
def authenticated_client_no_permissions_rbac(
create_test_user_rbac_limited, tenants_fixture, client
):
- client.user = create_test_user_rbac_limited
- serializer = TokenSerializer(
- data={
- "type": "tokens",
- "email": "rbac_limited@rbac.com",
- "password": TEST_PASSWORD,
- }
+ return authenticate_client_for_tenant(
+ client, create_test_user_rbac_limited, tenants_fixture[0]
)
- serializer.is_valid()
- access_token = serializer.validated_data["access"]
- client.defaults["HTTP_AUTHORIZATION"] = f"Bearer {access_token}"
- return client
@pytest.fixture
def authenticated_client(
create_test_user, tenants_fixture, set_user_admin_roles_fixture, client
):
- client.user = create_test_user
- serializer = TokenSerializer(
- data={"type": "tokens", "email": TEST_USER, "password": TEST_PASSWORD}
- )
- serializer.is_valid()
- access_token = serializer.validated_data["access"]
- client.defaults["HTTP_AUTHORIZATION"] = f"Bearer {access_token}"
- return client
+ return authenticate_client_for_tenant(client, create_test_user, tenants_fixture[0])
@pytest.fixture
@@ -468,7 +550,7 @@ def invitations_fixture(create_test_user, tenants_fixture):
email="testing@prowler.com",
state=Invitation.State.EXPIRED,
token="TESTING1234568",
- expires_at=datetime.now(timezone.utc) - timedelta(days=1),
+ expires_at=datetime.now(UTC) - timedelta(days=1),
inviter=user,
tenant=tenant,
)
@@ -490,102 +572,191 @@ def users_fixture(django_user_model):
@pytest.fixture
-def providers_fixture(tenants_fixture):
- tenant, *_ = tenants_fixture
- provider1 = Provider.objects.create(
- provider="aws",
- uid="123456789012",
- alias="aws_testing_1",
- tenant_id=tenant.id,
- )
- provider2 = Provider.objects.create(
- provider="aws",
- uid="123456789013",
- alias="aws_testing_2",
- tenant_id=tenant.id,
- )
- provider3 = Provider.objects.create(
- provider="gcp",
- uid="a12322-test321",
- alias="gcp_testing",
- tenant_id=tenant.id,
- )
- provider4 = Provider.objects.create(
- provider="kubernetes",
- uid="kubernetes-test-12345",
- alias="k8s_testing",
- tenant_id=tenant.id,
- )
- provider5 = Provider.objects.create(
- provider="azure",
- uid="37b065f8-26b0-4218-a665-0b23d07b27d9",
- alias="azure_testing",
- tenant_id=tenant.id,
- scanner_args={"key1": "value1", "key2": {"key21": "value21"}},
- )
- provider6 = Provider.objects.create(
- provider="m365",
- uid="m365.test.com",
- alias="m365_testing",
- tenant_id=tenant.id,
- )
- provider7 = Provider.objects.create(
- provider="oraclecloud",
- uid="ocid1.tenancy.oc1..aaaaaaaa3dwoazoox4q7wrvriywpokp5grlhgnkwtyt6dmwyou7no6mdmzda",
- alias="oci_testing",
- tenant_id=tenant.id,
- )
- provider8 = Provider.objects.create(
- provider="mongodbatlas",
- uid="64b1d3c0e4b03b1234567890",
- alias="mongodbatlas_testing",
- tenant_id=tenant.id,
- )
- provider9 = Provider.objects.create(
- provider="alibabacloud",
- uid="1234567890123456",
- alias="alibabacloud_testing",
- tenant_id=tenant.id,
- )
- provider10 = Provider.objects.create(
- provider="cloudflare",
- uid="a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4",
- alias="cloudflare_testing",
- tenant_id=tenant.id,
- )
- provider11 = Provider.objects.create(
- provider="openstack",
- uid="a1b2c3d4-e5f6-7890-abcd-ef1234567890",
- alias="openstack_testing",
- tenant_id=tenant.id,
- )
- provider12 = Provider.objects.create(
- provider="googleworkspace",
- uid="C12345678",
- alias="googleworkspace_testing",
- tenant_id=tenant.id,
- )
- provider13 = Provider.objects.create(
- provider="vercel",
- uid="team_abcdef1234567890ab",
- alias="vercel_testing",
- tenant_id=tenant.id,
+def provider_factory(tenants_fixture):
+ tenant = tenants_fixture[0]
+ counters = {}
+
+ def next_counter(provider):
+ counters[provider] = counters.get(provider, 0) + 1
+ return counters[provider]
+
+ def defaults_for(provider, sequence):
+ return {
+ Provider.ProviderChoices.AWS.value: {
+ "uid": f"{123456789011 + sequence:012d}",
+ "alias": f"aws_testing_{sequence}",
+ },
+ Provider.ProviderChoices.AZURE.value: {
+ "uid": str(uuid4()),
+ "alias": f"azure_testing_{sequence}",
+ "scanner_args": {"key1": "value1", "key2": {"key21": "value21"}},
+ },
+ Provider.ProviderChoices.GCP.value: {
+ "uid": f"a12322-test{sequence:05d}",
+ "alias": f"gcp_testing_{sequence}",
+ },
+ Provider.ProviderChoices.KUBERNETES.value: {
+ "uid": f"kubernetes-test-{sequence}",
+ "alias": f"k8s_testing_{sequence}",
+ },
+ Provider.ProviderChoices.M365.value: {
+ "uid": f"m365-{sequence}.test.com",
+ "alias": f"m365_testing_{sequence}",
+ },
+ Provider.ProviderChoices.GITHUB.value: {
+ "uid": f"github-test-{sequence}",
+ "alias": f"github_testing_{sequence}",
+ },
+ Provider.ProviderChoices.MONGODBATLAS.value: {
+ "uid": f"64b1d3c0e4b03b{sequence:010x}",
+ "alias": f"mongodbatlas_testing_{sequence}",
+ },
+ Provider.ProviderChoices.IAC.value: {
+ "uid": f"https://github.com/prowler-cloud/test-{sequence}.git",
+ "alias": f"iac_testing_{sequence}",
+ },
+ Provider.ProviderChoices.ORACLECLOUD.value: {
+ "uid": f"ocid1.tenancy.oc1..aaaaaaaa{sequence:024d}",
+ "alias": f"oci_testing_{sequence}",
+ },
+ Provider.ProviderChoices.ALIBABACLOUD.value: {
+ "uid": f"{1234567890123455 + sequence:016d}",
+ "alias": f"alibabacloud_testing_{sequence}",
+ },
+ Provider.ProviderChoices.CLOUDFLARE.value: {
+ "uid": f"{0x1000000000000000000000000000000 + sequence:032x}",
+ "alias": f"cloudflare_testing_{sequence}",
+ },
+ Provider.ProviderChoices.OPENSTACK.value: {
+ "uid": f"openstack-project-{sequence}",
+ "alias": f"openstack_testing_{sequence}",
+ },
+ Provider.ProviderChoices.IMAGE.value: {
+ "uid": f"registry.example.com/prowler/test:{sequence}",
+ "alias": f"image_testing_{sequence}",
+ },
+ Provider.ProviderChoices.GOOGLEWORKSPACE.value: {
+ "uid": f"C{12345677 + sequence}",
+ "alias": f"googleworkspace_testing_{sequence}",
+ },
+ Provider.ProviderChoices.VERCEL.value: {
+ "uid": f"team_{sequence:016x}",
+ "alias": f"vercel_testing_{sequence}",
+ },
+ Provider.ProviderChoices.OKTA.value: {
+ "uid": f"acme-{sequence}.okta.com",
+ "alias": f"okta_testing_{sequence}",
+ },
+ }[provider]
+
+ def create_provider(provider=Provider.ProviderChoices.AWS.value, **overrides):
+ provider_value = getattr(provider, "value", provider)
+ selected_tenant = overrides.pop("tenant", tenant)
+ sequence = next_counter(provider_value)
+ attributes = {
+ "provider": provider_value,
+ "tenant_id": selected_tenant.id,
+ **defaults_for(provider_value, sequence),
+ }
+ attributes.update(overrides)
+ return Provider.objects.create(**attributes)
+
+ return create_provider
+
+
+@pytest.fixture
+def aws_provider(provider_factory):
+ return provider_factory(Provider.ProviderChoices.AWS.value)
+
+
+@pytest.fixture
+def aws_provider_pair(aws_provider, provider_factory):
+ return (
+ aws_provider,
+ provider_factory(Provider.ProviderChoices.AWS.value),
)
- return (
- provider1,
- provider2,
- provider3,
- provider4,
- provider5,
- provider6,
- provider7,
- provider8,
- provider9,
- provider10,
- provider11,
- provider12,
- provider13,
+
+@pytest.fixture
+def azure_provider(provider_factory):
+ return provider_factory(Provider.ProviderChoices.AZURE.value)
+
+
+@pytest.fixture
+def gcp_provider(provider_factory):
+ return provider_factory(Provider.ProviderChoices.GCP.value)
+
+
+@pytest.fixture
+def kubernetes_provider(provider_factory):
+ return provider_factory(Provider.ProviderChoices.KUBERNETES.value)
+
+
+@pytest.fixture
+def m365_provider(provider_factory):
+ return provider_factory(Provider.ProviderChoices.M365.value)
+
+
+@pytest.fixture
+def github_provider(provider_factory):
+ return provider_factory(Provider.ProviderChoices.GITHUB.value)
+
+
+@pytest.fixture
+def mongodbatlas_provider(provider_factory):
+ return provider_factory(Provider.ProviderChoices.MONGODBATLAS.value)
+
+
+@pytest.fixture
+def iac_provider(provider_factory):
+ return provider_factory(Provider.ProviderChoices.IAC.value)
+
+
+@pytest.fixture
+def oraclecloud_provider(provider_factory):
+ return provider_factory(Provider.ProviderChoices.ORACLECLOUD.value)
+
+
+@pytest.fixture
+def alibabacloud_provider(provider_factory):
+ return provider_factory(Provider.ProviderChoices.ALIBABACLOUD.value)
+
+
+@pytest.fixture
+def cloudflare_provider(provider_factory):
+ return provider_factory(Provider.ProviderChoices.CLOUDFLARE.value)
+
+
+@pytest.fixture
+def openstack_provider(provider_factory):
+ return provider_factory(Provider.ProviderChoices.OPENSTACK.value)
+
+
+@pytest.fixture
+def image_provider(provider_factory):
+ return provider_factory(Provider.ProviderChoices.IMAGE.value)
+
+
+@pytest.fixture
+def googleworkspace_provider(provider_factory):
+ return provider_factory(Provider.ProviderChoices.GOOGLEWORKSPACE.value)
+
+
+@pytest.fixture
+def vercel_provider(provider_factory):
+ return provider_factory(Provider.ProviderChoices.VERCEL.value)
+
+
+@pytest.fixture
+def okta_provider(provider_factory):
+ return provider_factory(Provider.ProviderChoices.OKTA.value)
+
+
+@pytest.fixture
+def all_provider_types_fixture(provider_factory):
+ return tuple(
+ provider_factory(provider_choice.value)
+ for provider_choice in Provider.ProviderChoices
)
@@ -690,7 +861,7 @@ def roles_fixture(tenants_fixture):
@pytest.fixture
-def provider_secret_fixture(providers_fixture):
+def provider_secret_fixture(all_provider_types_fixture):
return tuple(
ProviderSecret.objects.create(
tenant_id=provider.tenant_id,
@@ -699,16 +870,16 @@ def provider_secret_fixture(providers_fixture):
secret={"key": "value"},
name=provider.alias,
)
- for provider in providers_fixture
+ for provider in all_provider_types_fixture
)
@pytest.fixture
-def scans_fixture(tenants_fixture, providers_fixture):
+def scans_fixture(tenants_fixture, aws_provider_pair):
tenant, *_ = tenants_fixture
- provider, provider2, *_ = providers_fixture
+ provider, provider2 = aws_provider_pair
- now = datetime.now(timezone.utc)
+ now = datetime.now(UTC)
scan1 = Scan.objects.create(
name="Scan 1",
@@ -769,8 +940,8 @@ def tasks_fixture(tenants_fixture):
@pytest.fixture
-def resources_fixture(providers_fixture):
- provider, *_ = providers_fixture
+def resources_fixture(aws_provider_pair):
+ provider, provider2 = aws_provider_pair
tags = [
ResourceTag.objects.create(
@@ -811,8 +982,8 @@ def resources_fixture(providers_fixture):
resource2.upsert_or_delete_tags(tags)
resource3 = Resource.objects.create(
- tenant_id=providers_fixture[1].tenant_id,
- provider=providers_fixture[1],
+ tenant_id=provider2.tenant_id,
+ provider=provider2,
uid="arn:aws:ec2:us-east-1:123456789012:bucket/i-1234567890abcdef2",
name="My Bucket 3",
region="us-east-1",
@@ -1160,9 +1331,9 @@ def get_api_tokens(
@pytest.fixture
-def scan_summaries_fixture(tenants_fixture, providers_fixture):
+def scan_summaries_fixture(tenants_fixture, aws_provider):
tenant = tenants_fixture[0]
- provider = providers_fixture[0]
+ provider = aws_provider
scan = Scan.objects.create(
name="overview scan",
provider=provider,
@@ -1239,8 +1410,8 @@ def scan_summaries_fixture(tenants_fixture, providers_fixture):
@pytest.fixture
-def integrations_fixture(providers_fixture):
- provider1, provider2, *_ = providers_fixture
+def integrations_fixture(aws_provider_pair):
+ provider1, provider2 = aws_provider_pair
tenant_id = provider1.tenant_id
integration1 = Integration.objects.create(
tenant_id=tenant_id,
@@ -1301,9 +1472,9 @@ def lighthouse_config_fixture(authenticated_client, tenants_fixture):
@pytest.fixture(scope="function")
-def latest_scan_finding(authenticated_client, providers_fixture, resources_fixture):
- provider = providers_fixture[0]
- tenant_id = str(providers_fixture[0].tenant_id)
+def latest_scan_finding(authenticated_client, aws_provider, resources_fixture):
+ provider = aws_provider
+ tenant_id = str(aws_provider.tenant_id)
resource = resources_fixture[0]
scan = Scan.objects.create(
name="latest completed scan",
@@ -1414,10 +1585,10 @@ def findings_with_multiple_categories(scans_fixture, resources_fixture):
@pytest.fixture(scope="function")
def latest_scan_finding_with_categories(
- authenticated_client, providers_fixture, resources_fixture
+ authenticated_client, aws_provider, resources_fixture
):
- provider = providers_fixture[0]
- tenant_id = str(providers_fixture[0].tenant_id)
+ provider = aws_provider
+ tenant_id = str(aws_provider.tenant_id)
resource = resources_fixture[0]
scan = Scan.objects.create(
name="latest completed scan with categories",
@@ -1451,9 +1622,9 @@ def latest_scan_finding_with_categories(
@pytest.fixture(scope="function")
-def latest_scan_resource(authenticated_client, providers_fixture):
- provider = providers_fixture[0]
- tenant_id = str(providers_fixture[0].tenant_id)
+def latest_scan_resource(authenticated_client, aws_provider):
+ provider = aws_provider
+ tenant_id = str(aws_provider.tenant_id)
scan = Scan.objects.create(
name="latest completed scan for resource",
provider=provider,
@@ -1601,7 +1772,7 @@ def api_keys_fixture(tenants_fixture, create_test_user):
name="Test API Key 2",
tenant_id=tenant.id,
entity=user,
- expiry_date=datetime.now(timezone.utc) + timedelta(days=60),
+ expiry_date=datetime.now(UTC) + timedelta(days=60),
)
# Revoked API key
@@ -1714,6 +1885,36 @@ def attack_paths_query_definition_factory():
return _create
+@pytest.fixture
+def sink_backend_stub():
+ """Install a stub `SinkDatabase` into the sink factory for the test's duration.
+
+ The sink factory caches a process-wide backend and lazily initializes it
+ against `settings.DATABASES["neo4j"]` / `["neptune"]`. Tests that don't
+ want to stand up a real Bolt driver can yield this fixture's mock and
+ configure its return values directly:
+
+ sink_backend_stub.execute_read_query.return_value = some_graph
+
+ Both the active backend and the secondary-backend cache are restored on
+ teardown so tests stay isolated.
+ """
+ from api.attack_paths.sink import factory
+ from api.attack_paths.sink.base import SinkDatabase
+
+ stub = MagicMock(spec=SinkDatabase)
+ previous_backend = factory._backend
+ previous_secondary = dict(factory._secondary_backends)
+ factory._backend = stub
+ factory._secondary_backends.clear()
+ try:
+ yield stub
+ finally:
+ factory._backend = previous_backend
+ factory._secondary_backends.clear()
+ factory._secondary_backends.update(previous_secondary)
+
+
@pytest.fixture
def attack_paths_graph_stub_classes():
"""Provide lightweight graph element stubs for Attack Paths serialization tests."""
@@ -1888,17 +2089,17 @@ def get_authorization_header(access_token: str) -> dict:
@pytest.fixture
def provider_compliance_scores_fixture(
- tenants_fixture, providers_fixture, scans_fixture
+ tenants_fixture, aws_provider_pair, scans_fixture
):
"""Create ProviderComplianceScore entries for compliance watchlist tests."""
tenant = tenants_fixture[0]
- provider1, provider2, *_ = providers_fixture
+ provider1, provider2 = aws_provider_pair
scan1, _, scan3 = scans_fixture
- scan1.completed_at = datetime.now(timezone.utc) - timedelta(hours=1)
+ scan1.completed_at = datetime.now(UTC) - timedelta(hours=1)
scan1.save()
scan3.state = StateChoices.COMPLETED
- scan3.completed_at = datetime.now(timezone.utc)
+ scan3.completed_at = datetime.now(UTC)
scan3.save()
scores = [
@@ -1989,9 +2190,7 @@ def tenant_compliance_summary_fixture(tenants_fixture):
@pytest.fixture
-def finding_groups_fixture(
- tenants_fixture, providers_fixture, scans_fixture, resources_fixture
-):
+def finding_groups_fixture(tenants_fixture, scans_fixture, resources_fixture):
"""
Create a comprehensive set of findings for testing Finding Groups aggregation.
@@ -2010,7 +2209,6 @@ def finding_groups_fixture(
- Finding counts (pass, fail, muted, new, changed)
"""
tenant = tenants_fixture[0]
- provider1, provider2, *_ = providers_fixture
scan1, scan2, *_ = scans_fixture
resource1, resource2, *_ = resources_fixture
@@ -2261,7 +2459,7 @@ def finding_groups_fixture(
@pytest.fixture
def finding_groups_title_variants_fixture(
- tenants_fixture, providers_fixture, scans_fixture, resources_fixture
+ tenants_fixture, scans_fixture, resources_fixture
):
"""
Two providers report the same check_id with different checktitle values.
@@ -2272,7 +2470,6 @@ def finding_groups_title_variants_fixture(
of which title variant matches the search term.
"""
tenant = tenants_fixture[0]
- provider1, provider2, *_ = providers_fixture
scan1, scan2, *_ = scans_fixture
resource1, resource2, *_ = resources_fixture
@@ -2346,8 +2543,27 @@ def pytest_collection_modifyitems(items):
"""Ensure test_rbac.py is executed first."""
items.sort(key=lambda item: 0 if "test_rbac.py" in item.nodeid else 1)
+ if any(item.get_closest_marker("requires_test_replica_alias") for item in items):
+ default_database = settings.DATABASES["default"]
+ if TEST_REPLICA_ALIAS not in settings.DATABASES:
+ settings.DATABASES[TEST_REPLICA_ALIAS] = {
+ **default_database,
+ "TEST": {
+ **default_database.get("TEST", {}),
+ "MIRROR": "default",
+ },
+ }
+ django_connections.databases[TEST_REPLICA_ALIAS] = settings.DATABASES[
+ TEST_REPLICA_ALIAS
+ ]
+
def pytest_configure(config):
+ config.addinivalue_line(
+ "markers",
+ "requires_test_replica_alias: creates a test-only replica alias mirrored "
+ "to default",
+ )
# Apply the mock before the test session starts. This is necessary to avoid admin error when running the
# 0004_rbac_missing_admin_roles migration
patch("api.db_router.MainRouter.admin_db", new="default").start()
diff --git a/api/src/backend/tasks/beat.py b/api/src/backend/tasks/beat.py
index e9eb9c9309..017bec844a 100644
--- a/api/src/backend/tasks/beat.py
+++ b/api/src/backend/tasks/beat.py
@@ -1,13 +1,12 @@
import json
-from datetime import datetime, timedelta, timezone
-
-from django_celery_beat.models import IntervalSchedule, PeriodicTask
-from tasks.tasks import perform_scheduled_scan_task
+from datetime import UTC, datetime, timedelta
from api.db_utils import rls_transaction
from api.exceptions import ConflictException
from api.models import Provider, Scan, StateChoices
+from django_celery_beat.models import IntervalSchedule, PeriodicTask
from tasks.jobs.attack_paths import db_utils as attack_paths_db_utils
+from tasks.tasks import perform_scheduled_scan_task
def schedule_provider_scan(provider_instance: Provider):
@@ -37,7 +36,7 @@ def schedule_provider_scan(provider_instance: Provider):
provider_id=provider_id,
trigger=Scan.TriggerChoices.SCHEDULED,
state=StateChoices.AVAILABLE,
- scheduled_at=datetime.now(timezone.utc),
+ scheduled_at=datetime.now(UTC),
)
attack_paths_db_utils.create_attack_paths_scan(
@@ -58,7 +57,7 @@ def schedule_provider_scan(provider_instance: Provider):
}
),
one_off=False,
- start_time=datetime.now(timezone.utc) + timedelta(hours=24),
+ start_time=datetime.now(UTC) + timedelta(hours=24),
)
scheduled_scan.scheduler_task_id = periodic_task_instance.id
scheduled_scan.save()
diff --git a/api/src/backend/tasks/jobs/attack_paths/aws.py b/api/src/backend/tasks/jobs/attack_paths/aws.py
index 692eee61cd..4b2b96dd1b 100644
--- a/api/src/backend/tasks/jobs/attack_paths/aws.py
+++ b/api/src/backend/tasks/jobs/attack_paths/aws.py
@@ -2,21 +2,23 @@
# (https://github.com/cartography-cncf/cartography), which is licensed under the Apache 2.0 License.
import time
-
from typing import Any
import aioboto3
import boto3
+import botocore
import neo4j
-
+import neo4j.exceptions
+from api.attack_paths.database import DATABASE_NOT_FOUND_CODE
+from api.models import (
+ AttackPathsScan as ProwlerAPIAttackPathsScan,
+)
+from api.models import (
+ Provider as ProwlerAPIProvider,
+)
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
@@ -74,13 +76,28 @@ def start_aws_ingestion(
# 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,
- )
+ # AWS Organizations account autodiscovery. Inlined from Cartography's removed
+ # `_autodiscover_accounts` (deleted in `0.137.0`), as `load_aws_accounts` is still public.
+ try:
+ org_client = boto3_session.client("organizations")
+ paginator = org_client.get_paginator("list_accounts")
+ discovered = []
+ for page in paginator.paginate():
+ discovered.extend(page["Accounts"])
+ active_accounts = {
+ a["Name"]: a["Id"] for a in discovered if a["Status"] == "ACTIVE"
+ }
+ cartography_aws.organizations.load_aws_accounts(
+ neo4j_session,
+ active_accounts,
+ cartography_config.update_tag,
+ common_job_parameters,
+ )
+ except botocore.exceptions.ClientError:
+ logger.warning(
+ f"Account {prowler_api_provider.uid} lacks permissions for AWS "
+ "Organizations autodiscovery."
+ )
db_utils.update_attack_paths_scan_progress(attack_paths_scan, 4)
failed_syncs = sync_aws_account(
@@ -278,7 +295,7 @@ def sync_aws_account(
sync_args: dict[str, Any],
attack_paths_scan: ProwlerAPIAttackPathsScan,
) -> dict[str, str]:
- current_progress = 4 # `cartography_aws._autodiscover_accounts`
+ current_progress = 4 # AWS Organizations account autodiscovery
max_progress = (
87 # `cartography_aws.RESOURCE_FUNCTIONS["permission_relationships"]` - 1
)
@@ -332,6 +349,12 @@ def sync_aws_account(
)
except Exception as e:
+ if (
+ isinstance(e, neo4j.exceptions.Neo4jError)
+ and e.code == DATABASE_NOT_FOUND_CODE
+ ):
+ raise
+
logger.info(
f"Synced function {func_name} for AWS account {prowler_api_provider.uid} in {time.perf_counter() - func_t0:.3f}s (FAILED)"
)
diff --git a/api/src/backend/tasks/jobs/attack_paths/cleanup.py b/api/src/backend/tasks/jobs/attack_paths/cleanup.py
index 65ba583a3e..11868b3fe0 100644
--- a/api/src/backend/tasks/jobs/attack_paths/cleanup.py
+++ b/api/src/backend/tasks/jobs/attack_paths/cleanup.py
@@ -1,39 +1,50 @@
-from datetime import datetime, timedelta, timezone
-
-from celery import current_app, states
-from celery.utils.log import get_task_logger
-from config.django.base import ATTACK_PATHS_SCAN_STALE_THRESHOLD_MINUTES
-from tasks.jobs.attack_paths.db_utils import (
- _mark_scan_finished,
- recover_graph_data_ready,
-)
+from datetime import UTC, datetime, timedelta
+from functools import partial
from api.attack_paths import database as graph_database
from api.db_router import MainRouter
from api.db_utils import rls_transaction
from api.models import AttackPathsScan, StateChoices
+from celery import current_app, states
+from celery.utils.log import get_task_logger
+from config.django.base import (
+ ATTACK_PATHS_SCAN_INACTIVITY_THRESHOLD_MINUTES,
+ ATTACK_PATHS_SCAN_STALE_THRESHOLD_MINUTES,
+)
+from django.db import DatabaseError
+from django.db.transaction import on_commit
+from tasks.jobs.attack_paths.db_utils import (
+ mark_scan_finished,
+ recover_graph_data_ready,
+)
+from tasks.jobs.orphan_recovery import revoke_task as _revoke_task
logger = get_task_logger(__name__)
+WORKER_PING_BASE_TIMEOUT_SECONDS = 5
+WORKER_PING_MAX_ATTEMPTS = 3
+
def cleanup_stale_attack_paths_scans() -> dict:
"""
Mark stale `AttackPathsScan` rows as `FAILED`.
Covers two stuck-state scenarios:
- 1. `EXECUTING` scans whose workers are dead, or that have exceeded the
- stale threshold while alive.
- 2. `SCHEDULED` scans that never made it to a worker — parent scan
+ 1. `EXECUTING` scans whose workers are unresponsive and whose rows have
+ stopped receiving progress updates, or that exceeded the stale threshold.
+ 2. `SCHEDULED` scans that never made it to a worker - parent scan
crashed before dispatch, broker lost the message, etc. Detected by
age plus the parent `Scan` no longer being in flight.
"""
- threshold = timedelta(minutes=ATTACK_PATHS_SCAN_STALE_THRESHOLD_MINUTES)
- now = datetime.now(tz=timezone.utc)
- cutoff = now - threshold
+ now = datetime.now(tz=UTC)
+ stale_cutoff = now - timedelta(minutes=ATTACK_PATHS_SCAN_STALE_THRESHOLD_MINUTES)
+ inactivity_cutoff = now - timedelta(
+ minutes=ATTACK_PATHS_SCAN_INACTIVITY_THRESHOLD_MINUTES
+ )
cleaned_up: list[str] = []
- cleaned_up.extend(_cleanup_stale_executing_scans(cutoff))
- cleaned_up.extend(_cleanup_stale_scheduled_scans(cutoff))
+ cleaned_up.extend(_cleanup_stale_executing_scans(stale_cutoff, inactivity_cutoff))
+ cleaned_up.extend(_cleanup_stale_scheduled_scans(stale_cutoff))
logger.info(
f"Stale `AttackPathsScan` cleanup: {len(cleaned_up)} scan(s) cleaned up"
@@ -41,13 +52,57 @@ def cleanup_stale_attack_paths_scans() -> dict:
return {"cleaned_up_count": len(cleaned_up), "scan_ids": cleaned_up}
-def _cleanup_stale_executing_scans(cutoff: datetime) -> list[str]:
+def _ping_workers(workers: set[str]) -> tuple[set[str], set[str] | None]:
+ """Ping worker destinations in parallel and retry only missing workers.
+
+ The second tuple item is `None` when the final ping attempt raises. In that
+ case the pending workers have unknown liveness and their scans must be kept.
+ """
+ pending = set(workers)
+ responsive: set[str] = set()
+
+ for attempt in range(WORKER_PING_MAX_ATTEMPTS):
+ if not pending:
+ return responsive, set()
+
+ timeout = WORKER_PING_BASE_TIMEOUT_SECONDS * 2**attempt
+ try:
+ response = current_app.control.inspect(
+ destination=sorted(pending), timeout=timeout
+ ).ping()
+ except Exception:
+ attempts_remaining = WORKER_PING_MAX_ATTEMPTS - attempt - 1
+ if attempts_remaining:
+ logger.warning(
+ f"Attack Paths worker ping attempt {attempt + 1} failed; "
+ f"retrying pending workers with {attempts_remaining} "
+ "attempt(s) remaining",
+ exc_info=True,
+ )
+ continue
+
+ logger.exception(
+ "Attack Paths worker ping attempts exhausted; preserving scans "
+ "for workers with unknown liveness"
+ )
+ return responsive, None
+
+ responded = pending.intersection((response or {}).keys())
+ responsive.update(responded)
+ pending.difference_update(responded)
+
+ return responsive, pending
+
+
+def _cleanup_stale_executing_scans(
+ stale_cutoff: datetime, inactivity_cutoff: datetime
+) -> list[str]:
"""
Two-pass detection for `EXECUTING` scans:
- 1. If `TaskResult.worker` exists, ping the worker.
- - Dead worker: cleanup immediately (any age).
- - Alive + past threshold: revoke the task, then cleanup.
- - Alive + within threshold: skip.
+ 1. Ping all recorded workers in parallel with bounded retries.
+ - Responsive + past stale threshold: cleanup.
+ - Unresponsive + past inactivity threshold: cleanup.
+ - Unknown after a final ping exception: preserve.
2. If no worker field: fall back to time-based heuristic only.
"""
executing_scans = list(
@@ -56,14 +111,13 @@ def _cleanup_stale_executing_scans(cutoff: datetime) -> list[str]:
.select_related("task__task_runner_task")
)
- # Cache worker liveness so each worker is pinged at most once
workers = {
tr.worker
for scan in executing_scans
if (tr := getattr(scan.task, "task_runner_task", None) if scan.task else None)
and tr.worker
}
- worker_alive = {w: _is_worker_alive(w) for w in workers}
+ responsive_workers, unresponsive_workers = _ping_workers(workers)
cleaned_up: list[str] = []
@@ -74,27 +128,50 @@ def _cleanup_stale_executing_scans(cutoff: datetime) -> list[str]:
worker = task_result.worker if task_result else None
if worker:
- alive = worker_alive.get(worker, True)
-
- if alive:
- if scan.started_at and scan.started_at >= cutoff:
+ if worker in responsive_workers:
+ if scan.started_at is None or scan.started_at >= stale_cutoff:
continue
- # Alive but stale — revoke before cleanup
- _revoke_task(task_result)
- reason = "Scan exceeded stale threshold — cleaned up by periodic task"
+ reason = "Scan exceeded stale threshold - cleaned up by periodic task"
+ recheck_activity_cutoff = None
+ elif unresponsive_workers is None or worker not in unresponsive_workers:
+ logger.info(
+ f"Preserving scan {scan.id}: worker {worker} liveness is "
+ f"unknown (progress={scan.progress}, updated_at={scan.updated_at})"
+ )
+ continue
else:
- reason = "Worker dead — cleaned up by periodic task"
+ if scan.updated_at >= inactivity_cutoff:
+ logger.info(
+ f"Preserving scan {scan.id}: worker {worker} is unresponsive "
+ f"but activity is recent (progress={scan.progress}, "
+ f"updated_at={scan.updated_at})"
+ )
+ continue
+
+ reason = (
+ "Worker unresponsive and scan inactive for "
+ f"{ATTACK_PATHS_SCAN_INACTIVITY_THRESHOLD_MINUTES} minutes - "
+ "cleaned up by periodic task"
+ )
+ recheck_activity_cutoff = inactivity_cutoff
else:
- # No worker recorded — time-based heuristic only
- if scan.started_at and scan.started_at >= cutoff:
+ # No worker recorded, time-based heuristic only
+ if scan.started_at is None or scan.started_at >= stale_cutoff:
continue
reason = (
- "No worker recorded, scan exceeded stale threshold — "
+ "No worker recorded, scan exceeded stale threshold - "
"cleaned up by periodic task"
)
+ recheck_activity_cutoff = None
- if _cleanup_scan(scan, task_result, reason):
+ if _cleanup_scan(
+ scan,
+ task_result,
+ reason,
+ revoke=worker is not None,
+ inactivity_cutoff=recheck_activity_cutoff,
+ ):
cleaned_up.append(str(scan.id))
return cleaned_up
@@ -111,10 +188,9 @@ def _cleanup_stale_scheduled_scans(cutoff: datetime) -> list[str]:
avoids cleaning up rows whose parent Prowler scan is legitimately still
running.
- For each match: revoke the queued task (best-effort; harmless if already
- consumed), atomically flip to `FAILED`, and mark the `TaskResult`. The
- temp Neo4j database is never created while `SCHEDULED`, so no drop is
- needed.
+ For each match: lock and recheck the row, mark the scan and `TaskResult` as
+ failed, then revoke the queued task after the transaction commits. The temp
+ Neo4j database is never created while `SCHEDULED`, so no drop is needed.
"""
scheduled_scans = list(
AttackPathsScan.all_objects.using(MainRouter.admin_db)
@@ -140,68 +216,54 @@ def _cleanup_stale_scheduled_scans(cutoff: datetime) -> list[str]:
task_result = (
getattr(scan.task, "task_runner_task", None) if scan.task else None
)
- if task_result:
- _revoke_task(task_result, terminate=False)
-
- reason = "Scan never started — cleaned up by periodic task"
+ reason = "Scan never started - cleaned up by periodic task"
if _cleanup_scheduled_scan(scan, task_result, reason):
cleaned_up.append(str(scan.id))
return cleaned_up
-def _is_worker_alive(worker: str) -> bool:
- """Ping a specific Celery worker. Returns `True` if it responds or on error."""
- try:
- response = current_app.control.inspect(destination=[worker], timeout=1.0).ping()
- return response is not None and worker in response
- except Exception:
- logger.exception(f"Failed to ping worker {worker}, treating as alive")
- return True
-
-
-def _revoke_task(task_result, terminate: bool = True) -> None:
- """Revoke a Celery task. Non-fatal on failure.
-
- `terminate=True` SIGTERMs the worker if the task is mid-execution; use
- for EXECUTING cleanup. `terminate=False` only marks the task id revoked
- across workers, so any worker pulling the queued message discards it;
- use for SCHEDULED cleanup where the task hasn't run yet.
- """
- try:
- kwargs = {"terminate": True, "signal": "SIGTERM"} if terminate else {}
- current_app.control.revoke(task_result.task_id, **kwargs)
- logger.info(f"Revoked task {task_result.task_id}")
- except Exception:
- logger.exception(f"Failed to revoke task {task_result.task_id}")
-
-
-def _cleanup_scan(scan, task_result, reason: str) -> bool:
+def _cleanup_scan(
+ scan,
+ task_result,
+ reason: str,
+ *,
+ revoke: bool = False,
+ inactivity_cutoff: datetime | None = None,
+) -> bool:
"""
Clean up a single stale `AttackPathsScan`:
- drop temp DB, mark `FAILED`, update `TaskResult`, recover `graph_data_ready`.
+ lock and recheck, mark `FAILED`, revoke after commit, drop the temp DB, and
+ recover graph readiness.
Returns `True` if the scan was actually cleaned up, `False` if skipped.
"""
scan_id_str = str(scan.id)
- # 1. Drop temp Neo4j database
+ try:
+ fresh_scan = _finalize_failed_scan(
+ scan,
+ StateChoices.EXECUTING,
+ reason,
+ task_result=task_result,
+ revoke=revoke,
+ inactivity_cutoff=inactivity_cutoff,
+ )
+ except DatabaseError:
+ logger.exception(
+ f"Failed to mark stale Attack Paths scan {scan_id_str} as failed"
+ )
+ return False
+
+ if fresh_scan is None:
+ return False
+
tmp_db_name = graph_database.get_database_name(scan.id, temporary=True)
try:
graph_database.drop_database(tmp_db_name)
except Exception:
logger.exception(f"Failed to drop temp database {tmp_db_name}")
- fresh_scan = _finalize_failed_scan(scan, StateChoices.EXECUTING, reason)
- if fresh_scan is None:
- return False
-
- # Mark `TaskResult` as `FAILURE` (not RLS-protected, outside lock)
- if task_result:
- task_result.status = states.FAILURE
- task_result.date_done = datetime.now(tz=timezone.utc)
- task_result.save(update_fields=["status", "date_done"])
-
recover_graph_data_ready(fresh_scan)
logger.info(f"Cleaned up stale scan {scan_id_str}: {reason}")
@@ -212,31 +274,49 @@ def _cleanup_scheduled_scan(scan, task_result, reason: str) -> bool:
"""
Clean up a `SCHEDULED` scan that never reached a worker.
- Skips the temp Neo4j drop — the database is only created once the worker
+ Skips the temp Neo4j drop - the database is only created once the worker
enters `EXECUTING`, so dropping it here just produces noisy log output.
Returns `True` if the scan was actually cleaned up, `False` if skipped.
"""
scan_id_str = str(scan.id)
- fresh_scan = _finalize_failed_scan(scan, StateChoices.SCHEDULED, reason)
- if fresh_scan is None:
+ try:
+ fresh_scan = _finalize_failed_scan(
+ scan,
+ StateChoices.SCHEDULED,
+ reason,
+ task_result=task_result,
+ revoke=task_result is not None,
+ terminate=False,
+ )
+ except DatabaseError:
+ logger.exception(
+ f"Failed to mark scheduled Attack Paths scan {scan_id_str} as failed"
+ )
return False
- if task_result:
- task_result.status = states.FAILURE
- task_result.date_done = datetime.now(tz=timezone.utc)
- task_result.save(update_fields=["status", "date_done"])
+ if fresh_scan is None:
+ return False
logger.info(f"Cleaned up scheduled scan {scan_id_str}: {reason}")
return True
-def _finalize_failed_scan(scan, expected_state: str, reason: str):
+def _finalize_failed_scan(
+ scan,
+ expected_state: str,
+ reason: str,
+ *,
+ task_result=None,
+ revoke: bool = False,
+ terminate: bool = True,
+ inactivity_cutoff: datetime | None = None,
+):
"""
- Atomically lock the row, verify it's still in `expected_state`, and
- mark it `FAILED`. Returns the locked row on success, `None` if the
- row is gone or has already moved on.
+ Atomically lock the row, verify it's still eligible, and mark it `FAILED`.
+ If requested, register revocation after commit. Returns the locked row on
+ success, `None` if the row is gone or has already moved on.
"""
scan_id_str = str(scan.id)
with rls_transaction(str(scan.tenant_id)):
@@ -250,6 +330,23 @@ def _finalize_failed_scan(scan, expected_state: str, reason: str):
logger.info(f"Scan {scan_id_str} is now {fresh_scan.state}, skipping")
return None
- _mark_scan_finished(fresh_scan, StateChoices.FAILED, {"global_error": reason})
+ if inactivity_cutoff is not None and fresh_scan.updated_at >= inactivity_cutoff:
+ logger.info(
+ f"Scan {scan_id_str} received activity during worker checks, skipping"
+ )
+ return None
+
+ mark_scan_finished(fresh_scan, StateChoices.FAILED, {"global_error": reason})
+
+ if task_result:
+ task_result.status = states.FAILURE
+ task_result.date_done = datetime.now(tz=UTC)
+ task_result.save(update_fields=["status", "date_done"])
+
+ if revoke and task_result:
+ on_commit(
+ partial(_revoke_task, task_result, terminate=terminate),
+ using=fresh_scan._state.db,
+ )
return fresh_scan
diff --git a/api/src/backend/tasks/jobs/attack_paths/config.py b/api/src/backend/tasks/jobs/attack_paths/config.py
index 0816626b67..122fc8a9e0 100644
--- a/api/src/backend/tasks/jobs/attack_paths/config.py
+++ b/api/src/backend/tasks/jobs/attack_paths/config.py
@@ -1,17 +1,19 @@
-from dataclasses import dataclass
-from typing import Callable
+from collections.abc import Callable
from uuid import UUID
from config.env import env
-from tasks.jobs.attack_paths import aws
+from tasks.jobs.attack_paths import provider_config as _provider_config
-# Batch size for Neo4j write operations (resource labeling, cleanup)
-BATCH_SIZE = env.int("ATTACK_PATHS_BATCH_SIZE", 1000)
+# Re-export provider config objects so existing imports keep working.
+AWS_CONFIG = _provider_config.AWS_CONFIG
+NormalizedList = _provider_config.NormalizedList
+PROVIDER_CONFIGS = _provider_config.PROVIDER_CONFIGS
+ProviderConfig = _provider_config.ProviderConfig
+
+# Batch size for graph mutation operations (resource labeling and subgraph deletion)
+GRAPH_MUTATION_BATCH_SIZE = env.int("ATTACK_PATHS_GRAPH_MUTATION_BATCH_SIZE", 1000)
# Batch size for Postgres findings fetch (keyset pagination page size)
FINDINGS_BATCH_SIZE = env.int("ATTACK_PATHS_FINDINGS_BATCH_SIZE", 1000)
-# Batch size for temp-to-tenant graph sync (nodes and relationships per cursor page)
-SYNC_BATCH_SIZE = env.int("ATTACK_PATHS_SYNC_BATCH_SIZE", 1000)
-
# Neo4j internal labels (Prowler-specific, not provider-specific)
# - `Internet`: Singleton node representing external internet access for exposed-resource queries
# - `ProwlerFinding`: Label for finding nodes created by Prowler and linked to cloud resources
@@ -21,42 +23,12 @@ PROWLER_FINDING_LABEL = "ProwlerFinding"
PROVIDER_RESOURCE_LABEL = "_ProviderResource"
# Dynamic isolation labels that contain entity UUIDs and are added to every synced node during sync
-# Format: _Tenant_{uuid_no_hyphens}, _Provider_{uuid_no_hyphens}
+# Format: `_Tenant_{uuid_no_hyphens}`, `_Provider_{uuid_no_hyphens}`
TENANT_LABEL_PREFIX = "_Tenant_"
PROVIDER_LABEL_PREFIX = "_Provider_"
DYNAMIC_ISOLATION_PREFIXES = [TENANT_LABEL_PREFIX, PROVIDER_LABEL_PREFIX]
-@dataclass(frozen=True)
-class ProviderConfig:
- """Configuration for a cloud provider's Attack Paths integration."""
-
- name: str
- root_node_label: str # e.g., "AWSAccount"
- uid_field: str # e.g., "arn"
- # Label for resources connected to the account node, enabling indexed finding lookups.
- resource_label: str # e.g., "_AWSResource"
- ingestion_function: Callable
- # Maps a Postgres resource UID (e.g. full ARN) to the short-id form Cartography stores on some node types (e.g. `i-xxx` for EC2Instance).
- short_uid_extractor: Callable[[str], str]
-
-
-# Provider Configurations
-# -----------------------
-
-AWS_CONFIG = ProviderConfig(
- name="aws",
- root_node_label="AWSAccount",
- uid_field="arn",
- resource_label="_AWSResource",
- ingestion_function=aws.start_aws_ingestion,
- short_uid_extractor=aws.extract_short_uid,
-)
-
-PROVIDER_CONFIGS: dict[str, ProviderConfig] = {
- "aws": AWS_CONFIG,
-}
-
# Labels added by Prowler that should be filtered from API responses
# Derived from provider configs + common internal labels
INTERNAL_LABELS: list[str] = [
@@ -87,7 +59,6 @@ INTERNAL_PROPERTIES: list[str] = [
# Provider Config Accessors
-# -------------------------
def is_provider_available(provider_type: str) -> bool:
@@ -135,7 +106,6 @@ def get_short_uid_extractor(provider_type: str) -> Callable[[str], str]:
# Dynamic Isolation Label Helpers
-# --------------------------------
def _normalize_uuid(value: str | UUID) -> str:
diff --git a/api/src/backend/tasks/jobs/attack_paths/db_utils.py b/api/src/backend/tasks/jobs/attack_paths/db_utils.py
index a6e7da7f87..327d4463e2 100644
--- a/api/src/backend/tasks/jobs/attack_paths/db_utils.py
+++ b/api/src/backend/tasks/jobs/attack_paths/db_utils.py
@@ -1,15 +1,16 @@
-from datetime import datetime, timezone
+from datetime import UTC, datetime
from typing import Any
-from cartography.config import Config as CartographyConfig
-from celery.utils.log import get_task_logger
-from tasks.jobs.attack_paths.config import is_provider_available
-
from api.attack_paths import database as graph_database
from api.db_utils import rls_transaction
from api.models import AttackPathsScan as ProwlerAPIAttackPathsScan
from api.models import Provider as ProwlerAPIProvider
from api.models import StateChoices
+from cartography.config import Config as CartographyConfig
+from celery.utils.log import get_task_logger
+from django.conf import settings
+from django.db.models import Case, IntegerField, Value, When
+from tasks.jobs.attack_paths.config import is_provider_available
logger = get_task_logger(__name__)
@@ -30,21 +31,43 @@ def create_attack_paths_scan(
return None
with rls_transaction(tenant_id):
- # Inherit graph_data_ready from the previous scan for this provider,
- # so queries remain available while the new scan runs.
- previous_data_ready = ProwlerAPIAttackPathsScan.objects.filter(
- tenant_id=tenant_id,
- provider_id=provider_id,
- graph_data_ready=True,
- ).exists()
+ # Inherit metadata from the previous ready scan for this provider so
+ # queries remain available while the new scan runs. The new row only
+ # flips to the target sink after its own graph sync succeeds.
+ active_sink_backend = settings.ATTACK_PATHS_SINK_DATABASE
+ previous_ready = (
+ ProwlerAPIAttackPathsScan.objects.filter(
+ tenant_id=tenant_id,
+ provider_id=provider_id,
+ graph_data_ready=True,
+ )
+ .annotate(
+ active_sink_rank=Case(
+ When(sink_backend=active_sink_backend, then=Value(0)),
+ default=Value(1),
+ output_field=IntegerField(),
+ )
+ )
+ .order_by("active_sink_rank", "-inserted_at")
+ .first()
+ )
+ previous_data_ready = previous_ready is not None
+ inherited_is_migrated = previous_ready.is_migrated if previous_ready else False
+ inherited_sink_backend = (
+ previous_ready.sink_backend
+ if previous_ready
+ else ProwlerAPIAttackPathsScan.SinkBackendChoices.NEO4J
+ )
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),
+ started_at=datetime.now(tz=UTC),
graph_data_ready=previous_data_ready,
+ is_migrated=inherited_is_migrated,
+ sink_backend=inherited_sink_backend,
)
attack_paths_scan.save()
@@ -103,25 +126,28 @@ def starting_attack_paths_scan(
if locked.state != StateChoices.SCHEDULED:
return False
+ now = datetime.now(tz=UTC)
locked.state = StateChoices.EXECUTING
- locked.started_at = datetime.now(tz=timezone.utc)
+ locked.started_at = now
+ locked.updated_at = now
locked.update_tag = cartography_config.update_tag
- locked.save(update_fields=["state", "started_at", "update_tag"])
+ locked.save(update_fields=["state", "started_at", "updated_at", "update_tag"])
# Keep the in-memory object the caller is holding in sync.
attack_paths_scan.state = locked.state
attack_paths_scan.started_at = locked.started_at
+ attack_paths_scan.updated_at = locked.updated_at
attack_paths_scan.update_tag = locked.update_tag
return True
-def _mark_scan_finished(
+def mark_scan_finished(
attack_paths_scan: ProwlerAPIAttackPathsScan,
state: StateChoices,
ingestion_exceptions: dict[str, Any],
) -> None:
"""Set terminal fields on a scan. Caller must be inside a transaction."""
- now = datetime.now(tz=timezone.utc)
+ now = datetime.now(tz=UTC)
duration = (
int((now - attack_paths_scan.started_at).total_seconds())
if attack_paths_scan.started_at
@@ -149,7 +175,7 @@ def finish_attack_paths_scan(
ingestion_exceptions: dict[str, Any],
) -> None:
with rls_transaction(attack_paths_scan.tenant_id):
- _mark_scan_finished(attack_paths_scan, state, ingestion_exceptions)
+ mark_scan_finished(attack_paths_scan, state, ingestion_exceptions)
def update_attack_paths_scan_progress(
@@ -158,7 +184,8 @@ def update_attack_paths_scan_progress(
) -> None:
with rls_transaction(attack_paths_scan.tenant_id):
attack_paths_scan.progress = progress
- attack_paths_scan.save(update_fields=["progress"])
+ attack_paths_scan.updated_at = datetime.now(tz=UTC)
+ attack_paths_scan.save(update_fields=["progress", "updated_at"])
def set_graph_data_ready(
@@ -170,19 +197,45 @@ def set_graph_data_ready(
attack_paths_scan.save(update_fields=["graph_data_ready"])
+def set_scan_migrated(
+ attack_paths_scan: ProwlerAPIAttackPathsScan,
+ migrated: bool,
+ sink_backend: str | None = None,
+) -> None:
+ """Mark the scan as written with the current (migrated) schema.
+
+ Called after a successful sync so the read catalog and sink backend only
+ switch once the new graph is actually live.
+
+ # TODO: drop after Neptune cutover
+ """
+ with rls_transaction(attack_paths_scan.tenant_id):
+ attack_paths_scan.is_migrated = migrated
+ update_fields = ["is_migrated"]
+ if sink_backend is not None:
+ attack_paths_scan.sink_backend = sink_backend
+ update_fields.append("sink_backend")
+ attack_paths_scan.save(update_fields=update_fields)
+
+
def set_provider_graph_data_ready(
attack_paths_scan: ProwlerAPIAttackPathsScan,
ready: bool,
+ sink_backend: str | None = None,
) -> None:
"""
- Set `graph_data_ready` for ALL scans of the same provider.
+ Set `graph_data_ready` for scans of the same provider in one sink.
- Used before drop/sync so that older scan IDs cannot bypass the query gate while the graph is being replaced.
+ Used before drop/sync so that older scan IDs in the target sink cannot
+ bypass the query gate while that sink's graph is being replaced. Scans
+ preserved in another sink stay queryable for rollback.
"""
+ target_sink_backend = sink_backend or attack_paths_scan.sink_backend
with rls_transaction(attack_paths_scan.tenant_id):
ProwlerAPIAttackPathsScan.objects.filter(
tenant_id=attack_paths_scan.tenant_id,
provider_id=attack_paths_scan.provider_id,
+ sink_backend=target_sink_backend,
).update(graph_data_ready=ready)
attack_paths_scan.refresh_from_db(fields=["graph_data_ready"])
@@ -203,10 +256,15 @@ def recover_graph_data_ready(
next successful scan) is a worse outcome for the user.
"""
try:
+ from api.attack_paths import sink as sink_module
+
tenant_db = graph_database.get_database_name(attack_paths_scan.tenant_id)
- if graph_database.has_provider_data(
- tenant_db, str(attack_paths_scan.provider_id)
- ):
+ # TODO: drop after Neptune cutover
+ # Check the backend that actually holds this scan's data, not the
+ # currently configured sink, a stale `EXECUTING` scan from before a
+ # backend switch must still be recoverable
+ backend = sink_module.get_backend_for_scan(attack_paths_scan)
+ if backend.has_provider_data(tenant_db, str(attack_paths_scan.provider_id)):
set_provider_graph_data_ready(attack_paths_scan, True)
logger.info(
f"Recovered `graph_data_ready` for provider {attack_paths_scan.provider_id}"
@@ -248,6 +306,6 @@ def fail_attack_paths_scan(
return
if fresh.state in (StateChoices.COMPLETED, StateChoices.FAILED):
return
- _mark_scan_finished(fresh, StateChoices.FAILED, {"global_error": error})
+ mark_scan_finished(fresh, StateChoices.FAILED, {"global_error": error})
recover_graph_data_ready(fresh)
diff --git a/api/src/backend/tasks/jobs/attack_paths/findings.py b/api/src/backend/tasks/jobs/attack_paths/findings.py
index 3581f0ca0f..c47c9f1149 100644
--- a/api/src/backend/tasks/jobs/attack_paths/findings.py
+++ b/api/src/backend/tasks/jobs/attack_paths/findings.py
@@ -8,16 +8,21 @@ This module handles:
"""
from collections import defaultdict
-from typing import Any, Callable, Generator
+from collections.abc import Callable, Generator
+from typing import Any
from uuid import UUID
import neo4j
-
+from api.db_router import READ_REPLICA_ALIAS
+from api.db_utils import rls_transaction
+from api.models import Finding as FindingModel
+from api.models import Provider, ResourceFindingMapping
from cartography.config import Config as CartographyConfig
from celery.utils.log import get_task_logger
+from prowler.config import config as ProwlerConfig
from tasks.jobs.attack_paths.config import (
- BATCH_SIZE,
FINDINGS_BATCH_SIZE,
+ GRAPH_MUTATION_BATCH_SIZE,
get_node_uid_field,
get_provider_resource_label,
get_root_node_label,
@@ -29,12 +34,6 @@ from tasks.jobs.attack_paths.queries import (
render_cypher_template,
)
-from api.db_router import READ_REPLICA_ALIAS
-from api.db_utils import rls_transaction
-from api.models import Finding as FindingModel
-from api.models import Provider, ResourceFindingMapping
-from prowler.config import config as ProwlerConfig
-
logger = get_task_logger(__name__)
@@ -83,7 +82,6 @@ def _to_neo4j_dict(
# Public API
-# ----------
def analysis(
@@ -137,7 +135,7 @@ def add_resource_label(
while labeled_count > 0:
result = neo4j_session.run(
query,
- {"provider_uid": provider_uid, "batch_size": BATCH_SIZE},
+ {"provider_uid": provider_uid, "batch_size": GRAPH_MUTATION_BATCH_SIZE},
)
labeled_count = result.single().get("labeled_count", 0)
total_labeled += labeled_count
@@ -197,7 +195,6 @@ def load_findings(
# Findings Streaming (Generator-based)
-# -------------------------------------
def stream_findings_with_resources(
@@ -276,7 +273,6 @@ def _fetch_findings_batch(
# Batch Enrichment
-# -----------------
def _enrich_batch_with_resources(
diff --git a/api/src/backend/tasks/jobs/attack_paths/indexes.py b/api/src/backend/tasks/jobs/attack_paths/indexes.py
index 0de94a162e..50e8a12bcd 100644
--- a/api/src/backend/tasks/jobs/attack_paths/indexes.py
+++ b/api/src/backend/tasks/jobs/attack_paths/indexes.py
@@ -1,13 +1,12 @@
import neo4j
-
from cartography.client.core.tx import run_write_query
+from cartography.intel import create_indexes as cartography_create_indexes
from celery.utils.log import get_task_logger
-
from tasks.jobs.attack_paths.config import (
INTERNET_NODE_LABEL,
- PROWLER_FINDING_LABEL,
PROVIDER_ELEMENT_ID_PROPERTY,
PROVIDER_RESOURCE_LABEL,
+ PROWLER_FINDING_LABEL,
)
logger = get_task_logger(__name__)
@@ -32,14 +31,34 @@ SYNC_INDEX_STATEMENTS = [
def create_findings_indexes(neo4j_session: neo4j.Session) -> None:
- """Create indexes for Prowler findings and resource lookups."""
+ """Create indexes for Prowler findings and resource lookups.
+
+ Runs `CREATE INDEX`, so the caller must only invoke this against a Neo4j
+ session (the temp ingest DB or a Neo4j sink). Neptune auto-manages indexes
+ and rejects `CREATE INDEX`, so callers skip it for the Neptune sink.
+ """
logger.info("Creating indexes for Prowler Findings node types")
for statement in FINDINGS_INDEX_STATEMENTS:
run_write_query(neo4j_session, statement)
+def create_cartography_indexes(neo4j_session: neo4j.Session, config) -> None:
+ """Create Cartography's standard indexes for the session's database.
+
+ Runs `CREATE INDEX`, so the caller must only invoke this against a Neo4j
+ session (the temp ingest DB or a Neo4j sink). Neptune auto-manages indexes
+ and rejects `CREATE INDEX`, so callers skip it for the Neptune sink.
+ """
+ cartography_create_indexes.run(neo4j_session, config)
+
+
def create_sync_indexes(neo4j_session: neo4j.Session) -> None:
- """Create indexes for provider resource sync operations."""
+ """Create indexes for provider resource sync operations.
+
+ Runs `CREATE INDEX`, so the caller must only invoke this against a Neo4j
+ session (the temp ingest DB or a Neo4j sink). Neptune auto-manages indexes
+ and rejects `CREATE INDEX`, so callers skip it for the Neptune sink.
+ """
logger.info("Ensuring ProviderResource indexes exist")
for statement in SYNC_INDEX_STATEMENTS:
neo4j_session.run(statement)
diff --git a/api/src/backend/tasks/jobs/attack_paths/internet.py b/api/src/backend/tasks/jobs/attack_paths/internet.py
index 83517bc903..4c7a61bd20 100644
--- a/api/src/backend/tasks/jobs/attack_paths/internet.py
+++ b/api/src/backend/tasks/jobs/attack_paths/internet.py
@@ -7,11 +7,9 @@ in the temporary scan database before sync.
"""
import neo4j
-
+from api.models import Provider
from cartography.config import Config as CartographyConfig
from celery.utils.log import get_task_logger
-
-from api.models import Provider
from prowler.config import config as ProwlerConfig
from tasks.jobs.attack_paths.config import get_root_node_label
from tasks.jobs.attack_paths.queries import (
diff --git a/api/src/backend/tasks/jobs/attack_paths/provider_config.py b/api/src/backend/tasks/jobs/attack_paths/provider_config.py
new file mode 100644
index 0000000000..7d834e6aff
--- /dev/null
+++ b/api/src/backend/tasks/jobs/attack_paths/provider_config.py
@@ -0,0 +1,431 @@
+"""
+Provider-level Attack Paths configuration.
+
+Each `ProviderConfig` carries the cloud provider's ingestion entry point and
+the catalog of list-typed node properties (`normalized_lists`). The sync
+layer reads this catalog and materialises each list element as a child node
+connected to the parent by a typed edge, so queries traverse the graph
+instead of working on serialised list values. Both Neo4j and Neptune sinks
+write the same shape and queries are portable across them.
+"""
+
+from collections.abc import Callable
+from dataclasses import dataclass, field
+
+from tasks.jobs.attack_paths import aws
+
+
+@dataclass(frozen=True)
+class NormalizedList:
+ """Catalog entry for a list-typed node property.
+
+ Describes how the sync layer materialises a parent node's list-typed
+ property as a set of child item nodes connected by a typed edge.
+
+ Conventions (mechanical, do not invent):
+ - `child_label`: `Item`
+ e.g. AWSPolicyStatement.resource -> AWSPolicyStatementResourceItem
+ - `rel_type`: `HAS_`
+ e.g. resource -> HAS_RESOURCE
+ - child node property:
+ * `field_map = []` (scalar list, ~95% case) -> child stores `value: str`
+ * `field_map = [(src_key, child_field), ...]` (list of dicts, rare)
+ -> child stores those fields
+ """
+
+ source_label: str
+ source_property: str
+ child_label: str
+ rel_type: str
+ field_map: list[tuple[str, str]] = field(default_factory=list)
+
+ def __post_init__(self) -> None:
+ if self.field_map:
+ child_fields = [dst for _, dst in self.field_map]
+ if "value" in child_fields:
+ raise ValueError(
+ f"NormalizedList {self.source_label}.{self.source_property}: "
+ "`value` is reserved for scalar mode; do not map a source key to it"
+ )
+ src_keys = [src for src, _ in self.field_map]
+ if len(set(src_keys)) != len(src_keys):
+ raise ValueError(
+ f"NormalizedList {self.source_label}.{self.source_property}: "
+ "duplicate source key in field_map"
+ )
+ if len(set(child_fields)) != len(child_fields):
+ raise ValueError(
+ f"NormalizedList {self.source_label}.{self.source_property}: "
+ "duplicate child field in field_map"
+ )
+
+
+@dataclass(frozen=True)
+class ProviderConfig:
+ """Configuration for a cloud provider's Attack Paths integration."""
+
+ name: str
+ root_node_label: str # e.g., "AWSAccount"
+ uid_field: str # e.g., "arn"
+ # Label for resources connected to the account node, enabling indexed finding lookups
+ resource_label: str # e.g., "_AWSResource"
+ ingestion_function: Callable
+ # Maps a Postgres resource UID (e.g. full ARN) to the short-id form Cartography stores on some node types (e.g. `i-xxx` for EC2Instance)
+ short_uid_extractor: Callable[[str], str]
+ # List-typed properties to materialise as child nodes + edges at sync time.
+ # Mandatory (may be []). Without an entry here, a list-typed property falls
+ # back to comma-string flatten and emits a one-time warning.
+ normalized_lists: list[NormalizedList]
+
+
+# AWS list-typed property catalog.
+# One entry per Cartography node property whose runtime value is a list. The
+# sync layer materialises each element as a `` node and links it
+# to the parent with a `` edge; see the `NormalizedList` docstring
+# above for the naming conventions.
+AWS_NORMALIZED_LISTS: list[NormalizedList] = [
+ # AWSPolicyStatement - the hot path driving the 53-query perf fix.
+ NormalizedList(
+ "AWSPolicyStatement", "action", "AWSPolicyStatementActionItem", "HAS_ACTION"
+ ),
+ NormalizedList(
+ "AWSPolicyStatement",
+ "notaction",
+ "AWSPolicyStatementNotactionItem",
+ "HAS_NOTACTION",
+ ),
+ NormalizedList(
+ "AWSPolicyStatement",
+ "resource",
+ "AWSPolicyStatementResourceItem",
+ "HAS_RESOURCE",
+ ),
+ NormalizedList(
+ "AWSPolicyStatement",
+ "notresource",
+ "AWSPolicyStatementNotresourceItem",
+ "HAS_NOTRESOURCE",
+ ),
+ # S3PolicyStatement - same shape as IAM policies; AWS allows list or string.
+ NormalizedList(
+ "S3PolicyStatement", "action", "S3PolicyStatementActionItem", "HAS_ACTION"
+ ),
+ NormalizedList(
+ "S3PolicyStatement", "resource", "S3PolicyStatementResourceItem", "HAS_RESOURCE"
+ ),
+ # IAM / Cognito / KMS / Secrets
+ NormalizedList(
+ "CognitoIdentityPool", "roles", "CognitoIdentityPoolRolesItem", "HAS_ROLES"
+ ),
+ NormalizedList(
+ "KMSKey",
+ "encryption_algorithms",
+ "KMSKeyEncryptionAlgorithmsItem",
+ "HAS_ENCRYPTION_ALGORITHMS",
+ ),
+ NormalizedList(
+ "KMSKey",
+ "signing_algorithms",
+ "KMSKeySigningAlgorithmsItem",
+ "HAS_SIGNING_ALGORITHMS",
+ ),
+ NormalizedList(
+ "KMSKey",
+ "anonymous_actions",
+ "KMSKeyAnonymousActionsItem",
+ "HAS_ANONYMOUS_ACTIONS",
+ ),
+ NormalizedList(
+ "KMSGrant", "operations", "KMSGrantOperationsItem", "HAS_OPERATIONS"
+ ),
+ NormalizedList(
+ "SecretsManagerSecretVersion",
+ "version_stages",
+ "SecretsManagerSecretVersionVersionStagesItem",
+ "HAS_VERSION_STAGES",
+ ),
+ NormalizedList(
+ "SecretsManagerSecretVersion",
+ "kms_key_ids",
+ "SecretsManagerSecretVersionKmsKeyIdsItem",
+ "HAS_KMS_KEY_IDS",
+ ),
+ NormalizedList(
+ "SecretsManagerSecretVersion",
+ "tags",
+ "SecretsManagerSecretVersionTagsItem",
+ "HAS_TAGS",
+ field_map=[("Key", "key"), ("Value", "value_")],
+ # `value` is reserved for scalar mode; map `Value` to `value_` to keep dict shape.
+ ),
+ # Lambda / Compute
+ NormalizedList(
+ "AWSLambda", "architectures", "AWSLambdaArchitecturesItem", "HAS_ARCHITECTURES"
+ ),
+ NormalizedList(
+ "AWSLambda",
+ "anonymous_actions",
+ "AWSLambdaAnonymousActionsItem",
+ "HAS_ANONYMOUS_ACTIONS",
+ ),
+ NormalizedList(
+ "CodeBuildProject",
+ "environment_variables",
+ "CodeBuildProjectEnvironmentVariablesItem",
+ "HAS_ENVIRONMENT_VARIABLES",
+ ),
+ # ECS family
+ NormalizedList(
+ "ECSCluster",
+ "capacity_providers",
+ "ECSClusterCapacityProvidersItem",
+ "HAS_CAPACITY_PROVIDERS",
+ ),
+ NormalizedList(
+ "ECSTaskDefinition",
+ "compatibilities",
+ "ECSTaskDefinitionCompatibilitiesItem",
+ "HAS_COMPATIBILITIES",
+ ),
+ NormalizedList(
+ "ECSTaskDefinition",
+ "requires_compatibilities",
+ "ECSTaskDefinitionRequiresCompatibilitiesItem",
+ "HAS_REQUIRES_COMPATIBILITIES",
+ ),
+ NormalizedList(
+ "ECSContainerDefinition",
+ "links",
+ "ECSContainerDefinitionLinksItem",
+ "HAS_LINKS",
+ ),
+ NormalizedList(
+ "ECSContainerDefinition",
+ "entry_point",
+ "ECSContainerDefinitionEntryPointItem",
+ "HAS_ENTRY_POINT",
+ ),
+ NormalizedList(
+ "ECSContainerDefinition",
+ "command",
+ "ECSContainerDefinitionCommandItem",
+ "HAS_COMMAND",
+ ),
+ NormalizedList(
+ "ECSContainerDefinition",
+ "dns_servers",
+ "ECSContainerDefinitionDnsServersItem",
+ "HAS_DNS_SERVERS",
+ ),
+ NormalizedList(
+ "ECSContainerDefinition",
+ "dns_search_domains",
+ "ECSContainerDefinitionDnsSearchDomainsItem",
+ "HAS_DNS_SEARCH_DOMAINS",
+ ),
+ NormalizedList(
+ "ECSContainerDefinition",
+ "docker_security_options",
+ "ECSContainerDefinitionDockerSecurityOptionsItem",
+ "HAS_DOCKER_SECURITY_OPTIONS",
+ ),
+ NormalizedList("ECSContainer", "gpu_ids", "ECSContainerGpuIdsItem", "HAS_GPU_IDS"),
+ # ECR
+ NormalizedList(
+ "ECRImage", "layer_diff_ids", "ECRImageLayerDiffIdsItem", "HAS_LAYER_DIFF_IDS"
+ ),
+ NormalizedList(
+ "ECRImage",
+ "child_image_digests",
+ "ECRImageChildImageDigestsItem",
+ "HAS_CHILD_IMAGE_DIGESTS",
+ ),
+ # EC2 / Networking
+ NormalizedList(
+ "EC2Instance",
+ "exposed_internet_type",
+ "EC2InstanceExposedInternetTypeItem",
+ "HAS_EXPOSED_INTERNET_TYPE",
+ ),
+ NormalizedList(
+ "AutoScalingGroup",
+ "exposed_internet_type",
+ "AutoScalingGroupExposedInternetTypeItem",
+ "HAS_EXPOSED_INTERNET_TYPE",
+ ),
+ NormalizedList(
+ "LaunchConfiguration",
+ "security_groups",
+ "LaunchConfigurationSecurityGroupsItem",
+ "HAS_SECURITY_GROUPS",
+ ),
+ NormalizedList(
+ "LaunchTemplateVersion",
+ "security_group_ids",
+ "LaunchTemplateVersionSecurityGroupIdsItem",
+ "HAS_SECURITY_GROUP_IDS",
+ ),
+ NormalizedList(
+ "LaunchTemplateVersion",
+ "security_groups",
+ "LaunchTemplateVersionSecurityGroupsItem",
+ "HAS_SECURITY_GROUPS",
+ ),
+ NormalizedList(
+ "AWSVpcEndpoint",
+ "route_table_ids",
+ "AWSVpcEndpointRouteTableIdsItem",
+ "HAS_ROUTE_TABLE_IDS",
+ ),
+ NormalizedList(
+ "AWSVpcEndpoint",
+ "network_interface_ids",
+ "AWSVpcEndpointNetworkInterfaceIdsItem",
+ "HAS_NETWORK_INTERFACE_IDS",
+ ),
+ NormalizedList(
+ "AWSVpcEndpoint",
+ "subnet_ids",
+ "AWSVpcEndpointSubnetIdsItem",
+ "HAS_SUBNET_IDS",
+ ),
+ NormalizedList(
+ "ELBListener", "policy_names", "ELBListenerPolicyNamesItem", "HAS_POLICY_NAMES"
+ ),
+ # CloudFront / Route53 / CloudWatch / CloudTrail
+ NormalizedList(
+ "CloudFrontDistribution",
+ "aliases",
+ "CloudFrontDistributionAliasesItem",
+ "HAS_ALIASES",
+ ),
+ NormalizedList(
+ "CloudFrontDistribution",
+ "geo_restriction_locations",
+ "CloudFrontDistributionGeoRestrictionLocationsItem",
+ "HAS_GEO_RESTRICTION_LOCATIONS",
+ ),
+ NormalizedList(
+ "CloudWatchLogGroup",
+ "inherited_properties",
+ "CloudWatchLogGroupInheritedPropertiesItem",
+ "HAS_INHERITED_PROPERTIES",
+ ),
+ # RDS / Storage
+ NormalizedList(
+ "RDSCluster",
+ "availability_zones",
+ "RDSClusterAvailabilityZonesItem",
+ "HAS_AVAILABILITY_ZONES",
+ ),
+ NormalizedList(
+ "RDSEventSubscription",
+ "event_categories",
+ "RDSEventSubscriptionEventCategoriesItem",
+ "HAS_EVENT_CATEGORIES",
+ ),
+ NormalizedList(
+ "RDSEventSubscription",
+ "source_ids",
+ "RDSEventSubscriptionSourceIdsItem",
+ "HAS_SOURCE_IDS",
+ ),
+ NormalizedList(
+ "S3Bucket",
+ "anonymous_actions",
+ "S3BucketAnonymousActionsItem",
+ "HAS_ANONYMOUS_ACTIONS",
+ ),
+ # Inspector / Config / SSM / ACM / APIGateway / Glue / SageMaker / Bedrock
+ NormalizedList(
+ "AWSInspectorFinding",
+ "referenceurls",
+ "AWSInspectorFindingReferenceurlsItem",
+ "HAS_REFERENCEURLS",
+ ),
+ NormalizedList(
+ "AWSInspectorFinding",
+ "relatedvulnerabilities",
+ "AWSInspectorFindingRelatedvulnerabilitiesItem",
+ "HAS_RELATEDVULNERABILITIES",
+ ),
+ NormalizedList(
+ "AWSInspectorFinding",
+ "vulnerablepackageids",
+ "AWSInspectorFindingVulnerablepackageidsItem",
+ "HAS_VULNERABLEPACKAGEIDS",
+ ),
+ NormalizedList(
+ "AWSConfigurationRecorder",
+ "recording_group_resource_types",
+ "AWSConfigurationRecorderRecordingGroupResourceTypesItem",
+ "HAS_RECORDING_GROUP_RESOURCE_TYPES",
+ ),
+ NormalizedList(
+ "AWSConfigRule",
+ "scope_compliance_resource_types",
+ "AWSConfigRuleScopeComplianceResourceTypesItem",
+ "HAS_SCOPE_COMPLIANCE_RESOURCE_TYPES",
+ ),
+ NormalizedList(
+ "AWSConfigRule",
+ "source_details",
+ "AWSConfigRuleSourceDetailsItem",
+ "HAS_SOURCE_DETAILS",
+ ),
+ NormalizedList(
+ "SSMInstancePatch", "cve_ids", "SSMInstancePatchCveIdsItem", "HAS_CVE_IDS"
+ ),
+ NormalizedList(
+ "ACMCertificate", "in_use_by", "ACMCertificateInUseByItem", "HAS_IN_USE_BY"
+ ),
+ NormalizedList(
+ "APIGatewayRestAPI",
+ "anonymous_actions",
+ "APIGatewayRestAPIAnonymousActionsItem",
+ "HAS_ANONYMOUS_ACTIONS",
+ ),
+ NormalizedList(
+ "GlueJob", "connections", "GlueJobConnectionsItem", "HAS_CONNECTIONS"
+ ),
+ NormalizedList(
+ "AWSBedrockFoundationModel",
+ "input_modalities",
+ "AWSBedrockFoundationModelInputModalitiesItem",
+ "HAS_INPUT_MODALITIES",
+ ),
+ NormalizedList(
+ "AWSBedrockFoundationModel",
+ "output_modalities",
+ "AWSBedrockFoundationModelOutputModalitiesItem",
+ "HAS_OUTPUT_MODALITIES",
+ ),
+ NormalizedList(
+ "AWSBedrockFoundationModel",
+ "customizations_supported",
+ "AWSBedrockFoundationModelCustomizationsSupportedItem",
+ "HAS_CUSTOMIZATIONS_SUPPORTED",
+ ),
+ NormalizedList(
+ "AWSBedrockFoundationModel",
+ "inference_types_supported",
+ "AWSBedrockFoundationModelInferenceTypesSupportedItem",
+ "HAS_INFERENCE_TYPES_SUPPORTED",
+ ),
+]
+
+
+AWS_CONFIG = ProviderConfig(
+ name="aws",
+ root_node_label="AWSAccount",
+ uid_field="arn",
+ resource_label="_AWSResource",
+ ingestion_function=aws.start_aws_ingestion,
+ short_uid_extractor=aws.extract_short_uid,
+ normalized_lists=AWS_NORMALIZED_LISTS,
+)
+
+
+PROVIDER_CONFIGS: dict[str, ProviderConfig] = {
+ "aws": AWS_CONFIG,
+}
diff --git a/api/src/backend/tasks/jobs/attack_paths/queries.py b/api/src/backend/tasks/jobs/attack_paths/queries.py
index eb1d82a96e..1166de17ed 100644
--- a/api/src/backend/tasks/jobs/attack_paths/queries.py
+++ b/api/src/backend/tasks/jobs/attack_paths/queries.py
@@ -2,8 +2,6 @@
from tasks.jobs.attack_paths.config import (
INTERNET_NODE_LABEL,
PROWLER_FINDING_LABEL,
- PROVIDER_ELEMENT_ID_PROPERTY,
- PROVIDER_RESOURCE_LABEL,
)
@@ -21,7 +19,6 @@ def render_cypher_template(template: str, replacements: dict[str, str]) -> str:
# Findings queries (used by findings.py)
-# ---------------------------------------
ADD_RESOURCE_LABEL_TEMPLATE = """
MATCH (account:__ROOT_LABEL__ {id: $provider_uid})-->(r)
@@ -88,7 +85,6 @@ INSERT_FINDING_TEMPLATE = f"""
"""
# Internet queries (used by internet.py)
-# ---------------------------------------
CREATE_INTERNET_NODE = f"""
MERGE (internet:{INTERNET_NODE_LABEL} {{id: 'Internet'}})
@@ -118,8 +114,8 @@ CREATE_CAN_ACCESS_RELATIONSHIPS_TEMPLATE = f"""
RETURN COUNT(r) AS relationships_merged
"""
-# Sync queries (used by sync.py)
-# -------------------------------
+# Sync queries (used by sync.py to fetch from the cartography temp Neo4j DB)
+# The write side of sync lives in each sink (`api/attack_paths/sink/`).
NODE_FETCH_QUERY = """
MATCH (n)
@@ -143,17 +139,3 @@ RELATIONSHIPS_FETCH_QUERY = """
ORDER BY internal_id
LIMIT $batch_size
"""
-
-NODE_SYNC_TEMPLATE = f"""
- UNWIND $rows AS row
- MERGE (n:__NODE_LABELS__ {{{PROVIDER_ELEMENT_ID_PROPERTY}: row.provider_element_id}})
- SET n += row.props
-"""
-
-RELATIONSHIP_SYNC_TEMPLATE = f"""
- UNWIND $rows AS row
- MATCH (s:{PROVIDER_RESOURCE_LABEL} {{{PROVIDER_ELEMENT_ID_PROPERTY}: row.start_element_id}})
- MATCH (t:{PROVIDER_RESOURCE_LABEL} {{{PROVIDER_ELEMENT_ID_PROPERTY}: row.end_element_id}})
- MERGE (s)-[r:__REL_TYPE__ {{{PROVIDER_ELEMENT_ID_PROPERTY}: row.provider_element_id}}]->(t)
- SET r += row.props
-"""
diff --git a/api/src/backend/tasks/jobs/attack_paths/scan.py b/api/src/backend/tasks/jobs/attack_paths/scan.py
index 452dae00d0..e161c9eb8d 100644
--- a/api/src/backend/tasks/jobs/attack_paths/scan.py
+++ b/api/src/backend/tasks/jobs/attack_paths/scan.py
@@ -39,8 +39,8 @@ Pipeline steps:
7. Sync the temp database into the tenant database:
- Drop the old provider subgraph (matched by dynamic _Provider_{uuid} label).
- graph_data_ready is set to False for all scans of this provider while
- the swap happens so the API doesn't serve partial data.
+ graph_data_ready is set to False for scans of this provider in the
+ target sink while the swap happens so the API doesn't serve partial data.
- Copy nodes and relationships in batches. Every synced node gets a
_ProviderResource label and dynamic _Tenant_{uuid} / _Provider_{uuid}
isolation labels, plus a _provider_element_id property for MERGE keys.
@@ -55,22 +55,27 @@ exception propagates to Celery.
import logging
import time
-
from typing import Any
-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 tasks.jobs.attack_paths import db_utils, findings, indexes, internet, sync, utils
-from tasks.jobs.attack_paths.config import get_cartography_ingestion_function
-
from api.attack_paths import database as graph_database
from api.db_utils import rls_transaction
from api.models import Provider as ProwlerAPIProvider
from api.models import StateChoices
from api.utils import initialize_prowler_provider
+from cartography.config import Config as CartographyConfig
+from cartography.intel import analysis as cartography_analysis
+from cartography.intel import ontology as cartography_ontology
+from celery.utils.log import get_task_logger
+from django.conf import settings
+from tasks.jobs.attack_paths import (
+ db_utils,
+ findings,
+ indexes,
+ internet,
+ sync,
+ utils,
+)
+from tasks.jobs.attack_paths.config import get_cartography_ingestion_function
# Without this Celery goes crazy with Cartography logging
logging.getLogger("cartography").setLevel(logging.ERROR)
@@ -98,7 +103,7 @@ def run(tenant_id: str, scan_id: str, task_id: str) -> dict[str, Any]:
attack_paths_scan = db_utils.retrieve_attack_paths_scan(tenant_id, scan_id)
# Idempotency guard: cleanup may have flipped this row to a terminal state
- # while the message was still in flight. Bail out before touching state.
+ # while the message was still in flight. Bail out before touching state
if attack_paths_scan and attack_paths_scan.state in (
StateChoices.FAILED,
StateChoices.COMPLETED,
@@ -127,7 +132,7 @@ def run(tenant_id: str, scan_id: str, task_id: str) -> dict[str, Any]:
else:
if not attack_paths_scan:
- # Safety net for in-flight messages or direct task invocations; dispatcher normally pre-creates the row.
+ # Safety net for in-flight messages or direct task invocations; dispatcher normally pre-creates the row
logger.warning(
f"No Attack Paths Scan found for scan {scan_id} and tenant {tenant_id}, let's create it then"
)
@@ -145,10 +150,18 @@ def run(tenant_id: str, scan_id: str, task_id: str) -> dict[str, Any]:
tenant_database_name = graph_database.get_database_name(
prowler_api_provider.tenant_id
)
+ target_sink_backend = settings.ATTACK_PATHS_SINK_DATABASE
+ target_description = (
+ f"tenant Neo4j database {tenant_database_name}"
+ if target_sink_backend == "neo4j"
+ else f"{target_sink_backend} sink"
+ )
# While creating the Cartography configuration, attributes `neo4j_user` and `neo4j_password` are not really needed in this config object
tmp_cartography_config = CartographyConfig(
- neo4j_uri=graph_database.get_uri(),
+ # The temp ingest database is always Neo4j, so use the ingest URI here
+ # rather than the sink URI (which points at Neptune when configured).
+ neo4j_uri=graph_database.get_ingest_uri(),
neo4j_database=tmp_database_name,
update_tag=int(time.time()),
)
@@ -158,6 +171,8 @@ def run(tenant_id: str, scan_id: str, task_id: str) -> dict[str, Any]:
update_tag=tmp_cartography_config.update_tag,
)
+ graph_database.verify_scan_databases_available()
+
# Starting the Attack Paths scan
if not db_utils.starting_attack_paths_scan(
attack_paths_scan, tenant_cartography_config
@@ -170,7 +185,8 @@ def run(tenant_id: str, scan_id: str, task_id: str) -> dict[str, Any]:
scan_t0 = time.perf_counter()
logger.info(
f"Starting Attack Paths scan ({attack_paths_scan.id}) for "
- f"{prowler_api_provider.provider.upper()} provider {prowler_api_provider.id}"
+ f"{prowler_api_provider.provider.upper()} provider {prowler_api_provider.id} "
+ f"(staging=Neo4j database {tmp_database_name}, target={target_description})"
)
subgraph_dropped = False
@@ -179,7 +195,8 @@ def run(tenant_id: str, scan_id: str, task_id: str) -> dict[str, Any]:
try:
logger.info(
- f"Creating Neo4j database {tmp_cartography_config.neo4j_database} for tenant {prowler_api_provider.tenant_id}"
+ f"Creating staging Neo4j database {tmp_cartography_config.neo4j_database} "
+ f"for tenant {prowler_api_provider.tenant_id}"
)
graph_database.create_database(tmp_cartography_config.neo4j_database)
@@ -193,7 +210,9 @@ def run(tenant_id: str, scan_id: str, task_id: str) -> dict[str, Any]:
tmp_cartography_config.neo4j_database
) as tmp_neo4j_session:
# Indexes creation
- cartography_create_indexes.run(tmp_neo4j_session, tmp_cartography_config)
+ indexes.create_cartography_indexes(
+ tmp_neo4j_session, tmp_cartography_config
+ )
indexes.create_findings_indexes(tmp_neo4j_session)
db_utils.update_attack_paths_scan_progress(attack_paths_scan, 2)
@@ -225,7 +244,7 @@ def run(tenant_id: str, scan_id: str, task_id: str) -> dict[str, Any]:
cartography_analysis.run(tmp_neo4j_session, tmp_cartography_config)
db_utils.update_attack_paths_scan_progress(attack_paths_scan, 95)
- # Creating Internet node and CAN_ACCESS relationships
+ # Creating Internet node and `CAN_ACCESS` relationships
logger.info(
f"Creating Internet graph for AWS account {prowler_api_provider.uid}"
)
@@ -249,23 +268,41 @@ def run(tenant_id: str, scan_id: str, task_id: str) -> dict[str, Any]:
db_utils.update_attack_paths_scan_progress(attack_paths_scan, 97)
logger.info(
- f"Clearing Neo4j cache for database {tmp_cartography_config.neo4j_database}"
+ f"Clearing Neo4j cache for staging database {tmp_cartography_config.neo4j_database}"
)
graph_database.clear_cache(tmp_cartography_config.neo4j_database)
+ t0 = time.perf_counter()
logger.info(
- f"Ensuring tenant database {tenant_database_name}, and its indexes, exists for tenant {prowler_api_provider.tenant_id}"
+ f"Preparing target {target_description} for tenant {prowler_api_provider.tenant_id}"
)
graph_database.create_database(tenant_database_name)
- with graph_database.get_session(tenant_database_name) as tenant_neo4j_session:
- cartography_create_indexes.run(
- tenant_neo4j_session, tenant_cartography_config
- )
- indexes.create_findings_indexes(tenant_neo4j_session)
- indexes.create_sync_indexes(tenant_neo4j_session)
+ # Sink-side index creation: Neptune auto-manages indexes and rejects
+ # `CREATE INDEX`, so only run it when the sink is Neo4j
+ # The temp ingest DB is always Neo4j and is always indexed above
+ if target_sink_backend != "neptune":
+ logger.info(f"Ensuring indexes exist for {target_description}")
+ with graph_database.get_session(
+ tenant_database_name
+ ) as tenant_neo4j_session:
+ indexes.create_cartography_indexes(
+ tenant_neo4j_session, tenant_cartography_config
+ )
+ indexes.create_findings_indexes(tenant_neo4j_session)
+ indexes.create_sync_indexes(tenant_neo4j_session)
+ else:
+ logger.info("Skipping tenant database indexes for neptune sink")
+ logger.info(
+ f"Prepared target {target_description} in {time.perf_counter() - t0:.3f}s"
+ )
- logger.info(f"Deleting existing provider graph in {tenant_database_name}")
- db_utils.set_provider_graph_data_ready(attack_paths_scan, False)
+ logger.info(
+ f"Deleting existing provider graph from {target_description} "
+ f"(tenant={prowler_api_provider.tenant_id}, provider={prowler_api_provider.id})"
+ )
+ db_utils.set_provider_graph_data_ready(
+ attack_paths_scan, False, target_sink_backend
+ )
provider_gated = True
t0 = time.perf_counter()
@@ -274,14 +311,17 @@ def run(tenant_id: str, scan_id: str, task_id: str) -> dict[str, Any]:
provider_id=str(prowler_api_provider.id),
)
logger.info(
- f"Deleted existing provider graph in {time.perf_counter() - t0:.3f}s "
- f"(deleted_nodes={deleted_nodes})"
+ f"Deleted existing provider graph from {target_description} "
+ f"in {time.perf_counter() - t0:.3f}s (deleted_nodes={deleted_nodes})"
)
subgraph_dropped = True
db_utils.update_attack_paths_scan_progress(attack_paths_scan, 98)
logger.info(
- f"Syncing graph from {tmp_database_name} into {tenant_database_name}"
+ f"Syncing staging graph {tmp_database_name} into {target_description} "
+ f"for provider {prowler_api_provider.id} "
+ f"(tenant {prowler_api_provider.tenant_id}, "
+ f"type {prowler_api_provider.provider})"
)
t0 = time.perf_counter()
sync_result = sync.sync_graph(
@@ -289,17 +329,34 @@ def run(tenant_id: str, scan_id: str, task_id: str) -> dict[str, Any]:
target_database=tenant_database_name,
tenant_id=str(prowler_api_provider.tenant_id),
provider_id=str(prowler_api_provider.id),
+ provider_type=prowler_api_provider.provider,
)
+ elapsed = time.perf_counter() - t0
+ total_nodes = sync_result["nodes"] + sync_result["child_nodes"]
+ elements = total_nodes + sync_result["relationships"]
+ rate = elements / elapsed if elapsed else 0
logger.info(
- f"Synced graph in {time.perf_counter() - t0:.3f}s "
- f"(nodes={sync_result['nodes']}, relationships={sync_result['relationships']})"
+ f"Synced staging graph into {target_description} in {elapsed:.3f}s - "
+ f"nodes={total_nodes} (source={sync_result['nodes']}, "
+ f"items={sync_result['child_nodes']}), "
+ f"relationships={sync_result['relationships']} "
+ f"(structural={sync_result['structural_relationships']}, "
+ f"items={sync_result['item_relationships']}), "
+ f"~{rate:.0f} elem/s"
)
sync_completed = True
+ # Flip metadata only now: the new schema is live in the target sink, so
+ # reads can switch to the current catalog/backend. The target-sink gate
+ # is already closed, so the switch is atomic from the API's view.
+ db_utils.set_scan_migrated(attack_paths_scan, True, target_sink_backend)
db_utils.set_graph_data_ready(attack_paths_scan, True)
db_utils.update_attack_paths_scan_progress(attack_paths_scan, 99)
- logger.info(f"Clearing Neo4j cache for database {tenant_database_name}")
- graph_database.clear_cache(tenant_database_name)
+ if target_sink_backend == "neptune":
+ logger.info("Skipping cache clear for neptune sink")
+ else:
+ logger.info(f"Clearing Neo4j cache for target {target_description}")
+ graph_database.clear_cache(tenant_database_name)
logger.info(f"Dropping temporary Neo4j database {tmp_database_name}")
graph_database.drop_database(tmp_database_name)
@@ -315,32 +372,51 @@ def run(tenant_id: str, scan_id: str, task_id: str) -> dict[str, Any]:
except Exception as e:
exception_message = utils.stringify_exception(e, "Attack Paths scan failed")
- logger.exception(exception_message)
+ temporary_database_missing = (
+ isinstance(e, graph_database.GraphDatabaseQueryException)
+ and e.code == graph_database.DATABASE_NOT_FOUND_CODE
+ and tmp_database_name in str(e)
+ )
+ if temporary_database_missing:
+ logger.warning(exception_message)
+ else:
+ logger.exception(exception_message)
+ cleanup_log_level = (
+ logging.WARNING if temporary_database_missing else logging.ERROR
+ )
+ cleanup_exc_info = not temporary_database_missing
ingestion_exceptions["global_error"] = exception_message
- # Recover graph_data_ready based on how far the swap got.
- # Partial drop (mid-batch failure) may leave `subgraph_dropped=False`
- # with data partially deleted, so we prefer that over permanently blocked queries.
+ # Recover `graph_data_ready` based on how far the swap got
+ # Partial drop (mid-batch failure) may leave `subgraph_dropped=False` with data partially deleted,
+ # so we prefer that over permanently blocked queries
try:
if sync_completed:
db_utils.set_graph_data_ready(attack_paths_scan, True)
elif provider_gated and not subgraph_dropped:
- db_utils.set_provider_graph_data_ready(attack_paths_scan, True)
+ db_utils.set_provider_graph_data_ready(
+ attack_paths_scan, True, target_sink_backend
+ )
except Exception:
- logger.error(
- f"Failed to recover `graph_data_ready` for provider {attack_paths_scan.provider_id}",
- exc_info=True,
+ logger.log(
+ cleanup_log_level,
+ "Failed to recover `graph_data_ready` for provider "
+ f"{attack_paths_scan.provider_id}",
+ exc_info=cleanup_exc_info,
)
# Dropping the temporary database if it still exists
try:
graph_database.drop_database(tmp_cartography_config.neo4j_database)
- except Exception as e:
- logger.error(
- f"Failed to drop temporary Neo4j database `{tmp_cartography_config.neo4j_database}` during cleanup: {e}",
- exc_info=True,
+ except Exception as cleanup_error:
+ logger.log(
+ cleanup_log_level,
+ "Failed to drop temporary Neo4j database "
+ f"`{tmp_cartography_config.neo4j_database}` during cleanup: "
+ f"{cleanup_error}",
+ exc_info=cleanup_exc_info,
)
# Set Attack Paths scan state to FAILED
@@ -348,10 +424,12 @@ def run(tenant_id: str, scan_id: str, task_id: str) -> dict[str, Any]:
db_utils.finish_attack_paths_scan(
attack_paths_scan, StateChoices.FAILED, ingestion_exceptions
)
- except Exception as e:
- logger.error(
- f"Could not mark Attack Paths scan {attack_paths_scan.id} as `FAILED` (row may have been deleted): {e}",
- exc_info=True,
+ except Exception as cleanup_error:
+ logger.log(
+ cleanup_log_level,
+ f"Could not mark Attack Paths scan {attack_paths_scan.id} as `FAILED` "
+ f"(row may have been deleted): {cleanup_error}",
+ exc_info=cleanup_exc_info,
)
raise
diff --git a/api/src/backend/tasks/jobs/attack_paths/sync.py b/api/src/backend/tasks/jobs/attack_paths/sync.py
index f720a12e82..98a52bd48b 100644
--- a/api/src/backend/tasks/jobs/attack_paths/sync.py
+++ b/api/src/backend/tasks/jobs/attack_paths/sync.py
@@ -1,42 +1,58 @@
"""
Graph sync operations for Attack Paths.
-This module handles syncing graph data from temporary scan databases
-to the tenant database, adding provider isolation labels and properties.
+Reads nodes and relationships out of the cartography temp database (always
+Neo4j) and hands them to the configured sink (Neo4j or Neptune) in batches.
+Backend-specific Cypher (MERGE shape, ID strategy, indexes) lives in each
+sink; this module owns the source read loop, per-batch grouping, and the
+list-property materialisation policy (see `NormalizedList`).
+
+Each list-typed node property that appears in the provider's
+`normalized_lists` catalog becomes a set of child item nodes connected to
+the parent by a typed edge. A list-typed property that is not in the
+catalog is serialised to a comma-delimited string and emits a one-time
+warning per (label, property), surfacing Cartography fields that should be
+added to the catalog.
"""
+import json
import time
-
from collections import defaultdict
+from collections.abc import Iterator
+from hashlib import sha256
from typing import Any
import neo4j
+from api.attack_paths import database as graph_database
+from api.attack_paths import sink as sink_module
from celery.utils.log import get_task_logger
from tasks.jobs.attack_paths.config import (
+ PROVIDER_CONFIGS,
PROVIDER_ISOLATION_PROPERTIES,
PROVIDER_RESOURCE_LABEL,
- SYNC_BATCH_SIZE,
+ NormalizedList,
get_provider_label,
get_tenant_label,
)
from tasks.jobs.attack_paths.queries import (
NODE_FETCH_QUERY,
- NODE_SYNC_TEMPLATE,
- RELATIONSHIP_SYNC_TEMPLATE,
RELATIONSHIPS_FETCH_QUERY,
- render_cypher_template,
)
-from api.attack_paths import database as graph_database
-
logger = get_task_logger(__name__)
+# (label, property) tuples for which we've already emitted the
+# "unnormalised list" warning. Module-level so the warning fires once per
+# process, not once per node.
+_WARNED_UNNORMALIZED: set[tuple[str, str]] = set()
+
def sync_graph(
source_database: str,
target_database: str,
tenant_id: str,
provider_id: str,
+ provider_type: str,
) -> dict[str, int]:
"""
Sync all nodes and relationships from source to target database.
@@ -46,25 +62,38 @@ def sync_graph(
`target_database`: The tenant database
`tenant_id`: The tenant ID for isolation
`provider_id`: The provider ID for isolation
+ `provider_type`: Provider type key (e.g. "aws"), used to resolve the
+ `NormalizedList` catalog from `PROVIDER_CONFIGS`.
Returns:
- Dict with counts of synced nodes and relationships
+ Dict with counts of synced nodes, child item nodes, and relationships.
"""
- nodes_synced = sync_nodes(
+ sink = sink_module.get_backend()
+ sink.ensure_sync_indexes(target_database)
+
+ normalized_lists = _resolve_normalized_lists(provider_type)
+
+ node_result = sync_nodes(
source_database,
target_database,
tenant_id,
provider_id,
+ sink,
+ normalized_lists,
)
relationships_synced = sync_relationships(
source_database,
target_database,
provider_id,
+ sink,
)
return {
- "nodes": nodes_synced,
- "relationships": relationships_synced,
+ "nodes": node_result["parents"],
+ "child_nodes": node_result["children"],
+ "relationships": relationships_synced + node_result["parent_child_rels"],
+ "structural_relationships": relationships_synced,
+ "item_relationships": node_result["parent_child_rels"],
}
@@ -73,84 +102,123 @@ def sync_nodes(
target_database: str,
tenant_id: str,
provider_id: str,
-) -> int:
+ sink: Any,
+ normalized_lists: list[NormalizedList],
+) -> dict[str, int]:
"""
- Sync nodes from source to target database.
+ Sync nodes from source to target database, exploding catalogued list
+ properties into child nodes + parent->child edges.
Adds `_ProviderResource` label and dynamic `_Tenant_{id}` and `_Provider_{id}`
- isolation labels to all nodes.
+ isolation labels to all nodes (parents and children alike).
Source and target sessions are opened sequentially per batch to avoid
holding two Bolt connections simultaneously for the entire sync duration.
"""
+ batch_size = sink.sync_batch_size
t0 = time.perf_counter()
last_id = -1
- total_synced = 0
+ parents_synced = 0
+ children_synced = 0
+ parent_child_rels = 0
+
+ catalog = _build_catalog_index(normalized_lists)
+ extra_labels = _build_extra_labels(tenant_id, provider_id)
while True:
- grouped: dict[tuple[str, ...], list[dict[str, Any]]] = defaultdict(list)
+ tb = time.perf_counter()
+ prev_children = children_synced
+ prev_rels = parent_child_rels
+ parent_groups: dict[tuple[str, ...], list[dict[str, Any]]] = defaultdict(list)
+ child_groups: dict[str, list[dict[str, Any]]] = defaultdict(list)
+ rel_groups: dict[str, list[dict[str, Any]]] = defaultdict(list)
batch_count = 0
with graph_database.get_session(source_database) as source_session:
result = source_session.run(
NODE_FETCH_QUERY,
- {"last_id": last_id, "batch_size": SYNC_BATCH_SIZE},
+ {"last_id": last_id, "batch_size": batch_size},
)
for record in result:
batch_count += 1
last_id = record["internal_id"]
- key, value = _node_to_sync_dict(record, provider_id)
- grouped[key].append(value)
+ key, parent_dict, children, rels = _node_to_sync_dict(
+ record, provider_id, catalog
+ )
+ parent_groups[key].append(parent_dict)
+ for child in children:
+ child_groups[child["_child_label"]].append(child["row"])
+ for rel in rels:
+ rel_groups[rel["rel_type"]].append(rel["row"])
if batch_count == 0:
break
- with graph_database.get_session(target_database) as target_session:
- for labels, batch in grouped.items():
- label_set = set(labels)
- label_set.add(PROVIDER_RESOURCE_LABEL)
- label_set.add(get_tenant_label(tenant_id))
- label_set.add(get_provider_label(provider_id))
- node_labels = ":".join(f"`{label}`" for label in sorted(label_set))
+ for labels, batch in parent_groups.items():
+ rendered_labels = _render_labels(labels, extra_labels)
+ for sink_batch in _iter_sink_batches(batch, batch_size):
+ sink.write_nodes(target_database, rendered_labels, sink_batch)
- query = render_cypher_template(
- NODE_SYNC_TEMPLATE, {"__NODE_LABELS__": node_labels}
+ for child_label, batch in child_groups.items():
+ rendered_labels = _render_labels((child_label,), extra_labels)
+ for sink_batch in _iter_sink_batches(batch, batch_size):
+ sink.write_nodes(target_database, rendered_labels, sink_batch)
+ children_synced += len(batch)
+
+ for rel_type, batch in rel_groups.items():
+ for sink_batch in _iter_sink_batches(batch, batch_size):
+ sink.write_relationships(
+ target_database, rel_type, provider_id, sink_batch
)
- target_session.run(query, {"rows": batch})
+ parent_child_rels += len(batch)
- total_synced += batch_count
+ parents_synced += batch_count
+ batch_dt = time.perf_counter() - tb
+ batch_elements = (
+ batch_count
+ + (children_synced - prev_children)
+ + (parent_child_rels - prev_rels)
+ )
+ rate = batch_elements / batch_dt if batch_dt else 0
logger.info(
- f"Synced {total_synced} nodes from {source_database} to {target_database} in {time.perf_counter() - t0:.3f}s"
+ f"[sync nodes] {parents_synced} source (+{children_synced} items, "
+ f"+{parent_child_rels} item rels) · batch {batch_dt:.1f}s · "
+ f"elapsed {time.perf_counter() - t0:.1f}s · ~{rate:.0f} elem/s"
)
- return total_synced
+ return {
+ "parents": parents_synced,
+ "children": children_synced,
+ "parent_child_rels": parent_child_rels,
+ }
def sync_relationships(
source_database: str,
target_database: str,
provider_id: str,
+ sink: Any,
) -> int:
"""
Sync relationships from source to target database.
- Matches source and target nodes by `_provider_element_id` in the tenant database.
-
Source and target sessions are opened sequentially per batch to avoid
holding two Bolt connections simultaneously for the entire sync duration.
"""
+ batch_size = sink.sync_batch_size
t0 = time.perf_counter()
last_id = -1
total_synced = 0
while True:
+ tb = time.perf_counter()
grouped: dict[str, list[dict[str, Any]]] = defaultdict(list)
batch_count = 0
with graph_database.get_session(source_database) as source_session:
result = source_session.run(
RELATIONSHIPS_FETCH_QUERY,
- {"last_id": last_id, "batch_size": SYNC_BATCH_SIZE},
+ {"last_id": last_id, "batch_size": batch_size},
)
for record in result:
batch_count += 1
@@ -161,32 +229,212 @@ def sync_relationships(
if batch_count == 0:
break
- with graph_database.get_session(target_database) as target_session:
- for rel_type, batch in grouped.items():
- query = render_cypher_template(
- RELATIONSHIP_SYNC_TEMPLATE, {"__REL_TYPE__": rel_type}
+ for rel_type, batch in grouped.items():
+ for sink_batch in _iter_sink_batches(batch, batch_size):
+ sink.write_relationships(
+ target_database, rel_type, provider_id, sink_batch
)
- target_session.run(query, {"rows": batch})
total_synced += batch_count
+ batch_dt = time.perf_counter() - tb
+ rate = batch_count / batch_dt if batch_dt else 0
logger.info(
- f"Synced {total_synced} relationships from {source_database} to {target_database} in {time.perf_counter() - t0:.3f}s"
+ f"[sync rels] {total_synced} structural · batch {batch_dt:.1f}s · "
+ f"elapsed {time.perf_counter() - t0:.1f}s · ~{rate:.0f}/s"
)
return total_synced
+def _iter_sink_batches(
+ rows: list[dict[str, Any]],
+ batch_size: int,
+) -> Iterator[list[dict[str, Any]]]:
+ """Yield final sink write batches after source rows have been transformed."""
+ if batch_size <= 0:
+ raise ValueError("Sink batch size must be greater than zero")
+
+ for index in range(0, len(rows), batch_size):
+ yield rows[index : index + batch_size]
+
+
def _node_to_sync_dict(
- record: neo4j.Record, provider_id: str
-) -> tuple[tuple[str, ...], dict[str, Any]]:
- """Transform a source node record into a (grouping_key, sync_dict) pair."""
+ record: neo4j.Record,
+ provider_id: str,
+ catalog: dict[tuple[str, str], NormalizedList],
+) -> tuple[
+ tuple[str, ...],
+ dict[str, Any],
+ list[dict[str, Any]],
+ list[dict[str, Any]],
+]:
+ """Transform a source node record into a (grouping_key, sync_dict, children, rels) tuple.
+
+ Catalogued list properties are popped from `props` and emitted as child
+ nodes + parent->child relationships.
+ """
props = dict(record["props"] or {})
_strip_internal_properties(props)
labels = tuple(sorted(set(record["labels"] or [])))
- return labels, {
- "provider_element_id": f"{provider_id}:{record['element_id']}",
+ parent_element_id = f"{provider_id}:{record['element_id']}"
+
+ children, rels = _explode_catalogued_lists(
+ labels, props, catalog, provider_id, parent_element_id
+ )
+
+ _normalize_sink_properties(props, labels)
+
+ parent = {
+ "provider_element_id": parent_element_id,
"props": props,
}
+ return labels, parent, children, rels
+
+
+def _explode_catalogued_lists(
+ labels: tuple[str, ...],
+ props: dict[str, Any],
+ catalog: dict[tuple[str, str], NormalizedList],
+ provider_id: str,
+ parent_element_id: str,
+) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
+ """Pop catalogued list properties from `props` and produce child + rel emits.
+
+ A node may carry multiple labels (e.g. `AWSPolicyStatement` plus
+ `_AWSResource`); we check each label for catalog matches independently.
+ Returns:
+ - children: list of {"_child_label": str, "row": } dicts.
+ - rels: list of {"rel_type": str, "row": } dicts.
+ """
+ children: list[dict[str, Any]] = []
+ rels: list[dict[str, Any]] = []
+
+ for label in labels:
+ for key in list(props.keys()):
+ spec = catalog.get((label, key))
+ if spec is None:
+ continue
+ value = props.pop(key)
+ if value is None:
+ continue
+ if not isinstance(value, list):
+ # Catalogued but not actually a list this scan - fall back to
+ # the generic normaliser so we don't lose the value.
+ props[key] = value
+ continue
+ for item in value:
+ child_value_key, child_props = _build_child_props(spec, item)
+ if child_value_key is None:
+ continue
+ child_element_id = _build_child_id(
+ provider_id, spec.child_label, child_value_key
+ )
+ children.append(
+ {
+ "_child_label": spec.child_label,
+ "row": {
+ "provider_element_id": child_element_id,
+ "props": child_props,
+ },
+ }
+ )
+ rels.append(
+ {
+ "rel_type": spec.rel_type,
+ "row": {
+ "start_element_id": parent_element_id,
+ "end_element_id": child_element_id,
+ "provider_element_id": (
+ f"{parent_element_id}::{spec.rel_type}::"
+ f"{child_element_id}"
+ ),
+ "props": {},
+ },
+ }
+ )
+
+ return children, rels
+
+
+def _build_child_props(
+ spec: NormalizedList, item: Any
+) -> tuple[str | None, dict[str, Any]]:
+ """Translate one list element into a child node's prop dict.
+
+ Returns (dedup_key, props). The dedup_key is what makes two child nodes
+ equal within (tenant, provider) - used to build `_provider_element_id`.
+ For scalar mode, the dedup key is the value itself. For dict mode it is
+ a stable concatenation of the mapped fields in `field_map` order.
+ """
+ if not spec.field_map:
+ if isinstance(item, (dict, list)):
+ # Defensive: caller marked this list as scalar but elements are
+ # structured. Convert to a stable string so the value survives.
+ value_str = json.dumps(item, sort_keys=True, default=str)
+ else:
+ value_str = str(item)
+ return value_str, {"value": value_str}
+
+ if not isinstance(item, dict):
+ # Catalogued as dict-shape but got a scalar. Skip - caller will see
+ # the value go missing and can fix the field_map.
+ return None, {}
+
+ props: dict[str, Any] = {}
+ dedup_parts: list[str] = []
+ for src_key, child_field in spec.field_map:
+ raw = item.get(src_key)
+ value_str = _to_sink_property_value(raw) if raw is not None else ""
+ props[child_field] = value_str
+ dedup_parts.append(f"{child_field}={value_str}")
+ return "::".join(dedup_parts), props
+
+
+def _build_child_id(provider_id: str, child_label: str, value_key: str) -> str:
+ """Deterministic `_provider_element_id` for a list-item child node.
+
+ Hashing the value keeps the ID bounded while preserving deduplication within
+ each provider and child label.
+ """
+ value_digest = sha256(value_key.encode("utf-8")).hexdigest()
+ return f"{provider_id}::{child_label}::{value_digest}"
+
+
+def _build_catalog_index(
+ normalized_lists: list[NormalizedList],
+) -> dict[tuple[str, str], NormalizedList]:
+ """Index the catalog by (source_label, source_property) for O(1) lookup."""
+ return {
+ (spec.source_label, spec.source_property): spec for spec in normalized_lists
+ }
+
+
+def _build_extra_labels(tenant_id: str, provider_id: str) -> tuple[str, ...]:
+ return (
+ PROVIDER_RESOURCE_LABEL,
+ get_tenant_label(tenant_id),
+ get_provider_label(provider_id),
+ )
+
+
+def _render_labels(base_labels: tuple[str, ...], extra_labels: tuple[str, ...]) -> str:
+ """Render the Cypher label string for a node-write batch."""
+ label_set = set(base_labels) | set(extra_labels)
+ return ":".join(f"`{label}`" for label in sorted(label_set))
+
+
+def _resolve_normalized_lists(provider_type: str) -> list[NormalizedList]:
+ config = PROVIDER_CONFIGS.get(provider_type)
+ if config is None:
+ # Unknown provider: empty catalog. Any list-typed property will be
+ # serialised to a comma-delimited string with one warning per
+ # (label, property).
+ logger.warning(
+ "Provider type %s not in PROVIDER_CONFIGS; no normalized_lists active",
+ provider_type,
+ )
+ return []
+ return config.normalized_lists
def _rel_to_sync_dict(
@@ -195,7 +443,11 @@ def _rel_to_sync_dict(
"""Transform a source relationship record into a (grouping_key, sync_dict) pair."""
props = dict(record["props"] or {})
_strip_internal_properties(props)
+ # Relationship properties go through the same primitive coercion as
+ # nodes; catalog-driven materialisation applies to node properties only.
+ _normalize_sink_properties(props, labels=None)
rel_type = record["rel_type"]
+
return rel_type, {
"start_element_id": f"{provider_id}:{record['start_element_id']}",
"end_element_id": f"{provider_id}:{record['end_element_id']}",
@@ -208,3 +460,80 @@ def _strip_internal_properties(props: dict[str, Any]) -> None:
"""Remove provider isolation properties before the += spread in sync templates."""
for key in PROVIDER_ISOLATION_PROPERTIES:
props.pop(key, None)
+
+
+def _normalize_sink_properties(
+ props: dict[str, Any], labels: tuple[str, ...] | None
+) -> None:
+ """Normalize property values to primitive Cypher literals for either sink.
+
+ Attack-paths node and relationship properties are written as primitive
+ scalars regardless of the active sink (Neo4j or Neptune). The convention
+ is driven by Neptune's openCypher type restrictions, which reject list,
+ map, temporal and spatial property values, but it is applied uniformly
+ so that custom and predefined queries are portable across sinks without
+ runtime rewriting.
+
+ Concretely:
+ - Temporal values (neo4j.time.{DateTime,Date,Time,Duration}) become
+ their ISO-8601 string representation.
+ - Spatial values (neo4j.spatial.Point and subclasses) become their
+ WKT-style string representation.
+ - Maps / dicts become a JSON-encoded string, read back with `CONTAINS`
+ substring checks inside queries.
+ - Lists become a comma-delimited string. Catalogued list properties
+ are materialised as child item nodes upstream in
+ `_explode_catalogued_lists` and never reach this point; any list
+ seen here is uncatalogued, so we log a one-time warning per
+ (label, property) to surface Cartography fields that should be
+ added to the catalog.
+
+ `labels` is only used for the warning message; pass `None` for
+ relationship props (no label context).
+ """
+ for key, value in list(props.items()):
+ if isinstance(value, list) and labels is not None:
+ _warn_unnormalized_list(labels, key)
+ props[key] = _to_sink_property_value(value)
+
+
+def _warn_unnormalized_list(labels: tuple[str, ...], key: str) -> None:
+ """Warn once per (label, property), on the real label(s) only.
+
+ Every synced node also carries internal isolation labels (`_AWSResource`,
+ `_ProviderResource`, `_Tenant_*`, `_Provider_*`); warning on those just
+ doubles the noise, so skip them and point at the actionable Cartography
+ label. Falls back to all labels if only internal ones are present.
+ """
+ real_labels = [label for label in labels if not label.startswith("_")]
+ for label in real_labels or labels:
+ token = (label, key)
+ if token in _WARNED_UNNORMALIZED:
+ continue
+ _WARNED_UNNORMALIZED.add(token)
+ logger.warning(
+ "Unnormalized list property %s.%s reached sink as comma-string; "
+ "add a NormalizedList entry to the provider catalog to explode it",
+ label,
+ key,
+ )
+
+
+def _to_sink_property_value(value: Any) -> Any:
+ if hasattr(value, "iso_format") and callable(value.iso_format):
+ return value.iso_format()
+
+ if type(value).__module__.startswith("neo4j.spatial"):
+ return str(value)
+
+ if isinstance(value, dict):
+ # openCypher `SET` rejects map property values: encode as JSON so the structured payload
+ # survives the round-trip and is queryable with `CONTAINS` substring checks
+ return json.dumps(value, sort_keys=True, default=str)
+
+ if isinstance(value, list):
+ # openCypher `SET` rejects list/array property values: encode as a
+ # delimited string read back with split() inside queries
+ return ",".join(str(_to_sink_property_value(v)) for v in value)
+
+ return value
diff --git a/api/src/backend/tasks/jobs/attack_paths/utils.py b/api/src/backend/tasks/jobs/attack_paths/utils.py
index eef5670782..50d670bfd3 100644
--- a/api/src/backend/tasks/jobs/attack_paths/utils.py
+++ b/api/src/backend/tasks/jobs/attack_paths/utils.py
@@ -1,7 +1,6 @@
import asyncio
import traceback
-
-from datetime import datetime, timezone
+from datetime import UTC, datetime
from celery.utils.log import get_task_logger
@@ -10,7 +9,7 @@ logger = get_task_logger(__name__)
def stringify_exception(exception: Exception, context: str) -> str:
"""Format an exception with timestamp and traceback for logging."""
- timestamp = datetime.now(tz=timezone.utc)
+ timestamp = datetime.now(tz=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 825dcb7ca8..56cb626786 100644
--- a/api/src/backend/tasks/jobs/backfill.py
+++ b/api/src/backend/tasks/jobs/backfill.py
@@ -1,19 +1,6 @@
from collections import defaultdict
from datetime import timedelta
-from celery.utils.log import get_task_logger
-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,
- aggregate_finding_group_summaries,
- aggregate_resource_group_counts,
-)
-
from api.db_router import READ_REPLICA_ALIAS, MainRouter
from api.db_utils import (
POSTGRES_TENANT_VAR,
@@ -36,6 +23,18 @@ from api.models import (
ScanSummary,
StateChoices,
)
+from celery.utils.log import get_task_logger
+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,
+ aggregate_finding_group_summaries,
+ aggregate_resource_group_counts,
+)
logger = get_task_logger(__name__)
diff --git a/api/src/backend/tasks/jobs/connection.py b/api/src/backend/tasks/jobs/connection.py
index d7068ebf3b..3ae20c96a0 100644
--- a/api/src/backend/tasks/jobs/connection.py
+++ b/api/src/backend/tasks/jobs/connection.py
@@ -1,13 +1,12 @@
-from datetime import datetime, timezone
+from datetime import UTC, datetime
import openai
-from celery.utils.log import get_task_logger
-
from api.models import Integration, LighthouseConfiguration, Provider
from api.utils import (
prowler_integration_connection_test,
prowler_provider_connection_test,
)
+from celery.utils.log import get_task_logger
logger = get_task_logger(__name__)
@@ -38,7 +37,7 @@ def check_provider_connection(provider_id: str):
raise e
provider_instance.connected = connection_result.is_connected
- provider_instance.connection_last_checked_at = datetime.now(tz=timezone.utc)
+ provider_instance.connection_last_checked_at = datetime.now(tz=UTC)
provider_instance.save()
connection_error = f"{connection_result.error}" if connection_result.error else None
@@ -111,7 +110,7 @@ def check_integration_connection(integration_id: str):
# Update integration connection status
integration.connected = result.is_connected
- integration.connection_last_checked_at = datetime.now(tz=timezone.utc)
+ integration.connection_last_checked_at = datetime.now(tz=UTC)
integration.save()
return {
diff --git a/api/src/backend/tasks/jobs/deletion.py b/api/src/backend/tasks/jobs/deletion.py
index f9ead01897..91e64610f7 100644
--- a/api/src/backend/tasks/jobs/deletion.py
+++ b/api/src/backend/tasks/jobs/deletion.py
@@ -1,11 +1,5 @@
-from celery.utils.log import get_task_logger
-from django.db import DatabaseError
-from tasks.jobs.queries import (
- COMPLIANCE_DELETE_EMPTY_TENANT_SUMMARY_SQL,
- COMPLIANCE_UPSERT_TENANT_SUMMARY_SQL,
-)
-
from api.attack_paths import database as graph_database
+from api.attack_paths import sink as sink_module
from api.db_router import MainRouter
from api.db_utils import batch_delete, rls_transaction
from api.models import (
@@ -18,6 +12,12 @@ from api.models import (
ScanSummary,
Tenant,
)
+from celery.utils.log import get_task_logger
+from django.db import DatabaseError
+from tasks.jobs.queries import (
+ COMPLIANCE_DELETE_EMPTY_TENANT_SUMMARY_SQL,
+ COMPLIANCE_UPSERT_TENANT_SUMMARY_SQL,
+)
logger = get_task_logger(__name__)
@@ -77,6 +77,12 @@ def delete_provider(tenant_id: str, pk: str):
"id", flat=True
)
)
+ attack_paths_sink_backends = list(
+ AttackPathsScan.all_objects.filter(provider=instance)
+ .values_list("sink_backend", flat=True)
+ .distinct()
+ .order_by("sink_backend")
+ )
deletion_steps = [
("Scan Summaries", ScanSummary.all_objects.filter(scan__provider=instance)),
@@ -98,7 +104,13 @@ def delete_provider(tenant_id: str, pk: str):
# Delete the Attack Paths' graph data related to the provider from the tenant database
tenant_database_name = graph_database.get_database_name(tenant_id)
try:
- graph_database.drop_subgraph(tenant_database_name, str(pk))
+ if attack_paths_sink_backends:
+ for sink_backend in attack_paths_sink_backends:
+ sink_module.get_backend_for_name(sink_backend).drop_subgraph(
+ tenant_database_name, str(pk)
+ )
+ else:
+ graph_database.drop_subgraph(tenant_database_name, str(pk))
except graph_database.GraphDatabaseQueryException as gdb_error:
logger.error(f"Error deleting Provider graph data: {gdb_error}")
diff --git a/api/src/backend/tasks/jobs/export.py b/api/src/backend/tasks/jobs/export.py
index 1b9295cc67..e658d6018c 100644
--- a/api/src/backend/tasks/jobs/export.py
+++ b/api/src/backend/tasks/jobs/export.py
@@ -4,12 +4,11 @@ import zipfile
import boto3
import config.django.base as base
+from api.db_utils import rls_transaction
+from api.models import Scan
from botocore.exceptions import ClientError, NoCredentialsError, ParamValidationError
from celery.utils.log import get_task_logger
from django.conf import settings
-
-from api.db_utils import rls_transaction
-from api.models import Scan
from prowler.config.config import (
csv_file_suffix,
html_file_suffix,
@@ -18,6 +17,9 @@ from prowler.config.config import (
set_output_timestamp,
)
from prowler.lib.outputs.asff.asff import ASFF
+from prowler.lib.outputs.compliance.asd_essential_eight.asd_essential_eight_aws import (
+ ASDEssentialEightAWS,
+)
from prowler.lib.outputs.compliance.aws_well_architected.aws_well_architected import (
AWSWellArchitected,
)
@@ -39,17 +41,9 @@ from prowler.lib.outputs.compliance.cis.cis_oraclecloud import OracleCloudCIS
from prowler.lib.outputs.compliance.cisa_scuba.cisa_scuba_googleworkspace import (
GoogleWorkspaceCISASCuBA,
)
-from prowler.lib.outputs.compliance.csa.csa_alibabacloud import AlibabaCloudCSA
-from prowler.lib.outputs.compliance.csa.csa_aws import AWSCSA
-from prowler.lib.outputs.compliance.csa.csa_azure import AzureCSA
-from prowler.lib.outputs.compliance.csa.csa_gcp import GCPCSA
-from prowler.lib.outputs.compliance.csa.csa_oraclecloud import OracleCloudCSA
from prowler.lib.outputs.compliance.ens.ens_aws import AWSENS
from prowler.lib.outputs.compliance.ens.ens_azure import AzureENS
from prowler.lib.outputs.compliance.ens.ens_gcp import GCPENS
-from prowler.lib.outputs.compliance.asd_essential_eight.asd_essential_eight_aws import (
- ASDEssentialEightAWS,
-)
from prowler.lib.outputs.compliance.iso27001.iso27001_aws import AWSISO27001
from prowler.lib.outputs.compliance.iso27001.iso27001_azure import AzureISO27001
from prowler.lib.outputs.compliance.iso27001.iso27001_gcp import GCPISO27001
@@ -63,6 +57,9 @@ from prowler.lib.outputs.compliance.mitre_attack.mitre_attack_azure import (
AzureMitreAttack,
)
from prowler.lib.outputs.compliance.mitre_attack.mitre_attack_gcp import GCPMitreAttack
+from prowler.lib.outputs.compliance.okta_idaas_stig.okta_idaas_stig_okta import (
+ OktaIDaaSSTIG,
+)
from prowler.lib.outputs.compliance.prowler_threatscore.prowler_threatscore_alibaba import (
ProwlerThreatScoreAlibaba,
)
@@ -102,7 +99,6 @@ COMPLIANCE_CLASS_MAP = {
(lambda name: name == "prowler_threatscore_aws", ProwlerThreatScoreAWS),
(lambda name: name.startswith("ccc_"), CCC_AWS),
(lambda name: name.startswith("c5_"), AWSC5),
- (lambda name: name.startswith("csa_"), AWSCSA),
(lambda name: name == "asd_essential_eight_aws", ASDEssentialEightAWS),
],
"azure": [
@@ -113,7 +109,6 @@ COMPLIANCE_CLASS_MAP = {
(lambda name: name.startswith("ccc_"), CCC_Azure),
(lambda name: name == "prowler_threatscore_azure", ProwlerThreatScoreAzure),
(lambda name: name == "c5_azure", AzureC5),
- (lambda name: name.startswith("csa_"), AzureCSA),
],
"gcp": [
(lambda name: name.startswith("cis_"), GCPCIS),
@@ -123,7 +118,6 @@ COMPLIANCE_CLASS_MAP = {
(lambda name: name == "prowler_threatscore_gcp", ProwlerThreatScoreGCP),
(lambda name: name.startswith("ccc_"), CCC_GCP),
(lambda name: name == "c5_gcp", GCPC5),
- (lambda name: name.startswith("csa_"), GCPCSA),
],
"kubernetes": [
(lambda name: name.startswith("cis_"), KubernetesCIS),
@@ -152,16 +146,17 @@ COMPLIANCE_CLASS_MAP = {
"image": [],
"oraclecloud": [
(lambda name: name.startswith("cis_"), OracleCloudCIS),
- (lambda name: name.startswith("csa_"), OracleCloudCSA),
],
"alibabacloud": [
(lambda name: name.startswith("cis_"), AlibabaCloudCIS),
- (lambda name: name.startswith("csa_"), AlibabaCloudCSA),
(
lambda name: name == "prowler_threatscore_alibabacloud",
ProwlerThreatScoreAlibaba,
),
],
+ "okta": [
+ (lambda name: name.startswith("okta_idaas_stig"), OktaIDaaSSTIG),
+ ],
}
diff --git a/api/src/backend/tasks/jobs/integrations.py b/api/src/backend/tasks/jobs/integrations.py
index 5ca94057da..c77ba22b7f 100644
--- a/api/src/backend/tasks/jobs/integrations.py
+++ b/api/src/backend/tasks/jobs/integrations.py
@@ -2,20 +2,19 @@ import os
import time
from glob import glob
-from celery.utils.log import get_task_logger
-from config.django.base import DJANGO_FINDINGS_BATCH_SIZE
-from django.db import OperationalError
-from tasks.utils import batched
-
from api.db_router import READ_REPLICA_ALIAS, MainRouter
from api.db_utils import REPLICA_MAX_ATTEMPTS, REPLICA_RETRY_BASE_DELAY, rls_transaction
from api.models import Finding, Integration, Provider
from api.utils import initialize_prowler_integration, initialize_prowler_provider
+from celery.utils.log import get_task_logger
+from config.django.base import DJANGO_FINDINGS_BATCH_SIZE
+from django.db import OperationalError
from prowler.lib.outputs.asff.asff import ASFF
from prowler.lib.outputs.compliance.generic.generic import GenericCompliance
from prowler.lib.outputs.csv.csv import CSV
from prowler.lib.outputs.finding import Finding as FindingOutput
from prowler.lib.outputs.html.html import HTML
+from prowler.lib.outputs.jira.exceptions.exceptions import JiraBaseException
from prowler.lib.outputs.ocsf.ocsf import OCSF
from prowler.providers.aws.aws_provider import AwsProvider
from prowler.providers.aws.lib.s3.s3 import S3
@@ -24,9 +23,12 @@ from prowler.providers.aws.lib.security_hub.exceptions.exceptions import (
)
from prowler.providers.aws.lib.security_hub.security_hub import SecurityHub
from prowler.providers.common.models import Connection
+from tasks.utils import batched
logger = get_task_logger(__name__)
+JIRA_GENERIC_SEND_ERROR = "Failed to create Jira issue."
+
def get_s3_client_from_integration(
integration: Integration,
@@ -484,6 +486,7 @@ def send_findings_to_jira(
jira_integration = initialize_prowler_integration(integration)
num_tickets_created = 0
+ error_messages = []
for finding_id in finding_ids:
with rls_transaction(tenant_id):
finding_instance = (
@@ -513,35 +516,54 @@ def send_findings_to_jira(
recommendation = remediation.get("recommendation", {})
remediation_code = remediation.get("code", {})
- # Send the individual finding to Jira
- result = jira_integration.send_finding(
- check_id=finding_instance.check_id,
- check_title=check_metadata.get("checktitle", ""),
- severity=finding_instance.severity,
- status=finding_instance.status,
- status_extended=finding_instance.status_extended or "",
- provider=finding_instance.scan.provider.provider,
- region=region,
- resource_uid=resource_uid,
- resource_name=resource_name,
- risk=check_metadata.get("risk", ""),
- recommendation_text=recommendation.get("text", ""),
- recommendation_url=recommendation.get("url", ""),
- remediation_code_native_iac=remediation_code.get("nativeiac", ""),
- remediation_code_terraform=remediation_code.get("terraform", ""),
- remediation_code_cli=remediation_code.get("cli", ""),
- remediation_code_other=remediation_code.get("other", ""),
- resource_tags=resource_tags,
- compliance=finding_instance.compliance or {},
- project_key=project_key,
- issue_type=issue_type,
- )
+ try:
+ # Send the individual finding to Jira
+ result = jira_integration.send_finding(
+ check_id=finding_instance.check_id,
+ check_title=check_metadata.get("checktitle", ""),
+ severity=finding_instance.severity,
+ status=finding_instance.status,
+ status_extended=finding_instance.status_extended or "",
+ provider=finding_instance.scan.provider.provider,
+ region=region,
+ resource_uid=resource_uid,
+ resource_name=resource_name,
+ risk=check_metadata.get("risk", ""),
+ recommendation_text=recommendation.get("text", ""),
+ recommendation_url=recommendation.get("url", ""),
+ remediation_code_native_iac=remediation_code.get("nativeiac", ""),
+ remediation_code_terraform=remediation_code.get("terraform", ""),
+ remediation_code_cli=remediation_code.get("cli", ""),
+ remediation_code_other=remediation_code.get("other", ""),
+ resource_tags=resource_tags,
+ compliance=finding_instance.compliance or {},
+ project_key=project_key,
+ issue_type=issue_type,
+ )
+ except JiraBaseException as error:
+ error_message = error.message or JIRA_GENERIC_SEND_ERROR
+ logger.exception(
+ "Failed to send finding %s to Jira: %s", finding_id, error_message
+ )
+ error_messages.append(error_message)
+ continue
+ except Exception:
+ logger.exception("Failed to send finding %s to Jira", finding_id)
+ error_messages.append(JIRA_GENERIC_SEND_ERROR)
+ continue
+
if result:
num_tickets_created += 1
else:
- logger.error(f"Failed to send finding {finding_id} to Jira")
+ error_message = JIRA_GENERIC_SEND_ERROR
+ logger.error(error_message)
+ error_messages.append(error_message)
- return {
+ result = {
"created_count": num_tickets_created,
"failed_count": len(finding_ids) - num_tickets_created,
}
+ if error_messages:
+ result["error"] = "; ".join(dict.fromkeys(error_messages))
+
+ return result
diff --git a/api/src/backend/tasks/jobs/lighthouse_providers.py b/api/src/backend/tasks/jobs/lighthouse_providers.py
index 29e36e5e57..1eec1d3c6a 100644
--- a/api/src/backend/tasks/jobs/lighthouse_providers.py
+++ b/api/src/backend/tasks/jobs/lighthouse_providers.py
@@ -1,14 +1,20 @@
-from typing import Dict
+import ssl
+from collections.abc import Iterable
import boto3
+import httpcore
+import httpx
import openai
+from api.models import LighthouseProviderConfiguration, LighthouseProviderModels
+from api.validators import (
+ resolve_lighthouse_openai_compatible_host,
+ validate_lighthouse_openai_compatible_base_url,
+)
from botocore import UNSIGNED
from botocore.config import Config
from botocore.exceptions import BotoCoreError, ClientError
from celery.utils.log import get_task_logger
-from api.models import LighthouseProviderConfiguration, LighthouseProviderModels
-
logger = get_task_logger(__name__)
# OpenAI model prefixes to exclude from Lighthouse model selection.
@@ -46,6 +52,90 @@ EXCLUDED_OPENAI_MODEL_SUBSTRINGS = (
"-instruct", # Legacy instruct models (gpt-3.5-turbo-instruct, etc.)
)
+OPENAI_COMPATIBLE_AUTHENTICATION_ERROR = "API key is invalid or missing"
+OPENAI_COMPATIBLE_CONNECTION_ERROR = "Provider connection failed"
+
+
+class _OpenAICompatibleProviderError(Exception):
+ """Sanitized OpenAI-compatible provider error safe for task results."""
+
+
+def _sanitize_openai_compatible_error(error: Exception) -> str:
+ status_code = getattr(error, "status_code", None)
+ if status_code is None:
+ response = getattr(error, "response", None)
+ status_code = getattr(response, "status_code", None)
+
+ if status_code == 401:
+ return OPENAI_COMPATIBLE_AUTHENTICATION_ERROR
+ return OPENAI_COMPATIBLE_CONNECTION_ERROR
+
+
+class _LighthouseOpenAICompatibleNetworkBackend(httpcore.SyncBackend):
+ """Validate and pin DNS results immediately before TCP connections."""
+
+ def connect_tcp(
+ self,
+ host: str,
+ port: int,
+ timeout: float | None = None,
+ local_address: str | None = None,
+ socket_options: Iterable[httpcore.SOCKET_OPTION] | None = None,
+ ) -> httpcore.NetworkStream:
+ resolved_addresses = resolve_lighthouse_openai_compatible_host(host, port)
+ last_error: httpcore.ConnectError | httpcore.ConnectTimeout | None = None
+
+ for address in resolved_addresses:
+ try:
+ return super().connect_tcp(
+ address,
+ port,
+ timeout=timeout,
+ local_address=local_address,
+ socket_options=socket_options,
+ )
+ except (httpcore.ConnectError, httpcore.ConnectTimeout) as error:
+ last_error = error
+
+ if last_error:
+ raise last_error
+ raise httpcore.ConnectError("No resolved addresses are available")
+
+
+class _LighthouseOpenAICompatibleHTTPTransport(httpx.HTTPTransport):
+ """HTTP transport that connects only to validated public IP addresses."""
+
+ def __init__(self) -> None:
+ self._pool = httpcore.ConnectionPool(
+ ssl_context=ssl.create_default_context(),
+ network_backend=_LighthouseOpenAICompatibleNetworkBackend(),
+ )
+
+
+def _create_openai_compatible_http_client() -> httpx.Client:
+ """Create the restricted HTTP client used for OpenAI-compatible providers."""
+ return httpx.Client(
+ follow_redirects=False,
+ trust_env=False,
+ transport=_LighthouseOpenAICompatibleHTTPTransport(),
+ )
+
+
+def _list_openai_compatible_models(base_url: str, api_key: str):
+ validate_lighthouse_openai_compatible_base_url(base_url)
+ try:
+ with _create_openai_compatible_http_client() as http_client:
+ client = openai.OpenAI(
+ api_key=api_key,
+ base_url=base_url,
+ http_client=http_client,
+ )
+ return client.models.list()
+ except Exception as error:
+ raise _OpenAICompatibleProviderError(
+ _sanitize_openai_compatible_error(error)
+ ) from error
+
def _extract_error_message(e: Exception) -> str:
"""
@@ -104,7 +194,7 @@ def _extract_openai_api_key(
def _extract_openai_compatible_params(
provider_cfg: LighthouseProviderConfiguration,
-) -> Dict[str, str] | None:
+) -> dict[str, str] | None:
"""
Extract base_url and api_key for OpenAI-compatible providers.
"""
@@ -117,12 +207,13 @@ def _extract_openai_compatible_params(
return None
if not isinstance(base_url, str) or not base_url:
return None
+ validate_lighthouse_openai_compatible_base_url(base_url, resolve_dns=False)
return {"base_url": base_url, "api_key": api_key}
def _extract_bedrock_credentials(
provider_cfg: LighthouseProviderConfiguration,
-) -> Dict[str, str] | None:
+) -> dict[str, str] | None:
"""
Safely extract AWS Bedrock credentials from a provider configuration.
@@ -177,7 +268,7 @@ def _extract_bedrock_credentials(
def _create_bedrock_client(
- bedrock_creds: Dict[str, str], service_name: str = "bedrock"
+ bedrock_creds: dict[str, str], service_name: str = "bedrock"
):
"""
Create a boto3 Bedrock client with the appropriate authentication method.
@@ -221,7 +312,7 @@ def _create_bedrock_client(
)
-def check_lighthouse_provider_connection(provider_config_id: str) -> Dict:
+def check_lighthouse_provider_connection(provider_config_id: str) -> dict:
"""
Validate a Lighthouse provider configuration by calling the provider API and
toggle its active state accordingly.
@@ -288,13 +379,7 @@ def check_lighthouse_provider_connection(provider_config_id: str) -> Dict:
"error": "Base URL or API key is invalid or missing",
}
- # Test connection using OpenAI SDK with custom base_url
- # Note: base_url should include version (e.g., https://openrouter.ai/api/v1)
- client = openai.OpenAI(
- api_key=params["api_key"],
- base_url=params["base_url"],
- )
- _ = client.models.list()
+ _ = _list_openai_compatible_models(params["base_url"], params["api_key"])
else:
return {"connected": False, "error": "Unsupported provider type"}
@@ -314,7 +399,7 @@ def check_lighthouse_provider_connection(provider_config_id: str) -> Dict:
return {"connected": False, "error": error_message}
-def _fetch_openai_models(api_key: str) -> Dict[str, str]:
+def _fetch_openai_models(api_key: str) -> dict[str, str]:
"""
Fetch available models from OpenAI API.
@@ -355,7 +440,7 @@ def _fetch_openai_models(api_key: str) -> Dict[str, str]:
return filtered_models
-def _fetch_openai_compatible_models(base_url: str, api_key: str) -> Dict[str, str]:
+def _fetch_openai_compatible_models(base_url: str, api_key: str) -> dict[str, str]:
"""
Fetch available models from an OpenAI-compatible API using the OpenAI SDK.
@@ -364,10 +449,9 @@ def _fetch_openai_compatible_models(base_url: str, api_key: str) -> Dict[str, st
Note: base_url should include version (e.g., https://openrouter.ai/api/v1)
"""
- client = openai.OpenAI(api_key=api_key, base_url=base_url)
- models = client.models.list()
+ models = _list_openai_compatible_models(base_url, api_key)
- available_models: Dict[str, str] = {}
+ available_models: dict[str, str] = {}
for model in models.data:
model_id = model.id
# Prefer provider-supplied human-friendly name when available
@@ -462,7 +546,7 @@ def _extract_foundation_model_ids(profile_models: list) -> list[str]:
def _build_inference_profile_map(
bedrock_client, region: str
-) -> Dict[str, tuple[str, str]]:
+) -> dict[str, tuple[str, str]]:
"""
Build map of foundation_model_id -> best inference profile.
@@ -472,7 +556,7 @@ def _build_inference_profile_map(
Prefers region-matched profiles over others
"""
region_prefix = _get_region_prefix(region)
- model_to_profile: Dict[str, tuple[str, str]] = {}
+ model_to_profile: dict[str, tuple[str, str]] = {}
try:
response = bedrock_client.list_inference_profiles()
@@ -533,7 +617,7 @@ def _check_on_demand_availability(bedrock_client, model_id: str) -> bool:
return False
-def _fetch_bedrock_models(bedrock_creds: Dict[str, str]) -> Dict[str, str]:
+def _fetch_bedrock_models(bedrock_creds: dict[str, str]) -> dict[str, str]:
"""
Fetch available models from AWS Bedrock, preferring inference profiles over ON_DEMAND.
@@ -560,7 +644,7 @@ def _fetch_bedrock_models(bedrock_creds: Dict[str, str]) -> Dict[str, str]:
foundation_response = bedrock_client.list_foundation_models()
model_summaries = foundation_response.get("modelSummaries", [])
- models_to_return: Dict[str, str] = {}
+ models_to_return: dict[str, str] = {}
on_demand_models: set[str] = set()
for model in model_summaries:
@@ -585,7 +669,7 @@ def _fetch_bedrock_models(bedrock_creds: Dict[str, str]) -> Dict[str, str]:
models_to_return[model_id] = model_name
on_demand_models.add(model_id)
- available_models: Dict[str, str] = {}
+ available_models: dict[str, str] = {}
for model_id, model_name in models_to_return.items():
if model_id in on_demand_models:
@@ -597,7 +681,7 @@ def _fetch_bedrock_models(bedrock_creds: Dict[str, str]) -> Dict[str, str]:
return available_models
-def refresh_lighthouse_provider_models(provider_config_id: str) -> Dict:
+def refresh_lighthouse_provider_models(provider_config_id: str) -> dict:
"""
Refresh the catalog of models for a Lighthouse provider configuration.
@@ -619,7 +703,7 @@ def refresh_lighthouse_provider_models(provider_config_id: str) -> Dict:
LighthouseProviderConfiguration.DoesNotExist: If no configuration exists with the given ID.
"""
provider_cfg = LighthouseProviderConfiguration.objects.get(pk=provider_config_id)
- fetched_models: Dict[str, str] = {}
+ fetched_models: dict[str, str] = {}
try:
if (
diff --git a/api/src/backend/tasks/jobs/muting.py b/api/src/backend/tasks/jobs/muting.py
index 6ef4d127f5..12a32ac574 100644
--- a/api/src/backend/tasks/jobs/muting.py
+++ b/api/src/backend/tasks/jobs/muting.py
@@ -1,10 +1,9 @@
+from api.db_utils import rls_transaction
+from api.models import Finding, MuteRule
from celery.utils.log import get_task_logger
from config.django.base import DJANGO_FINDINGS_BATCH_SIZE
from tasks.utils import batched
-from api.db_utils import rls_transaction
-from api.models import Finding, MuteRule
-
logger = get_task_logger(__name__)
diff --git a/api/src/backend/tasks/jobs/orphan_recovery.py b/api/src/backend/tasks/jobs/orphan_recovery.py
new file mode 100644
index 0000000000..05a2083dbe
--- /dev/null
+++ b/api/src/backend/tasks/jobs/orphan_recovery.py
@@ -0,0 +1,334 @@
+"""Detect and recover orphaned Celery tasks.
+
+A task is "orphaned" when its result row is non-terminal (STARTED/RECEIVED) but the
+worker that was running it is gone (deploy, OOM, eviction). We tell a real orphan
+from a still-running task by pinging the worker recorded on its `TaskResult`:
+
+- worker responds -> the task is in flight, leave it alone (never double-run);
+- worker is gone -> real orphan: mark the stale result terminal (so pending/started
+ alerts clear), then re-enqueue the task from its stored name + kwargs.
+
+This recovers only allowlisted tasks with local, proven idempotency. Celery's
+`result_extended=True` gives us the stored `task_name`/`task_kwargs`/`worker` once
+the task starts, but external side-effect tasks are failed instead of blindly
+re-run. A small recovery cap stops a task that repeatedly kills its worker from
+looping forever.
+
+This is the shared engine behind both the periodic Beat watchdog and the
+`reconcile_orphan_tasks` management command.
+"""
+
+import ast
+import json
+from contextlib import contextmanager
+from datetime import UTC, datetime, timedelta
+from uuid import uuid4
+
+from celery import current_app, states
+from celery.utils.log import get_task_logger
+from django.db import connections
+
+logger = get_task_logger(__name__)
+
+# Arbitrary constant key for pg_try_advisory_lock so only one reconciliation
+# runs at a time across replicas / the watchdog / the command.
+ORPHAN_RECOVERY_LOCK_KEY = 0x70726F77 # "prow"
+
+# Non-terminal states that mean "a worker had this and may have died with it".
+IN_FLIGHT_STATES = (states.STARTED, states.RECEIVED)
+
+# Tasks with proven idempotency are eligible for auto re-enqueue, grouped so each
+# group can be toggled independently by a feature flag (see config.django.base).
+# Summaries clear and rewrite their own rows and deletions are idempotent. Tasks with
+# external side effects are never eligible: integration-jira would create duplicate
+# issues, integration-s3 rebuilds its upload from worker-local files that do not
+# survive a crash, and report/Security Hub recovery is out of scope.
+RECOVERY_TASK_GROUPS = {
+ "summaries": {
+ "scan-summary",
+ "scan-compliance-overviews",
+ "scan-provider-compliance-scores",
+ "scan-daily-severity",
+ "scan-finding-group-summaries",
+ "scan-reset-ephemeral-resources",
+ },
+ "deletions": {"provider-deletion", "tenant-deletion"},
+}
+
+
+def reenqueueable_tasks() -> set[str]:
+ """Task names eligible for auto re-enqueue, honoring the per-group feature flags.
+
+ A group whose flag is disabled is dropped, so its orphaned tasks are marked
+ terminal instead of re-enqueued.
+ """
+ from django.conf import settings
+
+ group_enabled = {
+ "summaries": settings.TASK_RECOVERY_SUMMARIES_ENABLED,
+ "deletions": settings.TASK_RECOVERY_DELETIONS_ENABLED,
+ }
+ return {
+ task
+ for group, tasks in RECOVERY_TASK_GROUPS.items()
+ if group_enabled[group]
+ for task in tasks
+ }
+
+
+# Tasks the watchdog ignores entirely (not even marked terminal): scan tasks are not
+# auto-recovered, since re-running a scan is not safe to do automatically; attack-paths
+# scans are handled by their own stale-cleanup (which also drops the temp Neo4j db);
+# and the maintenance tasks must not self-recover (they run again on their own schedule).
+_SKIP_RECOVERY = {
+ "scan-perform",
+ "scan-perform-scheduled",
+ "attack-paths-scan-perform",
+ "attack-paths-cleanup-stale-scans",
+ "reconcile-orphan-tasks",
+}
+
+
+@contextmanager
+def advisory_lock(key: int = ORPHAN_RECOVERY_LOCK_KEY, using: str = "default"):
+ """Yield True if this session won a Postgres advisory lock, else False.
+
+ Non-blocking: losers get False and should no-op. The lock is released on
+ exit (and implicitly if the session dies).
+ """
+ with connections[using].cursor() as cursor:
+ cursor.execute("SELECT pg_try_advisory_lock(%s)", [key])
+ acquired = bool(cursor.fetchone()[0])
+ try:
+ yield acquired
+ finally:
+ if acquired:
+ cursor.execute("SELECT pg_advisory_unlock(%s)", [key])
+
+
+def is_worker_alive(worker: str, timeout: float = 1.0) -> bool:
+ """Ping a specific Celery worker. Returns True if it responds, or on error.
+
+ Erring on the side of "alive" means an unreachable control bus never causes
+ a still-running task to be re-enqueued.
+ """
+ try:
+ response = current_app.control.inspect(
+ destination=[worker], timeout=timeout
+ ).ping()
+ return response is not None and worker in response
+ except Exception:
+ logger.exception(f"Failed to ping worker {worker}, treating as alive")
+ return True
+
+
+def revoke_task(task_result, terminate: bool = True) -> None:
+ """Revoke a Celery task by its TaskResult. Non-fatal on failure.
+
+ terminate=True SIGTERMs the worker if the task is mid-execution; terminate=False
+ only marks the id revoked so any worker pulling the queued message discards it
+ (use before re-enqueuing, so a later broker redelivery of the stale message is
+ dropped).
+ """
+ try:
+ kwargs = {"terminate": True, "signal": "SIGTERM"} if terminate else {}
+ current_app.control.revoke(task_result.task_id, **kwargs)
+ logger.info(f"Revoked task {task_result.task_id}")
+ except Exception:
+ logger.exception(f"Failed to revoke task {task_result.task_id}")
+
+
+def _decode_celery_field(value, default):
+ """Decode django-celery-results' stored task_args/task_kwargs to a Python object.
+
+ The backend stores them as a (sometimes double-encoded) repr/JSON string. An
+ empty or missing field returns ``default``; a non-empty value that cannot be
+ decoded raises ``ValueError`` so the caller can avoid re-enqueuing a task with
+ the wrong arguments.
+ """
+ obj = value
+ for _ in range(2): # values can be double-encoded (a string holding a repr)
+ if not isinstance(obj, str):
+ break
+ text = obj.strip()
+ if not text:
+ return default
+ parsed = None
+ for parser in (ast.literal_eval, json.loads):
+ try:
+ parsed = parser(text)
+ break
+ except (ValueError, SyntaxError, TypeError):
+ continue
+ if parsed is None:
+ raise ValueError(f"undecodable celery field: {text[:120]!r}")
+ obj = parsed
+ return default if obj is None else obj
+
+
+def reconcile_orphans(
+ grace_minutes: int = 2,
+ max_attempts: int = 3,
+ window_hours: int = 6,
+ dry_run: bool = False,
+) -> dict:
+ """Run the orphan task sweep under a single-flight advisory lock.
+
+ Returns a recovery summary. A no-op is reported when the lock is not acquired.
+ """
+ with advisory_lock() as acquired:
+ if not acquired:
+ logger.info("Orphan reconcile skipped: another run holds the lock")
+ return {"acquired": False}
+
+ from django.conf import settings
+
+ if settings.TASK_RECOVERY_ENABLED:
+ # Populate the task registry so we can re-enqueue any task by name.
+ import tasks.tasks # noqa: F401
+
+ result = _reconcile_task_results(
+ grace_minutes=grace_minutes,
+ max_attempts=max_attempts,
+ window_hours=window_hours,
+ dry_run=dry_run,
+ )
+ result["enabled"] = True
+ else:
+ logger.info("Orphan task recovery disabled by feature flag")
+ result = {"recovered": [], "failed": [], "skipped": [], "enabled": False}
+
+ return {"acquired": True, **result}
+
+
+def _reconcile_task_results(
+ grace_minutes: int, max_attempts: int, window_hours: int, dry_run: bool
+) -> dict:
+ from django_celery_results.models import TaskResult
+
+ cutoff = datetime.now(tz=UTC) - timedelta(minutes=grace_minutes)
+ candidates = list(
+ TaskResult.objects.filter(status__in=IN_FLIGHT_STATES, date_created__lt=cutoff)
+ .exclude(worker__isnull=True)
+ .exclude(worker="")
+ .exclude(task_name__in=_SKIP_RECOVERY)
+ )
+
+ # Ping each distinct worker at most once.
+ worker_alive = {w: is_worker_alive(w) for w in {tr.worker for tr in candidates}}
+
+ recovered, failed, skipped = [], [], []
+ for task_result in candidates:
+ if worker_alive.get(task_result.worker, True):
+ skipped.append(task_result.task_id) # in flight, do not double-run
+ continue
+ if dry_run:
+ recovered.append(task_result.task_id)
+ continue
+ outcome = _recover_task(task_result, max_attempts, window_hours)
+ (recovered if outcome == "recovered" else failed).append(task_result.task_id)
+
+ logger.info(
+ "Orphan reconcile: recovered=%d failed=%d skipped(in-flight)=%d",
+ len(recovered),
+ len(failed),
+ len(skipped),
+ )
+ return {"recovered": recovered, "failed": failed, "skipped": skipped}
+
+
+def _recovery_attempt_count(name: str, kwargs_repr, window_hours: int) -> int:
+ """Increment and return the recovery count for this (task, kwargs) within the
+ window. Backed by Valkey so it survives result-row churn (a worker processing
+ the revoke can blank the TaskResult fields). Fail-open if Valkey is down (the
+ broker being unreachable means nothing is running anyway).
+ """
+ import hashlib
+
+ from django.conf import settings
+
+ try:
+ import redis
+
+ client = redis.from_url(settings.CELERY_BROKER_URL)
+ signature = f"{name}|{kwargs_repr}".encode()
+ key = (
+ "orphan-recovery:"
+ + hashlib.sha1(signature, usedforsecurity=False).hexdigest()
+ )
+ count = client.incr(key)
+ if count == 1:
+ client.expire(key, max(1, window_hours) * 3600)
+ return int(count)
+ except Exception:
+ logger.exception("Recovery-attempt counter unavailable; allowing recovery")
+ return 1
+
+
+def _recover_task(task_result, max_attempts: int, window_hours: int) -> str:
+ """Recover one orphaned task. Returns 'recovered' or 'failed'."""
+ # Capture name/args/kwargs now: revoking can let a worker blank the row.
+ name = task_result.task_name
+ args_repr = task_result.task_args
+ kwargs_repr = task_result.task_kwargs
+ now = datetime.now(tz=UTC)
+
+ # Drop any future broker redelivery of the stale message.
+ revoke_task(task_result, terminate=False)
+
+ # Mark the stale result terminal so "pending/started forever" alerts clear.
+ task_result.status = states.REVOKED
+ task_result.date_done = now
+ task_result.save(update_fields=["status", "date_done"])
+
+ if name not in reenqueueable_tasks():
+ logger.warning(
+ "Orphan %s (%s) not re-enqueued: not allowlisted for auto recovery",
+ task_result.task_id,
+ name,
+ )
+ return "failed"
+
+ # Count the attempt only once the task is allowlisted, so a task sitting in a
+ # disabled group does not burn its recovery budget while the flag is off (and is
+ # not already over the cap the moment the group is re-enabled).
+ attempt = _recovery_attempt_count(name, kwargs_repr, window_hours)
+ if attempt > max_attempts:
+ logger.warning(
+ "Orphan %s (%s) not re-enqueued: recovery cap reached (%d/%d)",
+ task_result.task_id,
+ name,
+ attempt,
+ max_attempts,
+ )
+ return "failed"
+
+ task_obj = current_app.tasks.get(name)
+ if task_obj is None:
+ logger.error(
+ "Orphan %s: task %s not registered, cannot re-enqueue",
+ task_result.task_id,
+ name,
+ )
+ return "failed"
+
+ try:
+ args = _decode_celery_field(args_repr, [])
+ kwargs = _decode_celery_field(kwargs_repr, {})
+ except ValueError:
+ logger.error(
+ "Orphan %s (%s): could not decode stored args/kwargs, not re-enqueuing",
+ task_result.task_id,
+ name,
+ )
+ return "failed"
+ new_task_id = str(uuid4())
+ task_obj.apply_async(
+ args=list(args) if isinstance(args, (list, tuple)) else [],
+ kwargs=kwargs if isinstance(kwargs, dict) else {},
+ task_id=new_task_id,
+ )
+ logger.info(
+ "Re-enqueued orphan %s (%s) as %s", task_result.task_id, name, new_task_id
+ )
+ return "recovered"
diff --git a/api/src/backend/tasks/jobs/report.py b/api/src/backend/tasks/jobs/report.py
index 4cc3074bcd..c9d63a63ac 100644
--- a/api/src/backend/tasks/jobs/report.py
+++ b/api/src/backend/tasks/jobs/report.py
@@ -1,3 +1,4 @@
+import fcntl
import gc
import os
import re
@@ -7,9 +8,16 @@ from pathlib import Path
from shutil import rmtree
from uuid import UUID
-import fcntl
+from api.db_router import READ_REPLICA_ALIAS, MainRouter
+from api.db_utils import rls_transaction
+from api.models import Provider, Scan, ScanSummary, StateChoices, ThreatScoreSnapshot
from celery.utils.log import get_task_logger
from config.django.base import DJANGO_TMP_OUTPUT_DIRECTORY
+from prowler.lib.check.compliance_models import (
+ Compliance,
+ get_bulk_compliance_frameworks_universal,
+)
+from prowler.lib.outputs.finding import Finding as FindingOutput
from tasks.jobs.export import _generate_compliance_output_directory, _upload_to_s3
from tasks.jobs.reports import (
FRAMEWORK_REGISTRY,
@@ -18,6 +26,7 @@ from tasks.jobs.reports import (
ENSReportGenerator,
NIS2ReportGenerator,
ThreatScoreReportGenerator,
+ build_provider_metadata,
)
from tasks.jobs.threatscore import compute_threatscore_metrics
from tasks.jobs.threatscore_utils import (
@@ -25,13 +34,6 @@ from tasks.jobs.threatscore_utils import (
_get_compliance_check_ids,
)
-from api.db_router import READ_REPLICA_ALIAS, MainRouter
-from api.db_utils import rls_transaction
-from api.models import Provider, Scan, ScanSummary, StateChoices, ThreatScoreSnapshot
-from api.utils import initialize_prowler_provider
-from prowler.lib.check.compliance_models import Compliance
-from prowler.lib.outputs.finding import Finding as FindingOutput
-
logger = get_task_logger(__name__)
STALE_TMP_OUTPUT_MAX_AGE_HOURS = 48
STALE_TMP_OUTPUT_MAX_DELETIONS_PER_RUN = 50
@@ -571,7 +573,7 @@ def generate_csa_report(
Args:
tenant_id: The tenant ID for Row-Level Security context.
scan_id: ID of the scan executed by Prowler.
- compliance_id: ID of the compliance framework (e.g., "csa_ccm_4.0_aws").
+ compliance_id: ID of the compliance framework (e.g., "csa_ccm_4.0").
output_path: Output PDF file path.
provider_id: Provider ID for the scan.
only_failed: If True, only include failed requirements in detailed section.
@@ -839,24 +841,12 @@ def generate_compliance_reports(
tenant_id, scan_id
)
- # Initialize the Prowler provider once for the whole report batch. Each
- # generator used to re-init this in _load_compliance_data, paying the
- # boto3/Azure-SDK construction cost 5 times per scan. The instance is
- # only used by FindingOutput.transform_api_finding to enrich findings,
- # so a single shared instance is correct.
- logger.info("Initializing prowler_provider once for all reports (scan %s)", scan_id)
- try:
- with rls_transaction(tenant_id, using=READ_REPLICA_ALIAS):
- prowler_provider = initialize_prowler_provider(provider_obj)
- except Exception as init_error:
- # If init fails the generators will fall back to lazy init in
- # _load_compliance_data; we just log and continue.
- logger.warning(
- "Could not pre-initialize prowler_provider for scan %s: %s",
- scan_id,
- init_error,
- )
- prowler_provider = None
+ # Build a credential-free provider metadata stub once for the whole
+ # report batch. FindingOutput.transform_api_finding only reads static
+ # attributes (type plus a few identity fields), so reports never decrypt
+ # the ProviderSecret nor construct a cloud SDK session — generation keeps
+ # working after credentials are deleted or invalidated (PROWLER-2145).
+ prowler_provider = build_provider_metadata(provider_obj)
# Create shared findings cache up front so the eviction closure below
# can reference it. Defined BEFORE the closure to avoid the UnboundLocalError
@@ -883,9 +873,11 @@ def generate_compliance_reports(
frameworks_bulk.get(f"nis2_{provider_type}")
)
if generate_csa:
- pending_checks_by_framework["csa"] = _get_compliance_check_ids(
- frameworks_bulk.get(f"csa_ccm_4.0_{provider_type}")
- )
+ # csa_ccm_4.0 lives at the top level, not under compliance/{provider}/.
+ csa_framework = frameworks_bulk.get(
+ "csa_ccm_4.0"
+ ) or get_bulk_compliance_frameworks_universal(provider_type).get("csa_ccm_4.0")
+ pending_checks_by_framework["csa"] = _get_compliance_check_ids(csa_framework)
if generate_cis and latest_cis:
pending_checks_by_framework["cis"] = _get_compliance_check_ids(
frameworks_bulk.get(latest_cis)
@@ -1183,7 +1175,7 @@ def generate_compliance_reports(
if generate_csa:
generated_report_keys.append("csa")
csa_path = output_paths["csa"]
- compliance_id_csa = f"csa_ccm_4.0_{provider_type}"
+ compliance_id_csa = "csa_ccm_4.0"
pdf_path_csa = f"{csa_path}_csa_report.pdf"
logger.info("Generating CSA CCM report with compliance %s", compliance_id_csa)
diff --git a/api/src/backend/tasks/jobs/reports/__init__.py b/api/src/backend/tasks/jobs/reports/__init__.py
index a538416f59..94da8d6524 100644
--- a/api/src/backend/tasks/jobs/reports/__init__.py
+++ b/api/src/backend/tasks/jobs/reports/__init__.py
@@ -98,6 +98,7 @@ from .config import (
from .csa import CSAReportGenerator
from .ens import ENSReportGenerator
from .nis2 import NIS2ReportGenerator
+from .provider_metadata import build_provider_metadata
from .threatscore import ThreatScoreReportGenerator
__all__ = [
@@ -105,6 +106,7 @@ __all__ = [
"BaseComplianceReportGenerator",
"ComplianceData",
"RequirementData",
+ "build_provider_metadata",
"create_pdf_styles",
"get_requirement_metadata",
# Framework-specific generators
diff --git a/api/src/backend/tasks/jobs/reports/base.py b/api/src/backend/tasks/jobs/reports/base.py
index 4180c847d8..574609280a 100644
--- a/api/src/backend/tasks/jobs/reports/base.py
+++ b/api/src/backend/tasks/jobs/reports/base.py
@@ -5,9 +5,18 @@ import time
from abc import ABC, abstractmethod
from contextlib import contextmanager
from dataclasses import dataclass, field
+from types import SimpleNamespace
from typing import Any
+from api.db_router import READ_REPLICA_ALIAS
+from api.db_utils import rls_transaction
+from api.models import Provider, StatusChoices
from celery.utils.log import get_task_logger
+from prowler.lib.check.compliance_models import (
+ Compliance,
+ get_bulk_compliance_frameworks_universal,
+)
+from prowler.lib.outputs.finding import Finding as FindingOutput
from reportlab.lib.enums import TA_CENTER
from reportlab.lib.pagesizes import letter
from reportlab.lib.styles import ParagraphStyle, getSampleStyleSheet
@@ -22,13 +31,6 @@ from tasks.jobs.threatscore_utils import (
_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,
@@ -49,6 +51,7 @@ from .config import (
PADDING_SMALL,
FrameworkConfig,
)
+from .provider_metadata import build_provider_metadata
logger = get_task_logger(__name__)
@@ -175,7 +178,8 @@ class ComplianceData:
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
+ prowler_provider: Credential-free provider metadata stub (see
+ ``build_provider_metadata``)
"""
tenant_id: str
@@ -222,6 +226,46 @@ def get_requirement_metadata(
return None
+def _universal_attributes_to_list(attributes) -> list:
+ """Flatten a universal requirement's ``attributes`` into a list of objects
+ with attribute access. MITRE wraps its list under ``_raw_attributes``."""
+ if isinstance(attributes, dict) and "_raw_attributes" in attributes:
+ entries = attributes.get("_raw_attributes") or []
+ return [
+ SimpleNamespace(**entry) for entry in entries if isinstance(entry, dict)
+ ]
+ if isinstance(attributes, dict):
+ return [SimpleNamespace(**attributes)] if attributes else []
+ return list(attributes or [])
+
+
+def _adapt_universal_to_legacy(framework, provider_type: str) -> SimpleNamespace:
+ """Expose a universal ``ComplianceFramework`` under the legacy ``Compliance``
+ attribute names used by the PDF pipeline."""
+ provider_key = (provider_type or "").lower()
+ requirements = []
+ for requirement in framework.requirements:
+ checks_by_provider = (
+ requirement.checks if isinstance(requirement.checks, dict) else {}
+ )
+ requirements.append(
+ SimpleNamespace(
+ Id=requirement.id,
+ Description=requirement.description or "",
+ Checks=list(checks_by_provider.get(provider_key, [])),
+ Attributes=_universal_attributes_to_list(requirement.attributes),
+ )
+ )
+ return SimpleNamespace(
+ Framework=framework.framework,
+ Name=framework.name,
+ Version=framework.version or "",
+ Description=framework.description or "",
+ Provider=framework.provider or provider_type,
+ Requirements=requirements,
+ )
+
+
# =============================================================================
# PDF Styles Cache
# =============================================================================
@@ -396,10 +440,10 @@ class BaseComplianceReportGenerator(ABC):
provider_obj: Optional pre-fetched Provider object
requirement_statistics: Optional pre-aggregated statistics
findings_cache: Optional pre-loaded findings cache
- prowler_provider: Optional pre-initialized Prowler provider. When
- generating multiple reports for the same scan the master
- function initializes this once and passes it in to avoid
- re-running boto3/Azure-SDK setup per framework.
+ prowler_provider: Optional provider metadata stub (see
+ ``build_provider_metadata``). When generating multiple
+ reports for the same scan the master function builds it
+ once and passes it in.
**kwargs: Additional framework-specific arguments
"""
framework = self.config.display_name
@@ -853,9 +897,9 @@ class BaseComplianceReportGenerator(ABC):
provider_obj: Optional pre-fetched Provider
requirement_statistics: Optional pre-aggregated statistics
findings_cache: Optional pre-loaded findings
- prowler_provider: Optional pre-initialized Prowler provider. When
- the master function initializes it once and passes it in,
- we skip the per-report ``initialize_prowler_provider`` call.
+ prowler_provider: Optional provider metadata stub. When the
+ master function builds it once and passes it in, we skip
+ the per-report ``build_provider_metadata`` call.
Returns:
Aggregated ComplianceData object
@@ -866,12 +910,21 @@ class BaseComplianceReportGenerator(ABC):
provider_obj = Provider.objects.get(id=provider_id)
if prowler_provider is None:
- prowler_provider = initialize_prowler_provider(provider_obj)
+ prowler_provider = build_provider_metadata(provider_obj)
provider_type = provider_obj.provider
- # Load compliance framework
- frameworks_bulk = Compliance.get_bulk(provider_type)
- compliance_obj = frameworks_bulk.get(compliance_id)
+ # Load compliance framework — fall back to the universal loader
+ # for top-level JSONs (e.g. csa_ccm_4.0) that Compliance.get_bulk
+ # does not scan.
+ compliance_obj = Compliance.get_bulk(provider_type).get(compliance_id)
+ if not compliance_obj:
+ universal_framework = get_bulk_compliance_frameworks_universal(
+ provider_type
+ ).get(compliance_id)
+ if universal_framework:
+ compliance_obj = _adapt_universal_to_legacy(
+ universal_framework, provider_type
+ )
if not compliance_obj:
raise ValueError(f"Compliance framework not found: {compliance_id}")
diff --git a/api/src/backend/tasks/jobs/reports/charts.py b/api/src/backend/tasks/jobs/reports/charts.py
index da3f5aae10..7e9ad7ef20 100644
--- a/api/src/backend/tasks/jobs/reports/charts.py
+++ b/api/src/backend/tasks/jobs/reports/charts.py
@@ -2,7 +2,7 @@ import gc
import io
import math
import time
-from typing import Callable
+from collections.abc import Callable
import matplotlib
from celery.utils.log import get_task_logger
diff --git a/api/src/backend/tasks/jobs/reports/cis.py b/api/src/backend/tasks/jobs/reports/cis.py
index 0fbb416a17..8a1cfb6eba 100644
--- a/api/src/backend/tasks/jobs/reports/cis.py
+++ b/api/src/backend/tasks/jobs/reports/cis.py
@@ -3,11 +3,10 @@ import re
from collections import defaultdict
from typing import Any
+from api.models import StatusChoices
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,
diff --git a/api/src/backend/tasks/jobs/reports/components.py b/api/src/backend/tasks/jobs/reports/components.py
index 31b808ec5c..0c15acb4cb 100644
--- a/api/src/backend/tasks/jobs/reports/components.py
+++ b/api/src/backend/tasks/jobs/reports/components.py
@@ -1,5 +1,6 @@
+from collections.abc import Callable
from dataclasses import dataclass
-from typing import Any, Callable
+from typing import Any
from reportlab.lib import colors
from reportlab.lib.styles import ParagraphStyle
diff --git a/api/src/backend/tasks/jobs/reports/csa.py b/api/src/backend/tasks/jobs/reports/csa.py
index c55ed198de..0a53c17cdf 100644
--- a/api/src/backend/tasks/jobs/reports/csa.py
+++ b/api/src/backend/tasks/jobs/reports/csa.py
@@ -1,11 +1,10 @@
from collections import defaultdict
+from api.models import StatusChoices
from celery.utils.log import get_task_logger
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,
diff --git a/api/src/backend/tasks/jobs/reports/ens.py b/api/src/backend/tasks/jobs/reports/ens.py
index 617d4ea59f..44c874bfc1 100644
--- a/api/src/backend/tasks/jobs/reports/ens.py
+++ b/api/src/backend/tasks/jobs/reports/ens.py
@@ -1,13 +1,12 @@
import os
from collections import defaultdict
+from api.models import StatusChoices
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,
diff --git a/api/src/backend/tasks/jobs/reports/nis2.py b/api/src/backend/tasks/jobs/reports/nis2.py
index 4ac5fa3d15..ed936f9571 100644
--- a/api/src/backend/tasks/jobs/reports/nis2.py
+++ b/api/src/backend/tasks/jobs/reports/nis2.py
@@ -1,11 +1,10 @@
import os
from collections import defaultdict
+from api.models import StatusChoices
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,
diff --git a/api/src/backend/tasks/jobs/reports/provider_metadata.py b/api/src/backend/tasks/jobs/reports/provider_metadata.py
new file mode 100644
index 0000000000..632f15c424
--- /dev/null
+++ b/api/src/backend/tasks/jobs/reports/provider_metadata.py
@@ -0,0 +1,124 @@
+from types import SimpleNamespace
+
+from prowler.providers.github.models import GithubIdentityInfo
+
+
+def build_provider_metadata(provider) -> SimpleNamespace:
+ """Build a credential-free stand-in for the Prowler SDK provider.
+
+ ``FindingOutput.transform_api_finding`` only reads static attributes
+ from the provider (``type`` plus a few identity/metadata fields used to
+ label accounts), so compliance reports never need the decrypted
+ ``ProviderSecret`` nor a live cloud SDK session. This builds an object
+ exposing exactly those attributes from the ``Provider`` DB row, which
+ keeps report generation working when the provider secret has been
+ deleted or its credentials are no longer valid (PROWLER-2145).
+
+ Args:
+ provider: The API ``Provider`` model instance (only ``provider``,
+ ``uid`` and ``alias`` are read).
+
+ Returns:
+ A ``SimpleNamespace`` mimicking the SDK provider attributes consumed
+ by ``FindingOutput.transform_api_finding`` / ``generate_output``.
+ """
+ provider_type = provider.provider
+ uid = provider.uid
+ display_name = provider.alias or uid
+
+ # Defaults cover every attribute read unconditionally in
+ # FindingOutput.generate_output (``provider.auth_method`` is accessed
+ # directly for several provider types); identity lookups go through
+ # get_nested_attribute/getattr, which tolerate missing attributes.
+ stub = SimpleNamespace(
+ type=provider_type,
+ auth_method="",
+ identity=SimpleNamespace(),
+ )
+
+ if provider_type == "aws":
+ stub.identity = SimpleNamespace(account=uid)
+ elif provider_type == "azure":
+ stub.identity = SimpleNamespace(
+ identity_type="",
+ identity_id="",
+ tenant_ids=[""],
+ tenant_domain="",
+ subscriptions={uid: display_name},
+ )
+ elif provider_type == "gcp":
+ stub.identity = SimpleNamespace(profile="")
+ stub.projects = {
+ uid: SimpleNamespace(
+ id=uid,
+ name=display_name,
+ labels={},
+ organization=None,
+ )
+ }
+ elif provider_type == "kubernetes":
+ stub.identity = SimpleNamespace(context=uid, cluster=uid)
+ elif provider_type == "m365":
+ stub.identity = SimpleNamespace(
+ identity_type="",
+ identity_id="",
+ tenant_domain=uid,
+ tenant_id="",
+ )
+ elif provider_type == "github":
+ # generate_output assigns account fields only inside
+ # isinstance(identity, Github*IdentityInfo) branches, so the stub
+ # must carry a real GithubIdentityInfo instance.
+ stub.identity = GithubIdentityInfo(
+ account_id=uid,
+ account_name=display_name,
+ account_url="",
+ )
+ elif provider_type == "mongodbatlas":
+ stub.identity = SimpleNamespace(
+ organization_id=uid,
+ organization_name=display_name,
+ )
+ elif provider_type == "iac":
+ stub.provider_uid = uid
+ elif provider_type == "oraclecloud":
+ stub.identity = SimpleNamespace(
+ tenancy_id=uid,
+ tenancy_name=display_name,
+ )
+ elif provider_type == "alibabacloud":
+ stub.identity = SimpleNamespace(
+ identity_arn="",
+ account_id=uid,
+ account_name=display_name,
+ )
+ elif provider_type == "cloudflare":
+ stub.identity = SimpleNamespace(
+ audited_accounts=[uid],
+ accounts=[],
+ )
+ elif provider_type == "openstack":
+ stub.identity = SimpleNamespace(
+ username="",
+ project_id=uid,
+ project_name=display_name,
+ )
+ elif provider_type == "googleworkspace":
+ stub.identity = SimpleNamespace(
+ delegated_user="",
+ customer_id=uid,
+ domain=display_name,
+ )
+ elif provider_type == "vercel":
+ stub.identity = SimpleNamespace(
+ team=None,
+ user_id=uid,
+ username=display_name,
+ )
+ elif provider_type == "okta":
+ stub.identity = SimpleNamespace(
+ org_domain=uid,
+ client_id="",
+ )
+
+ return stub
diff --git a/api/src/backend/tasks/jobs/reports/threatscore.py b/api/src/backend/tasks/jobs/reports/threatscore.py
index e23085b1c3..a71ebde536 100644
--- a/api/src/backend/tasks/jobs/reports/threatscore.py
+++ b/api/src/backend/tasks/jobs/reports/threatscore.py
@@ -1,12 +1,11 @@
import gc
+from api.models import StatusChoices
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,
diff --git a/api/src/backend/tasks/jobs/scan.py b/api/src/backend/tasks/jobs/scan.py
index d4bc2afba3..36dc806ff3 100644
--- a/api/src/backend/tasks/jobs/scan.py
+++ b/api/src/backend/tasks/jobs/scan.py
@@ -1,39 +1,16 @@
import csv
import io
import json
+import random
import re
import time
import uuid
from collections import defaultdict
-from datetime import datetime, timezone
+from collections.abc import Callable, Iterable
+from datetime import UTC, datetime
from typing import Any
import sentry_sdk
-from celery.utils.log import get_task_logger
-from config.django.base import DJANGO_FINDINGS_BATCH_SIZE
-from config.env import env
-from config.settings.celery import CELERY_DEADLOCK_ATTEMPTS
-from django.db import IntegrityError, OperationalError
-from django.db.models import (
- Case,
- Count,
- Exists,
- IntegerField,
- Max,
- Min,
- OuterRef,
- Prefetch,
- Q,
- Sum,
- When,
-)
-from django.utils import timezone as django_timezone
-from tasks.jobs.queries import (
- COMPLIANCE_UPSERT_PROVIDER_SCORE_SQL,
- COMPLIANCE_UPSERT_TENANT_SUMMARY_SQL,
-)
-from tasks.utils import CustomEncoder, batched
-
from api.compliance import PROWLER_COMPLIANCE_OVERVIEW_TEMPLATE
from api.constants import SEVERITY_ORDER
from api.db_router import READ_REPLICA_ALIAS, MainRouter
@@ -42,9 +19,8 @@ from api.db_utils import (
SET_CONFIG_QUERY,
psycopg_connection,
rls_transaction,
- update_objects_in_batches,
)
-from api.exceptions import ProviderConnectionError
+from api.exceptions import ProviderConnectionError, ProviderDeletedException
from api.models import (
AttackSurfaceOverview,
ComplianceOverviewSummary,
@@ -59,6 +35,7 @@ from api.models import (
ResourceFindingMapping,
ResourceScanSummary,
ResourceTag,
+ ResourceTagMapping,
Scan,
ScanCategorySummary,
ScanGroupSummary,
@@ -68,9 +45,33 @@ from api.models import (
from api.models import StatusChoices as FindingStatus
from api.utils import initialize_prowler_provider, return_prowler_provider
from api.v1.serializers import ScanTaskSerializer
+from celery.utils.log import get_task_logger
+from config.django.base import DJANGO_FINDINGS_BATCH_SIZE
+from config.env import env
+from config.settings.celery import CELERY_DEADLOCK_ATTEMPTS
+from django.core.exceptions import ImproperlyConfigured
+from django.db import DatabaseError, IntegrityError, OperationalError, transaction
+from django.db.models import (
+ Case,
+ Count,
+ Exists,
+ IntegerField,
+ Max,
+ Min,
+ OuterRef,
+ Q,
+ Sum,
+ When,
+)
+from django.utils import timezone as django_timezone
from prowler.lib.check.models import CheckMetadata
from prowler.lib.outputs.finding import Finding as ProwlerFinding
from prowler.lib.scan.scan import Scan as ProwlerScan
+from tasks.jobs.queries import (
+ COMPLIANCE_UPSERT_PROVIDER_SCORE_SQL,
+ COMPLIANCE_UPSERT_TENANT_SUMMARY_SQL,
+)
+from tasks.utils import CustomEncoder, batched
logger = get_task_logger(__name__)
@@ -97,8 +98,26 @@ COMPLIANCE_REQUIREMENT_COPY_COLUMNS = (
)
# Controls how many findings we process per micro-batch before flushing to DB writes
FINDINGS_MICRO_BATCH_SIZE = env.int("DJANGO_FINDINGS_MICRO_BATCH_SIZE", default=3000)
-# Controls how many rows each ORM bulk_create/bulk_update call sends to Postgres
-SCAN_DB_BATCH_SIZE = env.int("DJANGO_SCAN_DB_BATCH_SIZE", default=500)
+# Controls how many rows each ORM bulk_create/bulk_update call sends to Postgres.
+SCAN_DB_BATCH_SIZE = env.int("DJANGO_SCAN_DB_BATCH_SIZE", default=1000)
+# Rows per COPY statement when ingesting compliance requirement overviews. All
+# batches of a scan share one transaction/commit; the batch size only bounds the
+# client-side CSV buffer and how long each individual COPY statement runs on the
+# writer (memory footprint, lock time and slow-statement logging under load).
+COMPLIANCE_COPY_BATCH_SIZE = env.int("DJANGO_COMPLIANCE_COPY_BATCH_SIZE", default=2000)
+if COMPLIANCE_COPY_BATCH_SIZE < 1:
+ raise ImproperlyConfigured(
+ "DJANGO_COMPLIANCE_COPY_BATCH_SIZE must be a positive integer, got "
+ f"{COMPLIANCE_COPY_BATCH_SIZE}"
+ )
+# Throttle scan progress persistence: minimum progress delta (fraction 0-1)
+# between two persisted progress updates.
+PROGRESS_THROTTLE_DELTA = env.float("DJANGO_SCAN_PROGRESS_THROTTLE_DELTA", default=0.01)
+# Throttle scan progress persistence: maximum seconds without persisting progress
+# regardless of delta (so slow checks still show progress in the UI).
+PROGRESS_THROTTLE_SECONDS = env.float(
+ "DJANGO_SCAN_PROGRESS_THROTTLE_SECONDS", default=10.0
+)
ATTACK_SURFACE_PROVIDER_COMPATIBILITY = {
"internet-exposed": None, # Compatible with all providers
@@ -110,6 +129,20 @@ ATTACK_SURFACE_PROVIDER_COMPATIBILITY = {
_ATTACK_SURFACE_MAPPING_CACHE: dict[str, dict] = {}
+def _save_scan_instance(
+ scan_instance: Scan, provider_id: str, update_fields: list[str]
+) -> None:
+ try:
+ with transaction.atomic(): # Savepoint for not killing the `rls_transaction`
+ scan_instance.save(update_fields=update_fields)
+ except DatabaseError:
+ if Scan.objects.filter(pk=scan_instance.id).exists():
+ raise
+ raise ProviderDeletedException(
+ f"Provider '{provider_id}' for scan '{scan_instance.id}' was deleted during the scan"
+ ) from None
+
+
def aggregate_category_counts(
categories: list[str],
severity: str,
@@ -261,6 +294,7 @@ def _store_resources(
provider=provider_instance,
uid=finding.resource_uid,
defaults={
+ "name": finding.resource_name,
"region": finding.region,
"service": finding.service_name,
"type": finding.resource_type,
@@ -268,6 +302,7 @@ def _store_resources(
)
if not created:
+ resource_instance.name = finding.resource_name
resource_instance.region = finding.region
resource_instance.service = finding.service_name
resource_instance.type = finding.resource_type
@@ -283,30 +318,85 @@ def _store_resources(
return resource_instance, (resource_instance.uid, resource_instance.region)
-def _copy_compliance_requirement_rows(
- tenant_id: str, rows: list[dict[str, Any]]
+def _bulk_update_resource_failed_findings_counts(
+ tenant_id: str,
+ scan_id: str,
+ resources_to_update: list[Resource],
) -> None:
- """Stream compliance requirement rows into Postgres using COPY.
+ """Persist failed finding counters with stable row locking and retry."""
+ if not resources_to_update:
+ return
- We leverage the admin connection (when available) to bypass the COPY + RLS
- restriction, writing only the fields required by
- ``ComplianceRequirementOverview``.
+ sorted_resources = sorted(
+ resources_to_update, key=lambda resource: str(resource.id)
+ )
+ for start in range(0, len(sorted_resources), SCAN_DB_BATCH_SIZE):
+ chunk = sorted_resources[start : start + SCAN_DB_BATCH_SIZE]
+ chunk_ids = [resource.id for resource in chunk]
- Args:
- tenant_id: Target tenant UUID.
- rows: List of row dictionaries prepared by
- :func:`create_compliance_requirements`.
+ for attempt in range(CELERY_DEADLOCK_ATTEMPTS):
+ try:
+ with rls_transaction(tenant_id):
+ list(
+ Resource.objects.select_for_update()
+ .filter(id__in=chunk_ids)
+ .order_by("id")
+ .values_list("id", flat=True)
+ )
+ Resource.objects.bulk_update(
+ chunk,
+ ["failed_findings_count"],
+ batch_size=SCAN_DB_BATCH_SIZE,
+ )
+ break
+ except OperationalError:
+ if attempt < CELERY_DEADLOCK_ATTEMPTS - 1:
+ logger.warning(
+ "Resource failed findings count update hit a database "
+ "conflict on scan %s. Retrying chunk %s/%s "
+ "(attempt %s/%s).",
+ scan_id,
+ start // SCAN_DB_BATCH_SIZE + 1,
+ (len(sorted_resources) + SCAN_DB_BATCH_SIZE - 1)
+ // SCAN_DB_BATCH_SIZE,
+ attempt + 1,
+ CELERY_DEADLOCK_ATTEMPTS,
+ )
+ time.sleep((0.1 * (2**attempt)) + random.uniform(0, 0.1))
+ continue
+ raise
+
+
+class ComplianceRowScopeError(ValueError):
+ """A compliance requirement row does not belong to the scan being ingested."""
+
+
+def _compliance_requirement_rows_to_csv(
+ rows: list[dict[str, Any]], tenant_id: str, scan_id: str
+) -> io.StringIO:
+ """Serialize compliance requirement rows into a CSV buffer for COPY.
+
+ COPY runs on the admin connection, which bypasses RLS, so every row is
+ checked against the expected tenant/scan before it is written: a mismatched
+ row would otherwise be inserted verbatim into another tenant's data.
"""
-
csv_buffer = io.StringIO()
writer = csv.writer(csv_buffer)
- datetime_now = datetime.now(tz=timezone.utc)
+ datetime_now = datetime.now(tz=UTC)
for row in rows:
+ row_tenant_id = str(row.get("tenant_id"))
+ row_scan_id = str(row.get("scan_id"))
+ if row_tenant_id != tenant_id or row_scan_id != scan_id:
+ raise ComplianceRowScopeError(
+ "Compliance requirement row does not belong to the scan being "
+ f"ingested (expected tenant {tenant_id} / scan {scan_id}, got "
+ f"tenant {row_tenant_id} / scan {row_scan_id})"
+ )
writer.writerow(
[
str(row.get("id")),
- str(row.get("tenant_id")),
+ row_tenant_id,
(row.get("inserted_at") or datetime_now).isoformat(),
row.get("compliance_id") or "",
row.get("framework") or "",
@@ -320,95 +410,171 @@ def _copy_compliance_requirement_rows(
row.get("total_checks", 0),
row.get("passed_findings", 0),
row.get("total_findings", 0),
- str(row.get("scan_id")),
+ row_scan_id,
]
)
csv_buffer.seek(0)
+ return csv_buffer
+
+
+def _copy_compliance_requirement_rows(
+ tenant_id: str, scan_id: str, rows: Iterable[dict[str, Any]], batch_size: int
+) -> int:
+ """Replace a scan's compliance requirement rows using batched COPY.
+
+ We leverage the admin connection (when available) to bypass the COPY + RLS
+ restriction. The scan's DELETE and every COPY batch run on one connection
+ inside a single transaction with a single commit, so the writer takes one
+ fsync per scan instead of one per batch, and a failed ingest rolls back
+ without committing a partial delete/insert (which a retry would otherwise
+ delete again, feeding dead rows to autovacuum).
+
+ Args:
+ tenant_id: Target tenant UUID.
+ scan_id: Scan whose previous rows are replaced.
+ rows: Iterable of row dictionaries, consumed lazily batch by batch.
+ batch_size: Number of rows per COPY statement.
+
+ Returns:
+ int: total number of rows staged and committed.
+
+ Raises:
+ ComplianceRowScopeError: A row belongs to another tenant or scan.
+ """
+ # Normalized once so the per-row scope check compares like with like even if
+ # the caller passes UUID instances instead of strings.
+ tenant_id = str(tenant_id)
+ scan_id = str(scan_id)
+ total_rows = 0
+ batch_num = 0
copy_sql = (
"COPY compliance_requirements_overviews ("
+ ", ".join(COMPLIANCE_REQUIREMENT_COPY_COLUMNS)
+ ") FROM STDIN WITH (FORMAT CSV, DELIMITER ',', QUOTE '\"', ESCAPE '\"', NULL '\\N')"
)
- try:
- with psycopg_connection(MainRouter.admin_db) as connection:
- connection.autocommit = False
- try:
- with connection.cursor() as cursor:
- cursor.execute(SET_CONFIG_QUERY, [POSTGRES_TENANT_VAR, tenant_id])
- cursor.copy_expert(copy_sql, csv_buffer)
- connection.commit()
- except Exception:
- connection.rollback()
- raise
- finally:
- csv_buffer.close()
+ with psycopg_connection(MainRouter.admin_db) as connection:
+ connection.autocommit = False
+ try:
+ with connection.cursor() as cursor:
+ cursor.execute(SET_CONFIG_QUERY, [POSTGRES_TENANT_VAR, tenant_id])
+ # Idempotent re-run: clearing this scan's rows inside the same
+ # transaction keeps delete + reinsert atomic.
+ cursor.execute(
+ "DELETE FROM compliance_requirements_overviews "
+ "WHERE tenant_id = %s AND scan_id = %s",
+ [tenant_id, scan_id],
+ )
+ for batch, _is_last in batched(rows, batch_size):
+ if not batch:
+ continue
+ batch_num += 1
+ csv_buffer = _compliance_requirement_rows_to_csv(
+ batch, tenant_id, scan_id
+ )
+ try:
+ cursor.copy_expert(copy_sql, csv_buffer)
+ finally:
+ csv_buffer.close()
+ total_rows += len(batch)
+ logger.info(
+ f"Compliance COPY batch {batch_num}: staged {len(batch)} rows "
+ f"({total_rows} total)"
+ )
+ connection.commit()
+ except Exception:
+ connection.rollback()
+ raise
+
+ return total_rows
-def _persist_compliance_requirement_rows(
- tenant_id: str, rows: list[dict[str, Any]], batch_size: int = 10000
-) -> None:
- """Persist compliance requirement rows using batched COPY with ORM fallback.
+def _bulk_create_compliance_requirement_rows(
+ tenant_id: str, scan_id: str, rows: Iterable[dict[str, Any]], batch_size: int
+) -> int:
+ """Replace a scan's compliance requirement rows via the ORM.
- Splits large row sets into batches to reduce lock duration and improve concurrency.
-
- Args:
- tenant_id: Target tenant UUID.
- rows: Precomputed row dictionaries that reflect the compliance
- overview state for a scan.
- batch_size: Number of rows per COPY batch (default: 10000).
+ Fallback for when COPY is unavailable; the delete and every ``bulk_create``
+ share one RLS transaction so the replacement stays atomic.
"""
- if not rows:
- return
-
- total_rows = len(rows)
- total_batches = (total_rows + batch_size - 1) // batch_size
-
- try:
- # Process rows in batches to reduce lock duration
- for batch_num in range(total_batches):
- start_idx = batch_num * batch_size
- end_idx = min(start_idx + batch_size, total_rows)
- batch = rows[start_idx:end_idx]
-
- _copy_compliance_requirement_rows(tenant_id, batch)
-
- logger.info(
- f"Compliance COPY batch {batch_num + 1}/{total_batches}: "
- f"inserted {len(batch)} rows ({start_idx + len(batch)}/{total_rows} total)"
- )
- except Exception as error:
- logger.exception(
- "COPY bulk insert for compliance requirements failed; falling back to ORM bulk_create",
- exc_info=error,
- )
- # Fallback: use ORM bulk_create for all remaining rows
- fallback_objects = [
- ComplianceRequirementOverview(
- id=row["id"],
- tenant_id=row["tenant_id"],
- inserted_at=row["inserted_at"],
- compliance_id=row["compliance_id"],
- framework=row["framework"],
- version=row["version"],
- description=row["description"],
- region=row["region"],
- requirement_id=row["requirement_id"],
- requirement_status=row["requirement_status"],
- passed_checks=row["passed_checks"],
- failed_checks=row["failed_checks"],
- total_checks=row["total_checks"],
- passed_findings=row.get("passed_findings", 0),
- total_findings=row.get("total_findings", 0),
- scan_id=row["scan_id"],
- )
- for row in rows
- ]
- with rls_transaction(tenant_id):
+ total_rows = 0
+ with rls_transaction(tenant_id):
+ ComplianceRequirementOverview.objects.filter(scan_id=scan_id).delete()
+ for batch, _is_last in batched(rows, batch_size):
+ if not batch:
+ continue
+ fallback_objects = [
+ ComplianceRequirementOverview(
+ id=row["id"],
+ tenant_id=row["tenant_id"],
+ inserted_at=row["inserted_at"],
+ compliance_id=row["compliance_id"],
+ framework=row["framework"],
+ version=row["version"],
+ description=row["description"],
+ region=row["region"],
+ requirement_id=row["requirement_id"],
+ requirement_status=row["requirement_status"],
+ passed_checks=row["passed_checks"],
+ failed_checks=row["failed_checks"],
+ total_checks=row["total_checks"],
+ passed_findings=row.get("passed_findings", 0),
+ total_findings=row.get("total_findings", 0),
+ scan_id=row["scan_id"],
+ )
+ for row in batch
+ ]
ComplianceRequirementOverview.objects.bulk_create(
fallback_objects, batch_size=500
)
+ total_rows += len(batch)
+ return total_rows
+
+
+def _persist_compliance_requirement_rows(
+ tenant_id: str,
+ scan_id: str,
+ rows_factory: Callable[[], Iterable[dict[str, Any]]],
+ batch_size: int | None = None,
+) -> int:
+ """Persist a scan's compliance requirement rows, replacing any previous ones.
+
+ ``rows_factory`` must return a fresh row iterator on every call: the COPY
+ path consumes it lazily in batches (peak memory ~``batch_size`` rows), and
+ if COPY fails the whole ingest falls back to a single ORM transaction that
+ re-iterates the rows.
+
+ Args:
+ tenant_id: Target tenant UUID.
+ scan_id: Scan whose compliance overview rows are being replaced.
+ rows_factory: Callable returning an iterable of row dictionaries.
+ batch_size: Rows per COPY/bulk_create batch (default:
+ ``COMPLIANCE_COPY_BATCH_SIZE``).
+
+ Returns:
+ int: total number of rows persisted.
+ """
+ if batch_size is None:
+ batch_size = COMPLIANCE_COPY_BATCH_SIZE
+
+ try:
+ return _copy_compliance_requirement_rows(
+ tenant_id, scan_id, rows_factory(), batch_size
+ )
+ except ComplianceRowScopeError:
+ # Cross-tenant/scan rows are a bug in the caller, not a COPY failure:
+ # retrying through the ORM would persist the very rows we rejected.
+ raise
+ except Exception as error:
+ logger.exception(
+ "COPY bulk insert for compliance requirements failed; "
+ "falling back to ORM bulk_create",
+ exc_info=error,
+ )
+ return _bulk_create_compliance_requirement_rows(
+ tenant_id, scan_id, rows_factory(), batch_size
+ )
def _create_compliance_summaries(
@@ -468,9 +634,12 @@ def _create_compliance_summaries(
)
)
- # Bulk insert summaries
- if summary_objects:
- with rls_transaction(tenant_id):
+ # Idempotent re-run: clear this scan's prior summaries before re-inserting, so a
+ # recovered scan-compliance-overviews run reflects its own re-derived rows instead
+ # of keeping a stale one (bulk_create ignore_conflicts alone would keep the old).
+ with rls_transaction(tenant_id):
+ ComplianceOverviewSummary.objects.filter(scan_id=scan_id).delete()
+ if summary_objects:
ComplianceOverviewSummary.objects.bulk_create(
summary_objects, batch_size=500, ignore_conflicts=True
)
@@ -528,16 +697,26 @@ def _process_finding_micro_batch(
"""
# Accumulate objects for bulk operations
findings_to_create = []
- mappings_to_create = []
dirty_resources = {}
+ resources_with_new_tag_mappings: set[str] = set()
resource_denormalized_data = [] # (finding_instance, resource_instance) pairs
+ tag_mappings_to_create: list[ResourceTagMapping] = []
skipped_findings_count = 0 # Track findings skipped due to UID length
- # Prefetch last statuses for all findings in this batch
- # TEMPORARY WORKAROUND: Filter out UIDs > 300 chars to avoid query errors
- finding_uids = [
- f.uid for f in findings_batch if f is not None and len(f.uid) <= 300
- ]
+ # Separate findings into those persistable (uid <= 300) and over-limit.
+ # Resources/tags ARE still resolved for over-limit findings to preserve the
+ # original behavior (resources are persisted even when their finding is dropped).
+ non_null_findings = [f for f in findings_batch if f is not None]
+ persistable_findings = [f for f in non_null_findings if len(f.uid) <= 300]
+ skipped_findings_count = len(non_null_findings) - len(persistable_findings)
+ none_count = len(findings_batch) - len(non_null_findings)
+ if none_count:
+ logger.error(
+ f"{none_count} None finding(s) detected on scan {scan_instance.id}."
+ )
+
+ # Prefetch last statuses for all persistable findings in this batch (read replica)
+ finding_uids = [f.uid for f in persistable_findings]
with rls_transaction(tenant_id, using=READ_REPLICA_ALIAS):
last_statuses = {
item["uid"]: (item["status"], item["first_seen_at"])
@@ -548,281 +727,422 @@ def _process_finding_micro_batch(
.order_by("uid", "-inserted_at")
.distinct("uid")
}
- # Update cache
for uid, data in last_statuses.items():
if uid not in last_status_cache:
last_status_cache[uid] = data
- # Process each finding in the batch
- for finding in findings_batch:
- if finding is None:
- logger.error(f"None finding detected on scan {scan_instance.id}.")
- continue
+ # All DB writes for this micro-batch run inside ONE rls_transaction,
+ # with deadlock-retry at micro-batch granularity instead of per-finding.
+ for attempt in range(CELERY_DEADLOCK_ATTEMPTS):
+ try:
+ with rls_transaction(tenant_id):
+ # 1) Pre-resolve Resources in bulk
+ # Collect all uids referenced by this batch that are not in cache yet.
+ # NOTE: we intentionally include empty-string uids here. The SDK
+ # explicitly emits findings with `resource_uid=""` for some flows
+ # (IaC scans, some Azure/GCP/K8s checks). The original
+ # `get_or_create` behavior was to create/share a Resource with
+ # uid="" for these findings rather than dropping them. Preserve
+ # that behavior; do NOT filter by truthiness.
+ batch_resource_uids: set[str] = set()
+ for f in non_null_findings:
+ if f.resource_uid not in resource_cache:
+ batch_resource_uids.add(f.resource_uid)
- # Process resource with deadlock retry
- for attempt in range(CELERY_DEADLOCK_ATTEMPTS):
- try:
- 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(
+ if batch_resource_uids:
+ existing_resources = {
+ r.uid: r
+ for r in Resource.objects.filter(
tenant_id=tenant_id,
- provider=provider_instance,
- uid=resource_uid,
- defaults={
- "region": finding.region,
- "service": finding.service_name,
- "type": finding.resource_type,
- "name": finding.resource_name,
- "groups": [group] if group else None,
- },
+ provider_id=provider_instance.id,
+ uid__in=batch_resource_uids,
)
- resource_cache[resource_uid] = resource_instance
- resource_failed_findings_cache[resource_uid] = 0
- else:
- resource_instance = resource_cache[resource_uid]
- break
- except (OperationalError, IntegrityError) as db_err:
- if attempt < CELERY_DEADLOCK_ATTEMPTS - 1:
- logger.warning(
- f"{'Deadlock error' if isinstance(db_err, OperationalError) else 'Integrity error'} "
- f"detected when processing resource {resource_uid} on scan {scan_instance.id}. Retrying..."
+ }
+ missing_uids = batch_resource_uids - existing_resources.keys()
+ if missing_uids:
+ # Build defaults from the first finding referencing each uid.
+ first_finding_per_uid: dict[str, ProwlerFinding] = {}
+ for f in non_null_findings:
+ if f.resource_uid in missing_uids:
+ first_finding_per_uid.setdefault(f.resource_uid, f)
+ resources_to_create = []
+ for uid in missing_uids:
+ f = first_finding_per_uid[uid]
+ check_metadata = f.get_metadata()
+ group = check_metadata.get("resourcegroup") or None
+ resources_to_create.append(
+ Resource(
+ tenant_id=tenant_id,
+ provider=provider_instance,
+ uid=uid,
+ region=f.region,
+ service=f.service_name,
+ type=f.resource_type,
+ name=f.resource_name,
+ groups=[group] if group else None,
+ )
+ )
+ Resource.objects.bulk_create(
+ resources_to_create,
+ batch_size=SCAN_DB_BATCH_SIZE,
+ ignore_conflicts=True,
+ unique_fields=["tenant_id", "provider_id", "uid"],
+ )
+ # Re-fetch to obtain instances we just created AND any
+ # created concurrently by another scan against the same provider.
+ existing_resources.update(
+ {
+ r.uid: r
+ for r in Resource.objects.filter(
+ tenant_id=tenant_id,
+ provider_id=provider_instance.id,
+ uid__in=missing_uids,
+ )
+ }
+ )
+ for uid, r in existing_resources.items():
+ resource_cache[uid] = r
+ resource_failed_findings_cache.setdefault(uid, 0)
+
+ # 2) Pre-resolve ResourceTags in bulk
+ batch_tag_kv: set[tuple[str, str]] = set()
+ for f in non_null_findings:
+ for k, v in f.resource_tags.items():
+ if (k, v) not in tag_cache:
+ batch_tag_kv.add((k, v))
+
+ if batch_tag_kv:
+ keys_to_query = {k for k, _ in batch_tag_kv}
+ existing_tags = {
+ (t.key, t.value): t
+ for t in ResourceTag.objects.filter(
+ tenant_id=tenant_id, key__in=keys_to_query
+ )
+ if (t.key, t.value) in batch_tag_kv
+ }
+ missing_kv = batch_tag_kv - existing_tags.keys()
+ if missing_kv:
+ ResourceTag.objects.bulk_create(
+ [
+ ResourceTag(tenant_id=tenant_id, key=k, value=v)
+ for k, v in missing_kv
+ ],
+ batch_size=SCAN_DB_BATCH_SIZE,
+ ignore_conflicts=True,
+ unique_fields=["tenant_id", "key", "value"],
+ )
+ existing_tags.update(
+ {
+ (t.key, t.value): t
+ for t in ResourceTag.objects.filter(
+ tenant_id=tenant_id,
+ key__in={k for k, _ in missing_kv},
+ )
+ if (t.key, t.value) in missing_kv
+ }
+ )
+ tag_cache.update(existing_tags)
+
+ # 3) Per-finding in-memory processing
+ for finding in non_null_findings:
+ resource_uid = finding.resource_uid
+ resource_instance = resource_cache.get(resource_uid)
+ if resource_instance is None:
+ # Should be unreachable after the pre-resolve step. Defensive log.
+ logger.error(
+ f"Resource {resource_uid} missing from cache after pre-resolve "
+ f"on scan {scan_instance.id}; skipping finding."
+ )
+ continue
+
+ # Detect resource field changes (defer save until end-of-batch bulk_update).
+ check_metadata = finding.get_metadata()
+ group = check_metadata.get("resourcegroup") or None
+ updated = False
+ if finding.region and resource_instance.region != finding.region:
+ resource_instance.region = finding.region
+ updated = True
+ if (
+ finding.resource_name
+ and resource_instance.name != finding.resource_name
+ ):
+ resource_instance.name = finding.resource_name
+ updated = True
+ if resource_instance.service != finding.service_name:
+ resource_instance.service = finding.service_name
+ updated = True
+ if resource_instance.type != finding.resource_type:
+ resource_instance.type = finding.resource_type
+ updated = True
+ if resource_instance.metadata != finding.resource_metadata:
+ resource_instance.metadata = json.dumps(
+ finding.resource_metadata, cls=CustomEncoder
+ )
+ updated = True
+ if resource_instance.details != finding.resource_details:
+ resource_instance.details = finding.resource_details
+ updated = True
+ 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
+
+ # Accumulate ResourceTagMapping rows; bulk_create at end of block.
+ for k, v in finding.resource_tags.items():
+ tag_instance = tag_cache.get((k, v))
+ if tag_instance is None:
+ # Should not happen after pre-resolve; skip defensively.
+ continue
+ tag_mappings_to_create.append(
+ ResourceTagMapping(
+ tenant_id=tenant_id,
+ resource=resource_instance,
+ tag=tag_instance,
+ )
+ )
+
+ unique_resources.add(
+ (resource_instance.uid, resource_instance.region)
)
- time.sleep(0.1 * (2**attempt))
- continue
- else:
- raise db_err
- # 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
- if resource_instance.service != finding.service_name:
- resource_instance.service = finding.service_name
- updated = True
- if resource_instance.type != finding.resource_type:
- resource_instance.type = finding.resource_type
- updated = True
- if resource_instance.metadata != finding.resource_metadata:
- resource_instance.metadata = json.dumps(
- finding.resource_metadata, cls=CustomEncoder
- )
- updated = True
- if resource_instance.details != finding.resource_details:
- resource_instance.details = finding.resource_details
- updated = True
- 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
+ # TEMPORARY WORKAROUND: Skip findings with UID > 300 chars
+ # TODO: Remove this after implementing text field migration for finding.uid
+ if len(finding.uid) > 300:
+ logger.warning(
+ f"Skipping finding with UID exceeding 300 characters. "
+ f"Length: {len(finding.uid)}, "
+ f"Check: {finding.check_id}, "
+ f"Resource: {finding.resource_name}, "
+ f"UID: {finding.uid}"
+ )
+ continue
- if updated:
- dirty_resources[resource_uid] = resource_instance
-
- # Process tags
- tags = []
- with rls_transaction(tenant_id):
- for key, value in finding.resource_tags.items():
- tag_key = (key, value)
- if tag_key not in tag_cache:
- tag_instance, _ = ResourceTag.objects.get_or_create(
- tenant_id=tenant_id, key=key, value=value
+ finding_uid = finding.uid
+ last_status, last_first_seen_at = last_status_cache.get(
+ finding_uid, (None, None)
)
- tag_cache[tag_key] = tag_instance
- else:
- tag_instance = tag_cache[tag_key]
- tags.append(tag_instance)
- resource_instance.upsert_or_delete_tags(tags=tags)
- unique_resources.add((resource_instance.uid, resource_instance.region))
+ status = FindingStatus[finding.status]
+ delta = _create_finding_delta(last_status, status)
- # Prepare finding data
- finding_uid = finding.uid
+ if not last_first_seen_at:
+ last_first_seen_at = datetime.now(tz=UTC)
- # TEMPORARY WORKAROUND: Skip findings with UID > 300 chars
- # TODO: Remove this after implementing text field migration for finding.uid
- if len(finding_uid) > 300:
- skipped_findings_count += 1
- logger.warning(
- f"Skipping finding with UID exceeding 300 characters. "
- f"Length: {len(finding_uid)}, "
- f"Check: {finding.check_id}, "
- f"Resource: {finding.resource_name}, "
- f"UID: {finding_uid}"
- )
- continue
+ # Determine if finding should be muted and why
+ # Priority: mutelist processor (highest) > manual mute rules
+ is_muted = False
+ muted_reason = None
+ if finding.muted:
+ is_muted = True
+ muted_reason = "Muted by mutelist"
+ elif finding_uid in mute_rules_cache:
+ is_muted = True
+ muted_reason = mute_rules_cache[finding_uid]
- last_status, last_first_seen_at = last_status_cache.get(
- finding_uid, (None, None)
- )
+ if status == FindingStatus.FAIL and not is_muted:
+ resource_failed_findings_cache[resource_uid] += 1
- status = FindingStatus[finding.status]
- delta = _create_finding_delta(last_status, status)
+ check_metadata["compliance"] = finding.compliance
+ finding_instance = Finding(
+ tenant_id=tenant_id,
+ uid=finding_uid,
+ delta=delta,
+ check_metadata=check_metadata,
+ status=status,
+ status_extended=finding.status_extended,
+ severity=finding.severity,
+ impact=finding.severity,
+ raw_result=finding.raw,
+ check_id=finding.check_id,
+ scan=scan_instance,
+ first_seen_at=last_first_seen_at,
+ muted=is_muted,
+ muted_at=datetime.now(tz=UTC) if is_muted else None,
+ muted_reason=muted_reason,
+ compliance=finding.compliance,
+ categories=check_metadata.get("categories", []) or [],
+ resource_groups=check_metadata.get("resourcegroup") or None,
+ # Denormalized resource arrays populated directly on insert
+ # (was previously a separate bulk_update; saves a CASE WHEN
+ # over thousands of rows per micro-batch).
+ resource_regions=(
+ [resource_instance.region]
+ if resource_instance.region
+ else []
+ ),
+ resource_services=(
+ [resource_instance.service]
+ if resource_instance.service
+ else []
+ ),
+ resource_types=(
+ [resource_instance.type] if resource_instance.type else []
+ ),
+ )
+ findings_to_create.append(finding_instance)
+ resource_denormalized_data.append(
+ (finding_instance, resource_instance)
+ )
- if not last_first_seen_at:
- last_first_seen_at = datetime.now(tz=timezone.utc)
+ scan_resource_cache.add(
+ (
+ str(resource_instance.id),
+ resource_instance.service,
+ resource_instance.region,
+ resource_instance.type,
+ )
+ )
- # Determine if finding should be muted and why
- # Priority: mutelist processor (highest) > manual mute rules
- is_muted = False
- muted_reason = None
+ aggregate_category_counts(
+ categories=check_metadata.get("categories", []) or [],
+ severity=finding.severity.value,
+ status=status.value,
+ delta=delta.value if delta else None,
+ muted=is_muted,
+ cache=scan_categories_cache,
+ )
- # Check mutelist processor first (highest priority)
- if finding.muted:
- is_muted = True
- muted_reason = "Muted by mutelist"
- # If not muted by mutelist, check manual mute rules
- elif finding_uid in mute_rules_cache:
- is_muted = True
- muted_reason = mute_rules_cache[finding_uid]
+ 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,
+ )
- # Increment failed_findings_count cache if needed
- if status == FindingStatus.FAIL and not is_muted:
- resource_failed_findings_cache[resource_uid] += 1
+ # 4) Bulk create ResourceTagMappings
+ # Replaces the original per-resource `upsert_or_delete_tags`
+ # (which did one `update_or_create` + SELECT FOR UPDATE per mapping).
+ if tag_mappings_to_create:
+ # Pre-SELECT existing pairs: `bulk_create(ignore_conflicts=True)`
+ # does not populate `pk`, so we cannot tell new vs existing from
+ # the result; we need that to bump `updated_at` only on resources
+ # that actually gain a mapping.
+ candidate_resource_ids = {
+ m.resource_id for m in tag_mappings_to_create
+ }
+ candidate_tag_ids = {m.tag_id for m in tag_mappings_to_create}
+ existing_pairs = set(
+ ResourceTagMapping.objects.filter(
+ tenant_id=tenant_id,
+ resource_id__in=candidate_resource_ids,
+ tag_id__in=candidate_tag_ids,
+ ).values_list("resource_id", "tag_id")
+ )
+ resource_uid_by_id = {
+ str(r.id): uid for uid, r in resource_cache.items()
+ }
+ for m in tag_mappings_to_create:
+ if (m.resource_id, m.tag_id) not in existing_pairs:
+ uid = resource_uid_by_id.get(str(m.resource_id))
+ if uid is not None:
+ resources_with_new_tag_mappings.add(uid)
- # Create finding object (don't save yet)
- check_metadata = finding.get_metadata()
- check_metadata["compliance"] = finding.compliance
- finding_instance = Finding(
- tenant_id=tenant_id,
- uid=finding_uid,
- delta=delta,
- check_metadata=check_metadata,
- status=status,
- status_extended=finding.status_extended,
- severity=finding.severity,
- impact=finding.severity,
- raw_result=finding.raw,
- check_id=finding.check_id,
- scan=scan_instance,
- first_seen_at=last_first_seen_at,
- muted=is_muted,
- muted_at=datetime.now(tz=timezone.utc) if is_muted else None,
- 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))
+ ResourceTagMapping.objects.bulk_create(
+ tag_mappings_to_create,
+ batch_size=SCAN_DB_BATCH_SIZE,
+ ignore_conflicts=True,
+ unique_fields=["tenant_id", "resource_id", "tag_id"],
+ )
- # Track for scan summary
- scan_resource_cache.add(
- (
- str(resource_instance.id),
- resource_instance.service,
- resource_instance.region,
- resource_instance.type,
- )
- )
+ # 5) Bulk create Findings
+ if findings_to_create:
+ Finding.objects.bulk_create(
+ findings_to_create, batch_size=SCAN_DB_BATCH_SIZE
+ )
- # Track categories with counts for ScanCategorySummary by (category, severity)
- aggregate_category_counts(
- categories=check_metadata.get("categories", []) or [],
- severity=finding.severity.value,
- status=status.value,
- delta=delta.value if delta else None,
- muted=is_muted,
- cache=scan_categories_cache,
- )
+ # 6) Bulk create ResourceFindingMapping rows
+ mappings_to_create = [
+ ResourceFindingMapping(
+ tenant_id=tenant_id,
+ resource=resource_instance,
+ finding=finding_instance,
+ )
+ for finding_instance, resource_instance in resource_denormalized_data
+ ]
+ if mappings_to_create:
+ created_mappings = ResourceFindingMapping.objects.bulk_create(
+ mappings_to_create,
+ batch_size=SCAN_DB_BATCH_SIZE,
+ ignore_conflicts=True,
+ unique_fields=["tenant_id", "resource_id", "finding_id"],
+ )
+ inserted = sum(1 for m in created_mappings if m.pk)
+ if inserted != len(mappings_to_create):
+ logger.error(
+ f"scan {scan_instance.id}: expected "
+ f"{len(mappings_to_create)} ResourceFindingMapping rows, "
+ f"inserted {inserted}. Rolling back micro-batch."
+ )
- # 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
- if findings_to_create:
- Finding.objects.bulk_create(
- findings_to_create, batch_size=SCAN_DB_BATCH_SIZE
- )
-
- # Bulk create resource-finding mappings
- for finding_instance, resource_instance in resource_denormalized_data:
- mappings_to_create.append(
- ResourceFindingMapping(
- tenant_id=tenant_id,
- resource=resource_instance,
- finding=finding_instance,
+ # 7) Bulk update Resources
+ # Union of:
+ # - resources whose fields changed (dirty_resources)
+ # - resources that got new tag mappings (need updated_at bump,
+ # preserving the original `self.save(update_fields=["updated_at"])`
+ # behavior of `upsert_or_delete_tags`)
+ all_resource_uids_to_touch = (
+ set(dirty_resources.keys()) | resources_with_new_tag_mappings
)
- )
-
- if mappings_to_create:
- created_mappings = ResourceFindingMapping.objects.bulk_create(
- mappings_to_create,
- batch_size=SCAN_DB_BATCH_SIZE,
- ignore_conflicts=True,
- unique_fields=["tenant_id", "resource_id", "finding_id"],
- )
- inserted = sum(1 for m in created_mappings if m.pk)
- if inserted != len(mappings_to_create):
- logger.error(
- f"scan {scan_instance.id}: expected "
- f"{len(mappings_to_create)} ResourceFindingMapping rows, "
- f"inserted {inserted}. Rolling back micro-batch."
+ if all_resource_uids_to_touch:
+ now_utc = datetime.now(tz=UTC)
+ resources_to_bulk_update = []
+ for uid in all_resource_uids_to_touch:
+ # Use the instance from dirty_resources if present (has mutated
+ # fields), otherwise the cached one (for updated_at bump only).
+ r = dirty_resources.get(uid) or resource_cache.get(uid)
+ if r is None:
+ continue
+ # Manually bump updated_at since bulk_update bypasses auto_now.
+ r.updated_at = now_utc
+ resources_to_bulk_update.append(r)
+ if resources_to_bulk_update:
+ Resource.objects.bulk_update(
+ resources_to_bulk_update,
+ [
+ "name",
+ "metadata",
+ "details",
+ "partition",
+ "region",
+ "service",
+ "type",
+ "groups",
+ "updated_at",
+ ],
+ batch_size=1000,
+ )
+ # Successful execution: leave deadlock retry loop.
+ break
+ except (OperationalError, IntegrityError) as db_err:
+ if attempt < CELERY_DEADLOCK_ATTEMPTS - 1:
+ logger.warning(
+ f"{'Deadlock error' if isinstance(db_err, OperationalError) else 'Integrity error'} "
+ f"on micro-batch for scan {scan_instance.id}. Retrying (attempt {attempt + 1})..."
)
-
- # Update finding denormalized arrays
- findings_to_update = []
- for finding_instance, resource_instance in resource_denormalized_data:
- if not finding_instance.resource_regions:
- finding_instance.resource_regions = []
- if not finding_instance.resource_services:
- finding_instance.resource_services = []
- if not finding_instance.resource_types:
- finding_instance.resource_types = []
-
- if resource_instance.region not in finding_instance.resource_regions:
- finding_instance.resource_regions.append(resource_instance.region)
- if resource_instance.service not in finding_instance.resource_services:
- finding_instance.resource_services.append(resource_instance.service)
- if resource_instance.type not in finding_instance.resource_types:
- finding_instance.resource_types.append(resource_instance.type)
-
- findings_to_update.append(finding_instance)
-
- if findings_to_update:
- Finding.objects.bulk_update(
- findings_to_update,
- ["resource_regions", "resource_services", "resource_types"],
- batch_size=SCAN_DB_BATCH_SIZE,
- )
-
- # Bulk update dirty resources
- if dirty_resources:
- update_objects_in_batches(
- tenant_id=tenant_id,
- model=Resource,
- objects=list(dirty_resources.values()),
- fields=[
- "metadata",
- "details",
- "partition",
- "region",
- "service",
- "type",
- "groups",
- ],
- batch_size=1000,
- )
+ time.sleep(0.1 * (2**attempt))
+ # Clear accumulators that we appended to inside the failed transaction
+ # so the retry produces consistent results.
+ findings_to_create.clear()
+ resource_denormalized_data.clear()
+ tag_mappings_to_create.clear()
+ dirty_resources.clear()
+ resources_with_new_tag_mappings.clear()
+ continue
+ raise
# Log skipped findings summary
if skipped_findings_count > 0:
@@ -867,13 +1187,18 @@ def perform_prowler_scan(
group_resources_cache: dict[str, set] = {}
start_time = time.time()
exc = None
+ skip_final_scan_update = False
with rls_transaction(tenant_id):
provider_instance = Provider.objects.get(pk=provider_id)
scan_instance = Scan.objects.get(pk=scan_id)
scan_instance.state = StateChoices.EXECUTING
- scan_instance.started_at = datetime.now(tz=timezone.utc)
- scan_instance.save()
+ scan_instance.started_at = datetime.now(tz=UTC)
+ _save_scan_instance(
+ scan_instance,
+ provider_id,
+ ["state", "started_at", "updated_at"],
+ )
# Find the mutelist processor if it exists
with rls_transaction(tenant_id, using=READ_REPLICA_ALIAS):
@@ -915,10 +1240,14 @@ def perform_prowler_scan(
f"Provider {provider_instance.provider} is not connected: {e}"
)
finally:
- provider_instance.connection_last_checked_at = datetime.now(
- tz=timezone.utc
+ provider_instance.connection_last_checked_at = datetime.now(tz=UTC)
+ provider_instance.save(
+ update_fields=[
+ "connected",
+ "connection_last_checked_at",
+ "updated_at",
+ ]
)
- provider_instance.save()
# If the provider is not connected, raise an exception outside the transaction.
# If raised within the transaction, the transaction will be rolled back and the provider will not be marked
@@ -933,6 +1262,13 @@ def perform_prowler_scan(
last_status_cache = {}
resource_failed_findings_cache = defaultdict(int)
+ # Throttle scan_instance progress writes to avoid hammering the writer:
+ # only persist when progress moves by at least `PROGRESS_THROTTLE_DELTA`
+ # OR `PROGRESS_THROTTLE_SECONDS` have elapsed. The final progress (100)
+ # always persists in the `finally` block below.
+ last_persisted_progress = -1.0
+ last_persisted_progress_at = 0.0
+
for progress, findings in prowler_scan.scan():
# Process findings in micro-batches
findings_list = list(findings)
@@ -959,10 +1295,24 @@ def perform_prowler_scan(
group_resources_cache=group_resources_cache,
)
- # Update scan progress
- with rls_transaction(tenant_id):
- scan_instance.progress = progress
- scan_instance.save()
+ # Throttled progress save (the final save in the `finally` block
+ # below always runs regardless of throttle).
+ now = time.time()
+ progress_delta = progress - last_persisted_progress
+ elapsed = now - last_persisted_progress_at
+ if (
+ progress_delta >= PROGRESS_THROTTLE_DELTA
+ or elapsed >= PROGRESS_THROTTLE_SECONDS
+ ):
+ with rls_transaction(tenant_id):
+ scan_instance.progress = progress
+ _save_scan_instance(
+ scan_instance,
+ provider_id,
+ ["progress", "updated_at"],
+ )
+ last_persisted_progress = progress
+ last_persisted_progress_at = now
scan_instance.state = StateChoices.COMPLETED
@@ -976,25 +1326,45 @@ def perform_prowler_scan(
resources_to_update.append(resource_instance)
if resources_to_update:
- update_objects_in_batches(
+ _bulk_update_resource_failed_findings_counts(
tenant_id=tenant_id,
- model=Resource,
- objects=resources_to_update,
- fields=["failed_findings_count"],
- batch_size=1000,
+ scan_id=scan_id,
+ resources_to_update=resources_to_update,
)
+ except ProviderDeletedException as e:
+ logger.warning(str(e))
+ exception = e
+ skip_final_scan_update = True
except Exception as e:
logger.error(f"Error performing scan {scan_id}: {e}")
exception = e
scan_instance.state = StateChoices.FAILED
finally:
- with rls_transaction(tenant_id):
- scan_instance.duration = time.time() - start_time
- scan_instance.completed_at = datetime.now(tz=timezone.utc)
- scan_instance.unique_resource_count = len(unique_resources)
- scan_instance.save()
+ if not skip_final_scan_update:
+ try:
+ with rls_transaction(tenant_id):
+ scan_instance.duration = time.time() - start_time
+ scan_instance.completed_at = datetime.now(tz=UTC)
+ scan_instance.unique_resource_count = len(unique_resources)
+ if exception is None:
+ scan_instance.progress = 100
+ _save_scan_instance(
+ scan_instance,
+ provider_id,
+ [
+ "state",
+ "duration",
+ "completed_at",
+ "unique_resource_count",
+ "progress",
+ "updated_at",
+ ],
+ )
+ except ProviderDeletedException as e:
+ logger.warning(str(e))
+ exception = e
if exception is not None:
raise exception
@@ -1188,7 +1558,7 @@ def aggregate_findings(tenant_id: str, scan_id: str):
)
with rls_transaction(tenant_id):
- scan_aggregations = {
+ scan_aggregations = [
ScanSummary(
tenant_id=tenant_id,
scan_id=scan_id,
@@ -1213,9 +1583,18 @@ def aggregate_findings(tenant_id: str, scan_id: str):
for agg in aggregation
if agg["resources__service"] is not None
and agg["resources__region"] is not None
- }
- # Upsert so re-runs (post-mute reaggregation) don't trip
- # `unique_scan_summary`; race-safe under concurrent writers.
+ ]
+ # Needed sort so concurrent upserts acquire locks consistently
+ scan_aggregations.sort(
+ key=lambda summary: (
+ summary.tenant_id,
+ summary.scan_id,
+ summary.check_id,
+ summary.service,
+ summary.severity,
+ summary.region,
+ )
+ )
ScanSummary.objects.bulk_create(
scan_aggregations,
batch_size=3000,
@@ -1250,9 +1629,13 @@ def _aggregate_findings_by_region(
tenant_id: str, scan_id: str, modeled_threatscore_compliance_id: str
) -> tuple[dict, dict]:
"""
- Aggregate findings by region using optimized ORM queries.
+ Aggregate findings by region using streaming, column-scoped ORM reads.
- Replaces nested Python loops with efficient queries and aggregation.
+ Reads only the consumed columns as tuples via ``values_list`` and streams
+ them with ``.iterator()``, using the denormalized ``resource_regions`` array
+ instead of ``prefetch_related("resources")``. ``resource_regions`` mirrors the
+ regions of a finding's related resources, so it yields the same per-region
+ tally without joining the resource table.
Args:
tenant_id: Tenant UUID
@@ -1264,12 +1647,12 @@ def _aggregate_findings_by_region(
- check_status_by_region: {region: {check_id: status}}
- findings_count_by_compliance: {region: {normalized_id: {requirement_id: {total, pass}}}}
"""
- check_status_by_region = {}
- findings_count_by_compliance = {}
+ check_status_by_region: dict = {}
+ findings_count_by_compliance: dict = {}
+
+ normalized_id = re.sub(r"[^a-z0-9]", "", modeled_threatscore_compliance_id.lower())
with rls_transaction(tenant_id, using=READ_REPLICA_ALIAS):
- # Fetch only PASS/FAIL findings (optimized query reduces data transfer)
- # Other statuses are not needed for check_status or ThreatScore calculation
findings = (
Finding.all_objects.filter(
tenant_id=tenant_id,
@@ -1277,42 +1660,28 @@ def _aggregate_findings_by_region(
muted=False,
status__in=["PASS", "FAIL"],
)
- .only("id", "check_id", "status", "compliance")
- .prefetch_related(
- Prefetch(
- "resources",
- queryset=Resource.objects.only("id", "region"),
- to_attr="small_resources",
- )
+ .values_list("check_id", "status", "resource_regions", "compliance")
+ .iterator(chunk_size=DJANGO_FINDINGS_BATCH_SIZE)
+ )
+
+ for check_id, status, resource_regions, compliance in findings:
+ threatscore_requirements = (compliance or {}).get(
+ modeled_threatscore_compliance_id
)
- )
- # Process findings in a single pass (more efficient than original nested loops)
- normalized_id = re.sub(
- r"[^a-z0-9]", "", modeled_threatscore_compliance_id.lower()
- )
-
- for finding in findings:
- status = finding.status
-
- for resource in finding.small_resources:
- region = resource.region
-
- # Aggregate check status by region
- current_status = check_status_by_region.setdefault(region, {})
+ for region in resource_regions or ():
# Priority: FAIL > any other status
- if current_status.get(finding.check_id) != "FAIL":
- current_status[finding.check_id] = status
+ current_status = check_status_by_region.setdefault(region, {})
+ if current_status.get(check_id) != "FAIL":
+ current_status[check_id] = status
# Aggregate ThreatScore compliance counts
- if modeled_threatscore_compliance_id in (finding.compliance or {}):
+ if threatscore_requirements:
compliance_key = findings_count_by_compliance.setdefault(
region, {}
).setdefault(normalized_id, {})
- for requirement_id in finding.compliance[
- modeled_threatscore_compliance_id
- ]:
+ for requirement_id in threatscore_requirements:
requirement_stats = compliance_key.setdefault(
requirement_id, {"total": 0, "pass": 0}
)
@@ -1359,8 +1728,8 @@ def create_compliance_requirements(tenant_id: str, scan_id: str):
(compliance_id, requirement_id)
)
- compliance_requirement_rows: list[dict[str, Any]] = []
regions = []
+ requirements_created = 0
requirement_statuses = defaultdict(
lambda: {"fail_count": 0, "pass_count": 0, "total_count": 0}
)
@@ -1400,44 +1769,95 @@ def create_compliance_requirements(tenant_id: str, scan_id: str):
else:
requirement_stats["failed_checks"] += 1
- # Prepare compliance requirement rows and compute summaries in single pass
- utc_datetime_now = datetime.now(tz=timezone.utc)
-
- # Pre-compute shared strings (optimization: reduces string conversions)
+ utc_datetime_now = datetime.now(tz=UTC)
tenant_id_str = str(tenant_id)
scan_id_str = str(scan_instance.id)
- for region in regions:
- region_stats = region_requirement_stats.get(region, {})
- for compliance_id, compliance in compliance_template.items():
- modeled_compliance_id = _normalized_compliance_key(
- compliance["framework"], compliance["version"]
+ # Per-framework constants that don't depend on the region.
+ compliance_plan = []
+ for compliance_id, compliance in compliance_template.items():
+ modeled_compliance_id = _normalized_compliance_key(
+ compliance["framework"], compliance["version"]
+ )
+ framework = compliance["framework"]
+ version = compliance["version"] or ""
+ requirements = [
+ (
+ requirement_id,
+ requirement.get("description") or "",
+ len(requirement["checks"]),
)
- compliance_stats = region_stats.get(compliance_id, {})
- # Create an overview record for each requirement within each compliance framework
for requirement_id, requirement in compliance[
"requirements"
- ].items():
- stats = compliance_stats.get(requirement_id)
- passed_checks = stats["passed_checks"] if stats else 0
- failed_checks = stats["failed_checks"] if stats else 0
- total_checks = len(requirement["checks"])
- if total_checks == 0:
- requirement_status = "MANUAL"
- elif failed_checks > 0:
- requirement_status = "FAIL"
- else:
- requirement_status = "PASS"
+ ].items()
+ ]
+ compliance_plan.append(
+ (
+ compliance_id,
+ framework,
+ version,
+ modeled_compliance_id,
+ requirements,
+ )
+ )
- compliance_requirement_rows.append(
- {
+ # Yield rows lazily (consumed batch-by-batch by COPY) so peak memory
+ # stays bounded; tally requirement_statuses in the same pass. The
+ # ORM fallback re-iterates from scratch, so the tally resets first.
+ def _iter_compliance_requirement_rows():
+ requirement_statuses.clear()
+ for region in regions:
+ region_stats = region_requirement_stats.get(region, {})
+ region_findings = findings_count_by_compliance.get(region, {})
+ for (
+ compliance_id,
+ framework,
+ version,
+ modeled_compliance_id,
+ requirements,
+ ) in compliance_plan:
+ compliance_stats = region_stats.get(compliance_id, {})
+ compliance_findings = region_findings.get(
+ modeled_compliance_id, {}
+ )
+ for requirement_id, description, total_checks in requirements:
+ stats = compliance_stats.get(requirement_id)
+ if stats:
+ passed_checks = stats["passed_checks"]
+ failed_checks = stats["failed_checks"]
+ else:
+ passed_checks = 0
+ failed_checks = 0
+ if total_checks == 0:
+ requirement_status = "MANUAL"
+ elif failed_checks > 0:
+ requirement_status = "FAIL"
+ else:
+ requirement_status = "PASS"
+
+ finding_counts = compliance_findings.get(requirement_id)
+ if finding_counts:
+ passed_findings = finding_counts.get("pass", 0)
+ total_findings = finding_counts.get("total", 0)
+ else:
+ passed_findings = 0
+ total_findings = 0
+
+ key = (compliance_id, requirement_id)
+ requirement_statuses[key]["total_count"] += 1
+ if requirement_status == "FAIL":
+ requirement_statuses[key]["fail_count"] += 1
+ elif requirement_status == "PASS":
+ requirement_statuses[key]["pass_count"] += 1
+
+ yield {
"id": uuid.uuid4(),
"tenant_id": tenant_id_str,
"inserted_at": utc_datetime_now,
"compliance_id": compliance_id,
- "framework": compliance["framework"],
- "version": compliance["version"] or "",
- "description": requirement.get("description") or "",
+ "framework": framework,
+ "version": version,
+ "description": description,
"region": region,
"requirement_id": requirement_id,
"requirement_status": requirement_status,
@@ -1445,37 +1865,21 @@ def create_compliance_requirements(tenant_id: str, scan_id: str):
"failed_checks": failed_checks,
"total_checks": total_checks,
"scan_id": scan_id_str,
- "passed_findings": findings_count_by_compliance.get(
- region, {}
- )
- .get(modeled_compliance_id, {})
- .get(requirement_id, {})
- .get("pass", 0),
- "total_findings": findings_count_by_compliance.get(
- region, {}
- )
- .get(modeled_compliance_id, {})
- .get(requirement_id, {})
- .get("total", 0),
+ "passed_findings": passed_findings,
+ "total_findings": total_findings,
}
- )
- # Update summary tracking (single-pass optimization)
- key = (compliance_id, requirement_id)
- requirement_statuses[key]["total_count"] += 1
- if requirement_status == "FAIL":
- requirement_statuses[key]["fail_count"] += 1
- elif requirement_status == "PASS":
- requirement_statuses[key]["pass_count"] += 1
-
- # Bulk create requirement records using PostgreSQL COPY
- _persist_compliance_requirement_rows(tenant_id, compliance_requirement_rows)
+ # The delete of the scan's previous rows happens inside the same
+ # transaction as the inserts (see _copy_compliance_requirement_rows).
+ requirements_created = _persist_compliance_requirement_rows(
+ tenant_id_str, scan_id_str, _iter_compliance_requirement_rows
+ )
# Create pre-aggregated summaries for fast compliance overview lookups
_create_compliance_summaries(tenant_id, scan_id, requirement_statuses)
return {
- "requirements_created": len(compliance_requirement_rows),
+ "requirements_created": requirements_created,
"regions_processed": list(regions),
"compliance_frameworks": (
list(compliance_template.keys()) if regions else []
@@ -1850,9 +2254,7 @@ def aggregate_finding_group_summaries(tenant_id: str, scan_id: str):
summary_timestamp = scan.completed_at
if django_timezone.is_naive(summary_timestamp):
- summary_timestamp = django_timezone.make_aware(
- summary_timestamp, timezone.utc
- )
+ summary_timestamp = django_timezone.make_aware(summary_timestamp, UTC)
summary_timestamp = summary_timestamp.replace(
hour=0, minute=0, second=0, microsecond=0
)
diff --git a/api/src/backend/tasks/jobs/threatscore.py b/api/src/backend/tasks/jobs/threatscore.py
index a9a7516e55..663c179ea2 100644
--- a/api/src/backend/tasks/jobs/threatscore.py
+++ b/api/src/backend/tasks/jobs/threatscore.py
@@ -1,14 +1,13 @@
+from api.db_router import READ_REPLICA_ALIAS
+from api.db_utils import rls_transaction
+from api.models import Provider, StatusChoices
from celery.utils.log import get_task_logger
+from prowler.lib.check.compliance_models import Compliance
from tasks.jobs.threatscore_utils import (
_aggregate_requirement_statistics_from_database,
_calculate_requirements_data_from_statistics,
)
-from api.db_router import READ_REPLICA_ALIAS
-from api.db_utils import rls_transaction
-from api.models import Provider, StatusChoices
-from prowler.lib.check.compliance_models import Compliance
-
logger = get_task_logger(__name__)
diff --git a/api/src/backend/tasks/jobs/threatscore_utils.py b/api/src/backend/tasks/jobs/threatscore_utils.py
index 7be32c6ade..2f968749dd 100644
--- a/api/src/backend/tasks/jobs/threatscore_utils.py
+++ b/api/src/backend/tasks/jobs/threatscore_utils.py
@@ -1,13 +1,12 @@
+from api.db_router import READ_REPLICA_ALIAS
+from api.db_utils import rls_transaction
+from api.models import Finding, Scan, StatusChoices
from celery.utils.log import get_task_logger
from config.django.base import DJANGO_FINDINGS_BATCH_SIZE
from django.db.models import Count, F, Q, Window
from django.db.models.functions import RowNumber
-from tasks.jobs.reports.config import MAX_FINDINGS_PER_CHECK
-
-from api.db_router import READ_REPLICA_ALIAS
-from api.db_utils import rls_transaction
-from api.models import Finding, Scan, StatusChoices
from prowler.lib.outputs.finding import Finding as FindingOutput
+from tasks.jobs.reports.config import MAX_FINDINGS_PER_CHECK
logger = get_task_logger(__name__)
@@ -179,7 +178,9 @@ def _load_findings_for_requirement_checks(
tenant_id (str): The tenant ID for Row-Level Security context.
scan_id (str): The ID of the scan to retrieve findings for.
check_ids (list[str]): List of check IDs to load findings for.
- prowler_provider: The initialized Prowler provider instance.
+ prowler_provider: Credential-free provider metadata stub (see
+ ``tasks.jobs.reports.build_provider_metadata``) consumed by
+ ``FindingOutput.transform_api_finding``.
findings_cache (dict, optional): Cache of already loaded findings.
If provided, checks are first looked up in cache before querying database.
total_counts_out (dict, optional): If provided, populated with
@@ -359,35 +360,40 @@ def _load_findings_for_requirement_checks(
def _get_compliance_check_ids(compliance_obj) -> set[str]:
"""Return the union of all check_ids referenced by a compliance framework.
- Used by the master report orchestrator to know which checks each
- framework consumes from the shared ``findings_cache``, so that once a
- framework finishes the entries no other pending framework needs can be
- evicted from the cache (PROWLER-1733).
+ Used by the master report orchestrator to evict entries from
+ ``findings_cache`` once no pending framework needs them (PROWLER-1733).
- Args:
- compliance_obj: A loaded Compliance framework object exposing a
- ``Requirements`` iterable, each requirement carrying ``Checks``.
- ``None`` is treated as "no checks" rather than raising, so the
- caller can pass ``frameworks_bulk.get(...)`` directly without
- an extra existence check.
-
- Returns:
- Set of check_id strings (empty if ``compliance_obj`` is ``None``).
+ Accepts the legacy ``Compliance`` shape (``Requirements`` / ``Checks``
+ lists) and the universal ``ComplianceFramework`` shape (``requirements``
+ / ``checks`` dict keyed by provider). ``None`` returns an empty set so
+ callers can pass ``frameworks_bulk.get(...)`` directly.
"""
if compliance_obj is None:
return set()
- checks: set[str] = set()
- requirements = getattr(compliance_obj, "Requirements", None) or []
+
+ requirements = getattr(compliance_obj, "Requirements", None) or getattr(
+ compliance_obj, "requirements", None
+ )
+ if not requirements:
+ return set()
+
+ check_ids: set[str] = set()
try:
- # Defensive: Mock objects (used in unit tests) return another Mock
- # for any attribute access, which is truthy but not iterable. Treat
- # any non-iterable Requirements value as "no checks".
- for req in requirements:
- req_checks = getattr(req, "Checks", None) or []
+ # Mock objects in unit tests return another Mock for any attribute
+ # access — truthy but not iterable. Treat that as "no checks".
+ for requirement in requirements:
+ requirement_checks = getattr(requirement, "Checks", None)
+ if requirement_checks is None:
+ checks_by_provider = getattr(requirement, "checks", None) or {}
+ requirement_checks = [
+ check_id
+ for check_ids_list in checks_by_provider.values()
+ for check_id in check_ids_list
+ ]
try:
- checks.update(req_checks)
+ check_ids.update(requirement_checks)
except TypeError:
continue
except TypeError:
return set()
- return checks
+ return check_ids
diff --git a/api/src/backend/tasks/tasks.py b/api/src/backend/tasks/tasks.py
index 0fda2999a9..a91ff85c01 100644
--- a/api/src/backend/tasks/tasks.py
+++ b/api/src/backend/tasks/tasks.py
@@ -1,13 +1,41 @@
import os
-from datetime import datetime, timedelta, timezone
+from datetime import UTC, datetime, timedelta
from pathlib import Path
from shutil import rmtree
+from uuid import uuid4
-from celery import chain, group, shared_task
+from api.compliance import (
+ get_compliance_frameworks,
+ get_prowler_provider_compliance,
+)
+from api.db_router import READ_REPLICA_ALIAS
+from api.db_utils import delete_related_daily_task, rls_transaction
+from api.decorators import handle_provider_deletion, set_tenant
+from api.exceptions import ProviderDeletedException
+from api.models import (
+ Finding,
+ Integration,
+ Provider,
+ Scan,
+ ScanSummary,
+ StateChoices,
+ Task,
+)
+from api.utils import initialize_prowler_provider
+from api.v1.serializers import ScanTaskSerializer
+from celery import chain, group, shared_task, states
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.db import transaction
from django_celery_beat.models import PeriodicTask
+from django_celery_results.models import TaskResult
+from prowler.lib.check.compliance_models import Compliance
+from prowler.lib.outputs.compliance.compliance import (
+ process_universal_compliance_frameworks,
+)
+from prowler.lib.outputs.compliance.generic.generic import GenericCompliance
+from prowler.lib.outputs.finding import Finding as FindingOutput
from tasks.jobs.attack_paths import (
attack_paths_scan,
can_provider_run_attack_paths_scan,
@@ -15,13 +43,13 @@ from tasks.jobs.attack_paths import (
from tasks.jobs.attack_paths import db_utils as attack_paths_db_utils
from tasks.jobs.attack_paths.cleanup import cleanup_stale_attack_paths_scans
from tasks.jobs.backfill import (
+ aggregate_scan_category_summaries,
+ aggregate_scan_resource_group_summaries,
backfill_compliance_summaries,
backfill_daily_severity_summaries,
backfill_finding_group_summaries,
backfill_provider_compliance_scores,
backfill_resource_scan_summaries,
- aggregate_scan_category_summaries,
- aggregate_scan_resource_group_summaries,
)
from tasks.jobs.connection import (
check_integration_connection,
@@ -46,6 +74,7 @@ from tasks.jobs.lighthouse_providers import (
refresh_lighthouse_provider_models,
)
from tasks.jobs.muting import mute_historical_findings
+from tasks.jobs.orphan_recovery import reconcile_orphans
from tasks.jobs.report import (
STALE_TMP_OUTPUT_MAX_AGE_HOURS,
_cleanup_stale_tmp_output_directories,
@@ -67,19 +96,221 @@ from tasks.utils import (
get_next_execution_datetime,
)
-from api.compliance import get_compliance_frameworks
-from api.db_router import READ_REPLICA_ALIAS
-from api.db_utils import delete_related_daily_task, rls_transaction
-from api.decorators import handle_provider_deletion, set_tenant
-from api.models import Finding, Integration, Provider, Scan, ScanSummary, StateChoices
-from api.utils import initialize_prowler_provider
-from api.v1.serializers import ScanTaskSerializer
-from prowler.lib.check.compliance_models import Compliance
-from prowler.lib.outputs.compliance.generic.generic import GenericCompliance
-from prowler.lib.outputs.finding import Finding as FindingOutput
-
-
logger = get_task_logger(__name__)
+QUEUED_SCAN_TASK_STATE = "QUEUED"
+DISPATCHED_SCAN_TASK_STATES = (states.PENDING, states.STARTED, "PROGRESS")
+
+
+def _get_dispatched_provider_scan(tenant_id: str, provider_id: str):
+ """Return a scan that has already been dispatched for a provider."""
+ executing_scan = (
+ Scan.objects.select_for_update()
+ .filter(
+ tenant_id=tenant_id,
+ provider_id=provider_id,
+ state=StateChoices.EXECUTING,
+ )
+ .order_by("-inserted_at")
+ .first()
+ )
+ if executing_scan:
+ return executing_scan
+
+ return (
+ Scan.objects.select_for_update(of=("self",))
+ .select_related("task__task_runner_task")
+ .filter(
+ tenant_id=tenant_id,
+ provider_id=provider_id,
+ state__in=(StateChoices.AVAILABLE, StateChoices.SCHEDULED),
+ task__isnull=False,
+ task__task_runner_task__status__in=DISPATCHED_SCAN_TASK_STATES,
+ )
+ .order_by("-inserted_at")
+ .first()
+ )
+
+
+def _get_queued_provider_scan(tenant_id: str, provider_id: str):
+ """Return the next DB-queued scan for a provider."""
+ return (
+ Scan.objects.select_for_update(of=("self",))
+ .select_related("task__task_runner_task")
+ .filter(
+ tenant_id=tenant_id,
+ provider_id=provider_id,
+ state=StateChoices.AVAILABLE,
+ task__isnull=False,
+ task__task_runner_task__status=QUEUED_SCAN_TASK_STATE,
+ )
+ .order_by("inserted_at", "id")
+ .first()
+ )
+
+
+def get_active_provider_scan(tenant_id: str, provider_id: str):
+ """Return a dispatched or DB-queued scan for a provider."""
+ return _get_dispatched_provider_scan(
+ tenant_id, provider_id
+ ) or _get_queued_provider_scan(tenant_id, provider_id)
+
+
+def create_scan_task_record(
+ tenant_id: str,
+ task_id: str,
+ task_name: str = "scan-perform",
+ task_status: str | None = states.PENDING,
+) -> Task:
+ if task_status is None:
+ task_status = states.PENDING
+
+ task_result, _ = TaskResult.objects.update_or_create(
+ task_id=str(task_id),
+ defaults={"status": task_status, "task_name": task_name},
+ )
+ prowler_task, _ = Task.objects.update_or_create(
+ id=str(task_id),
+ tenant_id=tenant_id,
+ defaults={"task_runner_task": task_result},
+ )
+ return prowler_task
+
+
+def enqueue_scan_execution_on_commit(
+ tenant_id: str,
+ scan: Scan,
+ task_id: str,
+) -> None:
+ transaction.on_commit(
+ lambda: perform_scan_task.apply_async(
+ kwargs={
+ "tenant_id": str(tenant_id),
+ "scan_id": str(scan.id),
+ "provider_id": str(scan.provider_id),
+ },
+ task_id=str(task_id),
+ )
+ )
+
+
+def _get_queued_scheduled_scan(tenant_id: str, provider_id: str):
+ return (
+ Scan.objects.select_for_update(of=("self",))
+ .select_related("task__task_runner_task")
+ .filter(
+ tenant_id=tenant_id,
+ provider_id=provider_id,
+ trigger=Scan.TriggerChoices.SCHEDULED,
+ state=StateChoices.AVAILABLE,
+ task__isnull=False,
+ task__task_runner_task__status=QUEUED_SCAN_TASK_STATE,
+ )
+ .order_by("inserted_at", "id")
+ .first()
+ )
+
+
+def _get_or_create_queued_scheduled_scan(
+ tenant_id: str,
+ provider_id: str,
+ periodic_task_instance: PeriodicTask,
+ scheduled_at: datetime,
+) -> Scan:
+ queued_scan = _get_queued_scheduled_scan(tenant_id, provider_id)
+ if queued_scan:
+ return queued_scan
+
+ task_id = str(uuid4())
+ queued_task = create_scan_task_record(
+ tenant_id=tenant_id,
+ task_id=task_id,
+ task_status=QUEUED_SCAN_TASK_STATE,
+ )
+ return Scan.objects.create(
+ tenant_id=tenant_id,
+ name="Daily scheduled scan",
+ provider_id=provider_id,
+ trigger=Scan.TriggerChoices.SCHEDULED,
+ state=StateChoices.AVAILABLE,
+ scheduled_at=scheduled_at,
+ scheduler_task_id=periodic_task_instance.id,
+ task=queued_task,
+ )
+
+
+def _dispatch_next_queued_provider_scan(tenant_id: str, provider_id: str):
+ with rls_transaction(tenant_id):
+ if not Provider.objects.select_for_update().filter(pk=provider_id).exists():
+ return None
+
+ if _get_dispatched_provider_scan(tenant_id, provider_id):
+ return None
+
+ queued_scan = _get_queued_provider_scan(tenant_id, provider_id)
+ if not queued_scan or not queued_scan.task:
+ return None
+
+ task_result = queued_scan.task.task_runner_task
+ task_result.status = states.PENDING
+ task_result.task_name = "scan-perform"
+ task_result.save(update_fields=["status", "task_name"])
+ enqueue_scan_execution_on_commit(
+ tenant_id=tenant_id,
+ scan=queued_scan,
+ task_id=str(queued_scan.task_id),
+ )
+ return queued_scan
+
+
+def _dispatch_next_queued_provider_scan_best_effort(
+ tenant_id: str, provider_id: str
+) -> None:
+ try:
+ _dispatch_next_queued_provider_scan(tenant_id, provider_id)
+ except Exception:
+ logger.exception(
+ "Failed to dispatch next queued scan for provider %s", provider_id
+ )
+
+
+def _get_or_create_next_scheduled_scan(
+ tenant_id: str,
+ provider_id: str,
+ periodic_task_instance: PeriodicTask,
+ next_scan_datetime: datetime,
+) -> Scan:
+ interval = periodic_task_instance.interval
+ now = datetime.now(UTC)
+ while next_scan_datetime <= now:
+ next_scan_datetime += timedelta(**{interval.period: interval.every})
+
+ return _get_or_create_scheduled_scan(
+ tenant_id=tenant_id,
+ provider_id=provider_id,
+ scheduler_task_id=periodic_task_instance.id,
+ scheduled_at=next_scan_datetime,
+ update_state=True,
+ )
+
+
+def _ensure_next_scheduled_scan_best_effort(
+ tenant_id: str,
+ provider_id: str,
+ periodic_task_instance: PeriodicTask,
+ next_scan_datetime: datetime,
+) -> None:
+ try:
+ with rls_transaction(tenant_id):
+ _get_or_create_next_scheduled_scan(
+ tenant_id=tenant_id,
+ provider_id=provider_id,
+ periodic_task_instance=periodic_task_instance,
+ next_scan_datetime=next_scan_datetime,
+ )
+ except Exception:
+ logger.exception(
+ "Failed to ensure next scheduled scan for provider %s", provider_id
+ )
def _cleanup_orphan_scheduled_scans(
@@ -112,6 +343,7 @@ def _cleanup_orphan_scheduled_scans(
trigger=Scan.TriggerChoices.SCHEDULED,
state=StateChoices.AVAILABLE,
scheduler_task_id=scheduler_task_id,
+ task__isnull=True,
)
scheduled_scan_exists = Scan.objects.filter(
@@ -253,7 +485,9 @@ def delete_provider_task(provider_id: str, tenant_id: str):
return delete_provider(tenant_id=tenant_id, pk=provider_id)
-@shared_task(base=RLSTask, name="scan-perform", queue="scans")
+# acks_late=False: a re-run would duplicate findings and the task is not auto-recovered,
+# so a crashed scan is dropped rather than redelivered by the broker (as before #11416).
+@shared_task(base=RLSTask, name="scan-perform", queue="scans", acks_late=False)
@handle_provider_deletion
def perform_scan_task(
tenant_id: str, scan_id: str, provider_id: str, checks_to_execute: list[str] = None
@@ -285,19 +519,27 @@ def perform_scan_task(
)
return None
- result = perform_prowler_scan(
- tenant_id=tenant_id,
- scan_id=scan_id,
- provider_id=provider_id,
- checks_to_execute=checks_to_execute,
- )
-
- _perform_scan_complete_tasks(tenant_id, scan_id, provider_id)
-
- return result
+ try:
+ result = perform_prowler_scan(
+ tenant_id=tenant_id,
+ scan_id=scan_id,
+ provider_id=provider_id,
+ checks_to_execute=checks_to_execute,
+ )
+ _perform_scan_complete_tasks(tenant_id, scan_id, provider_id)
+ return result
+ finally:
+ _dispatch_next_queued_provider_scan_best_effort(tenant_id, provider_id)
-@shared_task(base=RLSTask, bind=True, name="scan-perform-scheduled", queue="scans")
+# acks_late=False: like scan-perform; a dropped run is re-fired by Beat on the next tick.
+@shared_task(
+ base=RLSTask,
+ bind=True,
+ name="scan-perform-scheduled",
+ queue="scans",
+ acks_late=False,
+)
@handle_provider_deletion
def perform_scheduled_scan_task(self, tenant_id: str, provider_id: str):
"""
@@ -321,7 +563,7 @@ def perform_scheduled_scan_task(self, tenant_id: str, provider_id: str):
task_id = self.request.id
with rls_transaction(tenant_id):
- if not Provider.objects.filter(pk=provider_id).exists():
+ if not Provider.objects.select_for_update().filter(pk=provider_id).exists():
logger.warning(
"scheduled scan-perform skipped: provider %s no longer exists "
"(tenant=%s)",
@@ -334,22 +576,6 @@ 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}"
)
- executing_scan = (
- Scan.objects.filter(
- tenant_id=tenant_id,
- provider_id=provider_id,
- trigger=Scan.TriggerChoices.SCHEDULED,
- state=StateChoices.EXECUTING,
- )
- .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,
@@ -374,6 +600,26 @@ def perform_scheduled_scan_task(self, tenant_id: str, provider_id: str):
scheduler_task_id=periodic_task_instance.id,
)
+ active_scan = get_active_provider_scan(tenant_id, provider_id)
+ if active_scan:
+ logger.warning(
+ "Scan already queued or executing for provider %s. Queueing scheduled run.",
+ provider_id,
+ )
+ queued_scheduled_scan = _get_or_create_queued_scheduled_scan(
+ tenant_id=tenant_id,
+ provider_id=provider_id,
+ periodic_task_instance=periodic_task_instance,
+ scheduled_at=current_scan_datetime,
+ )
+ _get_or_create_next_scheduled_scan(
+ tenant_id=tenant_id,
+ provider_id=provider_id,
+ periodic_task_instance=periodic_task_instance,
+ next_scan_datetime=next_scan_datetime,
+ )
+ return ScanTaskSerializer(instance=queued_scheduled_scan).data
+
scan_instance = _get_or_create_scheduled_scan(
tenant_id=tenant_id,
provider_id=provider_id,
@@ -389,24 +635,16 @@ def perform_scheduled_scan_task(self, tenant_id: str, provider_id: str):
scan_id=str(scan_instance.id),
provider_id=provider_id,
)
+ _perform_scan_complete_tasks(tenant_id, str(scan_instance.id), provider_id)
+ return result
finally:
- with rls_transaction(tenant_id):
- 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,
- provider_id=provider_id,
- 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)
-
- return result
+ _ensure_next_scheduled_scan_best_effort(
+ tenant_id=tenant_id,
+ provider_id=provider_id,
+ periodic_task_instance=periodic_task_instance,
+ next_scan_datetime=next_scan_datetime,
+ )
+ _dispatch_next_queued_provider_scan_best_effort(tenant_id, provider_id)
@shared_task(name="scan-summary", queue="overview")
@@ -429,7 +667,13 @@ class AttackPathsScanRLSTask(RLSTask):
scan_id = kwargs.get("scan_id")
if tenant_id and scan_id:
- logger.error(f"Attack paths scan task {task_id} failed: {exc}")
+ if isinstance(exc, ProviderDeletedException):
+ logger.warning(
+ f"Attack paths scan task {task_id} stopped because its provider "
+ f"or tenant was deleted: {exc}"
+ )
+ else:
+ logger.error(f"Attack paths scan task {task_id} failed: {exc}")
attack_paths_db_utils.fail_attack_paths_scan(tenant_id, scan_id, str(exc))
@@ -462,13 +706,42 @@ def cleanup_stale_attack_paths_scans_task():
return cleanup_stale_attack_paths_scans()
+@shared_task(name="reconcile-orphan-tasks", queue="celery")
+def reconcile_orphan_tasks_task():
+ """Periodic watchdog: recover tasks whose worker is gone (deploys, crashes)."""
+ return reconcile_orphans()
+
+
@shared_task(name="tenant-deletion", queue="deletion", autoretry_for=(Exception,))
def delete_tenant_task(tenant_id: str):
return delete_tenant(pk=tenant_id)
+def _scan_tmp_output_directory(tenant_id: str, scan_id: str) -> Path:
+ """Root tmp output directory for a scan ({tmp}/{tenant_id}/{scan_id})."""
+ return Path(DJANGO_TMP_OUTPUT_DIRECTORY) / str(tenant_id) / str(scan_id)
+
+
+class ScanReportRLSTask(RLSTask):
+ """
+ RLS task that removes the scan's tmp output directory when the task fails.
+
+ Covers failures both inside and outside the task body (e.g. ENOSPC mid-write,
+ or setup errors) so partial artifacts do not accumulate on the worker disk.
+ """
+
+ def on_failure(self, exc, task_id, args, kwargs, _einfo): # noqa: ARG002
+ del args # Required by Celery's Task.on_failure signature; not used.
+ tenant_id = kwargs.get("tenant_id")
+ scan_id = kwargs.get("scan_id")
+
+ if tenant_id and scan_id:
+ logger.error(f"Scan report task {task_id} failed: {exc}")
+ rmtree(_scan_tmp_output_directory(tenant_id, scan_id), ignore_errors=True)
+
+
@shared_task(
- base=RLSTask,
+ base=ScanReportRLSTask,
name="scan-report",
queue="scan-reports",
)
@@ -513,11 +786,23 @@ def generate_outputs_task(scan_id: str, provider_id: str, tenant_id: str):
provider_uid = provider_obj.uid
provider_type = provider_obj.provider
+ # Per-framework exporters in `COMPLIANCE_CLASS_MAP` consume the legacy bulk.
frameworks_bulk = Compliance.get_bulk(provider_type)
+ # Universal-only frameworks (top-level JSONs like `dora_2022_2554.json`) are emitted
+ # via `process_universal_compliance_frameworks` below.
+ universal_bulk = get_prowler_provider_compliance(provider_type)
+ universal_only_names = {
+ name
+ for name in universal_bulk
+ if name not in frameworks_bulk and universal_bulk[name].outputs
+ }
frameworks_avail = get_compliance_frameworks(provider_type)
out_dir, comp_dir = _generate_output_directory(
DJANGO_TMP_OUTPUT_DIRECTORY, provider_uid, tenant_id, scan_id
)
+ # Removed on success here and on failure by ScanReportRLSTask.on_failure,
+ # so partial artifacts do not accumulate and fill the disk (ENOSPC).
+ scan_tmp_dir = _scan_tmp_output_directory(tenant_id, scan_id)
def get_writer(writer_map, name, factory, is_last):
"""
@@ -535,6 +820,10 @@ def generate_outputs_task(scan_id: str, provider_id: str, tenant_id: str):
output_writers = {}
compliance_writers = {}
+ # Shared across batches so universal writers are created once and reused.
+ universal_compliance_state: dict[str, list] = {"compliance": []}
+ universal_base_dir = os.path.dirname(out_dir)
+ universal_output_filename = os.path.basename(out_dir)
scan_summary = FindingOutput._transform_findings_stats(
ScanSummary.objects.filter(scan_id=scan_id)
@@ -589,8 +878,30 @@ def generate_outputs_task(scan_id: str, provider_id: str, tenant_id: str):
writer.batch_write_data_to_file(**extra)
writer._data.clear()
- # Compliance CSVs
+ # Universal-only frameworks (e.g. `dora_2022_2554.json`).
+ if universal_only_names:
+ process_universal_compliance_frameworks(
+ input_compliance_frameworks=universal_only_names,
+ universal_frameworks=universal_bulk,
+ finding_outputs=fos,
+ output_directory=universal_base_dir,
+ output_filename=universal_output_filename,
+ provider=provider_type,
+ generated_outputs=universal_compliance_state,
+ from_cli=False,
+ is_last=is_last,
+ )
+
+ # Compliance CSVs (per-framework exporters).
for name in frameworks_avail:
+ if name in universal_only_names:
+ continue
+ if name not in frameworks_bulk:
+ logger.warning(
+ "Compliance framework '%s' missing from bulk; skipping CSV export",
+ name,
+ )
+ continue
compliance_obj = frameworks_bulk[name]
klass = GenericCompliance
@@ -666,7 +977,7 @@ def generate_outputs_task(scan_id: str, provider_id: str, tenant_id: str):
# TODO: We need to create a new periodic task to delete the output files
# This task shouldn't be responsible for deleting the output files
try:
- rmtree(Path(compressed).parent, ignore_errors=True)
+ rmtree(scan_tmp_dir, ignore_errors=True)
except Exception as e:
logger.error(f"Error deleting output files: {e}")
final_location, did_upload = upload_uri, True
@@ -1077,10 +1388,13 @@ def security_hub_integration_task(
return upload_security_hub_integration(tenant_id, provider_id, scan_id)
+# acks_late=False: Jira sends are not deduplicated and the task is not auto-recovered,
+# so a crashed send is dropped rather than redelivered (avoids duplicate Jira issues).
@shared_task(
base=RLSTask,
name="integration-jira",
queue="integrations",
+ acks_late=False,
)
def jira_integration_task(
tenant_id: str,
diff --git a/api/src/backend/tasks/tests/report_test_helpers.py b/api/src/backend/tasks/tests/report_test_helpers.py
new file mode 100644
index 0000000000..18d7b0e2b6
--- /dev/null
+++ b/api/src/backend/tasks/tests/report_test_helpers.py
@@ -0,0 +1,62 @@
+import io
+import struct
+import zlib
+from types import ModuleType, SimpleNamespace
+from typing import Any
+
+PNG_SIGNATURE = b"\x89PNG\r\n\x1a\n"
+
+
+def _png_chunk(chunk_type: bytes, data: bytes) -> bytes:
+ checksum = zlib.crc32(chunk_type + data) & 0xFFFFFFFF
+ return (
+ struct.pack(">I", len(data)) + chunk_type + data + struct.pack(">I", checksum)
+ )
+
+
+def _build_tiny_png() -> bytes:
+ ihdr = struct.pack(">IIBBBBB", 1, 1, 8, 2, 0, 0, 0)
+ # Filter byte 0 plus one white RGB pixel.
+ idat = zlib.compress(b"\x00\xff\xff\xff")
+ return (
+ PNG_SIGNATURE
+ + _png_chunk(b"IHDR", ihdr)
+ + _png_chunk(b"IDAT", idat)
+ + _png_chunk(b"IEND", b"")
+ )
+
+
+_TINY_PNG_BYTES = _build_tiny_png()
+
+
+def fake_png_buffer() -> io.BytesIO:
+ return io.BytesIO(_TINY_PNG_BYTES)
+
+
+def patch_chart_helpers(
+ monkeypatch: Any, module: ModuleType, names: tuple[str, ...]
+) -> dict[str, list[dict[str, Any]]]:
+ calls: dict[str, list[dict[str, Any]]] = {name: [] for name in names}
+
+ def _build_fake_chart(name: str):
+ def _fake_chart(*args: Any, **kwargs: Any) -> io.BytesIO:
+ calls[name].append({"args": args, "kwargs": kwargs})
+ return fake_png_buffer()
+
+ return _fake_chart
+
+ for name in names:
+ monkeypatch.setattr(module, name, _build_fake_chart(name))
+
+ return calls
+
+
+def patch_report_gc(monkeypatch: Any) -> None:
+ from tasks.jobs import report as report_module
+ from tasks.jobs.reports import base as base_report_module
+ from tasks.jobs.reports import threatscore as threatscore_report_module
+
+ gc_stub = SimpleNamespace(collect=lambda: 0)
+ monkeypatch.setattr(report_module, "gc", gc_stub)
+ monkeypatch.setattr(base_report_module, "gc", gc_stub)
+ monkeypatch.setattr(threatscore_report_module, "gc", gc_stub)
diff --git a/api/src/backend/tasks/tests/test_attack_paths_aws.py b/api/src/backend/tasks/tests/test_attack_paths_aws.py
new file mode 100644
index 0000000000..dc2c59d614
--- /dev/null
+++ b/api/src/backend/tasks/tests/test_attack_paths_aws.py
@@ -0,0 +1,102 @@
+from types import SimpleNamespace
+from unittest.mock import MagicMock, patch
+
+import neo4j.exceptions
+import pytest
+from tasks.jobs.attack_paths import aws
+
+DATABASE_NOT_FOUND_CODE = "Neo.ClientError.Database.DatabaseNotFound"
+
+
+def _make_neo4j_error(code: str) -> neo4j.exceptions.Neo4jError:
+ return neo4j.exceptions.Neo4jError._hydrate_neo4j(
+ code=code,
+ message="graph query failed",
+ )
+
+
+def _resource_functions(failing_sync, following_sync):
+ return {
+ "failing_sync": failing_sync,
+ "following_sync": following_sync,
+ "permission_relationships": MagicMock(),
+ "resourcegroupstaggingapi": MagicMock(),
+ }
+
+
+def test_sync_aws_account_reraises_database_not_found_immediately():
+ error = _make_neo4j_error(DATABASE_NOT_FOUND_CODE)
+ failing_sync = MagicMock(side_effect=error)
+ following_sync = MagicMock()
+
+ with (
+ patch.object(
+ aws.cartography_aws,
+ "RESOURCE_FUNCTIONS",
+ _resource_functions(failing_sync, following_sync),
+ ),
+ patch.object(aws.db_utils, "update_attack_paths_scan_progress"),
+ patch.object(aws.utils, "stringify_exception") as stringify_exception,
+ patch.object(aws.logger, "warning") as warning,
+ pytest.raises(neo4j.exceptions.Neo4jError) as exc_info,
+ ):
+ aws.sync_aws_account(
+ SimpleNamespace(uid="123456789012"),
+ [
+ "failing_sync",
+ "following_sync",
+ "permission_relationships",
+ "resourcegroupstaggingapi",
+ ],
+ {},
+ MagicMock(),
+ )
+
+ assert exc_info.value is error
+ following_sync.assert_not_called()
+ stringify_exception.assert_not_called()
+ warning.assert_not_called()
+
+
+@pytest.mark.parametrize(
+ "error",
+ [
+ _make_neo4j_error("Neo.ClientError.Statement.SyntaxError"),
+ RuntimeError("resource sync failed"),
+ ],
+ ids=["different-neo4j-error", "non-neo4j-error"],
+)
+def test_sync_aws_account_warns_and_continues_for_other_exceptions(error):
+ failing_sync = MagicMock(side_effect=error)
+ following_sync = MagicMock()
+
+ with (
+ patch.object(
+ aws.cartography_aws,
+ "RESOURCE_FUNCTIONS",
+ _resource_functions(failing_sync, following_sync),
+ ),
+ patch.object(aws.db_utils, "update_attack_paths_scan_progress"),
+ patch.object(
+ aws.utils,
+ "stringify_exception",
+ return_value="formatted failure",
+ ),
+ patch.object(aws.logger, "warning") as warning,
+ ):
+ failed_syncs = aws.sync_aws_account(
+ SimpleNamespace(uid="123456789012"),
+ [
+ "failing_sync",
+ "following_sync",
+ "permission_relationships",
+ "resourcegroupstaggingapi",
+ ],
+ {},
+ MagicMock(),
+ )
+
+ assert failed_syncs == {"failing_sync": "formatted failure"}
+ following_sync.assert_called_once_with()
+ warning.assert_called_once()
+ assert "Continuing to the next AWS sync function" in warning.call_args.args[0]
diff --git a/api/src/backend/tasks/tests/test_attack_paths_provider_config.py b/api/src/backend/tasks/tests/test_attack_paths_provider_config.py
new file mode 100644
index 0000000000..41ec2847d4
--- /dev/null
+++ b/api/src/backend/tasks/tests/test_attack_paths_provider_config.py
@@ -0,0 +1,30 @@
+from tasks.jobs.attack_paths.provider_config import AWS_NORMALIZED_LISTS
+from tasks.jobs.attack_paths.sync import _build_catalog_index, _node_to_sync_dict
+
+
+def test_aws_vpc_endpoint_id_lists_are_normalized():
+ catalog = _build_catalog_index(AWS_NORMALIZED_LISTS)
+ record = {
+ "element_id": "node-1",
+ "labels": ["AWSVpcEndpoint"],
+ "props": {
+ "id": "vpce-123",
+ "route_table_ids": ["rtb-1"],
+ "network_interface_ids": ["eni-1"],
+ "subnet_ids": ["subnet-1"],
+ },
+ }
+
+ _, parent, children, rels = _node_to_sync_dict(record, "provider-id", catalog)
+
+ assert parent["props"] == {"id": "vpce-123"}
+ assert {child["_child_label"] for child in children} == {
+ "AWSVpcEndpointRouteTableIdsItem",
+ "AWSVpcEndpointNetworkInterfaceIdsItem",
+ "AWSVpcEndpointSubnetIdsItem",
+ }
+ assert {rel["rel_type"] for rel in rels} == {
+ "HAS_ROUTE_TABLE_IDS",
+ "HAS_NETWORK_INTERFACE_IDS",
+ "HAS_SUBNET_IDS",
+ }
diff --git a/api/src/backend/tasks/tests/test_attack_paths_scan.py b/api/src/backend/tasks/tests/test_attack_paths_scan.py
index 918e54ff6c..4409e5f19d 100644
--- a/api/src/backend/tasks/tests/test_attack_paths_scan.py
+++ b/api/src/backend/tasks/tests/test_attack_paths_scan.py
@@ -1,16 +1,14 @@
+import logging
from contextlib import nullcontext
-from datetime import datetime, timedelta, timezone
+from datetime import UTC, datetime, timedelta
from types import SimpleNamespace
from unittest.mock import MagicMock, call, patch
+from uuid import uuid4
import pytest
-from django_celery_results.models import TaskResult
-from tasks.jobs.attack_paths import findings as findings_module
-from tasks.jobs.attack_paths import indexes as indexes_module
-from tasks.jobs.attack_paths import internet as internet_module
-from tasks.jobs.attack_paths import sync as sync_module
-from tasks.jobs.attack_paths.scan import run as attack_paths_run
-
+from api.attack_paths.database import GraphDatabaseQueryException
+from api.db_utils import rls_transaction
+from api.exceptions import ProviderDeletedException
from api.models import (
AttackPathsScan,
Finding,
@@ -22,17 +20,40 @@ from api.models import (
StatusChoices,
Task,
)
+from django.db import DEFAULT_DB_ALIAS, DatabaseError
+from django_celery_results.models import TaskResult
from prowler.lib.check.models import Severity
+from tasks.jobs.attack_paths import findings as findings_module
+from tasks.jobs.attack_paths import indexes as indexes_module
+from tasks.jobs.attack_paths import internet as internet_module
+from tasks.jobs.attack_paths import sync as sync_module
+from tasks.jobs.attack_paths.scan import run as attack_paths_run
+
+SYNC_RESULT_EMPTY = {
+ "nodes": 0,
+ "child_nodes": 0,
+ "relationships": 0,
+ "structural_relationships": 0,
+ "item_relationships": 0,
+}
@pytest.mark.django_db
class TestAttackPathsRun:
+ @pytest.fixture(autouse=True)
+ def mock_graph_database_preflight(self):
+ with patch(
+ "tasks.jobs.attack_paths.scan.graph_database.verify_scan_databases_available"
+ ) as mock_preflight:
+ yield mock_preflight
+
# Patching with decorators as we got a `SyntaxError: too many statically nested blocks` error if we use context managers
@patch("tasks.jobs.attack_paths.scan.graph_database.drop_database")
@patch(
"tasks.jobs.attack_paths.scan.utils.call_within_event_loop",
side_effect=lambda fn, *a, **kw: fn(*a, **kw),
)
+ @patch("tasks.jobs.attack_paths.scan.db_utils.set_scan_migrated")
@patch("tasks.jobs.attack_paths.scan.db_utils.set_graph_data_ready")
@patch("tasks.jobs.attack_paths.scan.db_utils.set_provider_graph_data_ready")
@patch("tasks.jobs.attack_paths.scan.db_utils.finish_attack_paths_scan")
@@ -40,7 +61,7 @@ class TestAttackPathsRun:
@patch("tasks.jobs.attack_paths.scan.db_utils.starting_attack_paths_scan")
@patch(
"tasks.jobs.attack_paths.scan.sync.sync_graph",
- return_value={"nodes": 0, "relationships": 0},
+ return_value=SYNC_RESULT_EMPTY,
)
@patch("tasks.jobs.attack_paths.scan.graph_database.drop_subgraph", return_value=0)
@patch("tasks.jobs.attack_paths.scan.indexes.create_sync_indexes")
@@ -49,11 +70,11 @@ class TestAttackPathsRun:
@patch("tasks.jobs.attack_paths.scan.indexes.create_findings_indexes")
@patch("tasks.jobs.attack_paths.scan.cartography_ontology.run")
@patch("tasks.jobs.attack_paths.scan.cartography_analysis.run")
- @patch("tasks.jobs.attack_paths.scan.cartography_create_indexes.run")
+ @patch("tasks.jobs.attack_paths.indexes.cartography_create_indexes.run")
@patch("tasks.jobs.attack_paths.scan.graph_database.clear_cache")
@patch("tasks.jobs.attack_paths.scan.graph_database.create_database")
@patch(
- "tasks.jobs.attack_paths.scan.graph_database.get_uri",
+ "tasks.jobs.attack_paths.scan.graph_database.get_ingest_uri",
return_value="bolt://neo4j",
)
@patch(
@@ -67,7 +88,7 @@ class TestAttackPathsRun:
def test_run_success_flow(
self,
mock_init_provider,
- mock_get_uri,
+ mock_get_ingest_uri,
mock_create_db,
mock_clear_cache,
mock_cartography_indexes,
@@ -84,19 +105,16 @@ class TestAttackPathsRun:
mock_finish,
mock_set_provider_graph_data_ready,
mock_set_graph_data_ready,
+ mock_set_scan_migrated,
mock_event_loop,
mock_drop_db,
tenants_fixture,
- providers_fixture,
+ aws_provider,
scans_fixture,
):
tenant = tenants_fixture[0]
- provider = providers_fixture[0]
- provider.provider = Provider.ProviderChoices.AWS
- provider.save()
+ provider = aws_provider
scan = scans_fixture[0]
- scan.provider = provider
- scan.save()
attack_paths_scan = AttackPathsScan.objects.create(
tenant_id=tenant.id,
@@ -160,6 +178,7 @@ class TestAttackPathsRun:
target_database="tenant-db",
tenant_id=str(provider.tenant_id),
provider_id=str(provider.id),
+ provider_type="aws",
)
mock_get_ingestion.assert_called_once_with(provider.provider)
mock_event_loop.assert_called_once()
@@ -173,10 +192,93 @@ class TestAttackPathsRun:
attack_paths_scan, StateChoices.COMPLETED, ingestion_result
)
mock_set_provider_graph_data_ready.assert_called_once_with(
- attack_paths_scan, False
+ attack_paths_scan, False, "neo4j"
)
mock_set_graph_data_ready.assert_called_once_with(attack_paths_scan, True)
+ # is_migrated is flipped to True only after the sync succeeds, so reads
+ # don't switch to the new catalog/sink before the graph is live.
+ mock_set_scan_migrated.assert_called_once_with(attack_paths_scan, True, "neo4j")
+ def test_run_preflight_failure_does_not_start_scan(
+ self,
+ mock_graph_database_preflight,
+ tenants_fixture,
+ aws_provider,
+ scans_fixture,
+ ):
+ tenant = tenants_fixture[0]
+ provider = aws_provider
+ scan = scans_fixture[0]
+
+ attack_paths_scan = AttackPathsScan.objects.create(
+ tenant_id=tenant.id,
+ provider=provider,
+ scan=scan,
+ state=StateChoices.SCHEDULED,
+ )
+ mock_graph_database_preflight.side_effect = RuntimeError("graph unavailable")
+
+ 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_ingest_uri",
+ return_value="bolt://neo4j",
+ ),
+ patch(
+ "tasks.jobs.attack_paths.scan.db_utils.retrieve_attack_paths_scan",
+ return_value=attack_paths_scan,
+ ),
+ patch(
+ "tasks.jobs.attack_paths.scan.get_cartography_ingestion_function",
+ return_value=MagicMock(return_value={}),
+ ),
+ patch(
+ "tasks.jobs.attack_paths.scan.db_utils.starting_attack_paths_scan"
+ ) as mock_starting,
+ patch(
+ "tasks.jobs.attack_paths.scan.graph_database.create_database"
+ ) as mock_create_db,
+ ):
+ with pytest.raises(RuntimeError, match="graph unavailable"):
+ attack_paths_run(str(tenant.id), str(scan.id), "task-123")
+
+ mock_graph_database_preflight.assert_called_once_with()
+ mock_starting.assert_not_called()
+ mock_create_db.assert_not_called()
+
+ @pytest.mark.parametrize(
+ ("ingestion_error", "temporary_database_missing"),
+ [
+ (RuntimeError("ingestion boom"), False),
+ (
+ GraphDatabaseQueryException(
+ message="Graph not found: db-scan-id",
+ code="Neo.ClientError.Database.DatabaseNotFound",
+ ),
+ True,
+ ),
+ (
+ GraphDatabaseQueryException(
+ message="Graph not found: db-tenant-id",
+ code="Neo.ClientError.Database.DatabaseNotFound",
+ ),
+ False,
+ ),
+ ],
+ ids=[
+ "regular-error",
+ "temporary-database-missing",
+ "sink-database-missing",
+ ],
+ )
+ @patch("tasks.jobs.attack_paths.scan.logger")
@patch(
"tasks.jobs.attack_paths.scan.utils.stringify_exception",
return_value="Cartography failed: ingestion boom",
@@ -195,13 +297,13 @@ class TestAttackPathsRun:
@patch("tasks.jobs.attack_paths.scan.internet.analysis")
@patch("tasks.jobs.attack_paths.scan.indexes.create_findings_indexes")
@patch("tasks.jobs.attack_paths.scan.cartography_analysis.run")
- @patch("tasks.jobs.attack_paths.scan.cartography_create_indexes.run")
+ @patch("tasks.jobs.attack_paths.indexes.cartography_create_indexes.run")
@patch("tasks.jobs.attack_paths.scan.graph_database.create_database")
@patch(
"tasks.jobs.attack_paths.scan.graph_database.get_database_name",
return_value="db-scan-id",
)
- @patch("tasks.jobs.attack_paths.scan.graph_database.get_uri")
+ @patch("tasks.jobs.attack_paths.scan.graph_database.get_ingest_uri")
@patch(
"tasks.jobs.attack_paths.scan.initialize_prowler_provider",
return_value=MagicMock(_enabled_regions=["us-east-1"]),
@@ -213,7 +315,7 @@ class TestAttackPathsRun:
def test_run_failure_marks_scan_failed(
self,
mock_init_provider,
- mock_get_uri,
+ mock_get_ingest_uri,
mock_get_db_name,
mock_create_db,
mock_cartography_indexes,
@@ -229,17 +331,16 @@ class TestAttackPathsRun:
mock_drop_db,
mock_event_loop,
mock_stringify,
+ mock_logger,
+ ingestion_error,
+ temporary_database_missing,
tenants_fixture,
- providers_fixture,
+ aws_provider,
scans_fixture,
):
tenant = tenants_fixture[0]
- provider = providers_fixture[0]
- provider.provider = Provider.ProviderChoices.AWS
- provider.save()
+ provider = aws_provider
scan = scans_fixture[0]
- scan.provider = provider
- scan.save()
attack_paths_scan = AttackPathsScan.objects.create(
tenant_id=tenant.id,
@@ -252,7 +353,11 @@ class TestAttackPathsRun:
session_ctx = MagicMock()
session_ctx.__enter__.return_value = mock_session
session_ctx.__exit__.return_value = False
- ingestion_fn = MagicMock(side_effect=RuntimeError("ingestion boom"))
+ ingestion_fn = MagicMock(side_effect=ingestion_error)
+ if temporary_database_missing:
+ mock_finish.side_effect = DatabaseError(
+ "Save with update_fields did not affect any rows"
+ )
with (
patch(
@@ -268,13 +373,28 @@ class TestAttackPathsRun:
return_value=ingestion_fn,
),
):
- with pytest.raises(RuntimeError, match="ingestion boom"):
+ with pytest.raises(type(ingestion_error)):
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_error": "Cartography failed: ingestion boom"}
+ mock_drop_db.assert_called_once_with("db-scan-id")
+ if temporary_database_missing:
+ mock_logger.warning.assert_any_call("Cartography failed: ingestion boom")
+ mock_logger.exception.assert_not_called()
+ mock_logger.log.assert_called_once_with(
+ logging.WARNING,
+ f"Could not mark Attack Paths scan {attack_paths_scan.id} as `FAILED` "
+ "(row may have been deleted): Save with update_fields did not affect "
+ "any rows",
+ exc_info=False,
+ )
+ else:
+ mock_logger.exception.assert_called_once_with(
+ "Cartography failed: ingestion boom"
+ )
@patch(
"tasks.jobs.attack_paths.scan.utils.stringify_exception",
@@ -294,13 +414,13 @@ class TestAttackPathsRun:
@patch("tasks.jobs.attack_paths.scan.internet.analysis")
@patch("tasks.jobs.attack_paths.scan.indexes.create_findings_indexes")
@patch("tasks.jobs.attack_paths.scan.cartography_analysis.run")
- @patch("tasks.jobs.attack_paths.scan.cartography_create_indexes.run")
+ @patch("tasks.jobs.attack_paths.indexes.cartography_create_indexes.run")
@patch("tasks.jobs.attack_paths.scan.graph_database.create_database")
@patch(
"tasks.jobs.attack_paths.scan.graph_database.get_database_name",
return_value="db-scan-id",
)
- @patch("tasks.jobs.attack_paths.scan.graph_database.get_uri")
+ @patch("tasks.jobs.attack_paths.scan.graph_database.get_ingest_uri")
@patch(
"tasks.jobs.attack_paths.scan.initialize_prowler_provider",
return_value=MagicMock(_enabled_regions=["us-east-1"]),
@@ -312,7 +432,7 @@ class TestAttackPathsRun:
def test_failure_before_gate_does_not_flip_graph_data_ready_true(
self,
mock_init_provider,
- mock_get_uri,
+ mock_get_ingest_uri,
mock_get_db_name,
mock_create_db,
mock_cartography_indexes,
@@ -329,18 +449,14 @@ class TestAttackPathsRun:
mock_event_loop,
mock_stringify,
tenants_fixture,
- providers_fixture,
+ aws_provider,
scans_fixture,
):
"""Failure during ingestion (before set_provider_graph_data_ready(False))
must NOT flip graph_data_ready to True for providers that never had data."""
tenant = tenants_fixture[0]
- provider = providers_fixture[0]
- provider.provider = Provider.ProviderChoices.AWS
- provider.save()
+ provider = aws_provider
scan = scans_fixture[0]
- scan.provider = provider
- scan.save()
attack_paths_scan = AttackPathsScan.objects.create(
tenant_id=tenant.id,
@@ -397,13 +513,13 @@ class TestAttackPathsRun:
@patch("tasks.jobs.attack_paths.scan.internet.analysis")
@patch("tasks.jobs.attack_paths.scan.indexes.create_findings_indexes")
@patch("tasks.jobs.attack_paths.scan.cartography_analysis.run")
- @patch("tasks.jobs.attack_paths.scan.cartography_create_indexes.run")
+ @patch("tasks.jobs.attack_paths.indexes.cartography_create_indexes.run")
@patch("tasks.jobs.attack_paths.scan.graph_database.create_database")
@patch(
"tasks.jobs.attack_paths.scan.graph_database.get_database_name",
return_value="db-scan-id",
)
- @patch("tasks.jobs.attack_paths.scan.graph_database.get_uri")
+ @patch("tasks.jobs.attack_paths.scan.graph_database.get_ingest_uri")
@patch(
"tasks.jobs.attack_paths.scan.initialize_prowler_provider",
return_value=MagicMock(_enabled_regions=["us-east-1"]),
@@ -415,7 +531,7 @@ class TestAttackPathsRun:
def test_run_failure_marks_scan_failed_even_when_drop_database_fails(
self,
mock_init_provider,
- mock_get_uri,
+ mock_get_ingest_uri,
mock_get_db_name,
mock_create_db,
mock_cartography_indexes,
@@ -432,16 +548,12 @@ class TestAttackPathsRun:
mock_event_loop,
mock_stringify,
tenants_fixture,
- providers_fixture,
+ aws_provider,
scans_fixture,
):
tenant = tenants_fixture[0]
- provider = providers_fixture[0]
- provider.provider = Provider.ProviderChoices.AWS
- provider.save()
+ provider = aws_provider
scan = scans_fixture[0]
- scan.provider = provider
- scan.save()
attack_paths_scan = AttackPathsScan.objects.create(
tenant_id=tenant.id,
@@ -494,7 +606,7 @@ class TestAttackPathsRun:
@patch("tasks.jobs.attack_paths.scan.db_utils.starting_attack_paths_scan")
@patch(
"tasks.jobs.attack_paths.scan.sync.sync_graph",
- return_value={"nodes": 0, "relationships": 0},
+ return_value=SYNC_RESULT_EMPTY,
)
@patch(
"tasks.jobs.attack_paths.scan.graph_database.drop_subgraph",
@@ -506,11 +618,11 @@ class TestAttackPathsRun:
@patch("tasks.jobs.attack_paths.scan.indexes.create_findings_indexes")
@patch("tasks.jobs.attack_paths.scan.cartography_ontology.run")
@patch("tasks.jobs.attack_paths.scan.cartography_analysis.run")
- @patch("tasks.jobs.attack_paths.scan.cartography_create_indexes.run")
+ @patch("tasks.jobs.attack_paths.indexes.cartography_create_indexes.run")
@patch("tasks.jobs.attack_paths.scan.graph_database.clear_cache")
@patch("tasks.jobs.attack_paths.scan.graph_database.create_database")
@patch(
- "tasks.jobs.attack_paths.scan.graph_database.get_uri",
+ "tasks.jobs.attack_paths.scan.graph_database.get_ingest_uri",
return_value="bolt://neo4j",
)
@patch(
@@ -524,7 +636,7 @@ class TestAttackPathsRun:
def test_failure_after_gate_before_drop_restores_graph_data_ready(
self,
mock_init_provider,
- mock_get_uri,
+ mock_get_ingest_uri,
mock_create_db,
mock_clear_cache,
mock_cartography_indexes,
@@ -545,16 +657,12 @@ class TestAttackPathsRun:
mock_event_loop,
mock_stringify,
tenants_fixture,
- providers_fixture,
+ aws_provider,
scans_fixture,
):
tenant = tenants_fixture[0]
- provider = providers_fixture[0]
- provider.provider = Provider.ProviderChoices.AWS
- provider.save()
+ provider = aws_provider
scan = scans_fixture[0]
- scan.provider = provider
- scan.save()
attack_paths_scan = AttackPathsScan.objects.create(
tenant_id=tenant.id,
@@ -590,8 +698,8 @@ class TestAttackPathsRun:
attack_paths_run(str(tenant.id), str(scan.id), "task-456")
assert mock_set_provider_graph_data_ready.call_args_list == [
- call(attack_paths_scan, False),
- call(attack_paths_scan, True),
+ call(attack_paths_scan, False, "neo4j"),
+ call(attack_paths_scan, True, "neo4j"),
]
@patch(
@@ -619,11 +727,11 @@ class TestAttackPathsRun:
@patch("tasks.jobs.attack_paths.scan.indexes.create_findings_indexes")
@patch("tasks.jobs.attack_paths.scan.cartography_ontology.run")
@patch("tasks.jobs.attack_paths.scan.cartography_analysis.run")
- @patch("tasks.jobs.attack_paths.scan.cartography_create_indexes.run")
+ @patch("tasks.jobs.attack_paths.indexes.cartography_create_indexes.run")
@patch("tasks.jobs.attack_paths.scan.graph_database.clear_cache")
@patch("tasks.jobs.attack_paths.scan.graph_database.create_database")
@patch(
- "tasks.jobs.attack_paths.scan.graph_database.get_uri",
+ "tasks.jobs.attack_paths.scan.graph_database.get_ingest_uri",
return_value="bolt://neo4j",
)
@patch(
@@ -637,7 +745,7 @@ class TestAttackPathsRun:
def test_failure_after_drop_before_sync_leaves_graph_data_ready_false(
self,
mock_init_provider,
- mock_get_uri,
+ mock_get_ingest_uri,
mock_create_db,
mock_clear_cache,
mock_cartography_indexes,
@@ -658,16 +766,12 @@ class TestAttackPathsRun:
mock_event_loop,
mock_stringify,
tenants_fixture,
- providers_fixture,
+ aws_provider,
scans_fixture,
):
tenant = tenants_fixture[0]
- provider = providers_fixture[0]
- provider.provider = Provider.ProviderChoices.AWS
- provider.save()
+ provider = aws_provider
scan = scans_fixture[0]
- scan.provider = provider
- scan.save()
attack_paths_scan = AttackPathsScan.objects.create(
tenant_id=tenant.id,
@@ -704,7 +808,7 @@ class TestAttackPathsRun:
# Only called with False (gate), never with True (no recovery for partial data)
mock_set_provider_graph_data_ready.assert_called_once_with(
- attack_paths_scan, False
+ attack_paths_scan, False, "neo4j"
)
@patch(
@@ -717,6 +821,7 @@ class TestAttackPathsRun:
)
@patch("tasks.jobs.attack_paths.scan.graph_database.drop_database")
@patch("tasks.jobs.attack_paths.scan.db_utils.finish_attack_paths_scan")
+ @patch("tasks.jobs.attack_paths.scan.db_utils.set_scan_migrated")
@patch(
"tasks.jobs.attack_paths.scan.db_utils.set_graph_data_ready",
side_effect=[RuntimeError("flag failed"), None],
@@ -726,7 +831,7 @@ class TestAttackPathsRun:
@patch("tasks.jobs.attack_paths.scan.db_utils.starting_attack_paths_scan")
@patch(
"tasks.jobs.attack_paths.scan.sync.sync_graph",
- return_value={"nodes": 0, "relationships": 0},
+ return_value=SYNC_RESULT_EMPTY,
)
@patch("tasks.jobs.attack_paths.scan.graph_database.drop_subgraph")
@patch("tasks.jobs.attack_paths.scan.indexes.create_sync_indexes")
@@ -735,11 +840,11 @@ class TestAttackPathsRun:
@patch("tasks.jobs.attack_paths.scan.indexes.create_findings_indexes")
@patch("tasks.jobs.attack_paths.scan.cartography_ontology.run")
@patch("tasks.jobs.attack_paths.scan.cartography_analysis.run")
- @patch("tasks.jobs.attack_paths.scan.cartography_create_indexes.run")
+ @patch("tasks.jobs.attack_paths.indexes.cartography_create_indexes.run")
@patch("tasks.jobs.attack_paths.scan.graph_database.clear_cache")
@patch("tasks.jobs.attack_paths.scan.graph_database.create_database")
@patch(
- "tasks.jobs.attack_paths.scan.graph_database.get_uri",
+ "tasks.jobs.attack_paths.scan.graph_database.get_ingest_uri",
return_value="bolt://neo4j",
)
@patch(
@@ -753,7 +858,7 @@ class TestAttackPathsRun:
def test_failure_after_sync_restores_graph_data_ready(
self,
mock_init_provider,
- mock_get_uri,
+ mock_get_ingest_uri,
mock_create_db,
mock_clear_cache,
mock_cartography_indexes,
@@ -769,21 +874,18 @@ class TestAttackPathsRun:
mock_update_progress,
mock_set_provider_graph_data_ready,
mock_set_graph_data_ready,
+ mock_set_scan_migrated,
mock_finish,
mock_drop_db,
mock_event_loop,
mock_stringify,
tenants_fixture,
- providers_fixture,
+ aws_provider,
scans_fixture,
):
tenant = tenants_fixture[0]
- provider = providers_fixture[0]
- provider.provider = Provider.ProviderChoices.AWS
- provider.save()
+ provider = aws_provider
scan = scans_fixture[0]
- scan.provider = provider
- scan.save()
attack_paths_scan = AttackPathsScan.objects.create(
tenant_id=tenant.id,
@@ -825,8 +927,11 @@ class TestAttackPathsRun:
]
# set_provider_graph_data_ready only called once with False (the gate)
mock_set_provider_graph_data_ready.assert_called_once_with(
- attack_paths_scan, False
+ attack_paths_scan, False, "neo4j"
)
+ # is_migrated is flipped once after the sync and is not touched again by
+ # the failure-recovery branch
+ mock_set_scan_migrated.assert_called_once_with(attack_paths_scan, True, "neo4j")
@patch(
"tasks.jobs.attack_paths.scan.utils.stringify_exception",
@@ -844,7 +949,7 @@ class TestAttackPathsRun:
@patch("tasks.jobs.attack_paths.scan.db_utils.starting_attack_paths_scan")
@patch(
"tasks.jobs.attack_paths.scan.sync.sync_graph",
- return_value={"nodes": 0, "relationships": 0},
+ return_value=SYNC_RESULT_EMPTY,
)
@patch(
"tasks.jobs.attack_paths.scan.graph_database.drop_subgraph",
@@ -856,11 +961,11 @@ class TestAttackPathsRun:
@patch("tasks.jobs.attack_paths.scan.indexes.create_findings_indexes")
@patch("tasks.jobs.attack_paths.scan.cartography_ontology.run")
@patch("tasks.jobs.attack_paths.scan.cartography_analysis.run")
- @patch("tasks.jobs.attack_paths.scan.cartography_create_indexes.run")
+ @patch("tasks.jobs.attack_paths.indexes.cartography_create_indexes.run")
@patch("tasks.jobs.attack_paths.scan.graph_database.clear_cache")
@patch("tasks.jobs.attack_paths.scan.graph_database.create_database")
@patch(
- "tasks.jobs.attack_paths.scan.graph_database.get_uri",
+ "tasks.jobs.attack_paths.scan.graph_database.get_ingest_uri",
return_value="bolt://neo4j",
)
@patch(
@@ -874,7 +979,7 @@ class TestAttackPathsRun:
def test_recovery_failure_does_not_suppress_original_exception(
self,
mock_init_provider,
- mock_get_uri,
+ mock_get_ingest_uri,
mock_create_db,
mock_clear_cache,
mock_cartography_indexes,
@@ -895,16 +1000,12 @@ class TestAttackPathsRun:
mock_event_loop,
mock_stringify,
tenants_fixture,
- providers_fixture,
+ aws_provider,
scans_fixture,
):
tenant = tenants_fixture[0]
- provider = providers_fixture[0]
- provider.provider = Provider.ProviderChoices.AWS
- provider.save()
+ provider = aws_provider
scan = scans_fixture[0]
- scan.provider = provider
- scan.save()
attack_paths_scan = AttackPathsScan.objects.create(
tenant_id=tenant.id,
@@ -992,17 +1093,13 @@ class TestAttackPathsRun:
@pytest.mark.django_db
class TestFailAttackPathsScan:
def test_marks_executing_scan_as_failed(
- self, tenants_fixture, providers_fixture, scans_fixture
+ self, tenants_fixture, aws_provider, scans_fixture
):
from tasks.jobs.attack_paths.db_utils import fail_attack_paths_scan
tenant = tenants_fixture[0]
- provider = providers_fixture[0]
- provider.provider = Provider.ProviderChoices.AWS
- provider.save()
+ provider = aws_provider
scan = scans_fixture[0]
- scan.provider = provider
- scan.save()
attack_paths_scan = AttackPathsScan.objects.create(
tenant_id=tenant.id,
@@ -1034,17 +1131,13 @@ class TestFailAttackPathsScan:
}
def test_drops_temp_database_even_when_drop_fails(
- self, tenants_fixture, providers_fixture, scans_fixture
+ self, tenants_fixture, aws_provider, scans_fixture
):
from tasks.jobs.attack_paths.db_utils import fail_attack_paths_scan
tenant = tenants_fixture[0]
- provider = providers_fixture[0]
- provider.provider = Provider.ProviderChoices.AWS
- provider.save()
+ provider = aws_provider
scan = scans_fixture[0]
- scan.provider = provider
- scan.save()
attack_paths_scan = AttackPathsScan.objects.create(
tenant_id=tenant.id,
@@ -1070,17 +1163,13 @@ class TestFailAttackPathsScan:
assert attack_paths_scan.state == StateChoices.FAILED
def test_skips_already_failed_scan(
- self, tenants_fixture, providers_fixture, scans_fixture
+ self, tenants_fixture, aws_provider, scans_fixture
):
from tasks.jobs.attack_paths.db_utils import fail_attack_paths_scan
tenant = tenants_fixture[0]
- provider = providers_fixture[0]
- provider.provider = Provider.ProviderChoices.AWS
- provider.save()
+ provider = aws_provider
scan = scans_fixture[0]
- scan.provider = provider
- scan.save()
attack_paths_scan = AttackPathsScan.objects.create(
tenant_id=tenant.id,
@@ -1117,17 +1206,13 @@ class TestFailAttackPathsScan:
fail_attack_paths_scan(str(tenant.id), "nonexistent", "setup exploded")
def test_fail_recovers_graph_data_ready_when_data_exists(
- self, tenants_fixture, providers_fixture, scans_fixture
+ self, tenants_fixture, aws_provider, scans_fixture, sink_backend_stub
):
from tasks.jobs.attack_paths.db_utils import fail_attack_paths_scan
tenant = tenants_fixture[0]
- provider = providers_fixture[0]
- provider.provider = Provider.ProviderChoices.AWS
- provider.save()
+ provider = aws_provider
scan = scans_fixture[0]
- scan.provider = provider
- scan.save()
attack_paths_scan = AttackPathsScan.objects.create(
tenant_id=tenant.id,
@@ -1136,16 +1221,18 @@ class TestFailAttackPathsScan:
state=StateChoices.EXECUTING,
)
+ # `recover_graph_data_ready` routes `has_provider_data` through
+ # `sink_module.get_backend_for_scan(scan)`. With `is_migrated=False`
+ # and the default `ATTACK_PATHS_SINK_DATABASE=neo4j`, the factory
+ # returns the active backend, which `sink_backend_stub` replaces.
+ sink_backend_stub.has_provider_data.return_value = True
+
with (
patch(
"tasks.jobs.attack_paths.db_utils.retrieve_attack_paths_scan",
return_value=attack_paths_scan,
),
patch("tasks.jobs.attack_paths.db_utils.graph_database.drop_database"),
- patch(
- "tasks.jobs.attack_paths.db_utils.graph_database.has_provider_data",
- return_value=True,
- ),
patch(
"tasks.jobs.attack_paths.db_utils.set_provider_graph_data_ready"
) as mock_set_ready,
@@ -1155,17 +1242,13 @@ class TestFailAttackPathsScan:
mock_set_ready.assert_called_once_with(attack_paths_scan, True)
def test_fail_leaves_graph_data_ready_false_when_no_data(
- self, tenants_fixture, providers_fixture, scans_fixture
+ self, tenants_fixture, aws_provider, scans_fixture, sink_backend_stub
):
from tasks.jobs.attack_paths.db_utils import fail_attack_paths_scan
tenant = tenants_fixture[0]
- provider = providers_fixture[0]
- provider.provider = Provider.ProviderChoices.AWS
- provider.save()
+ provider = aws_provider
scan = scans_fixture[0]
- scan.provider = provider
- scan.save()
attack_paths_scan = AttackPathsScan.objects.create(
tenant_id=tenant.id,
@@ -1174,16 +1257,14 @@ class TestFailAttackPathsScan:
state=StateChoices.EXECUTING,
)
+ sink_backend_stub.has_provider_data.return_value = False
+
with (
patch(
"tasks.jobs.attack_paths.db_utils.retrieve_attack_paths_scan",
return_value=attack_paths_scan,
),
patch("tasks.jobs.attack_paths.db_utils.graph_database.drop_database"),
- patch(
- "tasks.jobs.attack_paths.db_utils.graph_database.has_provider_data",
- return_value=False,
- ),
patch(
"tasks.jobs.attack_paths.db_utils.set_provider_graph_data_ready"
) as mock_set_ready,
@@ -1193,17 +1274,13 @@ class TestFailAttackPathsScan:
mock_set_ready.assert_not_called()
def test_recover_graph_data_ready_never_raises(
- self, tenants_fixture, providers_fixture, scans_fixture
+ self, tenants_fixture, aws_provider, scans_fixture
):
from tasks.jobs.attack_paths.db_utils import recover_graph_data_ready
tenant = tenants_fixture[0]
- provider = providers_fixture[0]
- provider.provider = Provider.ProviderChoices.AWS
- provider.save()
+ provider = aws_provider
scan = scans_fixture[0]
- scan.provider = provider
- scan.save()
attack_paths_scan = AttackPathsScan.objects.create(
tenant_id=tenant.id,
@@ -1239,6 +1316,33 @@ class TestAttackPathsScanRLSTaskOnFailure:
mock_fail.assert_called_once_with("t-1", "s-1", "boom")
+ def test_on_failure_logs_provider_deletion_as_warning(self):
+ from tasks.tasks import AttackPathsScanRLSTask
+
+ task = AttackPathsScanRLSTask()
+ error = ProviderDeletedException("provider deleted")
+
+ with (
+ patch("tasks.tasks.logger") as mock_logger,
+ patch(
+ "tasks.tasks.attack_paths_db_utils.fail_attack_paths_scan"
+ ) as mock_fail,
+ ):
+ task.on_failure(
+ exc=error,
+ task_id="task-abc",
+ args=(),
+ kwargs={"tenant_id": "t-1", "scan_id": "s-1"},
+ _einfo=None,
+ )
+
+ mock_logger.warning.assert_called_once_with(
+ "Attack paths scan task task-abc stopped because its provider or tenant "
+ "was deleted: provider deleted"
+ )
+ mock_logger.error.assert_not_called()
+ mock_fail.assert_called_once_with("t-1", "s-1", "provider deleted")
+
def test_on_failure_skips_when_missing_kwargs(self):
from tasks.tasks import AttackPathsScanRLSTask
@@ -1272,10 +1376,22 @@ class TestAttackPathsFindingsHelpers:
[call(mock_session, stmt) for stmt in FINDINGS_INDEX_STATEMENTS]
)
- def test_load_findings_batches_requests(self, providers_fixture):
- provider = providers_fixture[0]
- provider.provider = Provider.ProviderChoices.AWS
- provider.save()
+ def test_create_findings_indexes_runs_even_when_sink_is_neptune(self, settings):
+ # The index helpers run against the temp ingest DB, which is always
+ # Neo4j regardless of the configured sink. A Neptune sink must not
+ # suppress index creation on that DB (regression for the dropped
+ # in-helper sink gate).
+ settings.ATTACK_PATHS_SINK_DATABASE = "neptune"
+ mock_session = MagicMock()
+ with patch("tasks.jobs.attack_paths.indexes.run_write_query") as mock_run_write:
+ indexes_module.create_findings_indexes(mock_session)
+
+ from tasks.jobs.attack_paths.indexes import FINDINGS_INDEX_STATEMENTS
+
+ assert mock_run_write.call_count == len(FINDINGS_INDEX_STATEMENTS)
+
+ def test_load_findings_batches_requests(self, aws_provider):
+ provider = aws_provider
# Create a generator that yields two batches of dicts (pre-converted)
def findings_generator():
@@ -1323,12 +1439,10 @@ class TestAttackPathsFindingsHelpers:
def test_stream_findings_with_resources_returns_latest_scan_data(
self,
tenants_fixture,
- providers_fixture,
+ aws_provider,
):
tenant = tenants_fixture[0]
- provider = providers_fixture[0]
- provider.provider = Provider.ProviderChoices.AWS
- provider.save()
+ provider = aws_provider
resource = Resource.objects.create(
tenant_id=tenant.id,
@@ -1427,13 +1541,11 @@ class TestAttackPathsFindingsHelpers:
def test_enrich_batch_with_resources_single_resource(
self,
tenants_fixture,
- providers_fixture,
+ aws_provider,
):
"""One finding + one resource = one output dict"""
tenant = tenants_fixture[0]
- provider = providers_fixture[0]
- provider.provider = Provider.ProviderChoices.AWS
- provider.save()
+ provider = aws_provider
resource = Resource.objects.create(
tenant_id=tenant.id,
@@ -1511,13 +1623,11 @@ class TestAttackPathsFindingsHelpers:
def test_enrich_batch_with_resources_multiple_resources(
self,
tenants_fixture,
- providers_fixture,
+ aws_provider,
):
"""One finding + three resources = three output dicts"""
tenant = tenants_fixture[0]
- provider = providers_fixture[0]
- provider.provider = Provider.ProviderChoices.AWS
- provider.save()
+ provider = aws_provider
resources = []
for i in range(3):
@@ -1603,13 +1713,11 @@ class TestAttackPathsFindingsHelpers:
def test_enrich_batch_with_resources_no_resources_skips(
self,
tenants_fixture,
- providers_fixture,
+ aws_provider,
):
"""Finding without resources should be skipped"""
tenant = tenants_fixture[0]
- provider = providers_fixture[0]
- provider.provider = Provider.ProviderChoices.AWS
- provider.save()
+ provider = aws_provider
scan = Scan.objects.create(
name="Test Scan",
@@ -1668,11 +1776,9 @@ class TestAttackPathsFindingsHelpers:
assert len(result) == 0
mock_logger.warning.assert_not_called()
- def test_generator_is_lazy(self, providers_fixture):
+ def test_generator_is_lazy(self, aws_provider):
"""Generator should not execute queries until iterated"""
- provider = providers_fixture[0]
- provider.provider = Provider.ProviderChoices.AWS
- provider.save()
+ provider = aws_provider
scan_id = "some-scan-id"
with patch("tasks.jobs.attack_paths.findings.rls_transaction") as mock_rls:
@@ -1682,11 +1788,9 @@ class TestAttackPathsFindingsHelpers:
# Nothing should be called yet
mock_rls.assert_not_called()
- def test_load_findings_empty_generator(self, providers_fixture):
+ def test_load_findings_empty_generator(self, aws_provider):
"""Empty generator should not call neo4j"""
- provider = providers_fixture[0]
- provider.provider = Provider.ProviderChoices.AWS
- provider.save()
+ provider = aws_provider
mock_session = MagicMock()
config = SimpleNamespace(update_tag=12345)
@@ -1802,8 +1906,63 @@ def _make_session_ctx(session, call_order=None, name=None):
return ctx
+class TestBuildChildId:
+ def test_large_value_is_hashed_and_preserved_as_child_data(self):
+ value = "x" * 22_796
+ spec = sync_module.NormalizedList(
+ "SomeLabel",
+ "values",
+ "SomeLabelValuesItem",
+ "HAS_VALUES",
+ )
+ record = {
+ "element_id": "elem-1",
+ "labels": ["SomeLabel"],
+ "props": {"values": [value]},
+ }
+
+ _, parent, children, relationships = sync_module._node_to_sync_dict(
+ record,
+ "prov-1",
+ sync_module._build_catalog_index([spec]),
+ )
+
+ child = children[0]["row"]
+ child_id = child["provider_element_id"]
+ prefix = "prov-1::SomeLabelValuesItem::"
+ assert parent["provider_element_id"] == "prov-1:elem-1"
+ assert child["props"]["value"] == value
+ assert len(child_id) == len(prefix) + 64
+ assert value not in child_id
+ assert relationships[0]["row"]["end_element_id"] == child_id
+
+ @pytest.mark.parametrize(
+ ("provider_id", "child_label", "value_key"),
+ [
+ ("prov-2", "ChildLabel", "value"),
+ ("prov-1", "OtherChildLabel", "value"),
+ ("prov-1", "ChildLabel", "other-value"),
+ ],
+ )
+ def test_each_identity_component_changes_id(
+ self, provider_id, child_label, value_key
+ ):
+ child_id = sync_module._build_child_id("prov-1", "ChildLabel", "value")
+
+ assert sync_module._build_child_id("prov-1", "ChildLabel", "value") == child_id
+ assert (
+ sync_module._build_child_id(provider_id, child_label, value_key) != child_id
+ )
+
+
class TestSyncNodes:
- def test_sync_nodes_adds_private_label(self):
+ def test_iter_sink_batches_rejects_zero_batch_size(self):
+ with pytest.raises(
+ ValueError, match="Sink batch size must be greater than zero"
+ ):
+ list(sync_module._iter_sink_batches([], batch_size=0))
+
+ def test_sync_nodes_passes_isolation_labels_to_sink(self):
row = {
"internal_id": 1,
"element_id": "elem-1",
@@ -1813,29 +1972,32 @@ class TestSyncNodes:
mock_source_1 = MagicMock()
mock_source_1.run.return_value = [row]
- mock_target = MagicMock()
mock_source_2 = MagicMock()
mock_source_2.run.return_value = []
+ sink = MagicMock(sync_batch_size=1000)
with patch(
"tasks.jobs.attack_paths.sync.graph_database.get_session",
side_effect=[
_make_session_ctx(mock_source_1),
- _make_session_ctx(mock_target),
_make_session_ctx(mock_source_2),
],
):
- total = sync_module.sync_nodes(
- "source-db", "target-db", "tenant-1", "prov-1"
+ result = sync_module.sync_nodes(
+ "source-db", "target-db", "tenant-1", "prov-1", sink, []
)
- assert total == 1
- query = mock_target.run.call_args.args[0]
- assert "_ProviderResource" in query
- assert "_Tenant_tenant1" in query
- assert "_Provider_prov1" in query
+ assert result["parents"] == 1
+ sink.write_nodes.assert_called_once()
+ target_db, labels, batch = sink.write_nodes.call_args.args
+ assert target_db == "target-db"
+ assert "_ProviderResource" in labels
+ assert "_Tenant_tenant1" in labels
+ assert "_Provider_prov1" in labels
+ assert batch[0]["provider_element_id"] == "prov-1:elem-1"
+ assert batch[0]["props"] == {"key": "value"}
- def test_sync_nodes_source_closes_before_target_opens(self):
+ def test_sync_nodes_writes_after_source_session_closes(self):
row = {
"internal_id": 1,
"element_id": "elem-1",
@@ -1847,21 +2009,23 @@ class TestSyncNodes:
src_1 = MagicMock()
src_1.run.return_value = [row]
- tgt = MagicMock()
src_2 = MagicMock()
src_2.run.return_value = []
+ sink = MagicMock(sync_batch_size=1000)
+ sink.write_nodes.side_effect = lambda *_a, **_kw: call_order.append(
+ "sink:write"
+ )
with patch(
"tasks.jobs.attack_paths.sync.graph_database.get_session",
side_effect=[
_make_session_ctx(src_1, call_order, "source1"),
- _make_session_ctx(tgt, call_order, "target"),
_make_session_ctx(src_2, call_order, "source2"),
],
):
- sync_module.sync_nodes("src-db", "tgt-db", "t-1", "p-1")
+ sync_module.sync_nodes("src-db", "tgt-db", "t-1", "p-1", sink, [])
- assert call_order.index("source1:exit") < call_order.index("target:enter")
+ assert call_order.index("source1:exit") < call_order.index("sink:write")
def test_sync_nodes_pagination_with_batch_size_1(self):
row_a = {
@@ -1883,44 +2047,83 @@ class TestSyncNodes:
src_2.run.return_value = [row_b]
src_3 = MagicMock()
src_3.run.return_value = []
- tgt_1 = MagicMock()
- tgt_2 = MagicMock()
+ sink = MagicMock(sync_batch_size=1)
- with (
- patch(
- "tasks.jobs.attack_paths.sync.graph_database.get_session",
- side_effect=[
- _make_session_ctx(src_1),
- _make_session_ctx(tgt_1),
- _make_session_ctx(src_2),
- _make_session_ctx(tgt_2),
- _make_session_ctx(src_3),
- ],
- ),
- patch("tasks.jobs.attack_paths.sync.SYNC_BATCH_SIZE", 1),
+ with patch(
+ "tasks.jobs.attack_paths.sync.graph_database.get_session",
+ side_effect=[
+ _make_session_ctx(src_1),
+ _make_session_ctx(src_2),
+ _make_session_ctx(src_3),
+ ],
):
- total = sync_module.sync_nodes("src", "tgt", "t-1", "p-1")
+ result = sync_module.sync_nodes("src", "tgt", "t-1", "p-1", sink, [])
- assert total == 2
+ assert result["parents"] == 2
+ assert sink.write_nodes.call_count == 2
assert src_1.run.call_args.args[1]["last_id"] == -1
assert src_2.run.call_args.args[1]["last_id"] == 1
+ def test_sync_nodes_chunks_expanded_list_rows_before_sink_write(self):
+ row = {
+ "internal_id": 1,
+ "element_id": "elem-1",
+ "labels": ["SomeLabel"],
+ "props": {"values": ["a", "b", "c", "d", "e"]},
+ }
+ normalized_lists = [
+ sync_module.NormalizedList(
+ "SomeLabel",
+ "values",
+ "SomeLabelValuesItem",
+ "HAS_VALUES",
+ )
+ ]
+
+ src_1 = MagicMock()
+ src_1.run.return_value = [row]
+ src_2 = MagicMock()
+ src_2.run.return_value = []
+ sink = MagicMock(sync_batch_size=2)
+
+ with patch(
+ "tasks.jobs.attack_paths.sync.graph_database.get_session",
+ side_effect=[
+ _make_session_ctx(src_1),
+ _make_session_ctx(src_2),
+ ],
+ ):
+ result = sync_module.sync_nodes(
+ "src", "tgt", "t-1", "p-1", sink, normalized_lists
+ )
+
+ assert result == {"parents": 1, "children": 5, "parent_child_rels": 5}
+ assert [
+ len(call_args.args[2]) for call_args in sink.write_nodes.call_args_list[1:]
+ ] == [2, 2, 1]
+ assert [
+ len(call_args.args[3])
+ for call_args in sink.write_relationships.call_args_list
+ ] == [2, 2, 1]
+
def test_sync_nodes_empty_source_returns_zero(self):
src = MagicMock()
src.run.return_value = []
+ sink = MagicMock(sync_batch_size=1000)
with patch(
"tasks.jobs.attack_paths.sync.graph_database.get_session",
side_effect=[_make_session_ctx(src)],
) as mock_get_session:
- total = sync_module.sync_nodes("src", "tgt", "t-1", "p-1")
+ result = sync_module.sync_nodes("src", "tgt", "t-1", "p-1", sink, [])
- assert total == 0
+ assert result["parents"] == 0
assert mock_get_session.call_count == 1
+ sink.write_nodes.assert_not_called()
class TestSyncRelationships:
- def test_sync_relationships_source_closes_before_target_opens(self):
+ def test_sync_relationships_writes_after_source_session_closes(self):
row = {
"internal_id": 1,
"rel_type": "HAS",
@@ -1933,21 +2136,23 @@ class TestSyncRelationships:
src_1 = MagicMock()
src_1.run.return_value = [row]
- tgt = MagicMock()
src_2 = MagicMock()
src_2.run.return_value = []
+ sink = MagicMock(sync_batch_size=1000)
+ sink.write_relationships.side_effect = lambda *_a, **_kw: call_order.append(
+ "sink:write"
+ )
with patch(
"tasks.jobs.attack_paths.sync.graph_database.get_session",
side_effect=[
_make_session_ctx(src_1, call_order, "source1"),
- _make_session_ctx(tgt, call_order, "target"),
_make_session_ctx(src_2, call_order, "source2"),
],
):
- sync_module.sync_relationships("src", "tgt", "p-1")
+ sync_module.sync_relationships("src", "tgt", "p-1", sink)
- assert call_order.index("source1:exit") < call_order.index("target:enter")
+ assert call_order.index("source1:exit") < call_order.index("sink:write")
def test_sync_relationships_pagination_with_batch_size_1(self):
row_a = {
@@ -1971,40 +2176,70 @@ class TestSyncRelationships:
src_2.run.return_value = [row_b]
src_3 = MagicMock()
src_3.run.return_value = []
- tgt_1 = MagicMock()
- tgt_2 = MagicMock()
+ sink = MagicMock(sync_batch_size=1)
- with (
- patch(
- "tasks.jobs.attack_paths.sync.graph_database.get_session",
- side_effect=[
- _make_session_ctx(src_1),
- _make_session_ctx(tgt_1),
- _make_session_ctx(src_2),
- _make_session_ctx(tgt_2),
- _make_session_ctx(src_3),
- ],
- ),
- patch("tasks.jobs.attack_paths.sync.SYNC_BATCH_SIZE", 1),
+ with patch(
+ "tasks.jobs.attack_paths.sync.graph_database.get_session",
+ side_effect=[
+ _make_session_ctx(src_1),
+ _make_session_ctx(src_2),
+ _make_session_ctx(src_3),
+ ],
):
- total = sync_module.sync_relationships("src", "tgt", "p-1")
+ total = sync_module.sync_relationships("src", "tgt", "p-1", sink)
assert total == 2
+ assert sink.write_relationships.call_count == 2
assert src_1.run.call_args.args[1]["last_id"] == -1
assert src_2.run.call_args.args[1]["last_id"] == 1
+ def test_sync_relationships_chunks_grouped_rows_before_sink_write(self):
+ rows = [
+ {
+ "internal_id": idx,
+ "rel_type": "HAS",
+ "start_element_id": f"s-{idx}",
+ "end_element_id": f"e-{idx}",
+ "props": {},
+ }
+ for idx in range(1, 6)
+ ]
+
+ src_1 = MagicMock()
+ src_1.run.return_value = rows
+ src_2 = MagicMock()
+ src_2.run.return_value = []
+ sink = MagicMock(sync_batch_size=2)
+
+ with patch(
+ "tasks.jobs.attack_paths.sync.graph_database.get_session",
+ side_effect=[
+ _make_session_ctx(src_1),
+ _make_session_ctx(src_2),
+ ],
+ ):
+ total = sync_module.sync_relationships("src", "tgt", "p-1", sink)
+
+ assert total == 5
+ assert [
+ len(call_args.args[3])
+ for call_args in sink.write_relationships.call_args_list
+ ] == [2, 2, 1]
+
def test_sync_relationships_empty_source_returns_zero(self):
src = MagicMock()
src.run.return_value = []
+ sink = MagicMock(sync_batch_size=1000)
with patch(
"tasks.jobs.attack_paths.sync.graph_database.get_session",
side_effect=[_make_session_ctx(src)],
) as mock_get_session:
- total = sync_module.sync_relationships("src", "tgt", "p-1")
+ total = sync_module.sync_relationships("src", "tgt", "p-1", sink)
assert total == 0
assert mock_get_session.call_count == 1
+ sink.write_relationships.assert_not_called()
class TestInternetAnalysis:
@@ -2053,18 +2288,62 @@ class TestInternetAnalysis:
class TestAttackPathsDbUtilsGraphDataReady:
"""Tests for db_utils functions related to graph_data_ready lifecycle."""
+ def test_database_defaults_allow_legacy_insert_without_cutover_columns(
+ self, tenants_fixture, aws_provider, scans_fixture
+ ):
+ tenant = tenants_fixture[0]
+ provider = aws_provider
+ scan = scans_fixture[0]
+
+ attack_paths_scan_id = uuid4()
+ now = datetime.now(tz=UTC)
+
+ with rls_transaction(str(tenant.id), using=DEFAULT_DB_ALIAS) as cursor:
+ cursor.execute(
+ """
+ INSERT INTO attack_paths_scans (
+ id,
+ inserted_at,
+ updated_at,
+ state,
+ progress,
+ graph_data_ready,
+ started_at,
+ tenant_id,
+ provider_id,
+ scan_id
+ )
+ VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
+ """,
+ [
+ attack_paths_scan_id,
+ now,
+ now,
+ StateChoices.SCHEDULED,
+ 0,
+ False,
+ now,
+ tenant.id,
+ provider.id,
+ scan.id,
+ ],
+ )
+
+ attack_paths_scan = AttackPathsScan.objects.get(id=attack_paths_scan_id)
+
+ assert attack_paths_scan.is_migrated is False
+ assert (
+ attack_paths_scan.sink_backend == AttackPathsScan.SinkBackendChoices.NEO4J
+ )
+
def test_create_attack_paths_scan_first_scan_defaults_to_false(
- self, tenants_fixture, providers_fixture, scans_fixture
+ self, tenants_fixture, aws_provider, scans_fixture
):
from tasks.jobs.attack_paths.db_utils import create_attack_paths_scan
tenant = tenants_fixture[0]
- provider = providers_fixture[0]
- provider.provider = Provider.ProviderChoices.AWS
- provider.save()
+ provider = aws_provider
scan = scans_fixture[0]
- scan.provider = provider
- scan.save()
with patch(
"tasks.jobs.attack_paths.db_utils.rls_transaction",
@@ -2076,19 +2355,17 @@ class TestAttackPathsDbUtilsGraphDataReady:
assert attack_paths_scan is not None
assert attack_paths_scan.graph_data_ready is False
+ assert attack_paths_scan.is_migrated is False
+ assert attack_paths_scan.sink_backend == "neo4j"
def test_create_attack_paths_scan_inherits_true_from_previous(
- self, tenants_fixture, providers_fixture, scans_fixture
+ self, tenants_fixture, aws_provider, scans_fixture
):
from tasks.jobs.attack_paths.db_utils import create_attack_paths_scan
tenant = tenants_fixture[0]
- provider = providers_fixture[0]
- provider.provider = Provider.ProviderChoices.AWS
- provider.save()
+ provider = aws_provider
scan = scans_fixture[0]
- scan.provider = provider
- scan.save()
AttackPathsScan.objects.create(
tenant_id=tenant.id,
@@ -2096,6 +2373,8 @@ class TestAttackPathsDbUtilsGraphDataReady:
scan=scan,
state=StateChoices.COMPLETED,
graph_data_ready=True,
+ is_migrated=True,
+ sink_backend="neptune",
)
new_scan = Scan.objects.create(
@@ -2116,19 +2395,110 @@ class TestAttackPathsDbUtilsGraphDataReady:
assert attack_paths_scan is not None
assert attack_paths_scan.graph_data_ready is True
+ # is_migrated tracks the data being served: inherited from the ready scan
+ assert attack_paths_scan.is_migrated is True
+ assert attack_paths_scan.sink_backend == "neptune"
- def test_create_attack_paths_scan_inherits_false_when_no_previous_ready(
- self, tenants_fixture, providers_fixture, scans_fixture
+ def test_create_attack_paths_scan_prefers_active_sink_ready_scan(
+ self, tenants_fixture, aws_provider, scans_fixture, settings
+ ):
+ from tasks.jobs.attack_paths.db_utils import create_attack_paths_scan
+
+ settings.ATTACK_PATHS_SINK_DATABASE = "neo4j"
+ tenant = tenants_fixture[0]
+ provider = aws_provider
+ scan = scans_fixture[0]
+
+ AttackPathsScan.objects.create(
+ tenant_id=tenant.id,
+ provider=provider,
+ scan=scan,
+ state=StateChoices.COMPLETED,
+ graph_data_ready=True,
+ is_migrated=False,
+ sink_backend="neo4j",
+ )
+ AttackPathsScan.objects.create(
+ tenant_id=tenant.id,
+ provider=provider,
+ scan=scan,
+ state=StateChoices.COMPLETED,
+ graph_data_ready=True,
+ is_migrated=True,
+ sink_backend="neptune",
+ )
+
+ new_scan = Scan.objects.create(
+ name="New Scan",
+ provider=provider,
+ trigger=Scan.TriggerChoices.MANUAL,
+ state=StateChoices.AVAILABLE,
+ tenant_id=tenant.id,
+ )
+
+ with patch(
+ "tasks.jobs.attack_paths.db_utils.rls_transaction",
+ new=lambda *args, **kwargs: nullcontext(),
+ ):
+ attack_paths_scan = create_attack_paths_scan(
+ str(tenant.id), str(new_scan.id), provider.id
+ )
+
+ assert attack_paths_scan is not None
+ assert attack_paths_scan.graph_data_ready is True
+ assert attack_paths_scan.is_migrated is False
+ assert attack_paths_scan.sink_backend == "neo4j"
+
+ def test_create_attack_paths_scan_inherits_is_migrated_false_from_legacy_ready(
+ self, tenants_fixture, aws_provider, scans_fixture
):
from tasks.jobs.attack_paths.db_utils import create_attack_paths_scan
tenant = tenants_fixture[0]
- provider = providers_fixture[0]
- provider.provider = Provider.ProviderChoices.AWS
- provider.save()
+ provider = aws_provider
+ scan = scans_fixture[0]
+
+ # Previous scan is ready but pre-cutover (legacy Neo4j graph shape)
+ AttackPathsScan.objects.create(
+ tenant_id=tenant.id,
+ provider=provider,
+ scan=scan,
+ state=StateChoices.COMPLETED,
+ graph_data_ready=True,
+ is_migrated=False,
+ sink_backend="neo4j",
+ )
+
+ new_scan = Scan.objects.create(
+ name="New Scan",
+ provider=provider,
+ trigger=Scan.TriggerChoices.MANUAL,
+ state=StateChoices.AVAILABLE,
+ tenant_id=tenant.id,
+ )
+
+ with patch(
+ "tasks.jobs.attack_paths.db_utils.rls_transaction",
+ new=lambda *args, **kwargs: nullcontext(),
+ ):
+ attack_paths_scan = create_attack_paths_scan(
+ str(tenant.id), str(new_scan.id), provider.id
+ )
+
+ assert attack_paths_scan is not None
+ assert attack_paths_scan.graph_data_ready is True
+ # Reads stay on the legacy catalog/backend until this scan's own sync
+ assert attack_paths_scan.is_migrated is False
+ assert attack_paths_scan.sink_backend == "neo4j"
+
+ def test_create_attack_paths_scan_inherits_false_when_no_previous_ready(
+ self, tenants_fixture, aws_provider, scans_fixture
+ ):
+ from tasks.jobs.attack_paths.db_utils import create_attack_paths_scan
+
+ tenant = tenants_fixture[0]
+ provider = aws_provider
scan = scans_fixture[0]
- scan.provider = provider
- scan.save()
AttackPathsScan.objects.create(
tenant_id=tenant.id,
@@ -2136,6 +2506,7 @@ class TestAttackPathsDbUtilsGraphDataReady:
scan=scan,
state=StateChoices.FAILED,
graph_data_ready=False,
+ sink_backend="neptune",
)
new_scan = Scan.objects.create(
@@ -2156,19 +2527,17 @@ class TestAttackPathsDbUtilsGraphDataReady:
assert attack_paths_scan is not None
assert attack_paths_scan.graph_data_ready is False
+ assert attack_paths_scan.is_migrated is False
+ assert attack_paths_scan.sink_backend == "neo4j"
def test_set_graph_data_ready_updates_field(
- self, tenants_fixture, providers_fixture, scans_fixture
+ self, tenants_fixture, aws_provider, scans_fixture
):
from tasks.jobs.attack_paths.db_utils import set_graph_data_ready
tenant = tenants_fixture[0]
- provider = providers_fixture[0]
- provider.provider = Provider.ProviderChoices.AWS
- provider.save()
+ provider = aws_provider
scan = scans_fixture[0]
- scan.provider = provider
- scan.save()
attack_paths_scan = AttackPathsScan.objects.create(
tenant_id=tenant.id,
@@ -2197,17 +2566,13 @@ class TestAttackPathsDbUtilsGraphDataReady:
assert attack_paths_scan.graph_data_ready is True
def test_finish_attack_paths_scan_does_not_modify_graph_data_ready(
- self, tenants_fixture, providers_fixture, scans_fixture
+ self, tenants_fixture, aws_provider, scans_fixture
):
from tasks.jobs.attack_paths.db_utils import finish_attack_paths_scan
tenant = tenants_fixture[0]
- provider = providers_fixture[0]
- provider.provider = Provider.ProviderChoices.AWS
- provider.save()
+ provider = aws_provider
scan = scans_fixture[0]
- scan.provider = provider
- scan.save()
attack_paths_scan = AttackPathsScan.objects.create(
tenant_id=tenant.id,
@@ -2228,17 +2593,13 @@ class TestAttackPathsDbUtilsGraphDataReady:
assert attack_paths_scan.graph_data_ready is True
def test_finish_attack_paths_scan_preserves_graph_data_ready_on_failure(
- self, tenants_fixture, providers_fixture, scans_fixture
+ self, tenants_fixture, aws_provider, scans_fixture
):
from tasks.jobs.attack_paths.db_utils import finish_attack_paths_scan
tenant = tenants_fixture[0]
- provider = providers_fixture[0]
- provider.provider = Provider.ProviderChoices.AWS
- provider.save()
+ provider = aws_provider
scan = scans_fixture[0]
- scan.provider = provider
- scan.save()
attack_paths_scan = AttackPathsScan.objects.create(
tenant_id=tenant.id,
@@ -2262,19 +2623,15 @@ class TestAttackPathsDbUtilsGraphDataReady:
assert attack_paths_scan.state == StateChoices.FAILED
assert attack_paths_scan.graph_data_ready is True
- def test_set_provider_graph_data_ready_updates_all_scans_for_provider(
- self, tenants_fixture, providers_fixture, scans_fixture
+ def test_set_provider_graph_data_ready_updates_all_scans_for_provider_sink(
+ self, tenants_fixture, aws_provider, scans_fixture
):
from tasks.jobs.attack_paths.db_utils import set_provider_graph_data_ready
tenant = tenants_fixture[0]
- provider = providers_fixture[0]
- provider.provider = Provider.ProviderChoices.AWS
- provider.save()
+ provider = aws_provider
scan_a = scans_fixture[0]
- scan_a.provider = provider
- scan_a.save()
scan_b = Scan.objects.create(
name="Second Scan",
@@ -2290,6 +2647,7 @@ class TestAttackPathsDbUtilsGraphDataReady:
scan=scan_a,
state=StateChoices.COMPLETED,
graph_data_ready=True,
+ sink_backend="neptune",
)
new_ap_scan = AttackPathsScan.objects.create(
tenant_id=tenant.id,
@@ -2297,6 +2655,7 @@ class TestAttackPathsDbUtilsGraphDataReady:
scan=scan_b,
state=StateChoices.EXECUTING,
graph_data_ready=True,
+ sink_backend="neptune",
)
with patch(
@@ -2310,23 +2669,53 @@ class TestAttackPathsDbUtilsGraphDataReady:
assert old_ap_scan.graph_data_ready is False
assert new_ap_scan.graph_data_ready is False
- def test_set_provider_graph_data_ready_does_not_affect_other_providers(
- self, tenants_fixture, providers_fixture, scans_fixture
+ def test_set_provider_graph_data_ready_preserves_other_sink_scans(
+ self, tenants_fixture, aws_provider, scans_fixture
):
from tasks.jobs.attack_paths.db_utils import set_provider_graph_data_ready
tenant = tenants_fixture[0]
- provider_a = providers_fixture[0]
- provider_a.provider = Provider.ProviderChoices.AWS
- provider_a.save()
+ provider = aws_provider
- provider_b = providers_fixture[1]
- provider_b.provider = Provider.ProviderChoices.AWS
- provider_b.save()
+ scan = scans_fixture[0]
+
+ legacy_scan = AttackPathsScan.objects.create(
+ tenant_id=tenant.id,
+ provider=provider,
+ scan=scan,
+ state=StateChoices.COMPLETED,
+ graph_data_ready=True,
+ sink_backend="neo4j",
+ )
+ neptune_scan = AttackPathsScan.objects.create(
+ tenant_id=tenant.id,
+ provider=provider,
+ scan=scan,
+ state=StateChoices.EXECUTING,
+ graph_data_ready=True,
+ sink_backend="neptune",
+ )
+
+ with patch(
+ "tasks.jobs.attack_paths.db_utils.rls_transaction",
+ new=lambda *args, **kwargs: nullcontext(),
+ ):
+ set_provider_graph_data_ready(neptune_scan, False)
+
+ legacy_scan.refresh_from_db()
+ neptune_scan.refresh_from_db()
+ assert legacy_scan.graph_data_ready is True
+ assert neptune_scan.graph_data_ready is False
+
+ def test_set_provider_graph_data_ready_does_not_affect_other_providers(
+ self, tenants_fixture, aws_provider_pair, scans_fixture
+ ):
+ from tasks.jobs.attack_paths.db_utils import set_provider_graph_data_ready
+
+ tenant = tenants_fixture[0]
+ provider_a, provider_b = aws_provider_pair
scan_a = scans_fixture[0]
- scan_a.provider = provider_a
- scan_a.save()
scan_b = Scan.objects.create(
name="Scan for provider B",
@@ -2363,10 +2752,194 @@ class TestAttackPathsDbUtilsGraphDataReady:
assert ap_scan_b.graph_data_ready is True
+class TestAttackPathsWorkerPing:
+ @patch("tasks.jobs.attack_paths.cleanup.current_app")
+ def test_pings_workers_in_parallel_and_retries_only_missing(self, mock_app):
+ from tasks.jobs.attack_paths.cleanup import _ping_workers
+
+ first_ping = MagicMock(return_value={"worker-a@host": {"ok": "pong"}})
+ second_ping = MagicMock(return_value={"worker-b@host": {"ok": "pong"}})
+ third_ping = MagicMock(return_value={"worker-c@host": {"ok": "pong"}})
+ mock_app.control.inspect.side_effect = [
+ MagicMock(ping=first_ping),
+ MagicMock(ping=second_ping),
+ MagicMock(ping=third_ping),
+ ]
+
+ responsive, unresponsive = _ping_workers(
+ {"worker-c@host", "worker-a@host", "worker-b@host"}
+ )
+
+ assert responsive == {
+ "worker-a@host",
+ "worker-b@host",
+ "worker-c@host",
+ }
+ assert unresponsive == set()
+ assert mock_app.control.inspect.call_args_list == [
+ call(
+ destination=["worker-a@host", "worker-b@host", "worker-c@host"],
+ timeout=5,
+ ),
+ call(destination=["worker-b@host", "worker-c@host"], timeout=10),
+ call(destination=["worker-c@host"], timeout=20),
+ ]
+
+ @patch("tasks.jobs.attack_paths.cleanup.logger")
+ @patch("tasks.jobs.attack_paths.cleanup.current_app")
+ def test_retries_intermediate_ping_exceptions(self, mock_app, mock_logger):
+ from tasks.jobs.attack_paths.cleanup import _ping_workers
+
+ mock_app.control.inspect.side_effect = [
+ MagicMock(ping=MagicMock(side_effect=ConnectionError("first"))),
+ MagicMock(ping=MagicMock(side_effect=ConnectionError("second"))),
+ MagicMock(ping=MagicMock(return_value={})),
+ ]
+
+ responsive, unresponsive = _ping_workers({"worker@host"})
+
+ assert responsive == set()
+ assert unresponsive == {"worker@host"}
+ assert mock_logger.warning.call_count == 2
+ assert all(
+ warning.kwargs["exc_info"] is True
+ for warning in mock_logger.warning.call_args_list
+ )
+ mock_logger.exception.assert_not_called()
+
+ @patch("tasks.jobs.attack_paths.cleanup.logger")
+ @patch("tasks.jobs.attack_paths.cleanup.current_app")
+ def test_final_ping_exception_leaves_pending_workers_unknown(
+ self, mock_app, mock_logger
+ ):
+ from tasks.jobs.attack_paths.cleanup import _ping_workers
+
+ mock_app.control.inspect.side_effect = [
+ MagicMock(ping=MagicMock(return_value={"worker-a@host": {"ok": "pong"}})),
+ MagicMock(ping=MagicMock(return_value={})),
+ MagicMock(ping=MagicMock(side_effect=ConnectionError("final"))),
+ ]
+
+ responsive, unresponsive = _ping_workers({"worker-a@host", "worker-b@host"})
+
+ assert responsive == {"worker-a@host"}
+ assert unresponsive is None
+ mock_logger.exception.assert_called_once()
+
+ @patch("tasks.jobs.attack_paths.cleanup.logger")
+ @patch("tasks.jobs.attack_paths.cleanup.current_app")
+ def test_worker_can_respond_after_an_intermediate_exception(
+ self, mock_app, mock_logger
+ ):
+ from tasks.jobs.attack_paths.cleanup import _ping_workers
+
+ mock_app.control.inspect.side_effect = [
+ MagicMock(ping=MagicMock(side_effect=ConnectionError("first"))),
+ MagicMock(ping=MagicMock(side_effect=ConnectionError("second"))),
+ MagicMock(ping=MagicMock(return_value={"worker@host": {"ok": "pong"}})),
+ ]
+
+ responsive, unresponsive = _ping_workers({"worker@host"})
+
+ assert responsive == {"worker@host"}
+ assert unresponsive == set()
+ assert mock_logger.warning.call_count == 2
+ mock_logger.exception.assert_not_called()
+
+
+class TestAttackPathsCleanupTask:
+ @patch(
+ "tasks.tasks.cleanup_stale_attack_paths_scans",
+ return_value={"cleaned_up_count": 1, "scan_ids": ["scan-id"]},
+ )
+ def test_hourly_task_invokes_attack_paths_cleanup(self, mock_cleanup):
+ from tasks.tasks import cleanup_stale_attack_paths_scans_task
+
+ result = cleanup_stale_attack_paths_scans_task.run()
+
+ assert result == {"cleaned_up_count": 1, "scan_ids": ["scan-id"]}
+ mock_cleanup.assert_called_once_with()
+
+
+@pytest.mark.django_db
+class TestAttackPathsDbUtilsActivity:
+ @patch(
+ "tasks.jobs.attack_paths.db_utils.rls_transaction",
+ new=lambda *args, **kwargs: nullcontext(),
+ )
+ def test_starting_scan_refreshes_updated_at(
+ self, tenants_fixture, aws_provider, scans_fixture
+ ):
+ from tasks.jobs.attack_paths.db_utils import starting_attack_paths_scan
+
+ old_updated_at = datetime.now(tz=UTC) - timedelta(hours=1)
+ attack_paths_scan = AttackPathsScan.objects.create(
+ tenant_id=tenants_fixture[0].id,
+ provider=aws_provider,
+ scan=scans_fixture[0],
+ state=StateChoices.SCHEDULED,
+ )
+ AttackPathsScan.objects.filter(id=attack_paths_scan.id).update(
+ updated_at=old_updated_at
+ )
+ attack_paths_scan.refresh_from_db()
+
+ started = starting_attack_paths_scan(
+ attack_paths_scan, SimpleNamespace(update_tag=123)
+ )
+
+ assert attack_paths_scan.updated_at > old_updated_at
+ attack_paths_scan.refresh_from_db()
+ assert started is True
+ assert attack_paths_scan.updated_at > old_updated_at
+
+ @patch(
+ "tasks.jobs.attack_paths.db_utils.rls_transaction",
+ new=lambda *args, **kwargs: nullcontext(),
+ )
+ def test_progress_update_refreshes_updated_at(
+ self, tenants_fixture, aws_provider, scans_fixture
+ ):
+ from tasks.jobs.attack_paths.db_utils import update_attack_paths_scan_progress
+
+ old_updated_at = datetime.now(tz=UTC) - timedelta(hours=1)
+ attack_paths_scan = AttackPathsScan.objects.create(
+ tenant_id=tenants_fixture[0].id,
+ provider=aws_provider,
+ scan=scans_fixture[0],
+ state=StateChoices.EXECUTING,
+ )
+ AttackPathsScan.objects.filter(id=attack_paths_scan.id).update(
+ updated_at=old_updated_at
+ )
+ attack_paths_scan.refresh_from_db()
+
+ update_attack_paths_scan_progress(attack_paths_scan, 42)
+
+ assert attack_paths_scan.updated_at > old_updated_at
+ attack_paths_scan.refresh_from_db()
+ assert attack_paths_scan.progress == 42
+ assert attack_paths_scan.updated_at > old_updated_at
+
+
@pytest.mark.django_db
class TestCleanupStaleAttackPathsScans:
+ @pytest.fixture(autouse=True)
+ def execute_on_commit_callbacks(self):
+ with patch(
+ "tasks.jobs.attack_paths.cleanup.on_commit",
+ side_effect=lambda callback, **kwargs: callback(),
+ ):
+ yield
+
def _create_executing_scan(
- self, tenant, provider, scan=None, started_at=None, worker=None
+ self,
+ tenant,
+ provider,
+ scan=None,
+ started_at=None,
+ updated_at=None,
+ worker=None,
):
"""Helper to create an EXECUTING AttackPathsScan with optional Task+TaskResult."""
ap_scan = AttackPathsScan.objects.create(
@@ -2374,7 +2947,7 @@ class TestCleanupStaleAttackPathsScans:
provider=provider,
scan=scan,
state=StateChoices.EXECUTING,
- started_at=started_at or datetime.now(tz=timezone.utc),
+ started_at=started_at or datetime.now(tz=UTC),
)
task_result = None
@@ -2393,40 +2966,106 @@ class TestCleanupStaleAttackPathsScans:
ap_scan.task = task
ap_scan.save(update_fields=["task_id"])
+ if updated_at is not None:
+ AttackPathsScan.objects.filter(id=ap_scan.id).update(updated_at=updated_at)
+ ap_scan.updated_at = updated_at
+
return ap_scan, task_result
+ @patch(
+ "tasks.jobs.attack_paths.cleanup.rls_transaction",
+ new=lambda *args, **kwargs: nullcontext(),
+ )
+ @patch("tasks.jobs.attack_paths.cleanup._revoke_task")
+ def test_defers_revoke_until_scan_failure_is_persisted(
+ self,
+ mock_revoke,
+ tenants_fixture,
+ aws_provider,
+ ):
+ from tasks.jobs.attack_paths.cleanup import _finalize_failed_scan
+
+ ap_scan, task_result = self._create_executing_scan(
+ tenants_fixture[0],
+ aws_provider,
+ worker="unresponsive-worker@host",
+ )
+
+ with patch("tasks.jobs.attack_paths.cleanup.on_commit") as mock_on_commit:
+ finalized_scan = _finalize_failed_scan(
+ ap_scan,
+ StateChoices.EXECUTING,
+ "Cleanup reason",
+ task_result=task_result,
+ revoke=True,
+ )
+
+ assert finalized_scan is not None
+ ap_scan.refresh_from_db()
+ task_result.refresh_from_db()
+ assert ap_scan.state == StateChoices.FAILED
+ assert task_result.status == "FAILURE"
+ mock_revoke.assert_not_called()
+ mock_on_commit.assert_called_once()
+ assert mock_on_commit.call_args.kwargs == {"using": DEFAULT_DB_ALIAS}
+
+ callback = mock_on_commit.call_args.args[0]
+ callback()
+
+ mock_revoke.assert_called_once_with(task_result, terminate=True)
+
@patch("tasks.jobs.attack_paths.cleanup.recover_graph_data_ready")
@patch("tasks.jobs.attack_paths.cleanup.graph_database.drop_database")
@patch(
"tasks.jobs.attack_paths.cleanup.rls_transaction",
new=lambda *args, **kwargs: nullcontext(),
)
- @patch("tasks.jobs.attack_paths.cleanup._is_worker_alive", return_value=False)
- def test_cleans_up_scan_with_dead_worker(
+ @patch("tasks.jobs.attack_paths.cleanup._revoke_task")
+ @patch("tasks.jobs.attack_paths.cleanup._ping_workers")
+ def test_cleans_up_inactive_scan_with_unresponsive_worker(
self,
- mock_alive,
+ mock_ping,
+ mock_revoke,
mock_drop_db,
mock_recover,
tenants_fixture,
- providers_fixture,
+ aws_provider,
scans_fixture,
):
from tasks.jobs.attack_paths.cleanup import cleanup_stale_attack_paths_scans
+ from tasks.jobs.attack_paths.db_utils import mark_scan_finished
tenant = tenants_fixture[0]
- provider = providers_fixture[0]
- provider.provider = Provider.ProviderChoices.AWS
- provider.save()
+ provider = aws_provider
- # Recent scan — should still be cleaned up because worker is dead
+ updated_at = datetime.now(tz=UTC) - timedelta(minutes=31)
ap_scan, task_result = self._create_executing_scan(
- tenant, provider, worker="dead-worker@host"
+ tenant,
+ provider,
+ updated_at=updated_at,
+ worker="unresponsive-worker@host",
)
+ mock_ping.return_value = (set(), {"unresponsive-worker@host"})
- result = cleanup_stale_attack_paths_scans()
+ with patch(
+ "tasks.jobs.attack_paths.cleanup.mark_scan_finished",
+ wraps=mark_scan_finished,
+ ) as mock_mark_failed:
+ call_order = MagicMock()
+ call_order.attach_mock(mock_revoke, "revoke")
+ call_order.attach_mock(mock_mark_failed, "mark_failed")
+ call_order.attach_mock(mock_drop_db, "drop_database")
+
+ result = cleanup_stale_attack_paths_scans()
assert result["cleaned_up_count"] == 1
assert str(ap_scan.id) in result["scan_ids"]
+ assert [entry[0] for entry in call_order.mock_calls] == [
+ "mark_failed",
+ "revoke",
+ "drop_database",
+ ]
+ mock_revoke.assert_called_once_with(task_result, terminate=True)
mock_drop_db.assert_called_once()
mock_recover.assert_called_once()
@@ -2435,7 +3074,10 @@ class TestCleanupStaleAttackPathsScans:
assert ap_scan.progress == 100
assert ap_scan.completed_at is not None
assert ap_scan.ingestion_exceptions == {
- "global_error": "Worker dead — cleaned up by periodic task"
+ "global_error": (
+ "Worker unresponsive and scan inactive for 30 minutes - "
+ "cleaned up by periodic task"
+ )
}
task_result.refresh_from_db()
@@ -2449,63 +3091,115 @@ class TestCleanupStaleAttackPathsScans:
new=lambda *args, **kwargs: nullcontext(),
)
@patch("tasks.jobs.attack_paths.cleanup._revoke_task")
- @patch("tasks.jobs.attack_paths.cleanup._is_worker_alive", return_value=True)
- def test_revokes_and_cleans_scan_exceeding_threshold_on_live_worker(
+ @patch("tasks.jobs.attack_paths.cleanup._ping_workers")
+ def test_revokes_and_cleans_scan_exceeding_threshold_on_responsive_worker(
self,
- mock_alive,
+ mock_ping,
mock_revoke,
mock_drop_db,
mock_recover,
tenants_fixture,
- providers_fixture,
+ aws_provider,
scans_fixture,
):
from tasks.jobs.attack_paths.cleanup import cleanup_stale_attack_paths_scans
tenant = tenants_fixture[0]
- provider = providers_fixture[0]
- provider.provider = Provider.ProviderChoices.AWS
- provider.save()
+ provider = aws_provider
- old_start = datetime.now(tz=timezone.utc) - timedelta(hours=49)
+ old_start = datetime.now(tz=UTC) - timedelta(hours=49)
ap_scan, task_result = self._create_executing_scan(
tenant, provider, started_at=old_start, worker="live-worker@host"
)
+ mock_ping.return_value = ({"live-worker@host"}, set())
result = cleanup_stale_attack_paths_scans()
assert result["cleaned_up_count"] == 1
- mock_revoke.assert_called_once_with(task_result)
+ mock_revoke.assert_called_once_with(task_result, terminate=True)
mock_recover.assert_called_once()
ap_scan.refresh_from_db()
assert ap_scan.state == StateChoices.FAILED
+ @pytest.mark.parametrize(
+ ("age_seconds", "should_clean"),
+ [
+ (960 * 60 - 1, False),
+ (960 * 60, False),
+ (960 * 60 + 1, True),
+ ],
+ )
+ @patch("tasks.jobs.attack_paths.cleanup.recover_graph_data_ready")
+ @patch("tasks.jobs.attack_paths.cleanup.graph_database.drop_database")
+ @patch(
+ "tasks.jobs.attack_paths.cleanup.rls_transaction",
+ new=lambda *args, **kwargs: nullcontext(),
+ )
+ @patch("tasks.jobs.attack_paths.cleanup._revoke_task")
+ @patch("tasks.jobs.attack_paths.cleanup._ping_workers")
+ def test_stale_threshold_boundary_is_strict(
+ self,
+ mock_ping,
+ mock_revoke,
+ mock_drop_db,
+ mock_recover,
+ age_seconds,
+ should_clean,
+ tenants_fixture,
+ aws_provider,
+ ):
+ from tasks.jobs.attack_paths.cleanup import cleanup_stale_attack_paths_scans
+
+ now = datetime.now(tz=UTC)
+ ap_scan, task_result = self._create_executing_scan(
+ tenants_fixture[0],
+ aws_provider,
+ started_at=now - timedelta(seconds=age_seconds),
+ worker="live-worker@host",
+ )
+ mock_ping.return_value = ({"live-worker@host"}, set())
+
+ with patch("tasks.jobs.attack_paths.cleanup.datetime") as mock_datetime:
+ mock_datetime.now.return_value = now
+ result = cleanup_stale_attack_paths_scans()
+
+ assert result["cleaned_up_count"] == int(should_clean)
+ ap_scan.refresh_from_db()
+ expected_state = StateChoices.FAILED if should_clean else StateChoices.EXECUTING
+ assert ap_scan.state == expected_state
+ if should_clean:
+ mock_revoke.assert_called_once_with(task_result, terminate=True)
+ mock_drop_db.assert_called_once()
+ mock_recover.assert_called_once()
+ else:
+ mock_revoke.assert_not_called()
+ mock_drop_db.assert_not_called()
+ mock_recover.assert_not_called()
+
@patch("tasks.jobs.attack_paths.cleanup.recover_graph_data_ready")
@patch("tasks.jobs.attack_paths.cleanup.graph_database.drop_database")
@patch(
"tasks.jobs.attack_paths.cleanup.rls_transaction",
new=lambda *args, **kwargs: nullcontext(),
)
- @patch("tasks.jobs.attack_paths.cleanup._is_worker_alive", return_value=True)
- def test_ignores_recent_executing_scans_on_live_worker(
+ @patch("tasks.jobs.attack_paths.cleanup._ping_workers")
+ def test_ignores_recent_executing_scans_on_responsive_worker(
self,
- mock_alive,
+ mock_ping,
mock_drop_db,
mock_recover,
tenants_fixture,
- providers_fixture,
+ aws_provider,
scans_fixture,
):
from tasks.jobs.attack_paths.cleanup import cleanup_stale_attack_paths_scans
tenant = tenants_fixture[0]
- provider = providers_fixture[0]
- provider.provider = Provider.ProviderChoices.AWS
- provider.save()
+ provider = aws_provider
- # Recent scan on live worker — should be skipped
self._create_executing_scan(tenant, provider, worker="live-worker@host")
+ mock_ping.return_value = ({"live-worker@host"}, set())
result = cleanup_stale_attack_paths_scans()
@@ -2513,6 +3207,130 @@ class TestCleanupStaleAttackPathsScans:
mock_drop_db.assert_not_called()
mock_recover.assert_not_called()
+ @patch("tasks.jobs.attack_paths.cleanup.recover_graph_data_ready")
+ @patch("tasks.jobs.attack_paths.cleanup.graph_database.drop_database")
+ @patch(
+ "tasks.jobs.attack_paths.cleanup.rls_transaction",
+ new=lambda *args, **kwargs: nullcontext(),
+ )
+ @patch("tasks.jobs.attack_paths.cleanup._revoke_task")
+ @patch("tasks.jobs.attack_paths.cleanup._ping_workers")
+ def test_preserves_recent_scan_on_unresponsive_worker(
+ self,
+ mock_ping,
+ mock_revoke,
+ mock_drop_db,
+ mock_recover,
+ tenants_fixture,
+ aws_provider,
+ scans_fixture,
+ ):
+ from tasks.jobs.attack_paths.cleanup import cleanup_stale_attack_paths_scans
+
+ self._create_executing_scan(
+ tenants_fixture[0],
+ aws_provider,
+ updated_at=datetime.now(tz=UTC) - timedelta(minutes=29),
+ worker="unresponsive-worker@host",
+ )
+ mock_ping.return_value = (set(), {"unresponsive-worker@host"})
+
+ result = cleanup_stale_attack_paths_scans()
+
+ assert result["cleaned_up_count"] == 0
+ mock_revoke.assert_not_called()
+ mock_drop_db.assert_not_called()
+ mock_recover.assert_not_called()
+
+ @patch("tasks.jobs.attack_paths.cleanup.recover_graph_data_ready")
+ @patch("tasks.jobs.attack_paths.cleanup.graph_database.drop_database")
+ @patch(
+ "tasks.jobs.attack_paths.cleanup.rls_transaction",
+ new=lambda *args, **kwargs: nullcontext(),
+ )
+ @patch("tasks.jobs.attack_paths.cleanup._revoke_task")
+ @patch("tasks.jobs.attack_paths.cleanup._ping_workers", return_value=(set(), None))
+ def test_final_ping_exception_preserves_pending_worker_scan(
+ self,
+ mock_ping,
+ mock_revoke,
+ mock_drop_db,
+ mock_recover,
+ tenants_fixture,
+ aws_provider,
+ scans_fixture,
+ ):
+ from tasks.jobs.attack_paths.cleanup import cleanup_stale_attack_paths_scans
+
+ ap_scan, _ = self._create_executing_scan(
+ tenants_fixture[0],
+ aws_provider,
+ updated_at=datetime.now(tz=UTC) - timedelta(hours=1),
+ worker="unknown-worker@host",
+ )
+
+ result = cleanup_stale_attack_paths_scans()
+
+ assert result["cleaned_up_count"] == 0
+ mock_ping.assert_called_once_with({"unknown-worker@host"})
+ ap_scan.refresh_from_db()
+ assert ap_scan.state == StateChoices.EXECUTING
+ mock_revoke.assert_not_called()
+ mock_drop_db.assert_not_called()
+ mock_recover.assert_not_called()
+
+ @pytest.mark.parametrize(
+ ("inactive_seconds", "should_clean"),
+ [(29 * 60 + 59, False), (30 * 60, False), (30 * 60 + 1, True)],
+ )
+ @patch("tasks.jobs.attack_paths.cleanup.recover_graph_data_ready")
+ @patch("tasks.jobs.attack_paths.cleanup.graph_database.drop_database")
+ @patch(
+ "tasks.jobs.attack_paths.cleanup.rls_transaction",
+ new=lambda *args, **kwargs: nullcontext(),
+ )
+ @patch("tasks.jobs.attack_paths.cleanup._revoke_task")
+ @patch("tasks.jobs.attack_paths.cleanup._ping_workers")
+ def test_inactivity_boundary_is_strict(
+ self,
+ mock_ping,
+ mock_revoke,
+ mock_drop_db,
+ mock_recover,
+ inactive_seconds,
+ should_clean,
+ tenants_fixture,
+ aws_provider,
+ scans_fixture,
+ ):
+ from tasks.jobs.attack_paths.cleanup import cleanup_stale_attack_paths_scans
+
+ now = datetime.now(tz=UTC)
+ ap_scan, task_result = self._create_executing_scan(
+ tenants_fixture[0],
+ aws_provider,
+ updated_at=now - timedelta(seconds=inactive_seconds),
+ worker="unresponsive-worker@host",
+ )
+ mock_ping.return_value = (set(), {"unresponsive-worker@host"})
+
+ with patch("tasks.jobs.attack_paths.cleanup.datetime") as mock_datetime:
+ mock_datetime.now.return_value = now
+ result = cleanup_stale_attack_paths_scans()
+
+ assert result["cleaned_up_count"] == int(should_clean)
+ ap_scan.refresh_from_db()
+ expected_state = StateChoices.FAILED if should_clean else StateChoices.EXECUTING
+ assert ap_scan.state == expected_state
+ if should_clean:
+ mock_revoke.assert_called_once_with(task_result, terminate=True)
+ mock_drop_db.assert_called_once()
+ mock_recover.assert_called_once()
+ else:
+ mock_revoke.assert_not_called()
+ mock_drop_db.assert_not_called()
+ mock_recover.assert_not_called()
+
@patch("tasks.jobs.attack_paths.cleanup.recover_graph_data_ready")
@patch("tasks.jobs.attack_paths.cleanup.graph_database.drop_database")
@patch(
@@ -2524,15 +3342,13 @@ class TestCleanupStaleAttackPathsScans:
mock_drop_db,
mock_recover,
tenants_fixture,
- providers_fixture,
+ aws_provider,
scans_fixture,
):
from tasks.jobs.attack_paths.cleanup import cleanup_stale_attack_paths_scans
tenant = tenants_fixture[0]
- provider = providers_fixture[0]
- provider.provider = Provider.ProviderChoices.AWS
- provider.save()
+ provider = aws_provider
AttackPathsScan.objects.create(
tenant_id=tenant.id,
@@ -2553,35 +3369,100 @@ class TestCleanupStaleAttackPathsScans:
@patch("tasks.jobs.attack_paths.cleanup.recover_graph_data_ready")
@patch(
"tasks.jobs.attack_paths.cleanup.graph_database.drop_database",
- side_effect=Exception("Neo4j unreachable"),
+ side_effect=[Exception("Neo4j unreachable"), None],
)
@patch(
"tasks.jobs.attack_paths.cleanup.rls_transaction",
new=lambda *args, **kwargs: nullcontext(),
)
- @patch("tasks.jobs.attack_paths.cleanup._is_worker_alive", return_value=False)
- def test_handles_drop_database_failure_gracefully(
+ @patch("tasks.jobs.attack_paths.cleanup._revoke_task")
+ @patch("tasks.jobs.attack_paths.cleanup._ping_workers")
+ @patch("tasks.jobs.attack_paths.cleanup.logger")
+ def test_neo4j_failure_leaves_scan_failed_and_continues(
self,
- mock_alive,
+ mock_logger,
+ mock_ping,
+ mock_revoke,
mock_drop_db,
mock_recover,
tenants_fixture,
- providers_fixture,
+ aws_provider,
scans_fixture,
):
from tasks.jobs.attack_paths.cleanup import cleanup_stale_attack_paths_scans
tenant = tenants_fixture[0]
- provider = providers_fixture[0]
- provider.provider = Provider.ProviderChoices.AWS
- provider.save()
+ provider = aws_provider
- self._create_executing_scan(tenant, provider, worker="dead-worker@host")
+ updated_at = datetime.now(tz=UTC) - timedelta(minutes=31)
+ ap_scan_1, _ = self._create_executing_scan(
+ tenant,
+ provider,
+ updated_at=updated_at,
+ worker="unresponsive-worker-1@host",
+ )
+ ap_scan_2, _ = self._create_executing_scan(
+ tenant,
+ provider,
+ updated_at=updated_at,
+ worker="unresponsive-worker-2@host",
+ )
+ mock_ping.return_value = (
+ set(),
+ {"unresponsive-worker-1@host", "unresponsive-worker-2@host"},
+ )
result = cleanup_stale_attack_paths_scans()
- assert result["cleaned_up_count"] == 1
- mock_drop_db.assert_called_once()
+ assert result["cleaned_up_count"] == 2
+ assert mock_revoke.call_count == 2
+ assert mock_drop_db.call_count == 2
+ mock_logger.exception.assert_called_once()
+ ap_scan_1.refresh_from_db()
+ ap_scan_2.refresh_from_db()
+ assert ap_scan_1.state == StateChoices.FAILED
+ assert ap_scan_2.state == StateChoices.FAILED
+
+ @patch("tasks.jobs.attack_paths.cleanup.graph_database.drop_database")
+ @patch("tasks.jobs.attack_paths.cleanup._revoke_task")
+ @patch("tasks.jobs.attack_paths.cleanup._ping_workers")
+ @patch(
+ "tasks.jobs.attack_paths.cleanup.rls_transaction",
+ new=lambda *args, **kwargs: nullcontext(),
+ )
+ @patch(
+ "tasks.jobs.attack_paths.cleanup.mark_scan_finished",
+ side_effect=DatabaseError("PostgreSQL unavailable"),
+ )
+ @patch("tasks.jobs.attack_paths.cleanup.logger")
+ def test_postgresql_failure_prevents_revoke_and_neo4j_deletion(
+ self,
+ mock_logger,
+ mock_mark_failed,
+ mock_ping,
+ mock_revoke,
+ mock_drop_db,
+ tenants_fixture,
+ aws_provider,
+ scans_fixture,
+ ):
+ from tasks.jobs.attack_paths.cleanup import cleanup_stale_attack_paths_scans
+
+ self._create_executing_scan(
+ tenants_fixture[0],
+ aws_provider,
+ updated_at=datetime.now(tz=UTC) - timedelta(minutes=31),
+ worker="unresponsive-worker@host",
+ )
+ mock_ping.return_value = (set(), {"unresponsive-worker@host"})
+
+ result = cleanup_stale_attack_paths_scans()
+
+ assert result["cleaned_up_count"] == 0
+ mock_mark_failed.assert_called_once()
+ mock_logger.exception.assert_called_once()
+ mock_revoke.assert_not_called()
+ mock_drop_db.assert_not_called()
@patch("tasks.jobs.attack_paths.cleanup.recover_graph_data_ready")
@patch("tasks.jobs.attack_paths.cleanup.graph_database.drop_database")
@@ -2589,22 +3470,22 @@ class TestCleanupStaleAttackPathsScans:
"tasks.jobs.attack_paths.cleanup.rls_transaction",
new=lambda *args, **kwargs: nullcontext(),
)
- @patch("tasks.jobs.attack_paths.cleanup._is_worker_alive", return_value=False)
+ @patch("tasks.jobs.attack_paths.cleanup._revoke_task")
+ @patch("tasks.jobs.attack_paths.cleanup._ping_workers")
def test_cross_tenant_cleanup(
self,
- mock_alive,
+ mock_ping,
+ mock_revoke,
mock_drop_db,
mock_recover,
tenants_fixture,
- providers_fixture,
+ aws_provider,
):
from tasks.jobs.attack_paths.cleanup import cleanup_stale_attack_paths_scans
tenant1 = tenants_fixture[0]
tenant2 = tenants_fixture[1]
- provider1 = providers_fixture[0]
- provider1.provider = Provider.ProviderChoices.AWS
- provider1.save()
+ provider1 = aws_provider
provider2 = Provider.objects.create(
provider="aws",
@@ -2613,16 +3494,28 @@ class TestCleanupStaleAttackPathsScans:
tenant_id=tenant2.id,
)
+ updated_at = datetime.now(tz=UTC) - timedelta(minutes=31)
ap_scan1, _ = self._create_executing_scan(
- tenant1, provider1, worker="dead-worker-1@host"
+ tenant1,
+ provider1,
+ updated_at=updated_at,
+ worker="unresponsive-worker-1@host",
)
ap_scan2, _ = self._create_executing_scan(
- tenant2, provider2, worker="dead-worker-2@host"
+ tenant2,
+ provider2,
+ updated_at=updated_at,
+ worker="unresponsive-worker-2@host",
+ )
+ mock_ping.return_value = (
+ set(),
+ {"unresponsive-worker-1@host", "unresponsive-worker-2@host"},
)
result = cleanup_stale_attack_paths_scans()
assert result["cleaned_up_count"] == 2
+ assert mock_revoke.call_count == 2
assert mock_recover.call_count == 2
ap_scan1.refresh_from_db()
@@ -2636,29 +3529,34 @@ class TestCleanupStaleAttackPathsScans:
"tasks.jobs.attack_paths.cleanup.rls_transaction",
new=lambda *args, **kwargs: nullcontext(),
)
- @patch("tasks.jobs.attack_paths.cleanup._is_worker_alive", return_value=False)
+ @patch("tasks.jobs.attack_paths.cleanup._revoke_task")
+ @patch("tasks.jobs.attack_paths.cleanup._ping_workers")
def test_recovers_graph_data_ready_for_stale_scan(
self,
- mock_alive,
+ mock_ping,
+ mock_revoke,
mock_drop_db,
mock_recover,
tenants_fixture,
- providers_fixture,
+ aws_provider,
scans_fixture,
):
from tasks.jobs.attack_paths.cleanup import cleanup_stale_attack_paths_scans
tenant = tenants_fixture[0]
- provider = providers_fixture[0]
- provider.provider = Provider.ProviderChoices.AWS
- provider.save()
+ provider = aws_provider
ap_scan, _ = self._create_executing_scan(
- tenant, provider, worker="dead-worker@host"
+ tenant,
+ provider,
+ updated_at=datetime.now(tz=UTC) - timedelta(minutes=31),
+ worker="unresponsive-worker@host",
)
+ mock_ping.return_value = (set(), {"unresponsive-worker@host"})
cleanup_stale_attack_paths_scans()
+ mock_revoke.assert_called_once()
mock_recover.assert_called_once()
recovered_scan = mock_recover.call_args[0][0]
assert recovered_scan.id == ap_scan.id
@@ -2674,18 +3572,16 @@ class TestCleanupStaleAttackPathsScans:
mock_drop_db,
mock_recover,
tenants_fixture,
- providers_fixture,
+ aws_provider,
scans_fixture,
):
from tasks.jobs.attack_paths.cleanup import cleanup_stale_attack_paths_scans
tenant = tenants_fixture[0]
- provider = providers_fixture[0]
- provider.provider = Provider.ProviderChoices.AWS
- provider.save()
+ provider = aws_provider
# Old scan with no Task/TaskResult
- old_start = datetime.now(tz=timezone.utc) - timedelta(hours=49)
+ old_start = datetime.now(tz=UTC) - timedelta(hours=49)
ap_scan = AttackPathsScan.objects.create(
tenant_id=tenant.id,
provider=provider,
@@ -2706,32 +3602,176 @@ class TestCleanupStaleAttackPathsScans:
"tasks.jobs.attack_paths.cleanup.rls_transaction",
new=lambda *args, **kwargs: nullcontext(),
)
- @patch("tasks.jobs.attack_paths.cleanup._is_worker_alive", return_value=False)
- def test_shared_worker_is_pinged_only_once(
+ @patch("tasks.jobs.attack_paths.cleanup._revoke_task")
+ @patch("tasks.jobs.attack_paths.cleanup._ping_workers")
+ def test_preserves_scans_without_a_started_at_timestamp(
self,
- mock_alive,
+ mock_ping,
+ mock_revoke,
mock_drop_db,
mock_recover,
tenants_fixture,
- providers_fixture,
+ aws_provider,
+ scans_fixture,
+ ):
+ from tasks.jobs.attack_paths.cleanup import cleanup_stale_attack_paths_scans
+
+ responsive_scan, _ = self._create_executing_scan(
+ tenants_fixture[0],
+ aws_provider,
+ worker="responsive-worker@host",
+ )
+ AttackPathsScan.objects.filter(id=responsive_scan.id).update(started_at=None)
+ no_worker_scan = AttackPathsScan.objects.create(
+ tenant_id=tenants_fixture[0].id,
+ provider=aws_provider,
+ state=StateChoices.EXECUTING,
+ started_at=None,
+ )
+ mock_ping.return_value = ({"responsive-worker@host"}, set())
+
+ result = cleanup_stale_attack_paths_scans()
+
+ assert result["cleaned_up_count"] == 0
+ responsive_scan.refresh_from_db()
+ no_worker_scan.refresh_from_db()
+ assert responsive_scan.state == StateChoices.EXECUTING
+ assert no_worker_scan.state == StateChoices.EXECUTING
+ mock_revoke.assert_not_called()
+ mock_drop_db.assert_not_called()
+ mock_recover.assert_not_called()
+
+ @patch("tasks.jobs.attack_paths.cleanup.recover_graph_data_ready")
+ @patch("tasks.jobs.attack_paths.cleanup.graph_database.drop_database")
+ @patch(
+ "tasks.jobs.attack_paths.cleanup.rls_transaction",
+ new=lambda *args, **kwargs: nullcontext(),
+ )
+ @patch("tasks.jobs.attack_paths.cleanup._revoke_task")
+ @patch("tasks.jobs.attack_paths.cleanup._ping_workers")
+ def test_shared_worker_is_collected_only_once(
+ self,
+ mock_ping,
+ mock_revoke,
+ mock_drop_db,
+ mock_recover,
+ tenants_fixture,
+ aws_provider,
scans_fixture,
):
from tasks.jobs.attack_paths.cleanup import cleanup_stale_attack_paths_scans
tenant = tenants_fixture[0]
- provider = providers_fixture[0]
- provider.provider = Provider.ProviderChoices.AWS
- provider.save()
+ provider = aws_provider
- # Two scans on the same dead worker
- self._create_executing_scan(tenant, provider, worker="shared-worker@host")
- self._create_executing_scan(tenant, provider, worker="shared-worker@host")
+ updated_at = datetime.now(tz=UTC) - timedelta(minutes=31)
+ self._create_executing_scan(
+ tenant,
+ provider,
+ updated_at=updated_at,
+ worker="shared-worker@host",
+ )
+ self._create_executing_scan(
+ tenant,
+ provider,
+ updated_at=updated_at,
+ worker="shared-worker@host",
+ )
+ mock_ping.return_value = (set(), {"shared-worker@host"})
result = cleanup_stale_attack_paths_scans()
assert result["cleaned_up_count"] == 2
- # Worker should be pinged exactly once — cache prevents second ping
- mock_alive.assert_called_once_with("shared-worker@host")
+ assert mock_revoke.call_count == 2
+ mock_ping.assert_called_once_with({"shared-worker@host"})
+
+ @patch("tasks.jobs.attack_paths.cleanup.recover_graph_data_ready")
+ @patch("tasks.jobs.attack_paths.cleanup.graph_database.drop_database")
+ @patch(
+ "tasks.jobs.attack_paths.cleanup.rls_transaction",
+ new=lambda *args, **kwargs: nullcontext(),
+ )
+ @patch("tasks.jobs.attack_paths.cleanup._revoke_task")
+ def test_locked_recheck_preserves_scan_with_new_activity(
+ self,
+ mock_revoke,
+ mock_drop_db,
+ mock_recover,
+ tenants_fixture,
+ aws_provider,
+ scans_fixture,
+ ):
+ from tasks.jobs.attack_paths.cleanup import cleanup_stale_attack_paths_scans
+
+ ap_scan, _ = self._create_executing_scan(
+ tenants_fixture[0],
+ aws_provider,
+ updated_at=datetime.now(tz=UTC) - timedelta(minutes=31),
+ worker="unresponsive-worker@host",
+ )
+
+ def record_activity(_workers):
+ AttackPathsScan.objects.filter(id=ap_scan.id).update(
+ updated_at=datetime.now(tz=UTC)
+ )
+ return set(), {"unresponsive-worker@host"}
+
+ with patch(
+ "tasks.jobs.attack_paths.cleanup._ping_workers",
+ side_effect=record_activity,
+ ):
+ result = cleanup_stale_attack_paths_scans()
+
+ assert result["cleaned_up_count"] == 0
+ ap_scan.refresh_from_db()
+ assert ap_scan.state == StateChoices.EXECUTING
+ mock_revoke.assert_not_called()
+ mock_drop_db.assert_not_called()
+ mock_recover.assert_not_called()
+
+ @patch("tasks.jobs.attack_paths.cleanup.recover_graph_data_ready")
+ @patch("tasks.jobs.attack_paths.cleanup.graph_database.drop_database")
+ @patch(
+ "tasks.jobs.attack_paths.cleanup.rls_transaction",
+ new=lambda *args, **kwargs: nullcontext(),
+ )
+ @patch("tasks.jobs.attack_paths.cleanup._revoke_task")
+ def test_locked_recheck_preserves_scan_that_changed_state(
+ self,
+ mock_revoke,
+ mock_drop_db,
+ mock_recover,
+ tenants_fixture,
+ aws_provider,
+ scans_fixture,
+ ):
+ from tasks.jobs.attack_paths.cleanup import cleanup_stale_attack_paths_scans
+
+ ap_scan, _ = self._create_executing_scan(
+ tenants_fixture[0],
+ aws_provider,
+ updated_at=datetime.now(tz=UTC) - timedelta(minutes=31),
+ worker="unresponsive-worker@host",
+ )
+
+ def complete_scan(_workers):
+ AttackPathsScan.objects.filter(id=ap_scan.id).update(
+ state=StateChoices.COMPLETED
+ )
+ return set(), {"unresponsive-worker@host"}
+
+ with patch(
+ "tasks.jobs.attack_paths.cleanup._ping_workers",
+ side_effect=complete_scan,
+ ):
+ result = cleanup_stale_attack_paths_scans()
+
+ assert result["cleaned_up_count"] == 0
+ ap_scan.refresh_from_db()
+ assert ap_scan.state == StateChoices.COMPLETED
+ mock_revoke.assert_not_called()
+ mock_drop_db.assert_not_called()
+ mock_recover.assert_not_called()
# `SCHEDULED` state cleanup
def _create_scheduled_scan(
@@ -2761,7 +3801,7 @@ class TestCleanupStaleAttackPathsScans:
provider=provider,
scan=parent_scan,
state=StateChoices.SCHEDULED,
- started_at=datetime.now(tz=timezone.utc) - timedelta(minutes=age_minutes),
+ started_at=datetime.now(tz=UTC) - timedelta(minutes=age_minutes),
)
task_result = None
@@ -2794,14 +3834,12 @@ class TestCleanupStaleAttackPathsScans:
mock_drop_db,
mock_recover,
tenants_fixture,
- providers_fixture,
+ aws_provider,
):
from tasks.jobs.attack_paths.cleanup import cleanup_stale_attack_paths_scans
tenant = tenants_fixture[0]
- provider = providers_fixture[0]
- provider.provider = Provider.ProviderChoices.AWS
- provider.save()
+ provider = aws_provider
ap_scan, task_result = self._create_scheduled_scan(
tenant,
@@ -2820,7 +3858,7 @@ class TestCleanupStaleAttackPathsScans:
assert ap_scan.progress == 100
assert ap_scan.completed_at is not None
assert ap_scan.ingestion_exceptions == {
- "global_error": "Scan never started — cleaned up by periodic task"
+ "global_error": "Scan never started - cleaned up by periodic task"
}
# SCHEDULED revoke must NOT terminate a running worker
@@ -2849,14 +3887,12 @@ class TestCleanupStaleAttackPathsScans:
mock_drop_db,
mock_recover,
tenants_fixture,
- providers_fixture,
+ aws_provider,
):
from tasks.jobs.attack_paths.cleanup import cleanup_stale_attack_paths_scans
tenant = tenants_fixture[0]
- provider = providers_fixture[0]
- provider.provider = Provider.ProviderChoices.AWS
- provider.save()
+ provider = aws_provider
ap_scan, _ = self._create_scheduled_scan(
tenant,
@@ -2872,3 +3908,57 @@ class TestCleanupStaleAttackPathsScans:
ap_scan.refresh_from_db()
assert ap_scan.state == StateChoices.SCHEDULED
mock_revoke.assert_not_called()
+
+
+class TestNormalizeSinkProperties:
+ """Coerce Cartography-emitted property values into sink-portable primitives.
+
+ Lists become comma-strings, dicts become JSON strings, temporals become
+ ISO strings, spatials become their stringified form. The same coercion
+ runs regardless of the active sink so queries are portable.
+ """
+
+ @pytest.mark.parametrize(
+ "raw, expected",
+ [
+ (
+ {"a": "x", "b": 1, "c": 1.5, "d": True, "e": None},
+ {"a": "x", "b": 1, "c": 1.5, "d": True, "e": None},
+ ),
+ (
+ {"actions": ["s3:GetObject", "s3:PutObject"], "tags": []},
+ {"actions": "s3:GetObject,s3:PutObject", "tags": ""},
+ ),
+ (
+ {"condition": {"StringEquals": {"aws:SourceAccount": "123456789012"}}},
+ {
+ "condition": '{"StringEquals": {"aws:SourceAccount": "123456789012"}}'
+ },
+ ),
+ ],
+ )
+ def test_primitive_list_and_dict_branches(self, raw, expected):
+ sync_module._normalize_sink_properties(raw, labels=None)
+ assert raw == expected
+
+ def test_temporal_and_spatial_become_strings(self):
+ class FakeDateTime:
+ def iso_format(self) -> str:
+ return "2026-05-13T10:00:00+00:00"
+
+ class FakeSpatialPoint:
+ def __str__(self) -> str:
+ return "POINT(1.0 2.0)"
+
+ # The spatial branch is detected by module prefix, not by base class.
+ FakeSpatialPoint.__module__ = "neo4j.spatial.fake"
+
+ props = {
+ "created_at": FakeDateTime(),
+ "location": FakeSpatialPoint(),
+ }
+ sync_module._normalize_sink_properties(props, labels=None)
+ assert props == {
+ "created_at": "2026-05-13T10:00:00+00:00",
+ "location": "POINT(1.0 2.0)",
+ }
diff --git a/api/src/backend/tasks/tests/test_backfill.py b/api/src/backend/tasks/tests/test_backfill.py
index 3ab49a15e6..7b01c46480 100644
--- a/api/src/backend/tasks/tests/test_backfill.py
+++ b/api/src/backend/tasks/tests/test_backfill.py
@@ -1,16 +1,8 @@
-from datetime import datetime, timezone
+from datetime import UTC, datetime
from unittest.mock import MagicMock, patch
from uuid import uuid4
import pytest
-from tasks.jobs.backfill import (
- backfill_compliance_summaries,
- backfill_provider_compliance_scores,
- backfill_resource_scan_summaries,
- aggregate_scan_category_summaries,
- aggregate_scan_resource_group_summaries,
-)
-
from api.models import (
ComplianceOverviewSummary,
Finding,
@@ -24,6 +16,13 @@ from api.models import (
)
from prowler.lib.check.models import Severity
from prowler.lib.outputs.finding import Status
+from tasks.jobs.backfill import (
+ aggregate_scan_category_summaries,
+ aggregate_scan_resource_group_summaries,
+ backfill_compliance_summaries,
+ backfill_provider_compliance_scores,
+ backfill_resource_scan_summaries,
+)
@pytest.fixture(scope="function")
@@ -40,9 +39,9 @@ def resource_scan_summary_data(scans_fixture):
@pytest.fixture(scope="function")
-def get_not_completed_scans(providers_fixture):
- provider_id = providers_fixture[0].id
- tenant_id = providers_fixture[0].tenant_id
+def get_not_completed_scans(aws_provider):
+ provider_id = aws_provider.id
+ tenant_id = aws_provider.tenant_id
scan_1 = Scan.objects.create(
tenant_id=tenant_id,
trigger=Scan.TriggerChoices.MANUAL,
@@ -536,7 +535,7 @@ class TestBackfillProviderComplianceScores:
scan2 = scans_fixture[1]
# Set completed_at to make the scan eligible for backfill
- scan.completed_at = datetime.now(timezone.utc)
+ scan.completed_at = datetime.now(UTC)
scan.save()
scan2.state = StateChoices.AVAILABLE
scan2.completed_at = None
diff --git a/api/src/backend/tasks/tests/test_beat.py b/api/src/backend/tasks/tests/test_beat.py
index 5c25e97340..e3ce97a04a 100644
--- a/api/src/backend/tasks/tests/test_beat.py
+++ b/api/src/backend/tasks/tests/test_beat.py
@@ -2,17 +2,16 @@ import json
from unittest.mock import patch
import pytest
-from django_celery_beat.models import IntervalSchedule, PeriodicTask
-from tasks.beat import schedule_provider_scan
-
from api.exceptions import ConflictException
from api.models import Scan
+from django_celery_beat.models import IntervalSchedule, PeriodicTask
+from tasks.beat import schedule_provider_scan
@pytest.mark.django_db
class TestScheduleProviderScan:
- def test_schedule_provider_scan_success(self, providers_fixture):
- provider_instance, *_ = providers_fixture
+ def test_schedule_provider_scan_success(self, aws_provider):
+ provider_instance = aws_provider
with patch(
"tasks.tasks.perform_scheduled_scan_task.apply_async"
@@ -42,8 +41,8 @@ class TestScheduleProviderScan:
"provider_id": str(provider_instance.id),
}
- def test_schedule_provider_scan_already_exists(self, providers_fixture):
- provider_instance, *_ = providers_fixture
+ def test_schedule_provider_scan_already_exists(self, aws_provider):
+ provider_instance = aws_provider
# First, schedule the scan
with patch("tasks.tasks.perform_scheduled_scan_task.apply_async"):
@@ -57,8 +56,8 @@ class TestScheduleProviderScan:
exc_info.value
)
- def test_remove_periodic_task(self, providers_fixture):
- provider_instance = providers_fixture[0]
+ def test_remove_periodic_task(self, aws_provider):
+ provider_instance = aws_provider
assert Scan.objects.count() == 0
with patch("tasks.tasks.perform_scheduled_scan_task.apply_async"):
diff --git a/api/src/backend/tasks/tests/test_connection.py b/api/src/backend/tasks/tests/test_connection.py
index e5e39d8778..e8b27e0b00 100644
--- a/api/src/backend/tasks/tests/test_connection.py
+++ b/api/src/backend/tasks/tests/test_connection.py
@@ -1,16 +1,15 @@
import uuid
-from datetime import datetime, timezone
+from datetime import UTC, datetime
from unittest.mock import MagicMock, patch
import pytest
+from api.models import Integration, LighthouseConfiguration, Provider
from tasks.jobs.connection import (
check_integration_connection,
check_lighthouse_connection,
check_provider_connection,
)
-from api.models import Integration, LighthouseConfiguration, Provider
-
@pytest.mark.parametrize(
"provider_data",
@@ -38,7 +37,7 @@ def test_check_provider_connection(
mock_provider_connection_test.assert_called_once()
assert provider.connected is True
assert provider.connection_last_checked_at is not None
- assert provider.connection_last_checked_at <= datetime.now(tz=timezone.utc)
+ assert provider.connection_last_checked_at <= datetime.now(tz=UTC)
@patch("tasks.jobs.connection.Provider.objects.get")
diff --git a/api/src/backend/tasks/tests/test_deletion.py b/api/src/backend/tasks/tests/test_deletion.py
index 0ed8c5ddb2..9a6c4acc7c 100644
--- a/api/src/backend/tasks/tests/test_deletion.py
+++ b/api/src/backend/tasks/tests/test_deletion.py
@@ -1,16 +1,15 @@
-from unittest.mock import call, patch
+from unittest.mock import MagicMock, call, patch
import pytest
-from django.core.exceptions import ObjectDoesNotExist
-from tasks.jobs.deletion import delete_provider, delete_tenant
-
from api.attack_paths import database as graph_database
from api.models import Provider, Tenant, TenantComplianceSummary
+from django.core.exceptions import ObjectDoesNotExist
+from tasks.jobs.deletion import delete_provider, delete_tenant
@pytest.mark.django_db
class TestDeleteProvider:
- def test_delete_provider_success(self, providers_fixture):
+ def test_delete_provider_success(self, aws_provider):
with (
patch(
"tasks.jobs.deletion.graph_database.get_database_name",
@@ -20,7 +19,7 @@ class TestDeleteProvider:
"tasks.jobs.deletion.graph_database.drop_subgraph"
) as mock_drop_subgraph,
):
- instance = providers_fixture[0]
+ instance = aws_provider
tenant_id = str(instance.tenant_id)
result = delete_provider(tenant_id, instance.id)
@@ -54,17 +53,19 @@ class TestDeleteProvider:
mock_drop_subgraph.assert_not_called()
def test_delete_provider_drops_temp_attack_paths_databases(
- self, providers_fixture, create_attack_paths_scan
+ self, aws_provider, create_attack_paths_scan
):
- instance = providers_fixture[0]
+ instance = aws_provider
tenant_id = str(instance.tenant_id)
aps1 = create_attack_paths_scan(instance)
aps2 = create_attack_paths_scan(instance)
+ backend = MagicMock()
with (
patch(
- "tasks.jobs.deletion.graph_database.drop_subgraph",
+ "tasks.jobs.deletion.sink_module.get_backend_for_name",
+ return_value=backend,
),
patch(
"tasks.jobs.deletion.graph_database.drop_database",
@@ -73,23 +74,68 @@ class TestDeleteProvider:
result = delete_provider(tenant_id, instance.id)
assert result
+ backend.drop_subgraph.assert_called_once_with(
+ graph_database.get_database_name(tenant_id), str(instance.id)
+ )
expected_tmp_calls = [
call(f"db-tmp-scan-{str(aps1.id).lower()}"),
call(f"db-tmp-scan-{str(aps2.id).lower()}"),
]
mock_drop_database.assert_has_calls(expected_tmp_calls, any_order=True)
- def test_delete_provider_continues_when_temp_db_drop_fails(
- self, providers_fixture, create_attack_paths_scan
+ def test_delete_provider_drops_graph_data_from_all_recorded_sinks(
+ self, aws_provider, create_attack_paths_scan
):
- instance = providers_fixture[0]
+ instance = aws_provider
tenant_id = str(instance.tenant_id)
+ create_attack_paths_scan(instance, sink_backend="neo4j")
+ create_attack_paths_scan(instance, sink_backend="neptune")
+ neo4j_backend = MagicMock()
+ neptune_backend = MagicMock()
- create_attack_paths_scan(instance)
+ def get_backend_for_name(name):
+ return {
+ "neo4j": neo4j_backend,
+ "neptune": neptune_backend,
+ }[name]
with (
patch(
- "tasks.jobs.deletion.graph_database.drop_subgraph",
+ "tasks.jobs.deletion.graph_database.get_database_name",
+ return_value="tenant-db",
+ ),
+ patch(
+ "tasks.jobs.deletion.sink_module.get_backend_for_name",
+ side_effect=get_backend_for_name,
+ ) as mock_get_backend_for_name,
+ patch("tasks.jobs.deletion.graph_database.drop_database"),
+ ):
+ result = delete_provider(tenant_id, instance.id)
+
+ assert result
+ mock_get_backend_for_name.assert_has_calls(
+ [call("neo4j"), call("neptune")], any_order=True
+ )
+ neo4j_backend.drop_subgraph.assert_called_once_with(
+ "tenant-db", str(instance.id)
+ )
+ neptune_backend.drop_subgraph.assert_called_once_with(
+ "tenant-db", str(instance.id)
+ )
+
+ def test_delete_provider_continues_when_temp_db_drop_fails(
+ self, aws_provider, create_attack_paths_scan
+ ):
+ instance = aws_provider
+ tenant_id = str(instance.tenant_id)
+
+ create_attack_paths_scan(instance)
+ backend = MagicMock()
+
+ with (
+ patch(
+ "tasks.jobs.deletion.sink_module.get_backend_for_name",
+ return_value=backend,
),
patch(
"tasks.jobs.deletion.graph_database.drop_database",
@@ -105,10 +151,10 @@ class TestDeleteProvider:
def test_delete_provider_recalculates_tenant_compliance_summary(
self,
- providers_fixture,
+ aws_provider_pair,
provider_compliance_scores_fixture,
):
- instance = providers_fixture[0]
+ instance = aws_provider_pair[0]
tenant_id = instance.tenant_id
TenantComplianceSummary.objects.create(
@@ -153,7 +199,7 @@ class TestDeleteProvider:
@pytest.mark.django_db
class TestDeleteTenant:
- def test_delete_tenant_success(self, tenants_fixture, providers_fixture):
+ def test_delete_tenant_success(self, tenants_fixture, aws_provider):
"""
Test successful deletion of a tenant and its related data.
"""
diff --git a/api/src/backend/tasks/tests/test_integrations.py b/api/src/backend/tasks/tests/test_integrations.py
index e246405cdd..bebb813feb 100644
--- a/api/src/backend/tasks/tests/test_integrations.py
+++ b/api/src/backend/tasks/tests/test_integrations.py
@@ -1,7 +1,16 @@
from unittest.mock import MagicMock, patch
import pytest
+from api.db_router import READ_REPLICA_ALIAS, MainRouter
+from api.models import Integration
+from api.utils import prowler_integration_connection_test
from django.db import OperationalError
+from prowler.lib.outputs.jira.exceptions.exceptions import (
+ JiraRefreshTokenError,
+ JiraRequiredCustomFieldsError,
+)
+from prowler.providers.aws.lib.security_hub.security_hub import SecurityHubConnection
+from prowler.providers.common.models import Connection
from tasks.jobs.integrations import (
get_s3_client_from_integration,
get_security_hub_client_from_integration,
@@ -10,12 +19,6 @@ from tasks.jobs.integrations import (
upload_security_hub_integration,
)
-from api.db_router import READ_REPLICA_ALIAS, MainRouter
-from api.models import Integration
-from api.utils import prowler_integration_connection_test
-from prowler.providers.aws.lib.security_hub.security_hub import SecurityHubConnection
-from prowler.providers.common.models import Connection
-
@pytest.mark.django_db
class TestS3IntegrationUploads:
@@ -264,10 +267,9 @@ class TestS3IntegrationUploads:
def test_s3_integration_rejects_invalid_output_directory_characters(self):
"""Test that S3 integration validation rejects invalid characters."""
- from rest_framework.exceptions import ValidationError
-
from api.models import Integration
from api.v1.serializers import BaseWriteIntegrationSerializer
+ from rest_framework.exceptions import ValidationError
integration_type = Integration.IntegrationChoices.AMAZON_S3
providers = []
@@ -290,10 +292,9 @@ class TestS3IntegrationUploads:
def test_s3_integration_rejects_empty_output_directory(self):
"""Test that S3 integration validation rejects empty directories."""
- from rest_framework.exceptions import ValidationError
-
from api.models import Integration
from api.v1.serializers import BaseWriteIntegrationSerializer
+ from rest_framework.exceptions import ValidationError
integration_type = Integration.IntegrationChoices.AMAZON_S3
providers = []
@@ -1833,10 +1834,213 @@ class TestJiraIntegration:
)
# Assertions
- assert result == {"created_count": 2, "failed_count": 1}
+ assert result == {
+ "created_count": 2,
+ "failed_count": 1,
+ "error": "Failed to create Jira issue.",
+ }
# Verify error was logged for the failed finding
- mock_logger.error.assert_called_with("Failed to send finding finding-2 to Jira")
+ mock_logger.error.assert_called_with("Failed to create Jira issue.")
+
+ @patch("tasks.jobs.integrations.rls_transaction")
+ @patch("tasks.jobs.integrations.Finding")
+ @patch("tasks.jobs.integrations.Integration")
+ @patch("tasks.jobs.integrations.initialize_prowler_integration")
+ @patch("tasks.jobs.integrations.logger")
+ def test_send_findings_to_jira_preserves_exception_message(
+ self,
+ mock_logger,
+ mock_initialize_integration,
+ mock_integration_model,
+ mock_finding_model,
+ mock_rls_transaction,
+ ):
+ """Test Jira send exceptions are returned for UI polling."""
+ tenant_id = "tenant-123"
+ integration_id = "integration-456"
+ project_key = "PROJ"
+ issue_type = "Task"
+ finding_ids = ["finding-1"]
+ error_message = "Jira project requires custom fields: Team is required"
+
+ mock_rls_transaction.return_value.__enter__ = MagicMock()
+ mock_rls_transaction.return_value.__exit__ = MagicMock()
+
+ integration = MagicMock()
+ mock_integration_model.objects.get.return_value = integration
+
+ mock_jira_integration = MagicMock()
+
+ mock_jira_integration.send_finding.side_effect = JiraRequiredCustomFieldsError(
+ message=error_message
+ )
+ mock_initialize_integration.return_value = mock_jira_integration
+
+ finding = MagicMock()
+ finding.id = "finding-1"
+ finding.check_id = "check_001"
+ finding.severity = "high"
+ finding.status = "FAIL"
+ finding.status_extended = "Resource is not compliant"
+ finding.compliance = {}
+ finding.resources.exists.return_value = False
+ finding.resources.first.return_value = None
+ finding.scan.provider.provider = "aws"
+ finding.check_metadata = {
+ "checktitle": "Check Title",
+ "risk": "High risk",
+ "remediation": {"recommendation": {}, "code": {}},
+ }
+ mock_select_related = mock_finding_model.all_objects.select_related.return_value
+ mock_finding_query = mock_select_related.prefetch_related.return_value
+ mock_finding_query.get.return_value = finding
+
+ result = send_findings_to_jira(
+ tenant_id, integration_id, project_key, issue_type, finding_ids
+ )
+
+ assert result == {
+ "created_count": 0,
+ "failed_count": 1,
+ "error": error_message,
+ }
+ mock_logger.exception.assert_called_with(
+ "Failed to send finding %s to Jira: %s",
+ "finding-1",
+ error_message,
+ )
+
+ @patch("tasks.jobs.integrations.rls_transaction")
+ @patch("tasks.jobs.integrations.Finding")
+ @patch("tasks.jobs.integrations.Integration")
+ @patch("tasks.jobs.integrations.initialize_prowler_integration")
+ @patch("tasks.jobs.integrations.logger")
+ def test_send_findings_to_jira_preserves_refresh_token_error_message(
+ self,
+ mock_logger,
+ mock_initialize_integration,
+ mock_integration_model,
+ mock_finding_model,
+ mock_rls_transaction,
+ ):
+ """Test Jira refresh token exceptions return their UI-friendly message."""
+ tenant_id = "tenant-123"
+ integration_id = "integration-456"
+ project_key = "PROJ"
+ issue_type = "Task"
+ finding_ids = ["finding-1"]
+ error_message = "Failed to refresh the access token"
+
+ mock_rls_transaction.return_value.__enter__ = MagicMock()
+ mock_rls_transaction.return_value.__exit__ = MagicMock()
+
+ integration = MagicMock()
+ mock_integration_model.objects.get.return_value = integration
+
+ mock_jira_integration = MagicMock()
+
+ mock_jira_integration.send_finding.side_effect = JiraRefreshTokenError(
+ message=error_message
+ )
+ mock_initialize_integration.return_value = mock_jira_integration
+
+ finding = MagicMock()
+ finding.id = "finding-1"
+ finding.check_id = "check_001"
+ finding.severity = "high"
+ finding.status = "FAIL"
+ finding.status_extended = "Resource is not compliant"
+ finding.compliance = {}
+ finding.resources.exists.return_value = False
+ finding.resources.first.return_value = None
+ finding.scan.provider.provider = "aws"
+ finding.check_metadata = {
+ "checktitle": "Check Title",
+ "risk": "High risk",
+ "remediation": {"recommendation": {}, "code": {}},
+ }
+ mock_select_related = mock_finding_model.all_objects.select_related.return_value
+ mock_finding_query = mock_select_related.prefetch_related.return_value
+ mock_finding_query.get.return_value = finding
+
+ result = send_findings_to_jira(
+ tenant_id, integration_id, project_key, issue_type, finding_ids
+ )
+
+ assert result == {
+ "created_count": 0,
+ "failed_count": 1,
+ "error": error_message,
+ }
+ mock_logger.exception.assert_called_with(
+ "Failed to send finding %s to Jira: %s",
+ "finding-1",
+ error_message,
+ )
+
+ @patch("tasks.jobs.integrations.rls_transaction")
+ @patch("tasks.jobs.integrations.Finding")
+ @patch("tasks.jobs.integrations.Integration")
+ @patch("tasks.jobs.integrations.initialize_prowler_integration")
+ @patch("tasks.jobs.integrations.logger")
+ def test_send_findings_to_jira_sanitizes_unexpected_exception_message(
+ self,
+ mock_logger,
+ mock_initialize_integration,
+ mock_integration_model,
+ mock_finding_model,
+ mock_rls_transaction,
+ ):
+ """Test unexpected Jira send exceptions do not leak raw details to UI."""
+ tenant_id = "tenant-123"
+ integration_id = "integration-456"
+ project_key = "PROJ"
+ issue_type = "Task"
+ finding_ids = ["finding-1"]
+
+ mock_rls_transaction.return_value.__enter__ = MagicMock()
+ mock_rls_transaction.return_value.__exit__ = MagicMock()
+
+ integration = MagicMock()
+ mock_integration_model.objects.get.return_value = integration
+
+ mock_jira_integration = MagicMock()
+ mock_jira_integration.send_finding.side_effect = Exception("token=secret-value")
+ mock_initialize_integration.return_value = mock_jira_integration
+
+ finding = MagicMock()
+ finding.id = "finding-1"
+ finding.check_id = "check_001"
+ finding.severity = "high"
+ finding.status = "FAIL"
+ finding.status_extended = "Resource is not compliant"
+ finding.compliance = {}
+ finding.resources.exists.return_value = False
+ finding.resources.first.return_value = None
+ finding.scan.provider.provider = "aws"
+ finding.check_metadata = {
+ "checktitle": "Check Title",
+ "risk": "High risk",
+ "remediation": {"recommendation": {}, "code": {}},
+ }
+ mock_select_related = mock_finding_model.all_objects.select_related.return_value
+ mock_finding_query = mock_select_related.prefetch_related.return_value
+ mock_finding_query.get.return_value = finding
+
+ result = send_findings_to_jira(
+ tenant_id, integration_id, project_key, issue_type, finding_ids
+ )
+
+ assert result == {
+ "created_count": 0,
+ "failed_count": 1,
+ "error": "Failed to create Jira issue.",
+ }
+ assert "secret-value" not in result["error"]
+ mock_logger.exception.assert_called_with(
+ "Failed to send finding %s to Jira", "finding-1"
+ )
@patch("tasks.jobs.integrations.rls_transaction")
@patch("tasks.jobs.integrations.Finding")
diff --git a/api/src/backend/tasks/tests/test_muting.py b/api/src/backend/tasks/tests/test_muting.py
index d8ae310f2e..2e542980bf 100644
--- a/api/src/backend/tasks/tests/test_muting.py
+++ b/api/src/backend/tasks/tests/test_muting.py
@@ -1,13 +1,12 @@
-from datetime import datetime, timezone
+from datetime import UTC, datetime
from uuid import uuid4
import pytest
-from django.core.exceptions import ObjectDoesNotExist
-from tasks.jobs.muting import mute_historical_findings
-
from api.models import Finding, MuteRule
+from django.core.exceptions import ObjectDoesNotExist
from prowler.lib.check.models import Severity
from prowler.lib.outputs.finding import Status
+from tasks.jobs.muting import mute_historical_findings
@pytest.mark.django_db
@@ -162,7 +161,7 @@ class TestMuteHistoricalFindings:
"Description": f"Muted description {i}",
},
muted=True,
- muted_at=datetime.now(timezone.utc),
+ muted_at=datetime.now(UTC),
muted_reason="Already muted",
)
muted_uids.append(finding.uid)
diff --git a/api/src/backend/tasks/tests/test_orphan_recovery.py b/api/src/backend/tasks/tests/test_orphan_recovery.py
new file mode 100644
index 0000000000..074e311b70
--- /dev/null
+++ b/api/src/backend/tasks/tests/test_orphan_recovery.py
@@ -0,0 +1,421 @@
+from datetime import UTC, datetime, timedelta
+from unittest.mock import MagicMock, patch
+from uuid import uuid4
+
+import pytest
+from celery import states
+from django.test import override_settings
+from django_celery_results.models import TaskResult
+from tasks.jobs.orphan_recovery import (
+ _SKIP_RECOVERY,
+ _decode_celery_field,
+ _reconcile_task_results,
+ _recovery_attempt_count,
+ advisory_lock,
+ is_worker_alive,
+ reconcile_orphans,
+ reenqueueable_tasks,
+ revoke_task,
+)
+
+
+def _orphan_result(*, name, kwargs, worker, created_minutes_ago, status=states.STARTED):
+ """Create a TaskResult mimicking an in-flight task, backdated past the grace."""
+ tr = TaskResult.objects.create(
+ task_id=str(uuid4()),
+ status=status,
+ task_name=name,
+ worker=worker,
+ task_kwargs=repr(kwargs),
+ task_args=repr([]),
+ )
+ TaskResult.objects.filter(pk=tr.pk).update(
+ date_created=datetime.now(tz=UTC) - timedelta(minutes=created_minutes_ago)
+ )
+ tr.refresh_from_db()
+ return tr
+
+
+@pytest.mark.django_db
+class TestDecodeCeleryField:
+ def test_decodes_single_encoded_repr(self):
+ assert _decode_celery_field("{'tenant_id': 'abc'}", {}) == {"tenant_id": "abc"}
+
+ def test_decodes_double_encoded(self):
+ import json
+
+ stored = json.dumps(repr({"tenant_id": "abc", "scan_id": "s1"}))
+ assert _decode_celery_field(stored, {}) == {"tenant_id": "abc", "scan_id": "s1"}
+
+ def test_empty_returns_default(self):
+ assert _decode_celery_field(None, {}) == {}
+ assert _decode_celery_field("", []) == []
+
+ def test_unparseable_raises(self):
+ with pytest.raises(ValueError):
+ _decode_celery_field("<>", {})
+
+
+@pytest.mark.django_db
+class TestReconcileTaskResults:
+ def _patches(self, alive):
+ """Patch worker liveness, revoke, and the task registry for re-enqueue."""
+ mock_app = MagicMock()
+ mock_task = MagicMock()
+ mock_app.tasks.get.return_value = mock_task
+ return (
+ patch("tasks.jobs.orphan_recovery.is_worker_alive", return_value=alive),
+ patch("tasks.jobs.orphan_recovery.revoke_task"),
+ patch("tasks.jobs.orphan_recovery.current_app", mock_app),
+ mock_task,
+ )
+
+ def test_recovers_non_scan_task(self, tenants_fixture):
+ """A NON-scan task (tenant-deletion) left orphaned is re-enqueued too."""
+ tenant = tenants_fixture[0]
+ tr = _orphan_result(
+ name="tenant-deletion",
+ kwargs={"tenant_id": str(tenant.id)},
+ worker="dead@gone",
+ created_minutes_ago=60,
+ )
+ p_alive, p_revoke, p_app, mock_task = self._patches(alive=False)
+ with (
+ p_alive,
+ p_revoke,
+ p_app,
+ patch("tasks.jobs.orphan_recovery._recovery_attempt_count", return_value=1),
+ ):
+ result = _reconcile_task_results(
+ grace_minutes=2, max_attempts=3, window_hours=6, dry_run=False
+ )
+
+ assert tr.task_id in result["recovered"]
+ tr.refresh_from_db()
+ assert tr.status == states.REVOKED # stale result cleared (no pending alert)
+ mock_task.apply_async.assert_called_once()
+ call = mock_task.apply_async.call_args.kwargs
+ assert call["kwargs"] == {"tenant_id": str(tenant.id)}
+ assert call["task_id"] != tr.task_id # fresh task id
+
+ def test_external_integration_task_is_not_reenqueued_by_default(
+ self, tenants_fixture
+ ):
+ """External side-effect tasks without proven idempotency stay terminal.
+
+ integration-s3 rebuilds its upload from worker-local files that do not
+ survive the crash, so re-enqueuing it would upload nothing.
+ """
+ tr = _orphan_result(
+ name="integration-s3",
+ kwargs={
+ "tenant_id": str(tenants_fixture[0].id),
+ "provider_id": str(uuid4()),
+ "output_directory": "/tmp/gone",
+ },
+ worker="dead@gone",
+ created_minutes_ago=60,
+ )
+ p_alive, p_revoke, p_app, mock_task = self._patches(alive=False)
+ with (
+ p_alive,
+ p_revoke,
+ p_app,
+ patch("tasks.jobs.orphan_recovery._recovery_attempt_count", return_value=1),
+ ):
+ result = _reconcile_task_results(
+ grace_minutes=2, max_attempts=3, window_hours=6, dry_run=False
+ )
+
+ assert tr.task_id in result["failed"]
+ mock_task.apply_async.assert_not_called()
+
+ @override_settings(TASK_RECOVERY_SUMMARIES_ENABLED=False)
+ def test_disabled_group_task_is_not_reenqueued(self, tenants_fixture):
+ """A task whose group feature flag is off stays terminal, not re-enqueued."""
+ tr = _orphan_result(
+ name="scan-summary",
+ kwargs={
+ "tenant_id": str(tenants_fixture[0].id),
+ "scan_id": str(uuid4()),
+ },
+ worker="dead@gone",
+ created_minutes_ago=60,
+ )
+ p_alive, p_revoke, p_app, mock_task = self._patches(alive=False)
+ with (
+ p_alive,
+ p_revoke,
+ p_app,
+ patch("tasks.jobs.orphan_recovery._recovery_attempt_count", return_value=1),
+ ):
+ result = _reconcile_task_results(
+ grace_minutes=2, max_attempts=3, window_hours=6, dry_run=False
+ )
+
+ assert tr.task_id in result["failed"]
+ mock_task.apply_async.assert_not_called()
+
+ @override_settings(TASK_RECOVERY_SUMMARIES_ENABLED=False)
+ def test_disabled_group_task_does_not_consume_recovery_attempt(
+ self, tenants_fixture
+ ):
+ """A disabled-group task is failed without incrementing its Valkey attempt
+ counter, so re-enabling the group does not start it at the cap."""
+ tr = _orphan_result(
+ name="scan-summary",
+ kwargs={"tenant_id": str(tenants_fixture[0].id), "scan_id": str(uuid4())},
+ worker="dead@gone",
+ created_minutes_ago=60,
+ )
+ p_alive, p_revoke, p_app, mock_task = self._patches(alive=False)
+ with (
+ p_alive,
+ p_revoke,
+ p_app,
+ patch("tasks.jobs.orphan_recovery._recovery_attempt_count") as mock_count,
+ ):
+ result = _reconcile_task_results(
+ grace_minutes=2, max_attempts=3, window_hours=6, dry_run=False
+ )
+
+ assert tr.task_id in result["failed"]
+ mock_count.assert_not_called()
+
+ @pytest.mark.parametrize(
+ "task_name",
+ [
+ "scan-perform",
+ "attack-paths-scan-perform",
+ "attack-paths-cleanup-stale-scans",
+ ],
+ )
+ def test_scan_task_is_skipped_entirely(self, tenants_fixture, task_name):
+ """Scan tasks are excluded from recovery: the watchdog never touches them."""
+ tr = _orphan_result(
+ name=task_name,
+ kwargs={
+ "tenant_id": str(tenants_fixture[0].id),
+ "scan_id": str(uuid4()),
+ },
+ worker="dead@gone",
+ created_minutes_ago=60,
+ )
+ p_alive, p_revoke, p_app, mock_task = self._patches(alive=False)
+ with p_alive, p_revoke, p_app:
+ result = _reconcile_task_results(
+ grace_minutes=2, max_attempts=3, window_hours=6, dry_run=False
+ )
+
+ assert tr.task_id not in result["recovered"]
+ assert tr.task_id not in result["failed"]
+ assert tr.task_id not in result["skipped"]
+ mock_task.apply_async.assert_not_called()
+
+ def test_jira_integration_task_is_not_reenqueued(self, tenants_fixture):
+ """integration-jira stays terminal: re-running it would create duplicate Jira
+ issues, so an orphaned send is failed instead of re-enqueued."""
+ tenant = tenants_fixture[0]
+ kwargs = {
+ "tenant_id": str(tenant.id),
+ "integration_id": str(uuid4()),
+ "project_key": "PROWLER",
+ "issue_type": "Task",
+ "finding_ids": [str(uuid4()), str(uuid4())],
+ }
+ tr = _orphan_result(
+ name="integration-jira",
+ kwargs=kwargs,
+ worker="dead@gone",
+ created_minutes_ago=60,
+ )
+ p_alive, p_revoke, p_app, mock_task = self._patches(alive=False)
+ with (
+ p_alive,
+ p_revoke,
+ p_app,
+ patch("tasks.jobs.orphan_recovery._recovery_attempt_count", return_value=1),
+ ):
+ result = _reconcile_task_results(
+ grace_minutes=2, max_attempts=3, window_hours=6, dry_run=False
+ )
+
+ assert tr.task_id in result["failed"]
+ tr.refresh_from_db()
+ assert tr.status == states.REVOKED # stale result cleared (no pending alert)
+ mock_task.apply_async.assert_not_called()
+
+ def test_skips_live_worker(self, tenants_fixture):
+ tr = _orphan_result(
+ name="tenant-deletion",
+ kwargs={"tenant_id": str(tenants_fixture[0].id)},
+ worker="alive@host",
+ created_minutes_ago=60,
+ )
+ p_alive, p_revoke, p_app, mock_task = self._patches(alive=True)
+ with p_alive, p_revoke, p_app:
+ result = _reconcile_task_results(
+ grace_minutes=2, max_attempts=3, window_hours=6, dry_run=False
+ )
+
+ assert tr.task_id in result["skipped"]
+ mock_task.apply_async.assert_not_called()
+
+ def test_skips_recently_created(self, tenants_fixture):
+ tr = _orphan_result(
+ name="tenant-deletion",
+ kwargs={"tenant_id": str(tenants_fixture[0].id)},
+ worker="dead@gone",
+ created_minutes_ago=0,
+ )
+ p_alive, p_revoke, p_app, mock_task = self._patches(alive=False)
+ with p_alive, p_revoke, p_app:
+ result = _reconcile_task_results(
+ grace_minutes=2, max_attempts=3, window_hours=6, dry_run=False
+ )
+
+ # too recent: excluded by the grace window (not even a candidate)
+ assert tr.task_id not in result["recovered"]
+ mock_task.apply_async.assert_not_called()
+
+ def test_denylisted_task_failed_not_reenqueued(self, tenants_fixture):
+ """A non-allowlisted task is failed, never blind re-run."""
+ tr = _orphan_result(
+ name="some-non-idempotent-task",
+ kwargs={"tenant_id": str(tenants_fixture[0].id)},
+ worker="dead@gone",
+ created_minutes_ago=60,
+ )
+ p_alive, p_revoke, p_app, mock_task = self._patches(alive=False)
+ with (
+ p_alive,
+ p_revoke,
+ p_app,
+ patch("tasks.jobs.orphan_recovery._recovery_attempt_count", return_value=1),
+ ):
+ result = _reconcile_task_results(
+ grace_minutes=2, max_attempts=3, window_hours=6, dry_run=False
+ )
+
+ assert tr.task_id in result["failed"]
+ tr.refresh_from_db()
+ assert tr.status == states.REVOKED
+ mock_task.apply_async.assert_not_called()
+
+ def test_recovery_cap_marks_failed(self, tenants_fixture):
+ """When the recovery counter exceeds the cap, the task is failed not re-run."""
+ tr = _orphan_result(
+ name="tenant-deletion",
+ kwargs={"tenant_id": str(tenants_fixture[0].id)},
+ worker="dead@gone",
+ created_minutes_ago=60,
+ )
+ p_alive, p_revoke, p_app, mock_task = self._patches(alive=False)
+ with (
+ p_alive,
+ p_revoke,
+ p_app,
+ patch("tasks.jobs.orphan_recovery._recovery_attempt_count", return_value=4),
+ ):
+ result = _reconcile_task_results(
+ grace_minutes=2, max_attempts=3, window_hours=6, dry_run=False
+ )
+
+ assert tr.task_id in result["failed"]
+ mock_task.apply_async.assert_not_called()
+
+
+@pytest.mark.django_db
+class TestOrphanRecoveryHelpers:
+ def test_advisory_lock_acquires_and_releases(self):
+ with advisory_lock() as acquired:
+ assert acquired is True
+
+ def test_is_worker_alive_true_when_responds(self):
+ inspect = MagicMock()
+ inspect.ping.return_value = {"w@h": {"ok": "pong"}}
+ with patch(
+ "tasks.jobs.orphan_recovery.current_app.control.inspect",
+ return_value=inspect,
+ ):
+ assert is_worker_alive("w@h") is True
+
+ def test_is_worker_alive_false_when_silent(self):
+ inspect = MagicMock()
+ inspect.ping.return_value = None
+ with patch(
+ "tasks.jobs.orphan_recovery.current_app.control.inspect",
+ return_value=inspect,
+ ):
+ assert is_worker_alive("w@h") is False
+
+ def test_revoke_task_terminates_with_sigterm_by_default(self):
+ task_result = MagicMock(task_id="task-id")
+ with patch(
+ "tasks.jobs.orphan_recovery.current_app.control.revoke"
+ ) as mock_revoke:
+ revoke_task(task_result)
+
+ mock_revoke.assert_called_once_with("task-id", terminate=True, signal="SIGTERM")
+
+ def test_recovery_attempt_count_increments(self):
+ # Unique signature so the Valkey counter starts fresh for this test.
+ kwargs_repr = repr({"probe": str(uuid4())})
+ redis_client = MagicMock()
+ redis_client.incr.side_effect = [1, 2]
+ with patch("redis.from_url", return_value=redis_client):
+ assert _recovery_attempt_count("probe-task", kwargs_repr, 6) == 1
+ assert _recovery_attempt_count("probe-task", kwargs_repr, 6) == 2
+
+
+class TestRecoveryFeatureFlags:
+ def test_attack_paths_tasks_are_excluded_from_generic_recovery(self):
+ assert {
+ "attack-paths-scan-perform",
+ "attack-paths-cleanup-stale-scans",
+ } <= _SKIP_RECOVERY
+
+ def test_all_groups_enabled_by_default(self):
+ tasks = reenqueueable_tasks()
+ assert "scan-summary" in tasks
+ assert {"provider-deletion", "tenant-deletion"} <= tasks
+
+ @override_settings(TASK_RECOVERY_SUMMARIES_ENABLED=False)
+ def test_summaries_group_flag_excludes_summary_tasks(self):
+ tasks = reenqueueable_tasks()
+ assert "scan-summary" not in tasks
+ assert "scan-compliance-overviews" not in tasks
+ assert "provider-deletion" in tasks
+
+ @override_settings(TASK_RECOVERY_DELETIONS_ENABLED=False)
+ def test_deletions_group_flag_excludes_deletion_tasks(self):
+ tasks = reenqueueable_tasks()
+ assert "provider-deletion" not in tasks
+ assert "tenant-deletion" not in tasks
+ assert "scan-summary" in tasks
+
+
+@pytest.mark.django_db
+class TestRecoveryMasterFlag:
+ @override_settings(TASK_RECOVERY_ENABLED=False)
+ def test_master_flag_disables_task_recovery(self):
+ with patch(
+ "tasks.jobs.orphan_recovery._reconcile_task_results"
+ ) as mock_reconcile:
+ result = reconcile_orphans(grace_minutes=2, max_attempts=3, dry_run=False)
+
+ mock_reconcile.assert_not_called()
+ assert result["acquired"] is True
+ assert result["enabled"] is False
+ assert "attack_paths" not in result
+
+ @override_settings(TASK_RECOVERY_ENABLED=True)
+ def test_master_flag_enabled_runs_task_recovery(self):
+ with patch(
+ "tasks.jobs.orphan_recovery._reconcile_task_results",
+ return_value={"recovered": [], "failed": [], "skipped": []},
+ ) as mock_reconcile:
+ result = reconcile_orphans(grace_minutes=2, max_attempts=3, dry_run=False)
+
+ mock_reconcile.assert_called_once()
+ assert "attack_paths" not in result
diff --git a/api/src/backend/tasks/tests/test_reports.py b/api/src/backend/tasks/tests/test_reports.py
index ad8a2ff29e..626237922f 100644
--- a/api/src/backend/tasks/tests/test_reports.py
+++ b/api/src/backend/tasks/tests/test_reports.py
@@ -5,10 +5,20 @@ from unittest.mock import Mock, patch
import matplotlib
import pytest
+from api.models import (
+ Finding,
+ Resource,
+ ResourceFindingMapping,
+ ResourceTag,
+ ResourceTagMapping,
+ StateChoices,
+ StatusChoices,
+)
+from prowler.lib.check.models import Severity
from reportlab.lib import colors
from tasks.jobs.report import (
- STALE_TMP_OUTPUT_MAX_AGE_HOURS,
STALE_TMP_OUTPUT_LOCK_FILE_NAME,
+ STALE_TMP_OUTPUT_MAX_AGE_HOURS,
_cleanup_stale_tmp_output_directories,
_is_scan_directory_protected,
_pick_latest_cis_variant,
@@ -35,25 +45,48 @@ from tasks.jobs.reports import (
get_color_for_risk_level,
get_color_for_weight,
)
+from tasks.jobs.reports import cis as cis_report_module
+from tasks.jobs.reports import csa as csa_report_module
+from tasks.jobs.reports import ens as ens_report_module
+from tasks.jobs.reports import nis2 as nis2_report_module
+from tasks.jobs.reports import threatscore as threatscore_report_module
from tasks.jobs.threatscore_utils import (
_aggregate_requirement_statistics_from_database,
_load_findings_for_requirement_checks,
)
-
-from api.models import (
- Finding,
- Resource,
- ResourceFindingMapping,
- ResourceTag,
- ResourceTagMapping,
- StateChoices,
- StatusChoices,
-)
-from prowler.lib.check.models import Severity
+from tasks.tests.report_test_helpers import patch_chart_helpers, patch_report_gc
matplotlib.use("Agg") # Use non-interactive backend for tests
+@pytest.fixture
+def patch_report_rendering(monkeypatch):
+ patch_report_gc(monkeypatch)
+ patch_chart_helpers(
+ monkeypatch,
+ cis_report_module,
+ (
+ "create_pie_chart",
+ "create_horizontal_bar_chart",
+ "create_stacked_bar_chart",
+ ),
+ )
+ patch_chart_helpers(
+ monkeypatch, csa_report_module, ("create_horizontal_bar_chart",)
+ )
+ patch_chart_helpers(
+ monkeypatch,
+ ens_report_module,
+ ("create_horizontal_bar_chart", "create_radar_chart"),
+ )
+ patch_chart_helpers(
+ monkeypatch, nis2_report_module, ("create_horizontal_bar_chart",)
+ )
+ patch_chart_helpers(
+ monkeypatch, threatscore_report_module, ("create_vertical_bar_chart",)
+ )
+
+
@pytest.mark.django_db
class TestAggregateRequirementStatistics:
"""Test suite for _aggregate_requirement_statistics_from_database function."""
@@ -356,7 +389,7 @@ class TestPDFStylesCreation:
class TestLoadFindingsForChecks:
"""Test suite for _load_findings_for_requirement_checks function."""
- def test_empty_check_ids_returns_empty(self, tenants_fixture, providers_fixture):
+ def test_empty_check_ids_returns_empty(self, tenants_fixture):
"""Test that empty check_ids list returns empty dict."""
tenant = tenants_fixture[0]
@@ -377,8 +410,8 @@ class TestLoadFindingsForChecks:
finding. Without ``prefetch_related`` that's 2N additional queries;
with prefetch it collapses to a small constant per iterator chunk.
"""
- from django.test.utils import CaptureQueriesContext
from django.db import connections
+ from django.test.utils import CaptureQueriesContext
tenant = tenants_fixture[0]
scan = scans_fixture[0]
@@ -539,12 +572,12 @@ class TestLoadFindingsForChecks:
total_counts_out=totals,
)
- assert (
- len(result[check_id]) == 5
- ), f"cap=5 should yield exactly 5 loaded findings, got {len(result[check_id])}"
- assert (
- totals[check_id] == 12
- ), f"total_counts_out should report the pre-cap total (12), got {totals[check_id]}"
+ assert len(result[check_id]) == 5, (
+ f"cap=5 should yield exactly 5 loaded findings, got {len(result[check_id])}"
+ )
+ assert totals[check_id] == 12, (
+ f"total_counts_out should report the pre-cap total (12), got {totals[check_id]}"
+ )
def test_only_failed_findings_pushes_down_to_sql(
self, tenants_fixture, scans_fixture
@@ -616,13 +649,13 @@ class TestLoadFindingsForChecks:
loaded = result[check_id]
assert len(loaded) == 3, f"expected 3 FAIL findings, got {len(loaded)}"
statuses = {getattr(f, "status", None) for f in loaded}
- assert statuses == {
- StatusChoices.FAIL
- }, f"expected all loaded findings to be FAIL; got statuses {statuses}"
+ assert statuses == {StatusChoices.FAIL}, (
+ f"expected all loaded findings to be FAIL; got statuses {statuses}"
+ )
# total_counts must reflect the FAIL-only total, not the global total.
- assert (
- totals[check_id] == 3
- ), f"total_counts should be FAIL-only (3), got {totals[check_id]}"
+ assert totals[check_id] == 3, (
+ f"total_counts should be FAIL-only (3), got {totals[check_id]}"
+ )
def test_max_findings_per_check_disabled(self, tenants_fixture, scans_fixture):
"""``MAX_FINDINGS_PER_CHECK=0`` disables the cap; load all rows."""
@@ -1042,23 +1075,24 @@ class TestStaleCleanupProtectionHelpers:
@pytest.mark.django_db
+@pytest.mark.usefixtures("patch_report_rendering")
class TestGenerateThreatscoreReportFunction:
"""Test suite for generate_threatscore_report function."""
- @patch("tasks.jobs.reports.base.initialize_prowler_provider")
+ @patch("tasks.jobs.reports.base.build_provider_metadata")
def test_generate_threatscore_report_exception_handling(
self,
- mock_initialize_provider,
+ mock_build_provider_metadata,
tenants_fixture,
scans_fixture,
- providers_fixture,
+ aws_provider,
):
"""Test that exceptions during report generation are properly handled."""
tenant = tenants_fixture[0]
scan = scans_fixture[0]
- provider = providers_fixture[0]
+ provider = aws_provider
- mock_initialize_provider.side_effect = Exception("Test exception")
+ mock_build_provider_metadata.side_effect = Exception("Test exception")
with pytest.raises(Exception) as exc_info:
generate_threatscore_report(
@@ -1073,6 +1107,7 @@ class TestGenerateThreatscoreReportFunction:
@pytest.mark.django_db
+@pytest.mark.usefixtures("patch_report_rendering")
class TestGenerateComplianceReportsOptimized:
"""Test suite for generate_compliance_reports function."""
@@ -1088,12 +1123,12 @@ class TestGenerateComplianceReportsOptimized:
mock_upload,
tenants_fixture,
scans_fixture,
- providers_fixture,
+ aws_provider,
):
"""Test that function returns early when scan has no findings."""
tenant = tenants_fixture[0]
scan = scans_fixture[0]
- provider = providers_fixture[0]
+ provider = aws_provider
result = generate_compliance_reports(
tenant_id=str(tenant.id),
@@ -1145,14 +1180,14 @@ class TestGenerateComplianceReportsOptimized:
mock_upload,
tenants_fixture,
scans_fixture,
- providers_fixture,
+ aws_provider,
):
"""Scan with no findings and ``generate_cis=True`` must yield a flat
``{"upload": False, "path": ""}`` entry, consistent with the other
frameworks (no nested dict, no sentinel keys)."""
tenant = tenants_fixture[0]
scan = scans_fixture[0]
- provider = providers_fixture[0]
+ provider = aws_provider
result = generate_compliance_reports(
tenant_id=str(tenant.id),
@@ -1168,7 +1203,6 @@ class TestGenerateComplianceReportsOptimized:
assert result["cis"] == {"upload": False, "path": ""}
mock_cis.assert_not_called()
- @patch("api.utils.initialize_prowler_provider")
@patch("tasks.jobs.report.rmtree")
@patch("tasks.jobs.report._upload_to_s3")
@patch("tasks.jobs.report.generate_cis_report")
@@ -1195,7 +1229,6 @@ class TestGenerateComplianceReportsOptimized:
mock_cis,
mock_upload_to_s3,
mock_rmtree,
- mock_init_provider,
):
"""After each framework finishes, exclusive entries are evicted.
@@ -1205,6 +1238,7 @@ class TestGenerateComplianceReportsOptimized:
ThreatScore finishes, before ENS runs.
"""
from types import SimpleNamespace
+
from tasks.jobs import report as report_mod
mock_scan_summary_filter.return_value.exists.return_value = True
@@ -1223,7 +1257,6 @@ class TestGenerateComplianceReportsOptimized:
mock_aggregate_stats.return_value = {}
mock_generate_output_dir.return_value = "/tmp/tenant/scan/x/prowler-out"
mock_upload_to_s3.return_value = "s3://bucket/tenant/scan/x/report.pdf"
- mock_init_provider.return_value = Mock(name="prowler_provider")
# Seed the cache as if both frameworks had already loaded their
# findings. We mutate it indirectly: each generator wrapper is a
@@ -1259,14 +1292,14 @@ class TestGenerateComplianceReportsOptimized:
# ``tsc_only`` was exclusive to ThreatScore → evicted before ENS ran.
# ``shared`` is still pending for ENS → must remain.
- assert (
- "tsc_only" not in observed_state["cache_keys_when_ens_runs"]
- ), "tsc_only should have been evicted before ENS ran"
- assert (
- "shared" in observed_state["cache_keys_when_ens_runs"]
- ), "shared must remain in cache because ENS still needs it"
+ assert "tsc_only" not in observed_state["cache_keys_when_ens_runs"], (
+ "tsc_only should have been evicted before ENS ran"
+ )
+ assert "shared" in observed_state["cache_keys_when_ens_runs"], (
+ "shared must remain in cache because ENS still needs it"
+ )
- @patch("tasks.jobs.report.initialize_prowler_provider")
+ @patch("tasks.jobs.report.build_provider_metadata")
@patch("tasks.jobs.report.rmtree")
@patch("tasks.jobs.report._upload_to_s3")
@patch("tasks.jobs.report.generate_cis_report")
@@ -1279,7 +1312,7 @@ class TestGenerateComplianceReportsOptimized:
@patch("tasks.jobs.report.Compliance.get_bulk")
@patch("tasks.jobs.report.Provider.objects.get")
@patch("tasks.jobs.report.ScanSummary.objects.filter")
- def test_prowler_provider_initialized_once(
+ def test_provider_metadata_built_once(
self,
mock_scan_summary_filter,
mock_provider_get,
@@ -1293,11 +1326,11 @@ class TestGenerateComplianceReportsOptimized:
mock_cis,
mock_upload_to_s3,
mock_rmtree,
- mock_init_provider,
+ mock_build_metadata,
):
- """``initialize_prowler_provider`` must be called exactly once for
- the whole batch (PROWLER-1733). Previously each generator re-init'd
- the SDK provider in ``_load_compliance_data`` → 5 inits per scan.
+ """``build_provider_metadata`` must be called exactly once for the
+ whole batch and its result shared across all 5 reports
+ (PROWLER-1733 / PROWLER-2145).
"""
mock_scan_summary_filter.return_value.exists.return_value = True
mock_provider_get.return_value = Mock(uid="provider-uid", provider="aws")
@@ -1306,7 +1339,7 @@ class TestGenerateComplianceReportsOptimized:
mock_aggregate_stats.return_value = {}
mock_generate_output_dir.return_value = "/tmp/tenant/scan/x/prowler-out"
mock_upload_to_s3.return_value = "s3://bucket/tenant/scan/x/report.pdf"
- mock_init_provider.return_value = Mock(name="prowler_provider")
+ mock_build_metadata.return_value = Mock(name="prowler_provider")
generate_compliance_reports(
tenant_id=str(uuid.uuid4()),
@@ -1325,14 +1358,14 @@ class TestGenerateComplianceReportsOptimized:
mock_nis2.assert_called_once()
mock_csa.assert_called_once()
mock_cis.assert_called_once()
- # …but the SDK provider was initialized only once.
- assert mock_init_provider.call_count == 1, (
- f"expected 1 init, got {mock_init_provider.call_count} "
+ # …but the provider metadata stub was built only once.
+ assert mock_build_metadata.call_count == 1, (
+ f"expected 1 build, got {mock_build_metadata.call_count} "
f"(prowler_provider must be shared across reports)"
)
# The shared instance must reach every wrapper as kwargs.
- shared = mock_init_provider.return_value
+ shared = mock_build_metadata.return_value
for mock_wrapper in (
mock_threatscore,
mock_ens,
@@ -1442,6 +1475,7 @@ class TestGenerateComplianceReportsOptimized:
@pytest.mark.django_db
+@pytest.mark.usefixtures("patch_report_rendering")
class TestGenerateComplianceReportsCIS:
"""Test suite covering the CIS branch of generate_compliance_reports."""
@@ -1471,7 +1505,7 @@ class TestGenerateComplianceReportsCIS:
monkeypatch,
tenants_fixture,
scans_fixture,
- providers_fixture,
+ aws_provider,
):
"""CIS branch should generate a single PDF for the highest version.
@@ -1481,7 +1515,7 @@ class TestGenerateComplianceReportsCIS:
"""
tenant = tenants_fixture[0]
scan = scans_fixture[0]
- provider = providers_fixture[0]
+ provider = aws_provider
self._force_scan_has_findings(monkeypatch)
@@ -1530,12 +1564,12 @@ class TestGenerateComplianceReportsCIS:
monkeypatch,
tenants_fixture,
scans_fixture,
- providers_fixture,
+ aws_provider,
):
"""A failure in the latest CIS variant must be surfaced in the flat results entry."""
tenant = tenants_fixture[0]
scan = scans_fixture[0]
- provider = providers_fixture[0]
+ provider = aws_provider
self._force_scan_has_findings(monkeypatch)
@@ -1577,14 +1611,14 @@ class TestGenerateComplianceReportsCIS:
monkeypatch,
tenants_fixture,
scans_fixture,
- providers_fixture,
+ aws_provider,
):
"""When ``Compliance.get_bulk`` returns no CIS entry the CIS branch
must skip cleanly and record a flat ``{"upload": False, "path": ""}``
entry — no hard-coded provider whitelist is consulted."""
tenant = tenants_fixture[0]
scan = scans_fixture[0]
- provider = providers_fixture[0]
+ provider = aws_provider
self._force_scan_has_findings(monkeypatch)
mock_stats.return_value = {}
@@ -1616,12 +1650,12 @@ class TestGenerateComplianceReportsCIS:
monkeypatch,
tenants_fixture,
scans_fixture,
- providers_fixture,
+ aws_provider,
):
"""CIS output dir errors must be captured in results (not raised)."""
tenant = tenants_fixture[0]
scan = scans_fixture[0]
- provider = providers_fixture[0]
+ provider = aws_provider
self._force_scan_has_findings(monkeypatch)
mock_stats.return_value = {}
diff --git a/api/src/backend/tasks/tests/test_reports_base.py b/api/src/backend/tasks/tests/test_reports_base.py
index 6246654436..7577903bae 100644
--- a/api/src/backend/tasks/tests/test_reports_base.py
+++ b/api/src/backend/tasks/tests/test_reports_base.py
@@ -43,6 +43,7 @@ from tasks.jobs.reports import ( # Configuration; Colors; Components; Charts; B
get_framework_config,
get_status_color,
)
+from tasks.tests.report_test_helpers import PNG_SIGNATURE, fake_png_buffer
# =============================================================================
# Configuration Tests
@@ -452,174 +453,47 @@ class TestSectionHeader:
# =============================================================================
-class TestChartCreation:
- """Tests for chart creation functions."""
+class TestChartRenderingSmoke:
+ """Small real-render coverage for the chart helpers."""
- 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
+ @pytest.mark.parametrize(
+ ("chart_helper", "kwargs"),
+ [
+ (
+ create_vertical_bar_chart,
+ {"labels": ["Section 1", "Section 2"], "values": [90, 70]},
+ ),
+ (
+ create_horizontal_bar_chart,
+ {"labels": ["Category 1", "Category 2"], "values": [85, 65]},
+ ),
+ (
+ create_radar_chart,
+ {"labels": ["A", "B", "C"], "values": [50, 60, 70]},
+ ),
+ (
+ create_pie_chart,
+ {"labels": ["Pass", "Fail"], "values": [80, 20]},
+ ),
+ (
+ create_stacked_bar_chart,
+ {
+ "labels": ["Section 1", "Section 2"],
+ "data_series": {"Pass": [8, 6], "Fail": [2, 4]},
+ },
+ ),
+ ],
+ )
+ def test_chart_helper_renders_valid_png(self, chart_helper, kwargs):
+ buffer = chart_helper(**kwargs)
+ image_bytes = buffer.getvalue()
- 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)
+ assert image_bytes
+ assert image_bytes.startswith(PNG_SIGNATURE)
- 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)
+ buffer.seek(0)
+ assert Image(buffer, width=1 * inch, height=1 * inch)
# =============================================================================
@@ -1056,10 +930,7 @@ class TestExampleReportGenerator:
]
def create_charts_section(self, data):
- chart_buffer = create_vertical_bar_chart(
- labels=["Pass", "Fail"],
- values=[80, 20],
- )
+ chart_buffer = fake_png_buffer()
return [Image(chart_buffer, width=6 * inch, height=4 * inch)]
def create_requirements_index(self, data):
@@ -1150,63 +1021,6 @@ class TestExampleReportGenerator:
# =============================================================================
-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."""
diff --git a/api/src/backend/tasks/tests/test_reports_cis.py b/api/src/backend/tasks/tests/test_reports_cis.py
index 2d4528c82d..b57780b8a4 100644
--- a/api/src/backend/tasks/tests/test_reports_cis.py
+++ b/api/src/backend/tasks/tests/test_reports_cis.py
@@ -1,15 +1,16 @@
from unittest.mock import Mock, patch
import pytest
+from api.models import StatusChoices
from reportlab.platypus import Image, LongTable, Paragraph, Table
from tasks.jobs.reports import FRAMEWORK_REGISTRY, ComplianceData, RequirementData
+from tasks.jobs.reports import cis as cis_report_module
from tasks.jobs.reports.cis import (
CISReportGenerator,
_normalize_profile,
_profile_badge_text,
)
-
-from api.models import StatusChoices
+from tasks.tests.report_test_helpers import patch_chart_helpers
# =============================================================================
# Fixtures
@@ -400,18 +401,69 @@ class TestCISExecutiveSummary:
class TestCISChartsSection:
- def test_charts_rendered(self, cis_generator, populated_cis_compliance_data):
- elements = cis_generator.create_charts_section(populated_cis_compliance_data)
- # At least 1 image for the pie + 1 for section bar + 1 for stacked
- images = [e for e in elements if isinstance(e, Image)]
- assert len(images) >= 1
+ def test_charts_rendered(
+ self, monkeypatch, cis_generator, populated_cis_compliance_data
+ ):
+ chart_calls = patch_chart_helpers(
+ monkeypatch,
+ cis_report_module,
+ (
+ "create_pie_chart",
+ "create_horizontal_bar_chart",
+ "create_stacked_bar_chart",
+ ),
+ )
- def test_charts_no_data_no_crash(self, cis_generator, basic_cis_compliance_data):
+ elements = cis_generator.create_charts_section(populated_cis_compliance_data)
+
+ images = [e for e in elements if isinstance(e, Image)]
+ assert len(images) == 3
+
+ pie_kwargs = chart_calls["create_pie_chart"][0]["kwargs"]
+ assert pie_kwargs["labels"] == ["Pass (2)", "Fail (2)", "Manual (1)"]
+ assert pie_kwargs["values"] == [2, 2, 1]
+ assert pie_kwargs["colors"]
+
+ bar_kwargs = chart_calls["create_horizontal_bar_chart"][0]["kwargs"]
+ assert set(bar_kwargs["labels"]) == {
+ "1 Identity and Access Management",
+ "2 Storage",
+ }
+ assert bar_kwargs["values"] == [50.0, 50.0]
+ assert bar_kwargs["xlabel"] == "Compliance (%)"
+ assert bar_kwargs["color_func"]
+ assert bar_kwargs["label_fontsize"] == 9
+
+ stacked_kwargs = chart_calls["create_stacked_bar_chart"][0]["kwargs"]
+ assert stacked_kwargs["labels"] == ["Level 1", "Level 2"]
+ assert stacked_kwargs["data_series"] == {
+ "Pass": [1, 1],
+ "Fail": [2, 0],
+ "Manual": [0, 1],
+ }
+ assert stacked_kwargs["xlabel"] == "Profile"
+ assert stacked_kwargs["ylabel"] == "Requirements"
+
+ def test_charts_no_data_no_crash(
+ self, monkeypatch, cis_generator, basic_cis_compliance_data
+ ):
+ chart_calls = patch_chart_helpers(
+ monkeypatch,
+ cis_report_module,
+ (
+ "create_pie_chart",
+ "create_horizontal_bar_chart",
+ "create_stacked_bar_chart",
+ ),
+ )
basic_cis_compliance_data.requirements = []
basic_cis_compliance_data.attributes_by_requirement_id = {}
elements = cis_generator.create_charts_section(basic_cis_compliance_data)
- # Must not raise; may or may not have any Image
+
assert isinstance(elements, list)
+ assert chart_calls["create_pie_chart"] == []
+ assert chart_calls["create_horizontal_bar_chart"] == []
+ assert chart_calls["create_stacked_bar_chart"] == []
# =============================================================================
diff --git a/api/src/backend/tasks/tests/test_reports_csa.py b/api/src/backend/tasks/tests/test_reports_csa.py
index 602b9bb28e..7a97018f6c 100644
--- a/api/src/backend/tasks/tests/test_reports_csa.py
+++ b/api/src/backend/tasks/tests/test_reports_csa.py
@@ -2,9 +2,11 @@ import io
from unittest.mock import Mock
import pytest
-from reportlab.platypus import PageBreak, Paragraph, Table
+from reportlab.platypus import Image, PageBreak, Paragraph, Table
from tasks.jobs.reports import FRAMEWORK_REGISTRY, ComplianceData, RequirementData
+from tasks.jobs.reports import csa as csa_report_module
from tasks.jobs.reports.csa import CSAReportGenerator
+from tasks.tests.report_test_helpers import patch_chart_helpers
# Use string status values directly to avoid Django DB initialization
@@ -29,6 +31,13 @@ def csa_generator():
return CSAReportGenerator(config)
+@pytest.fixture
+def patched_csa_charts(monkeypatch):
+ return patch_chart_helpers(
+ monkeypatch, csa_report_module, ("create_horizontal_bar_chart",)
+ )
+
+
@pytest.fixture
def mock_csa_requirement_attribute_iam():
"""Create a mock CSA CCM requirement attribute for Identity & Access Management."""
@@ -80,7 +89,7 @@ def basic_csa_compliance_data():
tenant_id="tenant-123",
scan_id="scan-456",
provider_id="provider-789",
- compliance_id="csa_ccm_4.0_aws",
+ compliance_id="csa_ccm_4.0",
framework="CSA-CCM",
name="CSA Cloud Controls Matrix v4.0",
version="4.0",
@@ -320,7 +329,7 @@ class TestCSAChartsSection:
"""Test suite for CSA charts section generation."""
def test_charts_section_has_section_chart_title(
- self, csa_generator, basic_csa_compliance_data
+ self, csa_generator, basic_csa_compliance_data, patched_csa_charts
):
"""Test that charts section has section compliance title."""
basic_csa_compliance_data.requirements = []
@@ -331,9 +340,14 @@ class TestCSAChartsSection:
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
+ assert any(isinstance(e, Image) for e in elements)
+ chart_kwargs = patched_csa_charts["create_horizontal_bar_chart"][0]["kwargs"]
+ assert chart_kwargs["labels"] == []
+ assert chart_kwargs["values"] == []
+ assert chart_kwargs["xlabel"] == "Compliance (%)"
def test_charts_section_has_page_break(
- self, csa_generator, basic_csa_compliance_data
+ self, csa_generator, basic_csa_compliance_data, patched_csa_charts
):
"""Test that charts section has page breaks."""
basic_csa_compliance_data.requirements = []
@@ -343,12 +357,14 @@ class TestCSAChartsSection:
page_breaks = [e for e in elements if isinstance(e, PageBreak)]
assert len(page_breaks) >= 1
+ assert len(patched_csa_charts["create_horizontal_bar_chart"]) == 1
def test_charts_section_has_section_breakdown(
self,
csa_generator,
basic_csa_compliance_data,
mock_csa_requirement_attribute_iam,
+ patched_csa_charts,
):
"""Test that charts section includes section breakdown table."""
basic_csa_compliance_data.requirements = [
@@ -372,6 +388,11 @@ class TestCSAChartsSection:
paragraphs = [e for e in elements if isinstance(e, Paragraph)]
content = " ".join(str(p.text) for p in paragraphs)
assert "Section" in content or "Breakdown" in content
+ assert any(isinstance(e, Image) for e in elements)
+ chart_kwargs = patched_csa_charts["create_horizontal_bar_chart"][0]["kwargs"]
+ assert chart_kwargs["labels"] == ["Identity & Access Management"]
+ assert chart_kwargs["values"] == [100.0]
+ assert chart_kwargs["color_func"]
# =============================================================================
@@ -387,6 +408,7 @@ class TestCSASectionChart:
csa_generator,
basic_csa_compliance_data,
mock_csa_requirement_attribute_iam,
+ patched_csa_charts,
):
"""Test that section chart is created successfully."""
basic_csa_compliance_data.requirements = [
@@ -409,12 +431,17 @@ class TestCSASectionChart:
assert isinstance(chart_buffer, io.BytesIO)
assert chart_buffer.getvalue() # Not empty
+ chart_kwargs = patched_csa_charts["create_horizontal_bar_chart"][0]["kwargs"]
+ assert chart_kwargs["labels"] == ["Identity & Access Management"]
+ assert chart_kwargs["values"] == [100.0]
+ assert chart_kwargs["xlabel"] == "Compliance (%)"
def test_section_chart_excludes_manual(
self,
csa_generator,
basic_csa_compliance_data,
mock_csa_requirement_attribute_iam,
+ patched_csa_charts,
):
"""Test that manual requirements are excluded from section chart."""
basic_csa_compliance_data.requirements = [
@@ -447,6 +474,9 @@ class TestCSASectionChart:
# Should not raise any errors
chart_buffer = csa_generator._create_section_chart(basic_csa_compliance_data)
assert isinstance(chart_buffer, io.BytesIO)
+ chart_kwargs = patched_csa_charts["create_horizontal_bar_chart"][0]["kwargs"]
+ assert chart_kwargs["labels"] == ["Identity & Access Management"]
+ assert chart_kwargs["values"] == [100.0]
def test_section_chart_multiple_sections(
self,
@@ -455,6 +485,7 @@ class TestCSASectionChart:
mock_csa_requirement_attribute_iam,
mock_csa_requirement_attribute_logging,
mock_csa_requirement_attribute_crypto,
+ patched_csa_charts,
):
"""Test section chart with multiple sections."""
basic_csa_compliance_data.requirements = [
@@ -501,6 +532,13 @@ class TestCSASectionChart:
chart_buffer = csa_generator._create_section_chart(basic_csa_compliance_data)
assert isinstance(chart_buffer, io.BytesIO)
+ chart_kwargs = patched_csa_charts["create_horizontal_bar_chart"][0]["kwargs"]
+ assert chart_kwargs["labels"] == [
+ "Cryptography & Encryption",
+ "Identity & Access Management",
+ "Logging and Monitoring",
+ ]
+ assert chart_kwargs["values"] == [100.0, 100.0, 0.0]
# =============================================================================
diff --git a/api/src/backend/tasks/tests/test_reports_ens.py b/api/src/backend/tasks/tests/test_reports_ens.py
index 91eb6d6f3a..220c3e1427 100644
--- a/api/src/backend/tasks/tests/test_reports_ens.py
+++ b/api/src/backend/tasks/tests/test_reports_ens.py
@@ -2,9 +2,11 @@ import io
from unittest.mock import Mock, patch
import pytest
-from reportlab.platypus import PageBreak, Paragraph, Table
+from reportlab.platypus import Image, PageBreak, Paragraph, Table
from tasks.jobs.reports import FRAMEWORK_REGISTRY, ComplianceData, RequirementData
+from tasks.jobs.reports import ens as ens_report_module
from tasks.jobs.reports.ens import ENSReportGenerator
+from tasks.tests.report_test_helpers import patch_chart_helpers
# Use string status values directly to avoid Django DB initialization
@@ -29,6 +31,15 @@ def ens_generator():
return ENSReportGenerator(config)
+@pytest.fixture
+def patched_ens_charts(monkeypatch):
+ return patch_chart_helpers(
+ monkeypatch,
+ ens_report_module,
+ ("create_horizontal_bar_chart", "create_radar_chart"),
+ )
+
+
@pytest.fixture
def mock_ens_requirement_attribute():
"""Create a mock ENS requirement attribute with all fields."""
@@ -355,7 +366,7 @@ class TestENSChartsSection:
"""Test suite for ENS charts section generation."""
def test_charts_section_has_page_breaks(
- self, ens_generator, basic_ens_compliance_data
+ self, ens_generator, basic_ens_compliance_data, patched_ens_charts
):
"""Test that charts section has page breaks between charts."""
basic_ens_compliance_data.requirements = []
@@ -365,9 +376,25 @@ class TestENSChartsSection:
page_breaks = [e for e in elements if isinstance(e, PageBreak)]
assert len(page_breaks) >= 2 # At least 2 page breaks for different charts
+ assert any(isinstance(e, Image) for e in elements)
+ assert len(patched_ens_charts["create_horizontal_bar_chart"]) == 1
+ assert len(patched_ens_charts["create_radar_chart"]) == 1
+
+ marco_kwargs = patched_ens_charts["create_horizontal_bar_chart"][0]["kwargs"]
+ assert marco_kwargs["labels"] == []
+ assert marco_kwargs["values"] == []
+
+ radar_kwargs = patched_ens_charts["create_radar_chart"][0]["kwargs"]
+ assert radar_kwargs["labels"] == ens_report_module.DIMENSION_NAMES
+ assert radar_kwargs["values"] == [100, 100, 100, 100, 100]
+ assert radar_kwargs["color"] == "#2196F3"
def test_charts_section_has_marco_category_chart(
- self, ens_generator, basic_ens_compliance_data, mock_ens_requirement_attribute
+ self,
+ ens_generator,
+ basic_ens_compliance_data,
+ mock_ens_requirement_attribute,
+ patched_ens_charts,
):
"""Test that charts section contains Marco/Categoría chart."""
basic_ens_compliance_data.requirements = [
@@ -391,9 +418,18 @@ class TestENSChartsSection:
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
+ assert any(isinstance(e, Image) for e in elements)
+ chart_kwargs = patched_ens_charts["create_horizontal_bar_chart"][0]["kwargs"]
+ assert chart_kwargs["labels"] == ["Operacional - Gestión de incidentes"]
+ assert chart_kwargs["values"] == [100.0]
+ assert chart_kwargs["xlabel"] == "Porcentaje de Cumplimiento (%)"
def test_charts_section_has_dimensions_radar(
- self, ens_generator, basic_ens_compliance_data, mock_ens_requirement_attribute
+ self,
+ ens_generator,
+ basic_ens_compliance_data,
+ mock_ens_requirement_attribute,
+ patched_ens_charts,
):
"""Test that charts section contains dimensions radar chart."""
basic_ens_compliance_data.requirements = [
@@ -417,9 +453,17 @@ class TestENSChartsSection:
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()
+ radar_kwargs = patched_ens_charts["create_radar_chart"][0]["kwargs"]
+ assert radar_kwargs["labels"] == ens_report_module.DIMENSION_NAMES
+ assert radar_kwargs["values"] == [100, 100, 100.0, 100.0, 100]
+ assert radar_kwargs["color"] == "#2196F3"
def test_charts_section_has_tipo_distribution(
- self, ens_generator, basic_ens_compliance_data, mock_ens_requirement_attribute
+ self,
+ ens_generator,
+ basic_ens_compliance_data,
+ mock_ens_requirement_attribute,
+ patched_ens_charts,
):
"""Test that charts section contains tipo distribution."""
basic_ens_compliance_data.requirements = [
@@ -443,6 +487,8 @@ class TestENSChartsSection:
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()
+ assert len(patched_ens_charts["create_horizontal_bar_chart"]) == 1
+ assert len(patched_ens_charts["create_radar_chart"]) == 1
# =============================================================================
@@ -829,7 +875,11 @@ 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
+ self,
+ ens_generator,
+ basic_ens_compliance_data,
+ mock_ens_requirement_attribute,
+ patched_ens_charts,
):
"""Test handling dimensions as a list."""
# mock_ens_requirement_attribute has Dimensiones as list
@@ -837,9 +887,9 @@ class TestENSDimensionHandling:
RequirementData(
id="REQ-001",
description="Test requirement",
- status=StatusChoices.PASS,
- passed_findings=10,
- failed_findings=0,
+ status=StatusChoices.FAIL,
+ passed_findings=0,
+ failed_findings=10,
total_findings=10,
),
]
@@ -854,12 +904,16 @@ class TestENSDimensionHandling:
basic_ens_compliance_data
)
assert isinstance(chart_buffer, io.BytesIO)
+ chart_kwargs = patched_ens_charts["create_radar_chart"][0]["kwargs"]
+ assert chart_kwargs["labels"] == ens_report_module.DIMENSION_NAMES
+ assert chart_kwargs["values"] == [100, 100, 0.0, 0.0, 100]
def test_dimensions_as_string(
self,
ens_generator,
basic_ens_compliance_data,
mock_ens_requirement_attribute_medio,
+ patched_ens_charts,
):
"""Test handling dimensions as comma-separated string."""
# mock_ens_requirement_attribute_medio has Dimensiones as string
@@ -867,9 +921,9 @@ class TestENSDimensionHandling:
RequirementData(
id="REQ-001",
description="Test requirement",
- status=StatusChoices.PASS,
- passed_findings=10,
- failed_findings=0,
+ status=StatusChoices.FAIL,
+ passed_findings=0,
+ failed_findings=10,
total_findings=10,
),
]
@@ -884,12 +938,16 @@ class TestENSDimensionHandling:
basic_ens_compliance_data
)
assert isinstance(chart_buffer, io.BytesIO)
+ chart_kwargs = patched_ens_charts["create_radar_chart"][0]["kwargs"]
+ assert chart_kwargs["labels"] == ens_report_module.DIMENSION_NAMES
+ assert chart_kwargs["values"] == [0.0, 0.0, 100, 100, 100]
def test_dimensions_empty(
self,
ens_generator,
basic_ens_compliance_data,
mock_ens_requirement_attribute_opcional,
+ patched_ens_charts,
):
"""Test handling empty dimensions."""
# mock_ens_requirement_attribute_opcional has empty Dimensiones
@@ -916,6 +974,9 @@ class TestENSDimensionHandling:
basic_ens_compliance_data
)
assert isinstance(chart_buffer, io.BytesIO)
+ chart_kwargs = patched_ens_charts["create_radar_chart"][0]["kwargs"]
+ assert chart_kwargs["labels"] == ens_report_module.DIMENSION_NAMES
+ assert chart_kwargs["values"] == [100, 100, 100, 100, 100]
# =============================================================================
@@ -1061,7 +1122,11 @@ 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
+ self,
+ ens_generator,
+ basic_ens_compliance_data,
+ mock_ens_requirement_attribute,
+ patched_ens_charts,
):
"""Test that Marco/Categoría chart is created successfully."""
basic_ens_compliance_data.requirements = [
@@ -1086,9 +1151,17 @@ class TestENSMarcoCategoryChart:
assert isinstance(chart_buffer, io.BytesIO)
assert chart_buffer.getvalue() # Not empty
+ chart_kwargs = patched_ens_charts["create_horizontal_bar_chart"][0]["kwargs"]
+ assert chart_kwargs["labels"] == ["Operacional - Gestión de incidentes"]
+ assert chart_kwargs["values"] == [100.0]
+ assert chart_kwargs["xlabel"] == "Porcentaje de Cumplimiento (%)"
def test_marco_category_chart_excludes_manual(
- self, ens_generator, basic_ens_compliance_data, mock_ens_requirement_attribute
+ self,
+ ens_generator,
+ basic_ens_compliance_data,
+ mock_ens_requirement_attribute,
+ patched_ens_charts,
):
"""Test that manual requirements are excluded from chart."""
basic_ens_compliance_data.requirements = [
@@ -1123,6 +1196,9 @@ class TestENSMarcoCategoryChart:
basic_ens_compliance_data
)
assert isinstance(chart_buffer, io.BytesIO)
+ chart_kwargs = patched_ens_charts["create_horizontal_bar_chart"][0]["kwargs"]
+ assert chart_kwargs["labels"] == ["Operacional - Gestión de incidentes"]
+ assert chart_kwargs["values"] == [100.0]
# =============================================================================
diff --git a/api/src/backend/tasks/tests/test_reports_nis2.py b/api/src/backend/tasks/tests/test_reports_nis2.py
index 07e88ec7ca..ea6e06477d 100644
--- a/api/src/backend/tasks/tests/test_reports_nis2.py
+++ b/api/src/backend/tasks/tests/test_reports_nis2.py
@@ -2,9 +2,11 @@ import io
from unittest.mock import Mock, patch
import pytest
-from reportlab.platypus import PageBreak, Paragraph, Table
+from reportlab.platypus import Image, PageBreak, Paragraph, Table
from tasks.jobs.reports import FRAMEWORK_REGISTRY, ComplianceData, RequirementData
+from tasks.jobs.reports import nis2 as nis2_report_module
from tasks.jobs.reports.nis2 import NIS2ReportGenerator, _extract_section_number
+from tasks.tests.report_test_helpers import patch_chart_helpers
# Use string status values directly to avoid Django DB initialization
@@ -29,6 +31,13 @@ def nis2_generator():
return NIS2ReportGenerator(config)
+@pytest.fixture
+def patched_nis2_charts(monkeypatch):
+ return patch_chart_helpers(
+ monkeypatch, nis2_report_module, ("create_horizontal_bar_chart",)
+ )
+
+
@pytest.fixture
def mock_nis2_requirement_attribute_section1():
"""Create a mock NIS2 requirement attribute for Section 1."""
@@ -380,7 +389,7 @@ class TestNIS2ChartsSection:
"""Test suite for NIS2 charts section generation."""
def test_charts_section_has_section_chart_title(
- self, nis2_generator, basic_nis2_compliance_data
+ self, nis2_generator, basic_nis2_compliance_data, patched_nis2_charts
):
"""Test that charts section has section compliance title."""
basic_nis2_compliance_data.requirements = []
@@ -391,9 +400,14 @@ class TestNIS2ChartsSection:
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
+ assert any(isinstance(e, Image) for e in elements)
+ chart_kwargs = patched_nis2_charts["create_horizontal_bar_chart"][0]["kwargs"]
+ assert chart_kwargs["labels"] == []
+ assert chart_kwargs["values"] == []
+ assert chart_kwargs["xlabel"] == "Compliance (%)"
def test_charts_section_has_page_break(
- self, nis2_generator, basic_nis2_compliance_data
+ self, nis2_generator, basic_nis2_compliance_data, patched_nis2_charts
):
"""Test that charts section has page breaks."""
basic_nis2_compliance_data.requirements = []
@@ -403,12 +417,14 @@ class TestNIS2ChartsSection:
page_breaks = [e for e in elements if isinstance(e, PageBreak)]
assert len(page_breaks) >= 1
+ assert len(patched_nis2_charts["create_horizontal_bar_chart"]) == 1
def test_charts_section_has_subsection_breakdown(
self,
nis2_generator,
basic_nis2_compliance_data,
mock_nis2_requirement_attribute_section1,
+ patched_nis2_charts,
):
"""Test that charts section includes subsection breakdown table."""
basic_nis2_compliance_data.requirements = [
@@ -434,6 +450,11 @@ class TestNIS2ChartsSection:
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
+ assert any(isinstance(e, Image) for e in elements)
+ chart_kwargs = patched_nis2_charts["create_horizontal_bar_chart"][0]["kwargs"]
+ assert chart_kwargs["labels"] == ["1. Policy on Security"]
+ assert chart_kwargs["values"] == [100.0]
+ assert chart_kwargs["color_func"]
# =============================================================================
@@ -449,6 +470,7 @@ class TestNIS2SectionChart:
nis2_generator,
basic_nis2_compliance_data,
mock_nis2_requirement_attribute_section1,
+ patched_nis2_charts,
):
"""Test that section chart is created successfully."""
basic_nis2_compliance_data.requirements = [
@@ -473,12 +495,17 @@ class TestNIS2SectionChart:
assert isinstance(chart_buffer, io.BytesIO)
assert chart_buffer.getvalue() # Not empty
+ chart_kwargs = patched_nis2_charts["create_horizontal_bar_chart"][0]["kwargs"]
+ assert chart_kwargs["labels"] == ["1. Policy on Security"]
+ assert chart_kwargs["values"] == [100.0]
+ assert chart_kwargs["xlabel"] == "Compliance (%)"
def test_section_chart_excludes_manual(
self,
nis2_generator,
basic_nis2_compliance_data,
mock_nis2_requirement_attribute_section1,
+ patched_nis2_charts,
):
"""Test that manual requirements are excluded from section chart."""
basic_nis2_compliance_data.requirements = [
@@ -515,6 +542,9 @@ class TestNIS2SectionChart:
# Should not raise any errors
chart_buffer = nis2_generator._create_section_chart(basic_nis2_compliance_data)
assert isinstance(chart_buffer, io.BytesIO)
+ chart_kwargs = patched_nis2_charts["create_horizontal_bar_chart"][0]["kwargs"]
+ assert chart_kwargs["labels"] == ["1. Policy on Security"]
+ assert chart_kwargs["values"] == [100.0]
def test_section_chart_multiple_sections(
self,
@@ -523,6 +553,7 @@ class TestNIS2SectionChart:
mock_nis2_requirement_attribute_section1,
mock_nis2_requirement_attribute_section2,
mock_nis2_requirement_attribute_section11,
+ patched_nis2_charts,
):
"""Test section chart with multiple sections."""
basic_nis2_compliance_data.requirements = [
@@ -571,6 +602,13 @@ class TestNIS2SectionChart:
chart_buffer = nis2_generator._create_section_chart(basic_nis2_compliance_data)
assert isinstance(chart_buffer, io.BytesIO)
+ chart_kwargs = patched_nis2_charts["create_horizontal_bar_chart"][0]["kwargs"]
+ assert chart_kwargs["labels"] == [
+ "1. Policy on Security",
+ "2. Risk Management",
+ "11. Access Control",
+ ]
+ assert chart_kwargs["values"] == [100.0, 0.0, 100.0]
# =============================================================================
diff --git a/api/src/backend/tasks/tests/test_reports_provider_metadata.py b/api/src/backend/tasks/tests/test_reports_provider_metadata.py
new file mode 100644
index 0000000000..6fe1c32255
--- /dev/null
+++ b/api/src/backend/tasks/tests/test_reports_provider_metadata.py
@@ -0,0 +1,193 @@
+"""Tests for the credential-free provider metadata stub (PROWLER-2145).
+
+Every provider object used here is a plain ``SimpleNamespace`` WITHOUT a
+``secret`` attribute: any code path trying to read ``provider.secret`` (the
+coupling these tests guard against) would raise ``AttributeError`` and fail
+the test. No database is required.
+"""
+
+from types import SimpleNamespace
+
+import pytest
+from api.models import Provider
+from prowler.lib.outputs.finding import Finding as FindingOutput
+from prowler.providers.github.models import GithubIdentityInfo
+from tasks.jobs.reports import build_provider_metadata
+
+PROVIDER_UID = "provider-uid-123"
+PROVIDER_ALIAS = "my-provider-alias"
+
+
+def _provider_row(provider_type: str, alias: str | None = PROVIDER_ALIAS):
+ """Mimic the Provider DB row attributes read by build_provider_metadata."""
+ return SimpleNamespace(provider=provider_type, uid=PROVIDER_UID, alias=alias)
+
+
+class TestBuildProviderMetadata:
+ @pytest.mark.parametrize("provider_type", Provider.ProviderChoices.values)
+ def test_every_provider_type_gets_safe_defaults(self, provider_type):
+ stub = build_provider_metadata(_provider_row(provider_type))
+
+ assert stub.type == provider_type
+ assert isinstance(stub.auth_method, str)
+ assert hasattr(stub, "identity")
+
+ def test_aws_identity_account_is_uid(self):
+ stub = build_provider_metadata(_provider_row("aws"))
+ assert stub.identity.account == PROVIDER_UID
+
+ def test_azure_identity_covers_generate_output_accesses(self):
+ stub = build_provider_metadata(_provider_row("azure"))
+
+ # generate_output indexes tenant_ids[0] and reads these directly.
+ assert stub.identity.tenant_ids
+ assert stub.identity.identity_type == ""
+ assert stub.identity.identity_id == ""
+ assert stub.identity.subscriptions == {PROVIDER_UID: PROVIDER_ALIAS}
+
+ def test_gcp_projects_keyed_by_uid(self):
+ stub = build_provider_metadata(_provider_row("gcp"))
+
+ project = stub.projects[PROVIDER_UID]
+ assert project.id == PROVIDER_UID
+ assert project.name == PROVIDER_ALIAS
+ assert project.labels == {}
+ # generate_output calls getattr(project, "organization") without a
+ # default, so the attribute must exist (None skips the org branch).
+ assert project.organization is None
+
+ def test_kubernetes_identity_context_and_cluster(self):
+ stub = build_provider_metadata(_provider_row("kubernetes"))
+ assert stub.identity.context == PROVIDER_UID
+ assert stub.identity.cluster == PROVIDER_UID
+
+ def test_github_identity_is_real_identity_info(self):
+ # generate_output only assigns account fields inside
+ # isinstance(identity, Github*IdentityInfo) branches.
+ stub = build_provider_metadata(_provider_row("github"))
+ assert isinstance(stub.identity, GithubIdentityInfo)
+ assert stub.identity.account_id == PROVIDER_UID
+ assert stub.identity.account_name == PROVIDER_ALIAS
+
+ def test_iac_provider_uid(self):
+ stub = build_provider_metadata(_provider_row("iac"))
+ assert stub.provider_uid == PROVIDER_UID
+
+ def test_alias_falls_back_to_uid(self):
+ stub = build_provider_metadata(_provider_row("azure", alias=None))
+ assert stub.identity.subscriptions == {PROVIDER_UID: PROVIDER_UID}
+
+
+def _check_metadata_dict(provider_type: str, check_id: str) -> dict:
+ return {
+ "provider": provider_type,
+ "checkid": check_id,
+ "checktitle": "Test check title",
+ "checktype": [],
+ # CheckMetadata validates ServiceName == check_id.split("_")[0]
+ "servicename": check_id.split("_")[0],
+ "subservicename": "",
+ "severity": "high",
+ "resourcetype": "resource-type",
+ "description": "",
+ "risk": "",
+ "relatedurl": "",
+ "remediation": {
+ "recommendation": {"text": "", "url": ""},
+ "code": {"nativeiac": "", "terraform": "", "cli": "", "other": ""},
+ },
+ "resourceidtemplate": "",
+ "categories": [],
+ "dependson": [],
+ "relatedto": [],
+ "notes": "",
+ }
+
+
+class _FakeFinding:
+ """Attribute-faithful Finding stand-in.
+
+ A plain object instead of ``Mock``: only the attributes the Django model
+ exposes exist, so any new provider-attribute read in generate_output
+ (e.g. cloudflare's ``getattr(finding, "account_id", ...)``) hits the
+ same missing-attribute path it would hit in production instead of being
+ masked by Mock auto-created attributes.
+ """
+
+
+def _finding_model(provider_type: str, check_id: str, region: str):
+ """Mimic the Django Finding row attributes read by transform_api_finding."""
+ resource = SimpleNamespace(
+ uid="resource-uid",
+ name="resource-name",
+ metadata="{}",
+ details="",
+ region=region,
+ tags=SimpleNamespace(all=lambda: []),
+ )
+ finding = _FakeFinding()
+ finding.resources = SimpleNamespace(first=lambda: resource)
+ finding.check_metadata = _check_metadata_dict(provider_type, check_id)
+ finding.status = "FAIL"
+ finding.status_extended = "failed for testing"
+ finding.muted = False
+ return finding
+
+
+_FINDING_REGION = "region-x"
+
+# Expected (account_uid, region) of the transformed finding per provider
+# type, with resource.region = _FINDING_REGION. Keyed by every
+# Provider.ProviderChoices value so that adding a new provider type without
+# extending build_provider_metadata (and this table) fails the test below
+# instead of breaking PDF generation at runtime.
+_EXPECTED_TRANSFORM = {
+ "aws": (PROVIDER_UID, _FINDING_REGION),
+ "azure": (PROVIDER_UID, _FINDING_REGION),
+ "gcp": (PROVIDER_UID, _FINDING_REGION),
+ # transform_api_finding strips the "namespace: " prefix and
+ # generate_output re-adds it.
+ "kubernetes": (PROVIDER_UID, f"namespace: {_FINDING_REGION}"),
+ "m365": (PROVIDER_UID, _FINDING_REGION),
+ # For GitHub the owner comes from resource.region.
+ "github": (_FINDING_REGION, _FINDING_REGION),
+ "mongodbatlas": (PROVIDER_UID, _FINDING_REGION),
+ "iac": (PROVIDER_UID, _FINDING_REGION),
+ "oraclecloud": (PROVIDER_UID, _FINDING_REGION),
+ "alibabacloud": (PROVIDER_UID, _FINDING_REGION),
+ # Cloudflare uses the zone name (falls back to resource.name) as region.
+ "cloudflare": (PROVIDER_UID, "resource-name"),
+ "openstack": (PROVIDER_UID, _FINDING_REGION),
+ "image": ("image", _FINDING_REGION),
+ "googleworkspace": (PROVIDER_UID, _FINDING_REGION),
+ "vercel": (PROVIDER_UID, "global"),
+ "okta": (PROVIDER_UID, "global"),
+}
+
+
+class TestTransformApiFindingWithMetadataStub:
+ """transform_api_finding must work end-to-end with the stub — i.e.
+ without a credentialed SDK provider — for EVERY API provider type."""
+
+ @pytest.mark.parametrize("provider_type", Provider.ProviderChoices.values)
+ def test_transform_with_stub(self, provider_type):
+ assert provider_type in _EXPECTED_TRANSFORM, (
+ f"New provider type {provider_type!r}: add a branch to "
+ f"build_provider_metadata covering the attributes read by "
+ f"FindingOutput.generate_output, then add its expected "
+ f"(account_uid, region) here."
+ )
+ expected_account_uid, expected_region = _EXPECTED_TRANSFORM[provider_type]
+
+ stub = build_provider_metadata(_provider_row(provider_type))
+ check_id = f"{provider_type}_test_check"
+ finding_model = _finding_model(provider_type, check_id, _FINDING_REGION)
+
+ output = FindingOutput.transform_api_finding(finding_model, stub)
+
+ assert output.check_id == check_id
+ assert output.status == "FAIL"
+ assert output.account_uid == expected_account_uid
+ assert output.region == expected_region
+ assert output.resource_name
+ assert output.resource_uid
diff --git a/api/src/backend/tasks/tests/test_reports_threatscore.py b/api/src/backend/tasks/tests/test_reports_threatscore.py
index c79c0b16e9..570a437eb3 100644
--- a/api/src/backend/tasks/tests/test_reports_threatscore.py
+++ b/api/src/backend/tasks/tests/test_reports_threatscore.py
@@ -2,6 +2,7 @@ import io
from unittest.mock import Mock
import pytest
+from api.models import StatusChoices
from reportlab.platypus import Image, PageBreak, Paragraph, Table
from tasks.jobs.reports import (
FRAMEWORK_REGISTRY,
@@ -9,8 +10,8 @@ from tasks.jobs.reports import (
RequirementData,
ThreatScoreReportGenerator,
)
-
-from api.models import StatusChoices
+from tasks.jobs.reports import threatscore as threatscore_report_module
+from tasks.tests.report_test_helpers import patch_chart_helpers
# =============================================================================
# Fixtures
@@ -24,6 +25,13 @@ def threatscore_generator():
return ThreatScoreReportGenerator(config)
+@pytest.fixture
+def patched_threatscore_charts(monkeypatch):
+ return patch_chart_helpers(
+ monkeypatch, threatscore_report_module, ("create_vertical_bar_chart",)
+ )
+
+
@pytest.fixture
def mock_requirement_attribute():
"""Create a mock requirement attribute with numeric values."""
@@ -678,7 +686,7 @@ class TestSectionScoreChart:
"""Test suite for section score chart generation."""
def test_create_section_chart_empty_data(
- self, threatscore_generator, basic_compliance_data
+ self, threatscore_generator, basic_compliance_data, patched_threatscore_charts
):
"""Test chart creation with no requirements."""
basic_compliance_data.requirements = []
@@ -690,9 +698,22 @@ class TestSectionScoreChart:
assert isinstance(result, io.BytesIO)
assert result.getvalue() # Should have content
+ chart_kwargs = patched_threatscore_charts["create_vertical_bar_chart"][0][
+ "kwargs"
+ ]
+ assert chart_kwargs["labels"] == []
+ assert chart_kwargs["values"] == []
+ assert chart_kwargs["ylabel"] == "Compliance Score (%)"
+ assert chart_kwargs["xlabel"] == ""
+ assert chart_kwargs["color_func"]
+ assert chart_kwargs["rotation"] == 0
def test_create_section_chart_single_section(
- self, threatscore_generator, basic_compliance_data, mock_requirement_attribute
+ self,
+ threatscore_generator,
+ basic_compliance_data,
+ mock_requirement_attribute,
+ patched_threatscore_charts,
):
"""Test chart creation with a single section."""
basic_compliance_data.requirements = [
@@ -714,9 +735,14 @@ class TestSectionScoreChart:
)
assert isinstance(result, io.BytesIO)
+ chart_kwargs = patched_threatscore_charts["create_vertical_bar_chart"][0][
+ "kwargs"
+ ]
+ assert chart_kwargs["labels"] == ["1. IAM"]
+ assert chart_kwargs["values"] == [100.0]
def test_create_section_chart_multiple_sections(
- self, threatscore_generator, basic_compliance_data
+ self, threatscore_generator, basic_compliance_data, patched_threatscore_charts
):
"""Test chart creation with multiple sections."""
mock_attr_1 = Mock()
@@ -757,9 +783,14 @@ class TestSectionScoreChart:
)
assert isinstance(result, io.BytesIO)
+ chart_kwargs = patched_threatscore_charts["create_vertical_bar_chart"][0][
+ "kwargs"
+ ]
+ assert chart_kwargs["labels"] == ["1. IAM", "2. Attack Surface"]
+ assert chart_kwargs["values"] == [100.0, 50.0]
def test_create_section_chart_no_findings_section_gets_100(
- self, threatscore_generator, basic_compliance_data
+ self, threatscore_generator, basic_compliance_data, patched_threatscore_charts
):
"""Test that sections without findings get 100% score."""
mock_attr = Mock()
@@ -787,6 +818,11 @@ class TestSectionScoreChart:
)
assert isinstance(result, io.BytesIO)
+ chart_kwargs = patched_threatscore_charts["create_vertical_bar_chart"][0][
+ "kwargs"
+ ]
+ assert chart_kwargs["labels"] == ["1. IAM"]
+ assert chart_kwargs["values"] == [100.0]
# =============================================================================
@@ -798,7 +834,11 @@ class TestExecutiveSummary:
"""Test suite for executive summary generation."""
def test_executive_summary_contains_chart(
- self, threatscore_generator, basic_compliance_data, mock_requirement_attribute
+ self,
+ threatscore_generator,
+ basic_compliance_data,
+ mock_requirement_attribute,
+ patched_threatscore_charts,
):
"""Test that executive summary contains a chart."""
basic_compliance_data.requirements = [
@@ -819,9 +859,18 @@ class TestExecutiveSummary:
assert len(elements) > 0
assert any(isinstance(e, Image) for e in elements)
+ chart_kwargs = patched_threatscore_charts["create_vertical_bar_chart"][0][
+ "kwargs"
+ ]
+ assert chart_kwargs["labels"] == ["1. IAM"]
+ assert chart_kwargs["values"] == [100.0]
def test_executive_summary_contains_score_table(
- self, threatscore_generator, basic_compliance_data, mock_requirement_attribute
+ self,
+ threatscore_generator,
+ basic_compliance_data,
+ mock_requirement_attribute,
+ patched_threatscore_charts,
):
"""Test that executive summary contains a score table."""
basic_compliance_data.requirements = [
diff --git a/api/src/backend/tasks/tests/test_scan.py b/api/src/backend/tasks/tests/test_scan.py
index 0a7193cd4d..cad5e3d343 100644
--- a/api/src/backend/tasks/tests/test_scan.py
+++ b/api/src/backend/tasks/tests/test_scan.py
@@ -3,14 +3,32 @@ import json
import re
import uuid
from contextlib import contextmanager
-from datetime import datetime, timezone
+from datetime import UTC, datetime
from io import StringIO
from unittest.mock import MagicMock, patch
import pytest
+from api.db_router import MainRouter
+from api.exceptions import ProviderConnectionError, ProviderDeletedException
+from api.models import (
+ Finding,
+ MuteRule,
+ Provider,
+ Resource,
+ ResourceScanSummary,
+ Scan,
+ ScanSummary,
+ StateChoices,
+ StatusChoices,
+)
+from django.db import IntegrityError, OperationalError
+from prowler.lib.check.models import Severity
+from prowler.lib.outputs.finding import Status
from tasks.jobs.scan import (
_ATTACK_SURFACE_MAPPING_CACHE,
+ ComplianceRowScopeError,
_aggregate_findings_by_region,
+ _bulk_update_resource_failed_findings_counts,
_copy_compliance_requirement_rows,
_create_compliance_summaries,
_create_finding_delta,
@@ -29,22 +47,6 @@ from tasks.jobs.scan import (
)
from tasks.utils import CustomEncoder
-from api.db_router import MainRouter
-from api.exceptions import ProviderConnectionError
-from api.models import (
- Finding,
- MuteRule,
- Provider,
- Resource,
- ResourceScanSummary,
- Scan,
- ScanSummary,
- StateChoices,
- StatusChoices,
-)
-from prowler.lib.check.models import Severity
-from prowler.lib.outputs.finding import Status
-
@contextmanager
def noop_rls_transaction(*args, **kwargs):
@@ -75,7 +77,7 @@ class TestPerformScan:
self,
tenants_fixture,
scans_fixture,
- providers_fixture,
+ aws_provider,
):
with (
patch("api.db_utils.rls_transaction"),
@@ -133,7 +135,7 @@ class TestPerformScan:
tenant = tenants_fixture[0]
scan = scans_fixture[0]
- provider = providers_fixture[0]
+ provider = aws_provider
# Ensure the provider type is 'aws' to match our mocks
provider.provider = Provider.ProviderChoices.AWS
@@ -242,11 +244,11 @@ class TestPerformScan:
mock_prowler_scan_class,
tenants_fixture,
scans_fixture,
- providers_fixture,
+ aws_provider,
):
tenant = tenants_fixture[0]
scan = scans_fixture[0]
- provider = providers_fixture[0]
+ provider = aws_provider
tenant_id = str(tenant.id)
scan_id = str(scan.id)
@@ -263,6 +265,75 @@ class TestPerformScan:
assert provider.connected is False
assert isinstance(provider.connection_last_checked_at, datetime)
+ def test_perform_prowler_scan_provider_deleted_during_progress_update(
+ self,
+ tenants_fixture,
+ scans_fixture,
+ aws_provider,
+ ):
+ tenant = tenants_fixture[0]
+ scan = scans_fixture[0]
+ provider = aws_provider
+
+ tenant_id = str(tenant.id)
+ scan_id = str(scan.id)
+ provider_id = str(provider.id)
+
+ def scan_results():
+ Provider.objects.filter(pk=provider_id).delete()
+ yield 50, []
+
+ with (
+ patch(
+ "tasks.jobs.scan.initialize_prowler_provider",
+ return_value=MagicMock(),
+ ),
+ patch("tasks.jobs.scan.ProwlerScan") as mock_prowler_scan_class,
+ patch("tasks.jobs.scan.logger.error") as mock_logger_error,
+ ):
+ mock_prowler_scan_instance = MagicMock()
+ mock_prowler_scan_instance.scan.return_value = scan_results()
+ mock_prowler_scan_class.return_value = mock_prowler_scan_instance
+
+ with pytest.raises(ProviderDeletedException):
+ perform_prowler_scan(tenant_id, scan_id, provider_id, [])
+
+ mock_logger_error.assert_not_called()
+ assert not Scan.objects.filter(pk=scan_id).exists()
+
+ def test_perform_prowler_scan_sets_final_progress_when_progress_updates_are_throttled(
+ self,
+ tenants_fixture,
+ scans_fixture,
+ aws_provider,
+ ):
+ tenant = tenants_fixture[0]
+ scan = scans_fixture[0]
+ provider = aws_provider
+
+ tenant_id = str(tenant.id)
+ scan_id = str(scan.id)
+ provider_id = str(provider.id)
+
+ with (
+ patch(
+ "tasks.jobs.scan.initialize_prowler_provider",
+ return_value=MagicMock(),
+ ),
+ patch("tasks.jobs.scan.ProwlerScan") as mock_prowler_scan_class,
+ patch("tasks.jobs.scan.PROGRESS_THROTTLE_DELTA", 200),
+ patch("tasks.jobs.scan.PROGRESS_THROTTLE_SECONDS", 3600),
+ ):
+ mock_prowler_scan_instance = MagicMock()
+ mock_prowler_scan_instance.scan.return_value = [(99, []), (100, [])]
+ mock_prowler_scan_class.return_value = mock_prowler_scan_instance
+
+ perform_prowler_scan(tenant_id, scan_id, provider_id, [])
+
+ scan.refresh_from_db()
+ assert scan.state == StateChoices.COMPLETED
+ assert scan.progress == 100
+
@pytest.mark.parametrize(
"last_status, new_status, expected_delta",
[
@@ -315,6 +386,7 @@ class TestPerformScan:
provider=provider_instance,
uid=finding.resource_uid,
defaults={
+ "name": finding.resource_name,
"region": finding.region,
"service": finding.service_name,
"type": finding.resource_type,
@@ -348,6 +420,7 @@ class TestPerformScan:
resource_instance = MagicMock()
resource_instance.uid = finding.resource_uid
+ resource_instance.name = "old_name"
resource_instance.region = "us-west-1"
resource_instance.service = "old_service"
resource_instance.type = "old_type"
@@ -366,6 +439,7 @@ class TestPerformScan:
provider=provider_instance,
uid=finding.resource_uid,
defaults={
+ "name": finding.resource_name,
"region": finding.region,
"service": finding.service_name,
"type": finding.resource_type,
@@ -373,6 +447,7 @@ class TestPerformScan:
)
# Check that resource fields were updated
+ assert resource_instance.name == finding.resource_name
assert resource_instance.region == finding.region
assert resource_instance.service == finding.service_name
assert resource_instance.type == finding.resource_type
@@ -437,7 +512,7 @@ class TestPerformScan:
self,
tenants_fixture,
scans_fixture,
- providers_fixture,
+ aws_provider,
):
"""Test that failed findings increment the failed_findings_count"""
with (
@@ -458,7 +533,7 @@ class TestPerformScan:
tenant = tenants_fixture[0]
scan = scans_fixture[0]
- provider = providers_fixture[0]
+ provider = aws_provider
# Ensure the provider type is 'aws'
provider.provider = Provider.ProviderChoices.AWS
@@ -515,7 +590,7 @@ class TestPerformScan:
self,
tenants_fixture,
scans_fixture,
- providers_fixture,
+ aws_provider,
):
"""Test that multiple FAIL findings on the same resource increment the counter correctly"""
with (
@@ -532,7 +607,7 @@ class TestPerformScan:
):
tenant = tenants_fixture[0]
scan = scans_fixture[0]
- provider = providers_fixture[0]
+ provider = aws_provider
provider.provider = Provider.ProviderChoices.AWS
provider.save()
@@ -632,7 +707,7 @@ class TestPerformScan:
self,
tenants_fixture,
scans_fixture,
- providers_fixture,
+ aws_provider,
):
"""Test that muted FAIL findings do not increment the failed_findings_count"""
with (
@@ -649,7 +724,7 @@ class TestPerformScan:
):
tenant = tenants_fixture[0]
scan = scans_fixture[0]
- provider = providers_fixture[0]
+ provider = aws_provider
provider.provider = Provider.ProviderChoices.AWS
provider.save()
@@ -703,13 +778,13 @@ class TestPerformScan:
def test_perform_prowler_scan_reset_failed_findings_count(
self,
tenants_fixture,
- providers_fixture,
+ aws_provider,
resources_fixture,
):
"""Test that failed_findings_count is reset to 0 at the beginning of each scan"""
# Use existing resource from fixture and set initial failed_findings_count
tenant = tenants_fixture[0]
- provider = providers_fixture[0]
+ provider = aws_provider
resource = resources_fixture[0]
# Set a non-zero failed_findings_count initially
@@ -786,11 +861,103 @@ class TestPerformScan:
# Assert that failed_findings_count was reset to 0 during the scan
assert resource.failed_findings_count == 0
+ def test_failed_findings_count_update_retries_deadlock_in_stable_order(
+ self, resources_fixture, monkeypatch
+ ):
+ resource1, resource2, _ = resources_fixture
+ tenant_id = str(resource1.tenant_id)
+ resource1.failed_findings_count = 2
+ resource2.failed_findings_count = 3
+ resources_to_update = [resource2, resource1]
+ expected_order = [
+ str(resource.id)
+ for resource in sorted(resources_to_update, key=lambda item: str(item.id))
+ ]
+ original_bulk_update = Resource.objects.bulk_update
+ bulk_update_calls = []
+
+ def flaky_bulk_update(objects, fields, batch_size=None):
+ bulk_update_calls.append([str(obj.id) for obj in objects])
+ if len(bulk_update_calls) == 1:
+ raise OperationalError("deadlock detected")
+ return original_bulk_update(objects, fields, batch_size=batch_size)
+
+ monkeypatch.setattr("tasks.jobs.scan.SCAN_DB_BATCH_SIZE", 10)
+ monkeypatch.setattr(Resource.objects, "bulk_update", flaky_bulk_update)
+
+ _bulk_update_resource_failed_findings_counts(
+ tenant_id=tenant_id,
+ scan_id="scan-id",
+ resources_to_update=resources_to_update,
+ )
+
+ resource1.refresh_from_db()
+ resource2.refresh_from_db()
+ assert resource1.failed_findings_count == 2
+ assert resource2.failed_findings_count == 3
+ assert bulk_update_calls == [expected_order, expected_order]
+
+ def test_failed_findings_count_update_does_not_retry_integrity_error(
+ self, resources_fixture, monkeypatch
+ ):
+ resource, *_ = resources_fixture
+ resource.failed_findings_count = 2
+ bulk_update_calls = []
+ sleep_calls = []
+
+ def failing_bulk_update(objects, fields, batch_size=None):
+ bulk_update_calls.append([str(obj.id) for obj in objects])
+ raise IntegrityError("constraint violation")
+
+ monkeypatch.setattr(Resource.objects, "bulk_update", failing_bulk_update)
+ monkeypatch.setattr("tasks.jobs.scan.time.sleep", sleep_calls.append)
+
+ with pytest.raises(IntegrityError, match="constraint violation"):
+ _bulk_update_resource_failed_findings_counts(
+ tenant_id=str(resource.tenant_id),
+ scan_id="scan-id",
+ resources_to_update=[resource],
+ )
+
+ assert len(bulk_update_calls) == 1
+ assert sleep_calls == []
+
+ def test_failed_findings_count_update_adds_jitter_to_retry_backoff(
+ self, resources_fixture, monkeypatch
+ ):
+ from tasks.jobs import scan as scan_jobs
+
+ resource, *_ = resources_fixture
+ resource.failed_findings_count = 2
+ bulk_update_calls = []
+ sleep_calls = []
+ original_bulk_update = Resource.objects.bulk_update
+
+ def flaky_bulk_update(objects, fields, batch_size=None):
+ bulk_update_calls.append([str(obj.id) for obj in objects])
+ if len(bulk_update_calls) == 1:
+ raise OperationalError("deadlock detected")
+ return original_bulk_update(objects, fields, batch_size=batch_size)
+
+ monkeypatch.setattr(Resource.objects, "bulk_update", flaky_bulk_update)
+ monkeypatch.setattr(scan_jobs, "random", MagicMock())
+ scan_jobs.random.uniform.return_value = 0.037
+ monkeypatch.setattr("tasks.jobs.scan.time.sleep", sleep_calls.append)
+
+ _bulk_update_resource_failed_findings_counts(
+ tenant_id=str(resource.tenant_id),
+ scan_id="scan-id",
+ resources_to_update=[resource],
+ )
+
+ scan_jobs.random.uniform.assert_called_once_with(0, 0.1)
+ assert sleep_calls == [0.137]
+
def test_perform_prowler_scan_with_active_mute_rules(
self,
tenants_fixture,
scans_fixture,
- providers_fixture,
+ aws_provider,
):
"""Test active MuteRule mutes findings with correct reason"""
with (
@@ -807,7 +974,7 @@ class TestPerformScan:
):
tenant = tenants_fixture[0]
scan = scans_fixture[0]
- provider = providers_fixture[0]
+ provider = aws_provider
provider.provider = Provider.ProviderChoices.AWS
provider.save()
@@ -907,7 +1074,7 @@ class TestPerformScan:
self,
tenants_fixture,
scans_fixture,
- providers_fixture,
+ aws_provider,
):
"""Test inactive MuteRule does not mute findings"""
with (
@@ -924,7 +1091,7 @@ class TestPerformScan:
):
tenant = tenants_fixture[0]
scan = scans_fixture[0]
- provider = providers_fixture[0]
+ provider = aws_provider
provider.provider = Provider.ProviderChoices.AWS
provider.save()
@@ -993,7 +1160,7 @@ class TestPerformScan:
self,
tenants_fixture,
scans_fixture,
- providers_fixture,
+ aws_provider,
):
"""Test mutelist processor takes precedence over MuteRule"""
with (
@@ -1010,7 +1177,7 @@ class TestPerformScan:
):
tenant = tenants_fixture[0]
scan = scans_fixture[0]
- provider = providers_fixture[0]
+ provider = aws_provider
provider.provider = Provider.ProviderChoices.AWS
provider.save()
@@ -1079,7 +1246,7 @@ class TestPerformScan:
self,
tenants_fixture,
scans_fixture,
- providers_fixture,
+ aws_provider,
):
"""Test MuteRule with multiple finding UIDs mutes all findings"""
with (
@@ -1096,7 +1263,7 @@ class TestPerformScan:
):
tenant = tenants_fixture[0]
scan = scans_fixture[0]
- provider = providers_fixture[0]
+ provider = aws_provider
provider.provider = Provider.ProviderChoices.AWS
provider.save()
@@ -1178,7 +1345,7 @@ class TestPerformScan:
self,
tenants_fixture,
scans_fixture,
- providers_fixture,
+ aws_provider,
):
"""Test scan continues when MuteRule loading fails"""
with (
@@ -1196,7 +1363,7 @@ class TestPerformScan:
):
tenant = tenants_fixture[0]
scan = scans_fixture[0]
- provider = providers_fixture[0]
+ provider = aws_provider
provider.provider = Provider.ProviderChoices.AWS
provider.save()
@@ -1261,7 +1428,7 @@ class TestPerformScan:
self,
tenants_fixture,
scans_fixture,
- providers_fixture,
+ aws_provider,
):
"""Test muted_at timestamp is set correctly for muted findings"""
with (
@@ -1278,7 +1445,7 @@ class TestPerformScan:
):
tenant = tenants_fixture[0]
scan = scans_fixture[0]
- provider = providers_fixture[0]
+ provider = aws_provider
provider.provider = Provider.ProviderChoices.AWS
provider.save()
@@ -1331,9 +1498,9 @@ class TestPerformScan:
)
# Capture time before and after scan
- before_scan = datetime.now(timezone.utc)
+ before_scan = datetime.now(UTC)
perform_prowler_scan(tenant_id, scan_id, provider_id, [])
- after_scan = datetime.now(timezone.utc)
+ after_scan = datetime.now(UTC)
# Verify muted_at is within the scan time window
finding_db = Finding.objects.get(uid=finding_uid)
@@ -1469,7 +1636,7 @@ class TestProcessFindingMicroBatch:
partition="aws-old",
)
- previous_first_seen = datetime(2024, 1, 1, tzinfo=timezone.utc)
+ previous_first_seen = datetime(2024, 1, 1, tzinfo=UTC)
finding = FakeFinding(
uid="finding-muted",
@@ -1565,6 +1732,75 @@ class TestProcessFindingMicroBatch:
assert resource_cache[finding.resource_uid].service == finding.service_name
assert tag_cache.keys() == {("team", "devsec")}
+ def test_process_finding_micro_batch_refreshes_empty_resource_name(
+ self, tenants_fixture, scans_fixture
+ ):
+ tenant = tenants_fixture[0]
+ scan = scans_fixture[0]
+ provider = scan.provider
+
+ # Old resource stored before names were persisted: empty name.
+ existing_resource = Resource.objects.create(
+ tenant_id=tenant.id,
+ provider=provider,
+ uid="arn:aws:s3:::my-bucket",
+ name="",
+ region="us-east-1",
+ service="s3",
+ type="bucket",
+ )
+
+ finding = FakeFinding(
+ uid="finding-empty-name",
+ status=StatusChoices.PASS,
+ status_extended="passing",
+ severity=Severity.low,
+ check_id="s3_bucket_public_access",
+ resource_uid=existing_resource.uid,
+ resource_name="my-bucket",
+ region="us-east-1",
+ service_name="s3",
+ resource_type="bucket",
+ partition="aws",
+ raw={"status": "PASS"},
+ metadata={"source": "prowler"},
+ )
+
+ resource_cache = {existing_resource.uid: existing_resource}
+ tag_cache = {}
+ last_status_cache = {}
+ resource_failed_findings_cache = {existing_resource.uid: 0}
+ unique_resources: set[tuple[str, str]] = set()
+ 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),
+ patch("api.db_utils.rls_transaction", new=noop_rls_transaction),
+ ):
+ _process_finding_micro_batch(
+ str(tenant.id),
+ [finding],
+ scan,
+ provider,
+ resource_cache,
+ tag_cache,
+ last_status_cache,
+ resource_failed_findings_cache,
+ unique_resources,
+ scan_resource_cache,
+ mute_rules_cache,
+ scan_categories_cache,
+ scan_resource_groups_cache,
+ group_resources_cache,
+ )
+
+ existing_resource.refresh_from_db()
+ assert existing_resource.name == finding.resource_name
+
def test_process_finding_micro_batch_skips_long_uid(
self, tenants_fixture, scans_fixture
):
@@ -1796,7 +2032,7 @@ class TestCreateComplianceRequirements:
self,
tenants_fixture,
scans_fixture,
- providers_fixture,
+ aws_provider,
findings_fixture,
resources_fixture,
):
@@ -1847,7 +2083,7 @@ class TestCreateComplianceRequirements:
self,
tenants_fixture,
scans_fixture,
- providers_fixture,
+ aws_provider,
findings_fixture,
):
with patch(
@@ -1880,11 +2116,67 @@ class TestCreateComplianceRequirements:
assert "requirements_created" in result
+ @pytest.mark.django_db(transaction=True)
+ def test_create_compliance_requirements_idempotent_on_rerun(
+ self,
+ tenants_fixture,
+ scans_fixture,
+ aws_provider,
+ findings_fixture,
+ ):
+ """Re-running compliance materialization must not raise nor duplicate rows.
+
+ Uses transaction=True because the COPY path commits on its own connection,
+ so the test must use real commits (mirroring production) rather than the
+ default rollback wrapper.
+ """
+ from api.models import ComplianceRequirementOverview
+
+ with patch(
+ "tasks.jobs.scan.PROWLER_COMPLIANCE_OVERVIEW_TEMPLATE"
+ ) as mock_compliance_template:
+ tenant_id = str(tenants_fixture[0].id)
+ scan_id = str(scans_fixture[0].id)
+
+ mock_compliance_template.__getitem__.return_value = {
+ "test_compliance": {
+ "framework": "Test Framework",
+ "version": "1.0",
+ "requirements": {
+ "req_1": {
+ "description": "Test Requirement 1",
+ "checks": {"test_check_id": None},
+ "checks_status": {
+ "pass": 2,
+ "fail": 1,
+ "manual": 0,
+ "total": 3,
+ },
+ "status": "FAIL",
+ },
+ },
+ }
+ }
+
+ create_compliance_requirements(tenant_id, scan_id)
+ count_after_first = ComplianceRequirementOverview.objects.filter(
+ scan_id=scan_id
+ ).count()
+
+ # Second run must not raise and must not duplicate rows.
+ create_compliance_requirements(tenant_id, scan_id)
+ count_after_second = ComplianceRequirementOverview.objects.filter(
+ scan_id=scan_id
+ ).count()
+
+ assert count_after_first > 0
+ assert count_after_second == count_after_first
+
def test_create_compliance_requirements_kubernetes_provider(
self,
tenants_fixture,
scans_fixture,
- providers_fixture,
+ aws_provider,
findings_fixture,
):
with patch(
@@ -1892,7 +2184,7 @@ class TestCreateComplianceRequirements:
) as mock_compliance_template:
tenant = tenants_fixture[0]
scan = scans_fixture[0]
- provider = providers_fixture[0]
+ provider = aws_provider
provider.provider = Provider.ProviderChoices.KUBERNETES
provider.save()
@@ -1930,7 +2222,7 @@ class TestCreateComplianceRequirements:
self,
tenants_fixture,
scans_fixture,
- providers_fixture,
+ aws_provider,
findings_fixture,
):
with patch(
@@ -1949,7 +2241,7 @@ class TestCreateComplianceRequirements:
self,
tenants_fixture,
scans_fixture,
- providers_fixture,
+ aws_provider,
findings_fixture,
):
with patch("tasks.jobs.scan.return_prowler_provider") as mock_prowler_provider:
@@ -1994,7 +2286,7 @@ class TestCreateComplianceRequirements:
tags={},
check_id=existing_finding.check_id,
check_metadata={"CheckId": existing_finding.check_id},
- first_seen_at=datetime.now(timezone.utc),
+ first_seen_at=datetime.now(UTC),
muted=False,
)
resource = existing_finding.resources.first()
@@ -2023,9 +2315,9 @@ class TestCreateComplianceRequirements:
create_compliance_requirements(tenant_id, scan_id)
mock_persist.assert_called_once()
- persisted_rows = mock_persist.call_args[0][1]
+ rows_factory = mock_persist.call_args[0][2]
requirement_row = next(
- row for row in persisted_rows if row["requirement_id"] == "1.1"
+ row for row in rows_factory() if row["requirement_id"] == "1.1"
)
assert requirement_row["requirement_status"] == "FAIL"
@@ -2033,7 +2325,7 @@ class TestCreateComplianceRequirements:
self,
tenants_fixture,
scans_fixture,
- providers_fixture,
+ aws_provider,
findings_fixture,
):
with patch(
@@ -2071,7 +2363,7 @@ class TestCreateComplianceRequirements:
self,
tenants_fixture,
scans_fixture,
- providers_fixture,
+ aws_provider,
findings_fixture,
):
with patch(
@@ -2163,18 +2455,26 @@ class TestComplianceRequirementCopy:
}
with patch.object(MainRouter, "admin_db", "admin"):
- _copy_compliance_requirement_rows(str(row["tenant_id"]), [row])
+ _copy_compliance_requirement_rows(
+ str(row["tenant_id"]), str(row["scan_id"]), [row], 2000
+ )
mock_psycopg_connection.assert_called_once_with("admin")
connection.cursor.assert_called_once()
- cursor.execute.assert_called_once()
+ # One execute for set_config plus one for the scan's DELETE.
+ assert cursor.execute.call_count == 2
+ delete_sql, delete_params = cursor.execute.call_args_list[1][0]
+ assert "DELETE FROM compliance_requirements_overviews" in delete_sql
+ assert delete_params == [str(row["tenant_id"]), str(row["scan_id"])]
cursor.copy_expert.assert_called_once()
+ connection.commit.assert_called_once()
csv_rows = list(csv.reader(StringIO(captured["data"])))
assert csv_rows[0][0] == str(row["id"])
assert csv_rows[0][5] == ""
assert csv_rows[0][-1] == str(row["scan_id"])
+ @patch("tasks.jobs.scan.ComplianceRequirementOverview.objects.filter")
@patch("tasks.jobs.scan.ComplianceRequirementOverview.objects.bulk_create")
@patch("tasks.jobs.scan.rls_transaction")
@patch(
@@ -2182,9 +2482,9 @@ class TestComplianceRequirementCopy:
side_effect=Exception("copy failed"),
)
def test_persist_compliance_requirement_rows_fallback(
- self, mock_copy, mock_rls_transaction, mock_bulk_create
+ self, mock_copy, mock_rls_transaction, mock_bulk_create, mock_filter
):
- inserted_at = datetime.now(timezone.utc)
+ inserted_at = datetime.now(UTC)
row = {
"id": uuid.uuid4(),
"tenant_id": str(uuid.uuid4()),
@@ -2203,16 +2503,22 @@ class TestComplianceRequirementCopy:
}
tenant_id = row["tenant_id"]
+ scan_id = str(row["scan_id"])
ctx = MagicMock()
ctx.__enter__.return_value = None
ctx.__exit__.return_value = False
mock_rls_transaction.return_value = ctx
- _persist_compliance_requirement_rows(tenant_id, [row])
+ _persist_compliance_requirement_rows(tenant_id, scan_id, lambda: [row])
- mock_copy.assert_called_once_with(tenant_id, [row])
+ mock_copy.assert_called_once()
+ assert mock_copy.call_args[0][0] == tenant_id
+ assert mock_copy.call_args[0][1] == scan_id
mock_rls_transaction.assert_called_once_with(tenant_id)
+ # The fallback replaces the scan's rows: delete + insert atomically.
+ mock_filter.assert_called_once_with(scan_id=scan_id)
+ mock_filter.return_value.delete.assert_called_once()
mock_bulk_create.assert_called_once()
args, kwargs = mock_bulk_create.call_args
@@ -2224,13 +2530,18 @@ class TestComplianceRequirementCopy:
@patch("tasks.jobs.scan.ComplianceRequirementOverview.objects.bulk_create")
@patch("tasks.jobs.scan.rls_transaction")
- @patch("tasks.jobs.scan._copy_compliance_requirement_rows")
+ @patch("tasks.jobs.scan._copy_compliance_requirement_rows", return_value=0)
def test_persist_compliance_requirement_rows_no_rows(
self, mock_copy, mock_rls_transaction, mock_bulk_create
):
- _persist_compliance_requirement_rows(str(uuid.uuid4()), [])
+ # Even with no rows the COPY path runs: it must clear the scan's
+ # previous rows so a re-run with fewer findings drops stale data.
+ total = _persist_compliance_requirement_rows(
+ str(uuid.uuid4()), str(uuid.uuid4()), lambda: []
+ )
- mock_copy.assert_not_called()
+ assert total == 0
+ mock_copy.assert_called_once()
mock_rls_transaction.assert_not_called()
mock_bulk_create.assert_not_called()
@@ -2265,7 +2576,7 @@ class TestComplianceRequirementCopy:
tenant_id = str(uuid.uuid4())
scan_id = uuid.uuid4()
- inserted_at = datetime.now(timezone.utc)
+ inserted_at = datetime.now(UTC)
rows = [
{
@@ -2319,11 +2630,12 @@ class TestComplianceRequirementCopy:
]
with patch.object(MainRouter, "admin_db", "admin"):
- _copy_compliance_requirement_rows(tenant_id, rows)
+ _copy_compliance_requirement_rows(tenant_id, str(scan_id), rows, 2000)
mock_psycopg_connection.assert_called_once_with("admin")
connection.cursor.assert_called_once()
- cursor.execute.assert_called_once()
+ # set_config + DELETE of the scan's previous rows.
+ assert cursor.execute.call_count == 2
cursor.copy_expert.assert_called_once()
csv_rows = list(csv.reader(StringIO(captured["data"])))
@@ -2353,6 +2665,60 @@ class TestComplianceRequirementCopy:
assert csv_rows[2][5] == "2.0"
assert csv_rows[2][9] == "MANUAL"
+ @patch("tasks.jobs.scan.psycopg_connection")
+ def test_copy_compliance_requirement_rows_batches_share_one_transaction(
+ self, mock_psycopg_connection, settings
+ ):
+ """Every COPY batch runs on the same connection with a single commit."""
+ settings.DATABASES.setdefault("admin", settings.DATABASES["default"])
+
+ connection = MagicMock()
+ cursor = MagicMock()
+ cursor_context = MagicMock()
+ cursor_context.__enter__.return_value = cursor
+ cursor_context.__exit__.return_value = False
+ connection.cursor.return_value = cursor_context
+ connection.__enter__.return_value = connection
+ connection.__exit__.return_value = False
+
+ context_manager = MagicMock()
+ context_manager.__enter__.return_value = connection
+ context_manager.__exit__.return_value = False
+ mock_psycopg_connection.return_value = context_manager
+
+ tenant_id = str(uuid.uuid4())
+ scan_id = str(uuid.uuid4())
+ inserted_at = datetime.now(UTC)
+ rows = [
+ {
+ "id": uuid.uuid4(),
+ "tenant_id": tenant_id,
+ "inserted_at": inserted_at,
+ "compliance_id": "cisa_aws",
+ "framework": "CISA",
+ "version": "1.0",
+ "description": f"Requirement {index}",
+ "region": "us-east-1",
+ "requirement_id": f"req-{index}",
+ "requirement_status": "PASS",
+ "passed_checks": 1,
+ "failed_checks": 0,
+ "total_checks": 1,
+ "scan_id": scan_id,
+ }
+ for index in range(3)
+ ]
+
+ with patch.object(MainRouter, "admin_db", "admin"):
+ total = _copy_compliance_requirement_rows(tenant_id, scan_id, rows, 1)
+
+ assert total == 3
+ # One connection, three COPY statements, one commit for the whole scan.
+ mock_psycopg_connection.assert_called_once_with("admin")
+ assert cursor.copy_expert.call_count == 3
+ connection.commit.assert_called_once()
+ connection.rollback.assert_not_called()
+
@patch("tasks.jobs.scan.psycopg_connection")
def test_copy_compliance_requirement_rows_null_values(
self, mock_psycopg_connection, settings
@@ -2400,7 +2766,9 @@ class TestComplianceRequirementCopy:
}
with patch.object(MainRouter, "admin_db", "admin"):
- _copy_compliance_requirement_rows(str(row["tenant_id"]), [row])
+ _copy_compliance_requirement_rows(
+ str(row["tenant_id"]), str(row["scan_id"]), [row], 2000
+ )
csv_rows = list(csv.reader(StringIO(captured["data"])))
assert len(csv_rows) == 1
@@ -2456,7 +2824,9 @@ class TestComplianceRequirementCopy:
}
with patch.object(MainRouter, "admin_db", "admin"):
- _copy_compliance_requirement_rows(str(row["tenant_id"]), [row])
+ _copy_compliance_requirement_rows(
+ str(row["tenant_id"]), str(row["scan_id"]), [row], 2000
+ )
# Verify CSV was generated (csv module handles escaping automatically)
csv_rows = list(csv.reader(StringIO(captured["data"])))
@@ -2515,10 +2885,12 @@ class TestComplianceRequirementCopy:
# Note: inserted_at is intentionally missing
}
- before_call = datetime.now(timezone.utc)
+ before_call = datetime.now(UTC)
with patch.object(MainRouter, "admin_db", "admin"):
- _copy_compliance_requirement_rows(str(row["tenant_id"]), [row])
- after_call = datetime.now(timezone.utc)
+ _copy_compliance_requirement_rows(
+ str(row["tenant_id"]), str(row["scan_id"]), [row], 2000
+ )
+ after_call = datetime.now(UTC)
csv_rows = list(csv.reader(StringIO(captured["data"])))
assert len(csv_rows) == 1
@@ -2570,12 +2942,84 @@ class TestComplianceRequirementCopy:
with patch.object(MainRouter, "admin_db", "admin"):
with pytest.raises(Exception, match="COPY command failed"):
- _copy_compliance_requirement_rows(str(row["tenant_id"]), [row])
+ _copy_compliance_requirement_rows(
+ str(row["tenant_id"]), str(row["scan_id"]), [row], 2000
+ )
# Verify rollback was called
connection.rollback.assert_called_once()
connection.commit.assert_not_called()
+ @pytest.mark.parametrize("mismatched_field", ["tenant_id", "scan_id"])
+ @patch("tasks.jobs.scan.psycopg_connection")
+ def test_copy_compliance_requirement_rows_rejects_out_of_scope_rows(
+ self, mock_psycopg_connection, mismatched_field, settings
+ ):
+ """COPY bypasses RLS, so rows from another tenant/scan must be rejected."""
+ settings.DATABASES.setdefault("admin", settings.DATABASES["default"])
+
+ connection = MagicMock()
+ cursor = MagicMock()
+ cursor_context = MagicMock()
+ cursor_context.__enter__.return_value = cursor
+ cursor_context.__exit__.return_value = False
+ connection.cursor.return_value = cursor_context
+ connection.__enter__.return_value = connection
+ connection.__exit__.return_value = False
+
+ context_manager = MagicMock()
+ context_manager.__enter__.return_value = connection
+ context_manager.__exit__.return_value = False
+ mock_psycopg_connection.return_value = context_manager
+
+ tenant_id = str(uuid.uuid4())
+ scan_id = str(uuid.uuid4())
+ row = {
+ "id": uuid.uuid4(),
+ "tenant_id": tenant_id,
+ "compliance_id": "test",
+ "framework": "Test",
+ "version": "1.0",
+ "description": "desc",
+ "region": "us-east-1",
+ "requirement_id": "req-1",
+ "requirement_status": "PASS",
+ "passed_checks": 1,
+ "failed_checks": 0,
+ "total_checks": 1,
+ "scan_id": scan_id,
+ }
+ row[mismatched_field] = str(uuid.uuid4())
+
+ with patch.object(MainRouter, "admin_db", "admin"):
+ with pytest.raises(ComplianceRowScopeError):
+ _copy_compliance_requirement_rows(tenant_id, scan_id, [row], 2000)
+
+ cursor.copy_expert.assert_not_called()
+ connection.rollback.assert_called_once()
+ connection.commit.assert_not_called()
+
+ @patch("tasks.jobs.scan.ComplianceRequirementOverview")
+ @patch("tasks.jobs.scan.rls_transaction")
+ @patch(
+ "tasks.jobs.scan._copy_compliance_requirement_rows",
+ side_effect=ComplianceRowScopeError("out of scope"),
+ )
+ def test_persist_compliance_requirement_rows_does_not_fall_back_on_scope_error(
+ self, mock_copy, mock_rls_transaction, mock_model
+ ):
+ """A scope violation is a caller bug: the ORM fallback must not persist it."""
+ tenant_id = str(uuid.uuid4())
+ scan_id = str(uuid.uuid4())
+
+ with pytest.raises(ComplianceRowScopeError):
+ _persist_compliance_requirement_rows(tenant_id, scan_id, lambda: [])
+
+ mock_copy.assert_called_once()
+ mock_rls_transaction.assert_not_called()
+ mock_model.objects.filter.assert_not_called()
+ mock_model.objects.bulk_create.assert_not_called()
+
@patch("tasks.jobs.scan.psycopg_connection")
def test_copy_compliance_requirement_rows_transaction_rollback_on_set_config_error(
self, mock_psycopg_connection, settings
@@ -2618,7 +3062,9 @@ class TestComplianceRequirementCopy:
with patch.object(MainRouter, "admin_db", "admin"):
with pytest.raises(Exception, match="SET prowler.tenant_id failed"):
- _copy_compliance_requirement_rows(str(row["tenant_id"]), [row])
+ _copy_compliance_requirement_rows(
+ str(row["tenant_id"]), str(row["scan_id"]), [row], 2000
+ )
# Verify rollback was called
connection.rollback.assert_called_once()
@@ -2664,7 +3110,9 @@ class TestComplianceRequirementCopy:
}
with patch.object(MainRouter, "admin_db", "admin"):
- _copy_compliance_requirement_rows(str(row["tenant_id"]), [row])
+ _copy_compliance_requirement_rows(
+ str(row["tenant_id"]), str(row["scan_id"]), [row], 2000
+ )
# Verify commit was called and rollback was not
connection.commit.assert_called_once()
@@ -2675,14 +3123,15 @@ class TestComplianceRequirementCopy:
@patch("tasks.jobs.scan._copy_compliance_requirement_rows")
def test_persist_compliance_requirement_rows_success(self, mock_copy):
"""Test successful COPY path without fallback to ORM."""
- mock_copy.return_value = None # Success, no exception
+ mock_copy.return_value = 1 # Success, no exception
tenant_id = str(uuid.uuid4())
+ scan_id = str(uuid.uuid4())
rows = [
{
"id": uuid.uuid4(),
"tenant_id": tenant_id,
- "inserted_at": datetime.now(timezone.utc),
+ "inserted_at": datetime.now(UTC),
"compliance_id": "test",
"framework": "Test",
"version": "1.0",
@@ -2693,16 +3142,21 @@ class TestComplianceRequirementCopy:
"passed_checks": 1,
"failed_checks": 0,
"total_checks": 1,
- "scan_id": uuid.uuid4(),
+ "scan_id": scan_id,
}
]
- _persist_compliance_requirement_rows(tenant_id, rows)
+ total = _persist_compliance_requirement_rows(tenant_id, scan_id, lambda: rows)
- # Verify COPY was called
- mock_copy.assert_called_once_with(tenant_id, rows)
+ assert total == 1
+ mock_copy.assert_called_once()
+ copy_args = mock_copy.call_args[0]
+ assert copy_args[0] == tenant_id
+ assert copy_args[1] == scan_id
+ assert list(copy_args[2]) == rows
@patch("tasks.jobs.scan.logger")
+ @patch("tasks.jobs.scan.ComplianceRequirementOverview.objects.filter")
@patch("tasks.jobs.scan.ComplianceRequirementOverview.objects.bulk_create")
@patch("tasks.jobs.scan.rls_transaction")
@patch(
@@ -2710,14 +3164,19 @@ class TestComplianceRequirementCopy:
side_effect=Exception("COPY failed"),
)
def test_persist_compliance_requirement_rows_fallback_logging(
- self, mock_copy, mock_rls_transaction, mock_bulk_create, mock_logger
+ self,
+ mock_copy,
+ mock_rls_transaction,
+ mock_bulk_create,
+ mock_filter,
+ mock_logger,
):
"""Test logger.exception is called when COPY fails and fallback occurs."""
tenant_id = str(uuid.uuid4())
row = {
"id": uuid.uuid4(),
"tenant_id": tenant_id,
- "inserted_at": datetime.now(timezone.utc),
+ "inserted_at": datetime.now(UTC),
"compliance_id": "test",
"framework": "Test",
"version": "1.0",
@@ -2736,7 +3195,9 @@ class TestComplianceRequirementCopy:
ctx.__exit__.return_value = False
mock_rls_transaction.return_value = ctx
- _persist_compliance_requirement_rows(tenant_id, [row])
+ _persist_compliance_requirement_rows(
+ tenant_id, str(row["scan_id"]), lambda: [row]
+ )
# Verify logger.exception was called
mock_logger.exception.assert_called_once()
@@ -2745,6 +3206,7 @@ class TestComplianceRequirementCopy:
assert "falling back to ORM" in args[0]
assert kwargs.get("exc_info") is not None
+ @patch("tasks.jobs.scan.ComplianceRequirementOverview.objects.filter")
@patch("tasks.jobs.scan.ComplianceRequirementOverview.objects.bulk_create")
@patch("tasks.jobs.scan.rls_transaction")
@patch(
@@ -2752,12 +3214,12 @@ class TestComplianceRequirementCopy:
side_effect=Exception("copy failed"),
)
def test_persist_compliance_requirement_rows_fallback_multiple_rows(
- self, mock_copy, mock_rls_transaction, mock_bulk_create
+ self, mock_copy, mock_rls_transaction, mock_bulk_create, mock_filter
):
"""Test ORM fallback with multiple rows."""
tenant_id = str(uuid.uuid4())
scan_id = uuid.uuid4()
- inserted_at = datetime.now(timezone.utc)
+ inserted_at = datetime.now(UTC)
rows = [
{
@@ -2799,10 +3261,14 @@ class TestComplianceRequirementCopy:
ctx.__exit__.return_value = False
mock_rls_transaction.return_value = ctx
- _persist_compliance_requirement_rows(tenant_id, rows)
+ total = _persist_compliance_requirement_rows(
+ tenant_id, str(scan_id), lambda: rows
+ )
- mock_copy.assert_called_once_with(tenant_id, rows)
+ assert total == 2
+ mock_copy.assert_called_once()
mock_rls_transaction.assert_called_once_with(tenant_id)
+ mock_filter.assert_called_once_with(scan_id=str(scan_id))
mock_bulk_create.assert_called_once()
args, kwargs = mock_bulk_create.call_args
@@ -2826,6 +3292,7 @@ class TestComplianceRequirementCopy:
assert objects[1].passed_checks == 2
assert objects[1].failed_checks == 3
+ @patch("tasks.jobs.scan.ComplianceRequirementOverview.objects.filter")
@patch("tasks.jobs.scan.ComplianceRequirementOverview.objects.bulk_create")
@patch("tasks.jobs.scan.rls_transaction")
@patch(
@@ -2833,13 +3300,13 @@ class TestComplianceRequirementCopy:
side_effect=Exception("copy failed"),
)
def test_persist_compliance_requirement_rows_fallback_all_fields(
- self, mock_copy, mock_rls_transaction, mock_bulk_create
+ self, mock_copy, mock_rls_transaction, mock_bulk_create, mock_filter
):
"""Test ORM fallback correctly maps all fields from row dict to model."""
tenant_id = str(uuid.uuid4())
row_id = uuid.uuid4()
scan_id = uuid.uuid4()
- inserted_at = datetime.now(timezone.utc)
+ inserted_at = datetime.now(UTC)
row = {
"id": row_id,
@@ -2863,7 +3330,7 @@ class TestComplianceRequirementCopy:
ctx.__exit__.return_value = False
mock_rls_transaction.return_value = ctx
- _persist_compliance_requirement_rows(tenant_id, [row])
+ _persist_compliance_requirement_rows(tenant_id, str(scan_id), lambda: [row])
args, kwargs = mock_bulk_create.call_args
objects = args[0]
@@ -3361,6 +3828,95 @@ class TestAggregateFindings:
regions = {s.region for s in summaries}
assert regions == {"us-east-1", "us-west-2"}
+ @patch("tasks.jobs.scan.Finding.objects.filter")
+ @patch("tasks.jobs.scan.ScanSummary.objects.bulk_create")
+ @patch("tasks.jobs.scan.rls_transaction")
+ def test_aggregate_findings_orders_upserts_by_conflict_key(
+ self, mock_rls_transaction, mock_bulk_create, mock_findings_filter
+ ):
+ """Scan summaries must use a stable lock order for concurrent upserts."""
+ tenant_id = str(uuid.uuid4())
+ scan_id = str(uuid.uuid4())
+ counts = {
+ "fail": 1,
+ "_pass": 0,
+ "muted_count": 0,
+ "total": 1,
+ "new": 1,
+ "changed": 0,
+ "unchanged": 0,
+ "fail_new": 1,
+ "fail_changed": 0,
+ "pass_new": 0,
+ "pass_changed": 0,
+ "muted_new": 0,
+ "muted_changed": 0,
+ }
+
+ mock_queryset = MagicMock()
+ mock_queryset.values.return_value = mock_queryset
+ mock_queryset.annotate.return_value = [
+ {
+ "check_id": "check-b",
+ "resources__service": "s3",
+ "severity": "high",
+ "resources__region": "us-east-1",
+ **counts,
+ },
+ {
+ "check_id": "check-a",
+ "resources__service": "sqs",
+ "severity": "high",
+ "resources__region": "us-east-1",
+ **counts,
+ },
+ {
+ "check_id": "check-a",
+ "resources__service": "s3",
+ "severity": "medium",
+ "resources__region": "us-east-1",
+ **counts,
+ },
+ {
+ "check_id": "check-a",
+ "resources__service": "s3",
+ "severity": "high",
+ "resources__region": "us-west-2",
+ **counts,
+ },
+ {
+ "check_id": "check-a",
+ "resources__service": "s3",
+ "severity": "high",
+ "resources__region": "us-east-1",
+ **counts,
+ },
+ ]
+
+ ctx = MagicMock()
+ ctx.__enter__.return_value = None
+ ctx.__exit__.return_value = False
+ mock_rls_transaction.return_value = ctx
+ mock_findings_filter.return_value = mock_queryset
+
+ aggregate_findings(tenant_id, scan_id)
+
+ summaries = mock_bulk_create.call_args.args[0]
+ assert isinstance(summaries, list)
+ conflict_keys = [
+ (
+ str(summary.tenant_id),
+ str(summary.scan_id),
+ summary.check_id,
+ summary.service,
+ summary.severity,
+ summary.region,
+ )
+ for summary in summaries
+ ]
+ assert len(conflict_keys) == 5
+ assert conflict_keys == sorted(conflict_keys)
+
@patch("tasks.jobs.scan.Finding.objects.filter")
@patch("tasks.jobs.scan.ScanSummary.objects.bulk_create")
@patch("tasks.jobs.scan.rls_transaction")
@@ -3545,19 +4101,19 @@ class TestAggregateFindingsByRegion:
scan_id = str(uuid.uuid4())
modeled_threatscore_compliance_id = "ProwlerThreatScore-1.0"
- # Mock findings with resources
- mock_finding1 = MagicMock()
- mock_finding1.check_id = "check1"
- mock_finding1.status = "FAIL"
- mock_finding1.compliance = {modeled_threatscore_compliance_id: ["req1", "req2"]}
-
- mock_resource1 = MagicMock()
- mock_resource1.region = "us-east-1"
- mock_finding1.small_resources = [mock_resource1]
+ # (check_id, status, resource_regions, compliance) tuples
+ finding_rows = [
+ (
+ "check1",
+ "FAIL",
+ ["us-east-1"],
+ {modeled_threatscore_compliance_id: ["req1", "req2"]},
+ )
+ ]
mock_queryset = MagicMock()
- mock_queryset.only.return_value = mock_queryset
- mock_queryset.prefetch_related.return_value = [mock_finding1]
+ mock_queryset.values_list.return_value = mock_queryset
+ mock_queryset.iterator.return_value = finding_rows
ctx = MagicMock()
ctx.__enter__.return_value = None
@@ -3571,6 +4127,12 @@ class TestAggregateFindingsByRegion:
)
)
+ # Streaming query contract: column-scoped values_list + iterator
+ mock_queryset.values_list.assert_called_once_with(
+ "check_id", "status", "resource_regions", "compliance"
+ )
+ mock_queryset.iterator.assert_called_once()
+
# Verify structure of check_status_by_region
assert isinstance(check_status_by_region, dict)
assert "us-east-1" in check_status_by_region
@@ -3590,27 +4152,15 @@ class TestAggregateFindingsByRegion:
scan_id = str(uuid.uuid4())
modeled_threatscore_compliance_id = "ProwlerThreatScore-1.0"
- # First finding with PASS status
- mock_finding1 = MagicMock()
- mock_finding1.check_id = "check1"
- mock_finding1.status = "PASS"
- mock_finding1.compliance = {}
- mock_resource1 = MagicMock()
- mock_resource1.region = "us-east-1"
- mock_finding1.small_resources = [mock_resource1]
-
- # Second finding with FAIL status for same check/region
- mock_finding2 = MagicMock()
- mock_finding2.check_id = "check1"
- mock_finding2.status = "FAIL"
- mock_finding2.compliance = {}
- mock_resource2 = MagicMock()
- mock_resource2.region = "us-east-1"
- mock_finding2.small_resources = [mock_resource2]
+ # Same check/region: PASS first, then FAIL — FAIL must win
+ finding_rows = [
+ ("check1", "PASS", ["us-east-1"], {}),
+ ("check1", "FAIL", ["us-east-1"], {}),
+ ]
mock_queryset = MagicMock()
- mock_queryset.only.return_value = mock_queryset
- mock_queryset.prefetch_related.return_value = [mock_finding1, mock_finding2]
+ mock_queryset.values_list.return_value = mock_queryset
+ mock_queryset.iterator.return_value = finding_rows
ctx = MagicMock()
ctx.__enter__.return_value = None
@@ -3622,6 +4172,12 @@ class TestAggregateFindingsByRegion:
tenant_id, scan_id, modeled_threatscore_compliance_id
)
+ # Streaming query contract: column-scoped values_list + iterator
+ mock_queryset.values_list.assert_called_once_with(
+ "check_id", "status", "resource_regions", "compliance"
+ )
+ mock_queryset.iterator.assert_called_once()
+
# FAIL should override PASS
assert check_status_by_region["us-east-1"]["check1"] == "FAIL"
@@ -3636,8 +4192,8 @@ class TestAggregateFindingsByRegion:
modeled_threatscore_compliance_id = "ProwlerThreatScore-1.0"
mock_queryset = MagicMock()
- mock_queryset.only.return_value = mock_queryset
- mock_queryset.prefetch_related.return_value = []
+ mock_queryset.values_list.return_value = mock_queryset
+ mock_queryset.iterator.return_value = []
ctx = MagicMock()
ctx.__enter__.return_value = None
@@ -3649,6 +4205,12 @@ class TestAggregateFindingsByRegion:
tenant_id, scan_id, modeled_threatscore_compliance_id
)
+ # Streaming query contract: column-scoped values_list + iterator
+ mock_queryset.values_list.assert_called_once_with(
+ "check_id", "status", "resource_regions", "compliance"
+ )
+ mock_queryset.iterator.assert_called_once()
+
# Verify filter was called with muted=False
mock_findings_filter.assert_called_once_with(
tenant_id=tenant_id,
@@ -3667,27 +4229,25 @@ class TestAggregateFindingsByRegion:
scan_id = str(uuid.uuid4())
modeled_threatscore_compliance_id = "ProwlerThreatScore-1.0"
- # Finding with PASS status
- mock_finding1 = MagicMock()
- mock_finding1.check_id = "check1"
- mock_finding1.status = "PASS"
- mock_finding1.compliance = {modeled_threatscore_compliance_id: ["req1"]}
- mock_resource1 = MagicMock()
- mock_resource1.region = "us-east-1"
- mock_finding1.small_resources = [mock_resource1]
-
- # Finding with FAIL status
- mock_finding2 = MagicMock()
- mock_finding2.check_id = "check2"
- mock_finding2.status = "FAIL"
- mock_finding2.compliance = {modeled_threatscore_compliance_id: ["req1"]}
- mock_resource2 = MagicMock()
- mock_resource2.region = "us-east-1"
- mock_finding2.small_resources = [mock_resource2]
+ # PASS and FAIL findings mapped to the same ThreatScore requirement
+ finding_rows = [
+ (
+ "check1",
+ "PASS",
+ ["us-east-1"],
+ {modeled_threatscore_compliance_id: ["req1"]},
+ ),
+ (
+ "check2",
+ "FAIL",
+ ["us-east-1"],
+ {modeled_threatscore_compliance_id: ["req1"]},
+ ),
+ ]
mock_queryset = MagicMock()
- mock_queryset.only.return_value = mock_queryset
- mock_queryset.prefetch_related.return_value = [mock_finding1, mock_finding2]
+ mock_queryset.values_list.return_value = mock_queryset
+ mock_queryset.iterator.return_value = finding_rows
ctx = MagicMock()
ctx.__enter__.return_value = None
@@ -3699,6 +4259,12 @@ class TestAggregateFindingsByRegion:
tenant_id, scan_id, modeled_threatscore_compliance_id
)
+ # Streaming query contract: column-scoped values_list + iterator
+ mock_queryset.values_list.assert_called_once_with(
+ "check_id", "status", "resource_regions", "compliance"
+ )
+ mock_queryset.iterator.assert_called_once()
+
# Verify compliance counts
normalized_id = re.sub(
r"[^a-z0-9]", "", modeled_threatscore_compliance_id.lower()
@@ -3721,27 +4287,15 @@ class TestAggregateFindingsByRegion:
scan_id = str(uuid.uuid4())
modeled_threatscore_compliance_id = "ProwlerThreatScore-1.0"
- # Finding in us-east-1
- mock_finding1 = MagicMock()
- mock_finding1.check_id = "check1"
- mock_finding1.status = "FAIL"
- mock_finding1.compliance = {}
- mock_resource1 = MagicMock()
- mock_resource1.region = "us-east-1"
- mock_finding1.small_resources = [mock_resource1]
-
- # Finding in us-west-2
- mock_finding2 = MagicMock()
- mock_finding2.check_id = "check1"
- mock_finding2.status = "PASS"
- mock_finding2.compliance = {}
- mock_resource2 = MagicMock()
- mock_resource2.region = "us-west-2"
- mock_finding2.small_resources = [mock_resource2]
+ # One finding per region
+ finding_rows = [
+ ("check1", "FAIL", ["us-east-1"], {}),
+ ("check1", "PASS", ["us-west-2"], {}),
+ ]
mock_queryset = MagicMock()
- mock_queryset.only.return_value = mock_queryset
- mock_queryset.prefetch_related.return_value = [mock_finding1, mock_finding2]
+ mock_queryset.values_list.return_value = mock_queryset
+ mock_queryset.iterator.return_value = finding_rows
ctx = MagicMock()
ctx.__enter__.return_value = None
@@ -3753,6 +4307,12 @@ class TestAggregateFindingsByRegion:
tenant_id, scan_id, modeled_threatscore_compliance_id
)
+ # Streaming query contract: column-scoped values_list + iterator
+ mock_queryset.values_list.assert_called_once_with(
+ "check_id", "status", "resource_regions", "compliance"
+ )
+ mock_queryset.iterator.assert_called_once()
+
# Verify both regions are present with correct statuses
assert "us-east-1" in check_status_by_region
assert "us-west-2" in check_status_by_region
@@ -3761,17 +4321,26 @@ class TestAggregateFindingsByRegion:
@patch("tasks.jobs.scan.Finding.all_objects.filter")
@patch("tasks.jobs.scan.rls_transaction")
- def test_aggregate_findings_by_region_empty_findings(
+ def test_aggregate_findings_by_region_multi_region_finding(
self, mock_rls_transaction, mock_findings_filter
):
- """Test with no findings - should return empty dicts."""
+ """A finding with multiple resource_regions is tallied in every region."""
tenant_id = str(uuid.uuid4())
scan_id = str(uuid.uuid4())
modeled_threatscore_compliance_id = "ProwlerThreatScore-1.0"
+ finding_rows = [
+ (
+ "check1",
+ "FAIL",
+ ["us-east-1", "eu-west-1"],
+ {modeled_threatscore_compliance_id: ["req1"]},
+ )
+ ]
+
mock_queryset = MagicMock()
- mock_queryset.only.return_value = mock_queryset
- mock_queryset.prefetch_related.return_value = []
+ mock_queryset.values_list.return_value = mock_queryset
+ mock_queryset.iterator.return_value = finding_rows
ctx = MagicMock()
ctx.__enter__.return_value = None
@@ -3785,6 +4354,92 @@ class TestAggregateFindingsByRegion:
)
)
+ # Streaming query contract: column-scoped values_list + iterator
+ mock_queryset.values_list.assert_called_once_with(
+ "check_id", "status", "resource_regions", "compliance"
+ )
+ mock_queryset.iterator.assert_called_once()
+
+ normalized_id = re.sub(
+ r"[^a-z0-9]", "", modeled_threatscore_compliance_id.lower()
+ )
+ for region in ("us-east-1", "eu-west-1"):
+ assert check_status_by_region[region]["check1"] == "FAIL"
+ req_stats = findings_count_by_compliance[region][normalized_id]["req1"]
+ assert req_stats == {"total": 1, "pass": 0}
+
+ @patch("tasks.jobs.scan.Finding.all_objects.filter")
+ @patch("tasks.jobs.scan.rls_transaction")
+ def test_aggregate_findings_by_region_skips_empty_regions(
+ self, mock_rls_transaction, mock_findings_filter
+ ):
+ """A finding with no denormalized regions contributes nothing."""
+ tenant_id = str(uuid.uuid4())
+ scan_id = str(uuid.uuid4())
+ modeled_threatscore_compliance_id = "ProwlerThreatScore-1.0"
+
+ finding_rows = [
+ ("check1", "FAIL", [], {modeled_threatscore_compliance_id: ["req1"]}),
+ ("check2", "PASS", None, {}),
+ ]
+
+ mock_queryset = MagicMock()
+ mock_queryset.values_list.return_value = mock_queryset
+ mock_queryset.iterator.return_value = finding_rows
+
+ ctx = MagicMock()
+ ctx.__enter__.return_value = None
+ ctx.__exit__.return_value = False
+ mock_rls_transaction.return_value = ctx
+ mock_findings_filter.return_value = mock_queryset
+
+ check_status_by_region, findings_count_by_compliance = (
+ _aggregate_findings_by_region(
+ tenant_id, scan_id, modeled_threatscore_compliance_id
+ )
+ )
+
+ # Streaming query contract: column-scoped values_list + iterator
+ mock_queryset.values_list.assert_called_once_with(
+ "check_id", "status", "resource_regions", "compliance"
+ )
+ mock_queryset.iterator.assert_called_once()
+
+ assert check_status_by_region == {}
+ assert findings_count_by_compliance == {}
+
+ @patch("tasks.jobs.scan.Finding.all_objects.filter")
+ @patch("tasks.jobs.scan.rls_transaction")
+ def test_aggregate_findings_by_region_empty_findings(
+ self, mock_rls_transaction, mock_findings_filter
+ ):
+ """Test with no findings - should return empty dicts."""
+ tenant_id = str(uuid.uuid4())
+ scan_id = str(uuid.uuid4())
+ modeled_threatscore_compliance_id = "ProwlerThreatScore-1.0"
+
+ mock_queryset = MagicMock()
+ mock_queryset.values_list.return_value = mock_queryset
+ mock_queryset.iterator.return_value = []
+
+ ctx = MagicMock()
+ ctx.__enter__.return_value = None
+ ctx.__exit__.return_value = False
+ mock_rls_transaction.return_value = ctx
+ mock_findings_filter.return_value = mock_queryset
+
+ check_status_by_region, findings_count_by_compliance = (
+ _aggregate_findings_by_region(
+ tenant_id, scan_id, modeled_threatscore_compliance_id
+ )
+ )
+
+ # Streaming query contract: column-scoped values_list + iterator
+ mock_queryset.values_list.assert_called_once_with(
+ "check_id", "status", "resource_regions", "compliance"
+ )
+ mock_queryset.iterator.assert_called_once()
+
assert check_status_by_region == {}
assert findings_count_by_compliance == {}
@@ -4233,7 +4888,7 @@ class TestUpdateProviderComplianceScores:
scan_id = str(scan.id)
scan.state = StateChoices.COMPLETED
- scan.completed_at = datetime.now(timezone.utc)
+ scan.completed_at = datetime.now(UTC)
scan.save()
connection = MagicMock()
@@ -4299,7 +4954,7 @@ class TestUpdateProviderComplianceScores:
self,
mock_psycopg_connection,
tenants_fixture,
- providers_fixture,
+ aws_provider,
scans_fixture,
settings,
):
@@ -4310,7 +4965,7 @@ class TestUpdateProviderComplianceScores:
scan_id = str(scan.id)
scan.state = StateChoices.COMPLETED
- scan.completed_at = datetime.now(timezone.utc)
+ scan.completed_at = datetime.now(UTC)
scan.save()
connection = MagicMock()
@@ -4363,9 +5018,10 @@ class TestScanIsFullScope:
# If the SDK adds a new filter, this test still passes via the
# introspection-driven derivation; if it adds a non-filter kwarg
# (e.g. provider-like), keep the exclusion list in sync in models.py.
- from prowler.lib.scan.scan import Scan as ProwlerScan
import inspect
+ from prowler.lib.scan.scan import Scan as ProwlerScan
+
expected = tuple(
name
for name in inspect.signature(ProwlerScan.__init__).parameters
@@ -4406,7 +5062,7 @@ class TestResetEphemeralResourceFindingsCount:
)
def test_resets_only_resources_missing_from_full_scope_scan(
- self, tenants_fixture, scans_fixture, providers_fixture, resources_fixture
+ self, tenants_fixture, scans_fixture, aws_provider, resources_fixture
):
tenant, *_ = tenants_fixture
scan1, scan2, *_ = scans_fixture
@@ -4486,7 +5142,7 @@ class TestResetEphemeralResourceFindingsCount:
assert result["reason"] == "scan not found"
def test_skips_when_newer_scan_completed_for_same_provider(
- self, tenants_fixture, scans_fixture, providers_fixture, resources_fixture
+ self, tenants_fixture, scans_fixture, aws_provider, resources_fixture
):
# If a newer completed scan exists for the same provider, our
# ResourceScanSummary set is stale relative to the resources' current
@@ -4495,7 +5151,7 @@ class TestResetEphemeralResourceFindingsCount:
tenant, *_ = tenants_fixture
scan1, *_ = scans_fixture
- provider, *_ = providers_fixture
+ provider = aws_provider
_, resource2, _ = resources_fixture
Resource.objects.filter(id=resource2.id).update(failed_findings_count=5)
@@ -4525,7 +5181,7 @@ class TestResetEphemeralResourceFindingsCount:
assert resource2.failed_findings_count == 5
def test_does_not_touch_other_providers_resources(
- self, tenants_fixture, scans_fixture, providers_fixture, resources_fixture
+ self, tenants_fixture, scans_fixture, aws_provider, resources_fixture
):
tenant, *_ = tenants_fixture
scan1, *_ = scans_fixture
@@ -4591,14 +5247,14 @@ class TestResetEphemeralResourceFindingsCount:
assert resource2.failed_findings_count == 5
def test_ignores_sibling_scan_with_null_completed_at(
- self, tenants_fixture, scans_fixture, providers_fixture, resources_fixture
+ self, tenants_fixture, scans_fixture, aws_provider, resources_fixture
):
# Postgres orders NULL first in DESC; a sibling COMPLETED scan with a
# missing completed_at must not be treated as the latest scan and
# cause us to incorrectly skip the reset.
tenant, *_ = tenants_fixture
scan1, *_ = scans_fixture
- provider, *_ = providers_fixture
+ provider = aws_provider
resource1, resource2, _ = resources_fixture
Resource.objects.filter(id=resource2.id).update(failed_findings_count=5)
diff --git a/api/src/backend/tasks/tests/test_tasks.py b/api/src/backend/tasks/tests/test_tasks.py
index f15bdf7192..476444cb00 100644
--- a/api/src/backend/tasks/tests/test_tasks.py
+++ b/api/src/backend/tasks/tests/test_tasks.py
@@ -1,22 +1,35 @@
import uuid
from contextlib import contextmanager
-from datetime import datetime, timedelta, timezone
+from datetime import UTC, datetime, timedelta
from unittest.mock import MagicMock, patch
+import httpx
import openai
import pytest
+from api.models import (
+ Integration,
+ LighthouseProviderConfiguration,
+ LighthouseProviderModels,
+ Scan,
+ StateChoices,
+ Task,
+)
from botocore.exceptions import ClientError
+from celery import states
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,
+ _LighthouseOpenAICompatibleNetworkBackend,
)
from tasks.tasks import (
DJANGO_TMP_OUTPUT_DIRECTORY,
STALE_TMP_OUTPUT_MAX_AGE_HOURS,
+ ScanReportRLSTask,
_cleanup_orphan_scheduled_scans,
_perform_scan_complete_tasks,
+ _scan_tmp_output_directory,
check_integrations_task,
check_lighthouse_provider_connection_task,
generate_outputs_task,
@@ -29,15 +42,6 @@ from tasks.tasks import (
security_hub_integration_task,
)
-from api.models import (
- Integration,
- LighthouseProviderConfiguration,
- LighthouseProviderModels,
- Scan,
- StateChoices,
- Task,
-)
-
@pytest.mark.django_db
class TestExtractBedrockCredentials:
@@ -321,6 +325,7 @@ class TestGenerateOutputs:
mock_transformed_stats = {"some": "stats"}
with (
+ patch("tasks.tasks.get_prowler_provider_compliance", return_value={}),
patch(
"tasks.tasks.FindingOutput._transform_findings_stats",
return_value=mock_transformed_stats,
@@ -439,6 +444,7 @@ class TestGenerateOutputs:
mock_provider.uid = "test-provider-uid"
with (
+ patch("tasks.tasks.get_prowler_provider_compliance", return_value={}),
patch("tasks.tasks.ScanSummary.objects.filter") as mock_filter,
patch("tasks.tasks.Provider.objects.get", return_value=mock_provider),
patch("tasks.tasks.initialize_prowler_provider"),
@@ -594,6 +600,7 @@ class TestGenerateOutputs:
]
with (
+ patch("tasks.tasks.get_prowler_provider_compliance", return_value={}),
patch("tasks.tasks.ScanSummary.objects.filter") as mock_summary,
patch(
"tasks.tasks.Provider.objects.get",
@@ -668,6 +675,7 @@ class TestGenerateOutputs:
mock_provider.uid = "test-provider-uid"
with (
+ patch("tasks.tasks.get_prowler_provider_compliance", return_value={}),
patch("tasks.tasks.ScanSummary.objects.filter") as mock_filter,
patch("tasks.tasks.Provider.objects.get", return_value=mock_provider),
patch("tasks.tasks.initialize_prowler_provider"),
@@ -771,6 +779,38 @@ class TestGenerateOutputs:
mock_s3_task.assert_called_once()
+class TestScanReportRLSTaskOnFailure:
+ def test_on_failure_removes_scan_tmp_directory(self):
+ task = ScanReportRLSTask()
+
+ with patch("tasks.tasks.rmtree") as mock_rmtree:
+ task.on_failure(
+ exc=OSError("No space left on device"),
+ task_id="task-abc",
+ args=(),
+ kwargs={"tenant_id": "t-1", "scan_id": "s-1"},
+ _einfo=None,
+ )
+
+ mock_rmtree.assert_called_once_with(
+ _scan_tmp_output_directory("t-1", "s-1"), ignore_errors=True
+ )
+
+ def test_on_failure_skips_when_missing_kwargs(self):
+ task = ScanReportRLSTask()
+
+ with patch("tasks.tasks.rmtree") as mock_rmtree:
+ task.on_failure(
+ exc=OSError("No space left on device"),
+ task_id="task-abc",
+ args=(),
+ kwargs={},
+ _einfo=None,
+ )
+
+ mock_rmtree.assert_not_called()
+
+
class TestScanCompleteTasks:
@patch("tasks.tasks.aggregate_attack_surface_task.apply_async")
@patch("tasks.tasks.chain")
@@ -1079,6 +1119,7 @@ class TestCheckIntegrationsTask:
enabled=True,
)
+ @patch("tasks.tasks.get_prowler_provider_compliance", return_value={})
@patch("tasks.tasks.s3_integration_task")
@patch("tasks.tasks.Integration.objects.filter")
@patch("tasks.tasks.ScanSummary.objects.filter")
@@ -1111,6 +1152,7 @@ class TestCheckIntegrationsTask:
mock_scan_summary,
mock_integration_filter,
mock_s3_task,
+ mock_get_prowler_compliance,
):
"""Test that ASFF output is generated for AWS providers with SecurityHub integration."""
# Setup
@@ -1207,6 +1249,7 @@ class TestCheckIntegrationsTask:
assert result == {"upload": True}
+ @patch("tasks.tasks.get_prowler_provider_compliance", return_value={})
@patch("tasks.tasks.s3_integration_task")
@patch("tasks.tasks.Integration.objects.filter")
@patch("tasks.tasks.ScanSummary.objects.filter")
@@ -1239,6 +1282,7 @@ class TestCheckIntegrationsTask:
mock_scan_summary,
mock_integration_filter,
mock_s3_task,
+ mock_get_prowler_compliance,
):
"""Test that ASFF output is NOT generated for AWS providers without SecurityHub integration."""
# Setup
@@ -1332,6 +1376,7 @@ class TestCheckIntegrationsTask:
assert result == {"upload": True}
+ @patch("tasks.tasks.get_prowler_provider_compliance", return_value={})
@patch("tasks.tasks.ScanSummary.objects.filter")
@patch("tasks.tasks.Provider.objects.get")
@patch("tasks.tasks.initialize_prowler_provider")
@@ -1360,6 +1405,7 @@ class TestCheckIntegrationsTask:
mock_initialize_provider,
mock_provider_get,
mock_scan_summary,
+ mock_get_prowler_compliance,
):
"""Test that ASFF output is NOT generated for non-AWS providers (e.g., Azure, GCP)."""
# Setup
@@ -1434,9 +1480,9 @@ class TestCheckIntegrationsTask:
)
# Verify ASFF was NOT created for non-AWS provider
- assert (
- "asff" not in created_writers
- ), "ASFF writer should NOT be created for non-AWS providers"
+ assert "asff" not in created_writers, (
+ "ASFF writer should NOT be created for non-AWS providers"
+ )
assert "csv" in created_writers, "CSV writer should be created"
assert "ocsf" in created_writers, "OCSF writer should be created"
@@ -1522,7 +1568,7 @@ class TestCheckLighthouseProviderConnectionTask:
(
LighthouseProviderConfiguration.LLMProviderChoices.OPENAI_COMPATIBLE,
{"api_key": "sk-test123"},
- "https://openrouter.ai/api/v1",
+ "https://93.184.216.34/api/v1",
{"connected": True, "error": None},
),
(
@@ -1597,7 +1643,7 @@ class TestCheckLighthouseProviderConnectionTask:
(
LighthouseProviderConfiguration.LLMProviderChoices.OPENAI_COMPATIBLE,
{"api_key": "sk-invalid"},
- "https://openrouter.ai/api/v1",
+ "https://93.184.216.34/api/v1",
openai.APIConnectionError(request=MagicMock()),
),
(
@@ -1711,6 +1757,166 @@ class TestCheckLighthouseProviderConnectionTask:
provider_cfg.refresh_from_db()
assert provider_cfg.is_active is False
+ def test_openai_compatible_connection_rejects_metadata_base_url_without_request(
+ self, tenants_fixture
+ ):
+ provider_cfg = LighthouseProviderConfiguration(
+ tenant_id=tenants_fixture[0].id,
+ provider_type=LighthouseProviderConfiguration.LLMProviderChoices.OPENAI_COMPATIBLE,
+ base_url="https://169.254.169.254/latest/meta-data",
+ is_active=True,
+ )
+ provider_cfg.credentials_decoded = {"api_key": "compatible-key"}
+ provider_cfg.save()
+
+ with patch("tasks.jobs.lighthouse_providers.openai.OpenAI") as mock_openai:
+ eager_result = check_lighthouse_provider_connection_task.apply(
+ kwargs={
+ "provider_config_id": str(provider_cfg.id),
+ "tenant_id": str(tenants_fixture[0].id),
+ }
+ )
+
+ assert eager_result.successful()
+ result = eager_result.result
+ assert result["connected"] is False
+ assert "base url" in result["error"].lower()
+ mock_openai.assert_not_called()
+ provider_cfg.refresh_from_db()
+ assert provider_cfg.is_active is False
+
+ def test_openai_compatible_connection_disables_redirects(self, tenants_fixture):
+ provider_cfg = LighthouseProviderConfiguration(
+ tenant_id=tenants_fixture[0].id,
+ provider_type=LighthouseProviderConfiguration.LLMProviderChoices.OPENAI_COMPATIBLE,
+ base_url="https://93.184.216.34/api/v1",
+ is_active=False,
+ )
+ provider_cfg.credentials_decoded = {"api_key": "compatible-key"}
+ provider_cfg.save()
+
+ with patch("tasks.jobs.lighthouse_providers.openai.OpenAI") as mock_openai:
+ mock_client = MagicMock()
+ mock_client.models.list.return_value = MagicMock()
+ mock_openai.return_value = mock_client
+
+ eager_result = check_lighthouse_provider_connection_task.apply(
+ kwargs={
+ "provider_config_id": str(provider_cfg.id),
+ "tenant_id": str(tenants_fixture[0].id),
+ }
+ )
+
+ assert eager_result.successful()
+ result = eager_result.result
+ assert result == {"connected": True, "error": None}
+ http_client = mock_openai.call_args.kwargs["http_client"]
+ assert http_client.follow_redirects is False
+ assert http_client.trust_env is False
+
+ def test_openai_compatible_connection_masks_remote_http_error(
+ self, tenants_fixture
+ ):
+ provider_cfg = LighthouseProviderConfiguration(
+ tenant_id=tenants_fixture[0].id,
+ provider_type=LighthouseProviderConfiguration.LLMProviderChoices.OPENAI_COMPATIBLE,
+ base_url="https://93.184.216.34/api/v1",
+ is_active=True,
+ )
+ provider_cfg.credentials_decoded = {"api_key": "compatible-key"}
+ provider_cfg.save()
+ remote_body = "
"
+ response = httpx.Response(
+ 404,
+ request=httpx.Request("GET", "https://provider.example/v1/models"),
+ )
+
+ with patch("tasks.jobs.lighthouse_providers.openai.OpenAI") as mock_openai:
+ mock_client = MagicMock()
+ mock_client.models.list.side_effect = openai.NotFoundError(
+ remote_body,
+ response=response,
+ body=remote_body,
+ )
+ mock_openai.return_value = mock_client
+
+ eager_result = refresh_lighthouse_provider_models_task.apply(
+ kwargs={
+ "provider_config_id": str(provider_cfg.id),
+ "tenant_id": str(tenants_fixture[0].id),
+ }
+ )
+
+ assert eager_result.successful()
+ result = eager_result.result
+ assert result["created"] == 0
+ assert result["updated"] == 0
+ assert result["deleted"] == 0
+ assert result["error"] == "Provider connection failed"
+ assert remote_body not in result["error"]
+
def test_refresh_models_mixed_operations(self, tenants_fixture):
"""Test mixed create, update, and delete operations."""
# Create provider configuration
@@ -1920,11 +2226,11 @@ class TestCleanupOrphanScheduledScans:
)
def test_cleanup_deletes_orphan_when_both_available_and_scheduled_exist(
- self, tenants_fixture, providers_fixture
+ self, tenants_fixture, aws_provider
):
"""Test that AVAILABLE scan is deleted when SCHEDULED also exists."""
tenant = tenants_fixture[0]
- provider = providers_fixture[0]
+ provider = aws_provider
periodic_task = self._create_periodic_task(provider.id, tenant.id)
# Create orphan AVAILABLE scan
@@ -1960,11 +2266,11 @@ class TestCleanupOrphanScheduledScans:
assert Scan.objects.filter(id=scheduled_scan.id).exists()
def test_cleanup_does_not_delete_when_only_available_exists(
- self, tenants_fixture, providers_fixture
+ self, tenants_fixture, aws_provider
):
"""Test that AVAILABLE scan is NOT deleted when no SCHEDULED exists."""
tenant = tenants_fixture[0]
- provider = providers_fixture[0]
+ provider = aws_provider
periodic_task = self._create_periodic_task(provider.id, tenant.id)
# Create only AVAILABLE scan (normal first scan scenario)
@@ -1989,11 +2295,11 @@ class TestCleanupOrphanScheduledScans:
assert Scan.objects.filter(id=available_scan.id).exists()
def test_cleanup_does_not_delete_when_only_scheduled_exists(
- self, tenants_fixture, providers_fixture
+ self, tenants_fixture, aws_provider
):
"""Test that nothing is deleted when only SCHEDULED exists."""
tenant = tenants_fixture[0]
- provider = providers_fixture[0]
+ provider = aws_provider
periodic_task = self._create_periodic_task(provider.id, tenant.id)
# Create only SCHEDULED scan (normal subsequent scan scenario)
@@ -2018,11 +2324,11 @@ class TestCleanupOrphanScheduledScans:
assert Scan.objects.filter(id=scheduled_scan.id).exists()
def test_cleanup_returns_zero_when_no_scans_exist(
- self, tenants_fixture, providers_fixture
+ self, tenants_fixture, aws_provider
):
"""Test that cleanup returns 0 when no scans exist."""
tenant = tenants_fixture[0]
- provider = providers_fixture[0]
+ provider = aws_provider
periodic_task = self._create_periodic_task(provider.id, tenant.id)
# Execute cleanup with no scans
@@ -2035,11 +2341,11 @@ class TestCleanupOrphanScheduledScans:
assert deleted_count == 0
def test_cleanup_deletes_multiple_orphan_available_scans(
- self, tenants_fixture, providers_fixture
+ self, tenants_fixture, aws_provider
):
"""Test that multiple AVAILABLE orphan scans are all deleted."""
tenant = tenants_fixture[0]
- provider = providers_fixture[0]
+ provider = aws_provider
periodic_task = self._create_periodic_task(provider.id, tenant.id)
# Create multiple orphan AVAILABLE scans
@@ -2084,12 +2390,11 @@ class TestCleanupOrphanScheduledScans:
assert Scan.objects.filter(id=scheduled_scan.id).exists()
def test_cleanup_does_not_affect_different_provider(
- self, tenants_fixture, providers_fixture
+ self, tenants_fixture, aws_provider_pair
):
"""Test that cleanup only affects scans for the specified provider."""
tenant = tenants_fixture[0]
- provider1 = providers_fixture[0]
- provider2 = providers_fixture[1]
+ provider1, provider2 = aws_provider_pair
periodic_task1 = self._create_periodic_task(provider1.id, tenant.id)
periodic_task2 = self._create_periodic_task(provider2.id, tenant.id)
@@ -2134,12 +2439,10 @@ class TestCleanupOrphanScheduledScans:
assert Scan.objects.filter(id=scheduled_scan_p1.id).exists()
assert Scan.objects.filter(id=available_scan_p2.id).exists()
- def test_cleanup_does_not_affect_manual_scans(
- self, tenants_fixture, providers_fixture
- ):
+ def test_cleanup_does_not_affect_manual_scans(self, tenants_fixture, aws_provider):
"""Test that cleanup only affects SCHEDULED trigger scans, not MANUAL."""
tenant = tenants_fixture[0]
- provider = providers_fixture[0]
+ provider = aws_provider
periodic_task = self._create_periodic_task(provider.id, tenant.id)
# Create orphan AVAILABLE scheduled scan
@@ -2185,11 +2488,11 @@ class TestCleanupOrphanScheduledScans:
assert Scan.objects.filter(id=manual_scan.id).exists()
def test_cleanup_does_not_affect_different_scheduler_task(
- self, tenants_fixture, providers_fixture
+ self, tenants_fixture, aws_provider
):
"""Test that cleanup only affects scans with the specified scheduler_task_id."""
tenant = tenants_fixture[0]
- provider = providers_fixture[0]
+ provider = aws_provider
periodic_task1 = self._create_periodic_task(provider.id, tenant.id)
# Create another periodic task
@@ -2243,6 +2546,51 @@ class TestCleanupOrphanScheduledScans:
assert Scan.objects.filter(id=scheduled_scan.id).exists()
assert Scan.objects.filter(id=available_scan_other_task.id).exists()
+ def test_cleanup_keeps_db_queued_scheduled_scans(
+ self, tenants_fixture, aws_provider
+ ):
+ """DB-queued scheduled scans have a task and must not be deleted as orphans."""
+ tenant = tenants_fixture[0]
+ provider = aws_provider
+ periodic_task = self._create_periodic_task(provider.id, tenant.id)
+ task_result = TaskResult.objects.create(
+ task_id=str(uuid.uuid4()),
+ task_name="scan-perform",
+ status="QUEUED",
+ )
+ queued_task = Task.objects.create(
+ id=task_result.task_id,
+ task_runner_task=task_result,
+ tenant_id=tenant.id,
+ )
+ queued_scan = Scan.objects.create(
+ tenant_id=tenant.id,
+ provider=provider,
+ name="Queued scheduled scan",
+ trigger=Scan.TriggerChoices.SCHEDULED,
+ state=StateChoices.AVAILABLE,
+ scheduler_task_id=periodic_task.id,
+ task=queued_task,
+ )
+ scheduled_scan = Scan.objects.create(
+ tenant_id=tenant.id,
+ provider=provider,
+ name="Daily scheduled scan",
+ trigger=Scan.TriggerChoices.SCHEDULED,
+ state=StateChoices.SCHEDULED,
+ scheduler_task_id=periodic_task.id,
+ )
+
+ deleted_count = _cleanup_orphan_scheduled_scans(
+ tenant_id=str(tenant.id),
+ provider_id=str(provider.id),
+ scheduler_task_id=periodic_task.id,
+ )
+
+ assert deleted_count == 0
+ assert Scan.objects.filter(id=queued_scan.id).exists()
+ assert Scan.objects.filter(id=scheduled_scan.id).exists()
+
@pytest.mark.django_db
class TestPerformScheduledScanTask:
@@ -2284,19 +2632,19 @@ class TestPerformScheduledScanTask:
task_id=task_id,
task_name="scan-perform-scheduled",
status="STARTED",
- date_created=datetime.now(timezone.utc),
+ date_created=datetime.now(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
+ def test_queues_scheduled_scan_when_scheduled_scan_is_executing(
+ self, tenants_fixture, aws_provider
):
- """Skip a scheduled run when another scheduled scan is already executing."""
+ """Queue a scheduled run when another scheduled scan is executing."""
tenant = tenants_fixture[0]
- provider = providers_fixture[0]
+ provider = aws_provider
periodic_task = self._create_periodic_task(provider.id, tenant.id)
task_id = str(uuid.uuid4())
self._create_task_result(tenant.id, task_id)
@@ -2321,8 +2669,16 @@ class TestPerformScheduledScanTask:
mock_scan.assert_not_called()
mock_complete_tasks.assert_not_called()
- assert result["id"] == str(executing_scan.id)
- assert result["state"] == StateChoices.EXECUTING
+ assert result["id"] != str(executing_scan.id)
+ assert result["state"] == StateChoices.AVAILABLE
+ queued_scheduled_scan = Scan.objects.get(
+ tenant_id=tenant.id,
+ provider=provider,
+ trigger=Scan.TriggerChoices.SCHEDULED,
+ state=StateChoices.AVAILABLE,
+ )
+ assert result["id"] == str(queued_scheduled_scan.id)
+ assert queued_scheduled_scan.task.task_runner_task.status == "QUEUED"
assert (
Scan.objects.filter(
tenant_id=tenant.id,
@@ -2330,15 +2686,141 @@ class TestPerformScheduledScanTask:
trigger=Scan.TriggerChoices.SCHEDULED,
state=StateChoices.SCHEDULED,
).count()
- == 0
+ == 1
+ )
+
+ def test_queues_scheduled_scan_when_manual_scan_is_pending(
+ self, tenants_fixture, aws_provider
+ ):
+ """Queue one scheduled run when a manual scan is already dispatched."""
+ tenant = tenants_fixture[0]
+ provider = aws_provider
+ self._create_periodic_task(provider.id, tenant.id)
+ task_id = str(uuid.uuid4())
+ self._create_task_result(tenant.id, task_id)
+ manual_task_result = TaskResult.objects.create(
+ task_id=str(uuid.uuid4()),
+ task_name="scan-perform",
+ status=states.PENDING,
+ )
+ manual_task = Task.objects.create(
+ id=manual_task_result.task_id,
+ task_runner_task=manual_task_result,
+ tenant_id=tenant.id,
+ )
+ manual_scan = Scan.objects.create(
+ tenant_id=tenant.id,
+ provider=provider,
+ name="Manual scan",
+ trigger=Scan.TriggerChoices.MANUAL,
+ state=StateChoices.AVAILABLE,
+ task=manual_task,
+ )
+
+ 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(manual_scan.id)
+ assert result["state"] == StateChoices.AVAILABLE
+ queued_scheduled_scan = Scan.objects.get(
+ tenant_id=tenant.id,
+ provider=provider,
+ trigger=Scan.TriggerChoices.SCHEDULED,
+ state=StateChoices.AVAILABLE,
+ )
+ assert result["id"] == str(queued_scheduled_scan.id)
+ assert queued_scheduled_scan.task.task_runner_task.status == "QUEUED"
+ scheduled_scan = Scan.objects.get(
+ tenant_id=tenant.id,
+ provider=provider,
+ trigger=Scan.TriggerChoices.SCHEDULED,
+ state=StateChoices.SCHEDULED,
+ )
+ assert scheduled_scan.scheduled_at > datetime.now(UTC)
+
+ def test_coalesces_scheduled_scan_when_one_is_already_queued(
+ self, tenants_fixture, aws_provider
+ ):
+ """Reuse the existing queued scheduled scan instead of adding another."""
+ tenant = tenants_fixture[0]
+ provider = aws_provider
+ periodic_task = self._create_periodic_task(provider.id, tenant.id)
+ task_id = str(uuid.uuid4())
+ self._create_task_result(tenant.id, task_id)
+ manual_task_result = TaskResult.objects.create(
+ task_id=str(uuid.uuid4()),
+ task_name="scan-perform",
+ status=states.PENDING,
+ )
+ manual_task = Task.objects.create(
+ id=manual_task_result.task_id,
+ task_runner_task=manual_task_result,
+ tenant_id=tenant.id,
+ )
+ Scan.objects.create(
+ tenant_id=tenant.id,
+ provider=provider,
+ name="Manual scan",
+ trigger=Scan.TriggerChoices.MANUAL,
+ state=StateChoices.AVAILABLE,
+ task=manual_task,
+ )
+ queued_task_result = TaskResult.objects.create(
+ task_id=str(uuid.uuid4()),
+ task_name="scan-perform",
+ status="QUEUED",
+ )
+ queued_task = Task.objects.create(
+ id=queued_task_result.task_id,
+ task_runner_task=queued_task_result,
+ tenant_id=tenant.id,
+ )
+ queued_scheduled_scan = Scan.objects.create(
+ tenant_id=tenant.id,
+ provider=provider,
+ name="Daily scheduled scan",
+ trigger=Scan.TriggerChoices.SCHEDULED,
+ state=StateChoices.AVAILABLE,
+ scheduler_task_id=periodic_task.id,
+ task=queued_task,
+ )
+
+ 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(queued_scheduled_scan.id)
+ assert (
+ Scan.objects.filter(
+ tenant_id=tenant.id,
+ provider=provider,
+ trigger=Scan.TriggerChoices.SCHEDULED,
+ state=StateChoices.AVAILABLE,
+ ).count()
+ == 1
)
def test_creates_next_scheduled_scan_after_completion(
- self, tenants_fixture, providers_fixture
+ self, tenants_fixture, aws_provider
):
"""Create a next scheduled scan after a successful run completes."""
tenant = tenants_fixture[0]
- provider = providers_fixture[0]
+ provider = aws_provider
self._create_periodic_task(provider.id, tenant.id)
task_id = str(uuid.uuid4())
self._create_task_result(tenant.id, task_id)
@@ -2372,7 +2854,7 @@ class TestPerformScheduledScanTask:
state=StateChoices.SCHEDULED,
)
assert scheduled_scans.count() == 1
- assert scheduled_scans.first().scheduled_at > datetime.now(timezone.utc)
+ assert scheduled_scans.first().scheduled_at > datetime.now(UTC)
assert (
Scan.objects.filter(
tenant_id=tenant.id,
@@ -2392,12 +2874,47 @@ class TestPerformScheduledScanTask:
== 1
)
+ def test_next_scheduled_scan_failure_does_not_mask_completed_scan(
+ self, tenants_fixture, aws_provider, caplog
+ ):
+ """Keep scheduled scan success when next-run creation fails."""
+ tenant = tenants_fixture[0]
+ provider = aws_provider
+ 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):
+ 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"),
+ patch(
+ "tasks.tasks._get_or_create_next_scheduled_scan",
+ side_effect=RuntimeError("scheduler unavailable"),
+ ),
+ patch("tasks.tasks._dispatch_next_queued_provider_scan") as mock_dispatch,
+ self._override_task_request(perform_scheduled_scan_task, id=task_id),
+ caplog.at_level("ERROR"),
+ ):
+ result = perform_scheduled_scan_task.run(
+ tenant_id=str(tenant.id), provider_id=str(provider.id)
+ )
+
+ assert result == {"status": "ok"}
+ mock_dispatch.assert_called_once_with(str(tenant.id), str(provider.id))
+ assert "Failed to ensure next scheduled scan" in caplog.text
+
def test_dedupes_multiple_scheduled_scans_before_run(
- self, tenants_fixture, providers_fixture
+ self, tenants_fixture, aws_provider
):
"""Ensure duplicated scheduled scans are removed before executing."""
tenant = tenants_fixture[0]
- provider = providers_fixture[0]
+ provider = aws_provider
periodic_task = self._create_periodic_task(provider.id, tenant.id)
task_id = str(uuid.uuid4())
self._create_task_result(tenant.id, task_id)
@@ -2408,7 +2925,7 @@ class TestPerformScheduledScanTask:
name="Daily scheduled scan",
trigger=Scan.TriggerChoices.SCHEDULED,
state=StateChoices.SCHEDULED,
- scheduled_at=datetime.now(timezone.utc),
+ scheduled_at=datetime.now(UTC),
scheduler_task_id=periodic_task.id,
)
duplicate_scan = Scan.objects.create(
@@ -2506,6 +3023,104 @@ class TestPerformScanTask:
mock_scan.assert_not_called()
mock_complete_tasks.assert_not_called()
+ def test_dispatches_next_queued_scan_after_completion(
+ self,
+ tenants_fixture,
+ aws_provider,
+ django_capture_on_commit_callbacks,
+ ):
+ """Dispatch the next queued scan for the provider after completion."""
+ tenant = tenants_fixture[0]
+ provider = aws_provider
+ current_scan = Scan.objects.create(
+ tenant_id=tenant.id,
+ provider=provider,
+ name="Running scan",
+ trigger=Scan.TriggerChoices.MANUAL,
+ state=StateChoices.AVAILABLE,
+ )
+ queued_task_result = TaskResult.objects.create(
+ task_id=str(uuid.uuid4()),
+ task_name="scan-perform",
+ status="QUEUED",
+ )
+ queued_task = Task.objects.create(
+ id=queued_task_result.task_id,
+ task_runner_task=queued_task_result,
+ tenant_id=tenant.id,
+ )
+ queued_scan = Scan.objects.create(
+ tenant_id=tenant.id,
+ provider=provider,
+ name="Queued scan",
+ trigger=Scan.TriggerChoices.MANUAL,
+ state=StateChoices.AVAILABLE,
+ task=queued_task,
+ )
+
+ def _complete_scan(tenant_id, scan_id, provider_id, checks_to_execute=None):
+ 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"),
+ patch("tasks.tasks.perform_scan_task.apply_async") as mock_apply_async,
+ ):
+ with django_capture_on_commit_callbacks(execute=True):
+ result = perform_scan_task.run(
+ tenant_id=str(tenant.id),
+ scan_id=str(current_scan.id),
+ provider_id=str(provider.id),
+ )
+
+ queued_task_result.refresh_from_db()
+ assert result == {"status": "ok"}
+ assert queued_task_result.status == states.PENDING
+ mock_apply_async.assert_called_once_with(
+ kwargs={
+ "tenant_id": str(tenant.id),
+ "scan_id": str(queued_scan.id),
+ "provider_id": str(provider.id),
+ },
+ task_id=str(queued_task.id),
+ )
+
+ def test_dispatch_failure_does_not_mask_completed_scan(
+ self, tenants_fixture, aws_provider, caplog
+ ):
+ """Keep scan success when queued dispatch fails after completion."""
+ tenant = tenants_fixture[0]
+ provider = aws_provider
+ current_scan = Scan.objects.create(
+ tenant_id=tenant.id,
+ provider=provider,
+ name="Running scan",
+ trigger=Scan.TriggerChoices.MANUAL,
+ state=StateChoices.AVAILABLE,
+ )
+
+ with (
+ patch("tasks.tasks.perform_prowler_scan", return_value={"status": "ok"}),
+ patch("tasks.tasks._perform_scan_complete_tasks"),
+ patch(
+ "tasks.tasks._dispatch_next_queued_provider_scan",
+ side_effect=RuntimeError("dispatch unavailable"),
+ ) as mock_dispatch,
+ caplog.at_level("ERROR"),
+ ):
+ result = perform_scan_task.run(
+ tenant_id=str(tenant.id),
+ scan_id=str(current_scan.id),
+ provider_id=str(provider.id),
+ )
+
+ assert result == {"status": "ok"}
+ mock_dispatch.assert_called_once_with(str(tenant.id), str(provider.id))
+ assert "Failed to dispatch next queued scan" in caplog.text
+
@pytest.mark.django_db
class TestReaggregateAllFindingGroupSummaries:
@@ -2538,7 +3153,7 @@ class TestReaggregateAllFindingGroupSummaries:
scan_id_today_p1 = uuid.uuid4()
scan_id_yesterday_p1 = uuid.uuid4()
scan_id_today_p2 = uuid.uuid4()
- today = datetime.now(tz=timezone.utc)
+ today = datetime.now(tz=UTC)
yesterday = today - timedelta(days=1)
mock_outer_group_result = MagicMock()
@@ -2619,7 +3234,7 @@ class TestReaggregateAllFindingGroupSummaries:
provider_id = uuid.uuid4()
latest_scan_today = uuid.uuid4()
earlier_scan_today = uuid.uuid4()
- today_late = datetime.now(tz=timezone.utc)
+ today_late = datetime.now(tz=UTC)
today_early = today_late - timedelta(hours=4)
mock_outer_group_result = MagicMock()
@@ -2672,3 +3287,37 @@ class TestReaggregateAllFindingGroupSummaries:
assert result == {"scans_reaggregated": 0}
mock_group.assert_not_called()
mock_chain.assert_not_called()
+
+
+class TestTaskTimeLimits:
+ """The per-task limits in task_annotations must actually take effect.
+
+ Celery applies a "*" annotation after the per-task one, so a "*" entry would
+ silently overwrite every specific limit and cap long scans at the default. The
+ default is set as the global limit instead, and these per-task limits must win.
+ """
+
+ def test_long_running_tasks_exceed_the_default_limit(self):
+ from config.celery import celery_app
+
+ default = celery_app.conf.task_time_limit
+ for name in (
+ "scan-perform",
+ "scan-perform-scheduled",
+ "attack-paths-scan-perform",
+ "provider-deletion",
+ "tenant-deletion",
+ ):
+ assert celery_app.tasks[name].time_limit > default
+
+ def test_connection_checks_stay_below_the_default_limit(self):
+ from config.celery import celery_app
+
+ default = celery_app.conf.task_time_limit
+ for name in (
+ "provider-connection-check",
+ "integration-connection-check",
+ "lighthouse-connection-check",
+ "lighthouse-provider-connection-check",
+ ):
+ assert celery_app.tasks[name].time_limit < default
diff --git a/api/src/backend/tasks/tests/test_utils.py b/api/src/backend/tasks/tests/test_utils.py
index cc7b7f188b..e6b5544a1c 100644
--- a/api/src/backend/tasks/tests/test_utils.py
+++ b/api/src/backend/tasks/tests/test_utils.py
@@ -1,4 +1,4 @@
-from datetime import datetime, timedelta, timezone
+from datetime import UTC, datetime, timedelta
from unittest.mock import patch
import pytest
@@ -29,7 +29,7 @@ class TestGetNextExecutionDatetime:
task_id="abc123",
task_name="scan-perform-scheduled",
status="SUCCESS",
- date_created=datetime.now(timezone.utc) - timedelta(hours=1),
+ date_created=datetime.now(UTC) - timedelta(hours=1),
result="Success",
)
return task_result
diff --git a/api/src/backend/tasks/utils.py b/api/src/backend/tasks/utils.py
index eded5bfb9a..cac9a10f0d 100644
--- a/api/src/backend/tasks/utils.py
+++ b/api/src/backend/tasks/utils.py
@@ -1,12 +1,11 @@
import json
-from datetime import datetime, timedelta, timezone
+from datetime import UTC, datetime, timedelta
from enum import Enum
+from api.models import Scan, StateChoices
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"
@@ -45,9 +44,9 @@ def get_next_execution_datetime(task_id: int, provider_id: str) -> datetime:
interval = periodic_task_instance.interval
current_scheduled_time = datetime.combine(
- datetime.now(timezone.utc).date(),
+ datetime.now(UTC).date(),
task_instance.date_created.time(),
- tzinfo=timezone.utc,
+ tzinfo=UTC,
)
return current_scheduled_time + timedelta(**{interval.period: interval.every})
@@ -104,6 +103,7 @@ def _get_or_create_scheduled_scan(
trigger=Scan.TriggerChoices.SCHEDULED,
state__in=(StateChoices.SCHEDULED, StateChoices.AVAILABLE),
scheduler_task_id=scheduler_task_id,
+ task__isnull=True,
).order_by("scheduled_at", "inserted_at")
)
diff --git a/api/tests/performance/scenarios/compliance.py b/api/tests/performance/scenarios/compliance.py
index ae4e030a59..9692c3aac2 100644
--- a/api/tests/performance/scenarios/compliance.py
+++ b/api/tests/performance/scenarios/compliance.py
@@ -90,7 +90,7 @@ class APIUser(APIUserBase):
def compliance_overviews_default(self):
provider_type, scan_info = _get_random_scan()
name = f"/compliance-overviews ({provider_type})"
- endpoint = f"/compliance-overviews?" f"filter[scan_id]={scan_info['scan_id']}"
+ endpoint = f"/compliance-overviews?filter[scan_id]={scan_info['scan_id']}"
self.client.get(endpoint, headers=get_auth_headers(self.token), name=name)
@task(2)
@@ -122,7 +122,6 @@ class APIUser(APIUserBase):
compliance_id = _get_random_compliance_id(provider_type)
name = f"/compliance-overviews/attributes ({compliance_id})"
endpoint = (
- f"/compliance-overviews/attributes"
- f"?filter[compliance_id]={compliance_id}"
+ f"/compliance-overviews/attributes?filter[compliance_id]={compliance_id}"
)
self.client.get(endpoint, headers=get_auth_headers(self.token), name=name)
diff --git a/api/tests/performance/scenarios/findings.py b/api/tests/performance/scenarios/findings.py
index acd32f2497..7741d1df4c 100644
--- a/api/tests/performance/scenarios/findings.py
+++ b/api/tests/performance/scenarios/findings.py
@@ -80,7 +80,7 @@ class APIUser(APIUserBase):
@task(3)
def findings_metadata(self):
- endpoint = f"/findings/metadata?" f"filter[inserted_at]={TARGET_INSERTED_AT}"
+ endpoint = f"/findings/metadata?filter[inserted_at]={TARGET_INSERTED_AT}"
self.client.get(
endpoint, headers=get_auth_headers(self.token), name="/findings/metadata"
)
@@ -98,7 +98,7 @@ class APIUser(APIUserBase):
@task
def findings_metadata_scan_small(self):
- endpoint = f"/findings/metadata?" f"&filter[scan]={self.s_scan_id}"
+ endpoint = f"/findings/metadata?&filter[scan]={self.s_scan_id}"
self.client.get(
endpoint,
headers=get_auth_headers(self.token),
@@ -118,7 +118,7 @@ class APIUser(APIUserBase):
@task
def findings_metadata_scan_medium(self):
- endpoint = f"/findings/metadata?" f"&filter[scan]={self.m_scan_id}"
+ endpoint = f"/findings/metadata?&filter[scan]={self.m_scan_id}"
self.client.get(
endpoint,
headers=get_auth_headers(self.token),
@@ -150,7 +150,7 @@ class APIUser(APIUserBase):
@task
def findings_metadata_scan_large(self):
- endpoint = f"/findings/metadata?" f"&filter[scan]={self.l_scan_id}"
+ endpoint = f"/findings/metadata?&filter[scan]={self.l_scan_id}"
self.client.get(
endpoint,
headers=get_auth_headers(self.token),
diff --git a/api/tests/performance/scenarios/resources.py b/api/tests/performance/scenarios/resources.py
index dabf99c3f2..43e13fad92 100644
--- a/api/tests/performance/scenarios/resources.py
+++ b/api/tests/performance/scenarios/resources.py
@@ -97,7 +97,7 @@ class APIUser(APIUserBase):
name = "/resources?filter[scan_id] - 50k"
page_number = self._next_page(name)
endpoint = (
- f"/resources?page[number]={page_number}" f"&filter[scan]={self.s_scan_id}"
+ f"/resources?page[number]={page_number}&filter[scan]={self.s_scan_id}"
)
self.client.get(endpoint, headers=get_auth_headers(self.token), name=name)
@@ -116,7 +116,7 @@ class APIUser(APIUserBase):
name = "/resources?filter[scan_id] - 250k"
page_number = self._next_page(name)
endpoint = (
- f"/resources?page[number]={page_number}" f"&filter[scan]={self.m_scan_id}"
+ f"/resources?page[number]={page_number}&filter[scan]={self.m_scan_id}"
)
self.client.get(endpoint, headers=get_auth_headers(self.token), name=name)
@@ -135,7 +135,7 @@ class APIUser(APIUserBase):
name = "/resources?filter[scan_id] - 500k"
page_number = self._next_page(name)
endpoint = (
- f"/resources?page[number]={page_number}" f"&filter[scan]={self.l_scan_id}"
+ f"/resources?page[number]={page_number}&filter[scan]={self.l_scan_id}"
)
self.client.get(endpoint, headers=get_auth_headers(self.token), name=name)
diff --git a/api/tests/performance/utils/helpers.py b/api/tests/performance/utils/helpers.py
index 08144a5c4b..29ce2a47fc 100644
--- a/api/tests/performance/utils/helpers.py
+++ b/api/tests/performance/utils/helpers.py
@@ -153,9 +153,9 @@ def get_dynamic_filters_pairs(
f"{host}/{endpoint}/metadata?{metadata_filters}",
headers=get_auth_headers(token),
)
- assert (
- response.status_code == 200
- ), f"Failed to get resource filters values: {response.text}"
+ assert response.status_code == 200, (
+ f"Failed to get resource filters values: {response.text}"
+ )
attributes = response.json()["data"]["attributes"]
return {
diff --git a/api/towncrier.toml b/api/towncrier.toml
new file mode 100644
index 0000000000..528e6129ec
--- /dev/null
+++ b/api/towncrier.toml
@@ -0,0 +1,39 @@
+[tool.towncrier]
+directory = "changelog.d"
+filename = "CHANGELOG.md"
+start_string = "\n"
+title_format = "## [{version}] ({name})"
+issue_format = "[(#{issue})](https://github.com/prowler-cloud/prowler/pull/{issue})"
+template = "../.github/towncrier/template.md.jinja"
+underlines = ["", "", ""]
+ignore = [".gitkeep", "README.md"]
+
+[[tool.towncrier.type]]
+directory = "added"
+name = "🚀 Added"
+showcontent = true
+
+[[tool.towncrier.type]]
+directory = "changed"
+name = "🔄 Changed"
+showcontent = true
+
+[[tool.towncrier.type]]
+directory = "deprecated"
+name = "⚠️ Deprecated"
+showcontent = true
+
+[[tool.towncrier.type]]
+directory = "removed"
+name = "❌ Removed"
+showcontent = true
+
+[[tool.towncrier.type]]
+directory = "fixed"
+name = "🐞 Fixed"
+showcontent = true
+
+[[tool.towncrier.type]]
+directory = "security"
+name = "🔐 Security"
+showcontent = true
diff --git a/api/uv.lock b/api/uv.lock
index 03993172d9..de878d9dc9 100644
--- a/api/uv.lock
+++ b/api/uv.lock
@@ -16,7 +16,7 @@ constraints = [
{ name = "aiobotocore", specifier = "==2.25.1" },
{ name = "aiofiles", specifier = "==24.1.0" },
{ name = "aiohappyeyeballs", specifier = "==2.6.1" },
- { name = "aiohttp", specifier = "==3.13.5" },
+ { name = "aiohttp", specifier = "==3.14.0" },
{ name = "aioitertools", specifier = "==0.13.0" },
{ name = "aiosignal", specifier = "==1.4.0" },
{ name = "alibabacloud-actiontrail20200706", specifier = "==2.4.1" },
@@ -61,9 +61,8 @@ constraints = [
{ name = "astroid", specifier = "==3.2.4" },
{ name = "async-timeout", specifier = "==5.0.1" },
{ name = "attrs", specifier = "==25.4.0" },
- { name = "authlib", specifier = "==1.6.9" },
+ { name = "authlib", specifier = "==1.6.12" },
{ name = "autopep8", specifier = "==2.3.2" },
- { name = "awsipranges", specifier = "==0.3.3" },
{ name = "azure-cli-core", specifier = "==2.83.0" },
{ name = "azure-cli-telemetry", specifier = "==1.1.0" },
{ name = "azure-common", specifier = "==1.1.28" },
@@ -111,7 +110,7 @@ constraints = [
{ name = "blinker", specifier = "==1.9.0" },
{ name = "boto3", specifier = "==1.40.61" },
{ name = "botocore", specifier = "==1.40.61" },
- { name = "cartography", specifier = "==0.135.0" },
+ { name = "cartography", specifier = "==0.138.1" },
{ name = "celery", specifier = "==5.6.2" },
{ name = "certifi", specifier = "==2026.1.4" },
{ name = "cffi", specifier = "==2.0.0" },
@@ -136,7 +135,6 @@ constraints = [
{ name = "debugpy", specifier = "==1.8.20" },
{ name = "decorator", specifier = "==5.2.1" },
{ name = "defusedxml", specifier = "==0.7.1" },
- { name = "detect-secrets", specifier = "==1.5.0" },
{ name = "dill", specifier = "==0.4.1" },
{ name = "distro", specifier = "==1.9.0" },
{ name = "dj-rest-auth", specifier = "==7.0.1" },
@@ -146,6 +144,7 @@ constraints = [
{ name = "django-celery-results", specifier = "==2.6.0" },
{ name = "django-cors-headers", specifier = "==4.4.0" },
{ name = "django-environ", specifier = "==0.11.2" },
+ { name = "django-eventstream", specifier = "==5.3.3" },
{ name = "django-filter", specifier = "==24.3" },
{ name = "django-guid", specifier = "==3.5.0" },
{ name = "django-postgres-extra", specifier = "==2.0.9" },
@@ -163,7 +162,7 @@ constraints = [
{ name = "drf-simple-apikey", specifier = "==2.2.1" },
{ name = "drf-spectacular", specifier = "==0.27.2" },
{ name = "drf-spectacular-jsonapi", specifier = "==0.5.1" },
- { name = "dulwich", specifier = "==0.23.0" },
+ { name = "dulwich", specifier = "==1.2.5" },
{ name = "duo-client", specifier = "==5.5.0" },
{ name = "durationpy", specifier = "==0.10" },
{ name = "email-validator", specifier = "==2.2.0" },
@@ -190,7 +189,7 @@ constraints = [
{ name = "grpc-google-iam-v1", specifier = "==0.14.3" },
{ name = "grpcio", specifier = "==1.76.0" },
{ name = "grpcio-status", specifier = "==1.76.0" },
- { name = "gunicorn", specifier = "==23.0.0" },
+ { name = "gunicorn", specifier = "==26.0.0" },
{ name = "h11", specifier = "==0.16.0" },
{ name = "h2", specifier = "==4.3.0" },
{ name = "hpack", specifier = "==4.1.0" },
@@ -199,8 +198,8 @@ constraints = [
{ name = "httpx", specifier = "==0.28.1" },
{ name = "humanfriendly", specifier = "==10.0" },
{ name = "hyperframe", specifier = "==6.1.0" },
- { name = "iamdata", specifier = "==0.1.202602021" },
- { name = "idna", specifier = "==3.11" },
+ { name = "iamdata", specifier = "==0.1.202605131" },
+ { name = "idna", specifier = "==3.15" },
{ name = "importlib-metadata", specifier = "==8.7.1" },
{ name = "inflection", specifier = "==0.5.1" },
{ name = "iniconfig", specifier = "==2.3.0" },
@@ -218,6 +217,7 @@ constraints = [
{ name = "jsonschema", specifier = "==4.23.0" },
{ name = "jsonschema-specifications", specifier = "==2025.9.1" },
{ name = "keystoneauth1", specifier = "==5.13.0" },
+ { name = "kingfisher-bin", specifier = "==1.104.0" },
{ name = "kiwisolver", specifier = "==1.4.9" },
{ name = "knack", specifier = "==0.11.0" },
{ name = "kombu", specifier = "==5.6.2" },
@@ -252,7 +252,7 @@ constraints = [
{ name = "neo4j", specifier = "==6.1.0" },
{ name = "nest-asyncio", specifier = "==1.6.0" },
{ name = "nltk", specifier = "==3.9.4" },
- { name = "numpy", specifier = "==2.0.2" },
+ { name = "numpy", specifier = "==2.2.6" },
{ name = "oauthlib", specifier = "==3.3.1" },
{ name = "oci", specifier = "==2.169.0" },
{ name = "openai", specifier = "==1.109.1" },
@@ -281,7 +281,7 @@ constraints = [
{ name = "psutil", specifier = "==7.2.2" },
{ name = "psycopg2-binary", specifier = "==2.9.9" },
{ name = "py-deviceid", specifier = "==0.1.1" },
- { name = "py-iam-expand", specifier = "==0.1.0" },
+ { name = "py-iam-expand", specifier = "==0.3.0" },
{ name = "py-ocsf-models", specifier = "==0.8.1" },
{ name = "pyasn1", specifier = "==0.6.3" },
{ name = "pyasn1-modules", specifier = "==0.4.2" },
@@ -291,7 +291,7 @@ constraints = [
{ name = "pydantic-core", specifier = "==2.41.5" },
{ name = "pygithub", specifier = "==2.8.0" },
{ name = "pygments", specifier = "==2.20.0" },
- { name = "pyjwt", specifier = "==2.12.1" },
+ { name = "pyjwt", specifier = "==2.13.0" },
{ name = "pylint", specifier = "==3.2.5" },
{ name = "pymsalruntime", specifier = "==0.18.1" },
{ name = "pynacl", specifier = "==1.6.2" },
@@ -327,7 +327,7 @@ constraints = [
{ name = "rpds-py", specifier = "==0.30.0" },
{ name = "rsa", specifier = "==4.9.1" },
{ name = "ruamel-yaml", specifier = "==0.19.1" },
- { name = "ruff", specifier = "==0.5.0" },
+ { name = "ruff", specifier = "==0.15.11" },
{ name = "s3transfer", specifier = "==0.14.0" },
{ name = "scaleway", specifier = "==2.10.3" },
{ name = "scaleway-core", specifier = "==2.10.3" },
@@ -357,12 +357,14 @@ constraints = [
{ name = "uritemplate", specifier = "==4.2.0" },
{ name = "urllib3", specifier = "==2.7.0" },
{ name = "uuid6", specifier = "==2024.7.10" },
+ { name = "uvicorn", specifier = "==0.49.0" },
+ { name = "uvloop", specifier = "==0.22.1" },
{ name = "vine", specifier = "==5.1.0" },
{ name = "vulture", specifier = "==2.14" },
{ name = "wcwidth", specifier = "==0.5.3" },
{ name = "websocket-client", specifier = "==1.9.0" },
{ name = "werkzeug", specifier = "==3.1.7" },
- { name = "workos", specifier = "==6.0.4" },
+ { name = "workos", specifier = "==6.0.8" },
{ name = "wrapt", specifier = "==1.17.3" },
{ name = "xlsxwriter", specifier = "==3.2.9" },
{ name = "xmlsec", specifier = "==1.3.17" },
@@ -374,8 +376,11 @@ constraints = [
{ name = "zstd", specifier = "==1.5.7.3" },
]
overrides = [
+ { name = "azure-mgmt-containerservice", specifier = "==34.1.0" },
+ { name = "dulwich", specifier = "==1.2.5" },
{ name = "microsoft-kiota-abstractions", specifier = "==1.9.9" },
{ name = "okta", specifier = "==3.4.2" },
+ { name = "pyjwt", extras = ["crypto"], specifier = "==2.13.0" },
]
[[package]]
@@ -393,7 +398,7 @@ version = "1.2.7"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "cryptography" },
- { name = "pyjwt" },
+ { name = "pyjwt", extra = ["crypto"] },
{ name = "python-dateutil" },
{ name = "requests" },
]
@@ -467,7 +472,7 @@ wheels = [
[[package]]
name = "aiohttp"
-version = "3.13.5"
+version = "3.14.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "aiohappyeyeballs" },
@@ -476,44 +481,47 @@ dependencies = [
{ name = "frozenlist" },
{ name = "multidict" },
{ name = "propcache" },
+ { name = "typing-extensions" },
{ name = "yarl" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/77/9a/152096d4808df8e4268befa55fba462f440f14beab85e8ad9bf990516918/aiohttp-3.13.5.tar.gz", hash = "sha256:9d98cc980ecc96be6eb4c1994ce35d28d8b1f5e5208a23b421187d1209dbb7d1", size = 7858271, upload-time = "2026-03-31T22:01:03.343Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/ee/ab/93ce242f899b68c51b0578c027aafa791ab3614cb9345fa5d37b5f5c8e3e/aiohttp-3.14.0.tar.gz", hash = "sha256:2882de819734c715fd1b9c11c97e09fa020d14438203d1d354d8ed1702791c9b", size = 7940674, upload-time = "2026-06-01T19:41:02.763Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/d6/f5/a20c4ac64aeaef1679e25c9983573618ff765d7aa829fa2b84ae7573169e/aiohttp-3.13.5-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:7ab7229b6f9b5c1ba4910d6c41a9eb11f543eadb3f384df1b4c293f4e73d44d6", size = 757513, upload-time = "2026-03-31T21:57:02.146Z" },
- { url = "https://files.pythonhosted.org/packages/75/0a/39fa6c6b179b53fcb3e4b3d2b6d6cad0180854eda17060c7218540102bef/aiohttp-3.13.5-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:8f14c50708bb156b3a3ca7230b3d820199d56a48e3af76fa21c2d6087190fe3d", size = 506748, upload-time = "2026-03-31T21:57:04.275Z" },
- { url = "https://files.pythonhosted.org/packages/87/ec/e38ce072e724fd7add6243613f8d1810da084f54175353d25ccf9f9c7e5a/aiohttp-3.13.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e7d2f8616f0ff60bd332022279011776c3ac0faa0f1b463f7bb12326fbc97a1c", size = 501673, upload-time = "2026-03-31T21:57:06.208Z" },
- { url = "https://files.pythonhosted.org/packages/ba/ba/3bc7525d7e2beaa11b309a70d48b0d3cfc3c2089ec6a7d0820d59c657053/aiohttp-3.13.5-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a2567b72e1ffc3ab25510db43f355b29eeada56c0a622e58dcdb19530eb0a3cb", size = 1763757, upload-time = "2026-03-31T21:57:07.882Z" },
- { url = "https://files.pythonhosted.org/packages/5e/ab/e87744cf18f1bd78263aba24924d4953b41086bd3a31d22452378e9028a0/aiohttp-3.13.5-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:fb0540c854ac9c0c5ad495908fdfd3e332d553ec731698c0e29b1877ba0d2ec6", size = 1720152, upload-time = "2026-03-31T21:57:09.946Z" },
- { url = "https://files.pythonhosted.org/packages/6b/f3/ed17a6f2d742af17b50bae2d152315ed1b164b07a5fd5cc1754d99e4dfa5/aiohttp-3.13.5-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c9883051c6972f58bfc4ebb2116345ee2aa151178e99c3f2b2bbe2af712abd13", size = 1818010, upload-time = "2026-03-31T21:57:12.157Z" },
- { url = "https://files.pythonhosted.org/packages/53/06/ecbc63dc937192e2a5cb46df4d3edb21deb8225535818802f210a6ea5816/aiohttp-3.13.5-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2294172ce08a82fb7c7273485895de1fa1186cc8294cfeb6aef4af42ad261174", size = 1907251, upload-time = "2026-03-31T21:57:14.023Z" },
- { url = "https://files.pythonhosted.org/packages/7e/a5/0521aa32c1ddf3aa1e71dcc466be0b7db2771907a13f18cddaa45967d97b/aiohttp-3.13.5-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3a807cabd5115fb55af198b98178997a5e0e57dead43eb74a93d9c07d6d4a7dc", size = 1759969, upload-time = "2026-03-31T21:57:16.146Z" },
- { url = "https://files.pythonhosted.org/packages/f6/78/a38f8c9105199dd3b9706745865a8a59d0041b6be0ca0cc4b2ccf1bab374/aiohttp-3.13.5-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:aa6d0d932e0f39c02b80744273cd5c388a2d9bc07760a03164f229c8e02662f6", size = 1616871, upload-time = "2026-03-31T21:57:17.856Z" },
- { url = "https://files.pythonhosted.org/packages/6f/41/27392a61ead8ab38072105c71aa44ff891e71653fe53d576a7067da2b4e8/aiohttp-3.13.5-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:60869c7ac4aaabe7110f26499f3e6e5696eae98144735b12a9c3d9eae2b51a49", size = 1739844, upload-time = "2026-03-31T21:57:19.679Z" },
- { url = "https://files.pythonhosted.org/packages/6e/55/5564e7ae26d94f3214250009a0b1c65a0c6af4bf88924ccb6fdab901de28/aiohttp-3.13.5-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:26d2f8546f1dfa75efa50c3488215a903c0168d253b75fba4210f57ab77a0fb8", size = 1731969, upload-time = "2026-03-31T21:57:22.006Z" },
- { url = "https://files.pythonhosted.org/packages/6d/c5/705a3929149865fc941bcbdd1047b238e4a72bcb215a9b16b9d7a2e8d992/aiohttp-3.13.5-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:f1162a1492032c82f14271e831c8f4b49f2b6078f4f5fc74de2c912fa225d51d", size = 1795193, upload-time = "2026-03-31T21:57:24.256Z" },
- { url = "https://files.pythonhosted.org/packages/a6/19/edabed62f718d02cff7231ca0db4ef1c72504235bc467f7b67adb1679f48/aiohttp-3.13.5-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:8b14eb3262fad0dc2f89c1a43b13727e709504972186ff6a99a3ecaa77102b6c", size = 1606477, upload-time = "2026-03-31T21:57:26.364Z" },
- { url = "https://files.pythonhosted.org/packages/de/fc/76f80ef008675637d88d0b21584596dc27410a990b0918cb1e5776545b5b/aiohttp-3.13.5-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:ca9ac61ac6db4eb6c2a0cd1d0f7e1357647b638ccc92f7e9d8d133e71ed3c6ac", size = 1813198, upload-time = "2026-03-31T21:57:28.316Z" },
- { url = "https://files.pythonhosted.org/packages/e5/67/5b3ac26b80adb20ea541c487f73730dc8fa107d632c998f25bbbab98fcda/aiohttp-3.13.5-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:7996023b2ed59489ae4762256c8516df9820f751cf2c5da8ed2fb20ee50abab3", size = 1752321, upload-time = "2026-03-31T21:57:30.549Z" },
- { url = "https://files.pythonhosted.org/packages/88/06/e4a2e49255ea23fa4feeb5ab092d90240d927c15e47b5b5c48dff5a9ce29/aiohttp-3.13.5-cp311-cp311-win32.whl", hash = "sha256:77dfa48c9f8013271011e51c00f8ada19851f013cde2c48fca1ba5e0caf5bb06", size = 439069, upload-time = "2026-03-31T21:57:32.388Z" },
- { url = "https://files.pythonhosted.org/packages/c0/43/8c7163a596dab4f8be12c190cf467a1e07e4734cf90eebb39f7f5d53fc6a/aiohttp-3.13.5-cp311-cp311-win_amd64.whl", hash = "sha256:d3a4834f221061624b8887090637db9ad4f61752001eae37d56c52fddade2dc8", size = 462859, upload-time = "2026-03-31T21:57:34.455Z" },
- { url = "https://files.pythonhosted.org/packages/be/6f/353954c29e7dcce7cf00280a02c75f30e133c00793c7a2ed3776d7b2f426/aiohttp-3.13.5-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:023ecba036ddd840b0b19bf195bfae970083fd7024ce1ac22e9bba90464620e9", size = 748876, upload-time = "2026-03-31T21:57:36.319Z" },
- { url = "https://files.pythonhosted.org/packages/f5/1b/428a7c64687b3b2e9cd293186695affc0e1e54a445d0361743b231f11066/aiohttp-3.13.5-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:15c933ad7920b7d9a20de151efcd05a6e38302cbf0e10c9b2acb9a42210a2416", size = 499557, upload-time = "2026-03-31T21:57:38.236Z" },
- { url = "https://files.pythonhosted.org/packages/29/47/7be41556bfbb6917069d6a6634bb7dd5e163ba445b783a90d40f5ac7e3a7/aiohttp-3.13.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ab2899f9fa2f9f741896ebb6fa07c4c883bfa5c7f2ddd8cf2aafa86fa981b2d2", size = 500258, upload-time = "2026-03-31T21:57:39.923Z" },
- { url = "https://files.pythonhosted.org/packages/67/84/c9ecc5828cb0b3695856c07c0a6817a99d51e2473400f705275a2b3d9239/aiohttp-3.13.5-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a60eaa2d440cd4707696b52e40ed3e2b0f73f65be07fd0ef23b6b539c9c0b0b4", size = 1749199, upload-time = "2026-03-31T21:57:41.938Z" },
- { url = "https://files.pythonhosted.org/packages/f0/d3/3c6d610e66b495657622edb6ae7c7fd31b2e9086b4ec50b47897ad6042a9/aiohttp-3.13.5-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:55b3bdd3292283295774ab585160c4004f4f2f203946997f49aac032c84649e9", size = 1721013, upload-time = "2026-03-31T21:57:43.904Z" },
- { url = "https://files.pythonhosted.org/packages/49/a0/24409c12217456df0bae7babe3b014e460b0b38a8e60753d6cb339f6556d/aiohttp-3.13.5-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c2b2355dc094e5f7d45a7bb262fe7207aa0460b37a0d87027dcf21b5d890e7d5", size = 1781501, upload-time = "2026-03-31T21:57:46.285Z" },
- { url = "https://files.pythonhosted.org/packages/98/9d/b65ec649adc5bccc008b0957a9a9c691070aeac4e41cea18559fef49958b/aiohttp-3.13.5-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b38765950832f7d728297689ad78f5f2cf79ff82487131c4d26fe6ceecdc5f8e", size = 1878981, upload-time = "2026-03-31T21:57:48.734Z" },
- { url = "https://files.pythonhosted.org/packages/57/d8/8d44036d7eb7b6a8ec4c5494ea0c8c8b94fbc0ed3991c1a7adf230df03bf/aiohttp-3.13.5-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b18f31b80d5a33661e08c89e202edabf1986e9b49c42b4504371daeaa11b47c1", size = 1767934, upload-time = "2026-03-31T21:57:51.171Z" },
- { url = "https://files.pythonhosted.org/packages/31/04/d3f8211f273356f158e3464e9e45484d3fb8c4ce5eb2f6fe9405c3273983/aiohttp-3.13.5-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:33add2463dde55c4f2d9635c6ab33ce154e5ecf322bd26d09af95c5f81cfa286", size = 1566671, upload-time = "2026-03-31T21:57:53.326Z" },
- { url = "https://files.pythonhosted.org/packages/41/db/073e4ebe00b78e2dfcacff734291651729a62953b48933d765dc513bf798/aiohttp-3.13.5-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:327cc432fdf1356fb4fbc6fe833ad4e9f6aacb71a8acaa5f1855e4b25910e4a9", size = 1705219, upload-time = "2026-03-31T21:57:55.385Z" },
- { url = "https://files.pythonhosted.org/packages/48/45/7dfba71a2f9fd97b15c95c06819de7eb38113d2cdb6319669195a7d64270/aiohttp-3.13.5-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:7c35b0bf0b48a70b4cb4fc5d7bed9b932532728e124874355de1a0af8ec4bc88", size = 1743049, upload-time = "2026-03-31T21:57:57.341Z" },
- { url = "https://files.pythonhosted.org/packages/18/71/901db0061e0f717d226386a7f471bb59b19566f2cae5f0d93874b017271f/aiohttp-3.13.5-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:df23d57718f24badef8656c49743e11a89fd6f5358fa8a7b96e728fda2abf7d3", size = 1749557, upload-time = "2026-03-31T21:57:59.626Z" },
- { url = "https://files.pythonhosted.org/packages/08/d5/41eebd16066e59cd43728fe74bce953d7402f2b4ddfdfef2c0e9f17ca274/aiohttp-3.13.5-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:02e048037a6501a5ec1f6fc9736135aec6eb8a004ce48838cb951c515f32c80b", size = 1558931, upload-time = "2026-03-31T21:58:01.972Z" },
- { url = "https://files.pythonhosted.org/packages/30/e6/4a799798bf05740e66c3a1161079bda7a3dd8e22ca392481d7a7f9af82a6/aiohttp-3.13.5-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:31cebae8b26f8a615d2b546fee45d5ffb76852ae6450e2a03f42c9102260d6fe", size = 1774125, upload-time = "2026-03-31T21:58:04.007Z" },
- { url = "https://files.pythonhosted.org/packages/84/63/7749337c90f92bc2cb18f9560d67aa6258c7060d1397d21529b8004fcf6f/aiohttp-3.13.5-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:888e78eb5ca55a615d285c3c09a7a91b42e9dd6fc699b166ebd5dee87c9ccf14", size = 1732427, upload-time = "2026-03-31T21:58:06.337Z" },
- { url = "https://files.pythonhosted.org/packages/98/de/cf2f44ff98d307e72fb97d5f5bbae3bfcb442f0ea9790c0bf5c5c2331404/aiohttp-3.13.5-cp312-cp312-win32.whl", hash = "sha256:8bd3ec6376e68a41f9f95f5ed170e2fcf22d4eb27a1f8cb361d0508f6e0557f3", size = 433534, upload-time = "2026-03-31T21:58:08.712Z" },
- { url = "https://files.pythonhosted.org/packages/aa/ca/eadf6f9c8fa5e31d40993e3db153fb5ed0b11008ad5d9de98a95045bed84/aiohttp-3.13.5-cp312-cp312-win_amd64.whl", hash = "sha256:110e448e02c729bcebb18c60b9214a87ba33bac4a9fa5e9a5f139938b56c6cb1", size = 460446, upload-time = "2026-03-31T21:58:10.945Z" },
+ { url = "https://files.pythonhosted.org/packages/67/47/7727bfe8db93f8835a001bd4359d8480cc68d1259b8bce334668f8be97bd/aiohttp-3.14.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:54bf3522d6f7351e55f89a62d5c2bf138ad557b031670266c5df604ae88e0b5a", size = 759147, upload-time = "2026-06-01T19:37:12.918Z" },
+ { url = "https://files.pythonhosted.org/packages/eb/f2/cd3fedff6fade73d71df9ec908c210cec518ef90fd00289250684b90aecf/aiohttp-3.14.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:0746d9fb0ac4fdef643a84494efe3f06d50335dd8c7a530228b86448aae0a803", size = 513705, upload-time = "2026-06-01T19:37:14.633Z" },
+ { url = "https://files.pythonhosted.org/packages/5a/fe/49746b6b610144a06323bebd8e1211a390310d8c69b98dd6d52df341bc3e/aiohttp-3.14.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:9f3a96b6d39a4872222beee72e1df41d2ff886ae96152cf3e757ef8c5673ef0e", size = 509627, upload-time = "2026-06-01T19:37:16.385Z" },
+ { url = "https://files.pythonhosted.org/packages/4c/3f/28f2f6cf3d5c0e7b01b27140d0e7873fd11fb341169ad3ce78ad04aba628/aiohttp-3.14.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d336820adbb914debbc90a1d8c1bfc4bea55996aecf64866a989d35d1f9fd903", size = 1769293, upload-time = "2026-06-01T19:37:18.067Z" },
+ { url = "https://files.pythonhosted.org/packages/97/6f/2e5f1b525d5474b12b3c60abf733a755845f3bceff21542081ada515f837/aiohttp-3.14.0-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:71b2604c9bfc1b115547d63a094d5244b3f02799833513a99a68aaa7b167c4cb", size = 1732363, upload-time = "2026-06-01T19:37:20.138Z" },
+ { url = "https://files.pythonhosted.org/packages/a8/ce/596120faa85ca7b19cd061e3f2f3be23aa8f11a0aedf9191db9e0da1bd76/aiohttp-3.14.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:610d68800435903e303ca0542b9d3e4eb72a12ff33a6d471a070c1d81eebd3c2", size = 1840375, upload-time = "2026-06-01T19:37:22.104Z" },
+ { url = "https://files.pythonhosted.org/packages/72/3c/a7ffe05a757a4a7867643da69357ec41f506879fbd1b231d2ed90af246b2/aiohttp-3.14.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:514db9a79337068981ee2137310283a07b4b885c584991097a91a4da419bcb81", size = 1921484, upload-time = "2026-06-01T19:37:24.068Z" },
+ { url = "https://files.pythonhosted.org/packages/93/fa/2c861170bbd4a491de93a69e081db1d971092569e0d593a98ef62c384dc1/aiohttp-3.14.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c452d17eeb95d563fc8b936f3050301dbd1d268126c4632d8b70ede9696202ee", size = 1774153, upload-time = "2026-06-01T19:37:26.256Z" },
+ { url = "https://files.pythonhosted.org/packages/9d/da/1d2f5a165f47ec9b1f69d37b8b977fdc4d501aa72ffb7930db27bb9e49ea/aiohttp-3.14.0-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ed94a81506e3d1bdbad5108f497a58f2a2354aedb4ca314d5326f07d1fd1ac2d", size = 1632569, upload-time = "2026-06-01T19:37:28.192Z" },
+ { url = "https://files.pythonhosted.org/packages/46/1d/7a6e295c4257252f70f69e90864fdad74b6a1293054fb3f9e65a15de6d63/aiohttp-3.14.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1394dce36e0f0d260ac0b555a654de19cb989f3c1b8bdd24f505314dfea18a00", size = 1740325, upload-time = "2026-06-01T19:37:30.08Z" },
+ { url = "https://files.pythonhosted.org/packages/f1/7e/e1899b1ca3ec62f1eab2a5cbde14039b97493f7f53eb88d9b668562ffa8d/aiohttp-3.14.0-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:d1467d1e7b48a73ca7237e0ee4335f3d02b923dbc27b82fd254bc301c97d4026", size = 1748691, upload-time = "2026-06-01T19:37:32.211Z" },
+ { url = "https://files.pythonhosted.org/packages/ec/54/4e6b61c1fe7d3433f82bcc6bd7e4d7c683a742a10c9b12a025fd3695c047/aiohttp-3.14.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:6a5f3532125233c261cf61f32df4059cfcf482eb793c7d3db8452e3142028b86", size = 1814477, upload-time = "2026-06-01T19:37:34.173Z" },
+ { url = "https://files.pythonhosted.org/packages/9c/38/86fd51be2e08d8e45c83d879d255f10391903cd9fe2a16512f7591a15873/aiohttp-3.14.0-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:3ea81eb518a2ecb319d8ec6d1424a37c773f6634bd87d6985eb606b2faac419f", size = 1623393, upload-time = "2026-06-01T19:37:36.281Z" },
+ { url = "https://files.pythonhosted.org/packages/78/49/466e947a42a88ee23c486d036e7e5d1b097f1bafd8084ad9c9a0a92f0f43/aiohttp-3.14.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:32e735c3182de7b64f6941a4ede48b38c7f47d9437bd615dd30b5bda8fa1bc93", size = 1824097, upload-time = "2026-06-01T19:37:38.421Z" },
+ { url = "https://files.pythonhosted.org/packages/f3/89/35f3410bc284682338a1be6b6ea0c5abfa05f063942cfaa9256608440434/aiohttp-3.14.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:c21ca9a1c63d4509158f478aeb9d02914dcc52adc68d1bc9dee2452284ee5996", size = 1764790, upload-time = "2026-06-01T19:37:40.755Z" },
+ { url = "https://files.pythonhosted.org/packages/42/80/2d4291bd5724d3d17e5951aff5a3e02281483fb47295f0788276ee66cd73/aiohttp-3.14.0-cp311-cp311-win32.whl", hash = "sha256:19ca5fc84130675ba11c6ca5c7da5cb65f7bf8a32cdd2b616bf49cd334688aae", size = 454176, upload-time = "2026-06-01T19:37:42.837Z" },
+ { url = "https://files.pythonhosted.org/packages/59/ed/41d0ad4f6ececffc32bdf1f7b494e5498f7ca5c849ea2e3cc9bbd1668251/aiohttp-3.14.0-cp311-cp311-win_amd64.whl", hash = "sha256:d488e6e9d3bb8ba5ae7066d5be885ae9670eba021b8c6ccb9a3a568e6b19d6e5", size = 479334, upload-time = "2026-06-01T19:37:44.776Z" },
+ { url = "https://files.pythonhosted.org/packages/d1/86/c0b5e305c770053f8c3d069bb52b8196917ba91949d1962d52eb307fb0d2/aiohttp-3.14.0-cp311-cp311-win_arm64.whl", hash = "sha256:8b93618102caf12801638a01a2b478a55410ddd71bd41cfaf6f707953a49ac43", size = 450262, upload-time = "2026-06-01T19:37:46.461Z" },
+ { url = "https://files.pythonhosted.org/packages/89/97/2b6889bfb6b6847520d50d95eb8c4307a45e28aaca39faf4a9454b3d1b2f/aiohttp-3.14.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:b29518c9c2ec7e373e68259206a137c7f4f5439c58baaec4b5ab3ab799850a4e", size = 750194, upload-time = "2026-06-01T19:37:48.164Z" },
+ { url = "https://files.pythonhosted.org/packages/21/e2/62634b7fff918ed98c3c6b2f0e70d520f7f28846cb412d451b04354c6459/aiohttp-3.14.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:dbec68ce61b64cb73cab4d33df9433427b1713c8bcccb181dce695c1b6f8e87c", size = 506966, upload-time = "2026-06-01T19:37:50.014Z" },
+ { url = "https://files.pythonhosted.org/packages/dd/fb/5ce075150828c797a5106f1c2fb26034e709d4289b9d2bf8b07f1e59fac6/aiohttp-3.14.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:3cdf534aa455593e589302990c5097aa5c92c06c4262a20da22934f9186a5fff", size = 507527, upload-time = "2026-06-01T19:37:51.96Z" },
+ { url = "https://files.pythonhosted.org/packages/01/d5/405a0ae4e6b081754a3609c1c97c63a950e000a2def16046f1e736933a0e/aiohttp-3.14.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cb6c657104393b5fbff01a5f59b2023db74058a8077d94475d6c25d03882a108", size = 1762420, upload-time = "2026-06-01T19:37:53.839Z" },
+ { url = "https://files.pythonhosted.org/packages/ae/1d/e05a7c896b15a6bc6fb8fc5319eb437861c2c49c34559ef928add6590315/aiohttp-3.14.0-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:46fbbec4e4fab7428d4396a3823f9320e4560aa3113b89eeebce712c27c9ed5a", size = 1733672, upload-time = "2026-06-01T19:37:55.791Z" },
+ { url = "https://files.pythonhosted.org/packages/cc/22/a72f7c459e195fa41bf4f7abd1f925b91fe91f8097e51c654229ba144a33/aiohttp-3.14.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2c2c7e05dd5335b298085abf45ddf98673934c3ee1c083d0b9ea13d4186ad500", size = 1805064, upload-time = "2026-06-01T19:37:57.931Z" },
+ { url = "https://files.pythonhosted.org/packages/80/50/e85bdaba0be59ca4838005ebfef4048fcdd5f35a02b07057a9a123394440/aiohttp-3.14.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3c7139100fbaae76515b73051d8f0aa3a3ff02e415eec8a8eee8e2223d9ba955", size = 1902125, upload-time = "2026-06-01T19:38:00.225Z" },
+ { url = "https://files.pythonhosted.org/packages/19/d8/51de5c6b971c27bb1ef620293b8d1ca611ec78736b34b3f6ccf68e4c8785/aiohttp-3.14.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:78d6f9286a629ce52728430afe18f8ed2b6c39a1fddb3802d7244b9983910ad2", size = 1783112, upload-time = "2026-06-01T19:38:02.641Z" },
+ { url = "https://files.pythonhosted.org/packages/73/ae/b4402bfde77e43dfb1b6ccff83c7b7ab63ed06b50c4754f0c5423fb374fe/aiohttp-3.14.0-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cc3c3e12cdaeb92d7dcf13db00e9f6b1956b910e47256e696df1cfa946d02159", size = 1586356, upload-time = "2026-06-01T19:38:04.637Z" },
+ { url = "https://files.pythonhosted.org/packages/bc/05/750a3265ca4dc54a460bd0cb1121a8f2ce9171fce4a135fb47ea7fd594d2/aiohttp-3.14.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:4d6a998191f5ebe3b8c28463ff72bc030250008b3193c402464efadd08b5ca02", size = 1723119, upload-time = "2026-06-01T19:38:06.713Z" },
+ { url = "https://files.pythonhosted.org/packages/37/01/8c0812c50b3b1b1c37b323bf170d6be8847a8f234060485b7d1e71953f60/aiohttp-3.14.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:0fc2b75ae8d169d853be2862d960be8550da6c5c65711d5476407eb3fdb006bd", size = 1757216, upload-time = "2026-06-01T19:38:08.736Z" },
+ { url = "https://files.pythonhosted.org/packages/47/2a/50fb98028a26887cbe48dcc1df92a90825615bc73b5584301304090cded8/aiohttp-3.14.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:16eee56bcc72d04600bc56c1759982c2385ec0b41d3fd3521f836bf64a0957ef", size = 1770500, upload-time = "2026-06-01T19:38:11.111Z" },
+ { url = "https://files.pythonhosted.org/packages/bd/32/0ffd598a2fa2b9a423daf242e700cfdabda35d6e602394ad9ae58972c1c7/aiohttp-3.14.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:5a2e7ca615c3ddc15b82687e05a624e5f5cba3f1d6c20cb81172d70ea498451e", size = 1576224, upload-time = "2026-06-01T19:38:13.391Z" },
+ { url = "https://files.pythonhosted.org/packages/0b/f9/b9fc381dd9b66afb33f2634c40e229d106467be0afcabe79648631ab6712/aiohttp-3.14.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:f0b7b8bbbec3ce9467ee0ebe334622fd90624f593edd3136c567811453fc4fae", size = 1794252, upload-time = "2026-06-01T19:38:15.498Z" },
+ { url = "https://files.pythonhosted.org/packages/a8/fb/05d9214c975f23225a8cd5c439325e338c7c377b315480ef3871db51f54e/aiohttp-3.14.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5ba10966d4f03dd96a14365be4b8e37c327c76f11c3ca867116966cdd9f98066", size = 1760193, upload-time = "2026-06-01T19:38:17.624Z" },
+ { url = "https://files.pythonhosted.org/packages/d9/4b/02992fc4fb9e1b6673ee3f888a8e587a6447afda1f6f4aca776c148c2876/aiohttp-3.14.0-cp312-cp312-win32.whl", hash = "sha256:101df7779c80c0636014a6b2c6642acd3efb5b355d48347c9d7dfb720aee9430", size = 448650, upload-time = "2026-06-01T19:38:19.545Z" },
+ { url = "https://files.pythonhosted.org/packages/39/e9/246532214c3abda518477cbaaf16d420295ad8effa5233844cbb38f299ab/aiohttp-3.14.0-cp312-cp312-win_amd64.whl", hash = "sha256:b0a5747586d4467efd1f932710b269131c9717a872dce082cd92a00c1c13123a", size = 476145, upload-time = "2026-06-01T19:38:21.505Z" },
+ { url = "https://files.pythonhosted.org/packages/2b/c3/63f8c20090048915711598b0adf475b149216d736157961de06480a45b15/aiohttp-3.14.0-cp312-cp312-win_arm64.whl", hash = "sha256:5f1c5be60add78fabb4aacd13c5a348ae79d2fcbfc7fa78da8f1eb192273b370", size = 444250, upload-time = "2026-06-01T19:38:24.027Z" },
]
[[package]]
@@ -1043,15 +1051,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/9e/43/53afb8ba17218f19b77c7834128566c5bbb100a0ad9ba2e8e89d089d7079/autopep8-2.3.2-py2.py3-none-any.whl", hash = "sha256:ce8ad498672c845a0c3de2629c15b635ec2b05ef8177a6e7c91c74f3e9b51128", size = 45807, upload-time = "2025-01-14T14:46:15.466Z" },
]
-[[package]]
-name = "awsipranges"
-version = "0.3.3"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/19/2e/6efa95f995369da828715f41705686cd214b9259ed758266942553d40441/awsipranges-0.3.3.tar.gz", hash = "sha256:4f0b3f22a9dc1163c85b513bed812b6c92bdacd674e6a7b68252a3c25b99e2c0", size = 16739, upload-time = "2022-02-10T21:08:32.825Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/83/ce/5c9a8bf91bdc9592a409c99e58fd99f2727ab8d634719c0ad796021b76d7/awsipranges-0.3.3-py3-none-any.whl", hash = "sha256:f3d7a54aeaf7fe310beb5d377a4034a63a51b72677ae6af3e0967bc4de7eedaf", size = 18106, upload-time = "2022-02-10T21:08:31.497Z" },
-]
-
[[package]]
name = "azure-cli-core"
version = "2.83.0"
@@ -1074,7 +1073,7 @@ dependencies = [
{ name = "pkginfo" },
{ name = "psutil", marker = "sys_platform != 'cygwin'" },
{ name = "py-deviceid" },
- { name = "pyjwt" },
+ { name = "pyjwt", extra = ["crypto"] },
{ name = "pyopenssl" },
{ name = "requests", extra = ["socks"] },
]
@@ -1409,6 +1408,20 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/3d/66/0d8ae9ca4d75e57746026a1f9a10a7e25029511c128cf20166fce516bda9/azure_mgmt_logic-10.0.0-py3-none-any.whl", hash = "sha256:525c78afedf3edb35eb0a16152c8beba89769ee1bc6af01bcdc42842a551e443", size = 235433, upload-time = "2022-06-13T01:38:27.333Z" },
]
+[[package]]
+name = "azure-mgmt-managementgroups"
+version = "1.1.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "azure-mgmt-core" },
+ { name = "isodate" },
+ { name = "typing-extensions" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/fd/73/ac5e064ed7343e1b3172f32f09be3efca906087218d3046b5038f2f394ed/azure_mgmt_managementgroups-1.1.0.tar.gz", hash = "sha256:e6199baf118890ba2bda35dda83a88861c0b1bbef126311b20ec12eed9681951", size = 60101, upload-time = "2026-02-13T03:45:45.439Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/92/bc/993158de03cc0a49f2cf8192615ffedbc508c417cb3522e88f6652b714cc/azure_mgmt_managementgroups-1.1.0-py3-none-any.whl", hash = "sha256:140934589559ef6afcac6f1d24f995588a1965aaa89d47851c1cc639fafb1942", size = 83586, upload-time = "2026-02-13T03:45:46.836Z" },
+]
+
[[package]]
name = "azure-mgmt-monitor"
version = "6.0.2"
@@ -1728,7 +1741,7 @@ wheels = [
[[package]]
name = "cartography"
-version = "0.135.0"
+version = "0.138.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "adal" },
@@ -1748,6 +1761,7 @@ dependencies = [
{ name = "azure-mgmt-eventhub" },
{ name = "azure-mgmt-keyvault" },
{ name = "azure-mgmt-logic" },
+ { name = "azure-mgmt-managementgroups" },
{ name = "azure-mgmt-monitor" },
{ name = "azure-mgmt-network" },
{ name = "azure-mgmt-resource" },
@@ -1756,6 +1770,7 @@ dependencies = [
{ name = "azure-mgmt-storage" },
{ name = "azure-mgmt-synapse" },
{ name = "azure-mgmt-web" },
+ { name = "azure-storage-blob" },
{ name = "azure-synapse-artifacts" },
{ name = "backoff" },
{ name = "boto3" },
@@ -1767,8 +1782,12 @@ dependencies = [
{ name = "duo-client" },
{ name = "google-api-python-client" },
{ name = "google-auth" },
+ { name = "google-cloud-aiplatform" },
+ { name = "google-cloud-artifact-registry" },
{ name = "google-cloud-asset" },
{ name = "google-cloud-resource-manager" },
+ { name = "google-cloud-run" },
+ { name = "google-cloud-storage" },
{ name = "httpx" },
{ name = "kubernetes" },
{ name = "marshmallow" },
@@ -1794,9 +1813,9 @@ dependencies = [
{ name = "workos" },
{ name = "xmltodict" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/39/47/606851d2403a983b63813b9e95427a5dd896e49bc5a501868c041262e9a5/cartography-0.135.0.tar.gz", hash = "sha256:3f500cd22c3b392d00e8b49f62acc95fd4dcd559ce514aafe2eb8101133c7a49", size = 9106458, upload-time = "2026-04-10T16:25:34.898Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/51/cd/0eb6a5a3c89cc179801d902ade9719af1a583c516c00f50d72b8207db1eb/cartography-0.138.1.tar.gz", hash = "sha256:356e946a0bcac899cba293d57803c71bd35fdeabe623f5f67d9405d7a643af9f", size = 9756966, upload-time = "2026-06-19T22:11:32.411Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/b1/e1/99a26b3e662202be77961aba73338e1448623490710b81783e53a4bbef15/cartography-0.135.0-py3-none-any.whl", hash = "sha256:c62c32a6917b8f23a8b98fe2b6c7c4a918b50f55918482966c4dae1cf5f538e1", size = 1590545, upload-time = "2026-04-10T16:25:37.669Z" },
+ { url = "https://files.pythonhosted.org/packages/a8/15/4447ec968825b2a19cba26ecb74964208aa3f941d9181a7782572e30b43d/cartography-0.138.1-py3-none-any.whl", hash = "sha256:88ec0898ea1a1b3f4653be9a3e7e61144f5cee20384b9040e92039617d39f029", size = 2014725, upload-time = "2026-06-19T22:11:29.886Z" },
]
[[package]]
@@ -2226,16 +2245,15 @@ wheels = [
]
[[package]]
-name = "detect-secrets"
-version = "1.5.0"
+name = "deprecated"
+version = "1.3.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
- { name = "pyyaml" },
- { name = "requests" },
+ { name = "wrapt" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/69/67/382a863fff94eae5a0cf05542179169a1c49a4c8784a9480621e2066ca7d/detect_secrets-1.5.0.tar.gz", hash = "sha256:6bb46dcc553c10df51475641bb30fd69d25645cc12339e46c824c1e0c388898a", size = 97351, upload-time = "2024-05-06T17:46:19.721Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/49/85/12f0a49a7c4ffb70572b6c2ef13c90c88fd190debda93b23f026b25f9634/deprecated-1.3.1.tar.gz", hash = "sha256:b1b50e0ff0c1fddaa5708a2c6b0a6588bb09b892825ab2b214ac9ea9d92a5223", size = 2932523, upload-time = "2025-10-30T08:19:02.757Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/4e/5e/4f5fe4b89fde1dc3ed0eb51bd4ce4c0bca406246673d370ea2ad0c58d747/detect_secrets-1.5.0-py3-none-any.whl", hash = "sha256:e24e7b9b5a35048c313e983f76c4bd09dad89f045ff059e354f9943bf45aa060", size = 120341, upload-time = "2024-05-06T17:46:16.628Z" },
+ { url = "https://files.pythonhosted.org/packages/84/d0/205d54408c08b13550c733c4b85429e7ead111c7f0014309637425520a9a/deprecated-1.3.1-py2.py3-none-any.whl", hash = "sha256:597bfef186b6f60181535a29fbe44865ce137a5079f295b479886c82729d5f3f", size = 11298, upload-time = "2025-10-30T08:19:00.758Z" },
]
[[package]]
@@ -2360,6 +2378,19 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/c4/f1/468b49cccba3b42dda571063a14c668bb0b53a1d5712426d18e36663bd53/django_environ-0.11.2-py2.py3-none-any.whl", hash = "sha256:0ff95ab4344bfeff693836aa978e6840abef2e2f1145adff7735892711590c05", size = 19141, upload-time = "2023-09-01T21:02:59.88Z" },
]
+[[package]]
+name = "django-eventstream"
+version = "5.3.3"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "django" },
+ { name = "django-grip" },
+ { name = "gripcontrol" },
+ { name = "pyjwt", extra = ["crypto"] },
+ { name = "six" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/f2/49/ec6cbc24f3f30465370df7096cfea9722bad2b0c1f35a7ff5d45fb96cff6/django_eventstream-5.3.3.tar.gz", hash = "sha256:6880b03298eebf18c1b736b972fb862eaf631dfbb79f8b27496418a3495d08dc", size = 47622, upload-time = "2025-10-23T00:22:40.291Z" }
+
[[package]]
name = "django-filter"
version = "24.3"
@@ -2372,6 +2403,19 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/09/b1/92f1c30b47c1ebf510c35a2ccad9448f73437e5891bbd2b4febe357cc3de/django_filter-24.3-py3-none-any.whl", hash = "sha256:c4852822928ce17fb699bcfccd644b3574f1a2d80aeb2b4ff4f16b02dd49dc64", size = 95011, upload-time = "2024-08-02T13:27:55.616Z" },
]
+[[package]]
+name = "django-grip"
+version = "3.5.2"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "django" },
+ { name = "gripcontrol" },
+ { name = "pubcontrol" },
+ { name = "six" },
+ { name = "werkzeug" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/cb/d0/2c7b04fa864073cd8cb324f8674672162282d97540d56732cbd3a9ae5bca/django-grip-3.5.2.tar.gz", hash = "sha256:1ee1601492cd110256bd03e4a68797a9fbefa27c15f5a838bf245df97db0450c", size = 7626, upload-time = "2025-03-24T18:53:58.677Z" }
+
[[package]]
name = "django-guid"
version = "3.5.0"
@@ -2457,7 +2501,7 @@ source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "django" },
{ name = "djangorestframework" },
- { name = "pyjwt" },
+ { name = "pyjwt", extra = ["crypto"] },
]
sdist = { url = "https://files.pythonhosted.org/packages/a8/27/2874a325c11112066139769f7794afae238a07ce6adf96259f08fd37a9d7/djangorestframework_simplejwt-5.5.1.tar.gz", hash = "sha256:e72c5572f51d7803021288e2057afcbd03f17fe11d484096f40a460abc76e87f", size = 101265, upload-time = "2025-07-21T16:52:25.026Z" }
wheels = [
@@ -2487,6 +2531,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/e3/26/57c6fb270950d476074c087527a558ccb6f4436657314bfb6cdf484114c4/docker-7.1.0-py3-none-any.whl", hash = "sha256:c96b93b7f0a746f9e77d325bcfb87422a3d8bd4f03136ae8a85b37f1898d5fc0", size = 147774, upload-time = "2024-05-23T11:13:55.01Z" },
]
+[[package]]
+name = "docstring-parser"
+version = "0.18.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/e0/4d/f332313098c1de1b2d2ff91cf2674415cc7cddab2ca1b01ae29774bd5fdf/docstring_parser-0.18.0.tar.gz", hash = "sha256:292510982205c12b1248696f44959db3cdd1740237a968ea1e2e7a900eeb2015", size = 29341, upload-time = "2026-04-14T04:09:19.867Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/a7/5f/ed01f9a3cdffbd5a008556fc7b2a08ddb1cc6ace7effa7340604b1d16699/docstring_parser-0.18.0-py3-none-any.whl", hash = "sha256:b3fcbed555c47d8479be0796ef7e19c2670d428d72e96da63f3a40122860374b", size = 22484, upload-time = "2026-04-14T04:09:18.638Z" },
+]
+
[[package]]
name = "dogpile-cache"
version = "1.5.0"
@@ -2576,24 +2629,27 @@ wheels = [
[[package]]
name = "dulwich"
-version = "0.23.0"
+version = "1.2.5"
source = { registry = "https://pypi.org/simple" }
dependencies = [
+ { name = "typing-extensions", marker = "python_full_version < '3.12'" },
{ name = "urllib3" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/4b/ac/ba58cf420640c7bc77ae8e1b31e174d83c9117750c63cf9ea3b5e202e5c4/dulwich-0.23.0.tar.gz", hash = "sha256:0aa6c2489dd5e978b27e9b75983b7331a66c999f0efc54ebe37cab808ed322ae", size = 575116, upload-time = "2025-06-21T17:56:47.494Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/7f/85/ceb8ecff5cdeee4ceeebb86b599476dee559041dacc6c2c50cc0d4711549/dulwich-1.2.5.tar.gz", hash = "sha256:0395b2c8924c3424bafe2d9c1edd5348cc4b21ce9c1d6655bf01f9a5c47164c8", size = 1253230, upload-time = "2026-05-28T22:27:55.17Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/ae/11/f6bbba8583f69cf19ef4bd7f5fde1a6b5ccaf8b6951781cec8db247116f4/dulwich-0.23.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d68498fdda13ab00791b483daab3bcfe9f9721c037aa458695e6ad81640c57cc", size = 972658, upload-time = "2025-06-21T17:56:13.505Z" },
- { url = "https://files.pythonhosted.org/packages/2b/9d/2720e0ab58666378a33c752a61543f936cd6b06dfe5d84a2215ddc0914b0/dulwich-0.23.0-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:cb7bb930b12471a1cfcea4b3d25a671dc0ad32573f0ad25684684298959a1527", size = 1049813, upload-time = "2025-06-21T17:56:14.884Z" },
- { url = "https://files.pythonhosted.org/packages/e5/f3/81d8075141dfcc0a0449c2093596e58d3e11444e3af54e819eca63b84dd0/dulwich-0.23.0-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:a2abbce32fd2bc7902bcc5f69b10bf22576810de21651baaa864b78fd7aec261", size = 1051639, upload-time = "2025-06-21T17:56:16.437Z" },
- { url = "https://files.pythonhosted.org/packages/4f/0d/c06ccb227b096aef5906142fe78b5c79f9070a0ea6152fc219941186d540/dulwich-0.23.0-cp311-cp311-win32.whl", hash = "sha256:9e3151f10ce2a9ff91bca64c74345217f53bdd947dc958032343822009832f7a", size = 642918, upload-time = "2025-06-21T17:56:18.373Z" },
- { url = "https://files.pythonhosted.org/packages/d7/1c/1e99aa34c9aead9e641b2d9934f0a3d00257f75027cf5cdecc8a1a6c18ae/dulwich-0.23.0-cp311-cp311-win_amd64.whl", hash = "sha256:3ae9f1d9dc92d4e9a3f89ba2c55221f7b6442c5dd93b3f6f539a3c9eb3f37bdd", size = 659010, upload-time = "2025-06-21T17:56:19.947Z" },
- { url = "https://files.pythonhosted.org/packages/4a/d7/1e6fba0235babe912e8467b036062e37d11672cbbeb0d8074f9d4559057b/dulwich-0.23.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:52cdef66a7994d29528ca79ca59452518bbba3fd56a9c61c61f6c467c1c7956e", size = 960292, upload-time = "2025-06-21T17:56:21.308Z" },
- { url = "https://files.pythonhosted.org/packages/4b/6a/23f0c487ec03f2752600cab4a8e0dedb38186246c475bf3fa90a8db830d5/dulwich-0.23.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:d473888a6ab9ed5d4a4c3f053cbe5b77f72d54b6efdf5688fed76094316e571e", size = 1047892, upload-time = "2025-06-21T17:56:22.989Z" },
- { url = "https://files.pythonhosted.org/packages/c7/e2/8f3d216be5fd0ee1180d917b59b34b54b9896384cf139f319b5d3a8f16b4/dulwich-0.23.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:19fcf20224c641a61c774da92f098fbaae9938c7e17a52841e64092adf7e78f9", size = 1048699, upload-time = "2025-06-21T17:56:24.602Z" },
- { url = "https://files.pythonhosted.org/packages/8f/c4/18e6223cd4ad1ae9334eb4e6aa5952fd8f5c3d75762918eb90c209fec4ba/dulwich-0.23.0-cp312-cp312-win32.whl", hash = "sha256:7fc8b76b704ef35cd001e993e3aa4e1d666a2064bf467c07c560f12b2959dcaf", size = 641268, upload-time = "2025-06-21T17:56:26.18Z" },
- { url = "https://files.pythonhosted.org/packages/b8/9c/65bfbbac62d8a2967e13f6a1512371c5eb6b906a61fb6dead992669cad0e/dulwich-0.23.0-cp312-cp312-win_amd64.whl", hash = "sha256:cb0566b888b578325350b4d67c61a0de35d417e9877560e3a6df88cae4576a59", size = 657837, upload-time = "2025-06-21T17:56:27.821Z" },
- { url = "https://files.pythonhosted.org/packages/35/31/49318ee9db4b402e6d8b9b01bd4cae9298f59e1bb9bd56cf4a94e48fa069/dulwich-0.23.0-py3-none-any.whl", hash = "sha256:d8da6694ca332bb48775e35ee2215aa4673821164a91b83062f699c69f7cd135", size = 313776, upload-time = "2025-06-21T17:56:46.221Z" },
+ { url = "https://files.pythonhosted.org/packages/4a/4a/654ae1671610fdf6b65a64586ad67ddd8550d4d08a632b2a4b9614754b6d/dulwich-1.2.5-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:556593fd11637f80f6018bee1916b1a84f5b420423b470ebb3f1a782ad6ef081", size = 1399277, upload-time = "2026-05-28T22:27:00.801Z" },
+ { url = "https://files.pythonhosted.org/packages/85/d8/06ee3bc8eded4bd7adf8adf0c9ea5f19bf96f7e5e626bfaf7311cde4208a/dulwich-1.2.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a70477c991e96cfe8fdd7c866e7251faf71b38bfeb51d6f27554c9cce1caabf3", size = 1382310, upload-time = "2026-05-28T22:27:02.216Z" },
+ { url = "https://files.pythonhosted.org/packages/07/17/a03adf50b9095f9f5d863393f21d585dea39bdc4fdf60788ff3a9407a512/dulwich-1.2.5-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:9008ef25cabd379cda4fa86000fc38ca14b72afe17db798a8c85c0b2b7ce4d1e", size = 1470993, upload-time = "2026-05-28T22:27:04.075Z" },
+ { url = "https://files.pythonhosted.org/packages/60/58/1dc352d2a5e80befe4338af7208febb44bcfd7496b0dde5ac6dacb07b031/dulwich-1.2.5-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:a5549f4afc973e0a15ea6b0244d57f848d3f3ee13dac557eb311024aebebf128", size = 1497820, upload-time = "2026-05-28T22:27:05.549Z" },
+ { url = "https://files.pythonhosted.org/packages/c1/a8/e058959a87e7df7753b112ef66a43ccbc57338c1bbdc23a0edf3833396df/dulwich-1.2.5-cp311-cp311-win32.whl", hash = "sha256:5108acead814d1de8b6262d6d8fb90af7e82f5a4d83788b6b48e39d01800a92f", size = 1066549, upload-time = "2026-05-28T22:27:06.832Z" },
+ { url = "https://files.pythonhosted.org/packages/33/91/ff0b444f686718635348986bd73dfce42e947912417893de35de399b878b/dulwich-1.2.5-cp311-cp311-win_amd64.whl", hash = "sha256:5e067b7feceb7034bc99e7c7143a704f1d97d4be7027d9a0aa5a83c0657ff091", size = 1079481, upload-time = "2026-05-28T22:27:08.33Z" },
+ { url = "https://files.pythonhosted.org/packages/19/22/4f75770bbe5521cac61c4820ef46d4fbf8c2175d3519ba3d0378d4ba798e/dulwich-1.2.5-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:701a9ecf7a8a44f5e2459e46befa93530cf36a8b1ae3140aefc007db1d7d0207", size = 1396522, upload-time = "2026-05-28T22:27:09.997Z" },
+ { url = "https://files.pythonhosted.org/packages/e5/b1/c07c347681c0cf6acd4b189bf6e8d6207c71a1347b7a1e865eb40faa46b9/dulwich-1.2.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2f90d68bfa97c4ca71de7507984365aefe27b6d248cb28dc99644d0f3ae8c60b", size = 1334826, upload-time = "2026-05-28T22:27:11.582Z" },
+ { url = "https://files.pythonhosted.org/packages/13/80/6818eb7ce492e18ab2efa92ab901d173b4b0b159e5681c1424f329600c40/dulwich-1.2.5-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:00b54a1d56ddbacdd8eadd6d4787a51b3a05fefa30eadbf9165fd283a00b90ed", size = 1416616, upload-time = "2026-05-28T22:27:13.195Z" },
+ { url = "https://files.pythonhosted.org/packages/14/a7/9790e60d19870f6554f7583722bb324c1355784316f20aeda1c0b5b1491a/dulwich-1.2.5-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:d8f7ea8f47e38e5b0de3fab97e07e9c9161ffddc90b3964512cab2b7749df4e6", size = 1441354, upload-time = "2026-05-28T22:27:14.683Z" },
+ { url = "https://files.pythonhosted.org/packages/91/44/0ea8a69c24aa1254ff5996d682eae2eab287d471b937dcdb26d9ea9720b4/dulwich-1.2.5-cp312-cp312-win32.whl", hash = "sha256:8929134acf4ff967203df7600b38535f9b5b590462067a7e30dbce01acb97af9", size = 1017058, upload-time = "2026-05-28T22:27:16.121Z" },
+ { url = "https://files.pythonhosted.org/packages/bd/9c/2fcddda7faec3bae52db7c64bfcb5dc756f597f33fae90e8d4e4b4d3b39b/dulwich-1.2.5-cp312-cp312-win_amd64.whl", hash = "sha256:9693d2c9e226b2ea855c1dc3a87e2f4d972f7523fc0f7924e5997e9f4c23d97f", size = 1031731, upload-time = "2026-05-28T22:27:17.633Z" },
+ { url = "https://files.pythonhosted.org/packages/07/4b/4a18a59ad230581cd0ef460e96001f90762e566dc2dfdba22aa358eb5a0e/dulwich-1.2.5-py3-none-any.whl", hash = "sha256:1679b376433a0fc7f36586afda1d4ed7427afa7a79d4bf17e5014474eea69fa4", size = 686745, upload-time = "2026-05-28T22:27:53.695Z" },
]
[[package]]
@@ -2824,6 +2880,11 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/83/1d/d6466de3a5249d35e832a52834115ca9d1d0de6abc22065f049707516d47/google_auth-2.48.0-py3-none-any.whl", hash = "sha256:2e2a537873d449434252a9632c28bfc268b0adb1e53f9fb62afc5333a975903f", size = 236499, upload-time = "2026-01-26T19:22:45.099Z" },
]
+[package.optional-dependencies]
+requests = [
+ { name = "requests" },
+]
+
[[package]]
name = "google-auth-httplib2"
version = "0.2.0"
@@ -2850,6 +2911,46 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/ca/94/24b010493660dd55e2d9769ae7ef44164aebd7e1f4a9266cf9459affd687/google_cloud_access_context_manager-0.3.0-py3-none-any.whl", hash = "sha256:5d15ad51547f06c281e35f16b4ffcb3e98bb2d898b01470f88b94edfb2eeb0a3", size = 58852, upload-time = "2025-10-17T02:30:33.768Z" },
]
+[[package]]
+name = "google-cloud-aiplatform"
+version = "1.153.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "docstring-parser" },
+ { name = "google-api-core", extra = ["grpc"] },
+ { name = "google-auth" },
+ { name = "google-cloud-bigquery" },
+ { name = "google-cloud-resource-manager" },
+ { name = "google-cloud-storage" },
+ { name = "google-genai" },
+ { name = "packaging" },
+ { name = "proto-plus" },
+ { name = "protobuf" },
+ { name = "pydantic" },
+ { name = "typing-extensions" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/d5/97/1779e66ab845550bc602364311ea093ba156cb805a1c31b7c4d6f25b5863/google_cloud_aiplatform-1.153.1.tar.gz", hash = "sha256:445b6c683d5c630f174d81ae1f69f7da9e27e4d4ec5b70c5fe96de5c1247cfbc", size = 11011349, upload-time = "2026-05-15T06:34:14.851Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/16/01/8a1900e7a742ed480e6037ac4f6541466cb981d81bd4cbd34a9d46204ea1/google_cloud_aiplatform-1.153.1-py2.py3-none-any.whl", hash = "sha256:033fa1595a7e8ed1d97066e261e630f38fbc60e10c98c6487cf228fe9c7ec151", size = 9170782, upload-time = "2026-05-15T06:34:10.887Z" },
+]
+
+[[package]]
+name = "google-cloud-artifact-registry"
+version = "1.21.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "google-api-core", extra = ["grpc"] },
+ { name = "google-auth" },
+ { name = "grpc-google-iam-v1" },
+ { name = "grpcio" },
+ { name = "proto-plus" },
+ { name = "protobuf" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/13/2b/24e6956789bc1244efb18143aa4f124e03d870228e5bfd065c04d38a4d6b/google_cloud_artifact_registry-1.21.0.tar.gz", hash = "sha256:546e51eb5d463a6e5c668be6727d14f8ec82bc798031398006b2213d703e184c", size = 315219, upload-time = "2026-03-30T22:50:38.875Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/e1/8c/a5c68031728f38d3306bad5ac10c0ca670cbdf414db308ddefa2c47f2b34/google_cloud_artifact_registry-1.21.0-py3-none-any.whl", hash = "sha256:a07079035438fd0f2e7264d4318b388650495f011db575405c18c9881449025c", size = 250544, upload-time = "2026-03-30T22:48:49.345Z" },
+]
+
[[package]]
name = "google-cloud-asset"
version = "4.2.0"
@@ -2870,6 +2971,37 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/05/88/9a43fae1d2fed94d7f5f46b6f4c44bd15e5ea0e8657632108b5ec5f53d9d/google_cloud_asset-4.2.0-py3-none-any.whl", hash = "sha256:fd7ea04c64948a4779790343204cd5b41d4772d6ab1d05a9125e28a637ac0862", size = 282707, upload-time = "2026-01-09T14:53:03.081Z" },
]
+[[package]]
+name = "google-cloud-bigquery"
+version = "3.41.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "google-api-core", extra = ["grpc"] },
+ { name = "google-auth" },
+ { name = "google-cloud-core" },
+ { name = "google-resumable-media" },
+ { name = "packaging" },
+ { name = "python-dateutil" },
+ { name = "requests" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/ce/13/6515c7aab55a4a0cf708ffd309fb9af5bab54c13e32dc22c5acd6497193c/google_cloud_bigquery-3.41.0.tar.gz", hash = "sha256:2217e488b47ed576360c9b2cc07d59d883a54b83167c0ef37f915c26b01a06fe", size = 513434, upload-time = "2026-03-30T22:50:55.347Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/40/33/1d3902efadef9194566d499d61507e1f038454e0b55499d2d7f8ab2a4fee/google_cloud_bigquery-3.41.0-py3-none-any.whl", hash = "sha256:2a5b5a737b401cbd824a6e5eac7554100b878668d908e6548836b5d8aaa4dcaa", size = 262343, upload-time = "2026-03-30T22:48:45.444Z" },
+]
+
+[[package]]
+name = "google-cloud-core"
+version = "2.6.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "google-api-core" },
+ { name = "google-auth" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/a8/dd/1eef226e470369b26824a505c34482c0b493bc35fe8e0c6b003b5feca21a/google_cloud_core-2.6.0.tar.gz", hash = "sha256:e76149739f90fac1fc6757c09f47eaccb3145b54adbd7759b0f7c4b235f46c83", size = 36001, upload-time = "2026-05-07T08:04:04.124Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/84/4a/98da8930ab109c73d9a5d13782a9ebb81ea8c111f6d534a567b71d23e52b/google_cloud_core-2.6.0-py3-none-any.whl", hash = "sha256:6d63ac8e5eca6d9e4319d0a1e2265fadcd7f1049904378caecfa01cf52dd869e", size = 29390, upload-time = "2026-05-07T08:02:34.672Z" },
+]
+
[[package]]
name = "google-cloud-org-policy"
version = "1.16.0"
@@ -2919,6 +3051,93 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/94/ff/4b28bcc791d9d7e4ac8fea00fbd90ccb236afda56746a3b4564d2ae45df3/google_cloud_resource_manager-1.16.0-py3-none-any.whl", hash = "sha256:fb9a2ad2b5053c508e1c407ac31abfd1a22e91c32876c1892830724195819a28", size = 400218, upload-time = "2026-01-15T13:02:47.378Z" },
]
+[[package]]
+name = "google-cloud-run"
+version = "0.16.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "google-api-core", extra = ["grpc"] },
+ { name = "google-auth" },
+ { name = "grpc-google-iam-v1" },
+ { name = "grpcio" },
+ { name = "proto-plus" },
+ { name = "protobuf" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/b7/89/dcaf0dc97e39b41e446456ceb60657ab025de79cfccd39cbd739d1a9849e/google_cloud_run-0.16.0.tar.gz", hash = "sha256:d52cf4e6ad3702ae48caccf6abcab543afee6f61c2a6ec753cc62a31e5b629f1", size = 514452, upload-time = "2026-03-26T22:17:05.589Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/fa/c7/46153dc13713b5e4276d86f28ff4563332f9e4bae5ebc83abc5bfd994801/google_cloud_run-0.16.0-py3-none-any.whl", hash = "sha256:d7d2dd7307130fde2a0ce27e96d580dd23b7b2d973b6484b94d902e6b2618860", size = 459112, upload-time = "2026-03-26T22:16:00.018Z" },
+]
+
+[[package]]
+name = "google-cloud-storage"
+version = "3.10.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "google-api-core" },
+ { name = "google-auth" },
+ { name = "google-cloud-core" },
+ { name = "google-crc32c" },
+ { name = "google-resumable-media" },
+ { name = "requests" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/4c/47/205eb8e9a1739b5345843e5a425775cbdc472cc38e7eda082ba5b8d02450/google_cloud_storage-3.10.1.tar.gz", hash = "sha256:97db9aa4460727982040edd2bd13ff3d5e2260b5331ad22895802da1fc2a5286", size = 17309950, upload-time = "2026-03-23T09:35:23.409Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/ad/ff/ca9ab2417fa913d75aae38bf40bf856bb2749a604b2e0f701b37cfcd23cc/google_cloud_storage-3.10.1-py3-none-any.whl", hash = "sha256:a72f656759b7b99bda700f901adcb3425a828d4a29f911bc26b3ea79c5b1217f", size = 324453, upload-time = "2026-03-23T09:35:21.368Z" },
+]
+
+[[package]]
+name = "google-crc32c"
+version = "1.8.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/03/41/4b9c02f99e4c5fb477122cd5437403b552873f014616ac1d19ac8221a58d/google_crc32c-1.8.0.tar.gz", hash = "sha256:a428e25fb7691024de47fecfbff7ff957214da51eddded0da0ae0e0f03a2cf79", size = 14192, upload-time = "2025-12-16T00:35:25.142Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/5d/ef/21ccfaab3d5078d41efe8612e0ed0bfc9ce22475de074162a91a25f7980d/google_crc32c-1.8.0-cp311-cp311-macosx_12_0_arm64.whl", hash = "sha256:014a7e68d623e9a4222d663931febc3033c5c7c9730785727de2a81f87d5bab8", size = 31298, upload-time = "2025-12-16T00:20:32.241Z" },
+ { url = "https://files.pythonhosted.org/packages/c5/b8/f8413d3f4b676136e965e764ceedec904fe38ae8de0cdc52a12d8eb1096e/google_crc32c-1.8.0-cp311-cp311-macosx_12_0_x86_64.whl", hash = "sha256:86cfc00fe45a0ac7359e5214a1704e51a99e757d0272554874f419f79838c5f7", size = 30872, upload-time = "2025-12-16T00:33:58.785Z" },
+ { url = "https://files.pythonhosted.org/packages/f6/fd/33aa4ec62b290477181c55bb1c9302c9698c58c0ce9a6ab4874abc8b0d60/google_crc32c-1.8.0-cp311-cp311-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:19b40d637a54cb71e0829179f6cb41835f0fbd9e8eb60552152a8b52c36cbe15", size = 33243, upload-time = "2025-12-16T00:40:21.46Z" },
+ { url = "https://files.pythonhosted.org/packages/71/03/4820b3bd99c9653d1a5210cb32f9ba4da9681619b4d35b6a052432df4773/google_crc32c-1.8.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:17446feb05abddc187e5441a45971b8394ea4c1b6efd88ab0af393fd9e0a156a", size = 33608, upload-time = "2025-12-16T00:40:22.204Z" },
+ { url = "https://files.pythonhosted.org/packages/7c/43/acf61476a11437bf9733fb2f70599b1ced11ec7ed9ea760fdd9a77d0c619/google_crc32c-1.8.0-cp311-cp311-win_amd64.whl", hash = "sha256:71734788a88f551fbd6a97be9668a0020698e07b2bf5b3aa26a36c10cdfb27b2", size = 34439, upload-time = "2025-12-16T00:35:20.458Z" },
+ { url = "https://files.pythonhosted.org/packages/e9/5f/7307325b1198b59324c0fa9807cafb551afb65e831699f2ce211ad5c8240/google_crc32c-1.8.0-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:4b8286b659c1335172e39563ab0a768b8015e88e08329fa5321f774275fc3113", size = 31300, upload-time = "2025-12-16T00:21:56.723Z" },
+ { url = "https://files.pythonhosted.org/packages/21/8e/58c0d5d86e2220e6a37befe7e6a94dd2f6006044b1a33edf1ff6d9f7e319/google_crc32c-1.8.0-cp312-cp312-macosx_12_0_x86_64.whl", hash = "sha256:2a3dc3318507de089c5384cc74d54318401410f82aa65b2d9cdde9d297aca7cb", size = 30867, upload-time = "2025-12-16T00:38:31.302Z" },
+ { url = "https://files.pythonhosted.org/packages/ce/a9/a780cc66f86335a6019f557a8aaca8fbb970728f0efd2430d15ff1beae0e/google_crc32c-1.8.0-cp312-cp312-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:14f87e04d613dfa218d6135e81b78272c3b904e2a7053b841481b38a7d901411", size = 33364, upload-time = "2025-12-16T00:40:22.96Z" },
+ { url = "https://files.pythonhosted.org/packages/21/3f/3457ea803db0198c9aaca2dd373750972ce28a26f00544b6b85088811939/google_crc32c-1.8.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:cb5c869c2923d56cb0c8e6bcdd73c009c36ae39b652dbe46a05eb4ef0ad01454", size = 33740, upload-time = "2025-12-16T00:40:23.96Z" },
+ { url = "https://files.pythonhosted.org/packages/df/c0/87c2073e0c72515bb8733d4eef7b21548e8d189f094b5dad20b0ecaf64f6/google_crc32c-1.8.0-cp312-cp312-win_amd64.whl", hash = "sha256:3cc0c8912038065eafa603b238abf252e204accab2a704c63b9e14837a854962", size = 34437, upload-time = "2025-12-16T00:35:21.395Z" },
+ { url = "https://files.pythonhosted.org/packages/52/c5/c171e4d8c44fec1422d801a6d2e5d7ddabd733eeda505c79730ee9607f07/google_crc32c-1.8.0-pp311-pypy311_pp73-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:87fa445064e7db928226b2e6f0d5304ab4cd0339e664a4e9a25029f384d9bb93", size = 28615, upload-time = "2025-12-16T00:40:29.298Z" },
+ { url = "https://files.pythonhosted.org/packages/9c/97/7d75fe37a7a6ed171a2cf17117177e7aab7e6e0d115858741b41e9dd4254/google_crc32c-1.8.0-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f639065ea2042d5c034bf258a9f085eaa7af0cd250667c0635a3118e8f92c69c", size = 28800, upload-time = "2025-12-16T00:40:30.322Z" },
+]
+
+[[package]]
+name = "google-genai"
+version = "1.68.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "anyio" },
+ { name = "distro" },
+ { name = "google-auth", extra = ["requests"] },
+ { name = "httpx" },
+ { name = "pydantic" },
+ { name = "requests" },
+ { name = "sniffio" },
+ { name = "tenacity" },
+ { name = "typing-extensions" },
+ { name = "websockets" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/9c/2c/f059982dbcb658cc535c81bbcbe7e2c040d675f4b563b03cdb01018a4bc3/google_genai-1.68.0.tar.gz", hash = "sha256:ac30c0b8bc630f9372993a97e4a11dae0e36f2e10d7c55eacdca95a9fa14ca96", size = 511285, upload-time = "2026-03-18T01:03:18.243Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/84/de/7d3ee9c94b74c3578ea4f88d45e8de9405902f857932334d81e89bce3dfa/google_genai-1.68.0-py3-none-any.whl", hash = "sha256:a1bc9919c0e2ea2907d1e319b65471d3d6d58c54822039a249fe1323e4178d15", size = 750912, upload-time = "2026-03-18T01:03:15.983Z" },
+]
+
+[[package]]
+name = "google-resumable-media"
+version = "2.9.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "google-crc32c" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/00/4b/0b235beccc310d0a48adbc7246b719d173cca6c88c572dfa4b090e39143c/google_resumable_media-2.9.0.tar.gz", hash = "sha256:f7cfb224846a9dd444d125115dfbe8ef02a2b893e78f087762fe716a255a734b", size = 2164534, upload-time = "2026-05-07T08:04:44.236Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/07/73/3518e63deb1667c5409a4579e28daf5e84479a87a72c547e0487f7883dcd/google_resumable_media-2.9.0-py3-none-any.whl", hash = "sha256:c8901e88e389af8bed64d9696c74d8bad961865eb2236e13e0bfca9bb0a65ca3", size = 81507, upload-time = "2026-05-07T08:03:23.809Z" },
+]
+
[[package]]
name = "googleapis-common-protos"
version = "1.72.0"
@@ -2980,6 +3199,17 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/c8/ab/717c58343cf02c5265b531384b248787e04d8160b8afe53d9eec053d7b44/greenlet-3.3.1-cp312-cp312-win_arm64.whl", hash = "sha256:bfb2d1763d777de5ee495c85309460f6fd8146e50ec9d0ae0183dbf6f0a829d1", size = 226403, upload-time = "2026-01-23T15:31:39.372Z" },
]
+[[package]]
+name = "gripcontrol"
+version = "4.4.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "pubcontrol" },
+ { name = "pyjwt", extra = ["crypto"] },
+ { name = "six" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/4f/51/1cbf88384fbe97a1454fb0adddcdca8cb90ceb99c3250274c334db844f4f/gripcontrol-4.4.0.tar.gz", hash = "sha256:44ee6fe244a02870aa4e5bc810138ccaf5070dce5eb149b8ee9e27b960a95c2d", size = 12526, upload-time = "2026-05-14T21:19:28.49Z" }
+
[[package]]
name = "grpc-google-iam-v1"
version = "0.14.3"
@@ -3041,14 +3271,14 @@ wheels = [
[[package]]
name = "gunicorn"
-version = "23.0.0"
+version = "26.0.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "packaging" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/34/72/9614c465dc206155d93eff0ca20d42e1e35afc533971379482de953521a4/gunicorn-23.0.0.tar.gz", hash = "sha256:f014447a0101dc57e294f6c18ca6b40227a4c90e9bdb586042628030cba004ec", size = 375031, upload-time = "2024-08-10T20:25:27.378Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/6d/b7/a4a3f632f823e432ce6bc65f62961b7980c898c77f075a2f7118cb3846fe/gunicorn-26.0.0.tar.gz", hash = "sha256:ca9346f85e3a4aeeb64d491045c16b9a35647abd37ea15efe53080eb8b090baf", size = 727286, upload-time = "2026-05-05T06:38:25.529Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/cb/7d/6dac2a6e1eba33ee43f318edbed4ff29151a49b5d37f080aad1e6469bca4/gunicorn-23.0.0-py3-none-any.whl", hash = "sha256:ec400d38950de4dfd418cff8328b2c8faed0edb0d517d3394e457c317908ca4d", size = 85029, upload-time = "2024-08-10T20:25:24.996Z" },
+ { url = "https://files.pythonhosted.org/packages/e6/40/9c2384fc2be4ad25dd4a49decd5ad9ea5a3639814c11bd40ab77cb9f0a14/gunicorn-26.0.0-py3-none-any.whl", hash = "sha256:40233d26a5f0d1872916188c276e21641155111c2853f0c2cd55260aec0d24fc", size = 212009, upload-time = "2026-05-05T06:38:23.007Z" },
]
[[package]]
@@ -3150,20 +3380,20 @@ wheels = [
[[package]]
name = "iamdata"
-version = "0.1.202602021"
+version = "0.1.202605131"
source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/93/5e/8179963b7a528c548824a8e4088150509d9fa8571dd622b7399f6d2d5680/iamdata-0.1.202602021.tar.gz", hash = "sha256:c24265fc3694076f65da91a8aa9361b60da25f7b8cfd8ba4ddd6aa1b9bb5153e", size = 771233, upload-time = "2026-02-02T05:49:56.76Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/cc/ea/d68e25aa4392e8a9f8e6523adc95a5fb86baf98d052efa2cec4d4a00e7ce/iamdata-0.1.202605131.tar.gz", hash = "sha256:ab4e8f1ea080394157848fecd0ca643633e35b2e0cb1965c9ed9bdd673afe00c", size = 793465, upload-time = "2026-05-13T05:57:10.607Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/74/9e/ae7a3019aa5a27d70412b74da4f0304695efa5d9a88f0689f37ea2602ea2/iamdata-0.1.202602021-py3-none-any.whl", hash = "sha256:48419662d75dd0e1ea22b9cc98fd70201d4c72760c6897acc46ad9ab90633d18", size = 1226614, upload-time = "2026-02-02T05:49:54.735Z" },
+ { url = "https://files.pythonhosted.org/packages/f6/93/03396b477b0faa9f1a12142209b59aa13d0fe4f64e2be47883f607789c14/iamdata-0.1.202605131-py3-none-any.whl", hash = "sha256:350e317d96fb8c8ddf30aa6da222788d302af5f13c9e357b59f9eefe50b8af5a", size = 1259166, upload-time = "2026-05-13T05:57:09.093Z" },
]
[[package]]
name = "idna"
-version = "3.11"
+version = "3.15"
source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/82/77/7b3966d0b9d1d31a36ddf1746926a11dface89a83409bf1483f0237aa758/idna-3.15.tar.gz", hash = "sha256:ca962446ea538f7092a95e057da437618e886f4d349216d2b1e294abfdb65fdc", size = 199245, upload-time = "2026-05-12T22:45:57.011Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" },
+ { url = "https://files.pythonhosted.org/packages/d2/23/408243171aa9aaba178d3e2559159c24c1171a641aa83b67bdd3394ead8e/idna-3.15-py3-none-any.whl", hash = "sha256:048adeaf8c2d788c40fee287673ccaa74c24ffd8dcf09ffa555a2fbb59f10ac8", size = 72340, upload-time = "2026-05-12T22:45:55.733Z" },
]
[[package]]
@@ -3382,6 +3612,19 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/dd/99/76476a1057b349c860bae72e45d6ef438feb877c84ee7d565faf464e54c3/keystoneauth1-5.13.0-py3-none-any.whl", hash = "sha256:5ab81412eb0923ceb9c602cc3decce514b399523cb83d16b409ed3b0f9b03d41", size = 343585, upload-time = "2026-01-19T10:47:00.762Z" },
]
+[[package]]
+name = "kingfisher-bin"
+version = "1.104.0"
+source = { registry = "https://pypi.org/simple" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/1e/2b/324212f1baf482a7d4b66a2edf33073336735b67bb6b04a38d18fd9e67fb/kingfisher_bin-1.104.0-py3-none-macosx_10_9_x86_64.whl", hash = "sha256:8e3840e67004a971fef80aba240ee5c3c5f7a3a343a6d1083a2751aaf866d5d3", size = 14057606, upload-time = "2026-06-22T03:03:01.419Z" },
+ { url = "https://files.pythonhosted.org/packages/21/0a/cbf964da5102657cb9be4a59db7c9f7807ef88f9419673b7486daba785d3/kingfisher_bin-1.104.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:b838313411fa2166a318a45aec2cfcc238e2f30f5292e309ca1129a73180c851", size = 12468386, upload-time = "2026-06-22T03:03:03.951Z" },
+ { url = "https://files.pythonhosted.org/packages/0b/a0/cc7ef0ac28f147cdfc9d80e4239fff11c1329831c6f57510c929e848753c/kingfisher_bin-1.104.0-py3-none-manylinux_2_17_aarch64.musllinux_1_2_aarch64.whl", hash = "sha256:0a94abbf2154ef8a3b4845cc0240e2321cdc19e0f5c7f585ea5252e76b242f68", size = 13943188, upload-time = "2026-06-22T03:03:06.378Z" },
+ { url = "https://files.pythonhosted.org/packages/17/79/827cfd7787885798a00b5ab905bdc866ef6f8deeff0f708679b06bc9baaa/kingfisher_bin-1.104.0-py3-none-manylinux_2_17_x86_64.musllinux_1_2_x86_64.whl", hash = "sha256:f381274b946f7f68ed72911770fff72024f2192c6e2e2158f2a7fbfda8c482fb", size = 14757594, upload-time = "2026-06-22T03:03:08.66Z" },
+ { url = "https://files.pythonhosted.org/packages/da/93/b0061fc69cd10382f647f9266823f213fd0b3f168f8b5bd9151a2370abb1/kingfisher_bin-1.104.0-py3-none-win_amd64.whl", hash = "sha256:f228d0dd61a738673b1c536e965a5661a83b1ee6ca64186a46ba6ea81ab4fd0b", size = 27697957, upload-time = "2026-06-22T03:03:11.268Z" },
+ { url = "https://files.pythonhosted.org/packages/a5/fb/f062665b4eb3f77e799cb6335e56bc2945aea83787888a6c1ab329858d0a/kingfisher_bin-1.104.0-py3-none-win_arm64.whl", hash = "sha256:a7774d9d11815ca946bd80b8c9df0f1d39c36cb5a21def3323b99d148dc63065", size = 26063704, upload-time = "2026-06-22T03:03:14.08Z" },
+]
+
[[package]]
name = "kiwisolver"
version = "1.4.9"
@@ -3475,6 +3718,20 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/08/10/9f8af3e6f569685ce3af7faab51c8dd9d93b9c38eba339ca31c746119447/kubernetes-32.0.1-py2.py3-none-any.whl", hash = "sha256:35282ab8493b938b08ab5526c7ce66588232df00ef5e1dbe88a419107dc10998", size = 1988070, upload-time = "2025-02-18T21:06:31.391Z" },
]
+[[package]]
+name = "linode-api4"
+version = "5.45.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "deprecated" },
+ { name = "polling" },
+ { name = "requests" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/b2/b5/fce03d9b81008dcc0fe4961ce10e140ac3ae5ab17f2cdd659763e4964c0d/linode_api4-5.45.0.tar.gz", hash = "sha256:af8a0a5638345ad467447112dcf5d58ec47e7dd192b89ce0c8537a1e5c435d04", size = 283375, upload-time = "2026-06-11T18:05:13.671Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/18/38/19e3c8f7b7a9dbeea2aa5af61f70162bff5131b3d39acbe73e8d0dd12972/linode_api4-5.45.0-py3-none-any.whl", hash = "sha256:3cc2650b13d8d3bc7735fa8e92a639669618f320471dc8e519db778c6020eacd", size = 158336, upload-time = "2026-06-11T18:05:11.799Z" },
+]
+
[[package]]
name = "lxml"
version = "6.1.0"
@@ -3966,30 +4223,30 @@ wheels = [
[[package]]
name = "numpy"
-version = "2.0.2"
+version = "2.2.6"
source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/a9/75/10dd1f8116a8b796cb2c737b674e02d02e80454bda953fa7e65d8c12b016/numpy-2.0.2.tar.gz", hash = "sha256:883c987dee1880e2a864ab0dc9892292582510604156762362d9326444636e78", size = 18902015, upload-time = "2024-08-26T20:19:40.945Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/76/21/7d2a95e4bba9dc13d043ee156a356c0a8f0c6309dff6b21b4d71a073b8a8/numpy-2.2.6.tar.gz", hash = "sha256:e29554e2bef54a90aa5cc07da6ce955accb83f21ab5de01a62c8478897b264fd", size = 20276440, upload-time = "2025-05-17T22:38:04.611Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/8b/cf/034500fb83041aa0286e0fb16e7c76e5c8b67c0711bb6e9e9737a717d5fe/numpy-2.0.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:49ca4decb342d66018b01932139c0961a8f9ddc7589611158cb3c27cbcf76448", size = 21169137, upload-time = "2024-08-26T20:07:45.345Z" },
- { url = "https://files.pythonhosted.org/packages/4a/d9/32de45561811a4b87fbdee23b5797394e3d1504b4a7cf40c10199848893e/numpy-2.0.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:11a76c372d1d37437857280aa142086476136a8c0f373b2e648ab2c8f18fb195", size = 13703552, upload-time = "2024-08-26T20:08:06.666Z" },
- { url = "https://files.pythonhosted.org/packages/c1/ca/2f384720020c7b244d22508cb7ab23d95f179fcfff33c31a6eeba8d6c512/numpy-2.0.2-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:807ec44583fd708a21d4a11d94aedf2f4f3c3719035c76a2bbe1fe8e217bdc57", size = 5298957, upload-time = "2024-08-26T20:08:15.83Z" },
- { url = "https://files.pythonhosted.org/packages/0e/78/a3e4f9fb6aa4e6fdca0c5428e8ba039408514388cf62d89651aade838269/numpy-2.0.2-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:8cafab480740e22f8d833acefed5cc87ce276f4ece12fdaa2e8903db2f82897a", size = 6905573, upload-time = "2024-08-26T20:08:27.185Z" },
- { url = "https://files.pythonhosted.org/packages/a0/72/cfc3a1beb2caf4efc9d0b38a15fe34025230da27e1c08cc2eb9bfb1c7231/numpy-2.0.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a15f476a45e6e5a3a79d8a14e62161d27ad897381fecfa4a09ed5322f2085669", size = 13914330, upload-time = "2024-08-26T20:08:48.058Z" },
- { url = "https://files.pythonhosted.org/packages/ba/a8/c17acf65a931ce551fee11b72e8de63bf7e8a6f0e21add4c937c83563538/numpy-2.0.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:13e689d772146140a252c3a28501da66dfecd77490b498b168b501835041f951", size = 19534895, upload-time = "2024-08-26T20:09:16.536Z" },
- { url = "https://files.pythonhosted.org/packages/ba/86/8767f3d54f6ae0165749f84648da9dcc8cd78ab65d415494962c86fac80f/numpy-2.0.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:9ea91dfb7c3d1c56a0e55657c0afb38cf1eeae4544c208dc465c3c9f3a7c09f9", size = 19937253, upload-time = "2024-08-26T20:09:46.263Z" },
- { url = "https://files.pythonhosted.org/packages/df/87/f76450e6e1c14e5bb1eae6836478b1028e096fd02e85c1c37674606ab752/numpy-2.0.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:c1c9307701fec8f3f7a1e6711f9089c06e6284b3afbbcd259f7791282d660a15", size = 14414074, upload-time = "2024-08-26T20:10:08.483Z" },
- { url = "https://files.pythonhosted.org/packages/5c/ca/0f0f328e1e59f73754f06e1adfb909de43726d4f24c6a3f8805f34f2b0fa/numpy-2.0.2-cp311-cp311-win32.whl", hash = "sha256:a392a68bd329eafac5817e5aefeb39038c48b671afd242710b451e76090e81f4", size = 6470640, upload-time = "2024-08-26T20:10:19.732Z" },
- { url = "https://files.pythonhosted.org/packages/eb/57/3a3f14d3a759dcf9bf6e9eda905794726b758819df4663f217d658a58695/numpy-2.0.2-cp311-cp311-win_amd64.whl", hash = "sha256:286cd40ce2b7d652a6f22efdfc6d1edf879440e53e76a75955bc0c826c7e64dc", size = 15910230, upload-time = "2024-08-26T20:10:43.413Z" },
- { url = "https://files.pythonhosted.org/packages/45/40/2e117be60ec50d98fa08c2f8c48e09b3edea93cfcabd5a9ff6925d54b1c2/numpy-2.0.2-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:df55d490dea7934f330006d0f81e8551ba6010a5bf035a249ef61a94f21c500b", size = 20895803, upload-time = "2024-08-26T20:11:13.916Z" },
- { url = "https://files.pythonhosted.org/packages/46/92/1b8b8dee833f53cef3e0a3f69b2374467789e0bb7399689582314df02651/numpy-2.0.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8df823f570d9adf0978347d1f926b2a867d5608f434a7cff7f7908c6570dcf5e", size = 13471835, upload-time = "2024-08-26T20:11:34.779Z" },
- { url = "https://files.pythonhosted.org/packages/7f/19/e2793bde475f1edaea6945be141aef6c8b4c669b90c90a300a8954d08f0a/numpy-2.0.2-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:9a92ae5c14811e390f3767053ff54eaee3bf84576d99a2456391401323f4ec2c", size = 5038499, upload-time = "2024-08-26T20:11:43.902Z" },
- { url = "https://files.pythonhosted.org/packages/e3/ff/ddf6dac2ff0dd50a7327bcdba45cb0264d0e96bb44d33324853f781a8f3c/numpy-2.0.2-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:a842d573724391493a97a62ebbb8e731f8a5dcc5d285dfc99141ca15a3302d0c", size = 6633497, upload-time = "2024-08-26T20:11:55.09Z" },
- { url = "https://files.pythonhosted.org/packages/72/21/67f36eac8e2d2cd652a2e69595a54128297cdcb1ff3931cfc87838874bd4/numpy-2.0.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c05e238064fc0610c840d1cf6a13bf63d7e391717d247f1bf0318172e759e692", size = 13621158, upload-time = "2024-08-26T20:12:14.95Z" },
- { url = "https://files.pythonhosted.org/packages/39/68/e9f1126d757653496dbc096cb429014347a36b228f5a991dae2c6b6cfd40/numpy-2.0.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0123ffdaa88fa4ab64835dcbde75dcdf89c453c922f18dced6e27c90d1d0ec5a", size = 19236173, upload-time = "2024-08-26T20:12:44.049Z" },
- { url = "https://files.pythonhosted.org/packages/d1/e9/1f5333281e4ebf483ba1c888b1d61ba7e78d7e910fdd8e6499667041cc35/numpy-2.0.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:96a55f64139912d61de9137f11bf39a55ec8faec288c75a54f93dfd39f7eb40c", size = 19634174, upload-time = "2024-08-26T20:13:13.634Z" },
- { url = "https://files.pythonhosted.org/packages/71/af/a469674070c8d8408384e3012e064299f7a2de540738a8e414dcfd639996/numpy-2.0.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ec9852fb39354b5a45a80bdab5ac02dd02b15f44b3804e9f00c556bf24b4bded", size = 14099701, upload-time = "2024-08-26T20:13:34.851Z" },
- { url = "https://files.pythonhosted.org/packages/d0/3d/08ea9f239d0e0e939b6ca52ad403c84a2bce1bde301a8eb4888c1c1543f1/numpy-2.0.2-cp312-cp312-win32.whl", hash = "sha256:671bec6496f83202ed2d3c8fdc486a8fc86942f2e69ff0e986140339a63bcbe5", size = 6174313, upload-time = "2024-08-26T20:13:45.653Z" },
- { url = "https://files.pythonhosted.org/packages/b2/b5/4ac39baebf1fdb2e72585c8352c56d063b6126be9fc95bd2bb5ef5770c20/numpy-2.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:cfd41e13fdc257aa5778496b8caa5e856dc4896d4ccf01841daee1d96465467a", size = 15606179, upload-time = "2024-08-26T20:14:08.786Z" },
+ { url = "https://files.pythonhosted.org/packages/da/a8/4f83e2aa666a9fbf56d6118faaaf5f1974d456b1823fda0a176eff722839/numpy-2.2.6-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f9f1adb22318e121c5c69a09142811a201ef17ab257a1e66ca3025065b7f53ae", size = 21176963, upload-time = "2025-05-17T21:31:19.36Z" },
+ { url = "https://files.pythonhosted.org/packages/b3/2b/64e1affc7972decb74c9e29e5649fac940514910960ba25cd9af4488b66c/numpy-2.2.6-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:c820a93b0255bc360f53eca31a0e676fd1101f673dda8da93454a12e23fc5f7a", size = 14406743, upload-time = "2025-05-17T21:31:41.087Z" },
+ { url = "https://files.pythonhosted.org/packages/4a/9f/0121e375000b5e50ffdd8b25bf78d8e1a5aa4cca3f185d41265198c7b834/numpy-2.2.6-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:3d70692235e759f260c3d837193090014aebdf026dfd167834bcba43e30c2a42", size = 5352616, upload-time = "2025-05-17T21:31:50.072Z" },
+ { url = "https://files.pythonhosted.org/packages/31/0d/b48c405c91693635fbe2dcd7bc84a33a602add5f63286e024d3b6741411c/numpy-2.2.6-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:481b49095335f8eed42e39e8041327c05b0f6f4780488f61286ed3c01368d491", size = 6889579, upload-time = "2025-05-17T21:32:01.712Z" },
+ { url = "https://files.pythonhosted.org/packages/52/b8/7f0554d49b565d0171eab6e99001846882000883998e7b7d9f0d98b1f934/numpy-2.2.6-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b64d8d4d17135e00c8e346e0a738deb17e754230d7e0810ac5012750bbd85a5a", size = 14312005, upload-time = "2025-05-17T21:32:23.332Z" },
+ { url = "https://files.pythonhosted.org/packages/b3/dd/2238b898e51bd6d389b7389ffb20d7f4c10066d80351187ec8e303a5a475/numpy-2.2.6-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ba10f8411898fc418a521833e014a77d3ca01c15b0c6cdcce6a0d2897e6dbbdf", size = 16821570, upload-time = "2025-05-17T21:32:47.991Z" },
+ { url = "https://files.pythonhosted.org/packages/83/6c/44d0325722cf644f191042bf47eedad61c1e6df2432ed65cbe28509d404e/numpy-2.2.6-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:bd48227a919f1bafbdda0583705e547892342c26fb127219d60a5c36882609d1", size = 15818548, upload-time = "2025-05-17T21:33:11.728Z" },
+ { url = "https://files.pythonhosted.org/packages/ae/9d/81e8216030ce66be25279098789b665d49ff19eef08bfa8cb96d4957f422/numpy-2.2.6-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:9551a499bf125c1d4f9e250377c1ee2eddd02e01eac6644c080162c0c51778ab", size = 18620521, upload-time = "2025-05-17T21:33:39.139Z" },
+ { url = "https://files.pythonhosted.org/packages/6a/fd/e19617b9530b031db51b0926eed5345ce8ddc669bb3bc0044b23e275ebe8/numpy-2.2.6-cp311-cp311-win32.whl", hash = "sha256:0678000bb9ac1475cd454c6b8c799206af8107e310843532b04d49649c717a47", size = 6525866, upload-time = "2025-05-17T21:33:50.273Z" },
+ { url = "https://files.pythonhosted.org/packages/31/0a/f354fb7176b81747d870f7991dc763e157a934c717b67b58456bc63da3df/numpy-2.2.6-cp311-cp311-win_amd64.whl", hash = "sha256:e8213002e427c69c45a52bbd94163084025f533a55a59d6f9c5b820774ef3303", size = 12907455, upload-time = "2025-05-17T21:34:09.135Z" },
+ { url = "https://files.pythonhosted.org/packages/82/5d/c00588b6cf18e1da539b45d3598d3557084990dcc4331960c15ee776ee41/numpy-2.2.6-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:41c5a21f4a04fa86436124d388f6ed60a9343a6f767fced1a8a71c3fbca038ff", size = 20875348, upload-time = "2025-05-17T21:34:39.648Z" },
+ { url = "https://files.pythonhosted.org/packages/66/ee/560deadcdde6c2f90200450d5938f63a34b37e27ebff162810f716f6a230/numpy-2.2.6-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:de749064336d37e340f640b05f24e9e3dd678c57318c7289d222a8a2f543e90c", size = 14119362, upload-time = "2025-05-17T21:35:01.241Z" },
+ { url = "https://files.pythonhosted.org/packages/3c/65/4baa99f1c53b30adf0acd9a5519078871ddde8d2339dc5a7fde80d9d87da/numpy-2.2.6-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:894b3a42502226a1cac872f840030665f33326fc3dac8e57c607905773cdcde3", size = 5084103, upload-time = "2025-05-17T21:35:10.622Z" },
+ { url = "https://files.pythonhosted.org/packages/cc/89/e5a34c071a0570cc40c9a54eb472d113eea6d002e9ae12bb3a8407fb912e/numpy-2.2.6-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:71594f7c51a18e728451bb50cc60a3ce4e6538822731b2933209a1f3614e9282", size = 6625382, upload-time = "2025-05-17T21:35:21.414Z" },
+ { url = "https://files.pythonhosted.org/packages/f8/35/8c80729f1ff76b3921d5c9487c7ac3de9b2a103b1cd05e905b3090513510/numpy-2.2.6-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f2618db89be1b4e05f7a1a847a9c1c0abd63e63a1607d892dd54668dd92faf87", size = 14018462, upload-time = "2025-05-17T21:35:42.174Z" },
+ { url = "https://files.pythonhosted.org/packages/8c/3d/1e1db36cfd41f895d266b103df00ca5b3cbe965184df824dec5c08c6b803/numpy-2.2.6-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fd83c01228a688733f1ded5201c678f0c53ecc1006ffbc404db9f7a899ac6249", size = 16527618, upload-time = "2025-05-17T21:36:06.711Z" },
+ { url = "https://files.pythonhosted.org/packages/61/c6/03ed30992602c85aa3cd95b9070a514f8b3c33e31124694438d88809ae36/numpy-2.2.6-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:37c0ca431f82cd5fa716eca9506aefcabc247fb27ba69c5062a6d3ade8cf8f49", size = 15505511, upload-time = "2025-05-17T21:36:29.965Z" },
+ { url = "https://files.pythonhosted.org/packages/b7/25/5761d832a81df431e260719ec45de696414266613c9ee268394dd5ad8236/numpy-2.2.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fe27749d33bb772c80dcd84ae7e8df2adc920ae8297400dabec45f0dedb3f6de", size = 18313783, upload-time = "2025-05-17T21:36:56.883Z" },
+ { url = "https://files.pythonhosted.org/packages/57/0a/72d5a3527c5ebffcd47bde9162c39fae1f90138c961e5296491ce778e682/numpy-2.2.6-cp312-cp312-win32.whl", hash = "sha256:4eeaae00d789f66c7a25ac5f34b71a7035bb474e679f410e5e1a94deb24cf2d4", size = 6246506, upload-time = "2025-05-17T21:37:07.368Z" },
+ { url = "https://files.pythonhosted.org/packages/36/fa/8c9210162ca1b88529ab76b41ba02d433fd54fecaf6feb70ef9f124683f1/numpy-2.2.6-cp312-cp312-win_amd64.whl", hash = "sha256:c1f9540be57940698ed329904db803cf7a402f3fc200bfe599334c9bd84a40b2", size = 12614190, upload-time = "2025-05-17T21:37:26.213Z" },
]
[[package]]
@@ -4031,7 +4288,7 @@ dependencies = [
{ name = "pycryptodomex" },
{ name = "pydantic" },
{ name = "pydash" },
- { name = "pyjwt" },
+ { name = "pyjwt", extra = ["crypto"] },
{ name = "python-dateutil" },
{ name = "pyyaml" },
{ name = "requests" },
@@ -4294,6 +4551,12 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/41/f5/65b66420c275e9b26513fdd6d84687403d11ac8be4650b67d1e5572b8f48/policyuniverse-1.5.1.20231109-py2.py3-none-any.whl", hash = "sha256:0b0ece0ee8285af31fc39ce09c82a551ca62e62bc2842e23952503bccb973321", size = 484251, upload-time = "2023-11-30T19:12:43.463Z" },
]
+[[package]]
+name = "polling"
+version = "0.3.2"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/8f/c5/4249317962180d97ec7a60fe38aa91f86216533bd478a427a5468945c5c9/polling-0.3.2.tar.gz", hash = "sha256:3afd62320c99b725c70f379964bf548b302fc7f04d4604e6c315d9012309cc9a", size = 5189, upload-time = "2021-05-22T19:48:41.466Z" }
+
[[package]]
name = "portalocker"
version = "2.10.1"
@@ -4410,8 +4673,8 @@ wheels = [
[[package]]
name = "prowler"
-version = "5.27.0"
-source = { git = "https://github.com/prowler-cloud/prowler.git?rev=master#0abbb7fc590eaf7de6ed354dd5a217bca261d2b0" }
+version = "5.35.0"
+source = { git = "https://github.com/prowler-cloud/prowler.git?rev=master#f5ea116763aeffede9f399c8934fc280eaccd315" }
dependencies = [
{ name = "alibabacloud-actiontrail20200706" },
{ name = "alibabacloud-credentials" },
@@ -4427,7 +4690,6 @@ dependencies = [
{ name = "alibabacloud-tea-openapi" },
{ name = "alibabacloud-vpc20160428" },
{ name = "alive-progress" },
- { name = "awsipranges" },
{ name = "azure-identity" },
{ name = "azure-keyvault-keys" },
{ name = "azure-mgmt-apimanagement" },
@@ -4463,13 +4725,14 @@ dependencies = [
{ name = "dash" },
{ name = "dash-bootstrap-components" },
{ name = "defusedxml" },
- { name = "detect-secrets" },
{ name = "dulwich" },
{ name = "google-api-python-client" },
{ name = "google-auth-httplib2" },
{ name = "h2" },
{ name = "jsonschema" },
+ { name = "kingfisher-bin" },
{ name = "kubernetes" },
+ { name = "linode-api4" },
{ name = "markdown" },
{ name = "microsoft-kiota-abstractions" },
{ name = "msgraph-sdk" },
@@ -4484,9 +4747,14 @@ dependencies = [
{ name = "pygithub" },
{ name = "python-dateutil" },
{ name = "pytz" },
+ { name = "scaleway" },
{ name = "schema" },
{ name = "shodan" },
{ name = "slack-sdk" },
+ { name = "stackit-core" },
+ { name = "stackit-iaas" },
+ { name = "stackit-objectstorage" },
+ { name = "stackit-resourcemanager" },
{ name = "tabulate" },
{ name = "tzlocal" },
{ name = "uuid6" },
@@ -4494,7 +4762,7 @@ dependencies = [
[[package]]
name = "prowler-api"
-version = "1.29.0"
+version = "1.37.0"
source = { virtual = "." }
dependencies = [
{ name = "cartography" },
@@ -4507,6 +4775,7 @@ dependencies = [
{ name = "django-celery-results" },
{ name = "django-cors-headers" },
{ name = "django-environ" },
+ { name = "django-eventstream" },
{ name = "django-filter" },
{ name = "django-guid" },
{ name = "django-postgres-extra" },
@@ -4533,6 +4802,8 @@ dependencies = [
{ name = "sentry-sdk", extra = ["django"] },
{ name = "sqlparse" },
{ name = "uuid6" },
+ { name = "uvicorn-worker" },
+ { name = "uvloop" },
{ name = "werkzeug" },
{ name = "xmlsec" },
]
@@ -4561,7 +4832,7 @@ dev = [
[package.metadata]
requires-dist = [
- { name = "cartography", specifier = "==0.135.0" },
+ { name = "cartography", specifier = "==0.138.1" },
{ name = "celery", specifier = "==5.6.2" },
{ name = "defusedxml", specifier = "==0.7.1" },
{ name = "dj-rest-auth", extras = ["with-social", "jwt"], specifier = "==7.0.1" },
@@ -4571,6 +4842,7 @@ requires-dist = [
{ name = "django-celery-results", specifier = "==2.6.0" },
{ name = "django-cors-headers", specifier = "==4.4.0" },
{ name = "django-environ", specifier = "==0.11.2" },
+ { name = "django-eventstream", specifier = "==5.3.3" },
{ name = "django-filter", specifier = "==24.3" },
{ name = "django-guid", specifier = "==3.5.0" },
{ name = "django-postgres-extra", specifier = "==2.0.9" },
@@ -4583,7 +4855,7 @@ requires-dist = [
{ name = "drf-spectacular-jsonapi", specifier = "==0.5.1" },
{ name = "fonttools", specifier = "==4.62.1" },
{ name = "gevent", specifier = "==25.9.1" },
- { name = "gunicorn", specifier = "==23.0.0" },
+ { name = "gunicorn", specifier = "==26.0.0" },
{ name = "h2", specifier = "==4.3.0" },
{ name = "lxml", specifier = "==6.1.0" },
{ name = "markdown", specifier = "==3.10.2" },
@@ -4597,6 +4869,8 @@ requires-dist = [
{ name = "sentry-sdk", extras = ["django"], specifier = "==2.56.0" },
{ name = "sqlparse", specifier = "==0.5.5" },
{ name = "uuid6", specifier = "==2024.7.10" },
+ { name = "uvicorn-worker", specifier = "==0.4.0" },
+ { name = "uvloop", specifier = "==0.22.1" },
{ name = "werkzeug", specifier = "==3.1.7" },
{ name = "xmlsec", specifier = "==1.3.17" },
]
@@ -4618,7 +4892,7 @@ dev = [
{ name = "pytest-env", specifier = "==1.1.3" },
{ name = "pytest-randomly", specifier = "==3.15.0" },
{ name = "pytest-xdist", specifier = "==3.6.1" },
- { name = "ruff", specifier = "==0.5.0" },
+ { name = "ruff", specifier = "==0.15.11" },
{ name = "tqdm", specifier = "==4.67.1" },
{ name = "vulture", specifier = "==2.14" },
]
@@ -4671,6 +4945,16 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/7b/08/9c66c269b0d417a0af9fb969535f0371b8c538633535a7a6a5ca3f9231e2/psycopg2_binary-2.9.9-cp312-cp312-win_amd64.whl", hash = "sha256:81ff62668af011f9a48787564ab7eded4e9fb17a4a6a74af5ffa6a457400d2ab", size = 1163864, upload-time = "2023-10-28T09:37:28.155Z" },
]
+[[package]]
+name = "pubcontrol"
+version = "3.5.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "pyjwt", extra = ["crypto"] },
+ { name = "requests" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/25/6a/02202a247214a6ffd5148ab1b16aca1c334b40dca211bca0442c8b7c7447/pubcontrol-3.5.0.tar.gz", hash = "sha256:a5ec6b3f53edfd005675518e5e4cc23b34122776835ae7c6dbd1db173d1ff0cb", size = 18199, upload-time = "2023-07-05T19:11:40.477Z" }
+
[[package]]
name = "py-deviceid"
version = "0.1.1"
@@ -4682,14 +4966,14 @@ wheels = [
[[package]]
name = "py-iam-expand"
-version = "0.1.0"
+version = "0.3.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "iamdata" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/22/99/8d31a30b37825577275bb3663885b55075fba80257fcd6813b85d3aaffa8/py_iam_expand-0.1.0.tar.gz", hash = "sha256:5a2884dc267ac59a02c3a80fefc0b34c309dac681baa0f87c436067c6cf53a96", size = 10228, upload-time = "2025-04-30T07:15:35.304Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/a3/08/f6e11a029b81f0bec4b7b1f18704aadf509a882cc386c90ef1ac043c18cc/py_iam_expand-0.3.0.tar.gz", hash = "sha256:4ccfe25f40ba0633a152c4f86b49cde8972ee3d4b6009b017a4310cc4b9e64c7", size = 10234, upload-time = "2026-02-24T09:47:47.772Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/79/19/482c2e0768cda7afaed07918e4fbd951e2418255fb5d1d9b35b284871716/py_iam_expand-0.1.0-py3-none-any.whl", hash = "sha256:b845ce7b50ac895b02b4f338e09c62a68ea51849794f76e189b02009bd388510", size = 12522, upload-time = "2025-04-30T07:15:33.799Z" },
+ { url = "https://files.pythonhosted.org/packages/5a/dd/4056d0bc3d6317039d2dd2ee7cd6a5389575603e270399a8f9f20e11e721/py_iam_expand-0.3.0-py3-none-any.whl", hash = "sha256:94c0a1e9dd60316ce60ddc0cdc9a046119bde335b5bb9593ee29224857860d5a", size = 12527, upload-time = "2026-02-24T09:47:45.602Z" },
]
[[package]]
@@ -4873,11 +5157,11 @@ wheels = [
[[package]]
name = "pyjwt"
-version = "2.12.1"
+version = "2.13.0"
source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/c2/27/a3b6e5bf6ff856d2509292e95c8f57f0df7017cf5394921fc4e4ef40308a/pyjwt-2.12.1.tar.gz", hash = "sha256:c74a7a2adf861c04d002db713dd85f84beb242228e671280bf709d765b03672b", size = 102564, upload-time = "2026-03-13T19:27:37.25Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/3b/81/58d0ac84e1ef3a3843791d6954d94c0b33d526c75eeb1efbce9d0a4c4077/pyjwt-2.13.0.tar.gz", hash = "sha256:41571c89ca91598c79e8ef18a2d07367d4810fbbd6f637794879baf1b7703423", size = 107515, upload-time = "2026-05-21T19:54:36.618Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/e5/7a/8dd906bd22e79e47397a61742927f6747fe93242ef86645ee9092e610244/pyjwt-2.12.1-py3-none-any.whl", hash = "sha256:28ca37c070cad8ba8cd9790cd940535d40274d22f80ab87f3ac6a713e6e8454c", size = 29726, upload-time = "2026-03-13T19:27:35.677Z" },
+ { url = "https://files.pythonhosted.org/packages/a3/5e/ecf12fdb62546d64385c158514e9b2b671f7832108ef2ecd2020ce0af2d1/pyjwt-2.13.0-py3-none-any.whl", hash = "sha256:66adcc2aff09b3f1bbd95fc1e1577df8ac8723c978552fd43304c8a290ac5728", size = 31274, upload-time = "2026-05-21T19:54:35.362Z" },
]
[package.optional-dependencies]
@@ -5367,27 +5651,27 @@ wheels = [
[[package]]
name = "ruff"
-version = "0.5.0"
+version = "0.15.11"
source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/28/9a/dde343d95ecd0747207e4e8d143c373ef961cbd6b78c61a659f67582dbd2/ruff-0.5.0.tar.gz", hash = "sha256:eb641b5873492cf9bd45bc9c5ae5320648218e04386a5f0c264ad6ccce8226a1", size = 2587996, upload-time = "2024-06-27T15:42:16.137Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/e4/8d/192f3d7103816158dfd5ea50d098ef2aec19194e6cbccd4b3485bdb2eb2d/ruff-0.15.11.tar.gz", hash = "sha256:f092b21708bf0e7437ce9ada249dfe688ff9a0954fc94abab05dcea7dcd29c33", size = 4637264, upload-time = "2026-04-16T18:46:26.58Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/55/5d/0d9510720d61df753df39bf24a96d6c141080c94fe6025568747fbea856a/ruff-0.5.0-py3-none-linux_armv6l.whl", hash = "sha256:ee770ea8ab38918f34e7560a597cc0a8c9a193aaa01bfbd879ef43cb06bd9c4c", size = 9434156, upload-time = "2024-06-27T15:40:40.824Z" },
- { url = "https://files.pythonhosted.org/packages/be/5a/7f466f5449dce168c2d956ad4a207d62dc7b76836d46f1c04249a4daaf34/ruff-0.5.0-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:38f3b8327b3cb43474559d435f5fa65dacf723351c159ed0dc567f7ab735d1b6", size = 8536948, upload-time = "2024-06-27T15:40:47.907Z" },
- { url = "https://files.pythonhosted.org/packages/e2/a2/afc6952d5a0199e7e6c0a2051d6f4780fb70376f5bd07f27838f8bc0cf47/ruff-0.5.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:7594f8df5404a5c5c8f64b8311169879f6cf42142da644c7e0ba3c3f14130370", size = 8107163, upload-time = "2024-06-27T15:41:06.887Z" },
- { url = "https://files.pythonhosted.org/packages/34/54/ea77237405b7573298f5cc00045d1aceab609841d3cc88de3d7c3d2a6163/ruff-0.5.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:adc7012d6ec85032bc4e9065110df205752d64010bed5f958d25dbee9ce35de3", size = 9877009, upload-time = "2024-06-27T15:41:11.28Z" },
- { url = "https://files.pythonhosted.org/packages/56/db/3f74873bc0ca915f79d26575e549eb5e633022d56315d314e6f9c0fa596a/ruff-0.5.0-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d505fb93b0fabef974b168d9b27c3960714d2ecda24b6ffa6a87ac432905ea38", size = 9219926, upload-time = "2024-06-27T15:41:15.032Z" },
- { url = "https://files.pythonhosted.org/packages/57/08/1052c80f3f44321631a8c1337e55883dd7a7b02b4efe5c9282258db42358/ruff-0.5.0-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9dc5cfd3558f14513ed0d5b70ce531e28ea81a8a3b1b07f0f48421a3d9e7d80a", size = 10031146, upload-time = "2024-06-27T15:41:20.601Z" },
- { url = "https://files.pythonhosted.org/packages/8f/a2/f7c01c4a02b87998c9e1379ec8d7345d6a45f8b34e326e8700c13da391c3/ruff-0.5.0-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:db3ca35265de239a1176d56a464b51557fce41095c37d6c406e658cf80bbb362", size = 10770796, upload-time = "2024-06-27T15:41:24.665Z" },
- { url = "https://files.pythonhosted.org/packages/12/a1/5f45ab0948a202da7fe13c6e0678f907bd88caacc7e4f4909603d3774051/ruff-0.5.0-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b1a321c4f68809fddd9b282fab6a8d8db796b270fff44722589a8b946925a2a8", size = 10364804, upload-time = "2024-06-27T15:41:29.153Z" },
- { url = "https://files.pythonhosted.org/packages/7e/40/83f88d5bda41496a90871ec82dd82545def4c4683e1c2f4a42f5a168ae3e/ruff-0.5.0-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2c4dfcd8d34b143916994b3876b63d53f56724c03f8c1a33a253b7b1e6bf2a7d", size = 11241308, upload-time = "2024-06-27T15:41:33.569Z" },
- { url = "https://files.pythonhosted.org/packages/af/79/8a57016a761d11491b913460a3d1545cdbe96dca6acb1279102814c9147b/ruff-0.5.0-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:81e5facfc9f4a674c6a78c64d38becfbd5e4f739c31fcd9ce44c849f1fad9e4c", size = 10064506, upload-time = "2024-06-27T15:41:38.21Z" },
- { url = "https://files.pythonhosted.org/packages/67/34/fd7cd8be0d8cd4bcce0dbef807933f6c9685d5dc2549b729da7ee7a7a5cc/ruff-0.5.0-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:e589e27971c2a3efff3fadafb16e5aef7ff93250f0134ec4b52052b673cf988d", size = 9866155, upload-time = "2024-06-27T15:41:42.551Z" },
- { url = "https://files.pythonhosted.org/packages/7b/54/8a654417265fe91de3ff303274a9d4d64774496eaa2eadd7da8e88a48b82/ruff-0.5.0-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:d2ffbc3715a52b037bcb0f6ff524a9367f642cdc5817944f6af5479bbb2eb50e", size = 9285874, upload-time = "2024-06-27T15:41:46.991Z" },
- { url = "https://files.pythonhosted.org/packages/86/39/564161e306b12ab40d2b6be0a0bc843c692a8295cc7101fa930db89e1e7e/ruff-0.5.0-py3-none-musllinux_1_2_i686.whl", hash = "sha256:cd096e23c6a4f9c819525a437fa0a99d1c67a1b6bb30948d46f33afbc53596cf", size = 9645133, upload-time = "2024-06-27T15:41:52.535Z" },
- { url = "https://files.pythonhosted.org/packages/3b/67/3203d56ee41d3dee8d94c7926b298b13a150f105a55fef38b75ccf5e0901/ruff-0.5.0-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:46e193b36f2255729ad34a49c9a997d506e58f08555366b2108783b3064a0e1e", size = 10143022, upload-time = "2024-06-27T15:41:56.879Z" },
- { url = "https://files.pythonhosted.org/packages/71/2e/1bab3c5a3929f348cdc086a3f3013ea0b8823ec3d273f3334ef621f4f83f/ruff-0.5.0-py3-none-win32.whl", hash = "sha256:49141d267100f5ceff541b4e06552e98527870eafa1acc9dec9139c9ec5af64c", size = 7735210, upload-time = "2024-06-27T15:42:02.173Z" },
- { url = "https://files.pythonhosted.org/packages/48/05/04bf25784ba73abf0e639065fd7a785c005c895c4bf64aa2729d26a1984f/ruff-0.5.0-py3-none-win_amd64.whl", hash = "sha256:e9118f60091047444c1b90952736ee7b1792910cab56e9b9a9ac20af94cd0440", size = 8536440, upload-time = "2024-06-27T15:42:08.275Z" },
- { url = "https://files.pythonhosted.org/packages/63/ab/a10ab4a751514d4f954079fbd2f645cc0c5982a18f510ab411048a2a5409/ruff-0.5.0-py3-none-win_arm64.whl", hash = "sha256:ed5c4df5c1fb4518abcb57725b576659542bdbe93366f4f329e8f398c4b71178", size = 7949476, upload-time = "2024-06-27T15:42:12.464Z" },
+ { url = "https://files.pythonhosted.org/packages/02/1e/6aca3427f751295ab011828e15e9bf452200ac74484f1db4be0197b8170b/ruff-0.15.11-py3-none-linux_armv6l.whl", hash = "sha256:e927cfff503135c558eb581a0c9792264aae9507904eb27809cdcff2f2c847b7", size = 10607943, upload-time = "2026-04-16T18:46:05.967Z" },
+ { url = "https://files.pythonhosted.org/packages/e7/26/1341c262e74f36d4e84f3d6f4df0ac68cd53331a66bfc5080daa17c84c0b/ruff-0.15.11-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:7a1b5b2938d8f890b76084d4fa843604d787a912541eae85fd7e233398bbb73e", size = 10988592, upload-time = "2026-04-16T18:46:00.742Z" },
+ { url = "https://files.pythonhosted.org/packages/03/71/850b1d6ffa9564fbb6740429bad53df1094082fe515c8c1e74b6d8d05f18/ruff-0.15.11-py3-none-macosx_11_0_arm64.whl", hash = "sha256:d4176f3d194afbdaee6e41b9ccb1a2c287dba8700047df474abfbe773825d1cb", size = 10338501, upload-time = "2026-04-16T18:46:03.723Z" },
+ { url = "https://files.pythonhosted.org/packages/f2/11/cc1284d3e298c45a817a6aadb6c3e1d70b45c9b36d8d9cce3387b495a03a/ruff-0.15.11-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3b17c886fb88203ced3afe7f14e8d5ae96e9d2f4ccc0ee66aa19f2c2675a27e4", size = 10670693, upload-time = "2026-04-16T18:46:41.941Z" },
+ { url = "https://files.pythonhosted.org/packages/ce/9e/f8288b034ab72b371513c13f9a41d9ba3effac54e24bfb467b007daee2ca/ruff-0.15.11-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:49fafa220220afe7758a487b048de4c8f9f767f37dfefad46b9dd06759d003eb", size = 10416177, upload-time = "2026-04-16T18:46:21.717Z" },
+ { url = "https://files.pythonhosted.org/packages/85/71/504d79abfd3d92532ba6bbe3d1c19fada03e494332a59e37c7c2dabae427/ruff-0.15.11-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f2ab8427e74a00d93b8bda1307b1e60970d40f304af38bccb218e056c220120d", size = 11221886, upload-time = "2026-04-16T18:46:15.086Z" },
+ { url = "https://files.pythonhosted.org/packages/43/5a/947e6ab7a5ad603d65b474be15a4cbc6d29832db5d762cd142e4e3a74164/ruff-0.15.11-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:195072c0c8e1fc8f940652073df082e37a5d9cb43b4ab1e4d0566ab8977a13b7", size = 12075183, upload-time = "2026-04-16T18:46:07.944Z" },
+ { url = "https://files.pythonhosted.org/packages/9f/a1/0b7bb6268775fdd3a0818aee8efd8f5b4e231d24dd4d528ced2534023182/ruff-0.15.11-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a3a0996d486af3920dec930a2e7daed4847dfc12649b537a9335585ada163e9e", size = 11516575, upload-time = "2026-04-16T18:46:31.687Z" },
+ { url = "https://files.pythonhosted.org/packages/30/c3/bb5168fc4d233cc06e95f482770d0f3c87945a0cd9f614b90ea8dc2f2833/ruff-0.15.11-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1bef2cb556d509259f1fe440bb9cd33c756222cf0a7afe90d15edf0866702431", size = 11306537, upload-time = "2026-04-16T18:46:36.988Z" },
+ { url = "https://files.pythonhosted.org/packages/e4/92/4cfae6441f3967317946f3b788136eecf093729b94d6561f963ed810c82e/ruff-0.15.11-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:030d921a836d7d4a12cf6e8d984a88b66094ccb0e0f17ddd55067c331191bf19", size = 11296813, upload-time = "2026-04-16T18:46:24.182Z" },
+ { url = "https://files.pythonhosted.org/packages/43/26/972784c5dde8313acde8ac71ba8ac65475b85db4a2352a76c9934361f9bc/ruff-0.15.11-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:0e783b599b4577788dbbb66b9addcef87e9a8832f4ce0c19e34bf55543a2f890", size = 10633136, upload-time = "2026-04-16T18:46:39.802Z" },
+ { url = "https://files.pythonhosted.org/packages/5b/53/3985a4f185020c2f367f2e08a103032e12564829742a1b417980ce1514a0/ruff-0.15.11-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:ae90592246625ba4a34349d68ec28d4400d75182b71baa196ddb9f82db025ef5", size = 10424701, upload-time = "2026-04-16T18:46:10.381Z" },
+ { url = "https://files.pythonhosted.org/packages/d3/57/bf0dfb32241b56c83bb663a826133da4bf17f682ba8c096973065f6e6a68/ruff-0.15.11-py3-none-musllinux_1_2_i686.whl", hash = "sha256:1f111d62e3c983ed20e0ca2e800f8d77433a5b1161947df99a5c2a3fb60514f0", size = 10873887, upload-time = "2026-04-16T18:46:29.157Z" },
+ { url = "https://files.pythonhosted.org/packages/02/05/e48076b2a57dc33ee8c7a957296f97c744ca891a8ffb4ffb1aaa3b3f517d/ruff-0.15.11-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:06f483d6646f59eaffba9ae30956370d3a886625f511a3108994000480621d1c", size = 11404316, upload-time = "2026-04-16T18:46:19.462Z" },
+ { url = "https://files.pythonhosted.org/packages/88/27/0195d15fe7a897cbcba0904792c4b7c9fdd958456c3a17d2ea6093716a9a/ruff-0.15.11-py3-none-win32.whl", hash = "sha256:476a2aa56b7da0b73a3ee80b6b2f0e19cce544245479adde7baa65466664d5f3", size = 10655535, upload-time = "2026-04-16T18:46:12.47Z" },
+ { url = "https://files.pythonhosted.org/packages/3a/5e/c927b325bd4c1d3620211a4b96f47864633199feed60fa936025ab27e090/ruff-0.15.11-py3-none-win_amd64.whl", hash = "sha256:8b6756d88d7e234fb0c98c91511aae3cd519d5e3ed271cae31b20f39cb2a12a3", size = 11779692, upload-time = "2026-04-16T18:46:17.268Z" },
+ { url = "https://files.pythonhosted.org/packages/63/b6/aeadee5443e49baa2facd51131159fd6301cc4ccfc1541e4df7b021c37dd/ruff-0.15.11-py3-none-win_arm64.whl", hash = "sha256:063fed18cc1bbe0ee7393957284a6fe8b588c6a406a285af3ee3f46da2391ee4", size = 11032614, upload-time = "2026-04-16T18:46:34.487Z" },
]
[[package]]
@@ -5526,6 +5810,67 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/49/4b/359f28a903c13438ef59ebeee215fb25da53066db67b305c125f1c6d2a25/sqlparse-0.5.5-py3-none-any.whl", hash = "sha256:12a08b3bf3eec877c519589833aed092e2444e68240a3577e8e26148acc7b1ba", size = 46138, upload-time = "2025-12-19T07:17:46.573Z" },
]
+[[package]]
+name = "stackit-core"
+version = "0.2.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "cryptography" },
+ { name = "pydantic" },
+ { name = "pyjwt", extra = ["crypto"] },
+ { name = "requests" },
+ { name = "urllib3" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/24/90/20f9ec7387eec4067cfd3d29055d0e2b5e1e0322c601a7f48125fd8ea35f/stackit_core-0.2.0.tar.gz", hash = "sha256:b8af91877cdb060d6969a303d8cf20bc0b33b345afd91f679c44a987381e2d47", size = 8987, upload-time = "2025-06-12T08:24:45.251Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/ab/b4/7b53187ce68956870d864ccb9ccfb68066c9df9de1c9568fd2feb03c4504/stackit_core-0.2.0-py3-none-any.whl", hash = "sha256:04632fc6742790d08ddfcb7f2313e04d1254827397a80250f838a2f81b92645b", size = 10240, upload-time = "2025-06-12T08:24:44.214Z" },
+]
+
+[[package]]
+name = "stackit-iaas"
+version = "1.4.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "pydantic" },
+ { name = "python-dateutil" },
+ { name = "requests" },
+ { name = "stackit-core" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/52/07/24e65278300d5c3cb19cb1660bff924c80812cf8aad3e715f826bae5aa80/stackit_iaas-1.4.0.tar.gz", hash = "sha256:93523b23442350c7ebefd9129485c4c2a539f694a9c36a0f8edfaba9862057ea", size = 116236, upload-time = "2026-05-13T09:43:15.996Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/08/51/2201164d7bfacf47539888c735f10f6320c188252384957aa1b23121a210/stackit_iaas-1.4.0-py3-none-any.whl", hash = "sha256:3f4a32321b57ac238f73e5d660c6428186b92cc0425c1f0783ba801e377149d9", size = 316588, upload-time = "2026-05-13T09:43:14.943Z" },
+]
+
+[[package]]
+name = "stackit-objectstorage"
+version = "1.4.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "pydantic" },
+ { name = "python-dateutil" },
+ { name = "requests" },
+ { name = "stackit-core" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/90/80/b790756af40a5c6d979dd688b2557394ac54b594eb4c08edc33157ba890f/stackit_objectstorage-1.4.0.tar.gz", hash = "sha256:4a3812b4de102b199f061706a802909f9e53ae9b0858769d5bd720f814c8bdbe", size = 31814, upload-time = "2026-05-13T09:43:05.027Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/68/f1/ffa8d5e2ec9f818c72a6f045691364eb4e927ee86641993a70882d00205a/stackit_objectstorage-1.4.0-py3-none-any.whl", hash = "sha256:1a3285c6840d95cff591d84fd21803575cb0d010c398e6575ed92987b9c39866", size = 65061, upload-time = "2026-05-13T09:43:04.13Z" },
+]
+
+[[package]]
+name = "stackit-resourcemanager"
+version = "0.8.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "pydantic" },
+ { name = "python-dateutil" },
+ { name = "requests" },
+ { name = "stackit-core" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/23/2d/f458f18e48ed2b1c83df52cff7dbdfd5dd904fb2980ffd9385876e47bbd9/stackit_resourcemanager-0.8.0.tar.gz", hash = "sha256:f44542beab4130857f5a7f465cf02defeef657bdf63c1beeb3102f0ba3c003fe", size = 33943, upload-time = "2026-05-13T09:43:08.667Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/c7/9c/38a74d0f7a89b4320f6d2366fb660638bda8860daa08748b12c713d84381/stackit_resourcemanager-0.8.0-py3-none-any.whl", hash = "sha256:dd04bb8353d041a137c4dcba190beabded7acfaff1bc98b218fce20a99389ebc", size = 81288, upload-time = "2026-05-13T09:43:07.81Z" },
+]
+
[[package]]
name = "statsd"
version = "4.0.1"
@@ -5730,6 +6075,52 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/d3/3e/4ae6af487ce5781ed71d5fe10aca72e7cbc4d4f45afc31b120287082a8dd/uuid6-2024.7.10-py3-none-any.whl", hash = "sha256:93432c00ba403751f722829ad21759ff9db051dea140bf81493271e8e4dd18b7", size = 6376, upload-time = "2024-07-10T16:39:36.148Z" },
]
+[[package]]
+name = "uvicorn"
+version = "0.49.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "click" },
+ { name = "h11" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/c4/1f/fa18009dea8469069cca78a4e877a008ab78f08b064bfc9ab891579077ff/uvicorn-0.49.0.tar.gz", hash = "sha256:ebf4271aa580d9de97f93192d4595176df6e91f9aae919ca73e4fc07df1e66a3", size = 91284, upload-time = "2026-06-03T22:01:30.448Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/88/fa/e1388bbcf24ef3274f45c0c1c7b501fd14971037c1b6ee23610553307497/uvicorn-0.49.0-py3-none-any.whl", hash = "sha256:ba3d14c3ee7e41c6c654c46c9eb489d33213cdd30aa1696eab1374337c13f68f", size = 71376, upload-time = "2026-06-03T22:01:29.037Z" },
+]
+
+[[package]]
+name = "uvicorn-worker"
+version = "0.4.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "gunicorn" },
+ { name = "uvicorn" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/80/59/9101b9c0680fd80e9d26c07deb822a5d18a324339fcf9cd017885ee808ad/uvicorn_worker-0.4.0.tar.gz", hash = "sha256:8ee5306070d8f38dce124adce488c3c0b50f20cf0c0222b12c66188da7214493", size = 9361, upload-time = "2025-09-20T10:47:01.218Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/90/25/09cd7a90c8bb7fb693be0d6704fccd5f9778d5513214b7a01cc4a94ff314/uvicorn_worker-0.4.0-py3-none-any.whl", hash = "sha256:e2ed952cef976f5e9e429d7269640bbcafbd36c80aa80f1003c8c77a6797abde", size = 5364, upload-time = "2025-09-20T10:46:59.776Z" },
+]
+
+[[package]]
+name = "uvloop"
+version = "0.22.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/06/f0/18d39dbd1971d6d62c4629cc7fa67f74821b0dc1f5a77af43719de7936a7/uvloop-0.22.1.tar.gz", hash = "sha256:6c84bae345b9147082b17371e3dd5d42775bddce91f885499017f4607fdaf39f", size = 2443250, upload-time = "2025-10-16T22:17:19.342Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/c7/d5/69900f7883235562f1f50d8184bb7dd84a2fb61e9ec63f3782546fdbd057/uvloop-0.22.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:c60ebcd36f7b240b30788554b6f0782454826a0ed765d8430652621b5de674b9", size = 1352420, upload-time = "2025-10-16T22:16:21.187Z" },
+ { url = "https://files.pythonhosted.org/packages/a8/73/c4e271b3bce59724e291465cc936c37758886a4868787da0278b3b56b905/uvloop-0.22.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:3b7f102bf3cb1995cfeaee9321105e8f5da76fdb104cdad8986f85461a1b7b77", size = 748677, upload-time = "2025-10-16T22:16:22.558Z" },
+ { url = "https://files.pythonhosted.org/packages/86/94/9fb7fad2f824d25f8ecac0d70b94d0d48107ad5ece03769a9c543444f78a/uvloop-0.22.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:53c85520781d84a4b8b230e24a5af5b0778efdb39142b424990ff1ef7c48ba21", size = 3753819, upload-time = "2025-10-16T22:16:23.903Z" },
+ { url = "https://files.pythonhosted.org/packages/74/4f/256aca690709e9b008b7108bc85fba619a2bc37c6d80743d18abad16ee09/uvloop-0.22.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:56a2d1fae65fd82197cb8c53c367310b3eabe1bbb9fb5a04d28e3e3520e4f702", size = 3804529, upload-time = "2025-10-16T22:16:25.246Z" },
+ { url = "https://files.pythonhosted.org/packages/7f/74/03c05ae4737e871923d21a76fe28b6aad57f5c03b6e6bfcfa5ad616013e4/uvloop-0.22.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:40631b049d5972c6755b06d0bfe8233b1bd9a8a6392d9d1c45c10b6f9e9b2733", size = 3621267, upload-time = "2025-10-16T22:16:26.819Z" },
+ { url = "https://files.pythonhosted.org/packages/75/be/f8e590fe61d18b4a92070905497aec4c0e64ae1761498cad09023f3f4b3e/uvloop-0.22.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:535cc37b3a04f6cd2c1ef65fa1d370c9a35b6695df735fcff5427323f2cd5473", size = 3723105, upload-time = "2025-10-16T22:16:28.252Z" },
+ { url = "https://files.pythonhosted.org/packages/3d/ff/7f72e8170be527b4977b033239a83a68d5c881cc4775fca255c677f7ac5d/uvloop-0.22.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:fe94b4564e865d968414598eea1a6de60adba0c040ba4ed05ac1300de402cd42", size = 1359936, upload-time = "2025-10-16T22:16:29.436Z" },
+ { url = "https://files.pythonhosted.org/packages/c3/c6/e5d433f88fd54d81ef4be58b2b7b0cea13c442454a1db703a1eea0db1a59/uvloop-0.22.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:51eb9bd88391483410daad430813d982010f9c9c89512321f5b60e2cddbdddd6", size = 752769, upload-time = "2025-10-16T22:16:30.493Z" },
+ { url = "https://files.pythonhosted.org/packages/24/68/a6ac446820273e71aa762fa21cdcc09861edd3536ff47c5cd3b7afb10eeb/uvloop-0.22.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:700e674a166ca5778255e0e1dc4e9d79ab2acc57b9171b79e65feba7184b3370", size = 4317413, upload-time = "2025-10-16T22:16:31.644Z" },
+ { url = "https://files.pythonhosted.org/packages/5f/6f/e62b4dfc7ad6518e7eff2516f680d02a0f6eb62c0c212e152ca708a0085e/uvloop-0.22.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7b5b1ac819a3f946d3b2ee07f09149578ae76066d70b44df3fa990add49a82e4", size = 4426307, upload-time = "2025-10-16T22:16:32.917Z" },
+ { url = "https://files.pythonhosted.org/packages/90/60/97362554ac21e20e81bcef1150cb2a7e4ffdaf8ea1e5b2e8bf7a053caa18/uvloop-0.22.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e047cc068570bac9866237739607d1313b9253c3051ad84738cbb095be0537b2", size = 4131970, upload-time = "2025-10-16T22:16:34.015Z" },
+ { url = "https://files.pythonhosted.org/packages/99/39/6b3f7d234ba3964c428a6e40006340f53ba37993f46ed6e111c6e9141d18/uvloop-0.22.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:512fec6815e2dd45161054592441ef76c830eddaad55c8aa30952e6fe1ed07c0", size = 4296343, upload-time = "2025-10-16T22:16:35.149Z" },
+]
+
[[package]]
name = "vine"
version = "5.1.0"
@@ -5766,6 +6157,38 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/34/db/b10e48aa8fff7407e67470363eac595018441cf32d5e1001567a7aeba5d2/websocket_client-1.9.0-py3-none-any.whl", hash = "sha256:af248a825037ef591efbf6ed20cc5faa03d3b47b9e5a2230a529eeee1c1fc3ef", size = 82616, upload-time = "2025-10-07T21:16:34.951Z" },
]
+[[package]]
+name = "websockets"
+version = "16.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/04/24/4b2031d72e840ce4c1ccb255f693b15c334757fc50023e4db9537080b8c4/websockets-16.0.tar.gz", hash = "sha256:5f6261a5e56e8d5c42a4497b364ea24d94d9563e8fbd44e78ac40879c60179b5", size = 179346, upload-time = "2026-01-10T09:23:47.181Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/f2/db/de907251b4ff46ae804ad0409809504153b3f30984daf82a1d84a9875830/websockets-16.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:31a52addea25187bde0797a97d6fc3d2f92b6f72a9370792d65a6e84615ac8a8", size = 177340, upload-time = "2026-01-10T09:22:34.539Z" },
+ { url = "https://files.pythonhosted.org/packages/f3/fa/abe89019d8d8815c8781e90d697dec52523fb8ebe308bf11664e8de1877e/websockets-16.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:417b28978cdccab24f46400586d128366313e8a96312e4b9362a4af504f3bbad", size = 175022, upload-time = "2026-01-10T09:22:36.332Z" },
+ { url = "https://files.pythonhosted.org/packages/58/5d/88ea17ed1ded2079358b40d31d48abe90a73c9e5819dbcde1606e991e2ad/websockets-16.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:af80d74d4edfa3cb9ed973a0a5ba2b2a549371f8a741e0800cb07becdd20f23d", size = 175319, upload-time = "2026-01-10T09:22:37.602Z" },
+ { url = "https://files.pythonhosted.org/packages/d2/ae/0ee92b33087a33632f37a635e11e1d99d429d3d323329675a6022312aac2/websockets-16.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:08d7af67b64d29823fed316505a89b86705f2b7981c07848fb5e3ea3020c1abe", size = 184631, upload-time = "2026-01-10T09:22:38.789Z" },
+ { url = "https://files.pythonhosted.org/packages/c8/c5/27178df583b6c5b31b29f526ba2da5e2f864ecc79c99dae630a85d68c304/websockets-16.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7be95cfb0a4dae143eaed2bcba8ac23f4892d8971311f1b06f3c6b78952ee70b", size = 185870, upload-time = "2026-01-10T09:22:39.893Z" },
+ { url = "https://files.pythonhosted.org/packages/87/05/536652aa84ddc1c018dbb7e2c4cbcd0db884580bf8e95aece7593fde526f/websockets-16.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d6297ce39ce5c2e6feb13c1a996a2ded3b6832155fcfc920265c76f24c7cceb5", size = 185361, upload-time = "2026-01-10T09:22:41.016Z" },
+ { url = "https://files.pythonhosted.org/packages/6d/e2/d5332c90da12b1e01f06fb1b85c50cfc489783076547415bf9f0a659ec19/websockets-16.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:1c1b30e4f497b0b354057f3467f56244c603a79c0d1dafce1d16c283c25f6e64", size = 184615, upload-time = "2026-01-10T09:22:42.442Z" },
+ { url = "https://files.pythonhosted.org/packages/77/fb/d3f9576691cae9253b51555f841bc6600bf0a983a461c79500ace5a5b364/websockets-16.0-cp311-cp311-win32.whl", hash = "sha256:5f451484aeb5cafee1ccf789b1b66f535409d038c56966d6101740c1614b86c6", size = 178246, upload-time = "2026-01-10T09:22:43.654Z" },
+ { url = "https://files.pythonhosted.org/packages/54/67/eaff76b3dbaf18dcddabc3b8c1dba50b483761cccff67793897945b37408/websockets-16.0-cp311-cp311-win_amd64.whl", hash = "sha256:8d7f0659570eefb578dacde98e24fb60af35350193e4f56e11190787bee77dac", size = 178684, upload-time = "2026-01-10T09:22:44.941Z" },
+ { url = "https://files.pythonhosted.org/packages/84/7b/bac442e6b96c9d25092695578dda82403c77936104b5682307bd4deb1ad4/websockets-16.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:71c989cbf3254fbd5e84d3bff31e4da39c43f884e64f2551d14bb3c186230f00", size = 177365, upload-time = "2026-01-10T09:22:46.787Z" },
+ { url = "https://files.pythonhosted.org/packages/b0/fe/136ccece61bd690d9c1f715baaeefd953bb2360134de73519d5df19d29ca/websockets-16.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:8b6e209ffee39ff1b6d0fa7bfef6de950c60dfb91b8fcead17da4ee539121a79", size = 175038, upload-time = "2026-01-10T09:22:47.999Z" },
+ { url = "https://files.pythonhosted.org/packages/40/1e/9771421ac2286eaab95b8575b0cb701ae3663abf8b5e1f64f1fd90d0a673/websockets-16.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:86890e837d61574c92a97496d590968b23c2ef0aeb8a9bc9421d174cd378ae39", size = 175328, upload-time = "2026-01-10T09:22:49.809Z" },
+ { url = "https://files.pythonhosted.org/packages/18/29/71729b4671f21e1eaa5d6573031ab810ad2936c8175f03f97f3ff164c802/websockets-16.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9b5aca38b67492ef518a8ab76851862488a478602229112c4b0d58d63a7a4d5c", size = 184915, upload-time = "2026-01-10T09:22:51.071Z" },
+ { url = "https://files.pythonhosted.org/packages/97/bb/21c36b7dbbafc85d2d480cd65df02a1dc93bf76d97147605a8e27ff9409d/websockets-16.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e0334872c0a37b606418ac52f6ab9cfd17317ac26365f7f65e203e2d0d0d359f", size = 186152, upload-time = "2026-01-10T09:22:52.224Z" },
+ { url = "https://files.pythonhosted.org/packages/4a/34/9bf8df0c0cf88fa7bfe36678dc7b02970c9a7d5e065a3099292db87b1be2/websockets-16.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a0b31e0b424cc6b5a04b8838bbaec1688834b2383256688cf47eb97412531da1", size = 185583, upload-time = "2026-01-10T09:22:53.443Z" },
+ { url = "https://files.pythonhosted.org/packages/47/88/4dd516068e1a3d6ab3c7c183288404cd424a9a02d585efbac226cb61ff2d/websockets-16.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:485c49116d0af10ac698623c513c1cc01c9446c058a4e61e3bf6c19dff7335a2", size = 184880, upload-time = "2026-01-10T09:22:55.033Z" },
+ { url = "https://files.pythonhosted.org/packages/91/d6/7d4553ad4bf1c0421e1ebd4b18de5d9098383b5caa1d937b63df8d04b565/websockets-16.0-cp312-cp312-win32.whl", hash = "sha256:eaded469f5e5b7294e2bdca0ab06becb6756ea86894a47806456089298813c89", size = 178261, upload-time = "2026-01-10T09:22:56.251Z" },
+ { url = "https://files.pythonhosted.org/packages/c3/f0/f3a17365441ed1c27f850a80b2bc680a0fa9505d733fe152fdf5e98c1c0b/websockets-16.0-cp312-cp312-win_amd64.whl", hash = "sha256:5569417dc80977fc8c2d43a86f78e0a5a22fee17565d78621b6bb264a115d4ea", size = 178693, upload-time = "2026-01-10T09:22:57.478Z" },
+ { url = "https://files.pythonhosted.org/packages/72/07/c98a68571dcf256e74f1f816b8cc5eae6eb2d3d5cfa44d37f801619d9166/websockets-16.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:349f83cd6c9a415428ee1005cadb5c2c56f4389bc06a9af16103c3bc3dcc8b7d", size = 174947, upload-time = "2026-01-10T09:23:36.166Z" },
+ { url = "https://files.pythonhosted.org/packages/7e/52/93e166a81e0305b33fe416338be92ae863563fe7bce446b0f687b9df5aea/websockets-16.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:4a1aba3340a8dca8db6eb5a7986157f52eb9e436b74813764241981ca4888f03", size = 175260, upload-time = "2026-01-10T09:23:37.409Z" },
+ { url = "https://files.pythonhosted.org/packages/56/0c/2dbf513bafd24889d33de2ff0368190a0e69f37bcfa19009ef819fe4d507/websockets-16.0-pp311-pypy311_pp73-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f4a32d1bd841d4bcbffdcb3d2ce50c09c3909fbead375ab28d0181af89fd04da", size = 176071, upload-time = "2026-01-10T09:23:39.158Z" },
+ { url = "https://files.pythonhosted.org/packages/a5/8f/aea9c71cc92bf9b6cc0f7f70df8f0b420636b6c96ef4feee1e16f80f75dd/websockets-16.0-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0298d07ee155e2e9fda5be8a9042200dd2e3bb0b8a38482156576f863a9d457c", size = 176968, upload-time = "2026-01-10T09:23:41.031Z" },
+ { url = "https://files.pythonhosted.org/packages/9a/3f/f70e03f40ffc9a30d817eef7da1be72ee4956ba8d7255c399a01b135902a/websockets-16.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:a653aea902e0324b52f1613332ddf50b00c06fdaf7e92624fbf8c77c78fa5767", size = 178735, upload-time = "2026-01-10T09:23:42.259Z" },
+ { url = "https://files.pythonhosted.org/packages/6f/28/258ebab549c2bf3e64d2b0217b973467394a9cea8c42f70418ca2c5d0d2e/websockets-16.0-py3-none-any.whl", hash = "sha256:1637db62fad1dc833276dded54215f2c7fa46912301a24bd94d45d46a011ceec", size = 171598, upload-time = "2026-01-10T09:23:45.395Z" },
+]
+
[[package]]
name = "werkzeug"
version = "3.1.7"
@@ -5780,16 +6203,16 @@ wheels = [
[[package]]
name = "workos"
-version = "6.0.4"
+version = "6.0.8"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "cryptography" },
{ name = "httpx" },
- { name = "pyjwt" },
+ { name = "pyjwt", extra = ["crypto"] },
]
-sdist = { url = "https://files.pythonhosted.org/packages/3c/2f/99fb8718274116c5c146c745755620fd5c5943f78ca52ca9b17e94348286/workos-6.0.4.tar.gz", hash = "sha256:b0bfe8fd212b8567422c4ea3732eb33608794033eb3a69900c6b04db183c32d6", size = 172217, upload-time = "2026-04-16T03:09:28.583Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/ca/0d/0a7f78912657f99412c788932ea1f3f4089916e77bdef7d2463842febe08/workos-6.0.8.tar.gz", hash = "sha256:43aa3f1992a0a4ca8933d9b6e5ada846dd3b1fe0ee10e64c876ee2000fc6090d", size = 178137, upload-time = "2026-04-24T18:48:03.203Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/fa/f1/d2ab661e6dc2828a4c73e38f12630c3b109cfe2bc664ab70631c04f0db4b/workos-6.0.4-py3-none-any.whl", hash = "sha256:548668b3702673536f853ba72a7b5bbbc269e467aaf9ac4f477b6e0177df5e21", size = 511418, upload-time = "2026-04-16T03:09:27.098Z" },
+ { url = "https://files.pythonhosted.org/packages/b2/3f/3d96da80d650b2f97d58af626053354584f619dbb769051e118bd9cd1ca5/workos-6.0.8-py3-none-any.whl", hash = "sha256:a00dd4930333aded2babbba824f8032eea05c5ca8c44d04a3fa068cf6be6e21a", size = 524505, upload-time = "2026-04-24T18:48:01.389Z" },
]
[[package]]
diff --git a/claude_plugins/prowler/.claude-plugin/plugin.json b/claude_plugins/prowler/.claude-plugin/plugin.json
new file mode 100644
index 0000000000..c77187c3b0
--- /dev/null
+++ b/claude_plugins/prowler/.claude-plugin/plugin.json
@@ -0,0 +1,30 @@
+{
+ "$schema": "https://json.schemastore.org/claude-code-plugin-manifest.json",
+ "name": "prowler",
+ "version": "0.1.0",
+ "description": "Prowler for Claude Code — cloud security and compliance skills powered by the Prowler MCP server. Bundles compliance triage and remediation; more skills coming.",
+ "author": {
+ "name": "Prowler",
+ "email": "support@prowler.com",
+ "url": "https://prowler.com"
+ },
+ "homepage": "https://docs.prowler.com",
+ "repository": "https://github.com/prowler-cloud/prowler",
+ "license": "Apache-2.0",
+ "keywords": [
+ "prowler",
+ "security",
+ "compliance",
+ "cloud-security",
+ "mcp"
+ ],
+ "userConfig": {
+ "api_key": {
+ "type": "string",
+ "title": "Prowler API key",
+ "description": "API key token used to authenticate with Prowler (Prowler Cloud, Prowler Private Cloud, or Prowler Local Server) via the Prowler MCP server. Create one at https://cloud.prowler.com.",
+ "sensitive": true,
+ "required": true
+ }
+ }
+}
diff --git a/claude_plugins/prowler/.mcp.json b/claude_plugins/prowler/.mcp.json
new file mode 100644
index 0000000000..9788b6d982
--- /dev/null
+++ b/claude_plugins/prowler/.mcp.json
@@ -0,0 +1,9 @@
+{
+ "prowler": {
+ "type": "http",
+ "url": "https://mcp.prowler.com/mcp",
+ "headers": {
+ "Authorization": "Bearer ${user_config.api_key}"
+ }
+ }
+}
diff --git a/claude_plugins/prowler/README.md b/claude_plugins/prowler/README.md
new file mode 100644
index 0000000000..6e204fbe44
--- /dev/null
+++ b/claude_plugins/prowler/README.md
@@ -0,0 +1,80 @@
+# Prowler for Claude Code
+
+End-to-end cloud security and compliance from inside [Claude Code](https://www.claude.com/product/claude-code), powered by the [Prowler MCP server](https://docs.prowler.com/projects/prowler-mcp/). The plugin lets Claude walk a Prowler Cloud-connected account through a compliance assessment and remediate findings until the chosen security or industry framework is compliant.
+
+> **Preview**: this plugin is under active development. Report issues at or join the [Slack community](https://goto.prowler.com/slack).
+
+## Requirements
+
+- [Claude Code](https://www.claude.com/product/claude-code) installed and signed in.
+- A [Prowler Cloud](https://cloud.prowler.com) account (the free tier is enough to start).
+- A Prowler API key — create one at .
+
+## Installation
+
+Inside a Claude Code session:
+
+```text
+/plugin marketplace add prowler-cloud/prowler
+/plugin install prowler@prowler-plugins
+```
+
+Or, if you already have the repo checked out locally:
+
+```text
+/plugin marketplace add /absolute/path/to/prowler
+/plugin install prowler@prowler-plugins
+```
+
+## Configuration
+
+On first install, Claude Code prompts for your **Prowler API key**. It is stored securely (macOS keychain or `~/.claude/.credentials.json`) and used to authenticate against Prowler Cloud.
+
+To rotate the key, uninstall and reinstall the plugin — Claude Code will prompt again.
+
+## Verify the install
+
+In a Claude Code session:
+
+```text
+/mcp → "prowler" appears as a connected server
+/plugin → "prowler" enabled, skill listed as prowler:framework-compliance-triage
+```
+
+If `/mcp` reports the `prowler` server as failed, the most common cause is a rejected API key — re-issue one in Prowler Cloud and reinstall the plugin so it re-prompts.
+
+## Usage
+
+Open a conversation that mentions the framework you want to comply with. Examples:
+
+- *"Make my AWS production account compliant with CIS 4.0."*
+- *"Make my current Terraform project compliant with the Prowler ThreatScore Compliance Framework based on the latest scan results."*
+- *"Help me get to 100% on PCI-DSS for this GCP project."*
+
+You pick a **primary tool** (Terraform, gh / az / aws CLI, web console, or mixed) and a **mode**:
+
+- **Claude-assisted** (default). Claude shows each fix — target resource, exact commands, side effects, reversibility — and waits for your go-ahead before applying.
+- **Claude autonomous**. Claude presents a single up-front plan grouped by shared fixes, waits for one confirmation, then proceeds. It pauses mid-loop if a fix has wide blast radius or a finding is not applicable.
+
+Claude tracks progress in a markdown report under `.prowler/` at your project root — one file per framework × account. Open it any time to see exactly where the flow is. When all findings are addressed, Claude proposes a fresh Prowler scan to verify everything end-to-end.
+
+## Uninstalling
+
+```text
+/plugin uninstall prowler@prowler-plugins
+/plugin marketplace remove prowler-plugins
+```
+
+The stored API key is removed automatically.
+
+## Troubleshooting
+
+| Symptom | Likely cause | Fix |
+|---|---|---|
+| `/mcp` shows `prowler` as failed | Rejected API key | Generate a new one in Prowler Cloud and reinstall the plugin to re-prompt. |
+| Skill not invoked when expected | The skill description didn't match the prompt | Mention the framework name plus "compliance" or "compliant" in your prompt. |
+| "Framework not supported" | Prowler Hub does not list the framework for that provider | Open an issue or PR at . |
+
+## License
+
+Apache 2.0 — see [LICENSE](../../LICENSE).
diff --git a/claude_plugins/prowler/skills/framework-compliance-triage/SKILL.md b/claude_plugins/prowler/skills/framework-compliance-triage/SKILL.md
new file mode 100644
index 0000000000..f8a9ded0c6
--- /dev/null
+++ b/claude_plugins/prowler/skills/framework-compliance-triage/SKILL.md
@@ -0,0 +1,199 @@
+---
+name: framework-compliance-triage
+description: Make a cloud account compliant with a security or industry framework using Prowler Cloud.
+---
+
+# Framework compliance
+
+Iterative, interactive flow that takes a cloud account through setup, reporting, and remediation until it complies with the chosen security or industry framework.
+
+## Checkpoints
+
+This skill uses **checkpoints** to mark moments where you must stop, post a clear question or summary to the user, and wait for the reply before continuing. Each checkpoint is rendered like this:
+
+> **Checkpoint — **
+>
+> What to present, and what to wait for.
+
+Treat every checkpoint as a hard stop:
+
+- Do not skip a checkpoint because the user previously said "go ahead", "just do it", or similar. Confirmations are scoped to a single checkpoint and do not transfer to later ones.
+- Do not bundle two checkpoints into one message. Post one, wait for the reply, then continue.
+- Do not infer the user's answer from context or proceed on silence. Ask explicitly and wait.
+- If a checkpoint is conditional (e.g. only fires when multiple accounts exist), evaluate the condition first; if it does not apply, continue without prompting.
+- If the user's initial message already answers the question a checkpoint asks (e.g. "make my AWS subscription compliant with CIS using Terraform autonomously"), treat the checkpoint as satisfied for the parts they covered, and only ask for what is still missing.
+
+## 1. Initial Prowler Cloud setup
+
+> **Checkpoint — Provider and framework selection**
+>
+> If the user has not already specified both the provider and the framework, ask explicitly and wait for the answer. If they have specified them in their opening message, skip this checkpoint.
+
+Confirm both are supported by the Prowler Hub MCP:
+
+- Enumerate supported providers with `prowler_hub_list_providers`.
+- Enumerate frameworks for the chosen provider with `prowler_hub_list_compliances`, passing the provider `id` as the only element of the `provider` input list.
+
+If the framework is not supported, tell the user, suggest they request it or contribute it themselves, and end the flow. Otherwise continue.
+
+### 1.1 Connect to Prowler Cloud
+
+Verify the Prowler MCP connection by calling `prowler_search_providers` — a successful response returns the list of providers. If the call fails, walk the user through troubleshooting: internet connectivity, Prowler Cloud credentials, and permissions on the Prowler Cloud account.
+For getting accurate information about configurations use `prowler_docs_search` to pull relevant instructions from the Prowler documentation.
+
+### 1.2 Verify the provider is configured (or configure it)
+
+Call `prowler_search_providers` to check whether the target provider (AWS account, Azure Subscription, GitHub Account...) exists in the user's Prowler Cloud account. Handle the result based on what's found:
+
+- **Provider not present.** Guide the user through adding and configuring it. Retrieve the relevant connection, credential, and permission instructions with `prowler_docs_search`.
+- **Provider present but misconfigured** (missing credentials, insufficient permissions, etc.). Walk the user through fixing the configuration, pulling the relevant guidance with `prowler_docs_search`.
+- **Provider present and configured.** Continue.
+
+> **Checkpoint — Account selection** *(conditional: more than one account of the chosen provider is configured)*
+>
+> List the accounts with helpful detail (account name, uid, last scan date) and ask which one to use. Wait for the answer. If only one account exists, skip this checkpoint and use it.
+
+### 1.3 Review compliance report for the provider account
+
+The flow needs at least one completed scan with a compliance report available.
+
+Look for a completed scan first: call `prowler_list_scans` with the selected `provider_id` and `state: ["completed"]`, then call `prowler_get_compliance_overview` with each `scan_id` to find one whose compliance report is available. If one is found, continue to the next section.
+
+If no completed scan has a report, call `prowler_list_scans` again with `state: ["available", "executing"]` to detect a scan in progress.
+
+> **Checkpoint — Scan-in-progress decision** *(conditional: an in-progress scan was detected)*
+>
+> Tell the user a scan is already running and ask whether to wait for it to complete or start a fresh one. Wait for the answer.
+
+If no scan is running (or the user chose to start a fresh one), trigger a new scan with `prowler_trigger_scan` and the `provider_id`. The link `https://cloud.prowler.com/scans?filter%5Bprovider_uid__in%5D={provider_id}` lets the user monitor progress.
+
+When a scan is in progress (either pre-existing and elected to wait, or just triggered), stop the flow and ask the user to return when it's completed — restart this section to re-check the results.
+
+## 2. Compliance report
+
+Every iteration of the remediation loop reads and writes a single markdown file per provider account and framework, stored at `${CLAUDE_PROJECT_DIR}/.prowler/compliance--.md`. Sanitize `` to `[a-zA-Z0-9_-]` by replacing anything else with `-`. Create `.prowler/` if missing.
+
+Across iterations, edit only: status tags on failed requirements and their findings, the per-requirement `Fix plan` / `Fix applied` sub-bullets added during sections 3.3–3.4, the **Global remediation approach** block, and the **Activity log** (append-only, newest on top). Requirement descriptions, finding IDs, and the entire **Manual review requirements** section are read-only after first render.
+
+Status taxonomy for failed requirements and their findings:
+
+- `[FAIL]` — failing in the latest scan.
+- `[IN PROGRESS]` — picked up by section 3.3.
+- `[FIXED-UNVERIFIED]` — remediation applied; not yet confirmed.
+- `[PASS]` — passing in the latest scan (set when a rescan in section 3.5 confirms the fix).
+- `[SKIPPED]` — user explicitly deferred.
+
+### Report template
+
+A fresh report is rendered like this (substituting values from the `prowler_get_compliance_framework_state_details` Prowler MCP tool response):
+
+````markdown
+# Compliance report:
+
+**Provider account**:
+**Scan ID**:
+**Generated**:
+**Last update**:
+**Status**: / passing (%) · failing · manual review
+
+## Global remediation approach
+
+- **Primary tool**: _Terraform | Azure CLI | AWS CLI | web console | mixed_
+- **Mode**: _Claude autonomous | Claude-assisted_
+- **Notes**:
+
+## Activity log
+- — Report initialized from scan ``.
+
+## Failed requirements
+
+### — [FAIL]
+**Description**:
+**Findings** ():
+- [FAIL] ``
+
+## Manual review requirements
+- **** — [PENDING]:
+````
+
+### 2.1 Generate or refresh the report
+
+Resolve the report path for the current `compliance_id` and provider account.
+
+If the file does not exist, call `prowler_get_compliance_framework_state_details` for the target scan, render the template above, and write the file with one initialization entry in the activity log.
+
+If the file exists, read it and compare its `Scan ID` to the target scan from section 1.3. When the scan matches, reuse the file and summarize remaining `[FAIL]` and `[IN PROGRESS]` items in chat.
+
+> **Checkpoint — Report refresh** *(conditional: the file's `Scan ID` differs from the current target scan)*
+>
+> Tell the user the report on disk was generated from a different scan and ask whether to refresh it from the new scan. Wait for the answer.
+
+On confirmation, regenerate the failed-requirements section from the new `prowler_get_compliance_framework_state_details` response, carry forward the **Global remediation approach** block and the full activity log, and append an activity-log entry noting the scan change.
+
+Once the file is current, surface the top failing requirements in chat: sort by finding count descending, show the top 5 with their codes and counts, and point to the file path for the full list.
+
+## 3. Remediation loop
+
+### 3.1 Define the global remediation approach
+
+Two modes are available:
+
+- **Claude-assisted** (default when the user has not specified): per-requirement confirmation. For each requirement Claude shows the target resource, exact commands, side effects, and reversibility, then waits for explicit go-ahead before applying.
+- **Claude autonomous**: no per-requirement gate, but Claude still presents one batch-level fix plan up front (§3.2) and waits for a single confirmation, and pauses if a finding looks not applicable, requires a paid feature, or has wide blast radius (breaks dev workflow, forces collaborator changes, is hard to reverse).
+
+If the user phrases their request as "just do it" or similar, treat that as autonomous **with** the batch-plan confirmation still required — the confirmation is a property of the skill, not the user's verbosity preference.
+
+> **Checkpoint — Global remediation approach**
+>
+> Ask the user which tool to use for fixes (Terraform, gh / az / aws CLI, web console, mixed...) and which mode to operate in. Wait for the answer before continuing. This checkpoint is non-negotiable: never assume a default tool, and never assume autonomous mode.
+
+Once answered, write the values into the **Global remediation approach** block of the report file.
+
+> **Checkpoint — Overwriting an existing approach** *(conditional: the block is already populated from a previous session)*
+>
+> Show the previous values and the new ones, and ask the user to confirm before overwriting. Wait for the answer.
+
+### 3.2 Present the batch fix plan *(autonomous mode only)*
+
+In **assisted** mode, skip this section — the per-requirement gate in §3.3 confirms each fix as it comes up. Only run §3.2 in **autonomous** mode, where the loop will otherwise apply fixes without further input.
+
+Before touching anything, post a single chat summary covering every `[FAIL]` requirement:
+
+- Group findings that share a fix (e.g. ten branch-protection requirements satisfied by one PUT call → present as one group).
+- For each group: target resource, exact tool calls, side effects, reversibility.
+- Call out findings that look **not applicable** to this target (e.g. an Organization-only check evaluated against a User account, a feature gated by a paid plan, a resource type the user doesn't have) and propose `[SKIPPED]` with the reason.
+- Call out findings that require manual user action Claude cannot perform.
+
+> **Checkpoint — Batch fix plan approval** *(conditional: autonomous mode)*
+>
+> Post the grouped plan and wait for explicit confirmation. Do not start any fix before the user replies.
+
+Once approved, the loop proceeds through the batch without further prompts unless something deviates from the approved plan.
+
+### 3.3 Pick the first FAIL requirement and inspect its findings
+
+Pick the first `[FAIL]` requirement at the top of the failed-requirements section. Move its status and every finding under it to `[IN PROGRESS]`, and add a `**Fix plan**:` sub-bullet describing what will be done.
+
+Call `prowler_get_finding_details` for each `finding_id` to retrieve the failing resource and the Prowler Hub's remediation guidance for that check using the tool `prowler_hub_get_check_details` with the `check_id` from the finding details. Summarize the guidance in chat, and append it to the `**Fix plan**` note for each finding.
+
+If a finding does not apply to the target resource (Organization-only check on a User account, paid-tier feature, missing resource type, etc.), set the requirement status to `[SKIPPED]` with the reason, log it in the activity log, and move on without attempting the fix — even if it was missed during §3.2.
+
+> **Checkpoint — Per-requirement approval** *(conditional: assisted mode)*
+>
+> Post the per-requirement plan in chat — resource, command, side effects, reversibility — and wait for confirmation before moving to §3.4. In **autonomous** mode, post the plan for transparency but proceed unless it deviates from the batch plan agreed in §3.2.
+
+### 3.4 Diagnose, fix, verify
+
+Read the remediation guidance returned in §3.3, identify the root cause, and apply the fix using the tool defined in the **Global remediation approach** block. After applying, verify via the same tool that applied the fix or via a provider API call when applicable. If the re-read shows the change did not land, leave the status at `[IN PROGRESS]`, surface the error to the user, and stop the loop for this requirement.
+
+When the change is in place, append a `**Fix applied**: ` sub-bullet to the requirement, move each fixed finding to `[FIXED-UNVERIFIED]`, and add one activity-log entry describing the change. If no programmatic verification was possible (e.g. web console action), note in the activity log that confirmation depends on the rescan in §3.5.
+
+### 3.5 Loop
+
+Move to the next `[FAIL]` requirement and repeat from section 3.3.
+
+> **Checkpoint — Rescan trigger** *(conditional: no `[FAIL]` requirements remain; all are `[FIXED-UNVERIFIED]` or `[SKIPPED]`)*
+>
+> Summarize what was applied, list any `[SKIPPED]` items with reasons, and ask whether to trigger a fresh scan with `prowler_trigger_scan` to verify the fixes end-to-end. Wait for the answer.
+
+On confirmation, trigger the rescan. When it completes, restart section 2.1 with the carry-forward path — requirements no longer in the new FAIL list move to `[PASS]`, anything still failing reverts to `[FAIL]` with the previous fix attempt visible in the activity log.
diff --git a/contrib/aws/multi-account-securityhub/Dockerfile b/contrib/aws/multi-account-securityhub/Dockerfile
index 1cc6c326b7..7e5e7f5182 100644
--- a/contrib/aws/multi-account-securityhub/Dockerfile
+++ b/contrib/aws/multi-account-securityhub/Dockerfile
@@ -1,7 +1,7 @@
# Build command
# docker build --platform=linux/amd64 --no-cache -t prowler:latest .
-ARG PROWLER_VERSION=latest
+ARG PROWLER_VERSION=latest@sha256:ebb4ab999f10cb7e7c256226c2873de9b3bf2f3d855f385e0164bcf34104bfba
FROM toniblyx/prowler:${PROWLER_VERSION}
diff --git a/contrib/inventory-graph/lib/inventory_output.py b/contrib/inventory-graph/lib/inventory_output.py
index 359e7eb91f..459dc37cdb 100644
--- a/contrib/inventory-graph/lib/inventory_output.py
+++ b/contrib/inventory-graph/lib/inventory_output.py
@@ -16,7 +16,6 @@ from typing import Optional
from prowler.lib.logger import logger
from lib.models import ConnectivityGraph
-
# ---------------------------------------------------------------------------
# JSON output
# ---------------------------------------------------------------------------
diff --git a/contrib/k8s/helm/prowler-api/values.yaml b/contrib/k8s/helm/prowler-api/values.yaml
index 81eda5f690..a6074c7852 100644
--- a/contrib/k8s/helm/prowler-api/values.yaml
+++ b/contrib/k8s/helm/prowler-api/values.yaml
@@ -28,7 +28,7 @@ containers:
image:
repository: prowlercloud/prowler-api
pullPolicy: IfNotPresent
- command: ["../docker-entrypoint.sh", "beat"]
+ command: ["/home/prowler/docker-entrypoint.sh", "beat"]
secrets:
POSTGRES_HOST:
@@ -438,6 +438,34 @@ mainConfig:
# Minimum number of Availability Zones that an ELBv2 must be in
elbv2_min_azs: 2
+ # AWS Post-Quantum TLS Configuration
+ # aws.acmpca_certificate_authority_pqc_key_algorithm
+ acmpca_pqc_key_algorithms:
+ - "ML_DSA_44"
+ - "ML_DSA_65"
+ - "ML_DSA_87"
+ # aws.cloudfront_distributions_pqc_tls_enabled
+ cloudfront_pqc_min_protocol_versions:
+ - "TLSv1.3_2025"
+ # aws.apigateway_domain_name_pqc_tls_enabled
+ apigateway_pqc_tls_allowed_policies:
+ - "SecurityPolicy_TLS13_1_2_FIPS_PFS_PQ_2025_09"
+ - "SecurityPolicy_TLS13_1_2_PFS_PQ_2025_09"
+ - "SecurityPolicy_TLS13_1_2_PQ_2025_09"
+
+ # AWS Post-Quantum SSH Key Exchange Configuration
+ # aws.transfer_server_pqc_ssh_kex_enabled
+ transfer_pqc_ssh_allowed_policies:
+ - "TransferSecurityPolicy-2025-03"
+ - "TransferSecurityPolicy-FIPS-2025-03"
+ - "TransferSecurityPolicy-AS2Restricted-2025-07"
+
+
+ # aws.rolesanywhere_trust_anchor_pqc_pki
+ rolesanywhere_pqc_pca_key_algorithms:
+ - "ML_DSA_44"
+ - "ML_DSA_65"
+ - "ML_DSA_87"
# AWS Secrets Configuration
# Patterns to ignore in the secrets checks
diff --git a/contrib/k8s/helm/prowler-app/templates/api/configmap.yaml b/contrib/k8s/helm/prowler-app/templates/api/configmap.yaml
index 8e219a9271..a76982d403 100644
--- a/contrib/k8s/helm/prowler-app/templates/api/configmap.yaml
+++ b/contrib/k8s/helm/prowler-app/templates/api/configmap.yaml
@@ -7,4 +7,4 @@ metadata:
data:
{{- range $key, $value := .Values.api.djangoConfig }}
{{ $key }}: {{ $value | quote }}
- {{- end }}
\ No newline at end of file
+ {{- end }}
diff --git a/contrib/k8s/helm/prowler-app/templates/api/role.yaml b/contrib/k8s/helm/prowler-app/templates/api/role.yaml
index 172b035076..f55d3c7dc9 100644
--- a/contrib/k8s/helm/prowler-app/templates/api/role.yaml
+++ b/contrib/k8s/helm/prowler-app/templates/api/role.yaml
@@ -26,4 +26,4 @@ roleRef:
subjects:
- kind: ServiceAccount
name: {{ include "prowler.api.serviceAccountName" . }}
- namespace: {{ .Release.Namespace }}
\ No newline at end of file
+ namespace: {{ .Release.Namespace }}
diff --git a/contrib/k8s/helm/prowler-app/templates/ui/configmap.yaml b/contrib/k8s/helm/prowler-app/templates/ui/configmap.yaml
index 38d6e65ee3..856770722a 100644
--- a/contrib/k8s/helm/prowler-app/templates/ui/configmap.yaml
+++ b/contrib/k8s/helm/prowler-app/templates/ui/configmap.yaml
@@ -11,8 +11,7 @@ data:
{{- else }}
AUTH_URL: {{ .Values.ui.authUrl | quote }}
{{- end }}
- API_BASE_URL: "http://{{ include "prowler.fullname" . }}-api:{{ .Values.api.service.port }}/api/v1"
- NEXT_PUBLIC_API_BASE_URL: "http://{{ include "prowler.fullname" . }}-api:{{ .Values.api.service.port }}/api/v1"
- NEXT_PUBLIC_API_DOCS_URL: "http://{{ include "prowler.fullname" . }}-api:{{ .Values.api.service.port }}/api/v1/docs"
+ UI_API_BASE_URL: "http://{{ include "prowler.fullname" . }}-api:{{ .Values.api.service.port }}/api/v1"
+ UI_API_DOCS_URL: "http://{{ include "prowler.fullname" . }}-api:{{ .Values.api.service.port }}/api/v1/docs"
AUTH_TRUST_HOST: "true"
UI_PORT: {{ .Values.ui.service.port | quote }}
diff --git a/contrib/k8s/helm/prowler-app/values.yaml b/contrib/k8s/helm/prowler-app/values.yaml
index 9acca09f2a..ed390af29e 100644
--- a/contrib/k8s/helm/prowler-app/values.yaml
+++ b/contrib/k8s/helm/prowler-app/values.yaml
@@ -440,7 +440,7 @@ worker_beat:
tag: ""
command:
- - ../docker-entrypoint.sh
+ - /home/prowler/docker-entrypoint.sh
args:
- beat
diff --git a/contrib/k8s/helm/prowler-ui/values.yaml b/contrib/k8s/helm/prowler-ui/values.yaml
index d4f2dbe137..aca0178b3a 100644
--- a/contrib/k8s/helm/prowler-ui/values.yaml
+++ b/contrib/k8s/helm/prowler-ui/values.yaml
@@ -21,8 +21,8 @@ fullnameOverride: ""
secrets:
SITE_URL: http://localhost:3000
- API_BASE_URL: http://prowler-api:8080/api/v1
- NEXT_PUBLIC_API_DOCS_URL: http://prowler-api:8080/api/v1/docs
+ UI_API_BASE_URL: http://prowler-api:8080/api/v1
+ UI_API_DOCS_URL: http://prowler-api:8080/api/v1/docs
AUTH_TRUST_HOST: True
UI_PORT: 3000
# openssl rand -base64 32
diff --git a/contrib/reverse-proxy/docker-compose.reverse-proxy.yml b/contrib/reverse-proxy/docker-compose.reverse-proxy.yml
index 08c52f3558..cb6bd9c3a2 100644
--- a/contrib/reverse-proxy/docker-compose.reverse-proxy.yml
+++ b/contrib/reverse-proxy/docker-compose.reverse-proxy.yml
@@ -16,7 +16,7 @@
services:
nginx:
- image: nginx:alpine
+ image: nginx:alpine@sha256:54f2a904c251d5a34adf545a72d32515a15e08418dae0266e23be2e18c66fefa
container_name: prowler-nginx
restart: unless-stopped
ports:
diff --git a/dashboard/__main__.py b/dashboard/__main__.py
index 664ce3bfa5..6a36bda7ed 100644
--- a/dashboard/__main__.py
+++ b/dashboard/__main__.py
@@ -20,7 +20,7 @@ print_banner()
print(
f"{Fore.GREEN}Loading all CSV files from the folder {folder_path_overview} ...\n{Style.RESET_ALL}"
)
-cli.show_server_banner = lambda *x: click.echo(
+cli.show_server_banner = lambda *_: click.echo(
f"{Fore.YELLOW}NOTE:{Style.RESET_ALL} If you are using {Fore.GREEN}{Style.BRIGHT}Prowler Cloud{Style.RESET_ALL} with the S3 integration or that integration \nfrom {Fore.CYAN}{Style.BRIGHT}Prowler CLI{Style.RESET_ALL} and you want to use your data from your S3 bucket,\nrun: `{orange_color}aws s3 cp s3:///output/csv ./output --recursive{Style.RESET_ALL}`\nand then run `prowler dashboard` again to load the new files."
)
@@ -33,148 +33,187 @@ dashboard = dash.Dash(
title="Prowler Dashboard",
)
-# Logo
-prowler_logo = html.Img(
- src="https://cdn.prod.website-files.com/68c4ec3f9fb7b154fbcb6e36/68ffb46d40ed7faa37a592a5_prowler-logo.png",
- alt="Prowler Logo",
+# ``use_pages`` above already imported dashboard/pages/cloud.py and registered
+# every /cloud/* route. Import its metadata now (after app instantiation) so
+# the sidebar and the gated pages share a single source of truth.
+from dashboard.pages.cloud import CLOUD_FEATURES_BY_SLUG # noqa: E402
+
+ICON_DIR = "/assets/images/icons/cloud"
+
+# Official marketing "PROWLER / LOCAL DASHBOARD" lockup (white wordmark + teal
+# gradient sublabel) shown in the expanded sidebar. Vector SVG so it stays crisp
+# at any DPI. The sublabel is right-anchored (text-anchor="end"), so a font
+# fallback widens it leftward rather than clipping at the edge.
+prowler_lockup = html.Img(
+ src=f"{ICON_DIR}/prowler-lockup.svg",
+ alt="Prowler Local Dashboard",
+ className="pc-brand-lockup",
)
-menu_icons = {
- "overview": "/assets/images/icons/overview.svg",
- "compliance": "/assets/images/icons/compliance.svg",
-}
+# Compact brand mark shown only when the sidebar collapses to its icon rail.
+prowler_mark = html.Img(
+ src=f"{ICON_DIR}/prowler-mark.svg",
+ alt="Prowler",
+ className="pc-brand-mark",
+)
+
+# Locally functional destinations (Overview + Compliance).
+DASHBOARD_ITEMS = [
+ {"label": "Overview", "route": "/", "icon": f"{ICON_DIR}/overview.svg"},
+ {
+ "label": "Compliance",
+ "route": "/compliance",
+ "icon": f"{ICON_DIR}/compliance.svg",
+ },
+]
+
+# Gated navigation groups reference the shared feature metadata by slug so the
+# sidebar and the informational pages never drift apart.
+GATED_GROUPS = [
+ ("Upgrade to Prowler Cloud", ["lighthouse-ai", "attack-paths", "findings"]),
+ ("Configuration", ["alerts", "mutelist", "integrations"]),
+ ("Workspace", ["organization"]),
+]
+
+HELP_LINKS = [
+ {
+ "title": "Help",
+ "url": "https://github.com/prowler-cloud/prowler/issues",
+ "icon": f"{ICON_DIR}/help.svg",
+ },
+ {
+ "title": "Docs",
+ "url": "https://docs.prowler.com",
+ "icon": f"{ICON_DIR}/docs.svg",
+ },
+]
-# Function to generate navigation links
-def generate_nav_links(current_path):
- nav_links = []
- for page in dash.page_registry.values():
- # Gets the icon URL based on the page name
- icon_url = menu_icons.get(page["name"].lower())
- is_active = (
- " bg-prowler-stone-950 border-r-4 border-solid border-prowler-lime"
- if current_path == page["relative_path"]
- else ""
- )
- link_class = f"block hover:bg-prowler-stone-950 hover:border-r-4 hover:border-solid hover:border-prowler-lime{is_active}"
+def _mask_style(icon_url):
+ """Inline style rendering a recolorable mask icon from a local asset."""
+ return {
+ "WebkitMaskImage": f"url({icon_url})",
+ "maskImage": f"url({icon_url})",
+ }
- link_content = html.Span(
+
+def _nav_icon(icon_url):
+ return html.Span(className="pc-ico", style=_mask_style(icon_url))
+
+
+def _nav_item(label, route, icon_url, current_path, gated=False):
+ is_active = current_path == route
+ class_name = "pc-nav-item pc-active" if is_active else "pc-nav-item"
+
+ content = [
+ _nav_icon(icon_url),
+ html.Span(label, className="pc-nav-label"),
+ ]
+ if gated:
+ content.append(html.Span("Prowler Cloud", className="pc-pill"))
+
+ return dcc.Link(content, href=route, className=class_name)
+
+
+def _section_label(title):
+ return html.Div(title, className="pc-section")
+
+
+def generate_sidebar(current_path):
+ children = [
+ # Brand lockup: full wordmark when expanded, compact mark when collapsed.
+ html.Div(
+ [prowler_lockup, prowler_mark],
+ className="pc-brand",
+ ),
+ # Dashboards section — the only locally functional destinations.
+ _section_label("Dashboards"),
+ html.Nav(
[
- html.Img(src=icon_url, className="w-5"),
- html.Span(
- page["name"], className="font-medium text-base leading-6 text-white"
- ),
+ _nav_item(item["label"], item["route"], item["icon"], current_path)
+ for item in DASHBOARD_ITEMS
],
- className="flex justify-center lg:justify-normal items-center gap-x-3 py-2 px-3",
- )
-
- nav_link = html.Li(
- dcc.Link(link_content, href=page["relative_path"], className=link_class)
- )
- nav_links.append(nav_link)
- return nav_links
-
-
-def generate_help_menu():
- help_links = [
- {
- "title": "Help",
- "url": "https://github.com/prowler-cloud/prowler/issues",
- "icon": "/assets/images/icons/help.png",
- },
- {
- "title": "Docs",
- "url": "https://docs.prowler.com",
- "icon": "/assets/images/icons/docs.png",
- },
+ className="pc-nav",
+ ),
]
- link_class = "block hover:bg-prowler-stone-950 hover:border-r-4 hover:border-solid hover:border-prowler-lime"
-
- menu_items = []
- for link in help_links:
- menu_item = html.Li(
- html.A(
- html.Span(
- [
- html.Img(src=link["icon"], className="w-5"),
- html.Span(
- link["title"],
- className="font-medium text-base leading-6 text-white",
- ),
- ],
- className="flex items-center gap-x-3 py-2 px-3",
- ),
- href=link["url"],
- target="_blank",
- className=link_class,
+ # Gated groups (Prowler Cloud only).
+ for section_title, slugs in GATED_GROUPS:
+ children.append(_section_label(section_title))
+ children.append(
+ html.Nav(
+ [
+ _nav_item(
+ CLOUD_FEATURES_BY_SLUG[slug]["nav_label"],
+ CLOUD_FEATURES_BY_SLUG[slug]["route"],
+ CLOUD_FEATURES_BY_SLUG[slug]["icon"],
+ current_path,
+ gated=True,
+ )
+ for slug in slugs
+ ],
+ className="pc-nav",
)
)
- menu_items.append(menu_item)
- return menu_items
+ # Help and Docs pinned to the bottom, separated by a neutral top border.
+ children.append(
+ html.Nav(
+ [
+ html.A(
+ [
+ _nav_icon(link["icon"]),
+ html.Span(link["title"], className="pc-nav-label"),
+ ],
+ href=link["url"],
+ target="_blank",
+ rel="noopener noreferrer",
+ className="pc-nav-item",
+ )
+ for link in HELP_LINKS
+ ],
+ className="pc-nav pc-footer",
+ )
+ )
+
+ return html.Div(children, className="pc-sidebar pc-font")
# Layout
dashboard.layout = html.Div(
[
- dcc.Location(id="url", refresh=False),
html.Link(rel="icon", href="assets/favicon.ico"),
- # Placeholder for dynamic navigation bar
html.Div(
[
+ # Dynamic sidebar (rebuilt on navigation for active state).
+ html.Div(id="navigation-bar"),
+ # Main pane hosting the routed page content.
html.Div(
- id="navigation-bar", className="bg-prowler-stone-900 min-w-36 z-10"
- ),
- html.Div(
- [
+ html.Div(
dash.page_container,
- ],
+ className="pc-main-inner",
+ ),
id="content_select",
- className="bg-prowler-white w-full col-span-11 h-screen mx-auto overflow-y-scroll no-scrollbar px-10 py-7",
+ className="pc-main pc-font no-scrollbar",
),
],
- className="grid custom-grid 2xl:custom-grid-large h-screen",
+ className="pc-shell",
),
],
className="h-screen mx-auto",
)
-# Callback to update navigation bar
-@dashboard.callback(Output("navigation-bar", "children"), [Input("url", "pathname")])
+# Callback to update navigation bar.
+#
+# Triggered off Dash Pages' own location (``_pages_location``) rather than a
+# separate ``dcc.Location``. A standalone ``dcc.Location(id="url")`` stops
+# emitting ``pathname`` when navigating between two pages that render an
+# identical component tree — every ``/cloud/*`` gated page shares the same
+# ``build_cloud_layout`` structure — which left the active highlight stuck on
+# the first gated page visited. ``_pages_location`` fires on every route change.
+@dashboard.callback(
+ Output("navigation-bar", "children"), [Input("_pages_location", "pathname")]
+)
def update_nav_bar(pathname):
- return html.Div(
- [
- html.Div([prowler_logo], className="mb-8 px-3"),
- html.H6(
- "Dashboards",
- className="px-3 text-prowler-stone-500 text-sm opacity-90 font-regular mb-2",
- ),
- html.Nav(
- [html.Ul(generate_nav_links(pathname), className="")],
- className="flex flex-col gap-y-6",
- ),
- html.Nav(
- [
- html.A(
- [
- html.Span(
- [
- html.Img(src="assets/favicon.ico", className="w-5"),
- "Subscribe to Prowler Cloud",
- ],
- className="flex items-center gap-x-3 text-white",
- ),
- ],
- href="https://prowler.com/",
- target="_blank",
- className="block p-3 uppercase text-xs hover:bg-prowler-stone-950 hover:border-r-4 hover:border-solid hover:border-prowler-lime",
- ),
- html.Ul(generate_help_menu(), className=""),
- ],
- className="flex flex-col gap-y-6 mt-auto",
- ),
- ],
- className="flex flex-col bg-prowler-stone-900 py-7 h-full",
- )
+ return generate_sidebar(pathname)
diff --git a/dashboard/assets/cloud-pages.css b/dashboard/assets/cloud-pages.css
new file mode 100644
index 0000000000..53052eabda
--- /dev/null
+++ b/dashboard/assets/cloud-pages.css
@@ -0,0 +1,389 @@
+/*
+ * Prowler Local Dashboard — Cloud upsell chrome & gated pages.
+ * These styles are self-contained (not dependent on the precompiled Tailwind
+ * bundle) so pixel specs from the PRD render reliably without a rebuild.
+ */
+
+@import url("https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap");
+
+:root {
+ --pc-btn: #6ee7b7;
+ --pc-btn-hover: #99f6e4;
+ --pc-btn-press: #34d399;
+ --pc-grad-start: #2ee59b;
+ --pc-grad-end: #62dff0;
+ --pc-text: #020617;
+ --pc-text-2: #27272a;
+ --pc-sidebar-bg: #27272a;
+ --pc-pane-bg: #fdfdfd;
+ --pc-border: #e5e5e5;
+ --pc-teal-accent: #2ee59b;
+ --pc-sidebar-w: 264px;
+ --pc-sidebar-w-collapsed: 82px;
+}
+
+.pc-font {
+ font-family: "Inter", system-ui, -apple-system, "Segoe UI", Roboto,
+ Helvetica, Arial, sans-serif;
+}
+
+/* ----------------------------------------------------------------- Shell */
+
+.pc-shell {
+ display: flex;
+ height: 100vh;
+ width: 100%;
+ overflow: hidden;
+}
+
+.pc-main {
+ flex: 1 1 auto;
+ height: 100vh;
+ overflow-y: auto;
+ background: var(--pc-pane-bg);
+}
+
+.pc-main-inner {
+ padding: 28px 40px 64px;
+}
+
+/* --------------------------------------------------------------- Sidebar */
+
+.pc-sidebar {
+ flex: 0 0 var(--pc-sidebar-w);
+ width: var(--pc-sidebar-w);
+ background: var(--pc-sidebar-bg);
+ height: 100vh;
+ display: flex;
+ flex-direction: column;
+ padding: 22px 0 16px;
+ overflow-y: auto;
+ overflow-x: hidden;
+}
+
+.pc-sidebar::-webkit-scrollbar {
+ display: none;
+}
+
+.pc-brand {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ padding: 0 18px;
+ margin-bottom: 22px;
+}
+
+.pc-brand-lockup {
+ width: 190px;
+ height: auto;
+ display: block;
+}
+
+/* Compact mark is only revealed on the collapsed icon rail (see media query). */
+.pc-brand-mark {
+ width: 30px;
+ height: 30px;
+ flex: 0 0 auto;
+ display: none;
+}
+
+.pc-section {
+ color: #8a8a90;
+ font-size: 11px;
+ font-weight: 600;
+ letter-spacing: 0.07em;
+ text-transform: uppercase;
+ padding: 0 18px;
+ margin: 18px 0 8px;
+}
+
+.pc-nav {
+ display: flex;
+ flex-direction: column;
+ gap: 2px;
+}
+
+.pc-nav-item {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ padding: 9px 12px;
+ margin: 0 8px;
+ color: #ffffff;
+ font-size: 14px;
+ font-weight: 500;
+ text-decoration: none;
+ border-radius: 8px;
+ border-left: 3px solid transparent;
+ overflow: hidden;
+ transition: background 0.15s ease;
+}
+
+.pc-nav-item:hover {
+ background: rgba(255, 255, 255, 0.07);
+}
+
+.pc-nav-item.pc-active {
+ background: rgba(255, 255, 255, 0.09);
+ border-left-color: var(--pc-teal-accent);
+}
+
+.pc-nav-label {
+ flex: 0 1 auto;
+ min-width: 0;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+/* Icon rendered as a recolorable mask so one asset serves any color. */
+.pc-ico {
+ width: 20px;
+ height: 20px;
+ flex: 0 0 auto;
+ display: inline-block;
+ background-color: currentColor;
+ -webkit-mask-repeat: no-repeat;
+ mask-repeat: no-repeat;
+ -webkit-mask-position: center;
+ mask-position: center;
+ -webkit-mask-size: contain;
+ mask-size: contain;
+}
+
+.pc-pill {
+ flex: 0 0 auto;
+ margin-left: auto;
+ display: inline-flex;
+ align-items: center;
+ background: linear-gradient(
+ 112deg,
+ var(--pc-grad-start) 3.5%,
+ var(--pc-grad-end) 98.8%
+ );
+ color: var(--pc-text);
+ font-size: 10px;
+ font-weight: 700;
+ letter-spacing: 0.02em;
+ line-height: 1;
+ padding: 3px 8px;
+ border-radius: 9999px;
+ white-space: nowrap;
+}
+
+.pc-footer {
+ margin-top: auto;
+ padding-top: 12px;
+ border-top: 1px solid rgba(255, 255, 255, 0.1);
+}
+
+/* -------------------------------------------------------- Gated page body */
+
+.pc-page {
+ max-width: 1120px;
+ margin: 0 auto;
+}
+
+.pc-page-header {
+ border-bottom: 1px solid var(--pc-border);
+ padding-bottom: 18px;
+ margin-bottom: 28px;
+}
+
+.pc-page-title-row {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+}
+
+/* In the page header the badge sits right next to the title (not pushed to the
+ far right like the sidebar pills) and is uppercased for emphasis. */
+.pc-page-title-row .pc-pill {
+ margin-left: 0;
+ text-transform: uppercase;
+ letter-spacing: 0.04em;
+}
+
+.pc-page-title {
+ font-size: 24px;
+ font-weight: 700;
+ color: var(--pc-text);
+ margin: 0;
+}
+
+.pc-page-subtitle {
+ font-size: 15px;
+ line-height: 24px;
+ color: #52525b;
+ margin: 8px 0 0;
+}
+
+/* ----------------------------------------------------------- Upgrade card */
+
+.pc-card {
+ position: relative;
+ background: #ffffff;
+ border: 1px solid var(--pc-border);
+ border-radius: 18px;
+ padding: 56px 40px;
+ overflow: hidden;
+}
+
+.pc-card-glow {
+ position: absolute;
+ top: 0;
+ left: 50%;
+ transform: translateX(-50%);
+ width: 560px;
+ height: 240px;
+ background: radial-gradient(
+ ellipse at top,
+ rgba(46, 229, 155, 0.2),
+ rgba(98, 223, 240, 0.06) 45%,
+ transparent 72%
+ );
+ pointer-events: none;
+}
+
+.pc-card-body {
+ position: relative;
+ z-index: 1;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ text-align: center;
+}
+
+.pc-feature-icon {
+ width: 64px;
+ height: 64px;
+ border-radius: 16px;
+ background: linear-gradient(
+ 135deg,
+ rgba(46, 229, 155, 0.16),
+ rgba(98, 223, 240, 0.14)
+ );
+ border: 1px solid rgba(46, 229, 155, 0.3);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ color: var(--pc-text);
+ margin-bottom: 22px;
+}
+
+.pc-feature-icon .pc-ico {
+ width: 30px;
+ height: 30px;
+}
+
+.pc-avail {
+ color: #0e9f6e;
+ font-size: 12px;
+ font-weight: 700;
+ letter-spacing: 0.09em;
+ text-transform: uppercase;
+ margin-bottom: 8px;
+}
+
+.pc-card-title {
+ font-size: 24px;
+ font-weight: 700;
+ color: var(--pc-text);
+ margin: 0 0 14px;
+}
+
+.pc-card-desc {
+ font-size: 15px;
+ line-height: 24px;
+ color: var(--pc-text-2);
+ max-width: 560px;
+ margin: 0 0 26px;
+}
+
+.pc-benefits {
+ list-style: none;
+ padding: 0;
+ margin: 0 0 30px;
+ text-align: left;
+}
+
+.pc-benefit {
+ display: flex;
+ align-items: flex-start;
+ gap: 10px;
+ padding: 7px 0;
+ font-size: 15px;
+ line-height: 22px;
+ color: var(--pc-text-2);
+}
+
+.pc-benefit-check {
+ flex: 0 0 auto;
+ width: 18px;
+ height: 18px;
+ margin-top: 2px;
+ color: var(--pc-btn-press);
+}
+
+.pc-cta {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ background: var(--pc-btn);
+ color: var(--pc-text);
+ font-size: 15px;
+ font-weight: 700;
+ padding: 12px 24px;
+ border-radius: 12px;
+ border: 1px solid var(--pc-btn-press);
+ text-decoration: none;
+ cursor: pointer;
+ transition: background 0.15s ease;
+}
+
+.pc-cta:hover {
+ background: var(--pc-btn-hover);
+ color: var(--pc-text);
+}
+
+.pc-cta:active {
+ background: var(--pc-btn-press);
+}
+
+/* --------------------------------------------------- Responsive collapse */
+
+@media (max-width: 900px) {
+ .pc-sidebar {
+ flex-basis: var(--pc-sidebar-w-collapsed);
+ width: var(--pc-sidebar-w-collapsed);
+ }
+
+ .pc-brand {
+ justify-content: center;
+ padding: 0 8px;
+ }
+
+ .pc-brand-lockup {
+ display: none;
+ }
+
+ .pc-brand-mark {
+ display: block;
+ }
+
+ .pc-section,
+ .pc-nav-label,
+ .pc-pill {
+ display: none;
+ }
+
+ .pc-nav-item {
+ justify-content: center;
+ margin: 0 6px;
+ padding: 10px 0;
+ }
+
+ .pc-main-inner {
+ padding: 20px 18px 48px;
+ }
+}
diff --git a/dashboard/assets/images/icons/cloud/alerts.svg b/dashboard/assets/images/icons/cloud/alerts.svg
new file mode 100644
index 0000000000..6109e378ab
--- /dev/null
+++ b/dashboard/assets/images/icons/cloud/alerts.svg
@@ -0,0 +1 @@
+
diff --git a/dashboard/assets/images/icons/cloud/attack-paths.svg b/dashboard/assets/images/icons/cloud/attack-paths.svg
new file mode 100644
index 0000000000..b856c6ea2f
--- /dev/null
+++ b/dashboard/assets/images/icons/cloud/attack-paths.svg
@@ -0,0 +1 @@
+
diff --git a/dashboard/assets/images/icons/cloud/check.svg b/dashboard/assets/images/icons/cloud/check.svg
new file mode 100644
index 0000000000..a5f8ed0c8a
--- /dev/null
+++ b/dashboard/assets/images/icons/cloud/check.svg
@@ -0,0 +1 @@
+
diff --git a/dashboard/assets/images/icons/cloud/compliance.svg b/dashboard/assets/images/icons/cloud/compliance.svg
new file mode 100644
index 0000000000..e70ebda28c
--- /dev/null
+++ b/dashboard/assets/images/icons/cloud/compliance.svg
@@ -0,0 +1,4 @@
+
diff --git a/dashboard/assets/images/icons/cloud/docs.svg b/dashboard/assets/images/icons/cloud/docs.svg
new file mode 100644
index 0000000000..efc3dfc61b
--- /dev/null
+++ b/dashboard/assets/images/icons/cloud/docs.svg
@@ -0,0 +1 @@
+
diff --git a/dashboard/assets/images/icons/cloud/findings.svg b/dashboard/assets/images/icons/cloud/findings.svg
new file mode 100644
index 0000000000..93fc42a516
--- /dev/null
+++ b/dashboard/assets/images/icons/cloud/findings.svg
@@ -0,0 +1 @@
+
diff --git a/dashboard/assets/images/icons/cloud/help.svg b/dashboard/assets/images/icons/cloud/help.svg
new file mode 100644
index 0000000000..2b3f1024e8
--- /dev/null
+++ b/dashboard/assets/images/icons/cloud/help.svg
@@ -0,0 +1 @@
+
diff --git a/dashboard/assets/images/icons/cloud/integrations.svg b/dashboard/assets/images/icons/cloud/integrations.svg
new file mode 100644
index 0000000000..e7a2d07603
--- /dev/null
+++ b/dashboard/assets/images/icons/cloud/integrations.svg
@@ -0,0 +1 @@
+
diff --git a/dashboard/assets/images/icons/cloud/lighthouse-ai.svg b/dashboard/assets/images/icons/cloud/lighthouse-ai.svg
new file mode 100644
index 0000000000..1c510ac1fc
--- /dev/null
+++ b/dashboard/assets/images/icons/cloud/lighthouse-ai.svg
@@ -0,0 +1 @@
+
diff --git a/dashboard/assets/images/icons/cloud/mutelist.svg b/dashboard/assets/images/icons/cloud/mutelist.svg
new file mode 100644
index 0000000000..1d498fa9e3
--- /dev/null
+++ b/dashboard/assets/images/icons/cloud/mutelist.svg
@@ -0,0 +1 @@
+
diff --git a/dashboard/assets/images/icons/cloud/organization.svg b/dashboard/assets/images/icons/cloud/organization.svg
new file mode 100644
index 0000000000..1f3d1da9f5
--- /dev/null
+++ b/dashboard/assets/images/icons/cloud/organization.svg
@@ -0,0 +1 @@
+
diff --git a/dashboard/assets/images/icons/cloud/overview.svg b/dashboard/assets/images/icons/cloud/overview.svg
new file mode 100644
index 0000000000..809e63d945
--- /dev/null
+++ b/dashboard/assets/images/icons/cloud/overview.svg
@@ -0,0 +1,4 @@
+
diff --git a/dashboard/assets/images/icons/cloud/prowler-lockup.svg b/dashboard/assets/images/icons/cloud/prowler-lockup.svg
new file mode 100644
index 0000000000..9daee36463
--- /dev/null
+++ b/dashboard/assets/images/icons/cloud/prowler-lockup.svg
@@ -0,0 +1 @@
+
diff --git a/dashboard/assets/images/icons/cloud/prowler-mark.svg b/dashboard/assets/images/icons/cloud/prowler-mark.svg
new file mode 100644
index 0000000000..eb30d44be8
--- /dev/null
+++ b/dashboard/assets/images/icons/cloud/prowler-mark.svg
@@ -0,0 +1 @@
+
diff --git a/dashboard/common_methods.py b/dashboard/common_methods.py
index a2f9ffe89b..b9f59513a5 100644
--- a/dashboard/common_methods.py
+++ b/dashboard/common_methods.py
@@ -1538,6 +1538,186 @@ def get_section_container_iso(data, section_1, section_2):
return html.Div(section_containers, className="compliance-data-layout")
+def _status_bar(success, failed, classname):
+ """Build the stacked PASS/FAIL bar shown next to an accordion title."""
+ fig = go.Figure(
+ data=[
+ go.Bar(
+ name="Failed",
+ x=[failed],
+ y=[""],
+ orientation="h",
+ marker=dict(color="#e77676"),
+ width=[0.8],
+ ),
+ go.Bar(
+ name="Success",
+ x=[success],
+ y=[""],
+ orientation="h",
+ marker=dict(color="#45cc6e"),
+ width=[0.8],
+ ),
+ ]
+ )
+ fig.update_layout(
+ barmode="stack",
+ margin=dict(l=10, r=10, t=10, b=10),
+ paper_bgcolor="rgba(0,0,0,0)",
+ plot_bgcolor="rgba(0,0,0,0)",
+ showlegend=False,
+ width=350,
+ height=30,
+ xaxis=dict(showticklabels=False, showgrid=False, zeroline=False),
+ yaxis=dict(showticklabels=False, showgrid=False, zeroline=False),
+ annotations=[
+ dict(
+ x=success + failed,
+ y=0,
+ xref="x",
+ yref="y",
+ text=str(success),
+ showarrow=False,
+ font=dict(color="#45cc6e", size=14),
+ xanchor="left",
+ yanchor="middle",
+ ),
+ dict(
+ x=0,
+ y=0,
+ xref="x",
+ yref="y",
+ text=str(failed),
+ showarrow=False,
+ font=dict(color="#e77676", size=14),
+ xanchor="right",
+ yanchor="middle",
+ ),
+ ],
+ )
+ fig.add_annotation(
+ x=failed,
+ y=0.3,
+ text="|",
+ showarrow=False,
+ xanchor="center",
+ yanchor="middle",
+ font=dict(size=20),
+ )
+ return dcc.Graph(figure=fig, config={"staticPlot": True}, className=classname)
+
+
+def get_section_containers_generic(data, section_col, id_col):
+ """Two-level view: section -> requirement id (+ description) -> checks.
+
+ Sorts lexicographically so arbitrary requirement IDs never crash the
+ version-aware sort used by the CIS renderer.
+ """
+ data["STATUS"] = data["STATUS"].apply(map_status_to_icon)
+ data[section_col] = data[section_col].astype(str)
+ data[id_col] = data[id_col].astype(str)
+ data.sort_values(by=[section_col, id_col], inplace=True)
+
+ counts_section = data.groupby([section_col, "STATUS"]).size().unstack(fill_value=0)
+ counts_id = (
+ data.groupby([section_col, id_col, "STATUS"]).size().unstack(fill_value=0)
+ )
+
+ def count(counts, key, emoji):
+ return counts.loc[key, emoji] if emoji in counts.columns else 0
+
+ has_description = "REQUIREMENTS_DESCRIPTION" in data.columns
+ table_cols = ["CHECKID", "STATUS", "REGION", "ACCOUNTID", "RESOURCEID"]
+
+ section_containers = []
+ for section in data[section_col].unique():
+ graph_div = html.Div(
+ _status_bar(
+ count(counts_section, section, pass_emoji),
+ count(counts_section, section, fail_emoji),
+ "info-bar",
+ ),
+ className="graph-section",
+ )
+
+ internal_items = []
+ for req_id in data[data[section_col] == section][id_col].unique():
+ specific_data = data[
+ (data[section_col] == section) & (data[id_col] == req_id)
+ ]
+ data_table = dash_table.DataTable(
+ data=specific_data.to_dict("records"),
+ columns=[
+ {"name": i, "id": i}
+ for i in table_cols
+ if i in specific_data.columns
+ ],
+ style_table={"overflowX": "auto"},
+ style_as_list_view=True,
+ style_cell={"textAlign": "left", "padding": "5px"},
+ )
+ graph_div_req = html.Div(
+ _status_bar(
+ count(counts_id, (section, req_id), pass_emoji),
+ count(counts_id, (section, req_id), fail_emoji),
+ "info-bar-child",
+ ),
+ className="graph-section-req",
+ )
+
+ title = req_id
+ if has_description:
+ title = (
+ f"{req_id} - {specific_data['REQUIREMENTS_DESCRIPTION'].iloc[0]}"
+ )
+ if len(title) > 130:
+ title = title[:130] + " ..."
+
+ internal_items.append(
+ html.Div(
+ [
+ graph_div_req,
+ dbc.Accordion(
+ [
+ dbc.AccordionItem(
+ title=title,
+ children=[
+ html.Div(
+ [data_table],
+ className="inner-accordion-content",
+ )
+ ],
+ )
+ ],
+ start_collapsed=True,
+ flush=True,
+ ),
+ ],
+ className="accordion-inner--child",
+ )
+ )
+
+ section_containers.append(
+ html.Div(
+ [
+ graph_div,
+ dbc.Accordion(
+ [
+ dbc.AccordionItem(
+ title=f"{section}", children=internal_items
+ )
+ ],
+ start_collapsed=True,
+ flush=True,
+ ),
+ ],
+ className="accordion-inner",
+ )
+ )
+
+ return html.Div(section_containers, className="compliance-data-layout")
+
+
def get_section_containers_format4(data, section_1):
data["STATUS"] = data["STATUS"].apply(map_status_to_icon)
diff --git a/dashboard/compliance/aws_ai_security_framework_aws.py b/dashboard/compliance/aws_ai_security_framework_aws.py
new file mode 100644
index 0000000000..ece9bdf9cb
--- /dev/null
+++ b/dashboard/compliance/aws_ai_security_framework_aws.py
@@ -0,0 +1,27 @@
+import warnings
+
+from dashboard.common_methods import get_section_containers_3_levels
+
+warnings.filterwarnings("ignore")
+
+
+def get_table(data):
+ aux = data[
+ [
+ "REQUIREMENTS_ATTRIBUTES_SECTION",
+ "REQUIREMENTS_ATTRIBUTES_SUBSECTION",
+ "NAME",
+ "CHECKID",
+ "STATUS",
+ "REGION",
+ "ACCOUNTID",
+ "RESOURCEID",
+ ]
+ ]
+
+ return get_section_containers_3_levels(
+ aux,
+ "REQUIREMENTS_ATTRIBUTES_SECTION",
+ "REQUIREMENTS_ATTRIBUTES_SUBSECTION",
+ "NAME",
+ )
diff --git a/dashboard/compliance/cis_2_0_1_kubernetes.py b/dashboard/compliance/cis_2_0_1_kubernetes.py
new file mode 100644
index 0000000000..94558f33ad
--- /dev/null
+++ b/dashboard/compliance/cis_2_0_1_kubernetes.py
@@ -0,0 +1,24 @@
+import warnings
+
+from dashboard.common_methods import get_section_containers_cis
+
+warnings.filterwarnings("ignore")
+
+
+def get_table(data):
+ aux = data[
+ [
+ "REQUIREMENTS_ID",
+ "REQUIREMENTS_DESCRIPTION",
+ "REQUIREMENTS_ATTRIBUTES_SECTION",
+ "CHECKID",
+ "STATUS",
+ "REGION",
+ "ACCOUNTID",
+ "RESOURCEID",
+ ]
+ ].copy()
+
+ return get_section_containers_cis(
+ aux, "REQUIREMENTS_ID", "REQUIREMENTS_ATTRIBUTES_SECTION"
+ )
diff --git a/dashboard/compliance/cis_5_0_gcp.py b/dashboard/compliance/cis_5_0_gcp.py
new file mode 100644
index 0000000000..94558f33ad
--- /dev/null
+++ b/dashboard/compliance/cis_5_0_gcp.py
@@ -0,0 +1,24 @@
+import warnings
+
+from dashboard.common_methods import get_section_containers_cis
+
+warnings.filterwarnings("ignore")
+
+
+def get_table(data):
+ aux = data[
+ [
+ "REQUIREMENTS_ID",
+ "REQUIREMENTS_DESCRIPTION",
+ "REQUIREMENTS_ATTRIBUTES_SECTION",
+ "CHECKID",
+ "STATUS",
+ "REGION",
+ "ACCOUNTID",
+ "RESOURCEID",
+ ]
+ ].copy()
+
+ return get_section_containers_cis(
+ aux, "REQUIREMENTS_ID", "REQUIREMENTS_ATTRIBUTES_SECTION"
+ )
diff --git a/dashboard/compliance/cis_6_0_azure.py b/dashboard/compliance/cis_6_0_azure.py
new file mode 100644
index 0000000000..9d33cc67a8
--- /dev/null
+++ b/dashboard/compliance/cis_6_0_azure.py
@@ -0,0 +1,25 @@
+import warnings
+
+from dashboard.common_methods import get_section_containers_cis
+
+warnings.filterwarnings("ignore")
+
+
+def get_table(data):
+
+ aux = data[
+ [
+ "REQUIREMENTS_ID",
+ "REQUIREMENTS_DESCRIPTION",
+ "REQUIREMENTS_ATTRIBUTES_SECTION",
+ "CHECKID",
+ "STATUS",
+ "REGION",
+ "ACCOUNTID",
+ "RESOURCEID",
+ ]
+ ].copy()
+
+ return get_section_containers_cis(
+ aux, "REQUIREMENTS_ID", "REQUIREMENTS_ATTRIBUTES_SECTION"
+ )
diff --git a/dashboard/compliance/cis_7_0_aws.py b/dashboard/compliance/cis_7_0_aws.py
new file mode 100644
index 0000000000..94558f33ad
--- /dev/null
+++ b/dashboard/compliance/cis_7_0_aws.py
@@ -0,0 +1,24 @@
+import warnings
+
+from dashboard.common_methods import get_section_containers_cis
+
+warnings.filterwarnings("ignore")
+
+
+def get_table(data):
+ aux = data[
+ [
+ "REQUIREMENTS_ID",
+ "REQUIREMENTS_DESCRIPTION",
+ "REQUIREMENTS_ATTRIBUTES_SECTION",
+ "CHECKID",
+ "STATUS",
+ "REGION",
+ "ACCOUNTID",
+ "RESOURCEID",
+ ]
+ ].copy()
+
+ return get_section_containers_cis(
+ aux, "REQUIREMENTS_ID", "REQUIREMENTS_ATTRIBUTES_SECTION"
+ )
diff --git a/dashboard/compliance/cis_7_0_m365.py b/dashboard/compliance/cis_7_0_m365.py
new file mode 100644
index 0000000000..94558f33ad
--- /dev/null
+++ b/dashboard/compliance/cis_7_0_m365.py
@@ -0,0 +1,24 @@
+import warnings
+
+from dashboard.common_methods import get_section_containers_cis
+
+warnings.filterwarnings("ignore")
+
+
+def get_table(data):
+ aux = data[
+ [
+ "REQUIREMENTS_ID",
+ "REQUIREMENTS_DESCRIPTION",
+ "REQUIREMENTS_ATTRIBUTES_SECTION",
+ "CHECKID",
+ "STATUS",
+ "REGION",
+ "ACCOUNTID",
+ "RESOURCEID",
+ ]
+ ].copy()
+
+ return get_section_containers_cis(
+ aux, "REQUIREMENTS_ID", "REQUIREMENTS_ATTRIBUTES_SECTION"
+ )
diff --git a/dashboard/compliance/generic.py b/dashboard/compliance/generic.py
new file mode 100644
index 0000000000..f7d68bb52a
--- /dev/null
+++ b/dashboard/compliance/generic.py
@@ -0,0 +1,44 @@
+import warnings
+
+from dashboard.common_methods import (
+ get_section_containers_format4,
+ get_section_containers_generic,
+)
+
+warnings.filterwarnings("ignore")
+
+
+def get_table(data):
+ # Discover REQUIREMENTS_ATTRIBUTES_* columns at runtime.
+ attr_cols = [c for c in data.columns if c.startswith("REQUIREMENTS_ATTRIBUTES_")]
+
+ # Section column (in priority order):
+ # 1. REQUIREMENTS_ATTRIBUTES_SECTION — most common convention
+ # 2. First discovered attribute column — covers novel schemas
+ # 3. None — no section, group flat by requirement id
+ if "REQUIREMENTS_ATTRIBUTES_SECTION" in attr_cols:
+ section_col = "REQUIREMENTS_ATTRIBUTES_SECTION"
+ elif attr_cols:
+ section_col = attr_cols[0]
+ else:
+ section_col = None
+
+ base_cols = [
+ "REQUIREMENTS_ID",
+ "REQUIREMENTS_DESCRIPTION",
+ "STATUS",
+ "CHECKID",
+ "REGION",
+ "ACCOUNTID",
+ "RESOURCEID",
+ ]
+
+ # Two levels (section -> requirement id) when a section distinct from the
+ # id exists; otherwise group flat by requirement id.
+ if section_col and section_col != "REQUIREMENTS_ID":
+ needed = [section_col] + base_cols
+ aux = data[[c for c in needed if c in data.columns]].copy()
+ return get_section_containers_generic(aux, section_col, "REQUIREMENTS_ID")
+
+ aux = data[[c for c in base_cols if c in data.columns]].copy()
+ return get_section_containers_format4(aux, "REQUIREMENTS_ID")
diff --git a/dashboard/lib/layouts.py b/dashboard/lib/layouts.py
index 930432b6c4..6d1e7947fa 100644
--- a/dashboard/lib/layouts.py
+++ b/dashboard/lib/layouts.py
@@ -156,7 +156,7 @@ def create_layout_compliance(
html.Img(src="assets/favicon.ico", className="w-5 mr-3"),
html.Span("Subscribe to Prowler Cloud"),
],
- href="https://prowler.pro/",
+ href="https://cloud.prowler.com/sign-up?utm_source=prowler-local-dashboard&utm_content=compliance",
target="_blank",
className="text-prowler-stone-900 inline-flex px-4 py-2 text-xs font-bold uppercase transition-all rounded-lg text-gray-900 hover:bg-prowler-stone-900/10 border-solid border-1 hover:border-prowler-stone-900/10 hover:border-solid hover:border-1 border-prowler-stone-900/10",
),
diff --git a/dashboard/pages/cloud.py b/dashboard/pages/cloud.py
new file mode 100644
index 0000000000..49778f8e26
--- /dev/null
+++ b/dashboard/pages/cloud.py
@@ -0,0 +1,265 @@
+"""Prowler Cloud upsell (gated) informational pages.
+
+These routes live inside the Local Dashboard but do NOT reproduce any Prowler
+Cloud functionality. Each renders the same reusable upgrade template with a
+feature-specific name, icon, description, benefit bullets and UTM-tagged CTA.
+All copy in ``CLOUD_FEATURES`` is normative — do not change wording,
+capitalization or punctuation without Product approval.
+"""
+
+import dash
+from dash import html
+
+# Shared subtitle used across every gated page header.
+CLOUD_SUBTITLE = "Discover more ways to protect and operate your cloud."
+
+# Base Prowler Cloud URL; the UTM content value identifies the feature.
+CLOUD_CTA_BASE = (
+ "https://cloud.prowler.com/sign-up?utm_source=prowler-local-dashboard&utm_content="
+)
+
+# Path to the recolorable checkmark mask used for benefit bullets.
+CHECK_ICON = "/assets/images/icons/cloud/check.svg"
+
+
+# Normative feature definitions. ``icon`` points to a local mask asset so no
+# external requests are needed and the glyph recolors per context.
+CLOUD_FEATURES = [
+ {
+ "slug": "lighthouse-ai",
+ "route": "/cloud/lighthouse-ai",
+ "nav_label": "Lighthouse AI",
+ "page_title": "Lighthouse AI",
+ "card_title": "Unlock Lighthouse AI",
+ "description": (
+ "Work with an AI security analyst that understands your cloud "
+ "posture and helps turn risk into action."
+ ),
+ "benefits": [
+ "Ask questions about your security posture in plain language",
+ "Investigate findings with context from your connected providers",
+ "Move from insight to remediation faster",
+ ],
+ "utm_content": "lighthouse-ai",
+ "icon": "/assets/images/icons/cloud/lighthouse-ai.svg",
+ },
+ {
+ "slug": "attack-paths",
+ "route": "/cloud/attack-paths",
+ "nav_label": "Attack Paths",
+ "page_title": "Attack Paths",
+ "card_title": "Unlock Attack Paths",
+ "description": (
+ "Visualize the paths an attacker could take through connected "
+ "resources before risk becomes compromise."
+ ),
+ "benefits": [
+ "See exploitable relationships across your AWS environment",
+ "Focus remediation on the paths with the greatest impact",
+ "Explore each scan as a point-in-time security graph",
+ ],
+ "utm_content": "attack-paths",
+ "icon": "/assets/images/icons/cloud/attack-paths.svg",
+ },
+ {
+ "slug": "findings",
+ "route": "/cloud/findings",
+ "nav_label": "Findings",
+ "page_title": "Findings",
+ "card_title": "Unlock Findings",
+ "description": (
+ "Filter, investigate, and prioritize security findings across "
+ "providers and scans from one workspace."
+ ),
+ "benefits": [
+ "Search and filter findings across all connected accounts",
+ "Track status, severity, ownership, and remediation context",
+ "Triage findings and share a consistent source of truth with your security team",
+ ],
+ "utm_content": "findings",
+ "icon": "/assets/images/icons/cloud/findings.svg",
+ },
+ {
+ "slug": "alerts",
+ "route": "/cloud/alerts",
+ "nav_label": "Alerts",
+ "page_title": "Alerts",
+ "card_title": "Unlock Alerts",
+ "description": (
+ "Create alert rules and stay informed when scan results reveal "
+ "the risks your team cares about."
+ ),
+ "benefits": [
+ "Define alerts around the findings that matter most",
+ "Route security signals to the right responders",
+ "Reduce the time between detection and action",
+ ],
+ "utm_content": "alerts",
+ "icon": "/assets/images/icons/cloud/alerts.svg",
+ },
+ {
+ "slug": "mutelist",
+ "route": "/cloud/mutelist",
+ "nav_label": "Mutelist",
+ "page_title": "Mutelist",
+ "card_title": "Unlock Mutelist",
+ "description": (
+ "Quiet expected findings, document accepted risk, and keep your "
+ "team focused on actionable work."
+ ),
+ "benefits": [
+ "Create reusable rules for known exceptions",
+ "Keep muted findings available for audit and review",
+ "Cut noise without losing security context",
+ ],
+ "utm_content": "mutelist",
+ "icon": "/assets/images/icons/cloud/mutelist.svg",
+ },
+ {
+ "slug": "integrations",
+ "route": "/cloud/integrations",
+ "nav_label": "Integrations",
+ "page_title": "Integrations",
+ "card_title": "Unlock Integrations",
+ "description": (
+ "Connect Prowler to your security workflow so findings and scan "
+ "data reach the tools your team already uses."
+ ),
+ "benefits": [
+ "Connect ticketing, notification, and cloud security services",
+ "Automate the handoff from detection to response",
+ "Keep teams aligned without manual exports",
+ ],
+ "utm_content": "integrations",
+ "icon": "/assets/images/icons/cloud/integrations.svg",
+ },
+ {
+ "slug": "organization",
+ "route": "/cloud/organization",
+ "nav_label": "Organization",
+ "page_title": "Organization",
+ "card_title": "Unlock Organization",
+ "description": (
+ "Manage users, roles, and invitations while organizing cloud "
+ "security work across your team."
+ ),
+ "benefits": [
+ "Invite teammates into a shared security workspace",
+ "Control access with role-based permissions",
+ "Coordinate security operations across accounts and teams",
+ ],
+ "utm_content": "organization",
+ "icon": "/assets/images/icons/cloud/organization.svg",
+ },
+]
+
+# Convenience lookup for the navigation builder in ``__main__``.
+CLOUD_FEATURES_BY_SLUG = {feature["slug"]: feature for feature in CLOUD_FEATURES}
+
+
+def _mask_style(icon_url):
+ """Return the inline style that renders a recolorable mask icon."""
+ return {
+ "WebkitMaskImage": f"url({icon_url})",
+ "maskImage": f"url({icon_url})",
+ }
+
+
+def _benefit_item(text):
+ return html.Li(
+ [
+ html.Span(
+ className="pc-ico pc-benefit-check",
+ style=_mask_style(CHECK_ICON),
+ ),
+ html.Span(text),
+ ],
+ className="pc-benefit",
+ )
+
+
+def build_cloud_layout(feature):
+ """Build the reusable gated informational page for a single feature."""
+ cta_url = f"{CLOUD_CTA_BASE}{feature['utm_content']}"
+
+ return html.Div(
+ html.Div(
+ [
+ # Page header: title + Prowler Cloud badge + shared subtitle.
+ html.Div(
+ [
+ html.Div(
+ [
+ html.H1(
+ feature["page_title"],
+ className="pc-page-title",
+ ),
+ html.Span("Prowler Cloud", className="pc-pill"),
+ ],
+ className="pc-page-title-row",
+ ),
+ html.P(CLOUD_SUBTITLE, className="pc-page-subtitle"),
+ ],
+ className="pc-page-header",
+ ),
+ # Centered upgrade card.
+ html.Div(
+ [
+ html.Div(className="pc-card-glow"),
+ html.Div(
+ [
+ html.Div(
+ html.Span(
+ className="pc-ico",
+ style=_mask_style(feature["icon"]),
+ ),
+ className="pc-feature-icon",
+ ),
+ html.Div(
+ "Available in Prowler Cloud",
+ className="pc-avail",
+ ),
+ html.H2(
+ feature["card_title"],
+ className="pc-card-title",
+ ),
+ html.P(
+ feature["description"],
+ className="pc-card-desc",
+ ),
+ html.Ul(
+ [
+ _benefit_item(benefit)
+ for benefit in feature["benefits"]
+ ],
+ className="pc-benefits",
+ ),
+ html.A(
+ "Upgrade to Prowler Cloud",
+ href=cta_url,
+ target="_blank",
+ rel="noopener noreferrer",
+ className="pc-cta",
+ ),
+ ],
+ className="pc-card-body",
+ ),
+ ],
+ className="pc-card",
+ ),
+ ],
+ className="pc-page pc-font",
+ ),
+ )
+
+
+# Register one page per gated feature. A distinct module key keeps each entry
+# unique in Dash's page registry while sharing the same template.
+for _feature in CLOUD_FEATURES:
+ dash.register_page(
+ f"cloud_{_feature['slug'].replace('-', '_')}",
+ path=_feature["route"],
+ name=_feature["nav_label"],
+ title=f"Prowler Dashboard - {_feature['page_title']}",
+ layout=build_cloud_layout(_feature),
+ )
diff --git a/dashboard/pages/compliance.py b/dashboard/pages/compliance.py
index c1da9f611e..773dd095da 100644
--- a/dashboard/pages/compliance.py
+++ b/dashboard/pages/compliance.py
@@ -215,6 +215,58 @@ else:
)
+def _ensure_scope_columns(data):
+ """Guarantee ACCOUNTID and REGION exist.
+
+ Scope columns always sit between DESCRIPTION and ASSESSMENTDATE, so derive
+ them positionally for any provider (e.g. Okta's ORGANIZATIONDOMAIN) and
+ fall back to "-" to avoid a KeyError.
+ """
+ cols = list(data.columns)
+ scope = []
+ if "DESCRIPTION" in cols and "ASSESSMENTDATE" in cols:
+ start, end = cols.index("DESCRIPTION") + 1, cols.index("ASSESSMENTDATE")
+ scope = [c for c in cols[start:end] if c not in ("ACCOUNTID", "REGION")]
+
+ if "ACCOUNTID" not in data.columns:
+ if scope:
+ data.rename(columns={scope.pop(0): "ACCOUNTID"}, inplace=True)
+ else:
+ data["ACCOUNTID"] = "-"
+ if "REGION" not in data.columns:
+ if scope:
+ data.rename(columns={scope.pop(0): "REGION"}, inplace=True)
+ else:
+ data["REGION"] = "-"
+ return data
+
+
+def _dispatch_compliance_renderer(data, analytics_input):
+ """Resolve the compliance renderer module and return (table, deduped_data).
+
+ Tries to import the framework-specific builtin module. On
+ ModuleNotFoundError (dynamic/external provider with no dedicated module),
+ falls back to the generic renderer. Any other ImportError is re-raised.
+ get_table() is called OUTSIDE the try block so errors inside the renderer
+ surface as real exceptions rather than being swallowed.
+ """
+ current = analytics_input.replace(".", "_")
+ target = f"dashboard.compliance.{current}"
+ try:
+ module = importlib.import_module(target)
+ except ModuleNotFoundError as exc:
+ if exc.name != target:
+ raise
+ from dashboard.compliance import generic as module
+ dedup_columns = ["CHECKID", "STATUS", "RESOURCEID", "STATUSEXTENDED"]
+ if "MUTED" in data.columns:
+ dedup_columns.insert(2, "MUTED")
+ data = data.drop_duplicates(subset=dedup_columns)
+ if "threatscore" in analytics_input:
+ data = get_threatscore_mean_by_pillar(data)
+ return module.get_table(data), data
+
+
@callback(
[
Output("output", "children"),
@@ -292,7 +344,7 @@ def display_data(
data.rename(columns={"TENANCYID": "ACCOUNTID"}, inplace=True)
# Filter the chosen level of the CIS
- if is_level_1:
+ if is_level_1 and "REQUIREMENTS_ATTRIBUTES_PROFILE" in data.columns:
data = data[data["REQUIREMENTS_ATTRIBUTES_PROFILE"].str.contains("Level 1")]
# Rename the column PROJECTID to ACCOUNTID for GCP
@@ -314,6 +366,9 @@ def display_data(
data.rename(columns={"SUBSCRIPTION": "ACCOUNTID"}, inplace=True)
data["REGION"] = "-"
+ # Normalize scope columns for any remaining (e.g. dynamic) provider.
+ data = _ensure_scope_columns(data)
+
# Filter ACCOUNT
if account_filter == ["All"]:
updated_cloud_account_values = data["ACCOUNTID"].unique()
@@ -409,36 +464,7 @@ def display_data(
# Check cases where the compliance start with AWS_
if "aws_" in analytics_input:
analytics_input = analytics_input + "_aws"
- try:
- current = analytics_input.replace(".", "_")
- compliance_module = importlib.import_module(
- f"dashboard.compliance.{current}"
- )
- # Build subset list based on available columns
- dedup_columns = ["CHECKID", "STATUS", "RESOURCEID", "STATUSEXTENDED"]
- if "MUTED" in data.columns:
- dedup_columns.insert(2, "MUTED")
- data = data.drop_duplicates(subset=dedup_columns)
-
- if "threatscore" in analytics_input:
- data = get_threatscore_mean_by_pillar(data)
-
- table = compliance_module.get_table(data)
- except ModuleNotFoundError:
- table = html.Div(
- [
- html.H5(
- "No data found for this compliance",
- className="card-title",
- style={"text-align": "left", "color": "black"},
- )
- ],
- style={
- "width": "99%",
- "margin-right": "0.8%",
- "margin-bottom": "10px",
- },
- )
+ table, data = _dispatch_compliance_renderer(data, analytics_input)
df = data.copy()
# Remove Muted rows
diff --git a/dashboard/pages/overview.py b/dashboard/pages/overview.py
index 665aa8e195..8f786412d9 100644
--- a/dashboard/pages/overview.py
+++ b/dashboard/pages/overview.py
@@ -1538,7 +1538,7 @@ def filter_data(
html.Img(src="assets/favicon.ico", className="w-5 mr-3"),
html.Span("Subscribe to Prowler Cloud"),
],
- href="https://prowler.pro/",
+ href="https://cloud.prowler.com/sign-up?utm_source=prowler-local-dashboard&utm_content=overview",
target="_blank",
className="text-prowler-stone-900 inline-flex px-4 py-2 text-xs font-bold uppercase transition-all rounded-lg text-gray-900 hover:bg-prowler-stone-900/10 border-solid border-1 hover:border-prowler-stone-900/10 hover:border-solid hover:border-1 border-prowler-stone-900/10",
),
diff --git a/docker-compose-dev.yml b/docker-compose-dev.yml
index 2020c7d21a..d737298183 100644
--- a/docker-compose-dev.yml
+++ b/docker-compose-dev.yml
@@ -1,6 +1,6 @@
services:
api-dev-init:
- image: busybox:1.37.0
+ image: busybox:1.37.0@sha256:9532d8c39891ca2ecde4d30d7710e01fb739c87a8b9299685c63704296b16028
volumes:
- ./_data/api:/data
command: ["sh", "-c", "chown -R 1000:1000 /data"]
@@ -64,7 +64,7 @@ services:
condition: service_healthy
postgres:
- image: postgres:16.3-alpine3.20
+ image: postgres:16.3-alpine3.20@sha256:36ed71227ae36305d26382657c0b96cbaf298427b3f1eaeb10d77a6dea3eec41
hostname: "postgres-db"
volumes:
- ./_data/postgres:/var/lib/postgresql/data
@@ -88,7 +88,7 @@ services:
retries: 5
valkey:
- image: valkey/valkey:7-alpine3.19
+ image: valkey/valkey:7-alpine3.19@sha256:4054fe7fc607b9326ac7c4691ed26e9670d2ff17a9fb28c2577adecf928acbcc
hostname: "valkey"
volumes:
- ./_data/valkey:/data
@@ -104,7 +104,7 @@ services:
retries: 3
neo4j:
- image: graphstack/dozerdb:5.26.3.0
+ image: graphstack/dozerdb:5.26.3.0@sha256:a77526ea3918fdc46d1fff70c4aea7d71d3874a26ecec059179d6775845b1247
hostname: "neo4j"
volumes:
- ./_data/neo4j:/data
@@ -139,6 +139,8 @@ services:
worker-dev:
image: prowler-api-dev
+ # Give Celery soft shutdown time to drain/re-queue in-flight tasks on stop.
+ stop_grace_period: 120s
build:
context: ./api
dockerfile: Dockerfile
@@ -183,7 +185,7 @@ services:
soft: 65536
hard: 65536
entrypoint:
- - "../docker-entrypoint.sh"
+ - "/home/prowler/docker-entrypoint.sh"
- "beat"
mcp-server:
diff --git a/docker-compose.yml b/docker-compose.yml
index a9d2e03c3d..6cb5ac237d 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -6,7 +6,7 @@
#
services:
api-init:
- image: busybox:1.37.0
+ image: busybox:1.37.0@sha256:9532d8c39891ca2ecde4d30d7710e01fb739c87a8b9299685c63704296b16028
volumes:
- ./_data/api:/data
command: ["sh", "-c", "chown -R 1000:1000 /data"]
@@ -60,7 +60,7 @@ services:
start_period: 60s
postgres:
- image: postgres:16.3-alpine3.20
+ image: postgres:16.3-alpine3.20@sha256:36ed71227ae36305d26382657c0b96cbaf298427b3f1eaeb10d77a6dea3eec41
hostname: "postgres-db"
volumes:
- ./_data/postgres:/var/lib/postgresql/data
@@ -80,7 +80,7 @@ services:
retries: 5
valkey:
- image: valkey/valkey:7-alpine3.19
+ image: valkey/valkey:7-alpine3.19@sha256:4054fe7fc607b9326ac7c4691ed26e9670d2ff17a9fb28c2577adecf928acbcc
hostname: "valkey"
volumes:
- ./_data/valkey:/data
@@ -96,7 +96,7 @@ services:
retries: 3
neo4j:
- image: graphstack/dozerdb:5.26.3.0
+ image: graphstack/dozerdb:5.26.3.0@sha256:a77526ea3918fdc46d1fff70c4aea7d71d3874a26ecec059179d6775845b1247
hostname: "neo4j"
volumes:
- ./_data/neo4j:/data
@@ -129,6 +129,8 @@ services:
worker:
image: prowlercloud/prowler-api:${PROWLER_API_VERSION:-stable}
+ # Give Celery soft shutdown time to drain/re-queue in-flight tasks on stop.
+ stop_grace_period: 120s
env_file:
- path: .env
required: false
@@ -158,7 +160,7 @@ services:
soft: 65536
hard: 65536
entrypoint:
- - "../docker-entrypoint.sh"
+ - "/home/prowler/docker-entrypoint.sh"
- "beat"
mcp-server:
diff --git a/docs/AGENTS.md b/docs/AGENTS.md
index 826c6d63f4..45bd3f04fa 100644
--- a/docs/AGENTS.md
+++ b/docs/AGENTS.md
@@ -78,11 +78,25 @@ b. National security is of the utmost concern nowadays.
Prowler Features are considered proper nouns. They are to be referenced without articles in all pieces of writing.
-This is a list of Prowler Features:
+Prowler ships two product families. Use these names exactly; the former names Prowler App (now Prowler Local Server) and Prowler Enterprise (now Prowler Private Cloud) must not appear in new writing. The only allowed former-name notes are on the Prowler Product Families page (`getting-started/products/index.mdx`) and in the site-wide banner, which document the mapping.
+
+Prowler Products:
+
+* **Prowler Cloud**
+* **Prowler Private Cloud** (formerly Prowler Enterprise)
+* **Prowler Hub**
+* **Prowler Lighthouse AI**
+* **Prowler MCP**
+
+Open Source projects:
-* **Prowler App**
* **Prowler CLI**
+* **Prowler Local Server** (formerly Prowler App)
+* **Prowler Local Dashboard** (the Prowler CLI dashboard)
* **Prowler SDK**
+
+Other Prowler Features:
+
* **Built-in Compliance Checks**
* **Multi-cloud Security Scanning**
* **Autonomous Cloud Security Analyst (AI)**
@@ -97,8 +111,6 @@ This is a list of Prowler Features:
* **AI-Generated Detections & Remediations**
* **Prowler Studio**
* **Custom Security Policies**
-* **Prowler Cloud**
-* **Prowler Registry**
* **Open Source & Full APIs**
---
@@ -134,13 +146,13 @@ Example 1 is vague and even potentially ambiguous. Verbs state your purpose and
Explicit use of second-person pronouns (you) and possessives (your) should be minimized whenever possible. Those constructions are best reserved for cases when instructions are directly given in an imperative form:
-**Example of Improvement Through Avoiding Second Person Pronouns**
+### Example of Improvement Through Avoiding Second Person Pronouns
**Original:**
-Prowler App can be installed in different ways, depending on your environment:
+Prowler Local Server can be installed in different ways, depending on your environment:
**Improved Version:**
-Prowler App offers flexible installation methods tailored to various environments:
+Prowler Local Server offers flexible installation methods tailored to various environments:
---
@@ -236,7 +248,7 @@ The use of bullet points is highly recommended when:
* Information can be logically divided into multiple categories, each sharing characteristics, features, or other relevant classifications.
* Items are significant enough as standalone concepts to deserve their own bullet point.
-**Example of Improvement Through Bullet Points**
+#### Example of Improvement Through Bullet Points
**Original:**
It contains hundreds of controls covering CIS, NIST 800, NIST CSF, CISA, RBI, FedRAMS, PCI-DSS, GDPR, HIPAA, FFIEC, SOC2, GXP, AWS Well-Architected Framework Security Pillar, AWS Foundational Technical Review (FTR), ENS (Spanish National Security Scheme), and your custom security frameworks.
@@ -262,7 +274,7 @@ There are several options for punctuating bullet points. Regardless of the style
* **No punctuation (minimalistic):** This strategy is suitable when no verbs are involved and is best used to highlight products or features in isolation. For example:
- Prowler App is composed of three key components:
+ Prowler Local Server is composed of three key components:
* Prowler UI
* Prowler API
* Prowler SDK
@@ -271,7 +283,7 @@ There are several options for punctuating bullet points. Regardless of the style
* **Periods for full sentences:** This approach works best when each bullet point forms a full sentence or includes verbs. For example:
- Prowler App is composed of three key components:
+ Prowler Local Server is composed of three key components:
* Prowler UI, a web-based interface, built with Next.js, providing a user-friendly experience for executing Prowler scans and visualizing results.
* Prowler API, a backend service, developed with Django REST Framework, responsible for running Prowler scans and storing the generated results.
* Prowler SDK, a Python SDK designed to extend the functionality of the Prowler CLI for advanced capabilities.
@@ -539,6 +551,43 @@ Tag-Based Scanning allows filtering resources by AWS tags during security assess
---
+## AppliesTo Banner for Product Scope
+
+The AppliesTo component states which products a guide covers and links to the product families page. It is located at `docs/snippets/applies-to.mdx`.
+
+### When to Use the AppliesTo Banner
+
+Use it on web UI tutorial pages that apply to more than one product (for example, a guide written for Prowler Cloud whose steps also work on Prowler Local Server). Do not combine it with the SubscriptionBanner: pages carrying the SubscriptionBanner already state their availability.
+
+### How to Use the AppliesTo Banner
+
+```mdx
+import { AppliesTo } from "/snippets/applies-to.mdx"
+
+
+```
+
+The default covers Prowler Cloud, Prowler Private Cloud, and Prowler Local Server. Pass the `products` prop to narrow the scope:
+
+```mdx
+
+```
+
+Place it on its own line below the Version Badge when one is present, otherwise directly after the imports.
+
+### Cloud Marker for Subscription Content
+
+The custom green cloud glyph (`docs/images/icons/cloud-bold.svg`) marks content that requires a Prowler Cloud or Prowler Private Cloud subscription. It renders as a trailing `::after` element through the "Cloud marker" rules in `docs/style.css`, so sidebar labels stay left-aligned.
+
+* Pages: add a `li[id=""] a span::after` selector to the Cloud marker rule in `docs/style.css` for every page whose content is subscription-gated. The `li` id equals the page URL path. Pages where only one section is gated (for example the Support page, where only the Support Desk carries the SubscriptionBanner) get no marker.
+* Navigation groups: add a selector only when every page in the group is subscription-gated. One sanctioned exception: the Prowler MCP group is marked because the hosted server at `mcp.prowler.com` includes tools for Prowler Cloud-specific features, and its overview page explains the free local alternative. Nested groups render as `li[data-title=""]` with a button toggle, so use `li[data-title=""] > button span:first-child::after`. Top-level groups render as `h3` headings without `data-title`, which means name collisions with top-level groups resolve themselves; a gated top-level group (always expanded) is selected through its sibling list with `:has()`, like the Security tab group. Do not use `:has()` on child links of nested groups: collapsed groups do not render their children.
+
+Do not use the `icon` field for this marker (icons render before the label and misalign the sidebar), and do not use `"tag"` on navigation groups (group-level tags crash `mint broken-links` with a stack overflow, verified with mint 4.2.689). The SVG stroke uses the fixed brand green `#10B981` on purpose: it must be visible on both themes without `currentColor` support.
+
+The marker meaning is explained on the Prowler Product Families page at `getting-started/products/index.mdx`: keep that note in place.
+
+---
+
## Avoid Assumptions Regarding Audience’s Expertise
### Understand Your Audience’s Expertise
diff --git a/docs/README.md b/docs/README.md
index 595f79bc5a..7934972c9b 100644
--- a/docs/README.md
+++ b/docs/README.md
@@ -5,7 +5,7 @@ This repository contains the Prowler Open Source documentation powered by [Mintl
## Documentation Structure
- **Getting Started**: Overview, installation, and basic usage guides
-- **User Guide**: Comprehensive guides for Prowler App, CLI, providers, and compliance
+- **User Guide**: Comprehensive guides for Prowler Cloud, Prowler Local Server, Prowler CLI, providers, and compliance
- **Developer Guide**: Technical documentation for developers contributing to Prowler
## Local Development
@@ -13,7 +13,7 @@ This repository contains the Prowler Open Source documentation powered by [Mintl
Install a reviewed version of the [Mintlify CLI](https://www.npmjs.com/package/mint) to preview documentation changes locally:
```bash
-npm install --global mint@4.2.560
+npm install --global mint@4.2.689
```
Run the following command at the root of your documentation (where `mint.json` is located):
diff --git a/docs/developer-guide/ai-skills.mdx b/docs/developer-guide/ai-skills.mdx
index 6a0787dac8..ceb154d027 100644
--- a/docs/developer-guide/ai-skills.mdx
+++ b/docs/developer-guide/ai-skills.mdx
@@ -8,7 +8,77 @@ This guide explains the AI Skills system that provides on-demand context and pat
**What are AI Skills?** Skills are structured instructions that help AI agents (Claude Code, Cursor, Copilot, etc.) understand Prowler's conventions, patterns, and best practices.
-## Architecture Overview
+Skills live in the [`skills/`](https://github.com/prowler-cloud/prowler/tree/master/skills) directory of the Prowler OSS repository. Each skill is a folder containing a `SKILL.md` file with its patterns and metadata.
+
+## Installation
+
+To enable skills for the supported AI coding assistants, run the setup script from the repository root:
+
+```bash
+./skills/setup.sh
+```
+
+The script creates symlinks so each tool finds the skills in its expected location:
+
+| Tool | Created by setup |
+|------|------------------|
+| Claude Code | `.claude/skills/` symlink and `CLAUDE.md` |
+| Gemini CLI | `.gemini/skills/` symlink and `GEMINI.md` |
+| Codex (OpenAI) | `.codex/skills/` symlink (uses `AGENTS.md` natively) |
+| GitHub Copilot | `.github/copilot-instructions.md` symlink to `AGENTS.md` |
+
+After running the setup, restart the AI coding assistant to load the skills.
+
+## Using Skills
+
+AI agents discover skills automatically and load them when a request matches a skill trigger. To load a skill manually during a session, point the agent to the skill's `SKILL.md` file:
+
+```text
+Read skills/{skill-name}/SKILL.md
+```
+
+For the full list of available skills, their triggers, and the Auto-invoke mappings, see the [`skills/README.md`](https://github.com/prowler-cloud/prowler/blob/master/skills/README.md) and [`AGENTS.md`](https://github.com/prowler-cloud/prowler/blob/master/AGENTS.md) in the repository.
+
+## Available Skills
+
+| Type | Skills |
+|------|--------|
+| **Generic** | typescript, react-19, nextjs-16, tailwind-4, pytest, playwright, django-drf, zod-4, zustand-5, ai-sdk-5, vitest, tdd |
+| **Prowler** | prowler, prowler-sdk-check, prowler-api, prowler-ui, prowler-mcp, prowler-provider, prowler-compliance, prowler-compliance-review, prowler-docs, prowler-pr, prowler-ci, prowler-attack-paths-query |
+| **Testing** | prowler-test-sdk, prowler-test-api, prowler-test-ui |
+| **Meta** | skill-creator, skill-sync |
+
+
+This table is a snapshot. The repository is the source of truth: see [`skills/README.md`](https://github.com/prowler-cloud/prowler/blob/master/skills/README.md) for the current, complete list.
+
+
+## Skill Structure
+
+Each skill follows the [Agent Skills spec](https://agentskills.io):
+
+```text
+skills/{skill-name}/
+├── SKILL.md # Patterns, rules, decision trees
+├── assets/ # Code templates, schemas
+└── references/ # Links to local docs (single source of truth)
+```
+
+## Key Design Decisions
+
+1. **Self-contained skills** - Critical patterns inline for fast loading
+2. **Local doc references** - No web URLs, points to `docs/developer-guide/*.mdx`
+3. **Single source of truth** - Skills reference docs, no duplication
+4. **On-demand loading** - AI loads only what's needed for the task
+
+## Creating New Skills
+
+Use the `skill-creator` meta-skill to create new skills that follow the Agent Skills spec. See [`AGENTS.md`](https://github.com/prowler-cloud/prowler/blob/master/AGENTS.md) for the full list of available skills and their triggers.
+
+## How Skills Work
+
+The diagrams below explain the internals of the skill system. They are useful for understanding the design, but are not required to install or use skills.
+
+### Architecture Overview
```mermaid
graph LR
@@ -28,7 +98,7 @@ graph LR
style F fill:#1a4d2e,stroke:#66bb6a,color:#fff
```
-## How It Works
+### Request Lifecycle
```mermaid
sequenceDiagram
@@ -68,7 +138,7 @@ sequenceDiagram
A->>U: Creates check with correct patterns
```
-## Before vs After
+### With and Without Skills
```mermaid
graph TD
@@ -96,7 +166,7 @@ graph TD
style AFTER fill:#1a4d1a,stroke:#66bb6a,color:#fff
```
-## Complete Architecture
+### Full Component Map
```mermaid
flowchart TB
@@ -110,7 +180,7 @@ flowchart TB
subgraph GENERIC["Generic Skills"]
G1["typescript"]
G2["react-19"]
- G3["nextjs-15"]
+ G3["nextjs-16"]
G4["tailwind-4"]
G5["pytest"]
G6["playwright"]
@@ -186,34 +256,3 @@ flowchart TB
style STRUCTURE fill:#5c3d1a,stroke:#ffb74d,color:#fff
style DOCS fill:#1a3d4d,stroke:#4dd0e1,color:#fff
```
-
-## Skills Included
-
-| Type | Skills |
-|------|--------|
-| **Generic** | typescript, react-19, nextjs-15, tailwind-4, pytest, playwright, django-drf, zod-4, zustand-5, ai-sdk-5 |
-| **Prowler** | prowler, prowler-sdk-check, prowler-api, prowler-ui, prowler-mcp, prowler-provider, prowler-compliance, prowler-compliance-review, prowler-docs, prowler-pr, prowler-ci |
-| **Testing** | prowler-test-sdk, prowler-test-api, prowler-test-ui |
-| **Meta** | skill-creator, skill-sync |
-
-## Skill Structure
-
-Each skill follows the [Agent Skills spec](https://agentskills.io):
-
-```
-skills/{skill-name}/
-├── SKILL.md # Patterns, rules, decision trees
-├── assets/ # Code templates, schemas
-└── references/ # Links to local docs (single source of truth)
-```
-
-## Key Design Decisions
-
-1. **Self-contained skills** - Critical patterns inline for fast loading
-2. **Local doc references** - No web URLs, points to `docs/developer-guide/*.mdx`
-3. **Single source of truth** - Skills reference docs, no duplication
-4. **On-demand loading** - AI loads only what's needed for the task
-
-## Creating New Skills
-
-Use the `skill-creator` meta-skill to create new skills that follow the Agent Skills spec. See `AGENTS.md` for the full list of available skills and their triggers.
diff --git a/docs/developer-guide/checks.mdx b/docs/developer-guide/checks.mdx
index 2f0f45cd96..5334799d36 100644
--- a/docs/developer-guide/checks.mdx
+++ b/docs/developer-guide/checks.mdx
@@ -445,3 +445,5 @@ The metadata structure is enforced in code using a Pydantic model. For reference
## Specific Check Patterns
Details for specific providers can be found in documentation pages named using the pattern `-details`.
+
+Checks that scan resources for plaintext secrets follow a dedicated batched structure. Refer to [Secret-Scanning Checks](/developer-guide/secret-scanning-checks) before creating or updating one.
diff --git a/docs/developer-guide/configurable-checks.mdx b/docs/developer-guide/configurable-checks.mdx
index 76b339601d..f550ff4f52 100644
--- a/docs/developer-guide/configurable-checks.mdx
+++ b/docs/developer-guide/configurable-checks.mdx
@@ -40,9 +40,184 @@ When adding a new configurable check to Prowler, update the following files:
# aws.awslambda_function_vpc_multi_az
lambda_min_azs: 2
```
+- **Provider Schema:** Add the typed field to the provider's Pydantic schema in `prowler/config/schema/.py`. This is required: the loader validates user configs against these schemas and the shipped `config.yaml` must round-trip with zero warnings. See [Adding a Parameter to the Provider Schema](#adding-a-parameter-to-the-provider-schema) below.
- **Test Fixtures:** If tests depend on this configuration, add the variable to `tests/config/fixtures/config.yaml`.
-- **Documentation:** Document the new variable in the list of configurable checks in `docs/tutorials/configuration_file.md`.
+- **Documentation:** Document the new variable in the list of configurable checks in [Configuration File](/user-guide/cli/tutorials/configuration_file) (`docs/user-guide/cli/tutorials/configuration_file.mdx`).
For a complete list of checks that already support configuration, see the [Configuration File Tutorial](/user-guide/cli/tutorials/configuration_file).
+
+Because a configurable check's verdict depends on the `audit_config` value it reads, a compliance requirement can lose meaning if the scan ran with a looser threshold than the control demands. Compliance frameworks can guard against this with **configuration guardrails**: a requirement declares the strictest configuration it tolerates and is forced to FAIL when the scan's config falls short. See [Configuration Guardrails for Requirements](/developer-guide/security-compliance-framework#configuration-guardrails-for-requirements).
+
+
+## Adding a Parameter to the Provider Schema
+
+Most providers have a typed Pydantic schema in `prowler/config/schema/`, registered in `prowler/config/schema/registry.py`. When a config is loaded and the provider has a registered schema, `validate_provider_config` checks each user-supplied key against it, logs a warning, and drops any field that fails validation. The consumer's `.get(key, default)` then falls back to the built-in default. Providers without a registered schema are passed through unchanged.
+
+This catches typos in a value (for example, `0.2` typed as `20`, or `"medium"` for an enum that expects `"MEDIUM"`). It does NOT catch typos in a key name: `disalowed_regions` (one `l` missing) is treated as an unknown key and passes through untouched, because third-party check plugins legitimately rely on unknown keys being preserved. Reviewers should still check that any new key the YAML adds is named exactly the same as the field on the schema.
+
+### Where to Add the Field
+
+1. Open `prowler/config/schema/.py` (for example, `aws.py`).
+2. Add a field on the provider's schema class. Always make it `Optional[...] = None` so the absence of the key is valid.
+3. Apply the tightest type the value allows. Examples below.
+
+If you are introducing an entirely new provider rather than a new parameter, also add an entry mapping the provider name to its schema class in `prowler/config/schema/registry.py`. The loader uses that registry to find the schema for the provider it is loading.
+
+### Choosing the Right Type
+
+| Value kind | Field declaration |
+|---|---|
+| Boolean toggle | `Optional[bool] = None` |
+| Strictly positive integer (days, counts) | `Optional[int] = Field(default=None, gt=0)` |
+| Fraction in 0..1 (threshold) | `Optional[float] = Field(default=None, ge=0.0, le=1.0)` |
+| Closed set of strings | `Optional[Literal["A", "B", "C"]] = None` |
+| Free-form string | `Optional[str] = None` |
+| List of strings or ints | `Optional[list[str]] = None` |
+
+Prefer `Literal[...]` over `str` whenever the value is one of a known set. Prefer `Field(gt=0)` over `int` whenever zero or negative would be nonsensical. The point of the schema is to catch real-world mistakes that previously passed silently.
+
+### Custom Validators (Only When Needed)
+
+If the value has structural rules beyond type and range, add a `field_validator`. Examples already in `aws.py`:
+
+- `_validate_port_range` rejects ports outside `0..65535`.
+- `_validate_account_ids` rejects anything that isn't a 12-digit AWS account ID.
+- `_validate_trusted_ips` rejects entries that aren't a valid IP or CIDR.
+
+Raise `ValueError` from the validator. The framework converts the error into a warning and drops the offending key.
+
+### Example: Adding a New Parameter
+
+Say a new check needs `max_iam_role_session_hours`, a strictly positive integer that defaults to 12 in code.
+
+1. **Schema** (`prowler/config/schema/aws.py`):
+ ```python
+ # IAM
+ max_iam_role_session_hours: Optional[int] = Field(default=None, gt=0)
+ ```
+2. **Shipped config** (`prowler/config/config.yaml`):
+ ```yaml
+ # aws.iam_role_session_duration_within_limit
+ max_iam_role_session_hours: 12
+ ```
+3. **Consumer** (the check):
+ ```python
+ max_hours = iam_client.audit_config.get("max_iam_role_session_hours", 12)
+ ```
+4. **Tests** in `tests/config/schema/aws_schema_test.py`:
+ - one test for a valid value that round-trips,
+ - one test for an invalid value (zero, negative, wrong type) that is dropped.
+
+### What the Loader Guarantees
+
+- **Unknown keys pass through.** Third-party check plugins can introduce arbitrary keys without schema edits; they will not be filtered.
+- **Invalid values never crash the run.** They produce a single warning per field and the key is dropped.
+- **Coerced values are normalized.** A YAML-quoted `"180"` for an `int` field arrives downstream as the integer `180`.
+- **The shipped `config.yaml` must round-trip cleanly.** The integration test `test_shipped_default_config_loads_without_warnings` will fail if a key is added to the YAML without a matching schema field, so the two stay in sync.
+
+## Configuration Value Limits
+
+Configurable thresholds enforce hard limits. A value outside the documented range is **dropped with a warning** and the check falls back to its built-in default (the same as if the key were absent). These bounds are intentionally conservative: they are not the absolute service maxima but the range that still produces a meaningful security check.
+
+Use this section as the reference when upgrading an existing config: if a value you set is being rejected, it is outside the range below.
+
+Only fields with a numeric range, a fixed value set, or a length cap are listed. Fields typed as free-form strings or lists (for example `disallowed_regions`, `secrets_ignore_patterns`, `trusted_account_ids`) have no range limit — they are validated for shape only (a 12-digit account ID, a valid IP/CIDR, a dotted version string), not for magnitude.
+
+### AWS
+
+| Key | Allowed range | Notes |
+|---|---|---|
+| `max_unused_access_keys_days` | `30..180` days | CIS AWS 1.13 recommends 45; NIST IA-5 ≤90 |
+| `max_console_access_days` | `30..180` days | CIS AWS 1.12 recommends 45 |
+| `max_unused_sagemaker_access_days` | `7..180` days | |
+| `max_security_group_rules` | `1..1000` | AWS hard limit is 1000 rules per security group |
+| `max_ec2_instance_age_in_days` | `1..1095` days | 3 years |
+| `ec2_high_risk_ports` | each port `1..65535` | port 0 is reserved |
+| `max_idle_disconnect_timeout_in_seconds` | `60..1800` s | NIST AC-12: cap at 30 min |
+| `max_disconnect_timeout_in_seconds` | `60..3600` s | |
+| `max_session_duration_seconds` | `600..86400` s | 10 min .. 24 h (AppStream per-session hard limit) |
+| `lambda_min_azs` | `1..6` | |
+| `recommended_cdk_bootstrap_version` | `1..100` | |
+| `log_group_retention_days` | one of `1, 3, 5, 7, 14, 30, 60, 90, 120, 150, 180, 365, 400, 545, 731, 1096, 1827, 2192, 2557, 2922, 3288, 3653` | only the CloudWatch Logs API-accepted retention values |
+| `threat_detection_privilege_escalation_threshold` | `0.0..1.0` | fraction of suspicious actions |
+| `threat_detection_privilege_escalation_minutes` | `5..43200` min | under 5 min the signal is mostly false positives |
+| `threat_detection_enumeration_threshold` | `0.0..1.0` | |
+| `threat_detection_enumeration_minutes` | `5..43200` min | |
+| `threat_detection_llm_jacking_threshold` | `0.0..1.0` | |
+| `threat_detection_llm_jacking_minutes` | `5..43200` min | |
+| `days_to_expire_threshold` (ACM) | `7..365` days | PCI-DSS 4.2.1.1: alert ≥30 days before expiry |
+| `elb_min_azs` | `1..6` | |
+| `elbv2_min_azs` | `1..6` | |
+| `minimum_snapshot_retention_period` | `1..35` days | ElastiCache service hard limit |
+| `max_days_secret_unused` | `7..365` days | |
+| `max_days_secret_unrotated` | `1..180` days | NIST IA-5: rotate quarterly; CIS ≤90 |
+| `min_kinesis_stream_retention_hours` | `24..8760` h | 1 day .. 1 year |
+| `shodan_api_key` | ≤512 chars | |
+
+### Azure
+
+| Key | Allowed range | Notes |
+|---|---|---|
+| `vm_backup_min_daily_retention_days` | `7..9999` days | Azure Backup hard limit; under 7 days defeats DR/ransomware recovery |
+| `apim_threat_detection_llm_jacking_threshold` | `0.0..1.0` | fraction of suspicious actions |
+| `apim_threat_detection_llm_jacking_minutes` | `5..43200` min | under 5 min the signal is mostly false positives |
+| `shodan_api_key` | ≤512 chars | |
+
+### GCP
+
+| Key | Allowed range | Notes |
+|---|---|---|
+| `mig_min_zones` | `1..5` | |
+| `max_snapshot_age_days` | `1..1095` days | 3 years |
+| `max_unused_account_days` | `30..365` days | |
+| `storage_min_retention_days` | `1..3650` days | |
+| `shodan_api_key` | ≤512 chars | |
+
+### Kubernetes
+
+| Key | Allowed range | Notes |
+|---|---|---|
+| `audit_log_maxbackup` | `2..1000` | CIS Kubernetes 1.2.18 recommends ≥10 |
+| `audit_log_maxsize` | `10..10000` MB | CIS Kubernetes 1.2.19 recommends ≥100 MB |
+| `audit_log_maxage` | `7..3650` days | CIS Kubernetes 1.2.17 recommends ≥30 days |
+
+### M365
+
+| Key | Allowed range | Notes |
+|---|---|---|
+| `sign_in_frequency` | `1..168` h | 1 h .. 7 days; Conditional Access baseline for admins ≤24 h |
+| `recommended_mailtips_large_audience_threshold` | `5..10000` | Microsoft default 25 |
+| `audit_log_age` | `30..3650` days | M365 E3 default 90 days; SEC/FINRA require ≥7 years |
+
+### GitHub
+
+| Key | Allowed range | Notes |
+|---|---|---|
+| `inactive_not_archived_days_threshold` | `30..3650` days | CIS GitHub recommends 180 |
+
+### Cloudflare
+
+| Key | Allowed range | Notes |
+|---|---|---|
+| `max_retries` | `0..10` | 0 disables retries |
+
+### MongoDB Atlas
+
+| Key | Allowed range | Notes |
+|---|---|---|
+| `max_service_account_secret_validity_hours` | `1..720` h | 1 h .. 30 days |
+
+### Vercel
+
+| Key | Allowed range | Notes |
+|---|---|---|
+| `days_to_expire_threshold` | `7..365` days | PCI-DSS 4.2.1.1: alert ≥30 days before expiry |
+| `stale_token_threshold_days` | `30..3650` days | NIST AC-2(3) typical window 30..90 days |
+| `stale_invitation_threshold_days` | `7..365` days | |
+| `max_owner_percentage` | `1..50` % | guidance recommends ≤25% |
+| `max_owners` | `1..1000` | absolute cap, overrides percentage for large teams |
+
+These bounds live in the provider schemas under `prowler/config/schema/`; each field's `Field(ge=..., le=...)` (or `field_validator`) is the source of truth and the descriptions there carry the full rationale.
+
This approach ensures that checks are easily configurable, making Prowler highly adaptable to different environments and requirements.
diff --git a/docs/developer-guide/documentation.mdx b/docs/developer-guide/documentation.mdx
index d0fb808af2..fd045a1751 100644
--- a/docs/developer-guide/documentation.mdx
+++ b/docs/developer-guide/documentation.mdx
@@ -8,9 +8,9 @@ Prowler documentation is built using [Mintlify](https://www.mintlify.com/docs),
The Prowler documentation is organized into several sections. The main ones are:
-- **Getting Started**: Provides an overview of the Prowler platform and its different solutions, including Prowler Cloud/App, Prowler CLI, Prowler MCP Server, Prowler Hub, and Prowler Lighthouse AI. This section helps new users understand which Prowler solution best fits their needs and includes product comparisons.
+- **Getting Started**: Provides an overview of the Prowler platform and its two product families, Prowler Products (Prowler Cloud, Prowler Hub, Prowler MCP, Prowler Lighthouse AI) and Open Source (Prowler CLI, Prowler Local Server). This section helps new users understand which Prowler solution best fits their needs and includes product comparisons.
-- **Guides**: Contains practical tutorials and how-to guides organized by product (Prowler Cloud/App, CLI) and provider (AWS, Azure, GCP, Kubernetes, Microsoft 365, GitHub, etc.). This section covers authentication, integrations, compliance, and advanced usage scenarios.
+- **Guides**: Contains practical tutorials and how-to guides organized by product (Prowler Cloud, CLI) and provider (AWS, Azure, GCP, Kubernetes, Microsoft 365, GitHub, etc.). This section covers authentication, integrations, compliance, and advanced usage scenarios.
- **Developer Guide**: Documentation for contributors looking to extend Prowler functionality. This includes guides on creating providers, services, checks, output formats, integrations, and compliance frameworks. Provider-specific implementation details and testing strategies are also covered here.
diff --git a/docs/developer-guide/end2end-testing.mdx b/docs/developer-guide/end2end-testing.mdx
index 0a62251531..df99de6fee 100644
--- a/docs/developer-guide/end2end-testing.mdx
+++ b/docs/developer-guide/end2end-testing.mdx
@@ -1,12 +1,12 @@
---
-title: 'End-2-End Tests for Prowler App'
+title: 'End-2-End Tests for Prowler Local Server'
---
-End-to-end (E2E) tests validate complete user flows in Prowler App (UI + API). These tests are implemented with [Playwright](https://playwright.dev/) under the `ui/tests` folder and are designed to run against a Prowler App environment.
+End-to-end (E2E) tests validate complete user flows in Prowler Local Server (UI + API). These tests are implemented with [Playwright](https://playwright.dev/) under the `ui/tests` folder and are designed to run against a Prowler Local Server environment.
## General Recommendations
-When adding or maintaining E2E tests for Prowler App, follow these guidelines:
+When adding or maintaining E2E tests for Prowler Local Server, follow these guidelines:
1. **Test real user journeys**
Focus on full workflows (for example, sign-up → login → add provider → launch scan) instead of low-level UI details already covered by unit or integration tests.
@@ -19,8 +19,8 @@ When adding or maintaining E2E tests for Prowler App, follow these guidelines:
3. **Use a Page Model (Page Object Model)**
- Encapsulate selectors and common actions in page classes instead of repeating them in each test.
- Leverage and extend the existing Playwright page models in `ui/tests`—such as `ProvidersPage`, `ScansPage`, and others—which are all based on the shared `BasePage`.
- - Page models for Prowler App pages should be placed in their respective entity folders (for example, `ui/tests/providers/providers-page.ts`).
- - Page models for external pages (not part of Prowler App) should be grouped in the `external` folder (for example, `ui/tests/external/github-page.ts`).
+ - Page models for Prowler Local Server pages should be placed in their respective entity folders (for example, `ui/tests/providers/providers-page.ts`).
+ - Page models for external pages (not part of Prowler Local Server) should be grouped in the `external` folder (for example, `ui/tests/external/github-page.ts`).
- This approach improves readability, reduces duplication, and makes refactors safer.
4. **Reuse authentication states (StorageState)**
@@ -193,7 +193,7 @@ When adding or maintaining E2E tests for Prowler App, follow these guidelines:
## Running Prowler Tests
-E2E tests for Prowler App run from the `ui` project using Playwright. The Playwright configuration lives in `ui/playwright.config.ts` and defines:
+E2E tests for Prowler Local Server run from the `ui` project using Playwright. The Playwright configuration lives in `ui/playwright.config.ts` and defines:
- `testDir: "./tests"` – location of E2E test files (relative to the `ui` project root, so `ui/tests`).
- `webServer` – how to start the Next.js development server and connect to Prowler API.
@@ -221,11 +221,11 @@ Before running E2E tests:
```
- **Ensure Prowler API is available**
- - By default, Playwright uses `NEXT_PUBLIC_API_BASE_URL=http://localhost:8080/api/v1` (configured in `playwright.config.ts`).
+ - By default, Playwright uses `UI_API_BASE_URL=http://localhost:8080/api/v1` (configured in `playwright.config.ts`).
- Start Prowler API so it is reachable on that URL (for example, via `docker-compose-dev.yml` or the development orchestration used locally).
- - If a different API URL is required, set `NEXT_PUBLIC_API_BASE_URL` accordingly before running the tests.
+ - If a different API URL is required, set `UI_API_BASE_URL` accordingly before running the tests.
-- **Ensure Prowler App UI is available**
+- **Ensure Prowler Local Server UI is available**
- Playwright automatically starts the Next.js server through the `webServer` block in `playwright.config.ts` (`pnpm run dev` by default).
- If the UI is already running on `http://localhost:3000`, Playwright will reuse the existing server when `reuseExistingServer` is `true`.
@@ -246,7 +246,7 @@ Before running E2E tests:
### Executing Tests
-To execute E2E tests for Prowler App:
+To execute E2E tests for Prowler Local Server:
1. **Run the full E2E suite (headless)**
diff --git a/docs/developer-guide/environment-variables.mdx b/docs/developer-guide/environment-variables.mdx
new file mode 100644
index 0000000000..913444d84b
--- /dev/null
+++ b/docs/developer-guide/environment-variables.mdx
@@ -0,0 +1,71 @@
+---
+title: 'Environment Variable Naming Convention'
+---
+
+Prowler is a monorepo composed of several runtime components — Prowler Local Server (the web user interface), Prowler API (the backend), Prowler SDK, and Prowler MCP Server (Model Context Protocol) — that frequently share a single `.env` file. To keep that shared configuration unambiguous, each component namespaces its environment variables with a component-specific prefix.
+
+## Component Prefixes
+
+Each component owns a dedicated prefix for the environment variables it reads:
+
+| Component | Prefix | Status |
+|-----------|--------|--------|
+| Prowler Local Server (web UI) | `UI_` | Adopted |
+| Prowler API (backend) | `API_` | Planned |
+| Prowler SDK | `SDK_` | Planned |
+| Prowler MCP Server | `MCP_` | Planned |
+
+## Why Component Prefixes Matter
+
+Component prefixes solve three concrete problems in a shared configuration file:
+
+- **Collisions in a shared `.env`:** Several components historically read identically named variables. The API base URL, for example, is consumed by more than one component, so a single unprefixed name is ambiguous. A component prefix removes that ambiguity.
+- **Explicit ownership:** A prefix states, at a glance, which component consumes a variable.
+- **Reduced accidental exposure:** For Prowler Local Server, scoping browser-facing configuration under one intentional prefix prevents server-only values from leaking into the client bundle.
+
+## Prowler Local Server
+
+Prowler Local Server has adopted the `UI_` prefix. Its public configuration is resolved from the container environment at runtime rather than inlined at build time, so a single pre-built image serves any deployment. For the operational details on changing these values without rebuilding the image, see [Troubleshooting](/troubleshooting).
+
+The former build-time variables map to the new runtime variables as follows:
+
+| Former variable | New variable |
+|-----------------|--------------|
+| `NEXT_PUBLIC_API_BASE_URL` | `UI_API_BASE_URL` |
+| `NEXT_PUBLIC_API_DOCS_URL` | `UI_API_DOCS_URL` |
+| `NEXT_PUBLIC_GOOGLE_TAG_MANAGER_ID` | `UI_GOOGLE_TAG_MANAGER_ID` |
+| `NEXT_PUBLIC_SENTRY_DSN`, `SENTRY_DSN` | `UI_SENTRY_DSN` |
+| `NEXT_PUBLIC_SENTRY_ENVIRONMENT`, `SENTRY_ENVIRONMENT` | `UI_SENTRY_ENVIRONMENT` |
+
+The build-time-only Sentry variables used for source-map upload — `SENTRY_ORG`, `SENTRY_PROJECT`, `SENTRY_AUTH_TOKEN`, and `SENTRY_RELEASE` — keep their names, as they are not part of Prowler Local Server's runtime configuration.
+
+## Enabling Third-Party Integrations
+
+Prowler Local Server gates each optional third-party integration behind an explicit enable flag. When an integration is configured through its new `UI_*` variables, it loads only when its flag is set to the exact string `"true"`; any other value, including unset, leaves it off. This default-off behavior keeps a deployment free of third-party egress unless it opts in. Deployments still using the deprecated legacy variable names keep loading without the flag, for backward compatibility (see [Deprecated Names](#deprecated-names)).
+
+| Integration | Enable flag | Required configuration when enabled |
+|-------------|-------------|-------------------------------------|
+| Sentry (error monitoring) | `UI_SENTRY_ENABLED` | `UI_SENTRY_DSN` |
+| Google Tag Manager | `UI_GOOGLE_TAG_MANAGER_ENABLED` | `UI_GOOGLE_TAG_MANAGER_ID` |
+| PostHog (product analytics) | `UI_POSTHOG_ENABLED` | `UI_POSTHOG_KEY` and `UI_POSTHOG_HOST` |
+
+When an integration is enabled but its required configuration is missing, Prowler Local Server fails fast at server startup with a clear error, so a misconfigured container never starts silently. A new `UI_*` value set while its enable flag is not `"true"` is ignored, and the server logs a one-time startup warning noting that the integration will not load. Legacy names follow the backward-compatible rule described in [Deprecated Names](#deprecated-names).
+
+PostHog support is currently limited to configuration validation: Prowler Local Server reads and validates the PostHog variables but does not yet load a PostHog client.
+
+
+Configuring an integration through the new `UI_*` variables now requires its enable flag. A deployment that adopted `UI_SENTRY_DSN` or `UI_GOOGLE_TAG_MANAGER_ID` must also set `UI_SENTRY_ENABLED=true` or `UI_GOOGLE_TAG_MANAGER_ENABLED=true` to keep the integration active. Deployments still using the legacy names (`NEXT_PUBLIC_*`, or `POSTHOG_KEY` and `POSTHOG_HOST`) keep working without the flag.
+
+
+## Upcoming Breaking Change
+
+
+Adopting the `API_`, `SDK_`, and `MCP_` prefixes for Prowler API, Prowler SDK, and Prowler MCP Server is a planned breaking change in a future release. Migrate environment configuration to the new names when upgrading.
+
+
+Prowler API, Prowler SDK, and Prowler MCP Server have not yet adopted the convention. In a future release, the variables each of these components reads will be namespaced under `API_`, `SDK_`, and `MCP_` respectively. The per-component mapping from current to prefixed names will be documented when each change is released.
+
+## Deprecated Names
+
+- **Prowler Local Server:** The bare server-side `SENTRY_DSN` and `SENTRY_ENVIRONMENT` are no longer read; the server and edge runtimes now read `UI_SENTRY_DSN` and `UI_SENTRY_ENVIRONMENT`. The former `NEXT_PUBLIC_*` names — and, for PostHog, the unprefixed `POSTHOG_KEY` and `POSTHOG_HOST` — are deprecated but stay backward compatible: they are read at runtime regardless of the enable flag, so an existing deployment keeps its integration active without opting in. The new `UI_*` names, by contrast, load only when the matching enable flag is set to `"true"`. These legacy names will be removed in a future release, so migrate to the `UI_*` runtime variables — and set the enable flag — on the running container.
+- **Prowler API, Prowler SDK, and Prowler MCP Server:** The current, unprefixed variable names are deprecated. They continue to work today and will be removed once the prefixed convention is adopted for each component, as described in [Upcoming Breaking Change](#upcoming-breaking-change).
diff --git a/docs/developer-guide/introduction.mdx b/docs/developer-guide/introduction.mdx
index 117640c669..08c38ee17c 100644
--- a/docs/developer-guide/introduction.mdx
+++ b/docs/developer-guide/introduction.mdx
@@ -108,6 +108,39 @@ uv sync
source .venv/bin/activate
```
+### Running the Local API Development Stack
+
+For API development, Prowler provides a Makefile-based local stack in addition to the manual and Docker Compose workflows documented in the API README. PostgreSQL, Valkey, and Neo4j run with Docker Compose, while Django and the Celery worker run natively through `uv`.
+
+Before using this method, ensure `docker compose`, `tmux`, and `uv` are installed.
+
+This workflow is designed for macOS and should also work on Linux when Docker, `tmux`, and `uv` are available. Windows requires script changes before it can be supported.
+
+To start the local API stack, run:
+
+```shell
+make dev
+```
+
+This command starts the required services, creates a `tmux` session with panes for the API, worker, and PostgreSQL logs, waits until the API responds, and prints the API URL and log file paths. The API is available at:
+
+```text
+http://localhost:8080/api/v1
+```
+
+Use these commands to manage the stack:
+
+```shell
+make dev-setup # Bootstrap dependencies, migrations, and fixtures
+make dev-attach # Attach to the tmux session
+make dev-launch # Start the stack on fixed ports and attach
+make dev-stop # Stop the tmux session and containers
+make dev-clean # Remove stopped development containers
+make dev-wipe # Stop everything and delete local development data
+make dev-status # Show development container status
+```
+
+The UI is not started by this workflow. Start it separately by following the UI development instructions in the `ui/` directory.
### Pre-Commit Hooks
@@ -207,7 +240,7 @@ prowler/
├── README.md # Project overview and getting started
├── Makefile # Common development commands
├── Dockerfile # SDK Docker container
-├── docker-compose.yml # Prowler App Docker compose
+├── docker-compose.yml # Prowler Local Server Docker compose
└── ... # Other supporting files
```
diff --git a/docs/developer-guide/lighthouse-architecture.mdx b/docs/developer-guide/lighthouse-architecture.mdx
index e8acc6278c..e12eff9549 100644
--- a/docs/developer-guide/lighthouse-architecture.mdx
+++ b/docs/developer-guide/lighthouse-architecture.mdx
@@ -132,7 +132,7 @@ The MCP client manages connections to the Prowler MCP Server using a singleton p
- **Connection Management**: Retry logic with configurable attempts and delays
- **Tool Discovery**: Fetches available tools from MCP server on initialization
-- **Authentication Injection**: Automatically adds JWT tokens to `prowler_app_*` tool calls
+- **Authentication Injection**: Automatically adds JWT tokens to `prowler_*` tool calls
- **Reconnection**: Supports forced reconnection after server restarts
Key constants:
@@ -141,10 +141,14 @@ Key constants:
- `RECONNECT_INTERVAL_MS`: 5 minutes before retry after failure
```typescript
-// Authentication injection for Prowler App tools
+// Authentication injection for core prowler_ tools (Hub/Docs excluded)
private handleBeforeToolCall = ({ name, args }) => {
- // Only inject auth for prowler_app_* tools (user-specific data)
- if (!name.startsWith("prowler_app_")) {
+ // Only inject auth for prowler_* tools (user-specific data).
+ // The legacy prowler_app_ prefix is also accepted for a resilient rollout.
+ if (
+ !name.startsWith("prowler_") &&
+ !name.startsWith("prowler_app_")
+ ) {
return { args };
}
@@ -307,15 +311,15 @@ MCP tools are organized into three namespaces based on authentication requiremen
| Namespace | Auth Required | Description |
|-----------|---------------|-------------|
-| `prowler_app_*` | Yes (JWT) | Prowler Cloud/App tools for findings, providers, scans, resources |
+| `prowler_*` | Yes (JWT) | Prowler Cloud, Prowler Private Cloud, and Prowler Local Server tools for findings, providers, scans, resources |
| `prowler_hub_*` | No | Security checks catalog, compliance frameworks |
| `prowler_docs_*` | No | Documentation search and retrieval |
### Authentication Flow
-1. User authenticates with Prowler App, receiving a JWT token
+1. User authenticates with Prowler Local Server, receiving a JWT token
2. Token is stored in session and propagated via `authContextStorage`
-3. MCP client injects `Authorization: Bearer ` header for `prowler_app_*` calls
+3. MCP client injects `Authorization: Bearer ` header for `prowler_*` calls
4. MCP Server validates token and applies RLS filtering
### Tool Execution Pattern
@@ -323,7 +327,7 @@ MCP tools are organized into three namespaces based on authentication requiremen
The agent uses meta-tools rather than direct tool registration:
```
-Agent needs data → describe_tool("prowler_app_search_findings")
+Agent needs data → describe_tool("prowler_search_findings")
→ Returns parameter schema → execute_tool with parameters
→ MCP client adds auth header → MCP Server executes
→ Results returned to agent → Agent continues reasoning
diff --git a/docs/developer-guide/llm-details.mdx b/docs/developer-guide/llm-details.mdx
index 3647552c0f..3dc0f2653d 100644
--- a/docs/developer-guide/llm-details.mdx
+++ b/docs/developer-guide/llm-details.mdx
@@ -101,4 +101,4 @@ The LLM provider seamlessly integrates with Prowler's existing infrastructure:
- **Output Formats**: Supports all Prowler output formats (JSON, CSV, HTML, etc.)
- **Compliance Frameworks**: Integrates with Prowler's compliance reporting
- **Fixer Integration**: Supports automated remediation recommendations
-- **Dashboard Integration**: Compatible with Prowler App for centralized management
+- **Dashboard Integration**: Compatible with Prowler Cloud and Prowler Local Server for centralized management
diff --git a/docs/developer-guide/mcp-server.mdx b/docs/developer-guide/mcp-server.mdx
index 4174f0f730..6d409ccc02 100644
--- a/docs/developer-guide/mcp-server.mdx
+++ b/docs/developer-guide/mcp-server.mdx
@@ -18,11 +18,15 @@ The Prowler MCP Server brings the entire Prowler ecosystem to AI assistants thro
The server follows a modular architecture with three independent sub-servers:
-| Sub-Server | Auth Required | Description |
-|------------|---------------|-------------|
-| Prowler App | Yes | Full access to Prowler Cloud and Self-Managed features |
-| Prowler Hub | No | Security checks catalog with **over 1000 checks**, fixers, and **70+ compliance frameworks** |
-| Prowler Documentation | No | Full-text search and retrieval of official documentation |
+| Sub-Server | Tool Prefix | Auth Required | Description |
+|------------|-------------|---------------|-------------|
+| Prowler | `prowler_` | Yes | Full access to Prowler Cloud, Prowler Private Cloud, and Prowler Local Server features |
+| Prowler Hub | `prowler_hub_` | No | Security checks catalog with **over 2,000 checks**, fixers, and **70+ compliance frameworks** |
+| Prowler Documentation | `prowler_docs_` | No | Full-text search and retrieval of official documentation |
+
+
+The core Prowler sub-server is served under the `prowler_` tool prefix, while its source lives in the `prowler_app/` module for historical reasons. Tool names use the prefix; import paths use the module.
+
For a complete list of tools and their descriptions, see the [Tools Reference](/getting-started/basic-usage/prowler-mcp-tools).
@@ -53,9 +57,9 @@ mcp_server/prowler_mcp_server/
The MCP Server uses two patterns for tool registration:
1. **Direct Decorators** (Prowler Hub/Docs): Tools are registered using `@mcp.tool()` decorators
-2. **Auto-Discovery** (Prowler App): All public methods of `BaseTool` subclasses are auto-registered
+2. **Auto-Discovery** (`prowler_app`): All public methods of `BaseTool` subclasses are auto-registered
-## Adding Tools to Prowler App
+## Adding Tools to the `prowler_app` Sub-Server
### Step 1: Create the Tool Class
@@ -413,7 +417,7 @@ uv run prowler-mcp
uv run prowler-mcp --transport http --host 0.0.0.0 --port 8000
# Run with environment variables
-PROWLER_APP_API_KEY="pk_xxx" uv run prowler-mcp
+PROWLER_API_KEY="pk_xxx" uv run prowler-mcp
```
For complete installation and deployment options, see:
diff --git a/docs/developer-guide/provider.mdx b/docs/developer-guide/provider.mdx
index d3e7d3631f..af621c005a 100644
--- a/docs/developer-guide/provider.mdx
+++ b/docs/developer-guide/provider.mdx
@@ -3211,21 +3211,18 @@ class YourProviderAPITestCase(APITestCase):
#### 2.6.1. Add your mocked provider to the tests
-If needed, add your mocked provider to the tests config file so you can use it on the tests.
+If needed, add a named provider fixture or extend the provider factory defaults so tests can request only the provider they need.
**File:** `api/src/backend/conftest.py`
```python
@pytest.fixture
-def providers_fixture(tenants_fixture):
- tenant, *_ = tenants_fixture
- providerX = Provider.objects.create(
+def your_provider(provider_factory):
+ return provider_factory(
provider="your_provider",
uid="your_uid",
alias="your_alias",
- tenant_id=tenant.id,
)
- return provider1, provider2, provider3, ... providerX
```
### 2.7. Compliance and Output Support
@@ -3421,7 +3418,7 @@ Use existing providers as templates, this will help you to understand better the
- **Documentation & Maintenance**
- **README Updates**: Update provider-specific documentation
- - **Changelog**: Document changes and new features
+ - **Changelog**: Document changes and new features with a fragment under `prowler/changelog.d/` (see the [Pull Request Template](https://github.com/prowler-cloud/prowler/blob/master/.github/pull_request_template.md))
- **Examples**: Provide usage examples and common scenarios
- **Troubleshooting**: Include common issues and solutions
- **Documentation**: Update the provider documentation to include your new tool provider in the examples and implementation guidance.
diff --git a/docs/developer-guide/prowler-studio.mdx b/docs/developer-guide/prowler-studio.mdx
index a4a2a609b5..b89eea7175 100644
--- a/docs/developer-guide/prowler-studio.mdx
+++ b/docs/developer-guide/prowler-studio.mdx
@@ -5,7 +5,7 @@ title: 'Prowler Studio'
**Prowler Studio is an AI workflow that ensures Claude Code follows Prowler's skills, guardrails, and best practices when creating new security checks.** What lands in the resulting pull request is consistent, tested, and ready for human review — not half-correct boilerplate that needs to be rewritten.
-**Contributor Tool**: Prowler Studio is a workflow for advanced contributors adding new Prowler security checks. It is not part of Prowler Cloud, Prowler App, or Prowler CLI.
+**Contributor Tool**: Prowler Studio is a workflow for advanced contributors adding new Prowler security checks. It is not part of Prowler Cloud, Prowler Local Server, or Prowler CLI.
diff --git a/docs/developer-guide/secret-scanning-checks.mdx b/docs/developer-guide/secret-scanning-checks.mdx
new file mode 100644
index 0000000000..5044366b7e
--- /dev/null
+++ b/docs/developer-guide/secret-scanning-checks.mdx
@@ -0,0 +1,119 @@
+---
+title: 'Secret-Scanning Checks'
+---
+
+import { VersionBadge } from "/snippets/version-badge.mdx"
+
+
+
+Prowler scans audited resources for plaintext secrets using [Kingfisher](https://github.com/mongodb/kingfisher), an open-source secret-scanning engine that Prowler invokes as a subprocess. This guide explains the structure every secret-scanning check must follow to keep scanning correct and efficient on large accounts.
+
+
+Since Prowler 5.32.0 the secret-scanning checks scan with Kingfisher. Earlier versions used the `detect-secrets` library.
+
+
+## Overview
+
+Secret detection runs through a single helper in `prowler/lib/utils/utils.py`:
+
+- **`detect_secrets_scan_batch(payloads, excluded_secrets=..., validate=...)`** scans many payloads in chunked subprocess invocations and returns a `{key: [findings]}` dictionary. To scan a single payload, pass a one-entry mapping (for example, `{0: data}`).
+
+Every Kingfisher invocation carries a fixed process-startup cost (around 100 ms). Scanning once per resource would spawn thousands of subprocesses on large accounts (for example, thousands of CloudWatch log groups). `detect_secrets_scan_batch` amortizes that cost: it writes each payload to a temporary file as it consumes them, runs one subprocess per chunk (500 payloads by default), and maps the findings back to each payload by key.
+
+## The Batched Structure
+
+Every secret-scanning check follows three phases.
+
+### Phase 1: Collect
+
+Define a generator that yields `(key, payload)` for each scannable unit. The generator builds payload strings only — it does not call Kingfisher. Lazy yielding keeps memory and temporary-disk usage bounded to a single chunk, which matters when an account holds thousands of resources.
+
+### Phase 2: Batch
+
+Call `detect_secrets_scan_batch` once with the generator. The helper consumes it in chunks, runs Kingfisher per chunk, and returns the keys that produced findings mapped to their finding lists.
+
+### Phase 3: Report
+
+Iterate the resources, look up the findings by key, and build one report per resource. Emit a finding for **every** iterated resource — never drop one silently. When a resource's payload cannot be prepared for scanning (for example, user data that fails to base64-decode or decompress), report it as `MANUAL` with a status explaining the scan could not inspect it, rather than omitting it or claiming `PASS`.
+
+```python
+from prowler.lib.check.models import Check, Check_Report_AWS
+from prowler.lib.utils.utils import (
+ annotate_verified_secrets,
+ detect_secrets_scan_batch,
+)
+from prowler.providers.aws.services.example.example_client import example_client
+
+
+class example_resource_no_secrets(Check):
+ def execute(self):
+ findings = []
+ excluded = example_client.audit_config.get("secrets_ignore_patterns", [])
+ validate = example_client.audit_config.get("secrets_validate", False)
+ resources = list(example_client.resources)
+
+ # Phase 1: collect — builds strings only, no scan.
+ def payloads():
+ for index, resource in enumerate(resources):
+ if resource.scannable_data:
+ yield index, serialize(resource)
+
+ # Phase 2: batch — one call, chunked subprocesses.
+ batch_results = detect_secrets_scan_batch(
+ payloads(), excluded_secrets=excluded, validate=validate
+ )
+
+ # Phase 3: report — look up findings by key.
+ for index, resource in enumerate(resources):
+ report = Check_Report_AWS(metadata=self.metadata(), resource=resource)
+ report.status = "PASS"
+ report.status_extended = f"No secrets found in {resource.name}."
+ detect_secrets_output = batch_results.get(index)
+ if detect_secrets_output:
+ report.status = "FAIL"
+ report.status_extended = (
+ f"Potential secret found in {resource.name} -> ..."
+ )
+ annotate_verified_secrets(report, detect_secrets_output)
+ findings.append(report)
+
+ return findings
+```
+
+## Choosing the Key
+
+The key maps each finding back to its source. Two shapes cover every check:
+
+- **One payload per resource:** use the resource index. This fits checks that serialize a single payload per resource, such as launch configurations, CloudFormation outputs, SSM documents, Step Functions definitions, and OpenStack metadata.
+- **Several payloads per resource:** use a `(resource_index, fragment)` tuple, where the fragment identifies the variable, log stream, container, file, or version. Phase 3 groups the per-fragment findings to build the resource report. This fits CloudWatch log streams, ECS containers, CodeBuild variables, Glue arguments, and Lambda code files.
+
+Derive the indices from the same `list(...)` of resources in both Phase 1 and Phase 3 so the order stays stable and the keys align.
+
+## Preserving Per-Payload Results
+
+`detect_secrets_scan_batch` runs Kingfisher with `--no-dedup`, so a secret that appears in more than one payload is reported for each one. This reproduces the result of scanning each payload individually. Build payload strings exactly as a single scan would: serialize the same data and keep line ordering, because messages often map a finding's `line_number` back to a variable name or metadata key.
+
+## Validation and Severity
+
+`detect_secrets_scan_batch` accepts `validate`, read from `secrets_validate` in the provider configuration or the `--scan-secrets-validate` flag. When enabled, Kingfisher confirms whether each secret is live, and confirmed secrets carry `is_verified: True`.
+
+After marking a report as `FAIL`, pass the findings to `annotate_verified_secrets(report, findings)`. When any secret is verified, the helper escalates the finding to critical severity and appends a note that the secret was confirmed live. Validation stays off by default because it sends the discovered secret to the provider API.
+
+## Excluded Secrets
+
+`detect_secrets_scan_batch` applies `secrets_ignore_patterns` — regular expressions from the provider configuration — against each finding's source line and drops the matches, mirroring single-scan behavior.
+
+## Testing
+
+To assert on the verified-secret path, mock `detect_secrets_scan_batch` in the check module and return the keyed dictionary. For a single resource scanned at index `0`:
+
+```python
+mock.patch(
+ "prowler.providers.aws.services.example.example_resource_no_secrets.example_resource_no_secrets.detect_secrets_scan_batch",
+ return_value={
+ 0: [{"type": "...", "line_number": 1, "is_verified": True}]
+ },
+)
+```
+
+Most tests need no mock at all: they seed resources that contain example secrets and assert on the `FAIL` status and message, which exercises the real batched path. Refer to the [Testing](/developer-guide/unit-testing) documentation for the general structure.
diff --git a/docs/developer-guide/security-compliance-framework.mdx b/docs/developer-guide/security-compliance-framework.mdx
index 431849689f..2eeabebe2a 100644
--- a/docs/developer-guide/security-compliance-framework.mdx
+++ b/docs/developer-guide/security-compliance-framework.mdx
@@ -2,40 +2,231 @@
title: 'Creating a New Security Compliance Framework in Prowler'
---
-This guide explains how to add a new security compliance framework to Prowler, end to end. It covers directory layout, the JSON schema, check mapping conventions, the Pydantic models that validate each framework, the CSV output formatter, local validation, testing, and the pull request process.
+import { VersionBadge } from "/snippets/version-badge.mdx"
+
+This guide explains how to add a new security compliance framework to Prowler, end to end. It covers directory layout, the two supported JSON schemas (universal and legacy), the Pydantic models that validate each framework, check mapping conventions, output formatting, local validation, testing, and the pull request process.
## Introduction
-A compliance framework in Prowler maps a public or custom control catalog (for example CIS, NIST 800-53, PCI DSS, HIPAA, ENS, CCC) to the security checks that Prowler already runs. Each requirement links to zero, one or more Prowler checks. When a scan executes, findings are aggregated per requirement to produce the compliance report rendered by Prowler CLI and Prowler Cloud.
+A compliance framework in Prowler maps a public or custom control catalog (for example CIS, NIST 800-53, PCI DSS, HIPAA, ENS, CCC, DORA) to the security checks that Prowler already runs. Each requirement links to zero, one or more Prowler checks. When a scan executes, findings are aggregated per requirement to produce the compliance report rendered by Prowler CLI and Prowler Cloud.
-Prowler ships with 85+ compliance frameworks across All Providers. The catalog lives under `prowler/compliance//` (or `prowler/compliance/` for universal compliance frameworks)
+Prowler ships 85+ compliance frameworks across all providers. The catalog lives under `prowler/compliance//` (legacy, per-provider) or `prowler/compliance/` (universal, multi-provider).
-A compliance framework must represent the **complete state** of the source catalog. Every requirement defined by the framework has to be present in the JSON file, even when none of the existing Prowler checks can automate it. In that case, leave `Checks` as an empty array, but do not omit the requirement.
+A compliance framework must represent the **complete state** of the source catalog. Every requirement defined by the framework has to be present in the JSON file, even when no Prowler check can automate it. In that case, leave the requirement's check list empty, but do not omit the requirement.
Requirement coverage feeds the compliance percentage calculations and the metadata surfaces (dashboards, widgets, exports). Missing requirements skew those metrics and break the report as a faithful snapshot of the framework.
+### Two supported schemas
+
+| Schema | When to use | File location | Discovered as |
+| --- | --- | --- | --- |
+| **Universal (recommended for new frameworks)** | Multi-provider frameworks, or single-provider frameworks that benefit from declarative table/PDF rendering | `prowler/compliance/.json` (top-level) | Available for **every** provider whose key appears in any `requirement.checks` dict |
+| **Legacy provider-specific** | Single-provider frameworks with framework-specific attribute classes already declared in the codebase (CIS, ENS, ISO 27001, etc.) | `prowler/compliance//__.json` | Available only under that provider |
+
+Auto-discovery happens in `get_bulk_compliance_frameworks_universal(provider)` (`prowler/lib/check/compliance_models.py`), which scans **both** the top-level `prowler/compliance/` directory and every per-provider sub-directory. Legacy frameworks are transparently converted to the universal `ComplianceFramework` model via `adapt_legacy_to_universal()` before being returned, so the rest of Prowler — CLI table rendering, CSV/OCSF outputs, PDF generation — works the same regardless of the source schema.
+
+> The legacy entry-point `Compliance.get_bulk(provider)` (used by older code paths) only scans per-provider sub-directories. Universal top-level files are picked up exclusively via the universal loader; this matters if you are wiring a new code path against the legacy API.
+
+For **new** frameworks, prefer the universal schema: it requires no Python code changes, supports multiple providers in a single file, and table/PDF rendering is driven entirely from declarative configuration inside the JSON.
+
+> All Pydantic models in `compliance_models.py` are imported from `pydantic.v1`. Subclasses you add for the legacy schema must use `from pydantic.v1 import BaseModel`.
+
### Prerequisites
Before adding a new framework, complete the following checks:
-- **Verify the framework is not already supported.** Inspect `prowler/compliance//` for an existing JSON file matching the name and version.
+- **Verify the framework is not already supported.** Inspect `prowler/compliance/` and every `prowler/compliance//` for an existing JSON file matching the name and version.
- **Confirm the required checks exist.** Every requirement that can be automated must point to one or more existing Prowler checks. For each missing check, implement it first by following the [Prowler Checks](/developer-guide/checks) guide.
-- **Review a reference framework.** Use an existing framework with a similar structure as your template. `cis_2.0_aws.json` is the canonical reference for CIS-style frameworks. `ccc_aws.json`, `ens_rd2022_aws.json`, and `nist_800_53_revision_5_aws.json` illustrate other attribute shapes.
+- **Review a reference framework.** Use an existing framework with a similar structure as your template:
+ - Universal: `prowler/compliance/dora_2022_2554.json`, `prowler/compliance/csa_ccm_4.0.json`.
+ - Legacy: `prowler/compliance/aws/cis_2.0_aws.json` (canonical CIS shape), `prowler/compliance/aws/ccc_aws.json`, `prowler/compliance/aws/ens_rd2022_aws.json`, `prowler/compliance/aws/nist_800_53_revision_5_aws.json`.
-## Four-Layer Architecture
+## Universal Compliance Framework
-A compliance framework spans four layers. A complete contribution must touch each layer that applies.
+### Where the file lives
-- **Layer 1 – Schema validation:** The Pydantic models in `prowler/lib/check/compliance_models.py` define the canonical schema for each attribute shape (CIS, ENS, Mitre, CCC, C5, CSA CCM, ISO 27001, KISA ISMS-P, AWS Well-Architected, Prowler ThreatScore, and a generic fallback).
-- **Layer 2 – JSON catalog:** The framework JSON file in `prowler/compliance//` lists every requirement and maps it to checks.
-- **Layer 3 – Output formatter:** The Python module in `prowler/lib/outputs/compliance//` builds the CSV row model, the per-provider transformer, and the CLI summary table.
-- **Layer 4 – Output dispatchers:** The dispatchers in `prowler/lib/outputs/compliance/compliance.py` and `prowler/lib/outputs/compliance/compliance_output.py` route findings to the right formatter based on the framework identifier.
+Place the file at the top level of the compliance directory:
-The rest of this guide walks each layer in order.
+```
+prowler/compliance/.json
+```
-## Directory Structure and File Naming
+Examples in the repository: `prowler/compliance/csa_ccm_4.0.json`, `prowler/compliance/dora_2022_2554.json`.
+
+The file is auto-discovered — there is **no** need to register it in any `__init__.py`, modify `prowler/lib/outputs/`, or update any other Python module. The framework key Prowler CLI accepts via `--compliance` is the basename of the JSON file without `.json` (`dora_2022_2554.json` → `dora_2022_2554`).
+
+### Top-level structure
+
+```json
+{
+ "framework": "",
+ "name": "",
+ "version": "",
+ "description": "",
+ "icon": "",
+ "attributes_metadata": [ /* see below */ ],
+ "outputs": { /* see below — optional */ },
+ "requirements": [ /* see below */ ]
+}
+```
+
+A `provider` field at the top level is **optional**. The framework's effective provider list is derived by `ComplianceFramework.get_providers()` (`compliance_models.py`) from the union of all keys appearing in `requirement.checks` across all requirements; the explicit `provider` field is used **only as a fallback** when no requirement carries any `checks` key. This is what enables a single file (e.g. `dora_2022_2554.json`) to cover AWS today and add Azure / GCP / etc. tomorrow without restructuring.
+
+Provider keys inside `requirement.checks` must match the directory names under `prowler/providers/`. The valid keys at present are: `aws`, `azure`, `gcp`, `m365`, `kubernetes`, `iac`, `github`, `googleworkspace`, `alibabacloud`, `cloudflare`, `mongodbatlas`, `nhn`, `openstack`, `oraclecloud`, `llm`. Comparison in `supports_provider()` is case-insensitive, but lowercase is the convention used everywhere in the repository.
+
+### `attributes_metadata`
+
+Declares the shape of the per-requirement `attributes` dict. When this field is present, the root validator `validate_attributes_against_metadata` (`compliance_models.py`) enforces the schema at load time and rejects:
+
+- Missing keys marked `required: true`.
+- Keys present in `attributes` but not declared in `attributes_metadata` (typo / drift guard).
+- Values that violate a declared `enum`.
+- Values whose Python type does not match a declared `int`, `float` or `bool`.
+
+The runtime type check **only** covers `int`, `float` and `bool`. For `str`, `list_str` and `list_dict` the type is documentation-only — non-conforming values won't fail validation. If `attributes_metadata` is omitted, **no per-requirement validation runs at all**.
+
+```json
+"attributes_metadata": [
+ {
+ "key": "Pillar",
+ "label": "Pillar",
+ "type": "str",
+ "required": true,
+ "enum": [
+ "ICT Risk Management",
+ "ICT-Related Incident Reporting",
+ "Digital Operational Resilience Testing",
+ "ICT Third-Party Risk Management",
+ "Information Sharing"
+ ],
+ "output_formats": { "csv": true, "ocsf": true }
+ },
+ {
+ "key": "Article",
+ "label": "Article",
+ "type": "str",
+ "required": true,
+ "output_formats": { "csv": true, "ocsf": true }
+ }
+]
+```
+
+Per attribute:
+
+- `key` (required): attribute name as it will appear in `requirement.attributes`.
+- `label`: human-readable label used in CSV headers and PDF.
+- `type`: one of `str`, `int`, `float`, `bool`, `list_str`, `list_dict`. Defaults to `str`.
+- `enum`: optional list of allowed values; non-conforming values are rejected at load time.
+- `required`: if `true`, every requirement must include this key with a non-null value.
+- `enum_display` / `enum_order`: optional per-enum-value visual metadata (label, abbreviation, color, icon) and explicit ordering for PDF rendering.
+- `output_formats`: `{ "csv": , "ocsf": }` — toggles inclusion in each output format. Both default to `true`.
+
+### `outputs`
+
+Optional. Controls how the framework is rendered in the console table and in the generated PDF report. Skipping it falls back to sensible defaults.
+
+```json
+"outputs": {
+ "table_config": {
+ "group_by": "Pillar"
+ },
+ "pdf_config": {
+ "language": "en",
+ "primary_color": "#003399",
+ "secondary_color": "#0055A5",
+ "bg_color": "#F0F4FA",
+ "group_by_field": "Pillar",
+ "sections": [ "ICT Risk Management", "ICT-Related Incident Reporting", "..." ],
+ "section_short_names": { "ICT Risk Management": "ICT Risk Mgmt" },
+ "charts": [
+ {
+ "id": "pillar_compliance",
+ "type": "horizontal_bar",
+ "group_by": "Pillar",
+ "title": "Compliance Score by Pillar",
+ "y_label": "Pillar",
+ "x_label": "Compliance %",
+ "value_source": "compliance_percent",
+ "color_mode": "by_value"
+ }
+ ],
+ "filter": { "only_failed": true, "include_manual": false }
+ }
+}
+```
+
+`table_config.group_by` must reference an attribute key declared in `attributes_metadata`. The same applies to `pdf_config.group_by_field` and to every `charts[].group_by`.
+
+For frameworks with weighted scoring (e.g. ThreatScore) declare `pdf_config.scoring` with `risk_field` / `weight_field` / `risk_boost_factor`. For column splitting (e.g. CIS Level 1 vs Level 2) use `table_config.split_by`.
+
+### `requirements`
+
+```json
+"requirements": [
+ {
+ "id": "DORA-Art5",
+ "name": "Governance and organisation",
+ "description": "Financial entities shall have a sound, comprehensive and well-documented ICT internal governance and control framework. ...",
+ "attributes": {
+ "Pillar": "ICT Risk Management",
+ "Article": "Article 5",
+ "ArticleTitle": "Governance and organisation"
+ },
+ "checks": {
+ "aws": [
+ "iam_avoid_root_usage",
+ "iam_no_root_access_key",
+ "iam_root_mfa_enabled"
+ ],
+ "azure": [],
+ "gcp": []
+ }
+ }
+]
+```
+
+Per requirement:
+
+- `id` (required): unique identifier within the framework.
+- `description` (required): the requirement text as authored by the framework.
+- `name`: short title shown alongside the id.
+- `attributes`: flat dict; keys must conform to `attributes_metadata`.
+- `checks`: dict keyed by provider name (the same lowercase keys listed in the previous section). Each value is a list of Prowler check names that evidence this requirement for that provider. The list **may be empty** and the dict itself defaults to `{}` if omitted; either way the requirement is still loaded and listed by `--list-compliance-requirements`, it just has zero checks to execute. Note: there is **no automatic check-existence validation** at load time — referencing a non-existent check name will silently produce a requirement with no findings. Validate this yourself (see "Validating Your Framework" below).
+- `config_requirements`: optional list of configuration guardrails. Each entry asserts that a configurable check referenced by this requirement ran with a configuration strict enough to actually satisfy the requirement; otherwise the requirement is forced to FAIL. See [Configuration Guardrails for Requirements](#configuration-guardrails-for-requirements) for the full schema and semantics. In the universal schema the field name is lowercase (`config_requirements`); legacy files use `ConfigRequirements`.
+
+For MITRE-style frameworks, additional optional fields are available on the requirement: `tactics`, `sub_techniques`, `platforms`, `technique_url` (these are populated automatically when adapting a legacy MITRE JSON to the universal model).
+
+### Multi-provider frameworks
+
+A single universal file can cover any number of providers. The framework appears under each provider's `--list-compliance` output as long as **at least one** requirement has that provider key in its `checks` dict.
+
+When extending an existing universal framework with a new provider, the only change required is editing `requirement.checks`:
+
+```diff
+ "checks": {
+ "aws": ["iam_avoid_root_usage", "iam_no_root_access_key"],
++ "azure": ["entra_policy_ensure_mfa_for_admin_roles"]
+ }
+```
+
+No code changes, no new file, no registration step.
+
+## Legacy Provider-Specific Compliance Framework
+
+The legacy schema is still fully supported and remains the format used by most frameworks shipped today (CIS, NIST, ISO 27001, FedRAMP, PCI DSS, GDPR, HIPAA, ENS, etc.). It binds a framework to a single provider and validates each requirement against a framework-specific Pydantic attribute class.
+
+The legacy schema spans **four layers** — a complete contribution must touch every layer that applies:
+
+- **Layer 1 — Schema validation:** the Pydantic models in `prowler/lib/check/compliance_models.py` define the canonical schema for each attribute shape.
+- **Layer 2 — JSON catalog:** the framework JSON file in `prowler/compliance//` lists every requirement and maps it to checks.
+- **Layer 3 — Output formatter:** the Python module in `prowler/lib/outputs/compliance//` builds the CSV row model, the per-provider transformer, and the CLI summary table.
+- **Layer 4 — Output dispatchers:** the dispatchers in `prowler/lib/outputs/compliance/compliance.py` and `prowler/lib/outputs/compliance/compliance_output.py` route findings to the right formatter based on the framework identifier.
+
+The universal schema collapses Layers 3 and 4 into declarative configuration inside the JSON — that is the main reason it is preferred for new contributions.
+
+### Directory structure and file naming
Compliance frameworks live at:
@@ -46,8 +237,8 @@ prowler/compliance//__.json
The filename conventions are:
- All lowercase, words separated with underscores.
-- `` is a supported provider identifier: `aws`, `azure`, `gcp`, `kubernetes`, `m365`, `github`, `googleworkspace`, `alibabacloud`, `oraclecloud`, `cloudflare`, `mongodbatlas`, `nhn`, `openstack`, `iac`, `llm`.
-- `` is optional. Omit it when the framework has no versioning, as in `ccc_aws.json`.
+- `` is a supported provider identifier (same lowercase list as the universal section above).
+- `` is optional but recommended. Omit only when the framework has no versioning (e.g. `ccc_aws.json`).
- The file basename (without `.json`) is the framework key that Prowler CLI accepts via `--compliance`.
Examples:
@@ -62,48 +253,51 @@ The output formatter directory mirrors the framework name:
```
prowler/lib/outputs/compliance//
-├── .py # CLI summary-table dispatcher
+├── .py # CLI summary-table dispatcher
├── _.py # Per-provider transformer class
├── models.py # Pydantic CSV row model
└── __init__.py
```
-## JSON Schema Reference
+### JSON schema reference
-Every compliance file is a JSON document with the following top-level keys.
+Every legacy compliance file is a JSON document with the following top-level keys. `Framework`, `Name` and `Provider` are validated non-empty by the root validator `framework_and_provider_must_not_be_empty` (`compliance_models.py`).
| Field | Type | Required | Description |
|---|---|---|---|
| `Framework` | string | Yes | Canonical framework identifier, for example `CIS`, `NIST-800-53-Revision-5`, `ENS`, `CCC`. |
-| `Name` | string | Yes | Human-readable framework name displayed by Prowler App. |
-| `Version` | string | Yes | Framework version, for example `2.0`. Use an empty string only for frameworks without versioning. See [Version Handling](#version-handling). |
+| `Name` | string | Yes | Human-readable framework name displayed by Prowler Cloud and Prowler Local Server. |
+| `Version` | string | Yes (recommended) | Framework version, e.g. `2.0`. See [Version Handling](#version-handling). |
| `Provider` | string | Yes | Upper-cased provider identifier: `AWS`, `AZURE`, `GCP`, `KUBERNETES`, `M365`, `GITHUB`, `GOOGLEWORKSPACE`, and so on. |
| `Description` | string | Yes | Short description of the framework's scope and purpose. |
| `Requirements` | array | Yes | List of [requirement objects](#requirement-object). |
-### Requirement Object
+#### Requirement Object
Each entry in `Requirements` describes one control or requirement.
| Field | Type | Required | Description |
|---|---|---|---|
| `Id` | string | Yes | Unique identifier within the framework, for example `1.10` or `CCC.Core.CN01.AR01`. |
-| `Name` | string | No | Optional human-readable name used by frameworks that distinguish control name from description, such as NIST. |
+| `Name` | string | No | Optional human-readable name (frameworks like NIST distinguish control name from description). |
| `Description` | string | Yes | Verbatim description from the source framework. |
| `Attributes` | array | Yes | List of [attribute objects](#attribute-objects). The shape depends on the framework. |
| `Checks` | array of strings | Yes | Prowler check identifiers that automate the requirement. Leave the list empty when the control cannot be automated. |
+| `ConfigRequirements` | array of objects | No | Optional [configuration guardrails](#configuration-guardrails-for-requirements). Each entry asserts that a configurable check ran with a configuration strict enough to satisfy the requirement; when it did not, the requirement is forced to FAIL. |
-### Attribute Objects
+#### Attribute Objects
-Attributes carry the metadata that Prowler App and the CSV output display for each requirement. The object shape is framework-specific and is validated by a dedicated Pydantic model in `prowler/lib/check/compliance_models.py`. The most common shapes are summarized below.
+`Attributes` is parsed against the union declared in `Compliance_Requirement.Attributes` (`compliance_models.py`). Pydantic v1 tries each member of the union in declaration order and falls back to `Generic_Compliance_Requirement_Attribute` (the last entry) when nothing else matches — so a brand-new shape that doesn't match any existing class will silently be accepted as Generic, losing its specific fields.
-#### CIS_Requirement_Attribute
+As of today, the registered attribute classes are: `CIS_Requirement_Attribute`, `ENS_Requirement_Attribute`, `ASDEssentialEight_Requirement_Attribute`, `ISO27001_2013_Requirement_Attribute`, `AWS_Well_Architected_Requirement_Attribute`, `KISA_ISMSP_Requirement_Attribute`, `Prowler_ThreatScore_Requirement_Attribute`, `CCC_Requirement_Attribute`, `C5Germany_Requirement_Attribute`, `CSA_CCM_Requirement_Attribute`, and `Generic_Compliance_Requirement_Attribute` (fallback). MITRE-style frameworks use the separate `Mitre_Requirement` model with `Tactics` / `SubTechniques` / `Platforms` / `TechniqueURL` at the requirement top level. The most common shapes are summarized below.
+
+##### CIS_Requirement_Attribute
Used by every CIS benchmark.
| Field | Type | Required | Notes |
|---|---|---|---|
-| `Section` | string | Yes | Top-level section, for example `1 Identity and Access Management`. |
+| `Section` | string | Yes | Top-level section, e.g. `1 Identity and Access Management`. |
| `SubSection` | string | No | Optional second-level grouping. |
| `Profile` | enum | Yes | One of `Level 1`, `Level 2`, `E3 Level 1`, `E3 Level 2`, `E5 Level 1`, `E5 Level 2`. |
| `AssessmentStatus` | enum | Yes | `Manual` or `Automated`. |
@@ -116,7 +310,7 @@ Used by every CIS benchmark.
| `DefaultValue` | string | No | Default configuration value, when relevant. |
| `References` | string | Yes | Colon-separated list of reference URLs. |
-#### ENS_Requirement_Attribute
+##### ENS_Requirement_Attribute
Used by the Spanish ENS (Esquema Nacional de Seguridad) frameworks.
@@ -132,13 +326,13 @@ Used by the Spanish ENS (Esquema Nacional de Seguridad) frameworks.
| `ModoEjecucion` | string | Yes | Execution mode (`manual`, `automático`, `híbrido`). |
| `Dependencias` | array of strings | Yes | Ids of prerequisite controls. Empty list when none. |
-#### CCC_Requirement_Attribute
+##### CCC_Requirement_Attribute
Used by the Common Cloud Controls Catalog.
| Field | Type | Required | Notes |
|---|---|---|---|
-| `FamilyName` | string | Yes | Control family, for example `Data`. |
+| `FamilyName` | string | Yes | Control family, e.g. `Data`. |
| `FamilyDescription` | string | Yes | Description of the family. |
| `Section` | string | Yes | Section title. |
| `SubSection` | string | Yes | Subsection title, or empty string. |
@@ -148,9 +342,9 @@ Used by the Common Cloud Controls Catalog.
| `SectionThreatMappings` | array of objects | Yes | Each entry has `ReferenceId` and `Identifiers`. |
| `SectionGuidelineMappings` | array of objects | Yes | Each entry has `ReferenceId` and `Identifiers`. |
-#### Generic_Compliance_Requirement_Attribute
+##### Generic_Compliance_Requirement_Attribute
-The fallback attribute model used when no framework-specific schema applies (for example NIST 800-53, PCI DSS, GDPR, HIPAA).
+The fallback attribute model used when no framework-specific schema applies (e.g. NIST 800-53, PCI DSS, GDPR, HIPAA). It is **always the last** element of the `Compliance_Requirement.Attributes` Union; that ordering is load-bearing.
| Field | Type | Required | Notes |
|---|---|---|---|
@@ -158,17 +352,17 @@ The fallback attribute model used when no framework-specific schema applies (for
| `Section` | string | No | Section name. |
| `SubSection` | string | No | Subsection name. |
| `SubGroup` | string | No | Subgroup name. |
-| `Service` | string | No | Affected service, for example `aws`, `iam`. |
+| `Service` | string | No | Affected service, e.g. `iam`. |
| `Type` | string | No | Control type. |
| `Comment` | string | No | Free-form comment. |
-Additional per-framework attribute models exist for `AWS_Well_Architected_Requirement_Attribute`, `ISO27001_2013_Requirement_Attribute`, `Mitre_Requirement_Attribute_`, `KISA_ISMSP_Requirement_Attribute`, `Prowler_ThreatScore_Requirement_Attribute`, `C5Germany_Requirement_Attribute`, and `CSA_CCM_Requirement_Attribute`. Consult `prowler/lib/check/compliance_models.py` for their full field sets.
+For the remaining attribute classes (`AWS_Well_Architected_Requirement_Attribute`, `ISO27001_2013_Requirement_Attribute`, `Mitre_Requirement_Attribute_`, `KISA_ISMSP_Requirement_Attribute`, `Prowler_ThreatScore_Requirement_Attribute`, `C5Germany_Requirement_Attribute`, `CSA_CCM_Requirement_Attribute`) consult `prowler/lib/check/compliance_models.py` for the full field sets.
-The `Attributes` field is a Pydantic `Union`. The generic attribute model must remain the last element of that Union, otherwise Pydantic v1 silently coerces every framework into the generic shape and your specialized fields are dropped.
+The `Attributes` field is a Pydantic `Union`. The generic attribute model **must** remain the last element of that Union — otherwise Pydantic v1 silently coerces every framework into the generic shape and your specialized fields are dropped. Adding a brand-new attribute shape requires inserting the Pydantic class **before** `Generic_Compliance_Requirement_Attribute`.
-## Minimal Working Example
+#### Minimal working example
The following snippet is a complete, valid framework file named `my_framework_1.0_aws.json`, saved at `prowler/compliance/aws/my_framework_1.0_aws.json`. It uses the generic attribute shape for simplicity.
@@ -214,26 +408,26 @@ The following snippet is a complete, valid framework file named `my_framework_1.
}
```
-## Mapping Checks to Requirements
+### Mapping checks to requirements
Each requirement links to the Prowler checks that, together, produce a PASS or FAIL verdict for that control.
-- **Include every requirement from the source catalog.** The framework file must mirror the full control list, one-to-one. Compliance percentages, dashboards, and exported metadata are computed against the total requirement count, so omitting an unmappable control inflates coverage and misrepresents the framework.
-- List every check by its canonical identifier, the value of `CheckID` inside the check's `.metadata.json` file.
+- **Include every requirement from the source catalog.** The framework file must mirror the full control list, one-to-one. Compliance percentages, dashboards, and exported metadata are computed against the total requirement count.
+- List every check by its canonical identifier — the value of `CheckID` inside the check's `.metadata.json` file.
- One requirement can reference multiple checks. The requirement is evaluated as FAIL when any referenced check produces a FAIL finding for a resource in scope.
-- Leave `Checks` as an empty array when the requirement cannot be automated. The requirement still appears in the report, contributes to the total, and resolves to `MANUAL`. An empty mapping is valid; a missing requirement is not.
+- Leave `Checks` (legacy) or `checks.` (universal) as an empty array when the requirement cannot be automated. The requirement still appears in the report and contributes to the total.
- Reuse checks across requirements when the same control applies in multiple places. Do not duplicate check logic to match framework structure.
-- Avoid referencing checks from a different provider. A compliance file is bound to one provider, and cross-provider checks will never match findings in the scan.
+- Avoid referencing checks from a different provider. A legacy compliance file is bound to one provider, and cross-provider checks will never match findings in the scan.
-To discover available checks, run:
+To discover available checks:
```bash
uv run python prowler-cli.py --list-checks
```
-## Supporting Multiple Providers
+### Supporting multiple providers (legacy)
-Each compliance file targets a single provider. To cover several providers with the same framework (for example CIS across AWS, Azure, and GCP), ship one JSON file per provider:
+The legacy schema binds each file to a single provider. To cover several providers with the same framework, ship one JSON file per provider:
```
prowler/compliance/aws/cis_2.0_aws.json
@@ -241,15 +435,15 @@ prowler/compliance/azure/cis_2.0_azure.json
prowler/compliance/gcp/cis_2.0_gcp.json
```
-Keep the `Framework` and `Version` values identical across the files so the dispatcher matches them, and change only the `Provider`, `Checks`, and provider-specific metadata.
+Keep the `Framework` and `Version` values identical across the files so the dispatcher matches them; change only the `Provider`, `Checks`, and provider-specific metadata. The CIS output formatter already supports every provider listed above.
-The CIS output formatter already supports every provider listed above. For a brand-new framework that spans several providers, add one transformer per provider in `prowler/lib/outputs/compliance//` and extend the summary-table dispatcher accordingly. See [Output Formatter](#output-formatter).
+For a brand-new framework that spans several providers, **prefer the universal schema** — it covers every provider from a single file. If you must use the legacy schema, add one transformer per provider in `prowler/lib/outputs/compliance//` and extend the summary-table dispatcher accordingly. See [Output Formatter](#output-formatter).
-## Output Formatter
+### Output formatter
-Prowler renders every compliance framework in two forms: a detailed CSV report written to disk, and a summary table printed in the CLI. Both are produced by the output formatter package for the framework.
+Legacy frameworks render in two forms: a detailed CSV report written to disk, and a summary table printed in the CLI. Both are produced by the output formatter package for the framework. Universal frameworks do **not** need a Python output formatter — the `outputs` config inside the JSON drives rendering — so this section applies only to the legacy schema.
-For a new framework named `my_framework`, create:
+For a new legacy framework named `my_framework`, create:
```
prowler/lib/outputs/compliance/my_framework/
@@ -259,19 +453,19 @@ prowler/lib/outputs/compliance/my_framework/
└── models.py # CSV row Pydantic model
```
-### Step 1 – Define the CSV Row Model
+#### Step 1 — Define the CSV row model
In `models.py`, declare a Pydantic v1 model with one field per CSV column. Use existing models such as `AWSCISModel` in `prowler/lib/outputs/compliance/cis/models.py` as the reference. Fields typically include `Provider`, `Description`, `AccountId`, `Region`, `AssessmentDate`, `Requirements_Id`, `Requirements_Description`, one `Requirements_Attributes_*` field per attribute key, plus the finding fields `Status`, `StatusExtended`, `ResourceId`, `ResourceName`, `CheckId`, `Muted`, `Framework`, `Name`.
-### Step 2 – Implement the Transformer Class
+#### Step 2 — Implement the transformer
In `my_framework_aws.py`, subclass `ComplianceOutput` from `prowler.lib.outputs.compliance.compliance_output` and implement `transform(findings, compliance, compliance_name)`. Iterate over `findings`, match each finding to the requirements it satisfies through `finding.compliance.get(compliance_name, [])`, and append one row per attribute to `self._data`.
-### Step 3 – Add the Summary-Table Dispatcher
+#### Step 3 — Add the summary-table dispatcher
In `my_framework.py`, implement `get_my_framework_table(findings, bulk_checks_metadata, compliance_framework, output_filename, output_directory, compliance_overview)` following the pattern in `prowler/lib/outputs/compliance/cis/cis.py`.
-### Step 4 – Register the Framework in the Dispatchers
+#### Step 4 — Register the framework in the dispatchers
- Add the dispatcher call in `prowler/lib/outputs/compliance/compliance.py`, inside `display_compliance_table`, with a branch such as `elif "my_framework" in compliance_framework:`.
- Register the CSV model and transformer in `prowler/lib/outputs/compliance/compliance_output.py` so the CSV file is emitted during the scan.
@@ -280,58 +474,278 @@ In `my_framework.py`, implement `get_my_framework_table(findings, bulk_checks_me
For NIST-style catalogs that use `Generic_Compliance_Requirement_Attribute`, no custom formatter is needed. The generic formatter in `prowler/lib/outputs/compliance/generic/` handles them automatically, provided the JSON validates against the generic attribute schema.
-## Version Handling
+### Legacy-to-universal adapter
+
+At load time, every legacy file is transparently adapted to a `ComplianceFramework` via `adapt_legacy_to_universal()` (`compliance_models.py`), which: (a) flattens the first element of `Attributes` into a flat `attributes` dict, (b) wraps `Checks` as `{provider_lower: [...]}`, (c) infers `attributes_metadata` from the matched Pydantic class via `_infer_attribute_metadata()`. The rest of Prowler (CSV/OCSF/PDF output, CLI table) then treats both formats identically.
+
+Loader-error behaviour differs between the two entry points:
+
+- `load_compliance_framework()` (legacy) is **fail-fast**: it calls `sys.exit(1)` on any `ValidationError` (`compliance_models.py`).
+- `load_compliance_framework_universal()` is more lenient — it logs the error and returns `None`, so `get_bulk_compliance_frameworks_universal()` simply skips the broken file and keeps loading the rest.
+
+## Configuration Guardrails for Requirements
+
+
+
+Some requirements are only truly satisfied when the configurable checks behind them ran with a configuration strict enough to meet the control. A [configurable check](/developer-guide/configurable-checks) reads thresholds from the scan's `audit_config`, so loosening a value can make the check PASS while the requirement it backs is, in fact, not satisfied.
+
+A worked example: CIS AWS 6.0 requirement 2.11 ("credentials unused for 45 days or more are disabled") maps to `iam_user_accesskey_unused`, which is driven by the `max_unused_access_keys_days` config key. If a user raises that value to `120`, the check passes for a key unused for 90 days — yet the requirement explicitly demands a 45-day threshold, so the PASS is misleading.
+
+Configuration guardrails close that gap. A requirement declares the configuration it expects, and when the scan ran with a configuration too loose to honor it, the requirement is forced to **FAIL** in every compliance output, with the reason surfaced in the finding's extended status.
+
+
+Guardrails are an **optional** safety net for configurable checks. A requirement that maps only to non-configurable checks does not need them. When the field is absent, behavior is unchanged.
+
+
+### Where guardrails are declared
+
+The field is attached to each requirement and exists in both schemas:
+
+- **Legacy** (`prowler/compliance//...`): `ConfigRequirements`, a list of objects, validated against the `Compliance_Requirement_ConfigConstraint` Pydantic model (`prowler/lib/check/compliance_models.py`).
+- **Universal** (`prowler/compliance/...`): `config_requirements`, the same list of objects as plain dicts on `UniversalComplianceRequirement`.
+
+When a legacy file is adapted to the universal model, `adapt_legacy_to_universal()` copies `ConfigRequirements` into `config_requirements` (`compliance_models.py`), so downstream code only ever reads one shape.
+
+### Constraint schema
+
+Each entry in the list is a single constraint with the following fields:
+
+| Field | Type | Required | Description |
+|---|---|---|---|
+| `Check` | string | Yes | The configurable check this constraint guards. Should be one of the requirement's `Checks`. Used only to build a human-readable reason. |
+| `ConfigKey` | string | Yes | The `audit_config` key the check reads (for example `max_unused_access_keys_days`). |
+| `Operator` | enum | Yes | How to compare the applied value against `Value`. One of `lte`, `gte`, `eq`, `in`, `subset`, `superset`. |
+| `Value` | bool, int, float, string, or list | Yes | The strictest configuration the requirement tolerates. The accepted Python type depends on the operator (see below). |
+| `Provider` | string | No | The provider this constraint applies to (e.g. `aws`). **Required for universal (multi-provider) frameworks**, where the same requirement maps checks across providers — the constraint is only evaluated when the scanned provider matches. Single-provider (legacy) frameworks omit it. |
+
+### Operators
+
+| Operator | Applied value satisfies the guardrail when… | Typical use |
+|---|---|---|
+| `lte` | `applied <= Value` | Maximum-age / maximum-count thresholds (e.g. `max_unused_access_keys_days <= 45`). |
+| `gte` | `applied >= Value` | Minimum-retention / minimum-count thresholds. |
+| `eq` | `applied == Value` | Boolean toggles or an exact required value (e.g. `mute_non_default_regions == false`). |
+| `in` | `applied` is one of `Value` (a list) | The applied scalar must belong to an allowed set. |
+| `subset` | `set(applied) <= set(Value)` | **Allowlist** configs — every applied value must already be permitted. Widening the allowlist with a weaker value (e.g. adding TLS `1.0` to `recommended_minimal_tls_versions`) breaks the guardrail. |
+| `superset` | `set(applied) >= set(Value)` | **Denylist** configs — every forbidden value must remain forbidden. Removing an entry from a denylist (e.g. dropping a weak algorithm from `insecure_key_algorithms`) breaks the guardrail. |
+
+
+`subset` / `superset` require both the applied value and `Value` to be lists; any other type is treated as not satisfied. For `eq` against a boolean, declare `Value` as a JSON boolean (`false`, not `0`) — the model keeps booleans distinct from integers.
+
+
+### How guardrails are evaluated
+
+All evaluation lives in one shared module, `prowler/lib/check/compliance_config_eval.py`, consumed by every compliance output (CSV, OCSF, and the CLI tables) and reused by the Prowler API backend so the rule is defined exactly once.
+
+1. The applied configuration is the scan-global `audit_config` (the same mapping for every resource and region), resolved via `get_scan_audit_config()`.
+2. For each requirement that declares constraints, `evaluate_config_constraints()` walks the list and returns `(is_compliant, reason)`. The requirement is compliant when **every** explicitly-set key satisfies its constraint.
+3. A constraint tagged with a `Provider` that does **not** match the provider being scanned (resolved via `get_scan_provider_type()`) is **skipped**. This scopes a universal framework's constraints to the right provider, so a guardrail authored for an AWS check never affects a GCP or Azure scan of the same requirement. Untagged constraints (legacy single-provider frameworks) always apply.
+4. A constraint whose `ConfigKey` is **not present** in `audit_config` is **skipped** — the check's built-in default is assumed to already match what the requirement expects. This is why nothing changes for the default configuration.
+5. When a constraint is violated, the finding's status is overridden to `FAIL` and a plain-language explanation is prepended to `status_extended` (via `apply_config_status()`). The message opens with `Configuration not valid for this requirement.` and names the check, the value the scan applied, what the requirement needs and how to fix it. For the table generators, `get_effective_status()` applies the same FAIL roll-up so per-section counts stay consistent.
+
+
+Guardrails only ever make a result **stricter** (they can turn PASS into FAIL); they never relax a real FAIL into PASS. A requirement with no constraints, or whose keys all use defaults, is reported exactly as before.
+
+
+### Example: legacy framework
+
+From `prowler/compliance/aws/cis_6.0_aws.json`, requirement 2.11 declares two guardrails — one per configurable check it maps to:
+
+```json title="prowler/compliance/aws/cis_6.0_aws.json"
+{
+ "Id": "2.11",
+ "Description": "Ensure credentials unused for 45 days or more are disabled.",
+ "Checks": [
+ "iam_user_accesskey_unused",
+ "iam_user_console_access_unused"
+ ],
+ "ConfigRequirements": [
+ {
+ "Check": "iam_user_accesskey_unused",
+ "ConfigKey": "max_unused_access_keys_days",
+ "Operator": "lte",
+ "Value": 45
+ },
+ {
+ "Check": "iam_user_console_access_unused",
+ "ConfigKey": "max_console_access_days",
+ "Operator": "lte",
+ "Value": 45
+ }
+ ],
+ "Attributes": [ /* ... */ ]
+}
+```
+
+A boolean guardrail from the same file: requirement 2.5 (IAM Access Analyzer) only holds when regions are not muted, so a scan with `mute_non_default_regions: true` cannot be trusted for it:
+
+```json
+"ConfigRequirements": [
+ {
+ "Check": "accessanalyzer_enabled",
+ "ConfigKey": "mute_non_default_regions",
+ "Operator": "eq",
+ "Value": false
+ }
+]
+```
+
+### Example: universal framework
+
+The universal schema uses the lowercase `config_requirements` key with the identical object shape:
+
+```json
+{
+ "id": "MF-2.1",
+ "name": "Restrict TLS to modern versions",
+ "description": "Endpoints must negotiate only TLS 1.2 or higher.",
+ "checks": {
+ "aws": ["elbv2_listener_ssl_listeners"]
+ },
+ "config_requirements": [
+ {
+ "Check": "elbv2_listener_ssl_listeners",
+ "Provider": "aws",
+ "ConfigKey": "recommended_minimal_tls_versions",
+ "Operator": "subset",
+ "Value": ["TLS 1.2", "TLS 1.3"]
+ }
+ ]
+}
+```
+
+Each constraint declares the `Provider` it targets so the guardrail is only evaluated on scans of that provider — essential for universal frameworks like CSA CCM and DORA, where one requirement maps checks across `aws`, `azure`, `gcp` and more. Because the operator is `subset`, adding `"TLS 1.0"` to `recommended_minimal_tls_versions` widens the allowlist beyond `["TLS 1.2", "TLS 1.3"]` and the requirement is forced to FAIL.
+
+### What the user sees
+
+With a loosened config, the affected requirement's findings report:
+
+```text
+Status: FAIL
+StatusExtended: Configuration not valid for this requirement. The check
+ iam_user_accesskey_unused has max_unused_access_keys_days set
+ to 120, but the requirement needs a value of 45 or lower.
+ Update it to 45 or lower.
+```
+
+The same `Configuration not valid for this requirement.` message appears identically across the CSV, OCSF, and console-table outputs.
+
+### Authoring guidelines
+
+- Declare a guardrail only for keys whose value actually changes whether the requirement is met. Most configurable checks do not need one.
+- Set `Value` to the **strictest** configuration the control tolerates — the same number the control text cites (CIS 45 days, NIST ≤90, and so on).
+- Keep `ConfigKey` spelled exactly as the check reads it from `audit_config`; an unknown key is never present in the config and the constraint is silently skipped.
+- In a **universal (multi-provider) framework**, always set `Provider` to the provider that owns `Check` — otherwise the guardrail would leak onto scans of the other providers the requirement maps. Legacy single-provider files omit it.
+- Pick the operator from the value's role: a max threshold is `lte`, a min threshold is `gte`, a toggle is `eq`, an allowlist is `subset`, a denylist is `superset`.
+- An unrecognized operator does **not** block the requirement — a malformed constraint is treated as satisfied rather than failing the whole framework. Validate your JSON with the tests below.
+
+### Testing guardrails
+
+The shared evaluator and the per-output integration are covered by:
+
+- `tests/lib/check/compliance_config_eval_test.py` — operator semantics, skipped-key behavior, and the FAIL override.
+- `tests/lib/check/compliance_config_constraint_model_test.py` — model validation (types, operator enum, bool-vs-int).
+- `tests/lib/check/compliance_config_requirements_data_test.py` — sanity-checks the guardrails shipped in the JSON catalog.
+- Per-output tests under `tests/lib/outputs/compliance/` (CIS AWS/Azure, ENS AWS, OCSF, universal table) confirm the override reaches each format.
+
+Run them with:
+
+```bash
+uv run pytest -n auto \
+ tests/lib/check/compliance_config_eval_test.py \
+ tests/lib/check/compliance_config_constraint_model_test.py \
+ tests/lib/check/compliance_config_requirements_data_test.py \
+ tests/lib/outputs/compliance/
+```
+
+## Version handling
Prowler matches frameworks by concatenating `Framework` and `Version`. A missing or empty `Version` collapses several frameworks to the same key and breaks CLI filtering with `--compliance`.
-- Always set `Version` to a non-empty string, even for frameworks that rename editions rather than version them. Use the edition identifier (for example `RD2022`, `v2025.10`, `4.0`).
+- Always set `Version` (or `version` for universal frameworks) to a non-empty string, even for frameworks that rename editions rather than version them. Use the edition identifier (for example `RD2022`, `v2025.10`, `4.0`, `2022/2554`).
- When the source catalog has no version, use the first year of adoption or the release date.
-- Make sure the version substring embedded in the filename matches `Version`, because the CLI dispatcher reads `compliance_framework.split("_")[1]` to select the correct version.
+- For **legacy** files, make sure the version substring embedded in the filename matches `Version`, because the CLI dispatcher reads `compliance_framework.split("_")[1]` to select the correct version.
-## Validating the Framework Locally
+## Validating Your Framework
-Follow the steps below before opening a pull request.
+Before opening a PR, validate the JSON loads cleanly against the model and that every referenced check actually exists.
-### 1. Run the Compliance Model Validator
+### 1. Schema validation
+
+For **universal** frameworks, load the file and inspect what was parsed. The framework key inside `bulk` is the **basename of the JSON file** (without `.json`); for `prowler/compliance/dora_2022_2554.json` that key is `dora_2022_2554`, for `prowler/compliance/aws/cis_5.0_aws.json` it is `cis_5.0_aws`.
+
+```python
+from prowler.lib.check.compliance_models import (
+ load_compliance_framework_universal,
+ get_bulk_compliance_frameworks_universal,
+)
+
+fw = load_compliance_framework_universal("prowler/compliance/.json")
+assert fw is not None, "load returned None — check the logs for the validation error"
+print(fw.framework, len(fw.requirements), fw.get_providers())
+
+bulk = get_bulk_compliance_frameworks_universal("aws")
+assert "" in bulk
+```
+
+### 2. Check existence cross-check
+
+There is **no automatic check-existence validation** at load time. Cross-check that every check name in your framework maps to a real check directory:
+
+```python
+import os
+real = set()
+for svc in os.listdir("prowler/providers/aws/services"):
+ svc_path = f"prowler/providers/aws/services/{svc}"
+ if not os.path.isdir(svc_path):
+ continue
+ for entry in os.listdir(svc_path):
+ if os.path.isfile(f"{svc_path}/{entry}/{entry}.metadata.json"):
+ real.add(entry)
+
+referenced = {c for r in fw.requirements for c in r.checks.get("aws", [])}
+missing = referenced - real
+assert not missing, f"checks referenced in framework but not found in repo: {sorted(missing)}"
+```
+
+### 3. CLI smoke test
```bash
uv run python prowler-cli.py --list-compliance
```
-The framework must appear in the output. A validation error indicates a schema mismatch between the JSON file and the attribute model.
-
-### 2. Run a Scan Filtered by the Framework
+The framework must appear in the output. A validation error indicates a schema mismatch.
```bash
uv run python prowler-cli.py \
- --compliance __ \
+ --compliance \
--log-level ERROR
```
Verify that:
- Prowler produces a CSV file under `output/compliance/` with the expected name.
-- The CLI summary table lists every section in the framework.
+- The CLI summary table lists every section / pillar of the framework.
- Findings roll up under the expected requirements.
-### 3. Inspect the CSV Output
+### 4. Inspect the CSV output
Open the generated CSV and confirm:
-- All columns defined in `models.py` appear.
-- Every requirement has at least one row per scanned resource.
-- Values such as `Requirements_Attributes_Section` reflect the JSON content.
+- All columns defined in `models.py` (legacy) or in `attributes_metadata` (universal) appear.
+- Every requirement has at least one row per scanned resource (when there are findings).
+- Attribute values such as `Requirements_Attributes_Section` reflect the JSON content.
-### 4. Verify the Framework in Prowler App
+### 5. Verify the Framework in Prowler Local Server
-Launch Prowler App locally (`docker compose up` from the repository root) and run a scan with the new compliance framework. Confirm the compliance page renders the requirements, sections, and status widgets correctly.
+Launch Prowler Local Server (`docker compose up` from the repository root) and run a scan with the new compliance framework. Confirm the compliance page renders the requirements, sections, and status widgets correctly.
## Testing
Compliance contributions require two layers of tests.
- **Schema tests** exercise the Pydantic models. Extend `tests/lib/check/universal_compliance_models_test.py` with a case that loads the new JSON file and asserts the attribute type matches the expected model.
-- **Output tests** exercise the transformer. Mirror the structure under `tests/lib/outputs/compliance//` with fixtures that feed synthetic findings through the transformer and assert the resulting CSV rows.
+- **Output tests** (legacy frameworks only) exercise the transformer. Mirror the structure under `tests/lib/outputs/compliance//` with fixtures that feed synthetic findings through the transformer and assert the resulting CSV rows.
Run the suite with:
@@ -342,7 +756,20 @@ uv run pytest -n auto tests/lib/check/universal_compliance_models_test.py \
For guidance on writing Prowler SDK tests, refer to [Unit Testing](/developer-guide/unit-testing).
-## Submitting the Pull Request
+## Running and listing your framework
+
+Once the file is in place, the CLI auto-discovers it:
+
+```sh
+prowler --list-compliance # framework appears in the list
+prowler --compliance --list-checks
+prowler --compliance # full scan + compliance report
+prowler --compliance --list-compliance-requirements
+```
+
+For end-user-facing tutorials (recommended for high-profile frameworks), add a dedicated page under `docs/user-guide/compliance/tutorials/` and register it in the `"Compliance"` group of `docs/docs.json`. See `docs/user-guide/compliance/tutorials/threatscore.mdx` as a reference.
+
+## Submitting the pull request
Before opening the pull request:
@@ -351,29 +778,32 @@ Before opening the pull request:
uv run pre-commit run --all-files
uv run pytest -n auto
```
-2. Add a changelog entry under the `### 🚀 Added` section of `prowler/CHANGELOG.md`, describing the new framework and the providers it covers.
-3. Follow the [Pull Request Template](https://github.com/prowler-cloud/prowler/blob/master/.github/pull_request_template.md) and set the PR title using Conventional Commits, for example `feat(compliance): add My Framework 1.0 for AWS`.
+2. Add a changelog fragment `prowler/changelog.d/.added.md`, describing the new framework and the providers it covers (no PR link in the text; it is attached automatically at release time).
+3. Follow the [Pull Request Template](https://github.com/prowler-cloud/prowler/blob/master/.github/pull_request_template.md) and set the PR title using Conventional Commits, e.g. `feat(compliance): add My Framework 1.0 for AWS`.
4. Request review from the compliance codeowners listed in `.github/CODEOWNERS`.
## Troubleshooting
The following issues are the most common when contributing a compliance framework.
-- **`ValidationError: field required` during scan.** The JSON is missing a required attribute field. Re-check the matching Pydantic model in `prowler/lib/check/compliance_models.py`.
-- **All attributes collapse to `Generic_Compliance_Requirement_Attribute` values.** The Pydantic `Union` is ordered incorrectly, or the JSON matches only the generic shape. Move the generic model to the last Union position and ensure every required field is present in the JSON.
-- **`--compliance` filter does not find the framework.** The filename does not match the expected pattern `__.json`, the version is empty, or the file lives outside `prowler/compliance//`.
-- **CLI summary table is empty but the CSV is populated.** The dispatcher branch in `prowler/lib/outputs/compliance/compliance.py` is missing or its substring match does not catch the framework key.
-- **CSV file is missing after the scan.** The transformer class is not registered in `prowler/lib/outputs/compliance/compliance_output.py`, or `transform()` raises silently. Run the scan with `--log-level DEBUG`.
-- **Findings do not roll up under a requirement.** A check listed in `Checks` either does not exist for that provider or is spelled incorrectly. Run `--list-checks | grep ` to confirm.
+- **`ValidationError: field required` during scan (legacy).** The JSON is missing a required attribute field. Re-check the matching Pydantic model in `prowler/lib/check/compliance_models.py`.
+- **All attributes collapse to `Generic_Compliance_Requirement_Attribute` values (legacy).** The Pydantic `Union` is ordered incorrectly, or the JSON matches only the generic shape. Keep the generic model in the last Union position and ensure every required field is present in the JSON.
+- **`attributes_metadata validation failed` (universal).** The root validator in `compliance_models.py` rejected the file. The error message lists each offending requirement; common causes are unknown attribute keys (typo or missing entry in `attributes_metadata`), enum violations, or missing required keys.
+- **`--compliance` filter does not find the framework.** For legacy: the filename does not match `__.json`, the version is empty, or the file lives outside `prowler/compliance//`. For universal: the file is not at the top level of `prowler/compliance/` or it loaded as `None` (check logs for the validation error).
+- **CLI summary table is empty but the CSV is populated (legacy).** The dispatcher branch in `prowler/lib/outputs/compliance/compliance.py` is missing or its substring match does not catch the framework key.
+- **CSV file is missing after the scan (legacy).** The transformer class is not registered in `prowler/lib/outputs/compliance/compliance_output.py`, or `transform()` raises silently. Run the scan with `--log-level DEBUG`.
+- **Findings do not roll up under a requirement.** A check listed in `Checks` either does not exist for that provider or is spelled incorrectly. Run `--list-checks | grep ` to confirm, or run the check-existence cross-check from "Validating Your Framework".
-## Reference Examples
+## Reference examples
Use the following files as templates when modeling a new contribution.
-- `prowler/compliance/aws/cis_2.0_aws.json` – CIS attribute shape.
-- `prowler/compliance/aws/nist_800_53_revision_5_aws.json` – Generic attribute shape.
-- `prowler/compliance/aws/ccc_aws.json` – CCC attribute shape.
-- `prowler/compliance/azure/ens_rd2022_azure.json` – ENS attribute shape.
-- `prowler/lib/check/compliance_models.py` – Canonical Pydantic schemas.
-- `prowler/lib/outputs/compliance/cis/` – Reference implementation of a multi-provider output formatter.
-- `prowler/lib/outputs/compliance/generic/` – Reference implementation of a generic output formatter.
+- `prowler/compliance/dora_2022_2554.json` — universal schema, single-provider populated (AWS), ready to extend with more providers.
+- `prowler/compliance/csa_ccm_4.0.json` — universal schema, multi-provider populated (AWS, Azure, GCP, AlibabaCloud, OracleCloud).
+- `prowler/compliance/aws/cis_2.0_aws.json` — legacy CIS attribute shape.
+- `prowler/compliance/aws/nist_800_53_revision_5_aws.json` — legacy generic attribute shape.
+- `prowler/compliance/aws/ccc_aws.json` — legacy CCC attribute shape.
+- `prowler/compliance/azure/ens_rd2022_azure.json` — legacy ENS attribute shape.
+- `prowler/lib/check/compliance_models.py` — canonical Pydantic schemas for both formats.
+- `prowler/lib/outputs/compliance/cis/` — reference implementation of a multi-provider legacy output formatter.
+- `prowler/lib/outputs/compliance/generic/` — reference implementation of a legacy generic output formatter.
diff --git a/docs/developer-guide/server-sent-events.mdx b/docs/developer-guide/server-sent-events.mdx
new file mode 100644
index 0000000000..c91f27227f
--- /dev/null
+++ b/docs/developer-guide/server-sent-events.mdx
@@ -0,0 +1,241 @@
+---
+title: 'Server-Sent Events (SSE)'
+---
+
+import { VersionBadge } from "/snippets/version-badge.mdx"
+
+
+
+This guide explains how to add a **Server-Sent Events (SSE)** endpoint to the Prowler API. SSE lets the backend push a one-way stream of events to a client over a single long-lived HTTP connection — ideal for live progress, token-by-token LLM output, or any "the server has news for you" use case where the client should not poll.
+
+
+The platform ships the SSE **infrastructure** (`api.sse`) and wiring. No feature endpoint streams over SSE out of the box — this guide shows how to build one on top of the shared base.
+
+
+## When to use SSE
+
+| Need | Use |
+|------|-----|
+| Server pushes incremental updates, client only reads | **SSE** |
+| Bidirectional, low-latency messaging (chat both ways, games) | WebSocket |
+| Client asks, server answers once | Plain REST |
+
+SSE is the right tool when the **client only consumes**: scan progress, long-running job checkpoints, streamed LLM tokens, cross-client resource-sync notifications. It rides on plain HTTP, reconnects automatically in the browser via the native [`EventSource`](https://developer.mozilla.org/en-US/docs/Web/API/EventSource) API, and needs no extra protocol.
+
+## How it works
+
+SSE is wired through [`django-eventstream`](https://github.com/fanout/django_eventstream) and a small platform layer in `api/src/backend/api/sse/`:
+
+| Piece | File | Responsibility |
+|-------|------|----------------|
+| `BaseSSEViewSet` | `api/sse/base_views.py` | Base DRF viewset a feature subclasses. The feature implements `get_channels`; the base handles auth, the tenant transaction, and delegates streaming to `django-eventstream`. |
+| `SSEChannelManager` | `api/sse/channelmanager.py` | Registered in `settings.EVENTSTREAM_CHANNELMANAGER_CLASS`. Reads the channel set off the request and enforces the platform-wide tenant gate. |
+| `SSEAuthentication` | `api/authentication.py` | Same JWT/API-key stack as the rest of the API, plus an `?access_token=` fallback for browser `EventSource` clients. Lives with the other authentication classes, not in the `sse` package. |
+| `make_channel_name` / `tenant_id_from_channel` | `api/sse/utils.py` | Single source of truth for the channel-name format, so publishers and the channel manager agree byte-for-byte. |
+| Settings | `config/settings/eventstream.py` | Valkey Pub/Sub backend (dedicated DB), channel manager, allowed headers. |
+
+### Transport: the server runs on ASGI
+
+SSE connections are long-lived. Holding one open per synchronous worker would exhaust the worker pool, so the API runs under Gunicorn's native **`asgi` worker** (`config.asgi:application`). Streams are parked on the event loop while ordinary CRUD endpoints keep their synchronous execution (Django runs sync views in a thread-sensitive executor under ASGI). This is configured in `config/guniconf.py` and used by both the dev and production entrypoints — no separate server process is needed.
+
+### The data flow
+
+```
+publisher (Celery task / view) subscriber (browser, CLI)
+ │ │
+ │ send_event(channel, "scan.progress", …) │ GET …/event-stream
+ ▼ ▼
+ Valkey Pub/Sub ◄────────────────────► BaseSSEViewSet.list
+ (EVENTSTREAM_VALKEY_DB) → get_channels() (RLS-scoped)
+ → SSEChannelManager (tenant gate)
+ → StreamingHttpResponse (text/event-stream)
+```
+
+A publisher anywhere in the system (most often a Celery task) calls `send_event(channel, event_type, payload)`. `django-eventstream` fans it out over Valkey Pub/Sub to every connection subscribed to that channel.
+
+## Adding an SSE endpoint to your feature
+
+The example below streams progress for a long-running **scan**. Adapt the resource, prefix, and event names to your feature.
+
+
+
+
+
+Channels follow the format `::`, built only through `make_channel_name`. The prefix is owned by your feature and may contain hyphens but **never colons** (the parser splits on `:`).
+
+```python
+CHANNEL_PREFIX = "scan-progress"
+```
+
+The tenant id is baked into every channel name. That is what lets the platform enforce cross-tenant isolation without knowing anything about your feature.
+
+
+
+
+
+Create the viewset for the SSE sub-resource. The only required method is `get_channels`; it runs inside the tenant transaction set up by the base class, so any database lookup inside it is automatically RLS-scoped.
+
+```python
+# scans/event_streams.py
+from api.sse import BaseSSEViewSet, make_channel_name
+from django.shortcuts import get_object_or_404
+from scans.models import Scan
+
+CHANNEL_PREFIX = "scan-progress"
+
+
+class ScanEventStreamViewSet(BaseSSEViewSet):
+ def get_queryset(self):
+ # RLS already scopes to the tenant; narrow further as needed
+ # (e.g. only scans the requesting user may see).
+ return Scan.objects.filter(tenant_id=self.request.tenant_id)
+
+ def get_channels(self) -> set[str]:
+ scan = get_object_or_404(self.get_queryset(), pk=self.kwargs["scan_pk"])
+ return {make_channel_name(CHANNEL_PREFIX, scan.tenant_id, scan.id)}
+```
+
+
+`get_channels` **must raise** the relevant DRF exception (`NotFound`, `PermissionDenied`, `NotAuthenticated`) when authorization fails — `get_object_or_404` does this for you. Returning an empty set surfaces as django-eventstream's confusing "No channels specified" error instead of the real cause.
+
+
+
+
+
+
+Mount the endpoint as an `event-stream` sub-resource. Keep it **outside the DRF router**, which would force the URL into a list/detail convention. Route the `get` method to the viewset's `list` action.
+
+```python
+# scans/urls.py
+path(
+ "scans//event-stream",
+ ScanEventStreamViewSet.as_view({"get": "list"}),
+ name="scan-event-stream",
+),
+```
+
+
+
+
+
+A feature owns its event types in `//events.py`: one `publish_` function per event type, each body a **single** `send_event` call so the wire-level string lives in exactly one place.
+
+```python
+# scans/events.py
+from django_eventstream import send_event
+
+
+def publish_progress(channel: str, checked: int, total: int) -> None:
+ send_event(channel, "scan.progress", {"checked": checked, "total": total})
+
+
+def publish_end(channel: str, scan_id: str) -> None:
+ # Terminal event carries the canonical id so reconnecting clients
+ # can refetch the persisted resource over REST.
+ send_event(channel, "scan.end", {"scan_id": scan_id})
+
+
+def publish_error(channel: str, code: str, detail: str) -> None:
+ send_event(channel, "scan.error", {"code": code, "detail": detail})
+```
+
+There is no platform-side enum, registry, or dispatch table — **the naming convention is the contract** (see below).
+
+
+
+
+
+Wherever the work happens — usually a Celery task — build the channel the same way and publish:
+
+```python
+from api.sse import make_channel_name
+from scans.events import publish_progress, publish_end
+
+channel = make_channel_name("scan-progress", scan.tenant_id, scan.id)
+publish_progress(channel, checked=42, total=100)
+...
+publish_end(channel, scan_id=str(scan.id))
+```
+
+
+
+
+
+## Event naming convention
+
+Every event uses an event type of the form **`.`** (lowercased, dot-separated). The verb comes from this platform-wide vocabulary — if you need a verb that is not listed, document the addition in this guide so the catalog stays discoverable.
+
+| Verb | When to use |
+|------|-------------|
+| `delta` | An incremental piece of a stream the client concatenates (LLM text tokens, audio chunks). Standard term across OpenAI / Anthropic / LiteLLM / Vercel AI SDK. |
+| `start` | Begin marker for a compound operation (e.g. a tool call whose execution will be reported by a matching `end`). |
+| `end` | Terminal marker. Carries the canonical resource id so reconnecting clients can refetch persisted state via REST. |
+| `progress` | Periodic checkpoint with quantifiable completion, e.g. `{"checked": 42, "total": 100}`. |
+| `created` / `updated` / `deleted` | Resource-lifecycle events for cross-client sync streams. |
+| `error` | Terminal failure. Carries a stable `code` for client switching and a human-readable `detail`. |
+
+
+Payloads are **flat JSON**. The wire-level `event:` field already names the event type, so do **not** wrap the payload in `{"type": ..., "data": ...}`. Include the canonical resource UUID on terminal events so reconnecting clients can reconcile via REST.
+
+
+## Authentication
+
+SSE endpoints use the same authentication stack as the rest of the API. Non-browser clients (CLI, programmatic) send the standard `Authorization` header — JWT or API key.
+
+Browser `EventSource` is the only widely available SSE client API and it **cannot set custom headers**. For that case only, the endpoint accepts a JWT via the `?access_token=` query parameter. The header always wins when present — a header is intentional, while a query parameter can leak into referers and logs, so it is consulted only as a fallback.
+
+```javascript
+// Browser
+const es = new EventSource(
+ `/api/v1/scans/${scanId}/event-stream?access_token=${jwt}`
+);
+```
+
+```bash
+# CLI / programmatic — header, exactly like every other endpoint
+curl -N -H "Authorization: Bearer $JWT" \
+ https:///api/v1/scans/$SCAN_ID/event-stream
+```
+
+## Tenant isolation & security model
+
+Authorization is enforced at two layers:
+
+1. **At connect**, `get_channels` runs under the regular DRF stack inside the tenant transaction (`rls_transaction`). Resource lookups are RLS-scoped, so a user cannot even resolve a channel for a resource they cannot see. Narrow the queryset further (e.g. `created_by=request.user`) when a resource is per-user within a tenant.
+2. **After connect**, `SSEChannelManager.can_read_channel` re-verifies tenant membership by parsing the tenant id embedded in the channel name. Cross-tenant subscription is rejected even if a URL-level check ever has a bug. A malformed channel name is treated as "not authorized".
+
+Because the tenant id lives inside the channel name, this gate works for any feature without the platform knowing anything about it.
+
+## Reconnect & state recovery
+
+The platform deliberately ships **without server-side replay** (`is_channel_reliable` returns `False`). When a client reconnects, it does **not** receive missed events. Instead:
+
+- Terminal events (`*.end`) carry the canonical resource **UUID**.
+- On reconnect, the client refetches the authoritative state from the normal REST endpoint using that id.
+
+Design your event payloads accordingly: deltas are ephemeral and concatenated in-flight; the durable truth always lives behind a REST resource.
+
+## Local development
+
+- The dev and production entrypoints both launch Gunicorn with the `asgi` worker (`config.asgi:application`). In dev, `DJANGO_DEBUG=True` enables hot reload; `preload_app` is automatically disabled under debug so edited code is picked up.
+- SSE uses a **dedicated Valkey database** (`EVENTSTREAM_VALKEY_DB`, default `2`) kept separate from the Celery broker so a noisy broker cannot crowd out streaming traffic. It reuses the same `VALKEY_*` connection settings as the rest of the platform.
+
+| Env var | Default | Purpose |
+|---------|---------|---------|
+| `EVENTSTREAM_VALKEY_DB` | `2` | Valkey DB index for the SSE Pub/Sub bus |
+| `DJANGO_WORKER_CLASS` | `asgi` | Gunicorn worker class |
+
+Test the stream end to end with `curl -N` (disable buffering) and an auth header:
+
+```bash
+curl -N -H "Authorization: Bearer $JWT" \
+ http://localhost:8080/api/v1/scans/$SCAN_ID/event-stream
+```
+
+## Testing
+
+The platform basis is covered by `api/tests/test_sse.py` (channel parsing, the tenant gate, and auth precedence). For a feature endpoint, test:
+
+- `get_channels` returns the expected channel for an authorized resource and raises `NotFound`/`PermissionDenied` otherwise.
+- Each `publish_` helper emits the correct event type and flat payload (mock `send_event`).
+- The producer builds the channel with `make_channel_name` using the resource's own `tenant_id`.
diff --git a/docs/developer-guide/stackit-details.mdx b/docs/developer-guide/stackit-details.mdx
new file mode 100644
index 0000000000..58a5cf19a5
--- /dev/null
+++ b/docs/developer-guide/stackit-details.mdx
@@ -0,0 +1,730 @@
+---
+title: 'StackIT Provider'
+---
+
+This page details the [StackIT Cloud](https://www.stackit.de/) provider implementation in Prowler.
+
+By default, Prowler audits a single StackIT project per scan. To configure it, provide the project ID and either a service account key file path or inline service account key JSON.
+
+## StackIT Provider Classes Architecture
+
+The StackIT provider implementation follows the general [Provider structure](/developer-guide/provider). This section focuses on the StackIT-specific implementation, highlighting how the generic provider concepts are realized for StackIT in Prowler. For a full overview of the provider pattern, base classes, and extension guidelines, see [Provider documentation](/developer-guide/provider).
+
+### `StackitProvider` (Main Class)
+
+- **Location:** [`prowler/providers/stackit/stackit_provider.py`](https://github.com/prowler-cloud/prowler/blob/master/prowler/providers/stackit/stackit_provider.py)
+- **Base Class:** Inherits from `Provider` (see [base class details](https://github.com/prowler-cloud/prowler/blob/master/prowler/providers/common/provider.py)).
+- **Purpose:** Central orchestrator for StackIT-specific logic, API authentication, credential validation, and configuration.
+- **Key StackIT Responsibilities:**
+ - Initializes StackIT SDK authentication via a service account key file or inline service account key JSON. The SDK mints and refreshes access tokens internally.
+ - Validates the service account credentials and project ID (UUID format validation).
+ - Loads and manages configuration, mutelist, and fixer settings.
+ - Provides properties and methods for downstream StackIT service classes to access credentials, identity, and configuration data.
+
+### Data Models
+
+- **Location:** [`prowler/providers/stackit/models.py`](https://github.com/prowler-cloud/prowler/blob/master/prowler/providers/stackit/models.py)
+- **Purpose:** Define structured data for StackIT identity and output configuration.
+- **Key StackIT Models:**
+ - `StackITIdentityInfo`: Holds StackIT identity metadata, including project ID and project name (fetched automatically from Resource Manager API).
+ - `StackITOutputOptions`: Customizes default output filenames so StackIT reports include the audited project ID.
+ - IaaS resource models such as `SecurityGroup` and `SecurityGroupRule` are defined in the IaaS service module.
+
+### StackIT Services
+
+- **Location:** [`prowler/providers/stackit/services/`](https://github.com/prowler-cloud/prowler/tree/master/prowler/providers/stackit/services)
+- **Purpose:** Implement StackIT service clients and resource collection logic following the generic [service pattern](/developer-guide/services#service-base-class).
+- **Current Implementation:** The `IaaSService` collects security groups, rules, and network interface usage across supported StackIT regions.
+
+### Exception Handling
+
+- **Location:** [`prowler/providers/stackit/exceptions/exceptions.py`](https://github.com/prowler-cloud/prowler/blob/master/prowler/providers/stackit/exceptions/exceptions.py)
+- **Purpose:** Custom exception classes for StackIT-specific error handling, such as credential validation, API connection, and configuration errors.
+- **Key Exception Classes:**
+ - `StackITBaseException`: Base exception for all StackIT provider errors.
+ - `StackITCredentialsError`: Raised when credentials are invalid or missing.
+ - `StackITInvalidProjectIdError`: Raised when project ID is invalid or not in UUID format.
+ - `StackITAPIError`: Raised when StackIT API calls fail.
+
+## Authentication
+
+### Service Account Creation and Key Generation
+
+StackIT uses service account keys for API authentication. Service account keys are RSA key-pair based and provide secure, short-lived access tokens.
+
+### Creating a Service Account Key
+
+#### Method 1: Via StackIT Portal
+
+1. **Navigate to Service Accounts**
+ - Go to the [StackIT Portal](https://portal.stackit.cloud/)
+ - Select your project
+ - Click **Service Accounts** in the left sidebar
+
+2. **Create or Select Service Account**
+ - If you don't have a service account, click **Create Service Account**
+ - Provide a name and description
+ - Assign necessary permissions:
+ - For IaaS security checks: `iaas.viewer` or `project.owner`
+ - For comprehensive audits: `project.owner`
+
+3. **Generate Service Account Key**
+ - Select your service account
+ - Navigate to **Service Account Keys**
+ - Click **Create key**
+ - Choose one of the following options:
+ - **STACKIT-generated key pair** (Recommended): Let STACKIT automatically generate an RSA key-pair
+ - **User-provided key pair**: Upload your own RSA 2048 public key
+
+4. **Download and Save the Key**
+ - Download the generated service account key file (JSON format)
+ - **Important**: Save the key securely - it contains your private key and will only be available once
+ - Store the key file in a secure location (e.g., `~/.stackit/sa_key.json`)
+
+#### Method 2: Via StackIT CLI
+
+```bash
+# Install STACKIT CLI (if not already installed)
+# Follow instructions at: https://github.com/stackitcloud/stackit-cli
+
+# Create service account key (STACKIT-generated)
+stackit service-account key create --email my-service-account@example.com
+
+# Or create with your own RSA 2048 public key
+# First, generate your RSA key pair:
+openssl genrsa -out private-key.pem 2048
+openssl rsa -in private-key.pem -pubout -out public-key.pem
+
+# Then create the key with your public key:
+stackit service-account key create \
+ --email my-service-account@example.com \
+ --public-key "$(cat public-key.pem)"
+```
+
+### Finding Your Project ID
+
+Your StackIT project ID is a UUID that can be found:
+
+1. In the StackIT Portal URL when viewing your project: `https://portal.stackit.cloud/projects/{PROJECT_ID}/...`
+2. In the project settings page
+3. Using the StackIT CLI: `stackit project list`
+
+### Passing the Service Account Key to Prowler
+
+Prowler accepts the service account credentials in two equivalent forms; both go through the same StackIT SDK flow and refresh access tokens internally.
+
+#### Option 1: Key File Path (key persisted on disk)
+
+```bash
+export STACKIT_SERVICE_ACCOUNT_KEY_PATH="$HOME/.stackit/sa-key.json"
+export STACKIT_PROJECT_ID="12345678-1234-1234-1234-123456789abc"
+
+prowler stackit
+```
+
+Or as CLI flags:
+
+```bash
+prowler stackit \
+ --stackit-service-account-key-path ~/.stackit/sa-key.json \
+ --stackit-project-id 12345678-1234-1234-1234-123456789abc
+```
+
+#### Option 2: Inline Key Content (CI/CD, secret managers)
+
+```bash
+export STACKIT_SERVICE_ACCOUNT_KEY="$(vault kv get -field=key stackit/sa)"
+export STACKIT_PROJECT_ID="12345678-1234-1234-1234-123456789abc"
+
+prowler stackit
+```
+
+Prefer the environment variable over the matching `--stackit-service-account-key` CLI flag; passing the secret on the command line leaks it through process listings and shell history.
+
+### Credential Lookup Order
+
+Prowler resolves credentials in this order:
+
+1. **Command-line arguments**:
+ - `--stackit-service-account-key`
+ - `--stackit-service-account-key-path`
+ - `--stackit-project-id`
+2. **Environment variables**:
+ - `STACKIT_SERVICE_ACCOUNT_KEY`
+ - `STACKIT_SERVICE_ACCOUNT_KEY_PATH`
+ - `STACKIT_PROJECT_ID`
+
+When both the inline key and the key file path are set, the inline content takes precedence.
+
+## Configuration
+
+### Command-Line Arguments
+
+StackIT-specific command-line arguments:
+
+| Argument | Description | Required | Default |
+|----------|-------------|----------|---------|
+| `--stackit-service-account-key-path` | Path to a StackIT service account key JSON file | Yes* | `$STACKIT_SERVICE_ACCOUNT_KEY_PATH` |
+| `--stackit-service-account-key` | Inline JSON content of a StackIT service account key (preferred env var: `STACKIT_SERVICE_ACCOUNT_KEY`) | Yes* | `$STACKIT_SERVICE_ACCOUNT_KEY` |
+| `--stackit-project-id` | StackIT project ID (UUID format) | Yes* | `$STACKIT_PROJECT_ID` |
+| `--stackit-region` | StackIT region(s) to scan | No | All available regions |
+
+\* Required unless provided via environment variables.
+
+### Input Validation
+
+The StackIT provider performs comprehensive input validation:
+
+- **Service Account Credentials**:
+ - At least one of `service_account_key_path` (file path) or `service_account_key` (inline JSON) must be supplied; both empty raises `StackITNonExistentTokenError`
+ - When both are provided the inline content takes precedence
+ - The key file path is logged as-is; the inline content is redacted in the credentials box
+
+- **Project ID**:
+ - Must not be empty
+ - Must be a valid UUID format (e.g., `12345678-1234-1234-1234-123456789abc`)
+ - Validated using Python's UUID constructor
+
+Invalid credentials will result in clear error messages before any API calls are made.
+
+## Available Services
+
+### IaaS (Infrastructure as a Service)
+
+- **Service Class:** `IaaSService`
+- **Location:** [`prowler/providers/stackit/services/iaas/iaas_service.py`](https://github.com/prowler-cloud/prowler/blob/master/prowler/providers/stackit/services/iaas/iaas_service.py)
+- **SDK:** Uses the [stackit-iaas](https://pypi.org/project/stackit-iaas/) Python SDK
+- **Purpose:** Manages IaaS resources including security groups, servers, and network interfaces.
+
+**Supported Resources:**
+- Security Groups and Rules
+- Servers (Virtual Machines)
+- Network Interfaces (NICs)
+
+**Key Features:**
+- Automatic discovery of all security groups in the project
+- Security rule parsing with support for unrestricted access detection
+- Network interface analysis to determine whether security groups are in use
+- By default, reports only security groups attached to at least one NIC; `--scan-unused-services` includes unused security groups too
+
+## Available Checks
+
+The StackIT provider currently implements 4 security checks focused on network security:
+
+### 1. iaas_security_group_ssh_unrestricted
+
+- **Severity:** High
+- **Description:** Detects security groups that allow unrestricted SSH access (port 22) from the internet.
+- **Risk:** Unrestricted SSH access increases the attack surface and risk of brute-force attacks.
+- **Detection Logic:**
+ - Checks for ingress rules allowing TCP port 22
+ - Flags rules with `ip_range=None` or `ip_range="0.0.0.0/0"` or `ip_range="::/0"`
+ - Reports security groups attached to NICs by default, or all security groups when `--scan-unused-services` is enabled
+
+### 2. iaas_security_group_rdp_unrestricted
+
+- **Severity:** High
+- **Description:** Detects security groups that allow unrestricted RDP access (port 3389) from the internet.
+- **Risk:** Unrestricted RDP access enables potential unauthorized remote desktop access.
+- **Detection Logic:**
+ - Checks for ingress rules allowing TCP port 3389
+ - Flags unrestricted IP ranges (None, 0.0.0.0/0, ::/0)
+ - Reports security groups attached to NICs by default, or all security groups when `--scan-unused-services` is enabled
+
+### 3. iaas_security_group_database_unrestricted
+
+- **Severity:** High
+- **Description:** Detects security groups that allow unrestricted access to common database ports.
+- **Monitored Ports:**
+ - MySQL: 3306
+ - PostgreSQL: 5432
+ - MongoDB: 27017
+ - Redis: 6379
+ - SQL Server: 1433
+ - CouchDB: 5984
+- **Risk:** Unrestricted database access can lead to data breaches and unauthorized data access.
+
+### 4. iaas_security_group_all_traffic_unrestricted
+
+- **Severity:** Critical
+- **Description:** Detects security groups that allow all traffic from the internet.
+- **Detection Logic:**
+ - Checks for rules with `port_range=None` (all ports)
+ - Checks for rules with port range covering 0-65535 or 1-65535
+ - Flags unrestricted IP ranges
+ - Critical security misconfiguration requiring immediate remediation
+
+### Important Implementation Notes
+
+**Self-Referencing Security Group Rules:**
+
+Security group rules with `remoteSecurityGroupId` set are automatically filtered out from unrestricted access checks. These rules only allow traffic from instances within the same security group (self-referencing), not from the internet, and are therefore not flagged as security risks.
+
+**Rule Display Names:**
+
+All findings include user-friendly rule descriptions when available. If a security group rule has a description field set (the name shown in the StackIT UI), it will be displayed in the finding message along with the rule ID:
+- With description: `'Allow SSH from office' (sgr-abc123)`
+- Without description: `'sgr-abc123'`
+
+**Network Interface (NIC) Usage Filtering:**
+
+The IaaS service lists project NICs and records the security group IDs attached to them. Checks use that signal to decide whether a security group is in use:
+
+1. **Default behavior:** Report security groups attached to at least one NIC.
+2. **`--scan-unused-services`:** Report every security group, including unused ones.
+3. **FAIL logic:** Internet exposure is driven by security group rules that allow unrestricted source ranges, not by the presence of a public IP on the NIC.
+
+**Unrestricted IP Ranges:**
+
+The StackIT API represents "unrestricted" in two ways:
+- **`ip_range=null`**: No IP restriction specified (implicit unrestricted)
+- **`ip_range="0.0.0.0/0"` or `"::/0"`**: Explicitly configured to allow all IPs
+
+Both are flagged as unrestricted. A `null` value is **more permissive** than an explicit range and applies to all protocols/ports if other fields are also `null`.
+
+## Requirements
+
+### Python Version
+
+- **Minimum:** Python 3.10+
+- **Reason:** The StackIT SDK requires Python 3.10 or higher
+
+### Dependencies
+
+The StackIT provider requires the following Python packages (automatically installed with Prowler):
+
+- **stackit-core** (v0.2.0): Core SDK for StackIT API authentication and configuration
+- **stackit-iaas** (v1.4.0): IaaS service SDK for managing compute resources
+- **stackit-resourcemanager** (v0.8.0): Resource Manager SDK for fetching project metadata (e.g., project names)
+
+These dependencies are defined in `pyproject.toml` and installed automatically with:
+
+```bash
+poetry install
+```
+
+**Note:** The `stackit-resourcemanager` package enables automatic retrieval of project names for display in reports. If this package is not available, Prowler will still function normally but project names will be empty in the output.
+
+## Region Support
+
+### Supported Regions
+
+- **Available Regions:** `eu01` (Germany South) and `eu02` (Austria West)
+- **Default:** All scans use both `eu01` and `eu02` regions by default.
+
+### Multi-Region Scanning
+
+Prowler supports scanning multiple StackIT regions in a single execution. By default, it will scan all regions defined in the `stackit_regions_by_service.json` configuration file.
+
+### CLI Argument
+
+You can specify which regions to scan using the `--stackit-region` argument:
+
+```bash
+# Scan only eu01
+prowler stackit --stackit-region eu01
+
+# Scan both eu01 and eu02
+prowler stackit --stackit-region eu01 eu02
+```
+
+### Implementation Details
+
+- **Regional Clients:** Prowler generates a separate API client for each audited region.
+- **Service Iteration:** Each service (e.g., IaaS) iterates through the regional clients to fetch and audit resources.
+- **Identity Tracking:** The `audited_regions` are stored in the identity model for reporting.
+
+### Future Enhancements
+
+As StackIT adds more regions, they can be easily added to Prowler by updating the `prowler/providers/stackit/stackit_regions_by_service.json` file without requiring code changes.
+
+## Command Examples
+
+### Scan Specific Regions
+
+Scan only the `eu01` region:
+
+```bash
+export STACKIT_SERVICE_ACCOUNT_KEY_PATH="$HOME/.stackit/sa-key.json"
+
+prowler stackit \
+ --stackit-project-id "your-project-id" \
+ --stackit-region eu01
+```
+
+Scan multiple regions:
+
+```bash
+export STACKIT_SERVICE_ACCOUNT_KEY_PATH="$HOME/.stackit/sa-key.json"
+
+prowler stackit \
+ --stackit-project-id "your-project-id" \
+ --stackit-region eu01 eu02
+```
+
+### Scan Specific Checks
+
+Run only SSH unrestricted check:
+
+```bash
+export STACKIT_SERVICE_ACCOUNT_KEY_PATH="$HOME/.stackit/sa-key.json"
+
+prowler stackit \
+ --stackit-project-id "your-project-id" \
+ --checks iaas_security_group_ssh_unrestricted
+```
+
+### Scan All Security Group Checks
+
+```bash
+export STACKIT_SERVICE_ACCOUNT_KEY_PATH="$HOME/.stackit/sa-key.json"
+
+prowler stackit \
+ --stackit-project-id "your-project-id" \
+ --services iaas
+```
+
+### Output Formats
+
+Generate JSON output:
+
+```bash
+export STACKIT_SERVICE_ACCOUNT_KEY_PATH="$HOME/.stackit/sa-key.json"
+
+prowler stackit \
+ --stackit-project-id "your-project-id" \
+ --output-formats json
+```
+
+Generate HTML report:
+
+```bash
+export STACKIT_SERVICE_ACCOUNT_KEY_PATH="$HOME/.stackit/sa-key.json"
+
+prowler stackit \
+ --stackit-project-id "your-project-id" \
+ --output-formats html
+```
+
+## Known Limitations
+
+### Current Limitations
+
+1. **Single Project Scope**: Only one project can be scanned at a time
+2. **Service Coverage**: Only the IaaS service is currently implemented
+3. **Check Coverage**: Limited to security group network security checks (4 checks total)
+4. **No Compliance Frameworks**: Compliance framework mappings are not yet implemented
+
+### Planned Enhancements
+
+- Multi-project scanning capability
+- Additional IaaS checks (volume encryption, server public IP exposure, backup status)
+- Compliance framework mappings (CIS, custom StackIT best practices)
+- StackIT CLI remediation examples in metadata
+
+## Troubleshooting
+
+### Authentication Errors
+
+**Error:** `StackIT service account key was rejected`
+
+**Solutions:**
+1. Re-issue the service account key in the StackIT Portal
+2. Verify the service account key file or inline JSON content is complete
+3. Check that the service account has the necessary permissions (`iaas.viewer` or `project.owner`)
+4. Ensure the service account key is provided through `STACKIT_SERVICE_ACCOUNT_KEY_PATH`, `STACKIT_SERVICE_ACCOUNT_KEY`, or the matching CLI arguments
+
+**Error:** `StackIT credentials not found or are invalid`
+
+**Solutions:**
+1. Ensure the project ID and one service account credential source are provided
+2. Check that credentials are set via environment variables or command-line arguments
+3. Verify there are no extra spaces or newlines in the credentials
+
+**Error:** `Invalid StackIT project ID format`
+
+**Solutions:**
+1. Verify the project ID is a valid UUID format: `12345678-1234-1234-1234-123456789abc`
+2. Copy the project ID directly from the StackIT Portal
+3. Ensure there are no extra spaces or quotes around the UUID
+
+### API Connection Errors
+
+**Error:** `Failed to connect to StackIT API`
+
+**Solutions:**
+1. Check your internet connection
+2. Verify the StackIT API endpoint is accessible from your network
+3. Check if there are any firewall rules blocking HTTPS connections
+4. Review the full error message for specific API error codes
+
+**Error:** `HTTP 403 Forbidden`
+
+**Solutions:**
+1. Verify the service account has the correct permissions
+2. Ensure the project ID is correct and you have access to it
+3. Check that the service account is enabled (not disabled or expired)
+4. Verify the service account key has not been revoked
+
+**Error:** `HTTP 404 Not Found`
+
+**Solutions:**
+1. Verify the project ID exists and is correct
+2. Check that the IaaS service is enabled in your project
+3. Ensure you're using the correct region (eu01)
+
+### Empty Results
+
+**Issue:** No security groups or findings reported
+
+**Solutions:**
+1. Verify that security groups exist in your project
+2. Check that the IaaS service is properly configured
+3. Ensure the service account has `iaas.viewer` permission
+4. Check Prowler logs for any API errors (use `--log-level DEBUG`)
+
+### Debug Mode
+
+Enable debug logging for detailed troubleshooting:
+
+```bash
+export STACKIT_SERVICE_ACCOUNT_KEY_PATH="$HOME/.stackit/sa-key.json"
+
+prowler stackit \
+ --stackit-project-id "your-project-id" \
+ --log-level DEBUG
+```
+
+This will show:
+- API authentication details (with inline service account keys redacted)
+- Resource discovery progress
+- Security rule parsing details
+- Any API errors or warnings
+
+## Specific Patterns in StackIT Services
+
+The generic service pattern is described in [service page](/developer-guide/services#service-structure-and-initialisation). You can find all the currently implemented services in the following locations:
+
+- Directly in the code, in location [`prowler/providers/stackit/services/`](https://github.com/prowler-cloud/prowler/tree/master/prowler/providers/stackit/services)
+- In the [Prowler Hub](https://hub.prowler.com/) for a more human-readable view.
+
+The best reference to understand how to implement a new service is following the [service implementation documentation](/developer-guide/services#adding-a-new-service) and taking other StackIT services as reference.
+
+### StackIT Service Common Patterns
+
+- Services communicate with StackIT using the StackIT Python SDK, you can find the documentation [here](https://github.com/stackitcloud/stackit-sdk-python).
+- Service constructors receive a `StackitProvider` instance and use it to access credentials, identity, and configuration.
+- The provider builds StackIT SDK `Configuration` objects from the service account key path or inline key content.
+- Resource containers **must** be initialized in the constructor, typically as lists or dictionaries.
+- Do not manipulate `os.environ` for credentials inside services. Use the provider session and SDK configuration helpers.
+- All StackIT resources are represented as Pydantic `BaseModel` classes, providing type safety and structured access to resource attributes.
+- StackIT SDK calls are wrapped in try/except blocks, with specific handling for API errors, always logging errors.
+- **Centralized Error Handling**: Use `provider.handle_api_error(exception)` for consistent authentication error detection across all services.
+- **SDK Warning Suppression**: StackIT SDK prints deprecation warnings to stderr - use the `suppress_stderr()` context manager during SDK initialization and API calls.
+- **Unrestricted Access Detection**: In StackIT API, `None` values mean "allow all" (more permissive than explicit 0.0.0.0/0).
+ - `protocol=None` → All protocols allowed
+ - `ip_range=None` → All source IPs allowed (unrestricted!)
+ - `port_range=None` → All ports allowed
+ - `remote_security_group_id` set → Only allows traffic from the same security group (not unrestricted!)
+
+### IaaS Service Specific Patterns
+
+**Security Group Discovery:**
+```python
+# List all security groups
+security_groups = client.list_security_groups(
+ project_id=self.project_id,
+ region=region,
+)
+
+# List network interfaces to determine security group usage
+nics = client.list_project_nics(
+ project_id=self.project_id,
+ region=region,
+)
+
+# Checks report in-use security groups by default. Use --scan-unused-services
+# to include security groups that are not attached to any NIC.
+```
+
+**Centralized Authentication Error Handling:**
+```python
+def _handle_api_call(self, api_function, *args, **kwargs):
+ """Wrapper for API calls with centralized error handling."""
+ try:
+ with suppress_stderr(): # Suppress SDK warnings
+ return api_function(*args, **kwargs)
+ except Exception as e:
+ # Use centralized error handler from provider
+ self.provider.handle_api_error(e) # Detects 401 and raises StackITInvalidTokenError
+```
+
+**Unrestricted Access Detection:**
+```python
+def is_unrestricted(rule):
+ """Check if a rule allows unrestricted access."""
+ # Filter out self-referencing rules
+ if rule.remote_security_group_id is not None:
+ return False
+ # Check for unrestricted IP ranges
+ return rule.ip_range is None or rule.ip_range in ["0.0.0.0/0", "::/0"]
+
+def is_tcp(rule):
+ """Check if a rule applies to TCP protocol."""
+ # None means all protocols (including TCP)
+ return rule.protocol is None or rule.protocol.lower() in ["tcp", "all"]
+
+def includes_port(rule, port):
+ """Check if a rule includes a specific port."""
+ # None means all ports
+ if rule.port_range is None:
+ return True
+ return rule.port_range.min <= port <= rule.port_range.max
+```
+
+## Specific Patterns in StackIT Checks
+
+The StackIT checks pattern is described in [checks page](/developer-guide/checks). You can find all the currently implemented checks:
+
+- Directly in the code, within each service folder, each check has its own folder named after the name of the check. (e.g. [`prowler/providers/stackit/services/iaas/iaas_security_group_ssh_unrestricted/`](https://github.com/prowler-cloud/prowler/tree/master/prowler/providers/stackit/services/iaas/iaas_security_group_ssh_unrestricted))
+- In the [Prowler Hub](https://hub.prowler.com/) for a more human-readable view.
+
+The best reference to understand how to implement a new check is following the [check creation documentation](/developer-guide/checks#creating-a-check) and taking other similar StackIT checks as reference.
+
+### Check Report Class
+
+The `CheckReportStackIT` class models a single finding for a StackIT resource in a check report. It is defined in [`prowler/lib/check/models.py`](https://github.com/prowler-cloud/prowler/blob/master/prowler/lib/check/models.py) and inherits from the generic `Check_Report` base class.
+
+#### Purpose
+
+`CheckReportStackIT` extends the base report structure with StackIT-specific fields, enabling detailed tracking of the resource, project, and location associated with each finding.
+
+#### Constructor and Attribute Population
+
+When you instantiate `CheckReportStackIT`, you must provide the check metadata and a resource object. The class will attempt to automatically populate its StackIT-specific attributes from the resource, using the following logic:
+
+- **`resource_id`**:
+ - Uses `resource.id` if present.
+ - Otherwise, uses `resource.resource_id` if present.
+ - Defaults to an empty string if none are available.
+
+- **`resource_name`**:
+ - Uses `resource.name` if present.
+ - Defaults to an empty string if not available.
+
+- **`project_id`**:
+ - Uses `resource.project_id` if present.
+ - Defaults to an empty string if not available (should be set in check logic).
+
+- **`location`**:
+ - Uses `resource.region` if present.
+ - Otherwise, uses `resource.location` if present.
+ - Defaults to an empty string if not available.
+
+If the resource object does not contain the required attributes, you must set them manually in the check logic.
+
+Other attributes are inherited from the `Check_Report` class, from which you **always** have to set the `status` and `status_extended` attributes in the check logic.
+
+#### Example Usage
+
+```python
+from prowler.lib.check.models import CheckReportStackIT
+
+report = CheckReportStackIT(
+ metadata=self.metadata(),
+ resource=security_group
+)
+report.status = "FAIL"
+report.status_extended = f"Security group {security_group.name} allows unrestricted SSH access from the internet."
+report.resource_id = security_group.id
+report.resource_name = security_group.name
+report.project_id = security_group.project_id
+report.location = security_group.region
+```
+
+### Common Check Pattern
+
+```python
+from prowler.lib.check.models import Check, CheckReportStackIT
+from prowler.providers.stackit.services.iaas.iaas_client import iaas_client
+
+class iaas_security_group_ssh_unrestricted(Check):
+ """Check if IaaS security groups allow unrestricted SSH access."""
+
+ def execute(self):
+ findings = []
+
+ for security_group in iaas_client.security_groups:
+ if not (iaas_client.scan_unused_services or security_group.in_use):
+ continue
+
+ report = CheckReportStackIT(
+ metadata=self.metadata(),
+ resource=security_group
+ )
+ report.status = "PASS"
+ report.status_extended = f"Security group {security_group.name} does not allow unrestricted SSH access."
+
+ # Check each rule
+ for rule in security_group.rules:
+ if (rule.is_ingress() and
+ rule.is_tcp() and
+ rule.includes_port(22) and
+ rule.is_unrestricted()):
+ report.status = "FAIL"
+ report.status_extended = f"Security group {security_group.name} allows unrestricted SSH access from the internet."
+ break
+
+ findings.append(report)
+
+ return findings
+```
+
+## Resources
+
+### Official StackIT Documentation
+
+- **StackIT Portal**: [https://portal.stackit.cloud/](https://portal.stackit.cloud/)
+- **StackIT Documentation**: [https://docs.stackit.cloud/](https://docs.stackit.cloud/)
+- **StackIT API Documentation**: [https://docs.api.eu01.stackit.cloud/](https://docs.api.eu01.stackit.cloud/)
+
+### Python SDK
+
+- **StackIT Python SDK (GitHub)**: [https://github.com/stackitcloud/stackit-sdk-python](https://github.com/stackitcloud/stackit-sdk-python)
+- **stackit-core (PyPI)**: [https://pypi.org/project/stackit-core/](https://pypi.org/project/stackit-core/)
+- **stackit-iaas (PyPI)**: [https://pypi.org/project/stackit-iaas/](https://pypi.org/project/stackit-iaas/)
+- **IaaS Models**: [https://github.com/stackitcloud/stackit-sdk-python/tree/main/services/iaas/src/stackit/iaas/models](https://github.com/stackitcloud/stackit-sdk-python/tree/main/services/iaas/src/stackit/iaas/models)
+
+### Prowler Resources
+
+- **Provider Implementation**: [`prowler/providers/stackit/`](https://github.com/prowler-cloud/prowler/tree/master/prowler/providers/stackit/)
+- **IaaS Service**: [`prowler/providers/stackit/services/iaas/`](https://github.com/prowler-cloud/prowler/tree/master/prowler/providers/stackit/services/iaas/)
+- **Prowler Hub**: [https://hub.prowler.com/](https://hub.prowler.com/)
+- **GitHub Issues**: [https://github.com/prowler-cloud/prowler/issues](https://github.com/prowler-cloud/prowler/issues)
+
+## Contributing
+
+If you'd like to contribute to the StackIT provider:
+
+1. **Add New Checks**: Follow the [check creation guide](/developer-guide/checks#creating-a-check) and use existing StackIT checks as templates
+2. **Enhance Services**: Implement additional IaaS resource discovery or add new services
+3. **Improve Documentation**: Add metadata enhancements, CLI remediation examples, or Terraform code samples
+4. **Report Issues**: Submit bug reports or feature requests on [GitHub](https://github.com/prowler-cloud/prowler/issues)
+
+### Quick Start for Contributors
+
+1. **Install dependencies**: `poetry install` (includes stackit-core and stackit-iaas)
+2. **Set credentials**: Export `STACKIT_SERVICE_ACCOUNT_KEY_PATH` and `STACKIT_PROJECT_ID`
+3. **Run checks**: `prowler stackit`
+4. **View code**: Start in `prowler/providers/stackit/`
+5. **Add checks**: Create new check directories under `services/iaas/`
+6. **Run tests**: `poetry run pytest tests/providers/stackit/ -v`
+
+### Code Quality Standards
+
+The StackIT provider should follow the same quality expectations as the rest of the Prowler SDK:
+
+- Keep service and check logic covered by unit tests.
+- Redact inline service account keys from generated output.
+- Keep documentation aligned with the implemented services and checks.
+- Follow existing provider, service, and check patterns before adding StackIT-specific abstractions.
diff --git a/docs/docs.json b/docs/docs.json
index e39ccb67ea..b9bc195182 100644
--- a/docs/docs.json
+++ b/docs/docs.json
@@ -38,55 +38,73 @@
{
"group": "Welcome",
"pages": [
- "introduction"
+ "introduction",
+ "getting-started/products/index"
]
},
{
- "group": "Prowler Cloud",
+ "group": "Prowler Products",
"pages": [
- "getting-started/products/prowler-cloud",
- "getting-started/products/prowler-cloud-pricing",
- "getting-started/products/prowler-cloud-aws-marketplace",
- "getting-started/goto/prowler-cloud",
- "getting-started/goto/prowler-api-reference"
+ {
+ "group": "Prowler Cloud",
+ "pages": [
+ "getting-started/products/prowler-cloud",
+ "getting-started/products/prowler-cloud-pricing",
+ "getting-started/products/prowler-cloud-aws-marketplace",
+ "getting-started/goto/prowler-cloud",
+ "getting-started/goto/prowler-api-reference"
+ ]
+ },
+ {
+ "group": "Prowler Lighthouse AI",
+ "pages": [
+ "getting-started/products/prowler-cloud-lighthouse"
+ ]
+ },
+ {
+ "group": "Prowler Hub",
+ "pages": [
+ "getting-started/products/prowler-hub",
+ "getting-started/goto/prowler-hub"
+ ]
+ },
+ {
+ "group": "Prowler MCP",
+ "pages": [
+ "getting-started/products/prowler-mcp",
+ "getting-started/basic-usage/prowler-mcp",
+ "getting-started/basic-usage/prowler-mcp-tools",
+ "getting-started/installation/prowler-mcp"
+ ]
+ },
+ {
+ "group": "Prowler for AI Agents",
+ "pages": [
+ "getting-started/products/prowler-claude-code-plugin"
+ ]
+ }
]
},
{
- "group": "Prowler CLI",
+ "group": "Open Source",
"pages": [
- "getting-started/products/prowler-cli",
- "getting-started/installation/prowler-cli",
- "getting-started/basic-usage/prowler-cli"
- ]
- },
- {
- "group": "Prowler App",
- "pages": [
- "getting-started/products/prowler-app",
- "getting-started/installation/prowler-app",
- "getting-started/basic-usage/prowler-app"
- ]
- },
- {
- "group": "Prowler Lighthouse AI",
- "pages": [
- "getting-started/products/prowler-lighthouse-ai"
- ]
- },
- {
- "group": "Prowler MCP Server",
- "pages": [
- "getting-started/products/prowler-mcp",
- "getting-started/installation/prowler-mcp",
- "getting-started/basic-usage/prowler-mcp",
- "getting-started/basic-usage/prowler-mcp-tools"
- ]
- },
- {
- "group": "Prowler Hub",
- "pages": [
- "getting-started/products/prowler-hub",
- "getting-started/goto/prowler-hub"
+ {
+ "group": "Prowler CLI",
+ "pages": [
+ "getting-started/products/prowler-cli",
+ "getting-started/installation/prowler-cli",
+ "getting-started/basic-usage/prowler-cli"
+ ]
+ },
+ {
+ "group": "Prowler Local Server",
+ "pages": [
+ "getting-started/products/prowler-app",
+ "getting-started/installation/prowler-app",
+ "getting-started/basic-usage/prowler-app"
+ ]
+ },
+ "getting-started/products/prowler-sdk"
]
},
{
@@ -105,32 +123,38 @@
"tab": "Guides",
"groups": [
{
- "group": "Prowler Cloud/App",
+ "group": "Prowler Cloud",
"pages": [
"user-guide/tutorials/prowler-app",
{
- "group": "Authentication",
+ "group": "Authentication & Access",
"pages": [
+ "user-guide/tutorials/prowler-app-api-keys",
+ "user-guide/tutorials/prowler-app-multi-tenant",
+ "user-guide/tutorials/prowler-app-sso",
"user-guide/tutorials/prowler-app-social-login",
- "user-guide/tutorials/prowler-app-sso"
+ "user-guide/tutorials/prowler-app-rbac"
]
},
- "user-guide/tutorials/prowler-app-rbac",
- "user-guide/tutorials/prowler-app-multi-tenant",
- "user-guide/tutorials/prowler-app-api-keys",
- "user-guide/tutorials/prowler-app-import-findings",
- "user-guide/tutorials/prowler-app-alerts",
{
- "group": "Mutelist",
- "expanded": true,
+ "group": "Compliance",
"pages": [
- "user-guide/tutorials/prowler-app-simple-mutelist",
- "user-guide/tutorials/prowler-app-mute-findings"
+ "user-guide/compliance/tutorials/compliance",
+ "user-guide/compliance/tutorials/cross-provider-compliance",
+ "user-guide/compliance/tutorials/threatscore"
+ ]
+ },
+ {
+ "group": "Findings",
+ "pages": [
+ "user-guide/tutorials/prowler-alerts",
+ "user-guide/tutorials/prowler-app-attack-paths",
+ "user-guide/tutorials/prowler-app-finding-groups",
+ "user-guide/tutorials/prowler-app-findings-triage"
]
},
{
"group": "Integrations",
- "expanded": true,
"pages": [
"user-guide/tutorials/prowler-app-s3-integration",
"user-guide/tutorials/prowler-app-security-hub-integration",
@@ -138,33 +162,64 @@
]
},
{
- "group": "AWS Organizations",
- "expanded": true,
+ "group": "Mutelist",
"pages": [
- "user-guide/tutorials/prowler-cloud-aws-organizations"
+ "user-guide/tutorials/prowler-app-mute-findings",
+ "user-guide/tutorials/prowler-app-simple-mutelist"
]
},
{
- "group": "Lighthouse AI",
+ "group": "Providers",
"pages": [
- "user-guide/tutorials/prowler-app-lighthouse",
- "user-guide/tutorials/prowler-app-lighthouse-multi-llm"
+ "user-guide/tutorials/prowler-cloud-aws-organizations",
+ "user-guide/tutorials/prowler-cloud-azure-management-groups",
+ "user-guide/tutorials/prowler-cloud-gcp-organizations"
+ ]
+ },
+ {
+ "group": "Scans",
+ "pages": [
+ "user-guide/tutorials/prowler-app-scan-configuration",
+ "user-guide/tutorials/prowler-import-findings",
+ "user-guide/tutorials/prowler-scan-scheduling"
]
},
- "user-guide/tutorials/prowler-app-attack-paths",
- "user-guide/tutorials/prowler-app-finding-groups",
- "user-guide/tutorials/prowler-cloud-public-ips",
{
"group": "Tutorials",
"pages": [
- "user-guide/tutorials/prowler-app-sso-entra",
- "user-guide/tutorials/prowler-app-sso-google-workspace",
+ "user-guide/tutorials/aws-organizations-bulk-provisioning",
"user-guide/tutorials/bulk-provider-provisioning",
- "user-guide/tutorials/aws-organizations-bulk-provisioning"
+ "user-guide/tutorials/prowler-app-sso-entra",
+ "user-guide/tutorials/prowler-app-sso-google-workspace"
]
}
]
},
+ {
+ "group": "Prowler Lighthouse AI",
+ "pages": [
+ {
+ "group": "Prowler Cloud",
+ "pages": [
+ "user-guide/tutorials/prowler-cloud-lighthouse-multi-llm"
+ ]
+ },
+ {
+ "group": "Prowler Local Server",
+ "pages": [
+ "getting-started/products/prowler-lighthouse-ai",
+ "user-guide/tutorials/prowler-app-lighthouse",
+ "user-guide/tutorials/prowler-app-lighthouse-multi-llm"
+ ]
+ }
+ ]
+ },
+ {
+ "group": "Prowler for AI Agents",
+ "pages": [
+ "getting-started/products/prowler-claude-code-plugin"
+ ]
+ },
{
"group": "CI/CD",
"pages": [
@@ -206,6 +261,13 @@
{
"group": "Providers",
"pages": [
+ {
+ "group": "Alibaba Cloud",
+ "pages": [
+ "user-guide/providers/alibabacloud/getting-started-alibabacloud",
+ "user-guide/providers/alibabacloud/authentication"
+ ]
+ },
{
"group": "AWS",
"pages": [
@@ -229,9 +291,31 @@
"user-guide/providers/azure/authentication",
"user-guide/providers/azure/use-non-default-cloud",
"user-guide/providers/azure/subscriptions",
+ "user-guide/providers/azure/resource-groups",
"user-guide/providers/azure/create-prowler-service-principal"
]
},
+ {
+ "group": "Cloudflare",
+ "pages": [
+ "user-guide/providers/cloudflare/getting-started-cloudflare",
+ "user-guide/providers/cloudflare/authentication"
+ ]
+ },
+ {
+ "group": "E2E Networks",
+ "pages": [
+ "user-guide/providers/e2enetworks/getting-started-e2enetworks",
+ "user-guide/providers/e2enetworks/authentication"
+ ]
+ },
+ {
+ "group": "GitHub",
+ "pages": [
+ "user-guide/providers/github/getting-started-github",
+ "user-guide/providers/github/authentication"
+ ]
+ },
{
"group": "Google Cloud",
"pages": [
@@ -243,10 +327,24 @@
]
},
{
- "group": "Alibaba Cloud",
+ "group": "Google Workspace",
"pages": [
- "user-guide/providers/alibabacloud/getting-started-alibabacloud",
- "user-guide/providers/alibabacloud/authentication"
+ "user-guide/providers/googleworkspace/getting-started-googleworkspace",
+ "user-guide/providers/googleworkspace/authentication"
+ ]
+ },
+ {
+ "group": "IaC",
+ "pages": [
+ "user-guide/providers/iac/getting-started-iac",
+ "user-guide/providers/iac/authentication"
+ ]
+ },
+ {
+ "group": "Image",
+ "pages": [
+ "user-guide/providers/image/getting-started-image",
+ "user-guide/providers/image/authentication"
]
},
{
@@ -256,6 +354,19 @@
"user-guide/providers/kubernetes/misc"
]
},
+ {
+ "group": "Linode",
+ "pages": [
+ "user-guide/providers/linode/getting-started-linode",
+ "user-guide/providers/linode/authentication"
+ ]
+ },
+ {
+ "group": "LLM",
+ "pages": [
+ "user-guide/providers/llm/getting-started-llm"
+ ]
+ },
{
"group": "Microsoft 365",
"pages": [
@@ -264,27 +375,6 @@
"user-guide/providers/microsoft365/use-of-powershell"
]
},
- {
- "group": "Google Workspace",
- "pages": [
- "user-guide/providers/googleworkspace/getting-started-googleworkspace",
- "user-guide/providers/googleworkspace/authentication"
- ]
- },
- {
- "group": "GitHub",
- "pages": [
- "user-guide/providers/github/getting-started-github",
- "user-guide/providers/github/authentication"
- ]
- },
- {
- "group": "IaC",
- "pages": [
- "user-guide/providers/iac/getting-started-iac",
- "user-guide/providers/iac/authentication"
- ]
- },
{
"group": "MongoDB Atlas",
"pages": [
@@ -293,30 +383,11 @@
]
},
{
- "group": "Cloudflare",
+ "group": "Okta",
"pages": [
- "user-guide/providers/cloudflare/getting-started-cloudflare",
- "user-guide/providers/cloudflare/authentication"
- ]
- },
- {
- "group": "Image",
- "pages": [
- "user-guide/providers/image/getting-started-image",
- "user-guide/providers/image/authentication"
- ]
- },
- {
- "group": "LLM",
- "pages": [
- "user-guide/providers/llm/getting-started-llm"
- ]
- },
- {
- "group": "Oracle Cloud Infrastructure",
- "pages": [
- "user-guide/providers/oci/getting-started-oci",
- "user-guide/providers/oci/authentication"
+ "user-guide/providers/okta/getting-started-okta",
+ "user-guide/providers/okta/authentication",
+ "user-guide/providers/okta/retry-configuration"
]
},
{
@@ -326,6 +397,13 @@
"user-guide/providers/openstack/authentication"
]
},
+ {
+ "group": "Oracle Cloud Infrastructure",
+ "pages": [
+ "user-guide/providers/oci/getting-started-oci",
+ "user-guide/providers/oci/authentication"
+ ]
+ },
{
"group": "Scaleway",
"pages": [
@@ -333,29 +411,22 @@
"user-guide/providers/scaleway/authentication"
]
},
+ {
+ "group": "StackIT",
+ "pages": [
+ "user-guide/providers/stackit/getting-started-stackit",
+ "user-guide/providers/stackit/authentication"
+ ]
+ },
{
"group": "Vercel",
"pages": [
"user-guide/providers/vercel/getting-started-vercel",
"user-guide/providers/vercel/authentication"
]
- },
- {
- "group": "Okta",
- "pages": [
- "user-guide/providers/okta/getting-started-okta",
- "user-guide/providers/okta/authentication"
- ]
}
]
},
- {
- "group": "Compliance",
- "pages": [
- "user-guide/compliance/tutorials/compliance",
- "user-guide/compliance/tutorials/threatscore"
- ]
- },
{
"group": "Cookbooks",
"pages": [
@@ -376,13 +447,15 @@
"developer-guide/provider",
"developer-guide/services",
"developer-guide/checks",
+ "developer-guide/secret-scanning-checks",
"developer-guide/outputs",
"developer-guide/integrations",
"developer-guide/security-compliance-framework",
"developer-guide/lighthouse-architecture",
"developer-guide/mcp-server",
"developer-guide/ai-skills",
- "developer-guide/prowler-studio"
+ "developer-guide/prowler-studio",
+ "developer-guide/server-sent-events"
]
},
{
@@ -395,13 +468,15 @@
"developer-guide/kubernetes-details",
"developer-guide/m365-details",
"developer-guide/github-details",
- "developer-guide/llm-details"
+ "developer-guide/llm-details",
+ "developer-guide/stackit-details"
]
},
{
"group": "Miscellaneous",
"pages": [
"developer-guide/documentation",
+ "developer-guide/environment-variables",
{
"group": "Testing",
"pages": [
@@ -459,10 +534,6 @@
"tab": "Changelog",
"icon": "github",
"href": "https://github.com/prowler-cloud/prowler/releases"
- },
- {
- "tab": "Public Roadmap",
- "href": "https://roadmap.prowler.com/"
}
],
"global": {
@@ -498,6 +569,13 @@
}
]
},
+ "banner": {
+ "content": "Prowler App is now Prowler Local Server, and Prowler Enterprise is now Prowler Private Cloud. See [Prowler product families](/getting-started/products).",
+ "dismissible": false
+ },
+ "markdown": {
+ "instructions": "Prowler product naming: Prowler App is now Prowler Local Server, and Prowler Enterprise is now Prowler Private Cloud. Always use the current names when answering. The full product reference is at /getting-started/products: Open Source projects are Prowler CLI, Prowler Local Server, Prowler Local Dashboard, and Prowler SDK; Prowler Products are Prowler Cloud, Prowler Private Cloud, Prowler Hub, Prowler Lighthouse AI, and Prowler MCP."
+ },
"analytics": {
"ga4": {
"measurementId": "G-KBKV70W5Y2"
@@ -570,6 +648,18 @@
{
"source": "/contact",
"destination": "/support"
+ },
+ {
+ "source": "/user-guide/tutorials/prowler-app-import-findings",
+ "destination": "/user-guide/tutorials/prowler-import-findings"
+ },
+ {
+ "source": "/user-guide/tutorials/prowler-app-alerts",
+ "destination": "/user-guide/tutorials/prowler-alerts"
+ },
+ {
+ "source": "/user-guide/tutorials/prowler-cloud-public-ips",
+ "destination": "/security/networking"
}
]
}
diff --git a/docs/getting-started/basic-usage/prowler-app.mdx b/docs/getting-started/basic-usage/prowler-app.mdx
index bc39353dcc..0f4cf80a91 100644
--- a/docs/getting-started/basic-usage/prowler-app.mdx
+++ b/docs/getting-started/basic-usage/prowler-app.mdx
@@ -2,7 +2,7 @@
title: 'Basic Usage'
---
-## Access Prowler App
+## Access Prowler Local Server
After [installation](/getting-started/installation/prowler-app), navigate to [http://localhost:3000](http://localhost:3000) and sign up with email and password.
@@ -28,7 +28,7 @@ This mechanism ensures that the first user in a newly created tenant has adminis
## Log In
-Access Prowler App by logging in with **email and password**.
+Access Prowler Local Server by logging in with **email and password**.
@@ -62,7 +62,7 @@ Review findings during scan execution in the following sections:
- **Compliance** – Displays compliance insights based on security frameworks.
-> For detailed usage instructions, refer to the [Prowler App Guide](/user-guide/tutorials/prowler-app).
+> For detailed usage instructions, refer to the [Prowler Cloud guide](/user-guide/tutorials/prowler-app), which also applies to Prowler Local Server.
Prowler will automatically scan all configured providers every **24 hours**, ensuring your cloud environment stays continuously monitored.
diff --git a/docs/getting-started/basic-usage/prowler-mcp-tools.mdx b/docs/getting-started/basic-usage/prowler-mcp-tools.mdx
index ec11680dd5..acb50c9e81 100644
--- a/docs/getting-started/basic-usage/prowler-mcp-tools.mdx
+++ b/docs/getting-started/basic-usage/prowler-mcp-tools.mdx
@@ -10,7 +10,7 @@ Complete reference guide for all tools available in the Prowler MCP Server. Tool
|----------|------------|------------------------|
| Prowler Hub | 10 tools | No |
| Prowler Documentation | 2 tools | No |
-| Prowler Cloud/App | 32 tools | Yes |
+| Prowler Cloud, Private Cloud & Local Server | 32 tools | Yes |
## Tool Naming Convention
@@ -18,11 +18,11 @@ All tools follow a consistent naming pattern with prefixes:
- `prowler_hub_*` - Prowler Hub catalog and compliance tools
- `prowler_docs_*` - Prowler documentation search and retrieval
-- `prowler_app_*` - Prowler Cloud and App (Self-Managed) management tools
+- `prowler_*` - Prowler Cloud, Prowler Private Cloud & Prowler Local Server management tools
-## Prowler Cloud/App Tools
+## Prowler Tools
-Manage Prowler Cloud or Prowler App (Self-Managed) features. **Requires authentication.**
+Manage your Prowler deployment — Prowler Cloud, Prowler Private Cloud, or Prowler Local Server. **Requires authentication.**
These tools require a valid API key. See the [Configuration Guide](/getting-started/basic-usage/prowler-mcp) for authentication setup.
@@ -32,44 +32,44 @@ These tools require a valid API key. See the [Configuration Guide](/getting-star
Tools for searching, viewing, and analyzing security findings across all cloud providers.
-- **`prowler_app_search_security_findings`** - Search and filter security findings with advanced filtering options (severity, status, provider, region, service, check ID, date range, muted status)
-- **`prowler_app_get_finding_details`** - Get comprehensive details about a specific finding including remediation guidance, check metadata, and resource relationships
-- **`prowler_app_get_findings_overview`** - Get aggregate statistics and trends about security findings as a markdown report
+- **`prowler_search_security_findings`** - Search and filter security findings with advanced filtering options (severity, status, provider, region, service, check ID, date range, muted status)
+- **`prowler_get_finding_details`** - Get comprehensive details about a specific finding including remediation guidance, check metadata, and resource relationships
+- **`prowler_get_findings_overview`** - Get aggregate statistics and trends about security findings as a markdown report
### Finding Groups Management
Tools for listing finding groups aggregated by check ID, viewing complete group counters, and drilling down into affected resources.
-- **`prowler_app_list_finding_groups`** - List latest or historical finding groups with filters for provider, region, service, resource, category, check, severity, status, muted state, delta, date range, and sorting
-- **`prowler_app_get_finding_group_details`** - Get complete details for a specific finding group including counters, description, timestamps, and impacted providers
-- **`prowler_app_list_finding_group_resources`** - List actionable unmuted resources affected by a finding group by default, including nested resource and provider data plus the `finding_id` for remediation details. Set `include_muted` to include suppressed resources
+- **`prowler_list_finding_groups`** - List latest or historical finding groups with filters for provider, region, service, resource, category, check, severity, status, muted state, delta, date range, and sorting
+- **`prowler_get_finding_group_details`** - Get complete details for a specific finding group including counters, description, timestamps, and impacted providers
+- **`prowler_list_finding_group_resources`** - List actionable unmuted resources affected by a finding group by default, including nested resource and provider data plus the `finding_id` for remediation details. Set `include_muted` to include suppressed resources
### Provider Management
Tools for managing cloud provider connections in Prowler.
-- **`prowler_app_search_providers`** - Search and view configured providers with their connection status
-- **`prowler_app_connect_provider`** - Register and connect a provider with credentials for security scanning
-- **`prowler_app_delete_provider`** - Permanently remove a provider from Prowler
+- **`prowler_search_providers`** - Search and view configured providers with their connection status
+- **`prowler_connect_provider`** - Register and connect a provider with credentials for security scanning
+- **`prowler_delete_provider`** - Permanently remove a provider from Prowler
### Scan Management
Tools for managing and monitoring security scans.
-- **`prowler_app_list_scans`** - List and filter security scans across all providers
-- **`prowler_app_get_scan`** - Get comprehensive details about a specific scan (progress, duration, resource counts)
-- **`prowler_app_trigger_scan`** - Trigger a manual security scan for a provider
-- **`prowler_app_schedule_daily_scan`** - Schedule automated daily scans for continuous monitoring
-- **`prowler_app_update_scan`** - Update scan name for better organization
+- **`prowler_list_scans`** - List and filter security scans across all providers
+- **`prowler_get_scan`** - Get comprehensive details about a specific scan (progress, duration, resource counts)
+- **`prowler_trigger_scan`** - Trigger a manual security scan for a provider
+- **`prowler_schedule_daily_scan`** - Schedule automated daily scans for continuous monitoring
+- **`prowler_update_scan`** - Update scan name for better organization
### Resources Management
Tools for searching, viewing, and analyzing cloud resources discovered by Prowler.
-- **`prowler_app_list_resources`** - List and filter cloud resources with advanced filtering options (provider, region, service, resource type, tags)
-- **`prowler_app_get_resource`** - Get comprehensive details about a specific resource including configuration, metadata, and finding relationships
-- **`prowler_app_get_resource_events`** - Get the timeline of cloud API actions performed on a resource (AWS CloudTrail). Shows who did what and when, with full request/response payloads
-- **`prowler_app_get_resources_overview`** - Get aggregate statistics about cloud resources as a markdown report
+- **`prowler_list_resources`** - List and filter cloud resources with advanced filtering options (provider, region, service, resource type, tags)
+- **`prowler_get_resource`** - Get comprehensive details about a specific resource including configuration, metadata, and finding relationships
+- **`prowler_get_resource_events`** - Get the timeline of cloud API actions performed on a resource (AWS CloudTrail). Shows who did what and when, with full request/response payloads
+- **`prowler_get_resources_overview`** - Get aggregate statistics about cloud resources as a markdown report
### Muting Management
@@ -77,33 +77,33 @@ Tools for managing finding muting, including pattern-based bulk muting (mutelist
#### Mutelist (Pattern-Based Muting)
-- **`prowler_app_get_mutelist`** - Retrieve the current mutelist configuration for the tenant
-- **`prowler_app_set_mutelist`** - Create or update the mutelist configuration for pattern-based bulk muting
-- **`prowler_app_delete_mutelist`** - Remove the mutelist configuration from the tenant
+- **`prowler_get_mutelist`** - Retrieve the current mutelist configuration for the tenant
+- **`prowler_set_mutelist`** - Create or update the mutelist configuration for pattern-based bulk muting
+- **`prowler_delete_mutelist`** - Remove the mutelist configuration from the tenant
#### Mute Rules (Finding-Specific Muting)
-- **`prowler_app_list_mute_rules`** - Search and filter mute rules with pagination support
-- **`prowler_app_get_mute_rule`** - Retrieve comprehensive details about a specific mute rule
-- **`prowler_app_create_mute_rule`** - Create a new mute rule to mute specific findings with documentation and audit trail
-- **`prowler_app_update_mute_rule`** - Update a mute rule's name, reason, or enabled status
-- **`prowler_app_delete_mute_rule`** - Delete a mute rule from the system
+- **`prowler_list_mute_rules`** - Search and filter mute rules with pagination support
+- **`prowler_get_mute_rule`** - Retrieve comprehensive details about a specific mute rule
+- **`prowler_create_mute_rule`** - Create a new mute rule to mute specific findings with documentation and audit trail
+- **`prowler_update_mute_rule`** - Update a mute rule's name, reason, or enabled status
+- **`prowler_delete_mute_rule`** - Delete a mute rule from the system
### Attack Paths Analysis
Tools for analyzing privilege escalation chains and security misconfigurations using graph-based analysis. Attack Paths maps relationships between cloud resources, permissions, and security findings to detect how privileges can be escalated and how misconfigurations can be exploited.
-- **`prowler_app_list_attack_paths_scans`** - List Attack Paths scans with filtering by provider, provider type, and scan state (available, scheduled, executing, completed, failed, cancelled)
-- **`prowler_app_list_attack_paths_queries`** - Discover available Attack Paths queries for a completed scan, including query names, descriptions, and required parameters
-- **`prowler_app_run_attack_paths_query`** - Execute an Attack Paths query against a completed scan and retrieve graph results with nodes (cloud resources, findings, virtual nodes) and relationships (access paths, role assumptions, security group memberships)
-- **`prowler_app_get_attack_paths_cartography_schema`** - Retrieve the Cartography graph schema (node labels, relationships, properties) for writing accurate custom openCypher queries
+- **`prowler_list_attack_paths_scans`** - List Attack Paths scans with filtering by provider, provider type, and scan state (available, scheduled, executing, completed, failed, cancelled)
+- **`prowler_list_attack_paths_queries`** - Discover available Attack Paths queries for a completed scan, including query names, descriptions, and required parameters
+- **`prowler_run_attack_paths_query`** - Execute an Attack Paths query against a completed scan and retrieve graph results with nodes (cloud resources, findings, virtual nodes) and relationships (access paths, role assumptions, security group memberships)
+- **`prowler_get_attack_paths_cartography_schema`** - Retrieve the Cartography graph schema (node labels, relationships, properties) for writing accurate custom openCypher queries
### Compliance Management
Tools for viewing compliance status and framework details across all cloud providers.
-- **`prowler_app_get_compliance_overview`** - Get high-level compliance status across all frameworks for a specific scan or provider, including pass/fail statistics per framework
-- **`prowler_app_get_compliance_framework_state_details`** - Get detailed requirement-level breakdown for a specific compliance framework, including failed requirements and associated finding IDs
+- **`prowler_get_compliance_overview`** - Get high-level compliance status across all frameworks for a specific scan or provider, including pass/fail statistics per framework
+- **`prowler_get_compliance_framework_state_details`** - Get detailed requirement-level breakdown for a specific compliance framework, including failed requirements and associated finding IDs
## Prowler Hub Tools
@@ -145,7 +145,7 @@ Search and access official Prowler documentation. **No authentication required.*
- Use natural language to interact with the tools through your AI assistant
- Tools can be combined for complex workflows
- Filter options are available on most list tools
-- Authentication is only required for Prowler Cloud/App tools
+- Authentication is only required for Prowler tools (Prowler Cloud, Prowler Private Cloud, or Prowler Local Server)
## Additional Resources
diff --git a/docs/getting-started/basic-usage/prowler-mcp.mdx b/docs/getting-started/basic-usage/prowler-mcp.mdx
index 2c32dbdbfc..120e5c038b 100644
--- a/docs/getting-started/basic-usage/prowler-mcp.mdx
+++ b/docs/getting-started/basic-usage/prowler-mcp.mdx
@@ -7,10 +7,10 @@ Configure your MCP client to connect to Prowler MCP Server.
## Step 1: Get Your API Key
-**Authentication is optional**: Prowler Hub and Prowler Documentation features work without authentication. An API key is only required for Prowler Cloud and Prowler App (Self-Managed) features.
+**Authentication is optional**: Prowler Hub and Prowler Documentation features work without authentication. An API key is only required for Prowler tools (Prowler Cloud, Prowler Private Cloud, or Prowler Local Server).
-To use Prowler Cloud or Prowler App (Self-Managed) features. To get the API key, please refer to the [API Keys](/user-guide/tutorials/prowler-app-api-keys) guide.
+An API key authenticates the Prowler tools (Prowler Cloud, Prowler Private Cloud, or Prowler Local Server). To get the API key, please refer to the [API Keys](/user-guide/tutorials/prowler-app-api-keys) guide.
Keep the API key secure. Never share it publicly or commit it to version control.
@@ -18,12 +18,14 @@ Keep the API key secure. Never share it publicly or commit it to version control
## Step 2: Configure Your MCP Host/Client
-Choose the configuration based on your deployment:
+Most users should use the **Cloud MCP Server** — it needs no installation and is maintained by Prowler. The [Local MCP Server](#local-mcp-server-configuration) configuration is provided afterwards for users who run the server themselves.
-- **HTTP Mode**: Prowler Cloud MCP Server or self-hosted Prowler MCP Server.
-- **STDIO Mode**: Local installation only (runs as subprocess of your MCP client).
+- **Cloud MCP Server (HTTP)**: the managed server at `https://mcp.prowler.com/mcp` (or your own self-hosted HTTP server).
+- **Local MCP Server (STDIO)**: local installation only (runs as a subprocess of your MCP client).
-### HTTP Mode
+## Cloud MCP Server Configuration (Recommended)
+
+Connect to the **Cloud MCP Server** at `https://mcp.prowler.com/mcp` over HTTP. This is the recommended path — no installation, always up to date. The same configuration works for a self-hosted HTTP server: just swap the URL.
@@ -61,10 +63,10 @@ Choose the configuration based on your deployment:
"args": [
"https://mcp.prowler.com/mcp", // or your self-hosted Prowler MCP Server URL
"--header",
- "Authorization: Bearer ${PROWLER_APP_API_KEY}"
+ "Authorization: Bearer ${PROWLER_API_KEY}"
],
"env": {
- "PROWLER_APP_API_KEY": ""
+ "PROWLER_API_KEY": ""
}
}
}
@@ -96,10 +98,10 @@ Choose the configuration based on your deployment:
"args": [
"https://mcp.prowler.com/mcp",
"--header",
- "Authorization: Bearer ${PROWLER_APP_API_KEY}"
+ "Authorization: Bearer ${PROWLER_API_KEY}"
],
"env": {
- "PROWLER_APP_API_KEY": ""
+ "PROWLER_API_KEY": ""
}
}
}
@@ -110,8 +112,8 @@ Choose the configuration based on your deployment:
Run the following command:
```bash
- export PROWLER_APP_API_KEY=""
- claude mcp add --transport http prowler https://mcp.prowler.com/mcp --header "Authorization: Bearer $PROWLER_APP_API_KEY" --scope user
+ export PROWLER_API_KEY=""
+ claude mcp add --transport http prowler https://mcp.prowler.com/mcp --header "Authorization: Bearer $PROWLER_API_KEY" --scope user
```
@@ -137,9 +139,9 @@ Choose the configuration based on your deployment:
-### STDIO Mode
+## Local MCP Server Configuration
-STDIO mode is only available when running the MCP server locally.
+STDIO mode is only available when running the **Local MCP Server** on your own machine. See the [Installation guide](/getting-started/installation/prowler-mcp) to set it up first.
@@ -152,7 +154,7 @@ STDIO mode is only available when running the MCP server locally.
"command": "uvx",
"args": ["/absolute/path/to/prowler/mcp_server/"],
"env": {
- "PROWLER_APP_API_KEY": "",
+ "PROWLER_API_KEY": "",
"API_BASE_URL": "https://api.prowler.com/api/v1"
}
}
@@ -179,7 +181,7 @@ STDIO mode is only available when running the MCP server locally.
"--rm",
"-i",
"--env",
- "PROWLER_APP_API_KEY=",
+ "PROWLER_API_KEY=",
"--env",
"API_BASE_URL=https://api.prowler.com/api/v1",
"prowlercloud/prowler-mcp"
@@ -205,7 +207,7 @@ Restart your MCP client and start asking questions:
## Authentication Methods
-Prowler MCP Server supports two authentication methods to connect to Prowler Cloud or Prowler App (Self-Managed):
+Prowler MCP Server supports two authentication methods to connect to Prowler (Prowler Cloud, Prowler Private Cloud, or Prowler Local Server):
### API Key (Recommended)
diff --git a/docs/getting-started/installation/prowler-app.mdx b/docs/getting-started/installation/prowler-app.mdx
index f2ac07da72..1994b8ad0f 100644
--- a/docs/getting-started/installation/prowler-app.mdx
+++ b/docs/getting-started/installation/prowler-app.mdx
@@ -4,9 +4,9 @@ title: "Installation"
### Installation
-Prowler App offers flexible installation methods tailored to various environments.
+Prowler Local Server offers flexible installation methods tailored to various environments.
-Refer to the [Prowler App Tutorial](/user-guide/tutorials/prowler-app) for detailed usage instructions.
+Refer to the [Prowler Cloud guide](/user-guide/tutorials/prowler-app) for detailed usage instructions.
Prowler configuration is based on `.env` files. Every version of Prowler can have differences on that file, so, please, use the file that corresponds with that version or repository branch or tag.
@@ -20,7 +20,8 @@ Refer to the [Prowler App Tutorial](/user-guide/tutorials/prowler-app) for detai
_Commands_:
- ```bash
+
+ ```bash macOS/Linux
VERSION=$(curl -s https://api.github.com/repos/prowler-cloud/prowler/releases/latest | jq -r .tag_name)
curl -sLO "https://raw.githubusercontent.com/prowler-cloud/prowler/refs/tags/${VERSION}/docker-compose.yml"
# Environment variables can be customized in the .env file. Using default values in production environments is not recommended.
@@ -28,6 +29,15 @@ Refer to the [Prowler App Tutorial](/user-guide/tutorials/prowler-app) for detai
docker compose up -d
```
+ ```powershell Windows PowerShell
+ $VERSION = (Invoke-RestMethod -Uri "https://api.github.com/repos/prowler-cloud/prowler/releases/latest").tag_name
+ Invoke-WebRequest -Uri "https://raw.githubusercontent.com/prowler-cloud/prowler/refs/tags/$VERSION/docker-compose.yml" -OutFile "docker-compose.yml"
+ # Environment variables can be customized in the .env file. Using default values in production environments is not recommended.
+ Invoke-WebRequest -Uri "https://raw.githubusercontent.com/prowler-cloud/prowler/refs/tags/$VERSION/.env" -OutFile ".env"
+ docker compose up -d
+ ```
+
+
For a secure setup, the API auto-generates a unique key pair, `DJANGO_TOKEN_SIGNING_KEY` and `DJANGO_TOKEN_VERIFYING_KEY`, and stores it in `~/.config/prowler-api` (non-container) or the bound Docker volume in `_data/api` (container). Never commit or reuse static/default keys. To rotate keys, delete the stored key files and restart the API.
@@ -99,17 +109,17 @@ Refer to the [Prowler App Tutorial](/user-guide/tutorials/prowler-app) for detai
pnpm start
```
- > Enjoy Prowler App at http://localhost:3000 by signing up with your email and password.
+ > Enjoy Prowler Local Server at http://localhost:3000 by signing up with your email and password.
- Google and GitHub authentication is only available in [Prowler Cloud](https://prowler.com).
+ Google and GitHub authentication works out of the box in [Prowler Cloud](https://prowler.com). In Prowler Local Server it requires OAuth credentials: see [Social Login Configuration](/user-guide/tutorials/prowler-app-social-login).
-### Updating Prowler App
+### Updating Prowler Local Server
-Upgrade Prowler App installation using one of two options:
+Upgrade Prowler Local Server installation using one of two options:
#### Option 1: Updating the Environment File
@@ -118,12 +128,12 @@ To update the environment file:
Edit the `.env` file and change version values:
```env
-PROWLER_UI_VERSION="5.27.0"
-PROWLER_API_VERSION="5.27.0"
+PROWLER_UI_VERSION="5.35.0"
+PROWLER_API_VERSION="5.35.0"
```
- You can find the latest versions of Prowler App in the [Releases Github section](https://github.com/prowler-cloud/prowler/releases) or in the [Container Versions](#container-versions) section of this documentation.
+ You can find the latest versions of Prowler Local Server in the [Releases Github section](https://github.com/prowler-cloud/prowler/releases) or in the [Container Versions](#container-versions) section of this documentation.
@@ -162,7 +172,7 @@ docker compose up -d
### Container Versions
-The available versions of Prowler App are the following:
+The available versions of Prowler Local Server are the following:
- `latest`: in sync with `master` branch (please note that it is not a stable version)
- `v4-latest`: in sync with `v4` branch (please note that it is not a stable version)
@@ -174,6 +184,6 @@ The available versions of Prowler App are the following:
The container images are available here:
-- Prowler App:
+- Prowler Local Server:
- [DockerHub - Prowler UI](https://hub.docker.com/r/prowlercloud/prowler-ui/tags)
- [DockerHub - Prowler API](https://hub.docker.com/r/prowlercloud/prowler-api/tags)
diff --git a/docs/getting-started/installation/prowler-cli.mdx b/docs/getting-started/installation/prowler-cli.mdx
index 2c79eda37c..eae3d54ab1 100644
--- a/docs/getting-started/installation/prowler-cli.mdx
+++ b/docs/getting-started/installation/prowler-cli.mdx
@@ -4,7 +4,7 @@ title: 'Installation'
## Installation
-To install Prowler as a Python package, use `Python >= 3.10, <= 3.12`. Prowler is available as a project in [PyPI](https://pypi.org/project/prowler/):
+To install Prowler as a Python package, use `Python >= 3.10, <= 3.13`. Prowler is available as a project in [PyPI](https://pypi.org/project/prowler/):
@@ -12,7 +12,7 @@ To install Prowler as a Python package, use `Python >= 3.10, <= 3.12`. Prowler i
_Requirements_:
- * `Python >= 3.10, <= 3.12`
+ * `Python >= 3.10, <= 3.13`
* `pipx` installed: [pipx installation](https://pipx.pypa.io/stable/installation/).
* AWS, GCP, Azure and/or Kubernetes credentials
@@ -30,7 +30,7 @@ To install Prowler as a Python package, use `Python >= 3.10, <= 3.12`. Prowler i
_Requirements_:
- * `Python >= 3.10, <= 3.12`
+ * `Python >= 3.10, <= 3.13`
* `Python pip >= 21.0.0`
* AWS, GCP, Azure, M365 and/or Kubernetes credentials
@@ -40,12 +40,6 @@ To install Prowler as a Python package, use `Python >= 3.10, <= 3.12`. Prowler i
pip install prowler
prowler -v
```
-
- To upgrade Prowler to the latest version:
-
- ``` bash
- pip install --upgrade prowler
- ```
_Requirements_:
@@ -87,7 +81,7 @@ To install Prowler as a Python package, use `Python >= 3.10, <= 3.12`. Prowler i
_Requirements_:
- * `Python >= 3.10, <= 3.12`
+ * `Python >= 3.10, <= 3.13`
* AWS, GCP, Azure and/or Kubernetes credentials
_Commands_:
@@ -102,8 +96,8 @@ To install Prowler as a Python package, use `Python >= 3.10, <= 3.12`. Prowler i
_Requirements_:
- * `Ubuntu 23.04` or above. For older Ubuntu versions, check [pipx installation](https://docs.prowler.com/projects/prowler-open-source/en/latest/#__tabbed_1_1) and ensure `Python >= 3.10, <= 3.12` is installed.
- * `Python >= 3.10, <= 3.12`
+ * `Ubuntu 23.04` or above. For older Ubuntu versions, check [pipx installation](https://docs.prowler.com/projects/prowler-open-source/en/latest/#__tabbed_1_1) and ensure `Python >= 3.10, <= 3.13` is installed.
+ * `Python >= 3.10, <= 3.13`
* AWS, GCP, Azure and/or Kubernetes credentials
_Commands_:
@@ -170,6 +164,68 @@ To install Prowler as a Python package, use `Python >= 3.10, <= 3.12`. Prowler i
+## Updating Prowler CLI
+
+Upgrade Prowler CLI to the latest release using the same method chosen for installation:
+
+
+
+ ```bash
+ pipx upgrade prowler
+ prowler -v
+ ```
+
+
+ ```bash
+ pip install --upgrade prowler
+ prowler -v
+ ```
+
+
+ Pull the desired image tag to fetch the latest version:
+
+ ```bash
+ docker pull toniblyx/prowler:latest
+ ```
+
+
+ Replace `latest` with a specific release tag (for example, `stable` or ``) to pin a version. Refer to the [Container Versions](#container-versions) section for the full list of available tags.
+
+
+
+ Pull the latest changes and sync the environment:
+
+ ```bash
+ cd prowler
+ git pull
+ uv sync
+ uv run python prowler-cli.py -v
+ ```
+
+
+ To upgrade to a specific release, check out the corresponding tag before syncing: `git checkout `.
+
+
+
+ ```bash
+ brew upgrade prowler
+ prowler -v
+ ```
+
+
+ Both AWS CloudShell and Azure CloudShell install Prowler with `pipx`, so the upgrade command is the same:
+
+ ```bash
+ pipx upgrade prowler
+ prowler -v
+ ```
+
+
+
+
+ To install a specific version instead of the latest release, pin it explicitly. For example, with `pipx`: `pipx install prowler==`, or with `pip`: `pip install prowler==`. The available releases are listed in the [Releases GitHub section](https://github.com/prowler-cloud/prowler/releases).
+
+
## Container Versions
The available versions of Prowler CLI are the following:
diff --git a/docs/getting-started/installation/prowler-mcp.mdx b/docs/getting-started/installation/prowler-mcp.mdx
index 7bf766e143..5be469b5b2 100644
--- a/docs/getting-started/installation/prowler-mcp.mdx
+++ b/docs/getting-started/installation/prowler-mcp.mdx
@@ -5,12 +5,12 @@ title: "Installation"
There are **two ways** to use Prowler MCP Server:
-
+
**No installation required** - Just configuration
Use `https://mcp.prowler.com/mcp`
-
+
**Local installation** - Full control
Install via Docker, PyPI, or source code
@@ -18,8 +18,8 @@ There are **two ways** to use Prowler MCP Server:
-For "Option 1: Managed by Prowler", go directly to the [Configuration Guide](/getting-started/basic-usage/prowler-mcp#hosted-server-configuration-recommended) to set up your Claude Desktop, Cursor, or other MCP client.
-**This guide is focused on local installation, "Option 2: Run Locally"**.
+For the Cloud MCP Server, go directly to the [Configuration Guide](/getting-started/basic-usage/prowler-mcp#cloud-mcp-server-configuration-recommended) to set up your Claude Desktop, Cursor, or other MCP client.
+**This guide is focused on local installation, the Local MCP Server**.
## Installation Methods
@@ -51,7 +51,7 @@ Choose one of the following installation methods:
```bash
docker run --rm -i \
- -e PROWLER_APP_API_KEY="pk_your_api_key" \
+ -e PROWLER_API_KEY="pk_your_api_key" \
-e API_BASE_URL="https://api.prowler.com/api/v1" \
prowlercloud/prowler-mcp
```
@@ -141,6 +141,45 @@ Choose one of the following installation methods:
---
+## Updating Prowler MCP Server
+
+When running the Local MCP Server, upgrade to the latest version using the same method chosen for installation. The Cloud MCP Server (`https://mcp.prowler.com/mcp`) is always kept up to date by Prowler and requires no action.
+
+
+
+ Pull the latest image and restart the container:
+
+ ```bash
+ docker pull prowlercloud/prowler-mcp
+ ```
+
+
+ Recreate any running container after pulling the new image so the updated version takes effect.
+
+
+
+ Pull the latest changes and sync the dependencies:
+
+ ```bash
+ cd prowler/mcp_server
+ git pull
+ uv sync
+ uv run prowler-mcp --help
+ ```
+
+
+ Pull the latest source and rebuild the image:
+
+ ```bash
+ cd prowler/mcp_server
+ git pull
+ docker build -t prowler-mcp .
+ ```
+
+
+
+---
+
## Command Line Options
The Prowler MCP Server supports the following command-line arguments:
@@ -180,19 +219,19 @@ Configure the server using environment variables:
| Variable | Description | Required | Default |
|----------|-------------|----------|---------|
-| `PROWLER_APP_API_KEY` | Prowler API key | Only for STDIO mode | - |
+| `PROWLER_API_KEY` | Prowler API key | Only for STDIO mode | - |
| `API_BASE_URL` | Custom Prowler API endpoint | No | `https://api.prowler.com/api/v1` |
| `PROWLER_MCP_TRANSPORT_MODE` | Default transport mode (overwritten by `--transport` argument) | No | `stdio` |
```bash macOS/Linux
-export PROWLER_APP_API_KEY="pk_your_api_key_here"
+export PROWLER_API_KEY="pk_your_api_key_here"
export API_BASE_URL="https://api.prowler.com/api/v1"
export PROWLER_MCP_TRANSPORT_MODE="http"
```
```bash Windows PowerShell
-$env:PROWLER_APP_API_KEY="pk_your_api_key_here"
+$env:PROWLER_API_KEY="pk_your_api_key_here"
$env:API_BASE_URL="https://api.prowler.com/api/v1"
$env:PROWLER_MCP_TRANSPORT_MODE="http"
```
@@ -207,7 +246,7 @@ Never commit your API key to version control. Use environment variables or secur
For convenience, create a `.env` file in the `mcp_server` directory:
```bash .env
-PROWLER_APP_API_KEY=pk_your_api_key_here
+PROWLER_API_KEY=pk_your_api_key_here
API_BASE_URL=https://api.prowler.com/api/v1
PROWLER_MCP_TRANSPORT_MODE=stdio
```
diff --git a/docs/getting-started/products/index.mdx b/docs/getting-started/products/index.mdx
new file mode 100644
index 0000000000..c14ea37471
--- /dev/null
+++ b/docs/getting-started/products/index.mdx
@@ -0,0 +1,52 @@
+---
+title: 'Prowler Product Families'
+description: 'Official names for Prowler Open Source projects and Prowler Products, including former product names.'
+boost: 2
+---
+
+Prowler ships two product families: Prowler Products, operated or licensed by the Prowler team, and Open Source projects, free to run and extend. This page is the reference for every official name. If a page or blog post uses a former name, the [Former Names](#former-names) table maps it to the current one.
+
+
+Read the [public announcement of the Prowler product families](https://prowler-workspace.slack.com/archives/C03JUQVM33L/p1784120677833319) in our Slack community.
+
+
+## Prowler Products
+
+| Name | Description |
+|------|-------------|
+| [Prowler Cloud](/getting-started/products/prowler-cloud) | Managed cloud security platform operated by the Prowler team. See [pricing](https://prowler.com/pricing). |
+| Prowler Private Cloud | Prowler Cloud deployed in your own environment. Formerly Prowler Enterprise. See [pricing](https://prowler.com/pricing). |
+| [Prowler Hub](https://hub.prowler.com) | Free public library of versioned checks, cloud service artifacts, and compliance frameworks. |
+| [Prowler Lighthouse AI](/getting-started/products/prowler-cloud-lighthouse) | AI security analyst capabilities within Prowler Cloud and Prowler Private Cloud. |
+| [Prowler MCP](/getting-started/products/prowler-mcp) | MCP server that connects AI assistants and agents to Prowler, including IDE plugins such as [Prowler for Claude Code](/getting-started/products/prowler-claude-code-plugin). |
+
+{/* Unreleased products. Uncomment these rows in the Prowler Products table when announced:
+| Prowler Registry | Distribution service for Prowler content such as checks and compliance frameworks. Free and paid tiers. |
+| Prowler Local Registry | Prowler Registry running in your own environment. Paid. |
+*/}
+
+
+Throughout this documentation, the green cloud icon in the sidebar marks sections and pages for capabilities that require a Prowler Cloud or Prowler Private Cloud [subscription](https://prowler.com/pricing).
+
+
+Products without a documentation page here are available through the Prowler team. [Contact us](https://prowler.com/contact) for details.
+
+## Open Source Projects
+
+| Name | Description |
+|------|-------------|
+| [Prowler CLI](/getting-started/products/prowler-cli) | Command line tool to run security scans across all supported providers. |
+| [Prowler Local Server](/getting-started/products/prowler-app) | Self-hosted web application and API to run scans, visualize findings, and manage cloud providers. Formerly Prowler App. |
+| [Prowler Local Dashboard](/user-guide/cli/tutorials/dashboard) | Local web dashboard to visualize scan results from Prowler CLI CSV outputs. Shipped with Prowler CLI. |
+| [Prowler SDK](/getting-started/products/prowler-sdk) | Python library that powers Prowler CLI and Prowler Local Server. Part of the [prowler repository](https://github.com/prowler-cloud/prowler). |
+
+## Former Names
+
+| Former name | Current name |
+|-------------|--------------|
+| Prowler App | [Prowler Local Server](/getting-started/products/prowler-app) |
+| Prowler Enterprise | Prowler Private Cloud |
+
+## Prowler for MSPs and MSSPs
+
+Prowler partners with managed service providers (MSPs) and managed security service providers (MSSPs) that operate Prowler for their customers. Visit [partners.prowler.com](https://partners.prowler.com) to become a partner.
diff --git a/docs/getting-started/products/prowler-app.mdx b/docs/getting-started/products/prowler-app.mdx
index 2df6015d06..76af591ba0 100644
--- a/docs/getting-started/products/prowler-app.mdx
+++ b/docs/getting-started/products/prowler-app.mdx
@@ -2,16 +2,16 @@
title: 'Overview'
---
-Prowler App is a web application that simplifies running Prowler. It provides:
+Prowler Local Server is a self-hosted web application that simplifies running Prowler. It provides:
- **User-friendly interface** for configuring and executing scans
- Dashboard to **view results** and manage **security findings**
-
+
## Components
-Prowler App consists of four main components:
+Prowler Local Server consists of four main components:
- **Prowler UI**: User-friendly web interface for running Prowler and viewing results, powered by Next.js
- **Prowler API**: Backend API that executes Prowler scans and stores results, built with Django REST Framework
@@ -26,4 +26,42 @@ Supporting infrastructure includes:
- **Valkey**: In-memory database serving as message broker for Celery workers
- **Neo4j**: Graph database used by the Attack Paths feature to combine cloud inventory with Prowler findings (currently populated by AWS scans)
-
+```mermaid
+flowchart TB
+ user([User / Security Team])
+ cli([Prowler CLI])
+
+ subgraph APP["Prowler Local Server"]
+ ui["Prowler UI (Next.js)"]
+ api["Prowler API (Django REST Framework)"]
+ worker["API Worker (Celery)"]
+ beat["API Scheduler (Celery Beat)"]
+ mcp["Prowler MCP Server (Lighthouse AI tools)"]
+ end
+
+ sdk["Prowler SDK (Python)"]
+
+ subgraph DATA["Data Layer"]
+ pg[("PostgreSQL")]
+ valkey[("Valkey / Redis")]
+ neo4j[("Neo4j")]
+ end
+
+ providers["Providers"]
+
+ user --> ui
+ user --> cli
+ ui -->|REST| api
+ ui -->|MCP HTTP| mcp
+ mcp -->|REST| api
+ api --> pg
+ api --> valkey
+ beat -->|enqueue jobs| valkey
+ valkey -->|dispatch| worker
+ worker --> pg
+ worker -->|Attack Paths| neo4j
+ worker -->|invokes| sdk
+ cli --> sdk
+
+ sdk --> providers
+```
diff --git a/docs/getting-started/products/prowler-claude-code-plugin.mdx b/docs/getting-started/products/prowler-claude-code-plugin.mdx
new file mode 100644
index 0000000000..99c92a1488
--- /dev/null
+++ b/docs/getting-started/products/prowler-claude-code-plugin.mdx
@@ -0,0 +1,102 @@
+---
+title: 'Prowler for Claude Code'
+sidebarTitle: 'Claude Code'
+---
+
+End-to-end cloud security and compliance from inside [Claude Code](https://www.claude.com/product/claude-code), powered by the [Prowler MCP server](/getting-started/products/prowler-mcp). The plugin lets Claude walk a Prowler Cloud-connected account through a compliance assessment and remediate findings until the chosen security or industry framework is compliant.
+
+
+**Preview**: this plugin is under active development. Please report issues on [GitHub](https://github.com/prowler-cloud/prowler/issues) or join the [Slack community](https://goto.prowler.com/slack) for feedback.
+
+
+## Requirements
+
+
+
+ Installed and signed in. See the [official install guide](https://www.claude.com/product/claude-code).
+
+
+ The free tier is enough to start. Sign up at [cloud.prowler.com](https://cloud.prowler.com).
+
+
+ Create one at [cloud.prowler.com/profile](https://cloud.prowler.com/profile).
+
+
+
+## Installation
+
+
+
+ Inside a Claude Code session:
+
+ ```text
+ /plugin marketplace add prowler-cloud/prowler
+ /plugin install prowler@prowler-plugins
+ ```
+
+
+ If you already have the repository checked out:
+
+ ```text
+ /plugin marketplace add /absolute/path/to/prowler
+ /plugin install prowler@prowler-plugins
+ ```
+
+
+
+## Configuration
+
+On first install, Claude Code prompts for your **Prowler API key**. The value is stored securely (macOS keychain or `~/.claude/.credentials.json`) and used to authenticate against Prowler Cloud.
+
+
+To rotate the key, uninstall and reinstall the plugin — Claude Code will prompt again.
+
+
+## Verify the installation
+
+In a Claude Code session:
+
+```text
+/mcp → "prowler" appears as a connected server
+/plugin → "prowler" enabled, skill listed as prowler:framework-compliance-triage
+```
+
+If `/mcp` reports the `prowler` server as failed, the most common cause is a rejected API key — re-issue one in Prowler Cloud and reinstall the plugin so it re-prompts.
+
+## Usage
+
+Open a conversation that mentions the framework you want to comply with. Examples:
+
+- *"Make my AWS production account compliant with CIS 4.0."*
+- *"Make my current Terraform project compliant with Prowler ThreatScore Compliance Framework based on the latest scan results."*
+- *"Help me get to 100% on PCI-DSS for this GCP project."*
+
+You pick a **primary tool** (Terraform, gh / az / aws CLI, web console, or mixed) and a **mode**:
+
+
+
+ Claude shows each fix — target resource, exact commands, side effects, reversibility — and waits for your go-ahead before applying.
+
+
+ Claude presents a single up-front plan grouped by shared fixes, waits for one confirmation, then proceeds. It pauses mid-loop if a fix has wide blast radius or a finding is not applicable.
+
+
+
+Claude tracks progress in a markdown report under `.prowler/` at your project root — one file per framework × account. Open it any time to see exactly where the flow is. When all findings are addressed, Claude proposes a fresh Prowler scan to verify everything end-to-end.
+
+## Uninstalling
+
+```text
+/plugin uninstall prowler@prowler-plugins
+/plugin marketplace remove prowler-plugins
+```
+
+The stored API key is removed automatically.
+
+## Troubleshooting
+
+| Symptom | Likely cause | Fix |
+| --- | --- | --- |
+| `/mcp` shows `prowler` as failed | Rejected API key | Generate a new one in Prowler Cloud and reinstall the plugin to re-prompt. |
+| Skill not invoked when expected | The skill description didn't match the prompt | Mention the framework name plus "compliance" or "compliant" in your prompt. |
+| "Framework not supported" | Prowler Hub does not list the framework for that provider | Open an issue or PR at [github.com/prowler-cloud/prowler](https://github.com/prowler-cloud/prowler). |
diff --git a/docs/getting-started/products/prowler-cli.mdx b/docs/getting-started/products/prowler-cli.mdx
index ac4f37c291..fa75e0456e 100644
--- a/docs/getting-started/products/prowler-cli.mdx
+++ b/docs/getting-started/products/prowler-cli.mdx
@@ -9,12 +9,12 @@ prowler
```

-## Prowler Dashboard
+## Prowler Local Dashboard
```console
prowler dashboard
```
-
+
Prowler includes hundreds of security controls aligned with widely recognized industry frameworks and standards, including:
diff --git a/docs/getting-started/products/prowler-cloud-lighthouse.mdx b/docs/getting-started/products/prowler-cloud-lighthouse.mdx
new file mode 100644
index 0000000000..d32118d468
--- /dev/null
+++ b/docs/getting-started/products/prowler-cloud-lighthouse.mdx
@@ -0,0 +1,154 @@
+---
+title: 'Overview'
+---
+
+import { SubscriptionBanner } from "/snippets/subscription-banner.mdx"
+
+Prowler Cloud runs an enhanced version of Lighthouse AI in Open Source repository, the Agentic Cloud Defender that helps teams understand, prioritize, and remediate security findings across cloud environments.
+
+
+
+
+
+## What's New
+
+The Agentic Cloud Defender does more than answer questions, it helps teams **find and remediate what actually matters**, cutting through the noise to focus on the risk that counts. Prowler Cloud sharpens Lighthouse AI with the following improvements over the open-source version:
+
+
+
+ Conversations are saved and can be revisited or resumed at any time.
+
+
+ An upgraded default model delivers stronger reasoning and tool calling.
+
+
+ Switch between the standard interface and a chat-first agentic view.
+
+
+ Open Lighthouse AI as a side panel from any page to get help in context.
+
+
+ Credentials are validated automatically when a provider is configured.
+
+
+
+## Chat View
+
+Lighthouse AI is no longer a separate section in the left navigation. Prowler Cloud now offers two application views: a normal view for browsing dashboards, findings, and configuration, and an agentic chat view, powered by Lighthouse AI, for conversational, multi-step security analysis. Conversations are saved automatically, so earlier sessions can be reopened and resumed at any time.
+
+Promoting the chat to a top-level view gives Lighthouse AI the room it needs for a fully agentic workflow and makes the Agentic Cloud Defender a primary way to work in Prowler Cloud.
+
+
+
+### Side Panel
+
+You do not have to switch to the full chat view to reach Lighthouse AI. A side panel is available on every page of Prowler Cloud. While collapsed it stays out of the way; open it from any dashboard, findings list, or configuration screen to ask questions without leaving what you are working on. Open it using the Lighthouse AI button, circled in red in the image below.
+
+
+
+Once open, the panel slides in alongside your current page and shares the same agent, tools, and persistent chat sessions as the full Chat View, so a conversation started in the panel can be reopened and continued later from either place.
+
+
+
+- **Available everywhere:** Summon the assistant from any page while you keep working in the normal view.
+- **Context-aware help:** Ask about the findings, resources, or compliance data you are currently looking at.
+- **Continuous sessions:** Conversations opened in the side panel are saved alongside the rest of your chat history.
+
+### Tool Usage
+
+Lighthouse AI on Prowler Cloud renders the agent's work as it happens, so responses are easier to follow and to trust. Tool calls and reasoning steps appear in the order they occur within the conversation.
+
+- **Ordered steps:** Tool calls and reasoning are shown in sequence, reflecting how the agent reached its answer.
+- **Tool visibility:** The data tools invoked to retrieve findings and other Prowler information are displayed as the agent uses them.
+- **Thought process:** The agent's reasoning is presented alongside its actions, and can be expanded to review the full chain of steps.
+
+
+
+## Configuration
+
+Configure Lighthouse AI on Prowler Cloud from **Configuration** → **Lighthouse AI**:
+
+1. Click the desired provider (OpenAI, Amazon Bedrock, or OpenAI Compatible).
+2. Enter the required credentials.
+3. Click **Save**. The connection is validated automatically before the provider becomes available.
+
+
+
+### Business Context
+
+At the top of the configuration page, the optional **Business Context** field lets teams add environment priorities, compliance requirements, and ownership details, so responses align with organizational needs.
+
+Lighthouse AI on Prowler Cloud supports OpenAI, Amazon Bedrock, and OpenAI-compatible providers, with GPT-5.5 as the default. For per-provider setup and how to switch the default provider or model, see [Using Multiple LLM Providers](/user-guide/tutorials/prowler-cloud-lighthouse-multi-llm).
+
+## Capabilities
+
+Lighthouse AI works through the [Prowler MCP Server](/getting-started/products/prowler-mcp), which gives the agent a growing catalog of tools to explore and act on your security data. These actions run inside Prowler and never modify your cloud resources. Everything the agent can do maps to one of the following capability areas.
+
+### Findings and Finding Groups
+
+- Search and filter security findings across every connected provider by severity, status, region, service, check, date range, and muted state.
+- Retrieve full finding details, including remediation guidance, check metadata, and affected resources.
+- Summarize findings with aggregate statistics and trends.
+- Browse finding groups aggregated by check and drill down into the specific resources each group affects.
+
+### Resources
+
+- List and filter cloud resources by provider, region, service, resource type, and tags.
+- Inspect a resource's configuration, metadata, and related findings.
+- Review the timeline of cloud API actions performed on a resource (AWS CloudTrail), including who did what and when.
+- Get an aggregate overview of the resources Prowler has discovered.
+
+### Compliance
+
+- Review high-level compliance status across all frameworks, with pass/fail statistics per framework.
+- Get a requirement-level breakdown for a specific framework, including failed requirements and their associated findings.
+
+### Attack Paths
+
+- List Attack Paths scans and discover the queries available for each completed scan.
+- Run graph-based queries to reveal privilege-escalation chains and exploitable misconfigurations.
+- Retrieve the Cartography graph schema to build accurate custom queries.
+
+### Scans and Providers
+
+- List, inspect, and rename security scans across providers.
+- Trigger manual scans and schedule automated daily scans for continuous monitoring.
+- Search connected providers and check their connection status, connect new providers, or remove existing ones.
+
+### Muting
+
+- Manage the mutelist for pattern-based bulk muting.
+- Create, update, list, and delete finding-specific mute rules, each with a documented reason and audit trail.
+
+### Security Check Catalog and Documentation
+
+- Browse and search the Prowler Hub catalog of security checks and compliance frameworks, including check code and automated fixers.
+- Search and retrieve official Prowler documentation to answer how-to and product questions.
+
+For the complete list of underlying tools, see the [Prowler MCP Tools Reference](/getting-started/basic-usage/prowler-mcp-tools).
+
+## FAQ
+
+**Which LLM providers are supported?**
+
+OpenAI (GPT models, including the default GPT-5.5), Amazon Bedrock (Claude, Llama, Titan, and others), and any OpenAI-compatible service such as OpenRouter.
+
+**Can Lighthouse AI change my cloud environment?**
+
+No. Lighthouse AI cannot modify the resources in your connected cloud providers (AWS, Azure, GCP, and others). It has read-only access to that environment and no tools to change it, even when the connected cloud credentials would allow it.
+
+**Can Lighthouse AI change my Prowler Cloud environment?**
+
+Yes. Lighthouse AI can take action within Prowler Cloud itself, such as connecting or removing providers, triggering and scheduling scans, and managing mute rules and the mutelist. See [Capabilities](#capabilities) for the full list of what it can do. These actions only affect your Prowler Cloud workspace, never the resources in your cloud providers.
+
+## Looking for the Open Source Version?
+
+Lighthouse AI is also available in the open-source Prowler Local Server. For its capabilities, FAQs, and limitations, see the open-source documentation.
+
+
+ Capabilities, FAQs, and limitations for Lighthouse AI in the open-source Prowler Local Server
+
+
+## Getting Help
+
+For issues or suggestions with Lighthouse AI on Prowler Cloud, request support at [support.prowler.com](https://support.prowler.com) or [reach out through our Slack channel](https://goto.prowler.com/slack).
diff --git a/docs/getting-started/products/prowler-lighthouse-ai.mdx b/docs/getting-started/products/prowler-lighthouse-ai.mdx
index 4e2eed8538..6cb6edf502 100644
--- a/docs/getting-started/products/prowler-lighthouse-ai.mdx
+++ b/docs/getting-started/products/prowler-lighthouse-ai.mdx
@@ -8,11 +8,16 @@ import { VersionBadge } from "/snippets/version-badge.mdx"
Prowler Lighthouse AI is a Cloud Security Analyst chatbot that helps you understand, prioritize, and remediate security findings in your cloud environments. It's designed to provide security expertise for teams without dedicated resources, acting as your 24/7 virtual cloud security analyst.
-
+
-
- Learn how to configure Lighthouse AI with your preferred LLM provider
-
+
+
+ Discover the enhanced Cloud experience: persistent chat sessions, GPT-5.5 by default, a dedicated agentic view, and transparent reasoning
+
+
+ Learn how to configure Lighthouse AI with your preferred LLM provider
+
+
## Capabilities
@@ -26,7 +31,7 @@ Ask questions in plain English about your security findings. Examples:
- "Show me all S3 buckets with public access."
- "What security issues were found in my production accounts?"
-
+
### Detailed Remediation Guidance
@@ -36,7 +41,7 @@ Get tailored step-by-step instructions for fixing security issues:
- Commands or console steps to implement fixes
- Alternative approaches with different solutions
-
+
### Enhanced Context and Analysis
@@ -46,9 +51,9 @@ Lighthouse AI can provide additional context to help you understand the findings
- Provide risk assessments based on your environment and context
- Connect related findings to show broader security patterns
-
+
-
+
## Important Notes
diff --git a/docs/getting-started/products/prowler-mcp.mdx b/docs/getting-started/products/prowler-mcp.mdx
index 762b088326..93159b2e7d 100644
--- a/docs/getting-started/products/prowler-mcp.mdx
+++ b/docs/getting-started/products/prowler-mcp.mdx
@@ -8,6 +8,31 @@ title: "Overview"
**Preview Feature**: This MCP server is currently under active development. Features and functionality may change. We welcome your feedback—please report any issues on [GitHub](https://github.com/prowler-cloud/prowler/issues) or join our [Slack community](https://goto.prowler.com/slack) to discuss and share your thoughts.
+## Quickest Way to Connect: Cloud MCP Server
+
+The fastest way to get started is the **Cloud MCP Server** at `https://mcp.prowler.com/mcp` — no installation, always up to date, and maintained by Prowler. Just point your MCP client at the URL and authenticate with a [Prowler API key](/user-guide/tutorials/prowler-app-api-keys) as a Bearer token:
+
+```json
+{
+ "mcpServers": {
+ "prowler": {
+ "url": "https://mcp.prowler.com/mcp",
+ "headers": {
+ "Authorization": "Bearer "
+ }
+ }
+ }
+}
+```
+
+
+ Step-by-step setup for Claude Desktop, Claude Code, Cursor, and other clients.
+
+
+
+Prefer to run it yourself? The **Local MCP Server** runs on your own machine or infrastructure. The Cloud MCP Server additionally provides tools for Prowler Cloud-specific features such as [Alerts](/user-guide/tutorials/prowler-alerts), [Scan Scheduling](/user-guide/tutorials/prowler-scan-scheduling), and [Findings Triage](/user-guide/tutorials/prowler-app-findings-triage). See [Cloud vs Local MCP Server](#cloud-vs-local-mcp-server).
+
+
## What is the Model Context Protocol?
The [Model Context Protocol (MCP)](https://modelcontextprotocol.io) is an open standard developed by Anthropic that enables AI assistants to securely connect to external data sources and tools. It functions as a universal adapter enabling AI assistants to interact with various services through a standardized interface.
@@ -16,9 +41,9 @@ The [Model Context Protocol (MCP)](https://modelcontextprotocol.io) is an open s
The Prowler MCP Server provides three main integration points:
-### 1. Prowler Cloud and Prowler App (Self-Managed)
+### 1. Prowler Cloud, Private Cloud & Local Server
-Full access to Prowler Cloud platform and self-managed Prowler App for:
+Full access to your Prowler deployment — Prowler Cloud, Prowler Private Cloud, or Prowler Local Server — for:
- **Findings Analysis**: Query, filter, and analyze security findings across all your cloud environments
- **Provider Management**: Create, configure, and manage your configured Prowler providers (AWS, Azure, GCP, etc.)
- **Scan Orchestration**: Trigger on-demand scans and schedule recurring security assessments
@@ -29,7 +54,7 @@ Full access to Prowler Cloud platform and self-managed Prowler App for:
### 2. Prowler Hub
Access to Prowler's comprehensive security knowledge base:
-- **Security Checks Catalog**: Browse and search **over 1000 security checks** across multiple cloud providers.
+- **Security Checks Catalog**: Browse and search **over 2,000 security checks** across multiple cloud providers.
- **Check Implementation**: View the Python code that powers each security check.
- **Automated Fixers**: Access remediation scripts for common security issues.
- **Compliance Frameworks**: Explore mappings to **over 70 compliance standards and frameworks**.
@@ -44,12 +69,53 @@ Search and retrieve official Prowler documentation:
## MCP Server Architecture
-The following diagram illustrates the Prowler MCP Server architecture and its integration points:
+The following diagram illustrates the Prowler MCP Server architecture and its integration points. MCP clients connect to either the **Cloud MCP Server** (recommended) or a **Local MCP Server**; both expose the same tools and reach the same Prowler backends:
-
+```mermaid
+flowchart LR
+ subgraph HOSTS["MCP Clients"]
+ chat["Chat Interfaces (Claude Desktop, LobeChat)"]
+ ide["IDEs and Code Editors (Claude Code, Cursor)"]
+ apps["Other AI Applications (5ire, custom agents)"]
+ end
+
+ subgraph SERVERS["Prowler MCP Server"]
+ direction TB
+ cloud["☁️ Cloud MCP Server (Recommended) mcp.prowler.com/mcp · HTTP Managed by Prowler · always up to date Adds Cloud-only tools (Alerts, Scan Scheduling, Findings Triage)"]
+ local["💻 Local MCP Server Self-run · STDIO or HTTP Python 3.12+ or Docker You manage updates"]
+ end
+
+ subgraph TOOLS["Prowler MCP Tools"]
+ prowler_tools["prowler_* tools (API key or JWT auth) Findings · Providers · Scans Resources · Muting · Compliance Attack Paths"]
+ hub_tools["prowler_hub_* tools (no auth) Checks Catalog · Check Code Fixers · Compliance Frameworks"]
+ docs_tools["prowler_docs_* tools (no auth) Search · Document Retrieval"]
+ end
+
+ api["Prowler API (REST) Cloud · Private Cloud · Local Server"]
+ hub["hub.prowler.com (REST)"]
+ docs["docs.prowler.com (Mintlify)"]
+
+ chat -->|HTTP| cloud
+ ide -->|HTTP| cloud
+ apps -->|HTTP| cloud
+ chat -->|STDIO or HTTP| local
+ ide -->|STDIO or HTTP| local
+ apps -->|STDIO or HTTP| local
+
+ cloud --> prowler_tools
+ cloud --> hub_tools
+ cloud --> docs_tools
+ local --> prowler_tools
+ local --> hub_tools
+ local --> docs_tools
+
+ prowler_tools -->|REST| api
+ hub_tools -->|REST| hub
+ docs_tools -->|REST| docs
+```
The architecture shows how AI assistants connect through the MCP protocol to access Prowler's three main components:
-- Prowler Cloud/App for security operations
+- Prowler Cloud, Prowler Private Cloud, or Prowler Local Server for security operations
- Prowler Hub for security knowledge
- Prowler Documentation for guidance and reference.
@@ -88,8 +154,8 @@ REQUIREMENTS:
DATA TO FETCH:
Use these MCP tools in this order:
-1. Prowler app list providers - To get all available configured provider in the account
-2. Prowler app get latest findings - To get findings information, if there are so many you can use the filter_fields to get less information, or pagination to get in different batches
+1. Prowler list providers - To get all available configured provider in the account
+2. Prowler get latest findings - To get findings information, if there are so many you can use the filter_fields to get less information, or pagination to get in different batches
3. For most critical findings you can get more context and remediation with Prowler Hub to get remediations for example
DESIGN REQUIREMENTS:
@@ -126,65 +192,48 @@ Generate the complete HTML file and display it
>
-## Deployment Options
+## Cloud vs Local MCP Server
-Prowler MCP Server can be used in three ways:
+There are two ways to run the Prowler MCP Server. For almost everyone, the **Cloud MCP Server** is the right choice — it needs no installation and is maintained by Prowler. The **Local MCP Server** exists for users who need to run it on their own machine or infrastructure.
-### 1. Prowler Cloud MCP Server
+| | ☁️ **Cloud MCP Server** (Recommended) | 💻 **Local MCP Server** |
+|---|---|---|
+| **Endpoint** | `https://mcp.prowler.com/mcp` | Runs on your machine or infrastructure |
+| **Setup** | Just configure your MCP client | Install via Docker, or source |
+| **Transport** | HTTP | STDIO (subprocess) or self-hosted HTTP |
+| **Maintenance** | Managed by Prowler, always up to date | You manage updates |
+| **Requirements** | None (just an MCP client) | Python 3.12+ or Docker |
+| **Cloud-only tools** | ✅ Alerts, Scan Scheduling, Findings Triage | ❌ Not available |
+| **Authentication** | API key or JWT token | API key/JWT (HTTP) or env vars (STDIO) |
-**Use Prowler's managed MCP server at `https://mcp.prowler.com/mcp`**
+### ☁️ Cloud MCP Server (Recommended)
-- No installation required.
-- Managed and maintained by Prowler team.
-- Authentication to Prowler Cloud or Prowler App (self-managed) via API key or JWT token.
+Prowler's managed MCP server at `https://mcp.prowler.com/mcp`. No installation, always up to date, and it includes tools for Prowler Cloud-specific features such as Alerts, Scan Scheduling, and Findings Triage. This is the path we recommend for nearly all users — go straight to the [Configuration guide](/getting-started/basic-usage/prowler-mcp#cloud-mcp-server-configuration-recommended).
-### 2. Local STDIO Mode
+### 💻 Local MCP Server
-**Run the server locally on your machine**
+Run the server yourself when you need full control over the deployment. It connects to Prowler Cloud, Prowler Private Cloud, or Prowler Local Server and can run in two modes:
-- Runs as a subprocess of your MCP client.
-- Possibility to connect to a self-hosted Prowler App (e.g. self-hosted Prowler App).
-- Authentication to Prowler Cloud or Prowler App (self-managed) via environment variables.
-- Requires Python 3.12+ or Docker.
+- **STDIO mode** — the server runs as a subprocess of your MCP client. Authentication via environment variables.
+- **Self-hosted HTTP mode** — deploy your own remote HTTP server. Authentication via API key or JWT token.
-### 3. Self-Hosted HTTP Mode
-
-**Deploy your own remote MCP server**
-
-- Full control over deployment.
-- Possibility to connect to a self-hosted Prowler App (e.g. self-hosted Prowler App).
-- Authentication to Prowler App (self-managed) via API key or JWT token.
-- Requires Python 3.12+ or Docker.
-
-## Requirements
-
-Requirements vary based on deployment option:
-
-**For Prowler Cloud MCP Server:**
-- Prowler Cloud account and API key (only for Prowler Cloud/App features)
-
-**For self-hosted STDIO/HTTP Mode:**
-- Python 3.12+ or Docker
-- Network access to:
- - `https://hub.prowler.com` (for Prowler Hub)
- - `https://docs.prowler.com` (for Prowler Documentation)
- - Prowler Cloud API or self-hosted Prowler App API (for Prowler Cloud/App features)
+Both require Python 3.12+ or Docker, plus network access to `https://hub.prowler.com` (Prowler Hub), `https://docs.prowler.com` (Prowler Documentation), and the Prowler API or Prowler Local Server API (Prowler features). See the [Installation guide](/getting-started/installation/prowler-mcp) to get started.
-**No Authentication Required**: Prowler Hub and Prowler Documentation features work without authentication in both deployment options. A Prowler API key is only required to access Prowler Cloud or Prowler App (Self-Managed) features.
+**No Authentication Required**: Prowler Hub and Prowler Documentation features work without authentication on both the Cloud and Local MCP Server. A Prowler API key is only required to access Prowler features (Prowler Cloud, Prowler Private Cloud, or Prowler Local Server).
## Next Steps
-
- Install the Prowler MCP Server using uv or Docker
-
- Configure your MCP client to connect to the server
+ Connect your MCP client to the Cloud MCP Server
+
+
+ Explore all available tools and capabilities
-
- Explore all available tools and capabilities
+
+ Run the Local MCP Server yourself using Docker, source, or uvx
diff --git a/docs/getting-started/products/prowler-sdk.mdx b/docs/getting-started/products/prowler-sdk.mdx
new file mode 100644
index 0000000000..b19f576e12
--- /dev/null
+++ b/docs/getting-started/products/prowler-sdk.mdx
@@ -0,0 +1,11 @@
+---
+title: 'Prowler SDK'
+---
+
+Prowler SDK is the Python library that powers Prowler CLI and Prowler Local Server. It implements the providers, services, and security checks that every Prowler product runs.
+
+To use or extend Prowler SDK, start with the Developer Guide:
+
+
+ Providers, services, checks, and testing: everything needed to work with Prowler SDK.
+
diff --git a/docs/images/compliance/prowler-app-cross-provider-detail.png b/docs/images/compliance/prowler-app-cross-provider-detail.png
new file mode 100644
index 0000000000..8ee82562e2
Binary files /dev/null and b/docs/images/compliance/prowler-app-cross-provider-detail.png differ
diff --git a/docs/images/compliance/prowler-app-cross-provider-overview.png b/docs/images/compliance/prowler-app-cross-provider-overview.png
new file mode 100644
index 0000000000..d818da1b80
Binary files /dev/null and b/docs/images/compliance/prowler-app-cross-provider-overview.png differ
diff --git a/docs/images/compliance/prowler-app-cross-provider-report.png b/docs/images/compliance/prowler-app-cross-provider-report.png
new file mode 100644
index 0000000000..e3b45ef74e
Binary files /dev/null and b/docs/images/compliance/prowler-app-cross-provider-report.png differ
diff --git a/docs/images/compliance/prowler-app-cross-provider-requirements-accordion.png b/docs/images/compliance/prowler-app-cross-provider-requirements-accordion.png
new file mode 100644
index 0000000000..cd87e3ee35
Binary files /dev/null and b/docs/images/compliance/prowler-app-cross-provider-requirements-accordion.png differ
diff --git a/docs/images/compliance/prowler-app-cross-provider-tab.png b/docs/images/compliance/prowler-app-cross-provider-tab.png
new file mode 100644
index 0000000000..b3aa8ed2b5
Binary files /dev/null and b/docs/images/compliance/prowler-app-cross-provider-tab.png differ
diff --git a/docs/images/icons/cloud-bold.svg b/docs/images/icons/cloud-bold.svg
new file mode 100644
index 0000000000..623bb36c11
--- /dev/null
+++ b/docs/images/icons/cloud-bold.svg
@@ -0,0 +1 @@
+
diff --git a/docs/images/lighthouse-architecture.mmd b/docs/images/lighthouse-architecture.mmd
index 47407544e9..6798801fb8 100644
--- a/docs/images/lighthouse-architecture.mmd
+++ b/docs/images/lighthouse-architecture.mmd
@@ -15,7 +15,7 @@ flowchart TB
llm["LLM Provider (OpenAI / Bedrock / OpenAI-compatible)"]
subgraph MCP["Prowler MCP Server"]
- app_tools["prowler_app_* tools (auth required)"]
+ app_tools["prowler_* tools (auth required)"]
hub_tools["prowler_hub_* tools (no auth)"]
docs_tools["prowler_docs_* tools (no auth)"]
end
@@ -29,7 +29,7 @@ flowchart TB
agent <-->|LLM API| llm
agent --> metatools
metatools --> mcpclient
- mcpclient -->|MCP HTTP · Bearer token for prowler_app_* only| app_tools
+ mcpclient -->|MCP HTTP · Bearer token for prowler_* only| app_tools
mcpclient -->|MCP HTTP| hub_tools
mcpclient -->|MCP HTTP| docs_tools
app_tools -->|REST| api
diff --git a/docs/images/organizations/authentication-details.png b/docs/images/organizations/authentication-details.png
index 2b4ae782cf..aec5060afd 100644
Binary files a/docs/images/organizations/authentication-details.png and b/docs/images/organizations/authentication-details.png differ
diff --git a/docs/images/organizations/onboarding-flow.svg b/docs/images/organizations/onboarding-flow.svg
index f6e11fc0a3..b5ba7858a0 100644
--- a/docs/images/organizations/onboarding-flow.svg
+++ b/docs/images/organizations/onboarding-flow.svg
@@ -3,41 +3,37 @@
+
+
+ Onboarding Flow
-
+ 1
- Create Management
- Account Role
-
- Quick Create or Manual
- Allows Prowler to
- discover your org
- structure
+ Start the Wizard
+
+ In Prowler Cloud
+ Enter your Org ID
+ and OU/root target
-
-
-
-
- 2
- Deploy StackSet
-
- In AWS Console
- Creates ProwlerScan
- role in every
- member account
+ Deploy the Roles
+
+ Single CF Stack
+ Management role +
+ StackSet to members
+ in one CF stack
@@ -46,11 +42,11 @@
3
- Run the Wizard
-
- In Prowler Cloud
- Discovers accounts,
- tests connections
+ Discover & Connect
+
+ In Prowler Cloud
+ Discovers accounts,
+ tests connections
@@ -59,13 +55,13 @@
4
- Launch Scans
-
- Automatic
- Scans run on all
- connected accounts
- on your schedule
+ Launch Scans
+
+ Automatic
+ Scans run on all
+ connected accounts
+ on your schedule
- Steps 1 and 2 are done once in AWS | Steps 3 and 4 are done in Prowler Cloud
+ Step 2 runs once in AWS | Steps 1, 3 and 4 are in Prowler Cloud
diff --git a/docs/images/organizations/two-roles-architecture.svg b/docs/images/organizations/two-roles-architecture.svg
index c67588b049..f8c40d5b21 100644
--- a/docs/images/organizations/two-roles-architecture.svg
+++ b/docs/images/organizations/two-roles-architecture.svg
@@ -47,7 +47,7 @@
- Deploy: Quick Create link or Manual
+ Deploy: single stack or standalone
@@ -86,7 +86,7 @@
- Deploy: via CloudFormation StackSet
+ Deploy: StackSet (single stack)Prowler discovers
diff --git a/docs/images/products/prowler-app-architecture.mmd b/docs/images/products/prowler-app-architecture.mmd
index 0c13d580c3..da99d0ab96 100644
--- a/docs/images/products/prowler-app-architecture.mmd
+++ b/docs/images/products/prowler-app-architecture.mmd
@@ -1,8 +1,10 @@
+%% Source of truth for the architecture diagram.
+%% Inlined as native mermaid blocks in the root README.md and in docs/getting-started/products/prowler-app.mdx: keep all copies in sync.
flowchart TB
user([User / Security Team])
cli([Prowler CLI])
- subgraph APP["Prowler App"]
+ subgraph APP["Prowler Local Server"]
ui["Prowler UI (Next.js)"]
api["Prowler API (Django REST Framework)"]
worker["API Worker (Celery)"]
diff --git a/docs/images/prowler-app/attack-paths/execute-query.png b/docs/images/prowler-app/attack-paths/execute-query.png
index 3872f0de2f..683c7c5bd3 100644
Binary files a/docs/images/prowler-app/attack-paths/execute-query.png and b/docs/images/prowler-app/attack-paths/execute-query.png differ
diff --git a/docs/images/prowler-app/attack-paths/fullscreen-mode.png b/docs/images/prowler-app/attack-paths/fullscreen-mode.png
index c5156925ba..190d973638 100644
Binary files a/docs/images/prowler-app/attack-paths/fullscreen-mode.png and b/docs/images/prowler-app/attack-paths/fullscreen-mode.png differ
diff --git a/docs/images/prowler-app/attack-paths/graph-filtered.png b/docs/images/prowler-app/attack-paths/graph-filtered.png
index 3ff4822a26..3eddf04473 100644
Binary files a/docs/images/prowler-app/attack-paths/graph-filtered.png and b/docs/images/prowler-app/attack-paths/graph-filtered.png differ
diff --git a/docs/images/prowler-app/attack-paths/graph-visualization.png b/docs/images/prowler-app/attack-paths/graph-visualization.png
index 2ea160a6b2..d7f2a747eb 100644
Binary files a/docs/images/prowler-app/attack-paths/graph-visualization.png and b/docs/images/prowler-app/attack-paths/graph-visualization.png differ
diff --git a/docs/images/prowler-app/attack-paths/navigation.png b/docs/images/prowler-app/attack-paths/navigation.png
index d133c7f6b4..0526e05e5c 100644
Binary files a/docs/images/prowler-app/attack-paths/navigation.png and b/docs/images/prowler-app/attack-paths/navigation.png differ
diff --git a/docs/images/prowler-app/attack-paths/node-details.png b/docs/images/prowler-app/attack-paths/node-details.png
index 9343eedd7d..8b02128604 100644
Binary files a/docs/images/prowler-app/attack-paths/node-details.png and b/docs/images/prowler-app/attack-paths/node-details.png differ
diff --git a/docs/images/prowler-app/attack-paths/query-parameters.png b/docs/images/prowler-app/attack-paths/query-parameters.png
index 9f44f81838..683c7c5bd3 100644
Binary files a/docs/images/prowler-app/attack-paths/query-parameters.png and b/docs/images/prowler-app/attack-paths/query-parameters.png differ
diff --git a/docs/images/prowler-app/attack-paths/query-selector.png b/docs/images/prowler-app/attack-paths/query-selector.png
index d8b7414156..828183fdbf 100644
Binary files a/docs/images/prowler-app/attack-paths/query-selector.png and b/docs/images/prowler-app/attack-paths/query-selector.png differ
diff --git a/docs/images/prowler-app/attack-paths/scan-list-table.png b/docs/images/prowler-app/attack-paths/scan-list-table.png
index 092b539dd0..45d0af41dc 100644
Binary files a/docs/images/prowler-app/attack-paths/scan-list-table.png and b/docs/images/prowler-app/attack-paths/scan-list-table.png differ
diff --git a/docs/images/prowler-app/findings-triage/findings-triage-note-modal.png b/docs/images/prowler-app/findings-triage/findings-triage-note-modal.png
new file mode 100644
index 0000000000..9d7926997d
Binary files /dev/null and b/docs/images/prowler-app/findings-triage/findings-triage-note-modal.png differ
diff --git a/docs/images/prowler-app/findings-triage/findings-triage-status-dropdown.png b/docs/images/prowler-app/findings-triage/findings-triage-status-dropdown.png
new file mode 100644
index 0000000000..7fb7790610
Binary files /dev/null and b/docs/images/prowler-app/findings-triage/findings-triage-status-dropdown.png differ
diff --git a/docs/images/prowler-app/findings-triage/findings-triage-table.png b/docs/images/prowler-app/findings-triage/findings-triage-table.png
new file mode 100644
index 0000000000..c8fa79e379
Binary files /dev/null and b/docs/images/prowler-app/findings-triage/findings-triage-table.png differ
diff --git a/docs/images/prowler-app/lighthouse-config.png b/docs/images/prowler-app/lighthouse/oss/config.png
similarity index 100%
rename from docs/images/prowler-app/lighthouse-config.png
rename to docs/images/prowler-app/lighthouse/oss/config.png
diff --git a/docs/images/prowler-app/lighthouse-configuration.png b/docs/images/prowler-app/lighthouse/oss/configuration.png
similarity index 100%
rename from docs/images/prowler-app/lighthouse-configuration.png
rename to docs/images/prowler-app/lighthouse/oss/configuration.png
diff --git a/docs/images/prowler-app/lighthouse-feature1.png b/docs/images/prowler-app/lighthouse/oss/feature1.png
similarity index 100%
rename from docs/images/prowler-app/lighthouse-feature1.png
rename to docs/images/prowler-app/lighthouse/oss/feature1.png
diff --git a/docs/images/prowler-app/lighthouse-feature2.png b/docs/images/prowler-app/lighthouse/oss/feature2.png
similarity index 100%
rename from docs/images/prowler-app/lighthouse-feature2.png
rename to docs/images/prowler-app/lighthouse/oss/feature2.png
diff --git a/docs/images/prowler-app/lighthouse-feature3.png b/docs/images/prowler-app/lighthouse/oss/feature3.png
similarity index 100%
rename from docs/images/prowler-app/lighthouse-feature3.png
rename to docs/images/prowler-app/lighthouse/oss/feature3.png
diff --git a/docs/images/prowler-app/lighthouse-intro.png b/docs/images/prowler-app/lighthouse/oss/intro.png
similarity index 100%
rename from docs/images/prowler-app/lighthouse-intro.png
rename to docs/images/prowler-app/lighthouse/oss/intro.png
diff --git a/docs/images/prowler-app/lighthouse-set-default-provider.png b/docs/images/prowler-app/lighthouse/oss/set-default-provider.png
similarity index 100%
rename from docs/images/prowler-app/lighthouse-set-default-provider.png
rename to docs/images/prowler-app/lighthouse/oss/set-default-provider.png
diff --git a/docs/images/prowler-app/lighthouse-switch-models.png b/docs/images/prowler-app/lighthouse/oss/switch-models.png
similarity index 100%
rename from docs/images/prowler-app/lighthouse-switch-models.png
rename to docs/images/prowler-app/lighthouse/oss/switch-models.png
diff --git a/docs/images/prowler-app/lighthouse/prowler-cloud/chat-animation.gif b/docs/images/prowler-app/lighthouse/prowler-cloud/chat-animation.gif
new file mode 100644
index 0000000000..c2e69c4816
Binary files /dev/null and b/docs/images/prowler-app/lighthouse/prowler-cloud/chat-animation.gif differ
diff --git a/docs/images/prowler-app/lighthouse/prowler-cloud/config-page.png b/docs/images/prowler-app/lighthouse/prowler-cloud/config-page.png
new file mode 100644
index 0000000000..ad1b812e03
Binary files /dev/null and b/docs/images/prowler-app/lighthouse/prowler-cloud/config-page.png differ
diff --git a/docs/images/prowler-app/lighthouse/prowler-cloud/main-chat-page.png b/docs/images/prowler-app/lighthouse/prowler-cloud/main-chat-page.png
new file mode 100644
index 0000000000..7a83637285
Binary files /dev/null and b/docs/images/prowler-app/lighthouse/prowler-cloud/main-chat-page.png differ
diff --git a/docs/images/prowler-app/lighthouse/prowler-cloud/side-panel-closed.png b/docs/images/prowler-app/lighthouse/prowler-cloud/side-panel-closed.png
new file mode 100644
index 0000000000..c36da34a95
Binary files /dev/null and b/docs/images/prowler-app/lighthouse/prowler-cloud/side-panel-closed.png differ
diff --git a/docs/images/prowler-app/lighthouse/prowler-cloud/side-panel-open.png b/docs/images/prowler-app/lighthouse/prowler-cloud/side-panel-open.png
new file mode 100644
index 0000000000..fd10f5a00b
Binary files /dev/null and b/docs/images/prowler-app/lighthouse/prowler-cloud/side-panel-open.png differ
diff --git a/docs/images/prowler-app/lighthouse/prowler-cloud/tool-usage.png b/docs/images/prowler-app/lighthouse/prowler-cloud/tool-usage.png
new file mode 100644
index 0000000000..ae637b3ff3
Binary files /dev/null and b/docs/images/prowler-app/lighthouse/prowler-cloud/tool-usage.png differ
diff --git a/docs/images/prowler-app/rbac/provider_group.png b/docs/images/prowler-app/rbac/provider_group.png
index 878306e02f..8698cc9c41 100644
Binary files a/docs/images/prowler-app/rbac/provider_group.png and b/docs/images/prowler-app/rbac/provider_group.png differ
diff --git a/docs/images/prowler-app/rbac/provider_group_edit.png b/docs/images/prowler-app/rbac/provider_group_edit.png
index 5de9649578..3d37b329b7 100644
Binary files a/docs/images/prowler-app/rbac/provider_group_edit.png and b/docs/images/prowler-app/rbac/provider_group_edit.png differ
diff --git a/docs/images/prowler-app/rbac/provider_group_edit_1.png b/docs/images/prowler-app/rbac/provider_group_edit_1.png
index ed4a090eed..4369983a20 100644
Binary files a/docs/images/prowler-app/rbac/provider_group_edit_1.png and b/docs/images/prowler-app/rbac/provider_group_edit_1.png differ
diff --git a/docs/images/prowler-app/rbac/provider_group_remove.png b/docs/images/prowler-app/rbac/provider_group_remove.png
index 580a8533cf..f504617159 100644
Binary files a/docs/images/prowler-app/rbac/provider_group_remove.png and b/docs/images/prowler-app/rbac/provider_group_remove.png differ
diff --git a/docs/images/prowler-app/rbac/role_create_1.png b/docs/images/prowler-app/rbac/role_create_1.png
index a96b0ac9ef..b88fe990db 100644
Binary files a/docs/images/prowler-app/rbac/role_create_1.png and b/docs/images/prowler-app/rbac/role_create_1.png differ
diff --git a/docs/images/prowler-app/scan-scheduling/edit-scan-schedule.png b/docs/images/prowler-app/scan-scheduling/edit-scan-schedule.png
new file mode 100644
index 0000000000..e76809b810
Binary files /dev/null and b/docs/images/prowler-app/scan-scheduling/edit-scan-schedule.png differ
diff --git a/docs/images/prowler-app/scan-scheduling/launch-scan-schedule.png b/docs/images/prowler-app/scan-scheduling/launch-scan-schedule.png
new file mode 100644
index 0000000000..980b16bf7f
Binary files /dev/null and b/docs/images/prowler-app/scan-scheduling/launch-scan-schedule.png differ
diff --git a/docs/images/prowler-app/scan-scheduling/providers-scan-schedule.png b/docs/images/prowler-app/scan-scheduling/providers-scan-schedule.png
new file mode 100644
index 0000000000..a7dab3310c
Binary files /dev/null and b/docs/images/prowler-app/scan-scheduling/providers-scan-schedule.png differ
diff --git a/docs/images/prowler-app/scan-scheduling/scheduled-scans-tab.png b/docs/images/prowler-app/scan-scheduling/scheduled-scans-tab.png
new file mode 100644
index 0000000000..26b4b160ec
Binary files /dev/null and b/docs/images/prowler-app/scan-scheduling/scheduled-scans-tab.png differ
diff --git a/docs/images/prowler_mcp_schema.mmd b/docs/images/prowler_mcp_schema.mmd
index 96973546f6..2b8508cd18 100644
--- a/docs/images/prowler_mcp_schema.mmd
+++ b/docs/images/prowler_mcp_schema.mmd
@@ -1,29 +1,40 @@
flowchart LR
- subgraph HOSTS["MCP Hosts"]
+ subgraph HOSTS["MCP Clients"]
chat["Chat Interfaces (Claude Desktop, LobeChat)"]
ide["IDEs and Code Editors (Claude Code, Cursor)"]
apps["Other AI Applications (5ire, custom agents)"]
end
- subgraph MCP["Prowler MCP Server"]
- app_tools["prowler_app_* tools (JWT or API key auth) Findings · Providers · Scans Resources · Muting · Compliance Attack Paths"]
+ subgraph SERVERS["Prowler MCP Server"]
+ direction TB
+ cloud["☁️ Cloud MCP Server (Recommended) mcp.prowler.com/mcp · HTTP Managed by Prowler · always up to date Adds Cloud-only tools (Alerts, Scan Scheduling, Findings Triage)"]
+ local["💻 Local MCP Server Self-run · STDIO or HTTP Python 3.12+ or Docker You manage updates"]
+ end
+
+ subgraph TOOLS["Prowler MCP Tools"]
+ prowler_tools["prowler_* tools (API key or JWT auth) Findings · Providers · Scans Resources · Muting · Compliance Attack Paths"]
hub_tools["prowler_hub_* tools (no auth) Checks Catalog · Check Code Fixers · Compliance Frameworks"]
docs_tools["prowler_docs_* tools (no auth) Search · Document Retrieval"]
end
- api["Prowler API (REST)"]
+ api["Prowler API (REST) Cloud · Private Cloud · Local Server"]
hub["hub.prowler.com (REST)"]
docs["docs.prowler.com (Mintlify)"]
- chat -->|STDIO or HTTP| app_tools
- chat -->|STDIO or HTTP| hub_tools
- chat -->|STDIO or HTTP| docs_tools
- ide -->|STDIO or HTTP| app_tools
- ide -->|STDIO or HTTP| hub_tools
- ide -->|STDIO or HTTP| docs_tools
- apps -->|STDIO or HTTP| app_tools
- apps -->|STDIO or HTTP| hub_tools
- apps -->|STDIO or HTTP| docs_tools
- app_tools -->|REST| api
+ chat -->|HTTP| cloud
+ ide -->|HTTP| cloud
+ apps -->|HTTP| cloud
+ chat -->|STDIO or HTTP| local
+ ide -->|STDIO or HTTP| local
+ apps -->|STDIO or HTTP| local
+
+ cloud --> prowler_tools
+ cloud --> hub_tools
+ cloud --> docs_tools
+ local --> prowler_tools
+ local --> hub_tools
+ local --> docs_tools
+
+ prowler_tools -->|REST| api
hub_tools -->|REST| hub
docs_tools -->|REST| docs
diff --git a/docs/images/prowler_mcp_schema.png b/docs/images/prowler_mcp_schema.png
deleted file mode 100644
index 8a8884fa5e..0000000000
Binary files a/docs/images/prowler_mcp_schema.png and /dev/null differ
diff --git a/docs/introduction.mdx b/docs/introduction.mdx
index c8dc5c649e..9307fa351a 100644
--- a/docs/introduction.mdx
+++ b/docs/introduction.mdx
@@ -1,21 +1,36 @@
# What is Prowler?
-**Prowler** is the world’s most widely used open-source cloud security platform that **automates security and compliance** across any cloud environment. With hundreds of ready-to-use security checks, remediation guidance, and compliance frameworks, Prowler delivers AI-driven, customizable, and easy-to-use monitoring and integrations, making cloud security simple, scalable, and cost-effective for organizations of any size.
+**Prowler** is the world’s most widely used open-source cloud security platform that **automates security and compliance** across any cloud environment. With thousands of ready-to-use security checks, remediation guidance, and compliance frameworks, Prowler delivers AI-driven, customizable, and easy-to-use monitoring and integrations, making cloud security simple, scalable, and cost-effective for organizations of any size.

+Prowler ships two product families: Prowler Products, operated or licensed by the Prowler team, and Open Source projects, free to run and extend. See [Prowler product families](/getting-started/products) for every official name, including former names.
+
+### Prowler Products
+
-
- Command Line Interface
-
-
- Web Application
-
-
- A managed service built on top of Prowler App.
+
+ Managed cloud security platform operated by the Prowler team.
- A public library of versioned checks, cloud service artifacts, and compliance frameworks.
+ Free public library of versioned checks, cloud service artifacts, and compliance frameworks.
+
+
+ MCP server that connects AI assistants and agents to Prowler.
+
+
+ Prowler Private Cloud, Prowler Lighthouse AI, and more.
+
+
+
+### Open Source
+
+
+
+ Command line tool to run security scans across all supported providers.
+
+
+ Self-hosted web application and API.
@@ -31,11 +46,14 @@ Prowler supports a wide range of providers organized by category:
| [AWS](/user-guide/providers/aws/getting-started-aws) | Official | Accounts | UI, API, CLI |
| [Azure](/user-guide/providers/azure/getting-started-azure) | Official | Subscriptions | UI, API, CLI |
| [Cloudflare](/user-guide/providers/cloudflare/getting-started-cloudflare) | Official | Accounts | UI, API, CLI |
+| [E2E Networks](/user-guide/providers/e2enetworks/getting-started-e2enetworks) | [Contact us](https://prowler.com/contact) | Projects | CLI |
| [Google Cloud](/user-guide/providers/gcp/getting-started-gcp) | Official | Projects | UI, API, CLI |
-| **NHN** | Unofficial | Tenants | CLI |
+| [Linode](/user-guide/providers/linode/getting-started-linode) | [Contact us](https://prowler.com/contact) | Accounts | CLI |
+| **NHN** | [Contact us](https://prowler.com/contact) | Tenants | CLI |
| [OpenStack](/user-guide/providers/openstack/getting-started-openstack) | Official | Projects | UI, API, CLI |
| [Oracle Cloud](/user-guide/providers/oci/getting-started-oci) | Official | Tenancies / Compartments | UI, API, CLI |
-| [Scaleway](/user-guide/providers/scaleway/getting-started-scaleway) [Contact us](https://prowler.com/contact) | Unofficial | Organizations | CLI |
+| [Scaleway](/user-guide/providers/scaleway/getting-started-scaleway) | [Contact us](https://prowler.com/contact) | Organizations | CLI |
+| [StackIT](/user-guide/providers/stackit/getting-started-stackit) | [Contact us](https://prowler.com/contact) | Projects | CLI |
### Infrastructure as Code Providers
@@ -67,7 +85,7 @@ Prowler supports a wide range of providers organized by category:
| ------------------------------------------------------------------- | -------- | -------------------- | --------- |
| [Image](/user-guide/providers/image/getting-started-image) | Official | Container Images / Registries | CLI, API |
-### Custom Providers (Prowler Cloud Enterprise Only)
+### Custom Providers (Prowler Private Cloud Only)
| Provider | Support | Audit Scope/Entities | Interface |
| -------------------- | -------- | -------------------- | --------- |
diff --git a/docs/scripts/generate_provider_cards.py b/docs/scripts/generate_provider_cards.py
new file mode 100644
index 0000000000..1b35755507
--- /dev/null
+++ b/docs/scripts/generate_provider_cards.py
@@ -0,0 +1,155 @@
+#!/usr/bin/env python3
+"""Generate docs/snippets/provider-cards.mdx from provider getting-started pages.
+
+Scans docs/user-guide/providers//getting-started-*.mdx, keeps only the
+providers that Prowler Cloud and Prowler Local Server actually support (source of truth: the
+`ProviderChoices` enum in api/src/backend/api/models.py — CLI-only providers
+such as Linode/LLM/Scaleway/StackIT are excluded), reads the frontmatter
+`title`, derives a display name, and emits a snippet exporting a
+`ProviderCards` component. Wired into pre-commit so the snippet stays in sync
+whenever a provider page or the API enum changes.
+"""
+
+from __future__ import annotations
+
+import ast
+import re
+import sys
+from pathlib import Path
+
+REPO_ROOT = Path(__file__).resolve().parents[2]
+PROVIDERS_DIR = REPO_ROOT / "docs" / "user-guide" / "providers"
+SNIPPET_PATH = REPO_ROOT / "docs" / "snippets" / "provider-cards.mdx"
+API_MODELS_PATH = REPO_ROOT / "api" / "src" / "backend" / "api" / "models.py"
+
+# Docs folder names that don't match the API enum key. Keep tiny — only rename
+# entries when the docs folder disagrees with the API-side identifier.
+DOCS_DIR_TO_API_KEY = {
+ "microsoft365": "m365",
+ "oci": "oraclecloud",
+}
+
+# Folder-name → Mintlify icon override. Providers not listed fall back to
+# DEFAULT_ICON. Add an entry only when the default looks wrong for a provider.
+ICON_OVERRIDES = {
+ "alibabacloud": "cloud",
+ "aws": "aws",
+ "azure": "microsoft",
+ "cloudflare": "cloudflare",
+ "gcp": "google",
+ "github": "github",
+ "googleworkspace": "users",
+ "iac": "code",
+ "image": "docker",
+ "kubernetes": "dharmachakra",
+ "microsoft365": "briefcase",
+ "mongodbatlas": "leaf",
+ "oci": "database",
+ "okta": "key",
+ "openstack": "cubes",
+ "vercel": "triangle",
+}
+DEFAULT_ICON = "cloud"
+
+TITLE_RE = re.compile(r"^\s*title\s*:\s*['\"](?P.+?)['\"]\s*$", re.MULTILINE)
+NAME_CLEANUP_RE = re.compile(
+ r"^Getting Started [Ww]ith (?:the )?(?P.+?)(?: on Prowler)?(?: Provider)?$"
+)
+
+
+def app_supported_provider_keys() -> set[str]:
+ """Return the set of provider keys declared in the API's ProviderChoices enum.
+
+ Uses ast rather than regex so formatting changes, decorators, comments, or
+ multi-line values in the enum body don't silently drop or invent providers.
+ """
+ tree = ast.parse(API_MODELS_PATH.read_text(encoding="utf-8"))
+ for node in ast.walk(tree):
+ if not (isinstance(node, ast.ClassDef) and node.name == "ProviderChoices"):
+ continue
+ keys: set[str] = set()
+ for item in node.body:
+ if not isinstance(item, ast.Assign):
+ continue
+ value = item.value
+ # Django TextChoices members look like: NAME = "key", _("Label")
+ # which parses as an ast.Tuple whose first element is the key.
+ if isinstance(value, ast.Tuple) and value.elts:
+ first = value.elts[0]
+ if isinstance(first, ast.Constant) and isinstance(first.value, str):
+ keys.add(first.value)
+ return keys
+ raise RuntimeError(
+ f"Could not locate ProviderChoices class in {API_MODELS_PATH.relative_to(REPO_ROOT)}"
+ )
+
+
+def extract_title(mdx_path: Path) -> str:
+ text = mdx_path.read_text(encoding="utf-8")
+ match = TITLE_RE.search(text)
+ if not match:
+ raise ValueError(f"No frontmatter title in {mdx_path}")
+ return match.group("title")
+
+
+def display_name(title: str) -> str:
+ match = NAME_CLEANUP_RE.match(title)
+ return match.group("name") if match else title
+
+
+def collect_providers() -> list[dict]:
+ supported = app_supported_provider_keys()
+ providers = []
+ for provider_dir in sorted(PROVIDERS_DIR.iterdir()):
+ if not provider_dir.is_dir():
+ continue
+ api_key = DOCS_DIR_TO_API_KEY.get(provider_dir.name, provider_dir.name)
+ if api_key not in supported:
+ continue
+ pages = sorted(provider_dir.glob("getting-started-*.mdx"))
+ if not pages:
+ continue
+ page = pages[0]
+ name = display_name(extract_title(page))
+ href = f"/user-guide/providers/{provider_dir.name}/{page.stem}"
+ icon = ICON_OVERRIDES.get(provider_dir.name, DEFAULT_ICON)
+ providers.append({"name": name, "href": href, "icon": icon})
+ providers.sort(key=lambda p: p["name"].lower())
+ return providers
+
+
+def render_snippet(providers: list[dict]) -> str:
+ cards = "\n".join(
+ f' '
+ for p in providers
+ )
+ return (
+ "{/* AUTO-GENERATED by docs/scripts/generate_provider_cards.py — do not edit by hand. */}\n"
+ "{/* Regenerated on pre-commit whenever any provider getting-started page changes. */}\n"
+ "\n"
+ "export const ProviderCards = () => (\n"
+ " \n"
+ f"{cards}\n"
+ " \n"
+ ");\n"
+ )
+
+
+def main() -> int:
+ providers = collect_providers()
+ if not providers:
+ print("No provider getting-started pages found", file=sys.stderr)
+ return 1
+ new_content = render_snippet(providers)
+ current = SNIPPET_PATH.read_text(encoding="utf-8") if SNIPPET_PATH.exists() else ""
+ if new_content == current:
+ return 0
+ SNIPPET_PATH.write_text(new_content, encoding="utf-8")
+ print(
+ f"Regenerated {SNIPPET_PATH.relative_to(REPO_ROOT)} ({len(providers)} providers)"
+ )
+ return 1 # signal pre-commit that the file changed
+
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/docs/security/index.mdx b/docs/security/index.mdx
index 30364d5b82..45b39d608b 100644
--- a/docs/security/index.mdx
+++ b/docs/security/index.mdx
@@ -27,7 +27,7 @@ All Prowler code goes through the same security pipeline, whether running on Pro
| **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.
+Self-Managed includes Prowler Local Server 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 Local Server Installation Guide](/getting-started/installation/prowler-app) to get started.
---
diff --git a/docs/security/networking.mdx b/docs/security/networking.mdx
index 767e6ac4bf..eb00c9a840 100644
--- a/docs/security/networking.mdx
+++ b/docs/security/networking.mdx
@@ -16,6 +16,18 @@ Resolve the egress IP via DNS:
dig egress.prowler.com +short
```
+
+The egress IP address is stable, but it is recommended to periodically verify it remains current by querying `egress.prowler.com`.
+
+
+## Use Cases
+
+Allowlisting Prowler Cloud's egress IP address enables:
+
+- **Credential Usage Control**: Restrict where cloud provider credentials can be used from across AWS, Azure, GCP, and other providers
+- **Kubernetes Security**: Limit inbound HTTPS traffic to clusters by allowing only Prowler Cloud's IP address
+- **Compliance Requirements**: Meet security policies requiring allowlisting of external services
+
## Contact
For questions about networking, visit the [Support page](/support).
diff --git a/docs/security/software-security.mdx b/docs/security/software-security.mdx
index eb3afc606f..64c2645d3f 100644
--- a/docs/security/software-security.mdx
+++ b/docs/security/software-security.mdx
@@ -101,6 +101,28 @@ Dependencies are scanned against public vulnerability databases on every pull re
- Per-vulnerability ignores live in [`osv-scanner.toml`](https://github.com/prowler-cloud/prowler/blob/master/osv-scanner.toml) at the repo root, each with a reason and an expiry date.
- **Trivy:** scans container images for OS-package and application-dependency vulnerabilities. Runs in [`sdk-container-checks.yml`](https://github.com/prowler-cloud/prowler/blob/master/.github/workflows/sdk-container-checks.yml), [`api-container-checks.yml`](https://github.com/prowler-cloud/prowler/blob/master/.github/workflows/api-container-checks.yml), [`ui-container-checks.yml`](https://github.com/prowler-cloud/prowler/blob/master/.github/workflows/ui-container-checks.yml), and [`mcp-container-checks.yml`](https://github.com/prowler-cloud/prowler/blob/master/.github/workflows/mcp-container-checks.yml). Trivy uploads SARIF to the GitHub Security tab and posts a scan summary on the PR.
- **Dependabot:** [configured](https://github.com/prowler-cloud/prowler/blob/master/.github/dependabot.yml) for monthly updates of the SDK Python dependencies, GitHub Actions, Docker base images, and pre-commit hooks. Dependabot opens pull requests for known security advisories, so critical patches reach the team without delay. A 7-day default cooldown reduces exposure to compromised package releases.
+- **Renovate:** [configured](https://github.com/prowler-cloud/prowler/blob/master/.github/renovate.json) dependency update automation is transitioning from Dependabot to **Renovate** to gain finer control over update cadence, grouping, and per-component scope. Both tools currently run in parallel during the migration.
+
+#### Renovate (Primary)
+
+Configuration: [`.github/renovate.json`](https://github.com/prowler-cloud/prowler/blob/master/.github/renovate.json)
+
+- **Coverage:** Python (SDK, API, MCP Server), npm (UI), GitHub Actions, Docker images, and Pre-commit hooks
+- **Range Strategy:** Versions are pinned to ensure reproducible builds
+- **Vulnerability Alerts:** GitHub Security Advisories generate immediate pull requests that bypass rate limits and scheduled windows, labeled `security` for prioritized triage
+
+#### Dependabot (Legacy)
+
+Configuration: [`.github/dependabot.yml`](https://github.com/prowler-cloud/prowler/blob/master/.github/dependabot.yml)
+
+Dependabot remains active for the SDK and shared automation ecosystems until the Renovate migration completes:
+
+- **Python (pip):** Monthly updates for SDK
+- **GitHub Actions:** Monthly updates for workflow dependencies
+- **Docker:** Monthly updates for base images
+- **Pre-commit:** Monthly updates for hook revisions
+
+Dependabot is paused for the API and UI; Renovate now handles those components. Even when paused, Dependabot continues to open pull requests for security vulnerabilities, ensuring critical patches are never delayed.
### JavaScript/TypeScript (UI)
@@ -136,7 +158,7 @@ The MCP Server has a small direct-dependency surface and does not yet declare a
The UI uses [pnpm](https://pnpm.io) with supply-chain controls configured in [`ui/pnpm-workspace.yaml`](https://github.com/prowler-cloud/prowler/blob/master/ui/pnpm-workspace.yaml).
- **Minimum release age** (`minimumReleaseAge: 1440`): packages must publish at least 24 hours before install. This reduces exposure during the window when a compromised release has not yet been detected and yanked.
-- **Lifecycle script allow-list** (`strictDepBuilds: true` + `allowBuilds`): only explicitly approved packages may run `install` or `postinstall` scripts (currently `sharp`, `esbuild`, `@sentry/cli`, `@heroui/shared-utils`, `unrs-resolver`, `msw`). Any unlisted package with lifecycle scripts fails the install.
+- **Lifecycle script allow-list** (`strictDepBuilds: true` + `allowBuilds`): only explicitly approved packages may run `install` or `postinstall` scripts (currently `sharp`, `esbuild`, `@sentry/cli`, `unrs-resolver`, `msw`). Any unlisted package with lifecycle scripts fails the install.
- **Trust policy** (`trustPolicy: no-downgrade`): the install fails when a package's trust evidence drops, for example after a new publisher takes over.
- **Block exotic subdeps** (`blockExoticSubdeps: true`): transitive dependencies cannot ship as git URLs or tarballs. Every package in the tree resolves from the configured registry.
- **Transitive overrides** in [`ui/package.json`](https://github.com/prowler-cloud/prowler/blob/master/ui/package.json) force specific versions for transitive packages (`lodash`, `serialize-javascript`, `qs`, `rollup`, `minimatch`, `ajv`, and others).
diff --git a/docs/snippets/applies-to.mdx b/docs/snippets/applies-to.mdx
new file mode 100644
index 0000000000..0ce22af78d
--- /dev/null
+++ b/docs/snippets/applies-to.mdx
@@ -0,0 +1,14 @@
+export const AppliesTo = ({ products = ["Prowler Cloud", "Prowler Private Cloud", "Prowler Local Server"] }) => {
+ return (
+
+ This guide applies to{" "}
+ {products.map((name, index) => (
+
+ {index > 0 && (index === products.length - 1 ? (products.length > 2 ? ", and " : " and ") : ", ")}
+ {name}
+
+ ))}
+ . See Prowler product families.
+
+ );
+};
diff --git a/docs/snippets/provider-cards.mdx b/docs/snippets/provider-cards.mdx
new file mode 100644
index 0000000000..f469616cde
--- /dev/null
+++ b/docs/snippets/provider-cards.mdx
@@ -0,0 +1,23 @@
+{/* AUTO-GENERATED by docs/scripts/generate_provider_cards.py — do not edit by hand. */}
+{/* Regenerated on pre-commit whenever any provider getting-started page changes. */}
+
+export const ProviderCards = () => (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+);
diff --git a/docs/snippets/subscription-banner.mdx b/docs/snippets/subscription-banner.mdx
new file mode 100644
index 0000000000..90d9b651b5
--- /dev/null
+++ b/docs/snippets/subscription-banner.mdx
@@ -0,0 +1,8 @@
+export const SubscriptionBanner = ({ children, label = "feature" }) => {
+ return (
+
+ This {label} is available exclusively in Prowler Cloud and Prowler Private Cloud with a subscription.
+ {children}
+
+ );
+};
diff --git a/docs/style.css b/docs/style.css
index 5c626fb06b..9a1ed19a0f 100644
--- a/docs/style.css
+++ b/docs/style.css
@@ -66,3 +66,59 @@
color: #000000;
border: none;
}
+
+/* Cloud marker: subscription-gated sections and pages (Prowler Cloud / Prowler Private Cloud).
+ Rendered as a trailing ::after glyph so sidebar labels stay left-aligned.
+ Nested groups render as li[data-title] with a button toggle; top-level groups
+ render as h3 headings. Every ungated group that shares a name with a gated one
+ (Providers, Prowler Cloud, Prowler Lighthouse AI) is top-level,
+ so plain li[data-title] selectors match only the gated nested groups, folded
+ or unfolded. The Security tab group is top-level (h3) and always expanded,
+ so it is selected through its sibling list content with :has().
+ Pages are selected by their li id, which equals the page URL. The strict
+ > div > div > span:first-child path targets the title span only, never the
+ nested spans of a tag pill such as Coming Soon.
+ Icon source: /images/icons/cloud-bold.svg. See AGENTS.md "Cloud Marker" convention. */
+li[data-title="Prowler Cloud"] > button span:first-child::after,
+li[data-title="Prowler Lighthouse AI"] > button span:first-child::after,
+li[data-title="Providers"] > button span:first-child::after,
+li[data-title="Scans"] > button span:first-child::after,
+li[data-title="Prowler MCP"] > button span:first-child::after,
+div:has(+ ul a[href="/security/encryption"]) h3 span::after,
+li[id="/user-guide/compliance/tutorials/cross-provider-compliance"] a > div > div > span:first-child::after,
+li[id="/user-guide/tutorials/prowler-alerts"] a > div > div > span:first-child::after,
+li[id="/user-guide/tutorials/prowler-app-findings-triage"] a > div > div > span:first-child::after,
+li[id="/user-guide/tutorials/prowler-app-scan-configuration"] a > div > div > span:first-child::after,
+li[id="/user-guide/tutorials/prowler-cloud-aws-organizations"] a > div > div > span:first-child::after,
+li[id="/user-guide/tutorials/prowler-cloud-azure-management-groups"] a > div > div > span:first-child::after,
+li[id="/user-guide/tutorials/prowler-cloud-gcp-organizations"] a > div > div > span:first-child::after,
+li[id="/user-guide/tutorials/prowler-cloud-lighthouse-multi-llm"] a > div > div > span:first-child::after,
+li[id="/user-guide/tutorials/prowler-import-findings"] a > div > div > span:first-child::after,
+li[id="/user-guide/tutorials/prowler-scan-scheduling"] a > div > div > span:first-child::after {
+ content: "";
+ display: inline-block;
+ width: 0.875rem;
+ height: 0.875rem;
+ margin-left: 0.375rem;
+ vertical-align: -0.125rem;
+ background: url("/images/icons/cloud-bold.svg") no-repeat center / contain;
+}
+/* Wider sidebar: +2rem over the theme default (18rem) so gated labels with the
+ cloud marker fit on one line. The content column offsets are coupled to the
+ sidebar width and must shift by the same amount, hence the two companion
+ overrides selected by their Tailwind arbitrary-value class substrings. */
+@media (min-width: 1024px) {
+ #sidebar {
+ width: 20rem !important;
+ }
+
+ div[class*="pl-[23.7rem]"] {
+ padding-left: 25.7rem !important;
+ }
+}
+
+@media (min-width: 1280px) {
+ div[class*="(100%-28rem)"] {
+ width: calc(100% - 30rem) !important;
+ }
+}
diff --git a/docs/support.mdx b/docs/support.mdx
index 6999d5efbf..07c9843e12 100644
--- a/docs/support.mdx
+++ b/docs/support.mdx
@@ -3,23 +3,25 @@ title: 'Support'
description: 'Get help with Prowler'
---
-## Lighthouse AI
+import { SubscriptionBanner } from "/snippets/subscription-banner.mdx"
-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:
+## Prowler Lighthouse AI
+
+Prowler Lighthouse AI is your 24/7 cloud security analyst chatbot, the agentic cloud defender. Powered by [Prowler MCP](/getting-started/products/prowler-mcp), 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
+- **Search Prowler Hub**: Over 2,000 security checks and 70+ compliance frameworks
- **Access documentation**: Search and retrieve Prowler docs contextually
-Available in Prowler Cloud and Prowler App.
+Available in Prowler Cloud, Prowler Private Cloud, and Prowler Local Server.
-[Learn more about Lighthouse AI](/getting-started/products/prowler-lighthouse-ai)
+[Learn more about Prowler Lighthouse AI](/getting-started/products/prowler-cloud-lighthouse)
## 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.
+Submit support requests through our support desk. We'll route your request to the right team and respond via email.
Contact our support team
diff --git a/docs/troubleshooting.mdx b/docs/troubleshooting.mdx
index 9d60c57321..9e0c849796 100644
--- a/docs/troubleshooting.mdx
+++ b/docs/troubleshooting.mdx
@@ -2,7 +2,11 @@
title: 'Troubleshooting'
---
-## Running `prowler` I get `[File: utils.py:15] [Module: utils] CRITICAL: path/redacted: OSError[13]`
+import { VersionBadge } from "/snippets/version-badge.mdx"
+
+## Prowler CLI
+
+### Running `prowler` I get `[File: utils.py:15] [Module: utils] CRITICAL: path/redacted: OSError[13]`
That is an error related to file descriptors or opened files allowed by your operating system.
@@ -14,13 +18,15 @@ This error is also related with a lack of system requirements. To improve perfor
See section [Logging](/user-guide/cli/tutorials/logging) for further information or [contact us](/contact).
-## Common Issues with Docker Compose Installation
+## Prowler Local Server
+
+Common issues with the Docker Compose installation of Prowler Local Server.
### Problem adding AWS Provider using "Connect assuming IAM Role" in Docker
See [GitHub Issue #7745](https://github.com/prowler-cloud/prowler/issues/7745) for more details.
-When running Prowler App via Docker, you may encounter errors such as `Provider not set`, `AWS assume role error - Unable to locate credentials`, or `Provider has no secret` when trying to add an AWS Provider using the "Connect assuming IAM Role" option. This typically happens because the container does not have access to the necessary AWS credentials or profiles.
+When running Prowler Local Server via Docker, you may encounter errors such as `Provider not set`, `AWS assume role error - Unable to locate credentials`, or `Provider has no secret` when trying to add an AWS Provider using the "Connect assuming IAM Role" option. This typically happens because the container does not have access to the necessary AWS credentials or profiles.
**Workaround:**
@@ -51,7 +57,7 @@ AWS_PROFILE=prowler-profile
### Scans Complete but Reports Are Missing or Compliance Data Is Empty (`Too many open files` Error)
-When running Prowler App via Docker Compose, scans may complete successfully but reports are not available for download, compliance data shows as empty, or 404 errors appear when trying to access scan reports. Checking the `worker` container logs may reveal errors like `[Errno 24] Too many open files`.
+When running Prowler Local Server via Docker Compose, scans may complete successfully but reports are not available for download, compliance data shows as empty, or 404 errors appear when trying to access scan reports. Checking the `worker` container logs may reveal errors like `[Errno 24] Too many open files`.
This issue occurs because the default file descriptor limits in Docker containers are too low for Prowler's operations. The default `docker-compose.yml` already includes `ulimits` configuration with `nofile` set to `65536` for the `worker` and `worker-beat` services to prevent this issue.
@@ -81,6 +87,39 @@ docker compose down
docker compose up -d
```
+### Worker Uses Too Much Memory on Hosts with Many CPUs
+
+
+
+When Prowler Local Server runs on a machine or Kubernetes node with many CPUs,
+the Celery worker may create one prefork process per detected CPU if concurrency
+is not configured explicitly. Each process loads the SDK runtime and cloud
+provider clients, so idle memory can be high and worker containers can be
+terminated by their memory limit.
+
+Set `DJANGO_CELERY_WORKER_CONCURRENCY` in the worker runtime environment to cap
+the number of prefork processes:
+
+```yaml
+services:
+ worker:
+ environment:
+ DJANGO_CELERY_WORKER_CONCURRENCY: "4"
+```
+
+For Kubernetes deployments, set the same variable on the worker Deployment:
+
+```yaml
+env:
+ - name: DJANGO_CELERY_WORKER_CONCURRENCY
+ value: "4"
+```
+
+Lower values reduce idle memory and the number of tasks a worker can run in
+parallel. Increase the value only when the worker has enough memory for the
+expected scan workload. Leaving the variable unset preserves Celery's default
+CPU-based concurrency.
+
### API Container Fails to Start with JWT Key Permission Error
See [GitHub Issue #8897](https://github.com/prowler-cloud/prowler/issues/8897) for more details.
@@ -161,7 +200,7 @@ A fix addressing this permission issue is being evaluated in [PR #9953](https://
### Scan Stuck in Executing State After Worker Crash
-When running Prowler App via Docker Compose, a scan may remain indefinitely in the `executing` state if the worker process crashes (for example, due to an Out of Memory condition) before it can update the scan status. Since it is not currently possible to cancel a scan in `executing` state through the UI, the workaround is to manually update the scan record in the database.
+When running Prowler Local Server via Docker Compose, a scan may remain indefinitely in the `executing` state if the worker process crashes (for example, due to an Out of Memory condition) before it can update the scan status. Since it is not currently possible to cancel a scan in `executing` state through the UI, the workaround is to manually update the scan record in the database.
**Root Cause:**
@@ -201,35 +240,29 @@ When running Prowler behind a reverse proxy (nginx, Traefik, etc.) or load balan
**Root Cause:**
-Next.js environment variables prefixed with `NEXT_PUBLIC_` are **bundled at build time**, not runtime. The pre-built Docker images from Docker Hub (`prowlercloud/prowler-ui:stable`) are built with default internal URLs. Simply setting `NEXT_PUBLIC_API_BASE_URL` in your `.env` file or environment variables and restarting the container will **NOT** work because these values are already compiled into the JavaScript bundle.
+The API base and docs URLs are resolved from the container environment **at runtime**. A single pre-built Docker image (`prowlercloud/prowler-ui:stable`) therefore serves any environment: point the URLs at your external domain and restart the container — no rebuild is required.
**Solution:**
-You must **rebuild** the UI Docker image with your external URL:
-
-```bash
-# Clone the repository (if you haven't already)
-git clone https://github.com/prowler-cloud/prowler.git
-cd prowler/ui
-
-# Build with your external URL as a build argument
-docker build \
- --build-arg NEXT_PUBLIC_API_BASE_URL=https://prowler.example.com/api/v1 \
- --build-arg NEXT_PUBLIC_API_DOCS_URL=https://prowler.example.com/api/v1/docs \
- -t prowler-ui-custom:latest \
- --target prod \
- .
-```
-
-Then update your `docker-compose.yml` to use your custom image instead of the pre-built one:
+Set the runtime environment variables to your external URL and restart the UI container:
```yaml
services:
ui:
- image: prowler-ui-custom:latest # Use your custom-built image
+ image: prowlercloud/prowler-ui:stable
+ environment:
+ UI_API_BASE_URL: https://prowler.example.com/api/v1
+ UI_API_DOCS_URL: https://prowler.example.com/api/v1/docs
# ... rest of configuration
```
+The same values can be supplied through your `.env` file:
+
+```bash
+UI_API_BASE_URL=https://prowler.example.com/api/v1
+UI_API_DOCS_URL=https://prowler.example.com/api/v1/docs
+```
+
-The `NEXT_PUBLIC_` prefix is a Next.js convention that exposes environment variables to the browser. Since the browser bundle is compiled during `docker build`, these variables must be provided as build arguments, not runtime environment variables.
+Earlier releases inlined these values into the JavaScript bundle at build time (via the `NEXT_PUBLIC_` prefix) and required a rebuild with `--build-arg`. That is no longer necessary: `UI_API_BASE_URL` and `UI_API_DOCS_URL` are read at container start, so updating them and restarting is sufficient.
diff --git a/docs/user-guide/cli/tutorials/configuration_file.mdx b/docs/user-guide/cli/tutorials/configuration_file.mdx
index 38dad9ac3e..e637ea9c9a 100644
--- a/docs/user-guide/cli/tutorials/configuration_file.mdx
+++ b/docs/user-guide/cli/tutorials/configuration_file.mdx
@@ -2,6 +2,8 @@
title: "Configuration File"
---
+import { VersionBadge } from "/snippets/version-badge.mdx"
+
Several Prowler's checks have user configurable variables that can be modified in a common **configuration file**. This file can be found in the following [path](https://github.com/prowler-cloud/prowler/blob/master/prowler/config/config.yaml):
```
@@ -10,71 +12,191 @@ prowler/config/config.yaml
Additionally, you can input a custom configuration file using the `--config-file` argument.
+
+Numeric thresholds enforce hard limits. A value outside the accepted range is dropped with a warning and the check falls back to its built-in default. See [Configuration Value Limits](/developer-guide/configurable-checks#configuration-value-limits) for the exact range of every bounded option (max-days caps, percentages, counts, etc.).
+
+
## AWS
### Configurable Checks
+
The following list includes all the AWS checks with configurable variables that can be changed in the configuration yaml file:
-| Check Name | Value | Type |
-|---------------------------------------------------------------|--------------------------------------------------|-----------------|
-| `acm_certificates_expiration_check` | `days_to_expire_threshold` | Integer |
-| `appstream_fleet_maximum_session_duration` | `max_session_duration_seconds` | Integer |
-| `appstream_fleet_session_disconnect_timeout` | `max_disconnect_timeout_in_seconds` | Integer |
-| `appstream_fleet_session_idle_disconnect_timeout` | `max_idle_disconnect_timeout_in_seconds` | Integer |
-| `autoscaling_find_secrets_ec2_launch_configuration` | `secrets_ignore_patterns` | List of Strings |
-| `awslambda_function_no_secrets_in_code` | `secrets_ignore_patterns` | List of Strings |
-| `awslambda_function_no_secrets_in_variables` | `secrets_ignore_patterns` | List of Strings |
-| `awslambda_function_using_supported_runtimes` | `obsolete_lambda_runtimes` | Integer |
-| `awslambda_function_vpc_is_in_multi_azs` | `lambda_min_azs` | Integer |
-| `cloudformation_stack_outputs_find_secrets` | `secrets_ignore_patterns` | List of Strings |
-| `cloudtrail_threat_detection_enumeration` | `threat_detection_enumeration_actions` | List of Strings |
-| `cloudtrail_threat_detection_enumeration` | `threat_detection_enumeration_entropy` | Integer |
-| `cloudtrail_threat_detection_enumeration` | `threat_detection_enumeration_minutes` | Integer |
-| `cloudtrail_threat_detection_privilege_escalation` | `threat_detection_privilege_escalation_actions` | List of Strings |
-| `cloudtrail_threat_detection_privilege_escalation` | `threat_detection_privilege_escalation_entropy` | Integer |
-| `cloudtrail_threat_detection_privilege_escalation` | `threat_detection_privilege_escalation_minutes` | Integer |
-| `cloudwatch_log_group_no_secrets_in_logs` | `secrets_ignore_patterns` | List of Strings |
-| `cloudwatch_log_group_retention_policy_specific_days_enabled` | `log_group_retention_days` | Integer |
-| `codebuild_github_allowed_organizations` | `github_allowed_organizations` | List of Strings |
-| `codebuild_project_no_secrets_in_variables` | `excluded_sensitive_environment_variables` | List of Strings |
-| `codebuild_project_no_secrets_in_variables` | `secrets_ignore_patterns` | List of Strings |
-| `config_recorder_all_regions_enabled` | `mute_non_default_regions` | Boolean |
-| `drs_job_exist` | `mute_non_default_regions` | Boolean |
-| `ec2_elastic_ip_shodan` | `shodan_api_key` | String |
-| `ec2_instance_older_than_specific_days` | `max_ec2_instance_age_in_days` | Integer |
-| `ec2_instance_secrets_user_data` | `secrets_ignore_patterns` | List of Strings |
-| `ec2_launch_template_no_secrets` | `secrets_ignore_patterns` | List of Strings |
-| `ec2_securitygroup_allow_ingress_from_internet_to_any_port` | `ec2_allowed_instance_owners` | List of Strings |
-| `ec2_securitygroup_allow_ingress_from_internet_to_any_port` | `ec2_allowed_interface_types` | List of Strings |
-| `ec2_securitygroup_allow_ingress_from_internet_to_high_risk_tcp_ports`| `ec2_high_risk_ports` | List of Integer |
-| `ec2_securitygroup_with_many_ingress_egress_rules` | `max_security_group_rules` | Integer |
-| `ecs_task_definitions_no_environment_secrets` | `secrets_ignore_patterns` | List of Strings |
-| `ecr_repositories_scan_vulnerabilities_in_latest_image` | `ecr_repository_vulnerability_minimum_severity` | String |
-| `eks_cluster_uses_a_supported_version` | `eks_cluster_oldest_version_supported` | String |
-| `eks_control_plane_logging_all_types_enabled` | `eks_required_log_types` | List of Strings |
-| `elasticache_redis_cluster_backup_enabled` | `minimum_snapshot_retention_period` | Integer |
-| `elb_is_in_multiple_az` | `elb_min_azs` | Integer |
-| `elbv2_is_in_multiple_az` | `elbv2_min_azs` | Integer |
-| `guardduty_is_enabled` | `mute_non_default_regions` | Boolean |
-| `iam_user_access_not_stale_to_sagemaker` | `max_unused_sagemaker_access_days` | Integer |
-| `iam_user_accesskey_unused` | `max_unused_access_keys_days` | Integer |
-| `iam_user_console_access_unused` | `max_console_access_days` | Integer |
-| `organizations_delegated_administrators` | `organizations_trusted_delegated_administrators` | List of Strings |
-| `organizations_scp_check_deny_regions` | `organizations_enabled_regions` | List of Strings |
-| `rds_instance_backup_enabled` | `check_rds_instance_replicas` | Boolean |
-| `securityhub_enabled` | `mute_non_default_regions` | Boolean |
-| `secretsmanager_secret_unused` | `max_days_secret_unused` | Integer |
-| `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 |
-| `opensearch_service_domains_not_publicly_accessible` | `trusted_ips` | List of Strings |
+| Check Name | Value | Type | Default |
+|------------------------------------------------------------------------|---------------------------------------------------|-----------------|--------------------------------------------------------------------------------------------------------------------------------------|
+| `accessanalyzer_enabled` | `mute_non_default_regions` | Boolean | `False` |
+| `acm_certificates_expiration_check` | `days_to_expire_threshold` | Integer | `7` |
+| `acm_certificates_with_secure_key_algorithms` | `insecure_key_algorithms` | List of Strings | `["RSA-1024", "P-192"]` |
+| `acmpca_certificate_authority_pqc_key_algorithm` | `acmpca_pqc_key_algorithms` | List of Strings | `["ML_DSA_44", "ML_DSA_65", "ML_DSA_87"]` |
+| `apigateway_domain_name_pqc_tls_enabled` | `apigateway_pqc_tls_allowed_policies` | List of Strings | `["SecurityPolicy_TLS13_1_2_FIPS_PFS_PQ_2025_09", "SecurityPolicy_TLS13_1_2_PFS_PQ_2025_09", "SecurityPolicy_TLS13_1_2_PQ_2025_09"]` |
+| `apigateway_restapi_no_secrets_in_stage_variables` | `secrets_ignore_patterns` | List of Strings | `[]` |
+| `appstream_fleet_maximum_session_duration` | `max_session_duration_seconds` | Integer | `36000` |
+| `appstream_fleet_session_disconnect_timeout` | `max_disconnect_timeout_in_seconds` | Integer | `300` |
+| `appstream_fleet_session_idle_disconnect_timeout` | `max_idle_disconnect_timeout_in_seconds` | Integer | `600` |
+| `autoscaling_find_secrets_ec2_launch_configuration` | `secrets_ignore_patterns` | List of Strings | `[]` |
+| `awslambda_function_no_secrets_in_code` | `secrets_ignore_files` | List of Strings | `[]` |
+| `awslambda_function_no_secrets_in_code` | `secrets_ignore_patterns` | List of Strings | `[]` |
+| `awslambda_function_no_secrets_in_variables` | `secrets_ignore_patterns` | List of Strings | `[]` |
+| `awslambda_function_using_supported_runtimes` | `obsolete_lambda_runtimes` | List of Strings | See `config.yaml` |
+| `awslambda_function_vpc_multi_az` | `lambda_min_azs` | Integer | `2` |
+| `cloudformation_stack_cdktoolkit_bootstrap_version` | `recommended_cdk_bootstrap_version` | Integer | `21` |
+| `cloudformation_stack_outputs_find_secrets` | `secrets_ignore_patterns` | List of Strings | `[]` |
+| `cloudfront_distributions_pqc_tls_enabled` | `cloudfront_pqc_min_protocol_versions` | List of Strings | `["TLSv1.3_2025"]` |
+| `cloudtrail_threat_detection_enumeration` | `threat_detection_enumeration_actions` | List of Strings | See `config.yaml` |
+| `cloudtrail_threat_detection_enumeration` | `threat_detection_enumeration_minutes` | Integer | `1440` |
+| `cloudtrail_threat_detection_enumeration` | `threat_detection_enumeration_threshold` | Float | `0.3` |
+| `cloudtrail_threat_detection_llm_jacking` | `threat_detection_llm_jacking_actions` | List of Strings | See `config.yaml` |
+| `cloudtrail_threat_detection_llm_jacking` | `threat_detection_llm_jacking_minutes` | Integer | `1440` |
+| `cloudtrail_threat_detection_llm_jacking` | `threat_detection_llm_jacking_threshold` | Float | `0.4` |
+| `cloudtrail_threat_detection_privilege_escalation` | `threat_detection_privilege_escalation_actions` | List of Strings | See `config.yaml` |
+| `cloudtrail_threat_detection_privilege_escalation` | `threat_detection_privilege_escalation_minutes` | Integer | `1440` |
+| `cloudtrail_threat_detection_privilege_escalation` | `threat_detection_privilege_escalation_threshold` | Float | `0.2` |
+| `cloudwatch_log_group_no_secrets_in_logs` | `secrets_ignore_patterns` | List of Strings | `[]` |
+| `cloudwatch_log_group_retention_policy_specific_days_enabled` | `log_group_retention_days` | Integer | `365` |
+| `codebuild_project_no_secrets_in_variables` | `excluded_sensitive_environment_variables` | List of Strings | `[]` |
+| `codebuild_project_no_secrets_in_variables` | `secrets_ignore_patterns` | List of Strings | `[]` |
+| `codebuild_project_uses_allowed_github_organizations` | `codebuild_github_allowed_organizations` | List of Strings | `[]` |
+| `config_delegated_admin_and_org_aggregator_all_regions` | `mute_non_default_regions` | Boolean | `False` |
+| `config_recorder_all_regions_enabled` | `mute_non_default_regions` | Boolean | `False` |
+| `documentdb_cluster_backup_enabled` | `minimum_backup_retention_period` | Integer | `7` |
+| `drs_job_exist` | `mute_non_default_regions` | Boolean | `False` |
+| `dynamodb_table_cross_account_access` | `trusted_account_ids` | List of Strings | `[]` |
+| `ec2_elastic_ip_shodan` | `shodan_api_key` | String | `null` |
+| `ec2_instance_older_than_specific_days` | `max_ec2_instance_age_in_days` | Integer | `180` |
+| `ec2_instance_secrets_user_data` | `secrets_ignore_patterns` | List of Strings | `[]` |
+| `ec2_launch_template_no_secrets` | `secrets_ignore_patterns` | List of Strings | `[]` |
+| `ec2_securitygroup_allow_ingress_from_internet_to_any_port` | `ec2_allowed_instance_owners` | List of Strings | `["amazon-elb"]` |
+| `ec2_securitygroup_allow_ingress_from_internet_to_any_port` | `ec2_allowed_interface_types` | List of Strings | `["api_gateway_managed", "vpc_endpoint"]` |
+| `ec2_securitygroup_allow_ingress_from_internet_to_high_risk_tcp_ports` | `ec2_high_risk_ports` | List of Integer | `[25, 110, 135, 143, 445, 3000, 4333, 5000, 5500, 8080, 8088]` |
+| `ec2_securitygroup_with_many_ingress_egress_rules` | `max_security_group_rules` | Integer | `50` |
+| `ecr_repositories_scan_vulnerabilities_in_latest_image` | `ecr_repository_vulnerability_minimum_severity` | String | `"MEDIUM"` |
+| `ecs_service_fargate_latest_platform_version` | `fargate_linux_latest_version` | String | `"1.4.0"` |
+| `ecs_service_fargate_latest_platform_version` | `fargate_windows_latest_version` | String | `"1.0.0"` |
+| `ecs_task_definitions_no_environment_secrets` | `secrets_ignore_patterns` | List of Strings | `[]` |
+| `eks_cluster_uses_a_supported_version` | `eks_cluster_oldest_version_supported` | String | `"1.28"` |
+| `eks_control_plane_logging_all_types_enabled` | `eks_required_log_types` | List of Strings | `["api", "audit", "authenticator", "controllerManager", "scheduler"]` |
+| `elasticache_redis_cluster_backup_enabled` | `minimum_snapshot_retention_period` | Integer | `7` |
+| `elb_is_in_multiple_az` | `elb_min_azs` | Integer | `2` |
+| `elbv2_is_in_multiple_az` | `elbv2_min_azs` | Integer | `2` |
+| `elbv2_listener_pqc_tls_enabled` | `elbv2_listener_pqc_tls_allowed_policies` | List of Strings | See `config.yaml` |
+| `eventbridge_bus_cross_account_access` | `trusted_account_ids` | List of Strings | `[]` |
+| `eventbridge_schema_registry_cross_account_access` | `trusted_account_ids` | List of Strings | `[]` |
+| `glue_etl_jobs_no_secrets_in_arguments` | `secrets_ignore_patterns` | List of Strings | `[]` |
+| `guardduty_delegated_admin_enabled_all_regions` | `mute_non_default_regions` | Boolean | `False` |
+| `guardduty_is_enabled` | `mute_non_default_regions` | Boolean | `False` |
+| `iam_role_access_not_stale_to_bedrock` | `max_unused_bedrock_access_days` | Integer | `60` |
+| `iam_user_access_not_stale_to_bedrock` | `max_unused_bedrock_access_days` | Integer | `60` |
+| `iam_user_access_not_stale_to_sagemaker` | `max_unused_sagemaker_access_days` | Integer | `90` |
+| `iam_user_accesskey_unused` | `max_unused_access_keys_days` | Integer | `45` |
+| `iam_user_console_access_unused` | `max_console_access_days` | Integer | `45` |
+| `kinesis_stream_data_retention_period` | `min_kinesis_stream_retention_hours` | Integer | `168` |
+| `neptune_cluster_backup_enabled` | `minimum_backup_retention_period` | Integer | `7` |
+| `opensearch_service_domains_not_publicly_accessible` | `trusted_ips` | List of Strings | `[]` |
+| `organizations_delegated_administrators` | `organizations_trusted_delegated_administrators` | List of Strings | `[]` |
+| `organizations_scp_check_deny_regions` | `organizations_enabled_regions` | List of Strings | `[]` |
+| `rds_instance_backup_enabled` | `check_rds_instance_replicas` | Boolean | `False` |
+| `rolesanywhere_trust_anchor_pqc_pki` | `rolesanywhere_pqc_pca_key_algorithms` | List of Strings | `["ML_DSA_44", "ML_DSA_65", "ML_DSA_87"]` |
+| `s3_bucket_cross_account_access` | `trusted_account_ids` | List of Strings | `[]` |
+| `s3_bucket_object_public` | `s3_bucket_object_public_enabled` | Boolean | `False` |
+| `s3_bucket_object_public` | `s3_bucket_object_public_max_objects` | Integer | `100` |
+| `s3_bucket_object_public` | `s3_bucket_object_public_sample_size` | Integer | `3` |
+| `secretsmanager_has_restrictive_resource_policy` | `organizations_trusted_ids` | List of Strings | `[]` |
+| `secretsmanager_secret_rotated_periodically` | `max_days_secret_unrotated` | Integer | `90` |
+| `secretsmanager_secret_unused` | `max_days_secret_unused` | Integer | `90` |
+| `securityhub_delegated_admin_enabled_all_regions` | `mute_non_default_regions` | Boolean | `False` |
+| `securityhub_enabled` | `mute_non_default_regions` | Boolean | `False` |
+| `ssm_document_secrets` | `secrets_ignore_patterns` | List of Strings | `[]` |
+| `ssm_documents_set_as_public` | `trusted_account_ids` | List of Strings | `[]` |
+| `stepfunctions_statemachine_no_secrets_in_definition` | `secrets_ignore_patterns` | List of Strings | `[]` |
+| `transfer_server_pqc_ssh_kex_enabled` | `transfer_pqc_ssh_allowed_policies` | List of Strings | `["TransferSecurityPolicy-2025-03", "TransferSecurityPolicy-FIPS-2025-03", "TransferSecurityPolicy-AS2Restricted-2025-07"]` |
+| `trustedadvisor_premium_support_plan_subscribed` | `verify_premium_support_plans` | Boolean | `True` |
+| `vpc_endpoint_connections_trust_boundaries` | `trusted_account_ids` | List of Strings | `[]` |
+| `vpc_endpoint_services_allowed_principals_trust_boundaries` | `trusted_account_ids` | List of Strings | `[]` |
+
+### Resource Scan Limit
+
+
+
+Some AWS services accumulate large numbers of resources (EBS snapshots, backup recovery points, CloudWatch log groups, Lambda functions, ECS task definitions, and CodeArtifact packages). Scanning every resource increases scan time, cost, API throttling, and finding volume. By default, Prowler scans every resource. Configure a positive resource scan limit to cap how many resources Prowler analyzes for these high-volume AWS resource paths.
+
+The global default applies to the supported resources below and is overridable per resource. The default global value is `0`, which disables the limit and scans every resource. A global `null` value is also unlimited. For per-resource values, `null` means inherit the global default; set `0` or a negative value to disable that resource limit explicitly. Positive values enable limits.
+
+
+When positive resource scan limits are configured, compliance results are based only on the selected resources, not on the full set of matching resources in the account. Treat compliance summaries and percentages as partial evidence, because unselected resources are not analyzed and can change the real compliance posture.
+
+
+#### Global Behavior
+
+Resource scan limits select resources for analysis. They do not cap, prioritize, or reorder findings.
+
+* **`0`, negative, or global `null` values:** Disable the limit and keep the legacy behavior for that resource path. Prowler analyzes every discovered matching resource.
+* **Positive values:** Select at most that many resources for the affected resource path. A selected resource can produce zero, one, or many findings.
+* **No PASS/FAIL prioritization:** Prowler does not inspect the compliance result before selecting resources. Limits do not prefer failed resources, passed resources, or resources with more findings.
+* **Latest-first where possible:** When AWS exposes timestamps or useful ordering, Prowler selects the newest resources first. When AWS only exposes API order, Prowler preserves that API order and documents the behavior as best effort.
+* **Findings are downstream:** Checks only evaluate the resources exposed by the service client after selection. Findings from unselected resources are not produced because those resources are not analyzed.
+
+Exact list API call reduction depends on each AWS API's ordering and pagination capabilities. When Prowler must enumerate candidates locally to select the latest resources, list calls may still read candidates, but expensive per-resource enrichment calls are bounded to the selected resources for the supported paths below.
+
+#### Full Collections Versus Limited Analysis Sets
+
+Some checks need lightweight evidence from a complete resource collection to avoid incorrect cross-service conclusions, while other checks perform primary analysis on a limited resource set.
+
+Prowler keeps full lightweight collections where they are needed for cross-service evidence. For example:
+
+* **Lambda security groups and regions:** Prowler records security groups used by all discovered Lambda functions and the regions where functions exist before it limits Lambda functions for primary Lambda checks. This helps Amazon EC2 and Amazon Inspector checks avoid false positives such as treating Lambda security groups as unused or assuming a region has no Lambda functions.
+* **CloudWatch `all_log_groups`:** Prowler records all discovered CloudWatch log groups in `all_log_groups` before limiting the primary `log_groups` analysis set. Other services can still resolve log group evidence, while CloudWatch log group checks only analyze the selected log groups.
+
+This split is intentional. It reduces expensive per-resource analysis calls without discarding lightweight context that other services need for accurate results.
+
+#### Supported AWS Resource Limits
+
+| Value | Scope | Type |
+|-------|-------|------|
+| `max_scanned_resources_per_service` | Global default for all supported high-volume AWS resources (default `0`, disabled/unlimited) | Integer |
+| `max_ebs_snapshots` | EBS snapshots (`ec2_ebs_*` checks) | Integer |
+| `max_backup_recovery_points` | Backup recovery points (`backup_recovery_point_*`) | Integer |
+| `max_cloudwatch_log_groups` | CloudWatch log groups (`cloudwatch_log_group_*`) | Integer |
+| `max_lambda_functions` | Lambda functions (`awslambda_function_*`) | Integer |
+| `max_ecs_task_definitions` | ECS task definitions (`ecs_task_definitions_*`) | Integer |
+| `max_codeartifact_packages` | CodeArtifact packages (`codeartifact_packages_*`) | Integer |
+
+#### Resource Limit Behavior By Resource Path
+
+| Resource Path | What Prowler Discovers | What A Positive Limit Selects For Analysis | Ordering And Latest Behavior | AWS Calls Reduced | Drawbacks And Consequences |
+|---------------|------------------------|--------------------------------------------|------------------------------|-------------------|----------------------------|
+| EBS snapshots (`max_ebs_snapshots`) | Prowler lists self-owned snapshots and keeps lightweight evidence that volumes and regions have snapshots. | The selected EBS snapshots exposed to `ec2_ebs_*` checks. | Prowler sorts discovered snapshots by `StartTime` newest first, then applies the limit. Snapshots without a timestamp sort last. | Bounds expensive per-snapshot public attribute checks to selected snapshots. Snapshot listing still runs so Prowler can choose the newest snapshots and keep volume/region evidence. | Older unselected snapshots are not analyzed by snapshot checks. A public, unencrypted, or otherwise noncompliant older snapshot can be missed when the limit is lower than the number of snapshots. |
+| Backup recovery points (`max_backup_recovery_points`) | Prowler lists backup vaults, plans, selections, and recovery point candidates in discovered vaults. | The selected recovery points exposed to `backup_recovery_point_*` checks and tag hydration. | Prowler sorts discovered recovery points by `CreationDate` newest first across vaults, then applies the limit. Recovery points without a timestamp sort last. | Bounds recovery point tag calls to selected recovery points. Vault and recovery point list calls still run so Prowler can choose the newest points. | Older unselected recovery points are not analyzed. A nonencrypted or otherwise noncompliant older recovery point can be missed. |
+| CloudWatch log groups (`max_cloudwatch_log_groups`) | Prowler lists log groups into both `all_log_groups` and the primary `log_groups` collection. `all_log_groups` remains available as lightweight cross-service evidence. | The selected log groups exposed to `cloudwatch_log_group_*` checks, tag hydration, and log event retrieval for checks that need log contents. | Prowler sorts discovered log groups by `creationTime` newest first, then applies the limit. Log groups without a creation time sort last. | Bounds tag calls and log event retrieval to selected log groups. Log group listing still runs to build `all_log_groups` and choose newest log groups. | Older unselected log groups are not analyzed by CloudWatch log group checks. Retention, encryption, or secrets-in-logs issues in older log groups can be missed, although cross-service evidence can still use `all_log_groups`. |
+| Lambda functions (`max_lambda_functions`) | Prowler lists Lambda functions and records lightweight security group and region evidence for all discovered functions. | The selected Lambda functions exposed to `awslambda_function_*` checks and per-function enrichment such as tags, policies, function URLs, and event source mappings. | Prowler sorts discovered functions by `LastModified` newest first, then applies the limit. Functions without `LastModified` sort last. | Bounds per-function enrichment calls to selected functions. Function listing still runs to choose newest functions and keep security group/region evidence. | Older unselected functions are not analyzed by Lambda checks. Runtime, policy, URL, environment secret, or dead-letter queue issues in older functions can be missed. Cross-service checks can still use full Lambda security group and region evidence to avoid false positives. |
+| ECS task definitions (`max_ecs_task_definitions`) | Prowler lists ECS task definition ARN candidates in each region. Candidate ARNs can remain visible and discoverable through AWS list operations, even when not all are described. | The selected task definitions that Prowler describes and exposes to `ecs_task_definitions_*` checks. | Selection is not random. Prowler calls `ListTaskDefinitions` with `sort=DESC`, which asks AWS to return task definition ARNs in descending family and revision order. Prowler then interleaves regional candidate lists to avoid starving later regions before applying the limit. This selects the latest task definition revisions according to the ARN order AWS provides, while preserving regional fairness. | Bounds `DescribeTaskDefinition` calls to selected task definitions. Prowler may still list candidates so it can select the bounded set and keep discovery deterministic. | Unselected task definitions are not described or analyzed. Issues in older task definition revisions, or in lower-priority families outside the selected AWS `sort=DESC` order, can be missed. Because ECS ordering is family/revision based rather than a registration timestamp sort across every family, this is latest-first according to AWS task definition ARN ordering, not a global newest-by-time guarantee. |
+| CodeArtifact packages (`max_codeartifact_packages`) | Prowler lists CodeArtifact repositories and lazily lists packages inside them. | The selected packages exposed to `codeartifact_packages_*` checks, including latest-version metadata for those packages. | AWS `ListPackages` does not provide a newest-package timestamp ordering in this path. Prowler preserves repository order and package API order, then applies the limit. Latest package version metadata is retrieved for selected packages with `sortBy=PUBLISHED_TIME` and `maxResults=1`. | Bounds `ListPackageVersions` calls to selected packages and can stop package listing once the limit is reached. Repository listing still runs. | Package selection is best effort by API order, not newest package order. Packages outside the selected repository/API order are not analyzed, so origin restriction or latest-version issues can be missed. |
+
+Use limits when scan duration, API throttling, or cost are more important than exhaustive coverage for these high-volume resources. Keep limits disabled when you need complete evidence for every resource in the affected checks.
+
+### Validating Discovered Secrets
+
+
+
+By default, the secret-scanning checks run fully offline: secrets are detected but never sent anywhere. Setting `secrets_validate` to `True` additionally confirms whether each discovered secret is live by authenticating with it against the corresponding provider API. The discovered secret itself serves as the credential, so Prowler requires no additional permissions to validate it.
+
+`secrets_validate` applies to every AWS secret-scanning check listed above (those that accept `secrets_ignore_patterns`). The `--scan-secrets-validate` CLI flag is provider-wide: it also enables validation for the secret-scanning checks of other providers, such as the OpenStack metadata checks.
+
+To enable validation through the configuration file, set the value under the `aws` section:
+
+```yaml
+aws:
+ secrets_validate: True
+```
+
+To enable validation for a single scan (any provider), use Prowler CLI:
+
+```
+prowler aws --scan-secrets-validate
+```
+
+
+Secret validation makes outbound network calls that authenticate with each discovered secret. The credential is exercised against the provider, so the call appears in the audited account's logs and can trigger its monitoring (for example, AWS CloudTrail records the validation request). Validation stays disabled by default so that scans remain fully offline.
+
## Azure
@@ -82,19 +204,20 @@ The following list includes all the AWS checks with configurable variables that
### Configurable Checks
The following list includes all the Azure checks with configurable variables that can be changed in the configuration yaml file:
-| Check Name | Value | Type |
-|---------------------------------------------------------------|--------------------------------------------------|-----------------|
-| `network_public_ip_shodan` | `shodan_api_key` | String |
-| `app_ensure_php_version_is_latest` | `php_latest_version` | String |
-| `app_ensure_python_version_is_latest` | `python_latest_version` | String |
-| `app_ensure_java_version_is_latest` | `java_latest_version` | String |
-| `sqlserver_recommended_minimal_tls_version` | `recommended_minimal_tls_versions` | List of Strings |
-| `vm_sufficient_daily_backup_retention_period` | `vm_backup_min_daily_retention_days` | Integer |
-| `vm_desired_sku_size` | `desired_vm_sku_sizes` | List of Strings |
-| `defender_attack_path_notifications_properly_configured` | `defender_attack_path_minimal_risk_level` | String |
-| `apim_threat_detection_llm_jacking` | `apim_threat_detection_llm_jacking_threshold` | Float |
-| `apim_threat_detection_llm_jacking` | `apim_threat_detection_llm_jacking_minutes` | Integer |
-| `apim_threat_detection_llm_jacking` | `apim_threat_detection_llm_jacking_actions` | List of Strings |
+| Check Name | Value | Type | Default |
+|----------------------------------------------------------|-------------------------------------------------|-----------------|------------------------------------------------------------|
+| `apim_threat_detection_llm_jacking` | `apim_threat_detection_llm_jacking_actions` | List of Strings | See `config.yaml` |
+| `apim_threat_detection_llm_jacking` | `apim_threat_detection_llm_jacking_minutes` | Integer | `1440` |
+| `apim_threat_detection_llm_jacking` | `apim_threat_detection_llm_jacking_threshold` | Float | `0.1` |
+| `app_ensure_java_version_is_latest` | `java_latest_version` | String | `"17"` |
+| `app_ensure_php_version_is_latest` | `php_latest_version` | String | `"8.2"` |
+| `app_ensure_python_version_is_latest` | `python_latest_version` | String | `"3.12"` |
+| `defender_attack_path_notifications_properly_configured` | `defender_attack_path_minimal_risk_level` | String | `"High"` |
+| `network_public_ip_shodan` | `shodan_api_key` | String | `null` |
+| `sqlserver_recommended_minimal_tls_version` | `recommended_minimal_tls_versions` | List of Strings | `["1.2", "1.3"]` |
+| `storage_smb_channel_encryption_with_secure_algorithm` | `recommended_smb_channel_encryption_algorithms` | List of Strings | `["AES-256-GCM"]` |
+| `vm_desired_sku_size` | `desired_vm_sku_sizes` | List of Strings | `["Standard_A8_v2", "Standard_DS3_v2", "Standard_D4s_v3"]` |
+| `vm_sufficient_daily_backup_retention_period` | `vm_backup_min_daily_retention_days` | Integer | `7` |
## GCP
@@ -102,23 +225,28 @@ The following list includes all the Azure checks with configurable variables tha
### Configurable Checks
The following list includes all the GCP checks with configurable variables that can be changed in the configuration yaml file:
-| Check Name | Value | Type |
-|---------------------------------------------------------------|--------------------------------------------------|-----------------|
-| `compute_configuration_changes` | `compute_audit_log_lookback_days` | Integer |
-| `compute_instance_group_multiple_zones` | `mig_min_zones` | Integer |
+| Check Name | Value | Type | Default |
+|---------------------------------------------------|-----------------------------------|---------|---------|
+| `cloudstorage_bucket_sufficient_retention_period` | `storage_min_retention_days` | Integer | `90` |
+| `compute_instance_group_multiple_zones` | `mig_min_zones` | Integer | `2` |
+| `compute_public_address_shodan` | `shodan_api_key` | String | `null` |
+| `compute_snapshot_not_outdated` | `max_snapshot_age_days` | Integer | `90` |
+| `iam_sa_user_managed_key_unused` | `max_unused_account_days` | Integer | `180` |
+| `iam_service_account_unused` | `max_unused_account_days` | Integer | `180` |
+| `secretmanager_secret_rotation_enabled` | `secretmanager_max_rotation_days` | Integer | `90` |
## Kubernetes
### Configurable Checks
The following list includes all the Kubernetes checks with configurable variables that can be changed in the configuration yaml file:
-| Check Name | Value | Type |
-|---------------------------------------------------------------|--------------------------------------------------|-----------------|
-| `audit_log_maxbackup` | `audit_log_maxbackup` | String |
-| `audit_log_maxsize` | `audit_log_maxsize` | String |
-| `audit_log_maxage` | `audit_log_maxage` | String |
-| `apiserver_strong_ciphers` | `apiserver_strong_ciphers` | String |
-| `kubelet_strong_ciphers_only` | `kubelet_strong_ciphers` | String |
+| Check Name | Value | Type | Default |
+|-------------------------------------|----------------------------|-----------------|----------------------------------------------------------------------------------------|
+| `apiserver_audit_log_maxage_set` | `audit_log_maxage` | Integer | `30` |
+| `apiserver_audit_log_maxbackup_set` | `audit_log_maxbackup` | Integer | `10` |
+| `apiserver_audit_log_maxsize_set` | `audit_log_maxsize` | Integer | `100` |
+| `apiserver_strong_ciphers_only` | `apiserver_strong_ciphers` | List of Strings | `["TLS_AES_128_GCM_SHA256", "TLS_AES_256_GCM_SHA384", "TLS_CHACHA20_POLY1305_SHA256"]` |
+| `kubelet_strong_ciphers_only` | `kubelet_strong_ciphers` | List of Strings | See `config.yaml` |
## M365
@@ -126,11 +254,13 @@ The following list includes all the Kubernetes checks with configurable variable
### Configurable Checks
The following list includes all the Microsoft 365 checks with configurable variables that can be changed in the configuration yaml file:
-| Check Name | Value | Type |
-|---------------------------------------------------------------|--------------------------------------------------|-----------------|
-| `entra_admin_users_sign_in_frequency_enabled` | `sign_in_frequency` | Integer |
-| `teams_external_file_sharing_restricted` | `allowed_cloud_storage_services` | List of Strings |
-| `exchange_organization_mailtips_enabled` | `recommended_mailtips_large_audience_threshold` | Integer |
+| Check Name | Value | Type | Default |
+|--------------------------------------------------------------------|-------------------------------------------------|-----------------|--------------------|
+| `defender_malware_policy_comprehensive_attachments_filter_applied` | `recommended_blocked_file_types` | List of Strings | See check defaults |
+| `entra_admin_users_sign_in_frequency_enabled` | `sign_in_frequency` | Integer | `4` |
+| `exchange_organization_mailtips_enabled` | `recommended_mailtips_large_audience_threshold` | Integer | `25` |
+| `exchange_user_mailbox_auditing_enabled` | `audit_log_age` | Integer | `90` |
+| `teams_external_file_sharing_restricted` | `allowed_cloud_storage_services` | List of Strings | `[]` |
## GitHub
@@ -138,34 +268,76 @@ The following list includes all the Microsoft 365 checks with configurable varia
### Configurable Checks
The following list includes all the GitHub checks with configurable variables that can be changed in the configuration yaml file:
-| Check Name | Value | Type |
-|--------------------------------------------|---------------------------------------------|---------|
-| `repository_inactive_not_archived` | `inactive_not_archived_days_threshold` | Integer |
+| Check Name | Value | Type | Default |
+|------------------------------------|----------------------------------------|---------|---------|
+| `repository_inactive_not_archived` | `inactive_not_archived_days_threshold` | Integer | `180` |
## Vercel
### Configurable Checks
The following list includes all the Vercel checks with configurable variables that can be changed in the configuration YAML file:
-| Check Name | Value | Type |
-|-----------------------------------------------------|------------------------------------|-----------------|
-| `authentication_no_stale_tokens` | `stale_token_threshold_days` | Integer |
-| `authentication_token_not_expired` | `days_to_expire_threshold` | Integer |
-| `deployment_production_uses_stable_target` | `stable_branches` | List of Strings |
-| `domain_ssl_certificate_valid` | `days_to_expire_threshold` | Integer |
-| `project_environment_no_secrets_in_plain_type` | `secret_suffixes` | List of Strings |
-| `team_member_role_least_privilege` | `max_owner_percentage` | Integer |
-| `team_member_role_least_privilege` | `max_owners` | Integer |
-| `team_no_stale_invitations` | `stale_invitation_threshold_days` | Integer |
+| Check Name | Value | Type | Default |
+|------------------------------------------------|-----------------------------------|-----------------|--------------------------------------------------------------------------|
+| `authentication_no_stale_tokens` | `stale_token_threshold_days` | Integer | `90` |
+| `authentication_token_not_expired` | `days_to_expire_threshold` | Integer | `7` |
+| `deployment_production_uses_stable_target` | `stable_branches` | List of Strings | `["main", "master"]` |
+| `domain_ssl_certificate_valid` | `days_to_expire_threshold` | Integer | `7` |
+| `project_environment_no_secrets_in_plain_type` | `secret_suffixes` | List of Strings | `["_KEY", "_SECRET", "_TOKEN", "_PASSWORD", "_API_KEY", "_PRIVATE_KEY"]` |
+| `team_member_role_least_privilege` | `max_owner_percentage` | Integer | `20` |
+| `team_member_role_least_privilege` | `max_owners` | Integer | `3` |
+| `team_no_stale_invitations` | `stale_invitation_threshold_days` | Integer | `30` |
## Okta
### Configurable Checks
The following list includes all the Okta checks with configurable variables that can be changed in the configuration YAML file:
-| Check Name | Value | Type |
-|---------------------------------------------------------------|------------------------------------|---------|
-| `signon_global_session_idle_timeout_15min` | `okta_max_session_idle_minutes` | Integer |
+| Check Name | Value | Type | Default |
+|--------------------------------------------------------|-----------------------------------------------|-----------------|---------|
+| `application_admin_console_session_idle_timeout_15min` | `okta_admin_console_idle_timeout_max_minutes` | Integer | `15` |
+| `idp_smart_card_dod_approved_ca` | `okta_dod_approved_ca_issuer_patterns` | List of Strings | `[]` |
+| `signon_global_session_idle_timeout_15min` | `okta_max_session_idle_minutes` | Integer | `15` |
+| `signon_global_session_lifetime_18h` | `okta_max_session_lifetime_minutes` | Integer | `1080` |
+| `user_inactivity_automation_35d_enabled` | `okta_user_inactivity_max_days` | Integer | `35` |
+
+## Alibaba Cloud
+
+### Configurable Checks
+The following list includes all the Alibaba Cloud checks with configurable variables that can be changed in the configuration YAML file:
+
+| Check Name | Value | Type | Default |
+|--------------------------------------|--------------------------------|---------|---------|
+| `cs_kubernetes_cluster_check_recent` | `max_cluster_check_days` | Integer | `7` |
+| `cs_kubernetes_cluster_check_weekly` | `max_cluster_check_days` | Integer | `7` |
+| `ram_user_console_access_unused` | `max_console_access_days` | Integer | `90` |
+| `rds_instance_sql_audit_retention` | `min_rds_audit_retention_days` | Integer | `180` |
+| `sls_logstore_retention_period` | `min_log_retention_days` | Integer | `365` |
+
+
+## MongoDB Atlas
+
+### Configurable Checks
+The following list includes all the MongoDB Atlas checks with configurable variables that can be changed in the configuration YAML file:
+
+| Check Name | Value | Type | Default |
+|----------------------------------------------------|---------------------------------------------|---------|---------|
+| `organizations_service_account_secrets_expiration` | `max_service_account_secret_validity_hours` | Integer | `8` |
+
+
+## OpenStack
+
+### Configurable Checks
+The following list includes all the OpenStack checks with configurable variables that can be changed in the configuration YAML file:
+
+| Check Name | Value | Type | Default |
+|---------------------------------------------------|---------------------------|-----------------|---------|
+| `blockstorage_snapshot_metadata_sensitive_data` | `secrets_ignore_patterns` | List of Strings | `[]` |
+| `blockstorage_volume_metadata_sensitive_data` | `secrets_ignore_patterns` | List of Strings | `[]` |
+| `compute_instance_metadata_sensitive_data` | `secrets_ignore_patterns` | List of Strings | `[]` |
+| `image_not_shared_with_multiple_projects` | `image_sharing_threshold` | Integer | `5` |
+| `objectstorage_container_metadata_sensitive_data` | `secrets_ignore_patterns` | List of Strings | `[]` |
+
## Config YAML File Structure
@@ -179,6 +351,19 @@ aws:
# AWS Global Configuration
# aws.mute_non_default_regions --> Set to True to muted failed findings in non-default regions for AccessAnalyzer, GuardDuty, SecurityHub, DRS and Config
mute_non_default_regions: False
+
+ # AWS Resource Scan Limit Configuration
+ # Disabled by default: scan every resource unless a positive limit is configured.
+ # Findings are not capped. Set to 0 (or a negative value) to disable the limit.
+ # aws.max_scanned_resources_per_service --> global default for all services below
+ max_scanned_resources_per_service: 0
+ # Per-service overrides. Leave as null to fall back to the global default.
+ max_ebs_snapshots: null
+ max_backup_recovery_points: null
+ max_cloudwatch_log_groups: null
+ max_lambda_functions: null
+ max_ecs_task_definitions: null
+ max_codeartifact_packages: null
# If you want to mute failed findings only in specific regions, create a file with the following syntax and run it with `prowler aws -w mutelist.yaml`:
# Mutelist:
# Accounts:
@@ -534,6 +719,18 @@ azure:
"1.3"
]
+ # Azure Storage
+ # azure.storage_smb_channel_encryption_with_secure_algorithm
+ # List of SMB channel encryption algorithms allowed on file shares. A storage
+ # account passes only if every enabled algorithm is in this list. Defaults to
+ # the value required by CIS (AES-256-GCM only, excluding weaker AES-128 ciphers).
+ recommended_smb_channel_encryption_algorithms:
+ [
+ "AES-256-GCM",
+ # "AES-128-CCM",
+ # "AES-128-GCM",
+ ]
+
# Azure Virtual Machines
# azure.vm_desired_sku_size
# List of desired VM SKU sizes that are allowed in the organization
diff --git a/docs/user-guide/cli/tutorials/dashboard.mdx b/docs/user-guide/cli/tutorials/dashboard.mdx
index 0fe3c69fed..abb3872657 100644
--- a/docs/user-guide/cli/tutorials/dashboard.mdx
+++ b/docs/user-guide/cli/tutorials/dashboard.mdx
@@ -1,8 +1,8 @@
---
-title: "Dashboard"
+title: "Prowler Local Dashboard"
---
-Prowler allows you to run your own local dashboards using the csv outputs provided by Prowler
+Prowler Local Dashboard is a local web dashboard built from the CSV outputs produced by Prowler CLI. Launch it with:
```sh
prowler dashboard
@@ -12,7 +12,7 @@ prowler dashboard
You can expose the `dashboard` server in another address using the `HOST` environment variable.
-To run Prowler local dashboard with Docker, use:
+To run Prowler Local Dashboard with Docker, use:
```sh
docker run -v /your/local/dir/prowler-output:/home/prowler/output --env HOST=0.0.0.0 --publish 127.0.0.1:11666:11666 toniblyx/prowler:latest dashboard
diff --git a/docs/user-guide/cli/tutorials/integrations.mdx b/docs/user-guide/cli/tutorials/integrations.mdx
index f3e698d799..bfd53b0c3c 100644
--- a/docs/user-guide/cli/tutorials/integrations.mdx
+++ b/docs/user-guide/cli/tutorials/integrations.mdx
@@ -33,7 +33,7 @@ To configure the Slack Integration, follow the next steps:
2. Optionally, create a Slack Channel (you can use an existing one)
3. Integrate the created Slack App to your Slack channel:
- - Click on the channel, go to the Integrations tab, and Add an App.
+ - Click the channel, go to the Integrations tab, and Add an App.

4. Set the following environment variables that Prowler will read:
diff --git a/docs/user-guide/cli/tutorials/parallel-execution.mdx b/docs/user-guide/cli/tutorials/parallel-execution.mdx
index 93b8ef381b..d659116b50 100644
--- a/docs/user-guide/cli/tutorials/parallel-execution.mdx
+++ b/docs/user-guide/cli/tutorials/parallel-execution.mdx
@@ -184,7 +184,7 @@ $combinedCsv | Export-Csv -Path "CombinedCSV.csv" -NoTypeInformation
## TODO: Additional Improvements
-Some services need to instantiate another service to perform a check. For instance, `cloudwatch` will instantiate Prowler's `iam` service to perform the `cloudwatch_cross_account_sharing_disabled` check. When the `iam` service is instantiated, it will perform the `__init__` function, and pull all the information required for that service. This provides an opportunity for an improvement in the above script to group related services together so that the `iam` services (or any other cross-service references) isn't repeatedily instantiated by grouping dependant services together. A complete mapping between these services still needs to be further investigated, but these are the cross-references that have been noted:
+Some services need to instantiate another service to perform a check. For instance, `cloudwatch` will instantiate Prowler's `iam` service to perform the `cloudwatch_cross_account_sharing_disabled` check. When the `iam` service is instantiated, it will perform the `__init__` function, and pull all the information required for that service. This provides an opportunity for an improvement in the above script to group related services together so that the `iam` services (or any other cross-service references) aren't repeatedly instantiated by grouping dependent services together. A complete mapping between these services still needs to be further investigated, but these are the cross-references that have been noted:
* inspector2 needs lambda and ec2
* cloudwatch needs iam
diff --git a/docs/user-guide/cli/tutorials/pentesting.mdx b/docs/user-guide/cli/tutorials/pentesting.mdx
index 0ad5313611..a9a8c728df 100644
--- a/docs/user-guide/cli/tutorials/pentesting.mdx
+++ b/docs/user-guide/cli/tutorials/pentesting.mdx
@@ -6,20 +6,34 @@ Prowler has some checks that analyse pentesting risks (Secrets, Internet Exposed
## Detect Secrets
-Prowler uses `detect-secrets` library to search for any secrets that are stores in plaintext within your environment.
+Prowler scans for secrets stored in plaintext within the audited environment using [Kingfisher](https://github.com/mongodb/kingfisher), an open-source secret-scanning engine. By default these scans run fully offline, so no data leaves the audited environment. Discovered secrets can optionally be validated against the provider APIs to confirm whether they are live — see [Validating Discovered Secrets](/user-guide/cli/tutorials/configuration_file#validating-discovered-secrets).
-The actual checks that have this functionality are the following:
+The checks with this functionality are the following.
+AWS:
+
+- apigateway\_restapi\_no\_secrets\_in\_stage\_variables
- autoscaling\_find\_secrets\_ec2\_launch\_configuration
- awslambda\_function\_no\_secrets\_in\_code
- awslambda\_function\_no\_secrets\_in\_variables
- cloudformation\_stack\_outputs\_find\_secrets
+- cloudwatch\_log\_group\_no\_secrets\_in\_logs
+- codebuild\_project\_no\_secrets\_in\_variables
- ec2\_instance\_secrets\_user\_data
- ec2\_launch\_template\_no\_secrets
- ecs\_task\_definitions\_no\_environment\_secrets
+- glue\_etl\_jobs\_no\_secrets\_in\_arguments
- ssm\_document\_secrets
+- stepfunctions\_statemachine\_no\_secrets\_in\_definition
-To execute detect-secrets related checks, you can run the following command:
+OpenStack:
+
+- compute\_instance\_metadata\_sensitive\_data
+- blockstorage\_volume\_metadata\_sensitive\_data
+- blockstorage\_snapshot\_metadata\_sensitive\_data
+- objectstorage\_container\_metadata\_sensitive\_data
+
+To execute the secret-scanning checks, run the following command:
```console
prowler --categories secrets
diff --git a/docs/user-guide/cli/tutorials/reporting.mdx b/docs/user-guide/cli/tutorials/reporting.mdx
index 19a14c9ae2..47e46dc2aa 100644
--- a/docs/user-guide/cli/tutorials/reporting.mdx
+++ b/docs/user-guide/cli/tutorials/reporting.mdx
@@ -114,7 +114,7 @@ The CSV format follows a standardized structure across all providers. The follow
#### CSV Headers Mapping
-The following table shows the mapping between the CSV headers and the the providers fields:
+The following table shows the mapping between the CSV headers and the providers fields:
| Open Source Consolidated| AWS| GCP| AZURE| KUBERNETES
|----------|----------|----------|----------|----------
diff --git a/docs/user-guide/compliance/tutorials/compliance.mdx b/docs/user-guide/compliance/tutorials/compliance.mdx
index 63b2502b25..6c13f60529 100644
--- a/docs/user-guide/compliance/tutorials/compliance.mdx
+++ b/docs/user-guide/compliance/tutorials/compliance.mdx
@@ -1,14 +1,15 @@
---
title: 'Compliance'
-description: 'Run security checks against compliance frameworks, review posture across providers, and download CSV or PDF reports from Prowler Cloud, Prowler App, and Prowler CLI.'
+sidebarTitle: 'Overview'
+description: 'Run security checks against compliance frameworks, review posture across providers, and download CSV or PDF reports from Prowler Cloud and Prowler Local Server, or CSV reports from Prowler CLI.'
---
-Prowler maps every security check to one or more industry-standard compliance frameworks, so a single scan produces both technical findings and framework-aligned evidence. The same evaluation runs identically whether scans are launched from Prowler Cloud, Prowler App, or Prowler CLI.
+Prowler maps every security check to one or more industry-standard compliance frameworks, so a single scan produces both technical findings and framework-aligned evidence. The same evaluation runs identically whether scans are launched from Prowler Cloud, Prowler Local Server, or Prowler CLI.
Out of the box, Prowler covers frameworks such as CIS Benchmarks, NIST 800-53, NIST CSF, NIS2, ENS RD2022, ISO 27001, PCI-DSS, SOC 2, GDPR, HIPAA, AWS Well-Architected, BSI C5, CSA CCM, MITRE ATT&CK, KISA ISMS-P, FedRAMP, and Prowler ThreatScore. The full catalog is available at [Prowler Hub](https://hub.prowler.com/compliance).
-For the unified compliance score methodology used across frameworks, see [Prowler ThreatScore Documentation](/user-guide/compliance/tutorials/threatscore).
+For the unified compliance score methodology used across frameworks, see [Prowler ThreatScore](/user-guide/compliance/tutorials/threatscore).
@@ -20,15 +21,15 @@ For the unified compliance score methodology used across frameworks, see [Prowle
-## Prowler Cloud
+## Prowler Cloud and Prowler Local Server
-The Compliance section in Prowler Cloud and Prowler App centralizes compliance posture across every connected provider. It aggregates scan results, surfaces Prowler ThreatScore, and exposes detailed requirement-level evidence for each supported framework.
+The Compliance section in Prowler Cloud and Prowler Local Server centralizes compliance posture across every connected provider. It aggregates scan results, surfaces Prowler ThreatScore, and exposes detailed requirement-level evidence for each supported framework.
### Accessing the Compliance Section
To open the compliance overview, follow these steps:
-1. Sign in to Prowler Cloud at [cloud.prowler.com](https://cloud.prowler.com/sign-in) or to a self-hosted Prowler App instance.
+1. Sign in to Prowler Cloud at [cloud.prowler.com](https://cloud.prowler.com/sign-in) or to a Prowler Local Server instance.
2. Select **Compliance** from the left navigation.
The page lists every framework evaluated by the most recent completed scan of the selected provider.
@@ -74,7 +75,7 @@ When the selected scan includes Prowler ThreatScore data, a dedicated card appea
Selecting the card opens the ThreatScore framework detail page, covered in [Working With the Framework Detail Page](#working-with-the-framework-detail-page).
-For a complete explanation of the methodology, formula, and weighting, see [Prowler ThreatScore Documentation](/user-guide/compliance/tutorials/threatscore).
+For a complete explanation of the methodology, formula, and weighting, see [Prowler ThreatScore](/user-guide/compliance/tutorials/threatscore).
### Exploring the Framework Grid
@@ -169,7 +170,7 @@ Region filters disable the per-card download dropdown to avoid generating partia
#### Downloading the Full Scan Output
-To export every framework, finding, and resource at once, use the **Scan Jobs** section instead. The ZIP archive contains the CSV, JSON-OCSF, and HTML reports plus a `compliance/` subfolder with one CSV per framework. See [Prowler App — Getting Started](/user-guide/tutorials/prowler-app) for details.
+To export every framework, finding, and resource at once, use the **Scan Jobs** section instead. The ZIP archive contains the CSV, JSON-OCSF, and HTML reports plus a `compliance/` subfolder with one CSV per framework. See [Prowler Cloud — Getting Started](/user-guide/tutorials/prowler-app) for details.
### API Access
@@ -262,6 +263,7 @@ To request a new framework or contribute one, see [Creating a New Security Compl
## Related Documentation
-* [Prowler ThreatScore Documentation](/user-guide/compliance/tutorials/threatscore)
+* [Cross-Provider Compliance](/user-guide/compliance/tutorials/cross-provider-compliance)
+* [Prowler ThreatScore](/user-guide/compliance/tutorials/threatscore)
* [Creating a New Security Compliance Framework in Prowler](/developer-guide/security-compliance-framework)
-* [Prowler App — Getting Started](/user-guide/tutorials/prowler-app)
+* [Prowler Cloud — Getting Started](/user-guide/tutorials/prowler-app)
diff --git a/docs/user-guide/compliance/tutorials/cross-provider-compliance.mdx b/docs/user-guide/compliance/tutorials/cross-provider-compliance.mdx
new file mode 100644
index 0000000000..545f5fb834
--- /dev/null
+++ b/docs/user-guide/compliance/tutorials/cross-provider-compliance.mdx
@@ -0,0 +1,209 @@
+---
+title: 'Cross-Provider Compliance'
+sidebarTitle: 'Cross-Provider Compliance'
+description: 'Aggregate a single universal compliance framework across every connected provider in Prowler Cloud, review a consolidated roll-up and per-provider breakdown, and download a combined PDF report.'
+---
+
+import { VersionBadge } from "/snippets/version-badge.mdx"
+import { SubscriptionBanner } from "/snippets/subscription-banner.mdx"
+
+
+
+Cross-Provider Compliance consolidates a single **universal compliance framework** across all of your connected providers into one unified view. Instead of reviewing the same framework on AWS, Azure, Google Cloud, and every other provider as separate reports, Prowler takes the most recent completed scan of every compatible provider, aggregates them by requirement, and produces a single roll-up posture with a per-provider breakdown and a combined executive PDF.
+
+
+
+## What Is a Universal Compliance Framework
+
+Most Prowler compliance frameworks target a single provider (for example, CIS AWS or CIS Azure). A **universal** framework declares its requirements once in a single JSON and maps each requirement to checks for many providers at the same time, so the same CSA CCM control maps to both AWS and Azure checks. Universal frameworks live at the top of the compliance catalog (`prowler/compliance/.json`), as opposed to the legacy per-provider frameworks under `prowler/compliance//`. For real definitions, see [`csa_ccm_4.0.json`](https://github.com/prowler-cloud/prowler/blob/master/prowler/compliance/csa_ccm_4.0.json), [`cis_controls_8.1.json`](https://github.com/prowler-cloud/prowler/blob/master/prowler/compliance/cis_controls_8.1.json), and [`dora_2022_2554.json`](https://github.com/prowler-cloud/prowler/blob/master/prowler/compliance/dora_2022_2554.json) in the Prowler repository.
+
+Prowler currently ships three universal frameworks: [CSA CCM](https://hub.prowler.com/compliance/csa_ccm_4.0), [CIS Controls](https://hub.prowler.com/compliance/cis_controls_8.1), and [DORA](https://hub.prowler.com/compliance/dora_2022_2554). Each framework page on Prowler Hub lists the full requirement-to-check mapping per provider.
+
+## How Cross-Provider Compliance Works
+
+Cross-Provider Compliance uses this structure to answer a single question: **"How compliant is my whole estate against this framework, regardless of provider?"** For a chosen universal framework, Prowler Cloud:
+
+1. Selects **one scan per compatible provider**: by default, the latest completed scan of each provider the framework supports and that you are allowed to see.
+2. Aggregates the requirement results across those scans, folding multiple accounts of the same provider type together.
+3. Computes a **roll-up status** for each requirement and an overall pass / fail / manual summary for the framework.
+4. Exposes a **per-provider breakdown** so you can see exactly which provider is failing a given control.
+
+
+Cross-Provider Compliance never mixes different frameworks. It aggregates one universal framework at a time across providers. To review a single provider in isolation, use the standard per-scan [Compliance](/user-guide/compliance/tutorials/compliance) view.
+
+
+### Supported Universal Frameworks
+
+The Cross-Provider Compliance view currently supports the following universal frameworks. The compatible providers are the ones each framework declares checks for; a provider only contributes to the roll-up when it has a completed scan.
+
+| Framework | Version | Compatible providers |
+|-----------|---------|----------------------|
+| [**CSA CCM**](https://hub.prowler.com/compliance/csa_ccm_4.0) (Cloud Controls Matrix) | 4.0 | AWS, Azure, Google Cloud, Alibaba Cloud, Oracle Cloud |
+| [**CIS Controls**](https://hub.prowler.com/compliance/cis_controls_8.1) | 8.1 | AWS, Azure, Google Cloud, Microsoft 365, Kubernetes, GitHub, Google Workspace, Okta, Oracle Cloud, Alibaba Cloud, Cloudflare, MongoDB Atlas, OpenStack, Vercel |
+| [**DORA**](https://hub.prowler.com/compliance/dora_2022_2554) (Digital Operational Resilience Act) | 2022/2554 | AWS, Azure, Google Cloud, Alibaba Cloud, Cloudflare |
+
+The catalog grows as new universal frameworks ship in Prowler. Browse the full compliance catalog at [Prowler Hub](https://hub.prowler.com/compliance).
+
+## Accessing the Cross-Provider View
+
+
+
+ Sign in to Prowler Cloud at [cloud.prowler.com](https://cloud.prowler.com/sign-in) and select **Compliance** from the left navigation.
+
+
+ At the top of the Compliance page, select the **Cross-provider** tab. The **Per Scan** tab (the default) keeps the single-provider compliance experience unchanged.
+
+
+
+
+
+
+Cross-Provider Compliance requires at least one completed scan for a compatible provider. If no compatible provider has finished a scan yet, the page shows a notice prompting you to launch or wait for a scan to complete.
+
+
+## Exploring the Overview
+
+The overview presents one card per supported universal framework, each summarizing the consolidated posture across every contributing provider.
+
+
+
+Each **framework card** includes:
+
+* **Framework logo, name, and version:** Identifies the universal standard (CSA CCM, CIS Controls, DORA).
+* **Score:** The percentage of passing requirements over the total evaluated, aggregated across every contributing provider. Color coding follows three thresholds: red for severely low compliance, amber for partial compliance, and green for healthy posture.
+* **Passing Requirements:** A `passed / total` counter with the aggregated roll-up.
+* **Provider chips:** One icon per compatible provider. Providers with a completed scan appear active with their passing percentage; providers without a scan appear dimmed with a "no completed scan yet" tooltip so coverage gaps are obvious at a glance.
+* **Failed and manual counts:** The number of failing and manual requirements in the roll-up.
+
+Select any card to open the framework detail page.
+
+### Filtering the Roll-Up
+
+The filters bar controls which providers and accounts feed every card and detail view. Cross-Provider Compliance supports three filters:
+
+* **Provider type:** Narrow the roll-up to specific provider types (for example, only AWS and Azure).
+* **Provider / account:** Narrow to specific connected accounts.
+* **Provider group:** Narrow to accounts within one or more provider groups.
+
+Select **Clear filters** to reset all filters. Filters applied on the overview are carried through into the detail page and the PDF report so the view stays consistent end to end.
+
+
+Filters narrow **which providers contribute** to the aggregation. They do not change how a requirement rolls up (see [Understanding the Roll-Up Status](#understanding-the-roll-up-status)).
+
+
+## Working With the Framework Detail Page
+
+The detail page provides the full breakdown for a single universal framework: aggregate metrics, provider coverage, top failing sections, and a requirement-by-requirement view with per-provider status.
+
+
+
+### Header
+
+The header shows the framework name and version, a link to the framework page on [Prowler Hub](https://hub.prowler.com/compliance), and a summary such as *"X of Y compatible providers scanned · N scans aggregated"* so you always know the coverage behind the numbers. The **Report** button in the top-right generates and downloads the combined PDF (see [Downloading the Combined PDF Report](#downloading-the-combined-pdf-report)).
+
+### Summary Cards
+
+Below the header, three summary cards condense the framework state:
+
+* **Requirements Status:** Donut chart with `Pass`, `Fail`, and `Manual` counts plus the total number of requirements, reflecting the consolidated roll-up.
+* **Provider Coverage:** Shows which compatible providers contributed a scan and their individual posture, so coverage gaps and per-provider weak spots are visible at a glance.
+* **Top Failed Sections:** Ranks the framework sections with the highest number of failing requirements, with deep links into the requirements accordion.
+
+### Requirements Accordion
+
+The accordion organizes every requirement of the framework. For each requirement you see:
+
+* **Requirement ID and title:** The official identifier from the framework.
+* **Roll-up status badge:** A single `Pass`, `Fail`, or `Manual` badge representing the consolidated status across all contributing providers.
+* **Per-provider status:** The status each contributing provider returned for that requirement, so a single failing provider is immediately attributable.
+* **Provider-labeled checks:** When you expand a requirement, the underlying checks are labeled with the provider they belong to (each universal requirement maps to different check IDs per provider).
+
+Expand a requirement to review the failing checks per provider, the affected resources, and remediation guidance. Findings are queried across every contributing scan and merged into a single table.
+
+
+
+## Understanding the Roll-Up Status
+
+Cross-Provider Compliance rolls up results in two stages, with a strict **FAIL > PASS > MANUAL** precedence.
+
+**Per provider, per requirement:**
+
+* If any check fails → the provider contributes **FAIL** for that requirement.
+* Else if every check passes → **PASS**.
+* Otherwise (no pass/fail evidence) → **MANUAL**.
+
+Multiple accounts of the same provider type (for example, three AWS accounts) are folded together first, so a failure in any one account marks that provider type as failing.
+
+**Across providers, per requirement (the roll-up badge):**
+
+* If at least one contributing provider is **FAIL** → the requirement is **FAIL**.
+* Else if at least one contributing provider is **PASS** → **PASS**.
+* Otherwise → **MANUAL**.
+
+
+Only providers that **actually contributed a result** for a requirement are counted. A provider that has a scan in the aggregation but produced no result for a specific requirement (for example, because the framework maps no checks to that provider for that control) does **not** degrade the requirement to Manual. This keeps the roll-up focused on real evidence.
+
+
+### How Scans Are Selected
+
+By default, Cross-Provider Compliance auto-selects the **latest completed scan** of each compatible provider you are allowed to see. This means:
+
+* The view always reflects your most recent posture per provider, without any manual scan selection.
+* Adding a new compatible provider and running a scan automatically brings it into the roll-up.
+* Provider visibility follows your role: you only ever see providers your permissions allow, and the roll-up is scoped accordingly.
+
+## Downloading the Combined PDF Report
+
+The **Report** button on the detail page generates a single PDF that combines every contributing provider's latest scan for the framework into one executive document: a cover page listing all providers and accounts, an executive summary with the consolidated roll-up, charts, a requirements index, and detailed findings grouped by requirement, provider, and account.
+
+### The Report Follows Your Filters
+
+A report covers the exact set of scans your current filters resolve to. The provider type, provider / account, and provider group filters applied on the detail page determine which providers contribute, and the auto-select rule pins each contributing provider's latest completed scan. The PDF is built from that resolved scan set together with the framework.
+
+As a result, each filter combination produces its own report. For example:
+
+* No filters → a report covering every compatible provider that has a completed scan.
+* `Provider type = AWS, Azure` → a report covering only your AWS and Azure scans.
+* `Provider group = Production` → a report covering only the accounts in that group.
+
+Changing the filters and generating again produces a different, independent report. Each combination is tracked on its own, so switching filters back and forth never overwrites a previously generated report.
+
+
+Region filtering is **not** supported for the combined PDF report. The report recomputes status live across every region of the contributing scans, so a region-scoped request is rejected rather than producing a report that contradicts a region-filtered view.
+
+
+### Generating and Reusing a Report
+
+Because the report aggregates many scans, it is generated **asynchronously**:
+
+
+
+ Select **Report → Generate new report…**, optionally give it a name, and confirm. Prowler starts a background job and shows a "Report generation started" confirmation.
+
+
+ The button shows a "Generating report…" state while the job runs. Generation continues in the background: you can navigate away, and a toast notification appears when the report is ready, even after a page reload.
+
+
+ When the report is ready, select **Download** from the notification, or use **Report → Download latest** at any time to fetch the most recent report for the current filters.
+
+
+
+
+
+A report already generated for a given set of filters does not need to be generated again. When you open the detail page with a filter combination that was reported before, Prowler detects the existing report and surfaces **Report → Download latest** so you can download it immediately, without launching a new job. You only need to generate a fresh report when:
+
+* You apply a filter combination that has never been reported before, or
+* A contributing provider has run a new scan since the report was generated. The report is tied to the specific scans it was built from, so a newer scan makes the previous report stale; Prowler recognizes it no longer matches the current selection and offers to generate an up-to-date one.
+
+"Download latest" reuses an existing report only when it matches the framework, the exact resolved scan set for the current filters, and the same report options. This guarantees the PDF you download reflects the posture you are looking at, rather than a report generated for a different filter or an older scan.
+
+
+The PDF detail section renders only **failed** requirements by default so the report stays focused as an executive/auditor document. As with every Prowler PDF, the detail section is capped at the first 100 failed findings per check; use the per-scan CSV or JSON-OCSF exports for the complete, untruncated list. See [Downloading Compliance Reports](/user-guide/compliance/tutorials/compliance#downloading-compliance-reports) for the full PDF behavior and the `DJANGO_PDF_MAX_FINDINGS_PER_CHECK` setting.
+
+
+## Related Documentation
+
+* [Compliance](/user-guide/compliance/tutorials/compliance)
+* [Prowler ThreatScore](/user-guide/compliance/tutorials/threatscore)
+* [Creating a New Security Compliance Framework in Prowler](/developer-guide/security-compliance-framework)
+* [Prowler Cloud — Getting Started](/user-guide/tutorials/prowler-app)
diff --git a/docs/user-guide/compliance/tutorials/threatscore.mdx b/docs/user-guide/compliance/tutorials/threatscore.mdx
index bb5a006342..8d790cf90d 100644
--- a/docs/user-guide/compliance/tutorials/threatscore.mdx
+++ b/docs/user-guide/compliance/tutorials/threatscore.mdx
@@ -1,19 +1,17 @@
---
-title: "Prowler ThreatScore Documentation"
+title: "Prowler ThreatScore"
---
-
-
-
## Introduction
-The **Prowler ThreatScore** is a comprehensive compliance scoring system that provides a unified metric for assessing your organization's security posture across compliance frameworks. It aggregates findings from individual security checks into a single, normalized score ranging from 0 to 100.
+Prowler ThreatScore is a comprehensive compliance scoring system that provides a unified metric for assessing security posture across compliance frameworks. It aggregates findings from individual security checks into a single, normalized score ranging from 0 to 100.
### Purpose
-- **Unified View**: Get a single metric representing overall compliance health
-- **Risk Prioritization**: Understand which areas pose the highest security risks
-- **Progress Tracking**: Monitor improvements in compliance posture over time
-- **Executive Reporting**: Provide clear, quantifiable security metrics to stakeholders
+
+- **Unified view:** A single metric represents overall compliance health.
+- **Risk prioritization:** Highlights which areas pose the highest security risks.
+- **Progress tracking:** Monitors improvements in compliance posture over time.
+- **Executive reporting:** Delivers clear, quantifiable security metrics to stakeholders.
## How ThreatScore Works
@@ -29,7 +27,7 @@ Pass Rate = (Number of PASS findings) / (Total findings)
The total number of checks performed (both PASS and FAIL) for a requirement. This represents the amount of evidence available - more findings provide greater confidence in the assessment.
### 3. Weight (`weight_i`)
-A numerical value (1-1000) representing the business importance or criticality of the requirement within your organization's context.
+A numerical value (1-1000) representing the business importance or criticality of the requirement within the organizational context.
### 4. Risk Level (`risk_i`)
A severity rating (1-5) indicating the potential impact of non-compliance with this requirement.
@@ -380,7 +378,7 @@ This comprehensive example demonstrates how:
### Example 4: Impact of Parameter Changes
-Using the scenario, let's see how parameter changes affect the score:
+Using the scenario, the following changes show how parameter adjustments affect the score:
#### Scenario A: Increase Encryption Risk Level
diff --git a/docs/user-guide/cookbooks/cicd-pipeline.mdx b/docs/user-guide/cookbooks/cicd-pipeline.mdx
index ee7d49eb91..11295b1f29 100644
--- a/docs/user-guide/cookbooks/cicd-pipeline.mdx
+++ b/docs/user-guide/cookbooks/cicd-pipeline.mdx
@@ -6,7 +6,7 @@ title: 'Run Prowler in CI/CD and Send Findings to Prowler Cloud'
For new projects, use the official [Prowler GitHub Action](/user-guide/tutorials/prowler-app-github-action) — a Docker-based reusable action that runs scans, optionally pushes findings to Prowler Cloud, and uploads SARIF results to GitHub Code Scanning. The GitHub Actions examples below document the legacy pip-based flow.
-This cookbook demonstrates how to integrate Prowler into CI/CD pipelines so that security scans run automatically and findings are sent to Prowler Cloud via [Import Findings](/user-guide/tutorials/prowler-app-import-findings). Examples cover GitHub Actions and GitLab CI.
+This cookbook demonstrates how to integrate Prowler into CI/CD pipelines so that security scans run automatically and findings are sent to Prowler Cloud via [Import Findings](/user-guide/tutorials/prowler-import-findings). Examples cover GitHub Actions and GitLab CI.
## Prerequisites
@@ -19,7 +19,7 @@ This cookbook demonstrates how to integrate Prowler into CI/CD pipelines so that
Prowler CLI provides the `--push-to-cloud` flag, which uploads scan results directly to Prowler Cloud after a scan completes. Combined with the `PROWLER_CLOUD_API_KEY` environment variable, this enables fully automated ingestion without manual file uploads.
-For full details on the flag and API, refer to the [Import Findings](/user-guide/tutorials/prowler-app-import-findings) documentation.
+For full details on the flag and API, refer to the [Import Findings](/user-guide/tutorials/prowler-import-findings) documentation.
The examples in this guide use AWS as the target provider, but the same approach applies to any provider supported by Prowler (Azure, GCP, Kubernetes, and others). Replace `prowler aws` with the desired provider command (e.g., `prowler gcp`, `prowler azure`) and configure the corresponding credentials in the CI/CD environment.
@@ -127,7 +127,7 @@ Add the following to `.gitlab-ci.yml`:
```yaml
prowler-scan:
- image: python:3.12-slim
+ image: python:3.13-slim
stage: test
script:
- pip install prowler
@@ -154,7 +154,7 @@ stages:
- security
.prowler-base:
- image: python:3.12-slim
+ image: python:3.13-slim
stage: security
before_script:
- pip install prowler
@@ -195,7 +195,7 @@ By default, Prowler exits with a non-zero code when it finds failing checks. Thi
* **GitLab CI**: Add `allow_failure: true` to the job
-Ingestion failures (e.g., network issues reaching Prowler Cloud) do not affect the Prowler exit code. The scan completes normally and only a warning is emitted. See [Import Findings troubleshooting](/user-guide/tutorials/prowler-app-import-findings#troubleshooting) for details.
+Ingestion failures (e.g., network issues reaching Prowler Cloud) do not affect the Prowler exit code. The scan completes normally and only a warning is emitted. See [Import Findings troubleshooting](/user-guide/tutorials/prowler-import-findings#troubleshooting) for details.
### Caching Prowler Installation
diff --git a/docs/user-guide/cookbooks/kubernetes-in-cluster.mdx b/docs/user-guide/cookbooks/kubernetes-in-cluster.mdx
index 765bcf9313..661eeb17da 100644
--- a/docs/user-guide/cookbooks/kubernetes-in-cluster.mdx
+++ b/docs/user-guide/cookbooks/kubernetes-in-cluster.mdx
@@ -2,7 +2,7 @@
title: 'Run Kubernetes In-Cluster and Send Findings to Prowler Cloud'
---
-This cookbook walks through deploying Prowler inside a Kubernetes cluster on a recurring schedule and automatically sending findings to Prowler Cloud via [Import Findings](/user-guide/tutorials/prowler-app-import-findings). By the end, security scan results from the cluster appear in Prowler Cloud without any manual file uploads.
+This cookbook walks through deploying Prowler inside a Kubernetes cluster on a recurring schedule and automatically sending findings to Prowler Cloud via [Import Findings](/user-guide/tutorials/prowler-import-findings). By the end, security scan results from the cluster appear in Prowler Cloud without any manual file uploads.
## Prerequisites
@@ -181,7 +181,7 @@ Once the job completes and findings are pushed:
2. Open the "Scans" section to verify the ingestion job status
3. Browse findings under the Kubernetes provider
-For details on the ingestion workflow and status tracking, refer to the [Import Findings](/user-guide/tutorials/prowler-app-import-findings) documentation.
+For details on the ingestion workflow and status tracking, refer to the [Import Findings](/user-guide/tutorials/prowler-import-findings) documentation.
## Tips and Troubleshooting
@@ -204,4 +204,4 @@ For details on the ingestion workflow and status tracking, refer to the [Import
--namespace prowler-ns
```
-* **Failed uploads**: If the push to Prowler Cloud fails, the scan still completes and findings are saved locally in the container. Check the [Import Findings troubleshooting section](/user-guide/tutorials/prowler-app-import-findings#troubleshooting) for common error messages.
+* **Failed uploads**: If the push to Prowler Cloud fails, the scan still completes and findings are saved locally in the container. Check the [Import Findings troubleshooting section](/user-guide/tutorials/prowler-import-findings#troubleshooting) for common error messages.
diff --git a/docs/user-guide/cookbooks/powerbi-cis-benchmarks.mdx b/docs/user-guide/cookbooks/powerbi-cis-benchmarks.mdx
index 9b9e41ed93..15985aab28 100644
--- a/docs/user-guide/cookbooks/powerbi-cis-benchmarks.mdx
+++ b/docs/user-guide/cookbooks/powerbi-cis-benchmarks.mdx
@@ -14,7 +14,7 @@ The template and its source files live in the Prowler repository under [`contrib
The setup requires the following components:
* **Microsoft Power BI Desktop:** free download from Microsoft.
-* **Prowler compliance CSV exports:** produced by Prowler CLI or downloaded from Prowler Cloud or Prowler App.
+* **Prowler compliance CSV exports:** produced by Prowler CLI or downloaded from Prowler Cloud or Prowler Local Server.
* **Local directory:** holds the CSV exports that the template ingests at load time.
## Supported CIS Benchmarks
@@ -40,7 +40,7 @@ Download and install Microsoft Power BI Desktop from the official Microsoft site
### Step 2: Generate Compliance CSV Exports
-Compliance CSV exports can be generated through Prowler CLI or downloaded from Prowler Cloud and Prowler App.
+Compliance CSV exports can be generated through Prowler CLI or downloaded from Prowler Cloud and Prowler Local Server.
#### Option A: Prowler CLI
@@ -55,7 +55,7 @@ prowler kubernetes --compliance cis_1.12_kubernetes
The compliance CSV exports are written to `output/compliance/` by default.
-#### Option B: Prowler Cloud or Prowler App
+#### Option B: Prowler Cloud or Prowler Local Server
Open the Compliance section, select the desired CIS Benchmark, and download the CSV export.
@@ -160,7 +160,7 @@ A full walkthrough is available on YouTube:
- Review the Compliance workflow across Prowler Cloud, Prowler App, and Prowler CLI.
+ Review the Compliance workflow across Prowler Cloud, Prowler Local Server, and Prowler CLI.
Explore the built-in local dashboard for Prowler CSV exports.
diff --git a/docs/user-guide/providers/alibabacloud/getting-started-alibabacloud.mdx b/docs/user-guide/providers/alibabacloud/getting-started-alibabacloud.mdx
index 36520a8f8c..ce9778a3b5 100644
--- a/docs/user-guide/providers/alibabacloud/getting-started-alibabacloud.mdx
+++ b/docs/user-guide/providers/alibabacloud/getting-started-alibabacloud.mdx
@@ -32,14 +32,14 @@ Before you begin, make sure you have:
### Step 1: Get Your Alibaba Cloud Account ID
1. Log in to the [Alibaba Cloud Console](https://home.console.alibabacloud.com/)
-2. Click on your profile avatar in the top-right corner
+2. Click your profile avatar in the top-right corner
3. Locate and copy your Account ID

### Step 2: Access Prowler Cloud
-1. Navigate to [Prowler Cloud](https://cloud.prowler.com/) or launch [Prowler App](/user-guide/tutorials/prowler-app)
+1. Navigate to [Prowler Cloud](https://cloud.prowler.com/) or launch [Prowler Local Server](/user-guide/tutorials/prowler-app)
2. Go to "Configuration" > "Providers"

diff --git a/docs/user-guide/providers/aws/authentication.mdx b/docs/user-guide/providers/aws/authentication.mdx
index 22deb6ab99..e3c20ca99d 100644
--- a/docs/user-guide/providers/aws/authentication.mdx
+++ b/docs/user-guide/providers/aws/authentication.mdx
@@ -7,9 +7,9 @@ Prowler requires AWS credentials to function properly. Authentication is availab
- Static Credentials
- Assumed Role
-When using **Assumed Role**, the Prowler UI exposes two credential sources for calling `sts:AssumeRole`. The labels differ between Prowler Cloud and self-hosted Prowler App, but both map to the same underlying credential types:
+When using **Assumed Role**, the Prowler UI exposes two credential sources for calling `sts:AssumeRole`. The labels differ between Prowler Cloud and Prowler Local Server, but both map to the same underlying credential types:
-- **AWS SDK Default** (shown as *"Prowler Cloud will assume your IAM role"* in Prowler Cloud and *"AWS SDK Default"* in self-hosted Prowler App): Prowler uses the credentials already available to the API and worker containers through the [AWS SDK default credential chain](https://boto3.amazonaws.com/v1/documentation/api/latest/guide/credentials.html). This is the default in Prowler Cloud and requires extra configuration in self-hosted Prowler App (see [Configuring AWS SDK Default for Self-Hosted Prowler App](#configuring-aws-sdk-default-for-self-hosted-prowler-app)).
+- **AWS SDK Default** (shown as *"Prowler Cloud will assume your IAM role"* in Prowler Cloud and *"AWS SDK Default"* in Prowler Local Server): Prowler uses the credentials already available to the API and worker containers through the [AWS SDK default credential chain](https://boto3.amazonaws.com/v1/documentation/api/latest/guide/credentials.html). This is the default in Prowler Cloud and requires extra configuration in Prowler Local Server (see [Configuring AWS SDK Default for Prowler Local Server](#configuring-aws-sdk-default-for-prowler-local-server)).
- **Access & Secret Key**: You paste an IAM user's `AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY`, and optionally `AWS_SESSION_TOKEN` into the form. Prowler uses those keys to call `sts:AssumeRole`.
## Required Permissions
@@ -81,9 +81,9 @@ This method grants permanent access and is the recommended setup for production
---
-## Configuring AWS SDK Default for Self-Hosted Prowler App
+## Configuring AWS SDK Default for Prowler Local Server
-When self-hosting Prowler App with Docker Compose, the API and worker containers do not have AWS credentials by default. Selecting **AWS SDK Default** without configuring those credentials produces:
+When running Prowler Local Server with Docker Compose, the API and worker containers do not have AWS credentials by default. Selecting **AWS SDK Default** without configuring those credentials produces:
```
AWSAssumeRoleError[1012]: AWS assume role error - An error occurred (InvalidClientTokenId) when calling the AssumeRole operation: The security token included in the request is invalid.
@@ -116,7 +116,7 @@ docker compose up -d --force-recreate api worker worker-beat
### Option 2: IAM Role (Host with Instance Metadata)
-If you run Prowler App on an EC2 instance, ECS task, or EKS pod with an attached IAM role that can assume the scan role, no extra configuration is needed — `boto3` resolves credentials through instance or task metadata automatically.
+If you run Prowler Local Server on an EC2 instance, ECS task, or EKS pod with an attached IAM role that can assume the scan role, no extra configuration is needed — `boto3` resolves credentials through instance or task metadata automatically.
### Trust Policy: Align `IAMPrincipal` With Your Identity
diff --git a/docs/user-guide/providers/aws/getting-started-aws.mdx b/docs/user-guide/providers/aws/getting-started-aws.mdx
index f0c5ab882a..11516e88b3 100644
--- a/docs/user-guide/providers/aws/getting-started-aws.mdx
+++ b/docs/user-guide/providers/aws/getting-started-aws.mdx
@@ -18,7 +18,7 @@ title: 'Getting Started With AWS on Prowler'
### Step 2: Access Prowler Cloud
-1. Navigate to [Prowler Cloud](https://cloud.prowler.com/) or launch [Prowler App](/user-guide/tutorials/prowler-app)
+1. Navigate to [Prowler Cloud](https://cloud.prowler.com/) or launch [Prowler Local Server](/user-guide/tutorials/prowler-app)
2. Go to "Configuration" > "Providers"

@@ -67,7 +67,7 @@ This method grants permanent access and is the recommended setup for production
For detailed instructions on how to create the role, see [Authentication > Assume Role](/user-guide/providers/aws/authentication#assume-role-recommended).
-7. Once the role is created, go to the **IAM Console**, click on the "ProwlerScan" role to open its details:
+7. Once the role is created, go to the **IAM Console**, click the "ProwlerScan" role to open its details:

@@ -75,13 +75,13 @@ For detailed instructions on how to create the role, see [Authentication > Assum

-9. Paste the ARN into the corresponding field in Prowler Cloud or Prowler App
+9. Paste the ARN into the corresponding field in Prowler Cloud or Prowler Local Server

10. Select the credential source Prowler should use to call `sts:AssumeRole`. The option label differs between deployments but both map to the same `aws-sdk-default` credential type:
- - **"Prowler Cloud will assume your IAM role"** (default in Prowler Cloud) / **"AWS SDK Default"** (in self-hosted Prowler App): Prowler uses the credentials available in the API and worker environment through the [AWS SDK default credential chain](https://boto3.amazonaws.com/v1/documentation/api/latest/guide/credentials.html). In self-hosted Prowler App, these containers have no AWS credentials by default — see [Configuring AWS SDK Default for Self-Hosted Prowler App](/user-guide/providers/aws/authentication#configuring-aws-sdk-default-for-self-hosted-prowler-app) before choosing this option, or the connection test will fail with `InvalidClientTokenId`.
+ - **"Prowler Cloud will assume your IAM role"** (default in Prowler Cloud) / **"AWS SDK Default"** (in Prowler Local Server): Prowler uses the credentials available in the API and worker environment through the [AWS SDK default credential chain](https://boto3.amazonaws.com/v1/documentation/api/latest/guide/credentials.html). In Prowler Local Server, these containers have no AWS credentials by default — see [Configuring AWS SDK Default for Prowler Local Server](/user-guide/providers/aws/authentication#configuring-aws-sdk-default-for-prowler-local-server) before choosing this option, or the connection test will fail with `InvalidClientTokenId`.
- **Access & Secret Key**: Paste an IAM user's `AWS_ACCESS_KEY_ID` and `AWS_SECRET_ACCESS_KEY` (and optional `AWS_SESSION_TOKEN`) into the form. The IAM principal must be allowed to assume the target role and must match the `IAMPrincipal` parameter of the scan role template (default: `role/prowler*`).
11. Click "Next", then "Launch Scan"
@@ -110,7 +110,7 @@ AWS accounts can also be configured using static credentials (not recommended fo
For detailed instructions on how to create the credentials, see [Authentication > Credentials](/user-guide/providers/aws/authentication#credentials).
-1. Complete the form in Prowler Cloud or Prowler App and click "Next"
+1. Complete the form in Prowler Cloud or Prowler Local Server and click "Next"

diff --git a/docs/user-guide/providers/aws/organizations.mdx b/docs/user-guide/providers/aws/organizations.mdx
index bbdedef50b..b48df1c442 100644
--- a/docs/user-guide/providers/aws/organizations.mdx
+++ b/docs/user-guide/providers/aws/organizations.mdx
@@ -2,10 +2,12 @@
title: 'AWS Organizations in Prowler'
---
+import { VersionBadge } from "/snippets/version-badge.mdx"
+
**Using Prowler Cloud?** You can onboard your entire AWS Organization through the UI with automatic account discovery, OU-aware tree selection, and bulk connection testing — no scripts or YAML files required.
-See [AWS Organizations in Prowler Cloud](/user-guide/tutorials/prowler-cloud-aws-organizations) for the full walkthrough.
+See [AWS Organizations](/user-guide/tutorials/prowler-cloud-aws-organizations) in Prowler Cloud for the full walkthrough.
Prowler can integrate with AWS Organizations to manage the visibility and onboarding of accounts centrally.
@@ -71,11 +73,43 @@ The additional fields in CSV header output are as follows:
## Deploying Prowler IAM Roles Across AWS Organizations
+
+
When onboarding multiple AWS accounts into Prowler Cloud, it is important to deploy the Prowler Scan IAM Role in each account. The most efficient way to do this across an AWS Organization is by leveraging AWS CloudFormation StackSets, which rolls out infrastructure—like IAM roles—to all accounts centrally from the Management or Delegated Admin account.
-When using Infrastructure as Code (IaC), Terraform is recommended to manage this deployment systematically.
+### Native CloudFormation StackSet Deployment (Recommended)
-### Recommended Approach
+The [Prowler Scan IAM Role CloudFormation template](https://github.com/prowler-cloud/prowler/blob/master/permissions/templates/cloudformation/prowler-scan-role.yml) can deploy the role across your entire AWS Organization on its own—no third-party modules required. When launched in the **Management Account** (or a **Delegated Administrator** account) with `DeployStackSet=true` and `EnableOrganizations=true`, it creates a service-managed CloudFormation StackSet that rolls the ProwlerScan role out to every account under the target Organizational Unit (or the organization root), and keeps new accounts covered automatically through auto-deployment.
+
+To deploy from the CloudFormation console: open **CloudFormation → Create stack → With new resources**, choose **Upload a template file** and select `prowler-scan-role.yml` (or paste its S3 URL), then set the parameters below on the **Specify stack details** step. Leave the **Configure stack options** step at its defaults.
+
+Deploy a single CloudFormation Stack in the Management Account with the following parameters:
+
+| Parameter | Description | Default |
+| --- | --- | --- |
+| `ExternalId` | External ID provided by Prowler Cloud to secure role assumption. | — |
+| `DeployLocalRole` | Create the ProwlerScan role in this (Management) account. | `true` |
+| `DeployStackSet` | Create a service-managed StackSet that deploys the role to member accounts. | `false` |
+| `AWSOrganizationalUnitId` | Target OU (`ou-xxxx-yyyyyyyy`) or organization root (`r-xxxx`) for the StackSet. Required when `DeployStackSet=true`. | `""` |
+| `DeployFromDelegatedAdmin` | Set to `true` when deploying from a Delegated Administrator account instead of the Management Account (uses `CallAs: DELEGATED_ADMIN`). | `false` |
+| `EnableOrganizations` | Add AWS Organizations permissions to the Management Account role: read-only account discovery plus the StackSet-management permissions the deployment needs. Set to `true` when deploying in the Management Account. | `false` |
+| `FailureTolerancePercentage` | Percentage of accounts in which the StackSet operation can fail before CloudFormation stops the operation. | `10` |
+| `RetainStacksOnAccountRemoval` | Keep the role in an account after it leaves the Organization or OU. | `false` |
+
+
+On the review step, select **"I acknowledge that AWS CloudFormation might create IAM resources with custom names"** — the template provisions the named `ProwlerScan` IAM role, so the stack requires the `CAPABILITY_NAMED_IAM` capability and fails without this acknowledgment. (The quick-create link handles this for you.)
+
+
+
+The service-managed StackSet does **not** deploy to the Management Account itself. Keeping `DeployLocalRole=true` ensures the role also exists there, so a single stack covers both the Management and member accounts.
+
+Trusted access for CloudFormation StackSets must be enabled in the Organization (see the note at the top of this page) before `DeployStackSet` will work.
+
+Deploying for the CLI or a self-hosted Prowler (not Prowler Cloud)? Also set `AccountId` to the account you assume the role from and `IAMPrincipal` to your identity — the defaults target Prowler Cloud. See [Aligning the trust policy with your identity](/user-guide/providers/aws/authentication#trust-policy-align-iamprincipal-with-your-identity).
+
+
+
+### Alternative: Deploy with Terraform
- **Use StackSets** from the **Management Account** (or a Delegated Admin/Security Account).
- **Use Terraform** to orchestrate the deployment.
diff --git a/docs/user-guide/providers/aws/regions-and-partitions.mdx b/docs/user-guide/providers/aws/regions-and-partitions.mdx
index 377612013e..8556ccc74e 100644
--- a/docs/user-guide/providers/aws/regions-and-partitions.mdx
+++ b/docs/user-guide/providers/aws/regions-and-partitions.mdx
@@ -64,7 +64,7 @@ When more than one source is set, precedence is:
3. `aws.disallowed_regions` in `config.yaml`
-For self-hosted App or API-triggered scans, set `PROWLER_AWS_DISALLOWED_REGIONS` in the runtime environment of the backend scan containers such as `api` and `worker`. The `ui` container does not enforce AWS region selection.
+For Prowler Local Server or API-triggered scans, set `PROWLER_AWS_DISALLOWED_REGIONS` in the runtime environment of the backend scan containers such as `api` and `worker`. The `ui` container does not enforce AWS region selection.
diff --git a/docs/user-guide/providers/aws/role-assumption.mdx b/docs/user-guide/providers/aws/role-assumption.mdx
index b714beafbd..54dd0e214a 100644
--- a/docs/user-guide/providers/aws/role-assumption.mdx
+++ b/docs/user-guide/providers/aws/role-assumption.mdx
@@ -77,6 +77,15 @@ The template requires the following parameters:
- **AccountId:** *(Optional)* AWS Account ID that will assume the role (default: Prowler Cloud account)
- **IAMPrincipal:** *(Optional)* The IAM principal allowed to assume the role (default: `role/prowler*`)
+
+From the CLI you assume the role with **your own** identity, not from Prowler Cloud. The `AccountId` and `IAMPrincipal` defaults target Prowler Cloud, so set **`AccountId`** to the account you run Prowler from and **`IAMPrincipal`** to your identity (for example `role/` or `user/`). Otherwise `sts:AssumeRole` fails with `AccessDenied`. See [Aligning the trust policy with your identity](/user-guide/providers/aws/authentication#trust-policy-align-iamprincipal-with-your-identity).
+
+
+
+To deploy the role across an entire AWS Organization from a single stack (Management Account role plus a service-managed StackSet for the member accounts), the template also accepts `DeployLocalRole`, `DeployStackSet`, `AWSOrganizationalUnitId`, `DeployFromDelegatedAdmin`, `EnableOrganizations`, `FailureTolerancePercentage`, and `RetainStacksOnAccountRemoval`. See [AWS Organizations in Prowler](/user-guide/providers/aws/organizations#native-cloudformation-stackset-deployment-recommended) for the full parameter reference.
+
+
+
When running Prowler CLI, include the External ID using the `-I/--external-id` flag:
```sh
diff --git a/docs/user-guide/providers/azure/authentication.mdx b/docs/user-guide/providers/azure/authentication.mdx
index 852e79d115..f41e9dd9b3 100644
--- a/docs/user-guide/providers/azure/authentication.mdx
+++ b/docs/user-guide/providers/azure/authentication.mdx
@@ -2,9 +2,9 @@
title: 'Azure Authentication in Prowler'
---
-Prowler for Azure supports multiple authentication types. Authentication methods vary between Prowler App and Prowler CLI:
+Prowler for Azure supports multiple authentication types. Authentication methods vary between Prowler Cloud and Prowler CLI:
-**Prowler App:**
+**Prowler Cloud:**
- [**Service Principal Application**](#service-principal-application-authentication-recommended)
@@ -210,13 +210,15 @@ For more detailed guidance on subscription management and permissions:
The following security checks require the `ProwlerRole` permissions for execution. Ensure the role is assigned to the identity assumed by Prowler before running these checks:
- `app_function_access_keys_configured`
+- `app_function_application_insights_enabled`
- `app_function_ftps_deployment_disabled`
+- `app_function_latest_runtime_version`
---
## Service Principal Application Authentication (Recommended)
-This method is required for Prowler App and recommended for Prowler CLI.
+This method is required for Prowler Cloud and recommended for Prowler CLI.
### Creating the Service Principal
For more information, see [Creating Prowler Service Principal](/user-guide/providers/azure/create-prowler-service-principal).
diff --git a/docs/user-guide/providers/azure/getting-started-azure.mdx b/docs/user-guide/providers/azure/getting-started-azure.mdx
index 66b3b14e3a..034e8eacfe 100644
--- a/docs/user-guide/providers/azure/getting-started-azure.mdx
+++ b/docs/user-guide/providers/azure/getting-started-azure.mdx
@@ -16,7 +16,7 @@ Government cloud subscriptions (Azure Government) are not currently supported, b
### Prerequisites
-Before setting up Azure in Prowler App, you need to create a Service Principal with proper permissions.
+Before setting up Azure in Prowler Cloud, you need to create a Service Principal with proper permissions.
For detailed instructions on how to create the Service Principal and configure permissions, see [Authentication > Service Principal](/user-guide/providers/azure/authentication#service-principal-application-authentication-recommended).
@@ -34,12 +34,12 @@ For detailed instructions on how to create the Service Principal and configure p
### Step 2: Access Prowler Cloud
-1. Navigate to [Prowler Cloud](https://cloud.prowler.com/) or launch [Prowler App](/user-guide/tutorials/prowler-app)
+1. Navigate to [Prowler Cloud](https://cloud.prowler.com/) or launch [Prowler Local Server](/user-guide/tutorials/prowler-app)
2. Navigate to `Configuration` > `Providers`

-3. Click on `Add Provider`
+3. Click `Add Provider`

@@ -53,14 +53,14 @@ For detailed instructions on how to create the Service Principal and configure p
### Step 3: Add Credentials to Prowler Cloud
-For Azure, Prowler App uses a service principal application to authenticate. For more information about the process of creating and adding permissions to a service principal refer to this [section](/user-guide/providers/azure/authentication). When you finish creating and adding the [Entra](/user-guide/providers/azure/create-prowler-service-principal#assigning-proper-permissions) and [Subscription](/user-guide/providers/azure/subscriptions) scope permissions to the service principal, enter the `Tenant ID`, `Client ID` and `Client Secret` of the service principal application.
+For Azure, Prowler Cloud uses a service principal application to authenticate. For more information about the process of creating and adding permissions to a service principal refer to this [section](/user-guide/providers/azure/authentication). When you finish creating and adding the [Entra](/user-guide/providers/azure/create-prowler-service-principal#assigning-proper-permissions) and [Subscription](/user-guide/providers/azure/subscriptions) scope permissions to the service principal, enter the `Tenant ID`, `Client ID` and `Client Secret` of the service principal application.
1. Go to your App Registration overview and copy the `Client ID` and `Tenant ID`

-2. Go to Prowler App and paste:
+2. Go to Prowler Cloud and paste:
- `Client ID`
- `Tenant ID`
diff --git a/docs/user-guide/providers/azure/resource-groups.mdx b/docs/user-guide/providers/azure/resource-groups.mdx
new file mode 100644
index 0000000000..0dbda18235
--- /dev/null
+++ b/docs/user-guide/providers/azure/resource-groups.mdx
@@ -0,0 +1,47 @@
+---
+title: 'Azure Resource Group Scope'
+---
+
+Prowler supports narrowing security scans to specific resource groups within Azure subscriptions. This is useful when you want to audit only a subset of resources rather than scanning an entire subscription.
+
+By default, Prowler scans all resource groups it has permission to access. Passing `--azure-resource-group` limits the scan to only the specified resource groups across all accessible subscriptions.
+
+## Configuring Resource Group Scoped Scans
+
+To restrict a scan to one or more resource groups, pass them as arguments using the `--azure-resource-group` flag:
+
+```console
+prowler azure --az-cli-auth --azure-resource-group ...
+```
+
+For example, to scan only `rg-production` and `rg-staging`:
+
+```console
+prowler azure --az-cli-auth --azure-resource-group rg-production rg-staging
+```
+
+This works with all supported authentication methods:
+
+```console
+# Service Principal
+prowler azure --sp-env-auth --azure-resource-group rg-production
+
+# Browser
+prowler azure --browser-auth --tenant-id --azure-resource-group rg-production
+
+# Managed Identity
+prowler azure --managed-identity-auth --azure-resource-group rg-production
+```
+
+## How It Works
+
+When `--azure-resource-group` is provided, Prowler validates each specified resource group against all accessible subscriptions. A resource group is included in the scan if it exists in **at least one** subscription.
+
+- If a resource group is found in one or more subscriptions, it will be scanned in those subscriptions only.
+- If a resource group is **not found in any** subscription, Prowler logs a warning and skips it.
+- If **none** of the provided resource groups are found across any subscription, Prowler logs a warning and no resource group scoped checks will run.
+- Resource group names are matched case-insensitively, so `MyGroup` and `mygroup` are treated as the same group, mirroring Azure's own behavior.
+
+
+If `--azure-resource-group` is used, checks that apply to specific resources are limited to the relevant resource groups. But if checks that apply to tenant or subscription scope (identity, policy, or subscription-level configuration checks) are involved, then these checks will run in their natural scope.
+
diff --git a/docs/user-guide/providers/azure/subscriptions.mdx b/docs/user-guide/providers/azure/subscriptions.mdx
index 8fa6c73740..efd37c13e5 100644
--- a/docs/user-guide/providers/azure/subscriptions.mdx
+++ b/docs/user-guide/providers/azure/subscriptions.mdx
@@ -17,7 +17,7 @@ prowler azure --az-cli-auth --subscription-ids
-The multi-subscription feature is available only in the CLI. In Prowler App, each scan is limited to a single subscription.
+The multi-subscription feature is available only in the CLI. In Prowler Cloud, each scan is limited to a single subscription.
## Assigning Permissions for Subscription Scans
diff --git a/docs/user-guide/providers/cloudflare/authentication.mdx b/docs/user-guide/providers/cloudflare/authentication.mdx
index fe69656a04..37e2ffafef 100644
--- a/docs/user-guide/providers/cloudflare/authentication.mdx
+++ b/docs/user-guide/providers/cloudflare/authentication.mdx
@@ -56,8 +56,8 @@ Template URLs only pre-fill the token creation form. Review the permissions, con
### Step 1: Create a User API Token
1. Log into the [Cloudflare Dashboard](https://dash.cloudflare.com).
-2. Click on the profile icon in the top right corner, then select "My Profile".
-3. Click on the **API Tokens** tab.
+2. Click the profile icon in the top right corner, then select "My Profile".
+3. Click the **API Tokens** tab.
4. Click **Create Token**, then select **Create Custom Token** at the bottom of the page.
5. Configure the token with the following settings:
- **Token name:** A descriptive name (e.g., "Prowler Security Scanner")
@@ -102,8 +102,8 @@ API Keys provide full access to the Cloudflare account. While supported, this me
### Step 1: Get the Global API Key
1. Log into the [Cloudflare Dashboard](https://dash.cloudflare.com).
-2. Click on the profile icon in the top right corner, then select "My Profile".
-3. Click on the **API Tokens** tab.
+2. Click the profile icon in the top right corner, then select "My Profile".
+3. Click the **API Tokens** tab.
4. Scroll down to the **API Keys** section.
5. Click **View** next to **Global API Key**.
6. Enter the account password to reveal the key, then copy it.
diff --git a/docs/user-guide/providers/cloudflare/getting-started-cloudflare.mdx b/docs/user-guide/providers/cloudflare/getting-started-cloudflare.mdx
index c4303b4d50..9af3dbe589 100644
--- a/docs/user-guide/providers/cloudflare/getting-started-cloudflare.mdx
+++ b/docs/user-guide/providers/cloudflare/getting-started-cloudflare.mdx
@@ -50,7 +50,7 @@ The Account ID is a 32-character hexadecimal string (e.g., `372e67954025e0ba6aaa
### Step 2: Open Prowler Cloud
-1. Go to [Prowler Cloud](https://cloud.prowler.com/) or launch [Prowler App](/user-guide/tutorials/prowler-app).
+1. Go to [Prowler Cloud](https://cloud.prowler.com/) or launch [Prowler Local Server](/user-guide/tutorials/prowler-app).
2. Navigate to "Configuration" > "Providers".

diff --git a/docs/user-guide/providers/e2enetworks/authentication.mdx b/docs/user-guide/providers/e2enetworks/authentication.mdx
new file mode 100644
index 0000000000..67d031861d
--- /dev/null
+++ b/docs/user-guide/providers/e2enetworks/authentication.mdx
@@ -0,0 +1,99 @@
+---
+title: "E2E Networks Authentication in Prowler"
+---
+
+import { VersionBadge } from "/snippets/version-badge.mdx"
+
+
+
+Prowler for E2E Networks authenticates against the E2E Networks MyAccount API using an **API key**, an **auth token**, and a numeric **project ID**. Set the API key and auth token as environment variables to avoid exposing secrets in shell history or process listings. Compatibility CLI flags are still accepted, but Prowler warns when secret values are passed directly on the command line.
+
+## Required Credentials
+
+Prowler requires read access to the E2E Networks project. The following values are needed:
+
+| Credential | Environment Variable | Description |
+|------------|----------------------|-------------|
+| API Key | `E2E_NETWORKS_API_KEY` | Identifies the MyAccount API key used for requests |
+| Auth Token | `E2E_NETWORKS_AUTH_TOKEN` | Bearer token authorizing the API key |
+| Project ID | `E2E_NETWORKS_PROJECT_ID` | Numeric ID of the project to scan |
+| Region | `E2E_NETWORKS_REGION` | Optional region to scan (defaults to all regions: Delhi, Chennai) |
+
+
+The project ID must be an integer. A missing or non-numeric project ID stops the scan before any resource is read.
+
+
+---
+
+## API Credentials
+
+### Step 1: Create the API Key and Auth Token
+
+1. Log into the [E2E Networks MyAccount portal](https://myaccount.e2enetworks.com).
+2. Open the **API Tokens** section under the account settings.
+3. Create a new API token and copy both the API key and the auth token.
+4. Copy the values immediately — the auth token is not shown again.
+
+### Step 2: Find the Project ID
+
+Select the target project in MyAccount and copy its numeric project ID from the project settings or the API context.
+
+### Step 3: Configure Authentication
+
+Export the credentials as environment variables:
+
+```bash
+export E2E_NETWORKS_API_KEY="your-api-key"
+export E2E_NETWORKS_AUTH_TOKEN="your-auth-token"
+export E2E_NETWORKS_PROJECT_ID="your-project-id"
+```
+
+Then run Prowler:
+
+```bash
+prowler e2enetworks
+```
+
+---
+
+## Verifying Authentication
+
+To confirm that Prowler can reach the E2E Networks project, run a scan against a single location:
+
+```bash
+prowler e2enetworks --region Delhi
+```
+
+A successful run reports findings for the discovered resources. A failed run displays an error message indicating the credential or connectivity issue.
+
+---
+
+## CI/CD Integration
+
+For automated pipelines, set the credentials as secret environment variables:
+
+**GitHub Actions:**
+
+```yaml
+env:
+ E2E_NETWORKS_API_KEY: ${{ secrets.E2E_NETWORKS_API_KEY }}
+ E2E_NETWORKS_AUTH_TOKEN: ${{ secrets.E2E_NETWORKS_AUTH_TOKEN }}
+ E2E_NETWORKS_PROJECT_ID: ${{ secrets.E2E_NETWORKS_PROJECT_ID }}
+
+steps:
+ - name: Run Prowler
+ run: prowler e2enetworks
+```
+
+**GitLab CI:**
+
+```yaml
+variables:
+ E2E_NETWORKS_API_KEY: $E2E_NETWORKS_API_KEY
+ E2E_NETWORKS_AUTH_TOKEN: $E2E_NETWORKS_AUTH_TOKEN
+ E2E_NETWORKS_PROJECT_ID: $E2E_NETWORKS_PROJECT_ID
+
+prowler_scan:
+ script:
+ - prowler e2enetworks
+```
diff --git a/docs/user-guide/providers/e2enetworks/getting-started-e2enetworks.mdx b/docs/user-guide/providers/e2enetworks/getting-started-e2enetworks.mdx
new file mode 100644
index 0000000000..6b67f4e32c
--- /dev/null
+++ b/docs/user-guide/providers/e2enetworks/getting-started-e2enetworks.mdx
@@ -0,0 +1,66 @@
+---
+title: 'Getting Started With E2E Networks on Prowler'
+---
+
+import { VersionBadge } from "/snippets/version-badge.mdx"
+
+
+
+Prowler for E2E Networks scans E2E Networks infrastructure for security misconfigurations across compute nodes, networking, security groups, load balancers, storage, and managed databases.
+
+
+E2E Networks support in Prowler is community-maintained. For commercial support or to request additional service coverage, [contact us](https://prowler.com/contact).
+
+
+## Prerequisites
+
+Set up authentication for E2E Networks with the [E2E Networks Authentication](/user-guide/providers/e2enetworks/authentication) guide before starting:
+
+- Create an E2E Networks API key and auth token from the MyAccount API Tokens section
+- Note the numeric project ID of the project to scan
+
+## Prowler CLI
+
+### Run Prowler for E2E Networks
+
+Once authenticated, export the credentials as environment variables and run Prowler for E2E Networks. Environment variables keep secrets out of shell history and process listings:
+
+```bash
+export E2E_NETWORKS_API_KEY="your-api-key"
+export E2E_NETWORKS_AUTH_TOKEN="your-auth-token"
+export E2E_NETWORKS_PROJECT_ID="your-project-id"
+prowler e2enetworks
+```
+
+### Run Specific Checks
+
+```bash
+prowler e2enetworks --checks node_encryption_enabled storage_block_volume_not_orphaned
+```
+
+### Run a Specific Service
+
+```bash
+prowler e2enetworks --services node
+```
+
+### Scan Specific Regions
+
+Use `--region` (aliases `--filter-region` and `-f`) to limit the scan to one or more E2E Networks regions. When the flag is omitted, Prowler reads `E2E_NETWORKS_REGION` and otherwise scans all regions (Delhi, Chennai).
+
+```bash
+prowler e2enetworks --region Delhi Chennai
+```
+
+## Available Services
+
+Prowler for E2E Networks currently supports the following services:
+
+| Service | Description |
+|---------|-------------|
+| `database` | Managed database clusters and their backup, network, and encryption settings |
+| `loadbalancer` | Application load balancers and their TLS, health-check, and protection settings |
+| `network` | Virtual Private Clouds, peering, and reserved public IP addresses |
+| `node` | Compute nodes and their encryption, protection, and network configuration |
+| `securitygroup` | Security groups and their inbound and default traffic rules |
+| `storage` | Block volumes and file storage (EFS/EPFS) with their protection settings |
diff --git a/docs/user-guide/providers/gcp/authentication.mdx b/docs/user-guide/providers/gcp/authentication.mdx
index 9447d73a20..ce01c1ba23 100644
--- a/docs/user-guide/providers/gcp/authentication.mdx
+++ b/docs/user-guide/providers/gcp/authentication.mdx
@@ -55,7 +55,7 @@ This method uses the Google Cloud CLI to authenticate and is suitable for develo
### Setup Application Default Credentials
-1. In the [GCP Console](https://console.cloud.google.com/), click on "Activate Cloud Shell"
+1. In the [GCP Console](https://console.cloud.google.com/), click "Activate Cloud Shell"

@@ -89,7 +89,7 @@ This method uses the Google Cloud CLI to authenticate and is suitable for develo

-8. Extract the following values for Prowler Cloud/App:
+8. Extract the following values for Prowler Cloud or Prowler Local Server:
- `client_id`
- `client_secret`
@@ -138,6 +138,10 @@ To keep permissions focused:
4. Continue through the wizard and finish. No principals need to be granted access in step 3 unless you want other identities to impersonate this account.
+
+To use this service account with `--organization-id`, additionally grant `roles/cloudasset.viewer` at the organization node and enable the Cloud Asset API in the service account's host project. See [Scanning a Specific GCP Organization](./organization). Without these, organization-wide scans silently fall back to listing only the projects accessible to the service account.
+
+
### Step 3: Generate a JSON Key
1. Open the newly created service account, move to the **Keys** tab, and choose **Add key > Create new key**.
diff --git a/docs/user-guide/providers/gcp/getting-started-gcp.mdx b/docs/user-guide/providers/gcp/getting-started-gcp.mdx
index e16114e19a..f40542a151 100644
--- a/docs/user-guide/providers/gcp/getting-started-gcp.mdx
+++ b/docs/user-guide/providers/gcp/getting-started-gcp.mdx
@@ -13,7 +13,7 @@ title: 'Getting Started With GCP on Prowler'
### Step 2: Access Prowler Cloud
-1. Navigate to [Prowler Cloud](https://cloud.prowler.com/) or launch [Prowler App](/user-guide/tutorials/prowler-app)
+1. Navigate to [Prowler Cloud](https://cloud.prowler.com/) or launch [Prowler Local Server](/user-guide/tutorials/prowler-app)
2. Go to "Configuration" > "Providers"

@@ -66,7 +66,7 @@ For Google Cloud, first enter your `GCP Project ID` and then select the authenti
2. Once authenticated, get the `Client ID`, `Client Secret` and `Refresh Token` from `~/.config/gcloud/application_default_credentials`.
- 3. Paste the `Client ID`, `Client Secret` and `Refresh Token` into Prowler App.
+ 3. Paste the `Client ID`, `Client Secret` and `Refresh Token` into Prowler Cloud.
diff --git a/docs/user-guide/providers/gcp/organization.mdx b/docs/user-guide/providers/gcp/organization.mdx
index 6f4f7658e5..6790be5827 100644
--- a/docs/user-guide/providers/gcp/organization.mdx
+++ b/docs/user-guide/providers/gcp/organization.mdx
@@ -11,8 +11,19 @@ prowler gcp --organization-id organization-id
```
-Ensure the credentials used have one of the following roles at the organization level:
-Cloud Asset Viewer (`roles/cloudasset.viewer`), or Cloud Asset Owner (`roles/cloudasset.owner`).
+Ensure the credentials used have one of the following roles bound **at the organization node** (not at a project): Cloud Asset Viewer (`roles/cloudasset.viewer`) or Cloud Asset Owner (`roles/cloudasset.owner`). The role must be bound directly on the organization so the Cloud Asset API can enumerate projects across the whole hierarchy.
+
+```bash
+gcloud organizations add-iam-policy-binding \
+ --member="serviceAccount:" \
+ --role="roles/cloudasset.viewer"
+```
+
+The Cloud Asset API (`cloudasset.googleapis.com`) must also be enabled in the project that owns the credentials (the service account's host project, or the quota project for user credentials):
+
+```bash
+gcloud services enable cloudasset.googleapis.com --project
+```
diff --git a/docs/user-guide/providers/github/authentication.mdx b/docs/user-guide/providers/github/authentication.mdx
index 0b5ab6b720..1546bf284a 100644
--- a/docs/user-guide/providers/github/authentication.mdx
+++ b/docs/user-guide/providers/github/authentication.mdx
@@ -12,7 +12,7 @@ Prowler offers three authentication methods. Fine-Grained Personal Access Tokens
| Method | Best For | Key Benefit |
|--------|----------|-------------|
-| [**Fine-Grained Personal Access Token**](#fine-grained-personal-access-token-recommended) | Individual users, quick setup | Simple, user-scoped access |
+| [**Fine-Grained Personal Access Token**](#fine-grained-personal-access-token-recommended-for-individual-use) | Individual users, quick setup | Simple, user-scoped access |
| [**GitHub App**](#github-app-credentials) | Organizations, automation, CI/CD | Organization-scoped, no personal account dependency |
| [**OAuth App Token**](#oauth-app-token) | Delegated user authorization | User-consented access flows |
@@ -271,7 +271,7 @@ Store the `.pem` private key securely. Anyone with this key can authenticate as
## Prowler Cloud Authentication
-For step-by-step setup instructions for Prowler Cloud, see the [Getting Started Guide](/user-guide/providers/github/getting-started-github#prowler-cloudapp).
+For step-by-step setup instructions for Prowler Cloud, see the [Getting Started Guide](/user-guide/providers/github/getting-started-github#prowler-cloud-and-prowler-local-server).
### Using Personal Access Token
@@ -301,7 +301,7 @@ For step-by-step setup instructions for Prowler Cloud, see the [Getting Started
3. Enter your GitHub App ID and upload the private key (`.pem` file).
-For complete step-by-step instructions, see the [Getting Started Guide](/user-guide/providers/github/getting-started-github#prowler-cloudapp).
+For complete step-by-step instructions, see the [Getting Started Guide](/user-guide/providers/github/getting-started-github#prowler-cloud-and-prowler-local-server).
---
diff --git a/docs/user-guide/providers/github/getting-started-github.mdx b/docs/user-guide/providers/github/getting-started-github.mdx
index 079f9a1d7b..09acdfe0c9 100644
--- a/docs/user-guide/providers/github/getting-started-github.mdx
+++ b/docs/user-guide/providers/github/getting-started-github.mdx
@@ -16,7 +16,7 @@ Prowler can scan either:
-
+
Web-based interface with centralized management
@@ -26,7 +26,7 @@ Prowler can scan either:
---
-## Prowler Cloud/App
+## Prowler Cloud and Prowler Local Server
@@ -34,7 +34,7 @@ Prowler can scan either:
### Prerequisites
-Before adding GitHub to Prowler Cloud/App, ensure you have:
+Before adding GitHub to Prowler Cloud or Prowler Local Server, ensure you have:
1. **GitHub Account Access**
- Personal GitHub account, OR
@@ -46,9 +46,9 @@ Before adding GitHub to Prowler Cloud/App, ensure you have:
- OAuth App Token
- GitHub App Credentials (Not Recommended - limited data access)
-### Step 1: Access Prowler Cloud/App
+### Step 1: Access Prowler Cloud or Prowler Local Server
-1. Navigate to [Prowler Cloud](https://cloud.prowler.com/) or launch [Prowler App](/user-guide/tutorials/prowler-app)
+1. Navigate to [Prowler Cloud](https://cloud.prowler.com/) or launch [Prowler Local Server](/user-guide/tutorials/prowler-app)
2. Go to **Configuration** → **Providers**

diff --git a/docs/user-guide/providers/googleworkspace/authentication.mdx b/docs/user-guide/providers/googleworkspace/authentication.mdx
index efed268b9a..049b4fb4ae 100644
--- a/docs/user-guide/providers/googleworkspace/authentication.mdx
+++ b/docs/user-guide/providers/googleworkspace/authentication.mdx
@@ -18,7 +18,7 @@ Prowler requests the following read-only OAuth 2.0 scopes:
| `https://www.googleapis.com/auth/admin.directory.domain.readonly` | Read access to domain information |
| `https://www.googleapis.com/auth/admin.directory.customer.readonly` | Read access to customer information (Customer ID) |
| `https://www.googleapis.com/auth/admin.directory.orgunit.readonly` | Read access to organizational unit hierarchy (identifies the root OU for policy filtering) |
-| `https://www.googleapis.com/auth/cloud-identity.policies.readonly` | Read access to domain-level application policies (required for Calendar, Gmail, Chat, and Drive service checks) |
+| `https://www.googleapis.com/auth/cloud-identity.policies.readonly` | Read access to domain-level application policies (required for Calendar, Chat, Drive, Gmail, Groups, Marketplace, Security, and Sites service checks) |
| `https://www.googleapis.com/auth/admin.directory.rolemanagement.readonly` | Read access to admin roles and role assignments |
@@ -40,7 +40,7 @@ In the [Google Cloud Console](https://console.cloud.google.com), select the targ
| API | Required For |
|-----|--------------|
| **Admin SDK API** | Directory service checks (users, roles, domains) |
-| **Cloud Identity API** | Calendar, Gmail, Chat, and Drive service checks (domain-level application policies) |
+| **Cloud Identity API** | All service checks except Directory (domain-level application policies) |
For each API:
@@ -49,7 +49,7 @@ For each API:
3. Click **Enable**
-Both APIs must be enabled in the same GCP project that hosts the Service Account. Calendar, Gmail, Chat, and Drive checks will return no findings if the Cloud Identity API is not enabled.
+Both APIs must be enabled in the same GCP project that hosts the Service Account. All service checks except Directory will return no findings if the Cloud Identity API is not enabled.
### Step 3: Create a Service Account
@@ -178,7 +178,7 @@ If Prowler connects but returns empty results or permission errors for specific
### Policy API Checks Return No Findings
-If the Directory checks run successfully but the Calendar, Gmail, Chat, or Drive checks return no findings, the Cloud Identity Policy API is not reachable for this Service Account. Verify:
+If the Directory checks run successfully but other service checks (Calendar, Chat, Drive, Gmail, Groups, Marketplace, Security, Sites) return no findings, the Cloud Identity Policy API is not reachable for this Service Account. Verify:
- The **Cloud Identity API** is enabled in the GCP project hosting the Service Account (Step 2)
- The scope `https://www.googleapis.com/auth/cloud-identity.policies.readonly` is included in the Domain-Wide Delegation OAuth scopes list in the Admin Console (Step 5)
diff --git a/docs/user-guide/providers/googleworkspace/getting-started-googleworkspace.mdx b/docs/user-guide/providers/googleworkspace/getting-started-googleworkspace.mdx
index 8931c43ebd..01e6e89986 100644
--- a/docs/user-guide/providers/googleworkspace/getting-started-googleworkspace.mdx
+++ b/docs/user-guide/providers/googleworkspace/getting-started-googleworkspace.mdx
@@ -42,7 +42,7 @@ The Customer ID starts with the letter "C" followed by alphanumeric characters (
### Step 2: Open Prowler Cloud
-1. Go to [Prowler Cloud](https://cloud.prowler.com/) or launch [Prowler App](/user-guide/tutorials/prowler-app).
+1. Go to [Prowler Cloud](https://cloud.prowler.com/) or launch [Prowler Local Server](/user-guide/tutorials/prowler-app).
2. Navigate to "Configuration" > "Providers".

diff --git a/docs/user-guide/providers/iac/getting-started-iac.mdx b/docs/user-guide/providers/iac/getting-started-iac.mdx
index d1e978dc75..9a941c633d 100644
--- a/docs/user-guide/providers/iac/getting-started-iac.mdx
+++ b/docs/user-guide/providers/iac/getting-started-iac.mdx
@@ -24,7 +24,7 @@ Prowler IaC provider scans the following Infrastructure as Code configurations f
## How It Works
-- Prowler App leverages [Trivy](https://trivy.dev/docs/latest/guide/coverage/iac/#scanner) to scan local directories (or specified paths) for supported IaC files, or scans remote repositories.
+- Prowler Cloud leverages [Trivy](https://trivy.dev/docs/latest/guide/coverage/iac/#scanner) to scan local directories (or specified paths) for supported IaC files, or scans remote repositories.
- No cloud credentials or authentication are required for local scans.
- For remote repository scans, authentication can be provided via [git URL](https://git-scm.com/docs/git-clone#_git_urls), CLI flags or environment variables.
- Check the [IaC Authentication](/user-guide/providers/iac/authentication) page for more details.
@@ -37,11 +37,11 @@ Prowler IaC provider scans the following Infrastructure as Code configurations f
### Supported Scanners
-Scanner selection is not configurable in Prowler App. Default scanners, misconfig and secret, run automatically during each scan.
+Scanner selection is not configurable in Prowler Cloud. Default scanners, misconfig and secret, run automatically during each scan.
-### Step 1: Access Prowler Cloud/App
+### Step 1: Access Prowler Cloud or Prowler Local Server
-1. Navigate to [Prowler Cloud](https://cloud.prowler.com/) or launch [Prowler App](/user-guide/tutorials/prowler-app)
+1. Navigate to [Prowler Cloud](https://cloud.prowler.com/) or launch [Prowler Local Server](/user-guide/tutorials/prowler-app)
2. Go to "Configuration" > "Providers"

diff --git a/docs/user-guide/providers/image/getting-started-image.mdx b/docs/user-guide/providers/image/getting-started-image.mdx
index b9c305d0ef..6382f10e5c 100644
--- a/docs/user-guide/providers/image/getting-started-image.mdx
+++ b/docs/user-guide/providers/image/getting-started-image.mdx
@@ -33,7 +33,7 @@ Prowler Cloud does not support scanner selection. The vulnerability, secret, and
### Step 1: Access Prowler Cloud
-1. Navigate to [Prowler Cloud](https://cloud.prowler.com/) or launch [Prowler App](/user-guide/tutorials/prowler-app)
+1. Navigate to [Prowler Cloud](https://cloud.prowler.com/) or launch [Prowler Local Server](/user-guide/tutorials/prowler-app)
2. Navigate to "Configuration" > "Providers"

diff --git a/docs/user-guide/providers/kubernetes/getting-started-k8s.mdx b/docs/user-guide/providers/kubernetes/getting-started-k8s.mdx
index 9ca791110f..c62ec2b8a1 100644
--- a/docs/user-guide/providers/kubernetes/getting-started-k8s.mdx
+++ b/docs/user-guide/providers/kubernetes/getting-started-k8s.mdx
@@ -4,9 +4,9 @@ title: 'Getting Started with Kubernetes'
## Prowler Cloud
-### Step 1: Access Prowler Cloud/App
+### Step 1: Access Prowler Cloud or Prowler Local Server
-1. Navigate to [Prowler Cloud](https://cloud.prowler.com/) or launch [Prowler App](/user-guide/tutorials/prowler-app)
+1. Navigate to [Prowler Cloud](https://cloud.prowler.com/) or launch [Prowler Local Server](/user-guide/tutorials/prowler-app)
2. Go to "Configuration" > "Providers"

@@ -21,7 +21,11 @@ title: 'Getting Started with Kubernetes'
### Step 2: Configure Kubernetes Authentication
-For Kubernetes, Prowler App uses a `kubeconfig` file to authenticate. Paste the contents of your `kubeconfig` file into the `Kubeconfig content` field.
+For Kubernetes, Prowler Cloud uses a `kubeconfig` file to authenticate. Paste the contents of your `kubeconfig` file into the `Kubeconfig content` field.
+
+
+Kubeconfigs that use `users[].user.exec` authentication are not supported in Prowler Cloud or Prowler Local Server. For security reasons, Prowler Cloud does not run commands declared by uploaded kubeconfigs. Use kubeconfig credentials that do not rely on `exec` authentication, such as the ServiceAccount token flow documented below.
+
By default, the `kubeconfig` file is located at `~/.kube/config`.
diff --git a/docs/user-guide/providers/linode/authentication.mdx b/docs/user-guide/providers/linode/authentication.mdx
new file mode 100644
index 0000000000..3ebb7ef326
--- /dev/null
+++ b/docs/user-guide/providers/linode/authentication.mdx
@@ -0,0 +1,97 @@
+---
+title: "Linode Authentication in Prowler"
+---
+
+import { VersionBadge } from "/snippets/version-badge.mdx"
+
+
+
+Prowler for Linode uses a **Personal Access Token** (PAT) for authentication. Prowler reads the token **exclusively** from the `LINODE_TOKEN` environment variable, so the secret is never exposed in shell history or process listings. There are no credential CLI flags.
+
+## Required Permissions
+
+Prowler requires read-only access to your Linode account. The following OAuth scopes are needed on the Personal Access Token:
+
+| Scope | Access | Description |
+|-------|--------|-------------|
+| `account` | `Read Only` | Required to list users and verify account identity |
+| `linodes` | `Read Only` | Required to list instances and their configurations |
+| `firewall` | `Read Only` | Required to list firewalls and their rules |
+
+
+Ensure the token has all required scopes. Missing permissions will cause some checks to fail or return incomplete results.
+
+
+---
+
+## Personal Access Token
+
+### Step 1: Create a Personal Access Token
+
+1. Log into the [Linode Cloud Manager](https://cloud.linode.com).
+2. Click your username in the top-right corner, then select **API Tokens** under the "My Profile" section.
+3. Click **Create a Personal Access Token**.
+4. Configure the token:
+ - **Label:** A descriptive name (e.g., "Prowler Security Scanner")
+ - **Expiry:** Set an appropriate expiration (e.g., 6 months)
+ - **Permissions:** Set the following scopes to **Read Only**:
+ - Account
+ - Linodes
+ - Firewall
+ - All other scopes can be set to **No Access**
+5. Click **Create Token**.
+6. Copy the token immediately — it will not be shown again.
+
+### Step 2: Configure Authentication
+
+Set the `LINODE_TOKEN` environment variable:
+
+```bash
+export LINODE_TOKEN="your-personal-access-token"
+```
+
+Then run Prowler:
+
+```bash
+prowler linode
+```
+
+---
+
+## Verifying Authentication
+
+To verify that Prowler can connect to your Linode account, run:
+
+```bash
+prowler linode --list-checks
+```
+
+If authentication succeeds, you will see a list of available checks. If it fails, Prowler will display an error message indicating the credentials issue.
+
+---
+
+## CI/CD Integration
+
+For automated pipelines, set the token as a secret environment variable:
+
+**GitHub Actions:**
+
+```yaml
+env:
+ LINODE_TOKEN: ${{ secrets.LINODE_TOKEN }}
+
+steps:
+ - name: Run Prowler
+ run: prowler linode
+```
+
+**GitLab CI:**
+
+```yaml
+variables:
+ LINODE_TOKEN: $LINODE_TOKEN
+
+prowler_scan:
+ script:
+ - prowler linode
+```
diff --git a/docs/user-guide/providers/linode/getting-started-linode.mdx b/docs/user-guide/providers/linode/getting-started-linode.mdx
new file mode 100644
index 0000000000..128fdf1c90
--- /dev/null
+++ b/docs/user-guide/providers/linode/getting-started-linode.mdx
@@ -0,0 +1,61 @@
+---
+title: 'Getting Started With Linode on Prowler'
+---
+
+import { VersionBadge } from "/snippets/version-badge.mdx"
+
+
+
+Prowler for Linode scans your Linode infrastructure for security misconfigurations, including compute settings, networking rules, user account security, and more.
+
+
+Linode support in Prowler is community-maintained. For commercial support or to request additional service coverage, [contact us](https://prowler.com/contact).
+
+
+## Prerequisites
+
+Set up authentication for Linode with the [Linode Authentication](/user-guide/providers/linode/authentication) guide before starting:
+
+- Create a Linode Personal Access Token with read-only permissions
+- The token requires at minimum: `account:read_only`, `linodes:read_only`, and `firewall:read_only` scopes
+
+## Prowler CLI
+
+### Run Prowler for Linode
+
+Once authenticated with a Personal Access Token, set the `LINODE_TOKEN` environment variable and run Prowler for Linode. Prowler reads the token exclusively from the environment variable, so the secret is never exposed in shell history or process listings:
+
+```bash
+export LINODE_TOKEN="your-personal-access-token"
+prowler linode
+```
+
+### Run Specific Checks
+
+```bash
+prowler linode --checks compute_instance_backups_enabled compute_instance_watchdog_enabled
+```
+
+### Run a Specific Service
+
+```bash
+prowler linode --services networking
+```
+
+### Scan Specific Regions
+
+Use `--region` (alias `--filter-region` / `-f`) to limit the scan to one or more Linode regions. Region-less resources (account administration and Cloud Firewalls) are always scanned; only regional resources such as instances are filtered. When the flag is omitted, all regions are scanned.
+
+```bash
+prowler linode --region eu-central us-east
+```
+
+## Available Services
+
+Prowler for Linode currently supports the following services:
+
+| Service | Description |
+|---------|-------------|
+| `administration` | Account administration includes users and access controls such as two-factor authentication |
+| `compute` | Compute includes Linode instances and their workload configuration |
+| `networking` | Networking includes Cloud Firewalls and their stateful network rules |
diff --git a/docs/user-guide/providers/microsoft365/getting-started-m365.mdx b/docs/user-guide/providers/microsoft365/getting-started-m365.mdx
index a21b796b5c..fd791cbbca 100644
--- a/docs/user-guide/providers/microsoft365/getting-started-m365.mdx
+++ b/docs/user-guide/providers/microsoft365/getting-started-m365.mdx
@@ -41,7 +41,7 @@ Set up authentication for Microsoft 365 with the [Microsoft 365 Authentication](
### Step 2: Open Prowler Cloud
-1. Go to [Prowler Cloud](https://cloud.prowler.com/) or launch [Prowler App](/user-guide/tutorials/prowler-app).
+1. Go to [Prowler Cloud](https://cloud.prowler.com/) or launch [Prowler Local Server](/user-guide/tutorials/prowler-app).
2. Navigate to "Configuration" > "Providers".

diff --git a/docs/user-guide/providers/mongodbatlas/getting-started-mongodbatlas.mdx b/docs/user-guide/providers/mongodbatlas/getting-started-mongodbatlas.mdx
index c68bfac9c2..2fb415a11b 100644
--- a/docs/user-guide/providers/mongodbatlas/getting-started-mongodbatlas.mdx
+++ b/docs/user-guide/providers/mongodbatlas/getting-started-mongodbatlas.mdx
@@ -15,12 +15,12 @@ Before you begin, make sure you have:
3. An **API Key pair** (public and private keys) with appropriate permissions:
- **Organization Read Only**: Provides read-only access to everything in the organization, including all projects in the organization. This permission is sufficient for most security checks.
- **Organization Owner**: Required to audit the [Auditing configuration](https://www.mongodb.com/docs/api/doc/atlas-admin-api-v2/group/endpoint-auditing) for projects. Database auditing tracks database operations and security events, including authentication attempts, data definition language (DDL) changes, user and role modifications, and privilege grants. This configuration is essential for security monitoring, forensics, and compliance. Without **Organization Owner** permission, the `projects_auditing_enabled` check cannot retrieve the audit configuration status.
-4. Prowler App access (cloud or self-hosted) or the Prowler CLI (`pip install prowler`).
+4. Access to Prowler Cloud or Prowler Local Server, or Prowler CLI (`pip install prowler`).
For detailed instructions on creating API keys, see the [MongoDB Atlas authentication guide](./authentication.mdx).
-If **Require IP Access List for the Atlas Administration API** is enabled in your organization settings, you **must** add the IP address of the host running Prowler (or the public IP of Prowler Cloud) to the organization IP Access List or Atlas will reject every API call. You can manage this under **Settings → Organization Settings → Security**. See step 7 of the [authentication guide](./authentication.mdx) for detailed instructions, and refer to the [Prowler Cloud public IP list](../../tutorials/prowler-cloud-public-ips) when using Prowler Cloud.
+If **Require IP Access List for the Atlas Administration API** is enabled in the organization settings, add the IP address of the host running Prowler (or the public IP of Prowler Cloud) to the organization IP Access List or Atlas will reject every API call. Manage this under **Settings → Organization Settings → Security**. See step 7 of the [authentication guide](./authentication.mdx) for detailed instructions, and refer to the [Prowler Cloud egress IPs](/security/networking) when using Prowler Cloud.
@@ -53,7 +53,7 @@ If **Require IP Access List for the Atlas Administration API** is enabled in you
### Step 3: Test the connection and start scanning
-1. Click **Test connection** to ensure Prowler App can reach the Atlas API.
+1. Click **Test connection** to ensure Prowler Cloud can reach the Atlas API.
2. Save the credentials. The provider will appear in the list with its current connection status.
3. Launch a scan from the provider row or from the **Scans** page.

@@ -108,6 +108,6 @@ prowler mongodbatlas --atlas-project-id
- Combine flags (for example, `--checks` or `--services`) just like with other providers.
- Use `--output-modes` to export findings in JSON, CSV, ASFF, etc.
-- Rotate API keys regularly and update the stored credentials in Prowler App to maintain connectivity.
+- Rotate API keys regularly and update the stored credentials in Prowler Cloud to maintain connectivity.
For more examples (filters, outputs, scheduling), refer back to the [MongoDB Atlas documentation hub](./authentication.mdx) and the main Prowler CLI usage guide.
diff --git a/docs/user-guide/providers/oci/authentication.mdx b/docs/user-guide/providers/oci/authentication.mdx
index f977a80eb2..cc0e75cded 100644
--- a/docs/user-guide/providers/oci/authentication.mdx
+++ b/docs/user-guide/providers/oci/authentication.mdx
@@ -55,7 +55,7 @@ After running `oci session authenticate`, you need to manually add your user OCI
**Get your user OCID from the OCI Console:**
-Navigate to: **Identity & Security** → **Users** → Click on your username → Copy the OCID
+Navigate to: **Identity & Security** → **Users** → Click your username → Copy the OCID

@@ -434,7 +434,7 @@ prowler oci --oci-config-file /path/to/config
**Cause**: Insufficient IAM permissions
-**Solution**: Add required policies (see [Required Permissions](./getting-started-oci.md#required-permissions))
+**Solution**: Add required policies (see [Required Permissions](/user-guide/providers/oci/getting-started-oci#required-permissions))
### Configuration Validation
diff --git a/docs/user-guide/providers/oci/getting-started-oci.mdx b/docs/user-guide/providers/oci/getting-started-oci.mdx
index fc9fb620bf..6a8c8de3e9 100644
--- a/docs/user-guide/providers/oci/getting-started-oci.mdx
+++ b/docs/user-guide/providers/oci/getting-started-oci.mdx
@@ -6,7 +6,7 @@ Prowler supports security scanning of Oracle Cloud Infrastructure (OCI) environm
## Prowler Cloud
-The following steps apply to Prowler Cloud and the self-hosted Prowler App.
+The following steps apply to Prowler Cloud and Prowler Local Server.
### Step 1: Collect OCI Identifiers
1. Sign in to the [OCI Console](https://cloud.oracle.com/) and open **Tenancy Details** to copy the Tenancy OCID.
@@ -15,14 +15,14 @@ The following steps apply to Prowler Cloud and the self-hosted Prowler App.
4. Note the **Region** identifier to scan (for example, `us-ashburn-1`).
### Step 2: Access Prowler Cloud
-1. Navigate to [Prowler Cloud](https://cloud.prowler.com/) or launch [Prowler App](/user-guide/tutorials/prowler-app).
+1. Navigate to [Prowler Cloud](https://cloud.prowler.com/) or launch [Prowler Local Server](/user-guide/tutorials/prowler-app).
2. Go to **Configuration** → **Providers** and click **Add Provider**.

3. Select **Oracle Cloud** and enter the **Tenancy OCID** and an optional alias, then choose **Next**.

### Step 3: Add OCI API Key Credentials
-Prowler App connects to OCI with API key credentials. Provide:
+Prowler Cloud connects to OCI with API key credentials. Provide:
- **User OCID** for the API key owner
- **Fingerprint** of the API key
@@ -58,7 +58,7 @@ Before you begin, ensure you have:
### Authentication
-Prowler supports multiple authentication methods for OCI. For detailed authentication setup, see the [OCI Authentication Guide](./authentication).
+Prowler supports multiple authentication methods for OCI. For detailed authentication setup, see the [OCI Authentication Guide](/user-guide/providers/oci/authentication).
**Note:** OCI Session Authentication and Config File Authentication both use the same `~/.oci/config` file. The difference is how the config file is generated - automatically via browser (session auth) or manually with API keys.
@@ -78,7 +78,7 @@ The easiest and most secure method is using OCI session authentication, which au
**Get your user OCID from the OCI Console:**
- Navigate to: **Identity & Security** → **Users** → Click on your username → Copy the OCID
+ Navigate to: **Identity & Security** → **Users** → Click your username → Copy the OCID

@@ -107,7 +107,7 @@ The easiest and most secure method is using OCI session authentication, which au
#### Alternative: Manual API Key Setup
-If you prefer to manually generate API keys instead of using browser-based session authentication, see the detailed instructions in the [Authentication Guide](./authentication#config-file-authentication-manual-api-key-setup).
+If you prefer to manually generate API keys instead of using browser-based session authentication, see the detailed instructions in the [Authentication Guide](/user-guide/providers/oci/authentication#config-file-authentication-manual-api-key-setup).
**Note:** Both methods use the same `~/.oci/config` file - the difference is that manual setup uses static API keys while session authentication uses temporary session tokens.
diff --git a/docs/user-guide/providers/okta/authentication.mdx b/docs/user-guide/providers/okta/authentication.mdx
index a30bae3810..8f664160a2 100644
--- a/docs/user-guide/providers/okta/authentication.mdx
+++ b/docs/user-guide/providers/okta/authentication.mdx
@@ -20,7 +20,7 @@ Prowler authenticates to Okta as a **service application** using **OAuth 2.0 wit
| Method | Status | Use Case |
|---|---|---|
-| **OAuth 2.0 (private-key JWT)** | Supported | Production scans, CI/CD, Prowler App. |
+| **OAuth 2.0 (private-key JWT)** | Supported | Production scans, CI/CD, Prowler Cloud. |
The private-key JWT flow is the only supported authentication method in the initial release. The service application proves possession of a private key on every token request; Okta returns a short-lived access token, refreshed automatically by the SDK.
@@ -30,23 +30,65 @@ If a different authentication method is needed (SSWS API token, OAuth with user
### Required OAuth Scopes
-For the initial check (`signon_global_session_idle_timeout_15min`) only one scope is required:
+The bundled checks require the following read-only scopes:
- `okta.policies.read`
+- `okta.brands.read`
+- `okta.apps.read`
+- `okta.authenticators.read`
+- `okta.networkZones.read`
+- `okta.apiTokens.read`
+- `okta.roles.read`
+- `okta.groups.read`
+- `okta.logStreams.read`
+- `okta.idps.read`
-Additional scopes will be needed as more services and checks are added, this are the current ones needed:
+Additional scopes will be needed as more services and checks are added. These are the current ones needed:
| Scope | Used by |
|---|---|
-| `okta.policies.read` | Sign-on / password / authentication policies |
+| `okta.policies.read` | Sign-on, password, authentication, and `USER_LIFECYCLE` (Workflow > Automations) policies |
+| `okta.brands.read` | Sign-in page customizations (DOD Notice and Consent Banner check) |
+| `okta.apps.read` | First-party app settings (Okta Admin Console session), integrated app inventory, and the Authentication Policies bound to Okta applications |
+| `okta.authenticators.read` | Okta authenticator configuration, including Okta Verify and Smart Card IdP |
+| `okta.networkZones.read` | Network Zone inventory, anonymized-proxy blocklist checks, and API token Network Zone validation |
+| `okta.apiTokens.read` | API token metadata and token network conditions |
+| `okta.roles.read` | Admin role assignments for API token owners (both direct and group-inherited) |
+| `okta.groups.read` | Group memberships of API token owners, used to resolve admin roles inherited via group assignment (e.g. Super Admin granted through the default admin group) |
+| `okta.logStreams.read` | Log Stream configuration (`/api/v1/logStreams`) |
+| `okta.idps.read` | Identity Providers, including Smart Card (X509) IdPs (`/api/v1/idps`) |
### Required Admin Role
-The service application must be assigned the built-in **Read-Only Administrator** role.
+The service application must be assigned **one** of the following Okta admin roles:
-Okta's Management API enforces a two-layer authorization model: an OAuth **scope** decides which API endpoints the token can call, and an **admin role** decides whether the call returns data. With only a scope granted, the token mint succeeds but every read returns `403 Forbidden`. The Read-Only Administrator role is the minimum that lets the granted `okta.*.read` scopes actually return configuration data to Prowler's checks — without it, the credential probe at provider startup fails and the scan never gets to evaluate any check.
+- **Read-Only Administrator** — covers every `signon` check and runs `application_authentication_policy_network_zone_enforced` against the apps it can see. **Visibility caveat:** under Read-Only Administrator the `/api/v1/apps` endpoint returns only the apps the service application is itself assigned to — typically just the service app's own row (for example, `Prowler Scanner`). The check still produces a finding for that app, but the rest of the org's app inventory is invisible at this role level.
+- **Super Administrator** — required additionally to evaluate five application-service checks that target Okta's first-party apps (Okta Admin Console, Okta Dashboard). With Super Administrator, `application_authentication_policy_network_zone_enforced` also evaluates the full org-wide app inventory instead of the service-app-only slice.
-Read-Only Administrator is intentionally the narrowest role that satisfies this requirement and aligns with the least-privilege guidance in DISA STIG.
+Okta's Management API enforces a two-layer authorization model: an OAuth **scope** decides which API endpoints the token can call, and an **admin role** decides whether the call returns data. With only a scope granted, the token mint succeeds but every read returns `403 Forbidden`. Read-Only Administrator is the minimum role that lets the granted `okta.*.read` scopes return configuration data to Prowler's checks; without it, the credential probe at provider startup fails and the scan never gets to evaluate any check.
+
+#### When Super Administrator is required
+
+Four checks need to resolve the Authentication Policy bound to Okta's first-party apps (Okta Admin Console, Okta Dashboard) and depend on `/api/v1/apps` returning those system apps — which Okta restricts to Super Administrator:
+
+| Check | STIG |
+|---|---|
+| `application_admin_console_mfa_required` | V-273193 |
+| `application_admin_console_phishing_resistant_authentication` | V-273191 |
+| `application_dashboard_mfa_required` | V-273194 |
+| `application_dashboard_phishing_resistant_authentication` | V-273190 |
+
+Okta filters the first-party apps (`saasure`, `okta_enduser`) out of `/api/v1/apps` for every role below Super Administrator, so `okta.apps.read` alone is not enough. The `okta.apps.manageFirstPartyApps` permission exists only in the paid Okta Identity Governance role `ACCESS_REQUESTS_ADMIN` and cannot be added to custom roles ([Okta Permissions Catalog](https://developer.okta.com/docs/api/openapi/okta-management/guides/permissions)).
+
+A fifth check — `application_admin_console_session_idle_timeout_15min` (STIG V-273187) — also requires Super Administrator: it calls `GET /api/v1/first-party-app-settings/admin-console`, which returns `403 E0000006` for every role below Super Administrator.
+
+`user_inactivity_automation_35d_enabled` (STIG V-273188) reads `USER_LIFECYCLE` policies (`list_policies(type='USER_LIFECYCLE')`) using the `okta.policies.read` scope. The Read-Only Administrator role is enough to list them; no Super Administrator requirement.
+
+When the service app runs with Read-Only Administrator, the checks listed in this section return **MANUAL** instead of PASS/FAIL — the rest of the scan keeps running.
+
+
+Read-Only Administrator stays the recommended default for the least-privilege framing that aligns with DISA STIG. Assign Super Administrator on a separate run when full coverage of the first-party app checks is needed.
+
## Step-by-Step Setup
@@ -96,19 +138,21 @@ Okta displays the private key **only once**. If you close the modal without copy
### 5. Grant the required OAuth scopes
-On the app, open the **Okta API Scopes** tab and click **Grant** on every scope Prowler needs. For the initial release, granting only `okta.policies.read` is sufficient.
+On the app, open the **Okta API Scopes** tab and click **Grant** on every scope Prowler needs. The bundled checks require `okta.policies.read`, `okta.brands.read`, `okta.apps.read`, `okta.authenticators.read`, `okta.networkZones.read`, `okta.apiTokens.read`, `okta.roles.read`, `okta.groups.read`, `okta.logStreams.read`, and `okta.idps.read`.

-### 6. Assign the Read-Only Administrator role
+### 6. Assign an admin role
On the app, open the **Admin roles** tab and click **Edit assignments → Add assignment**:
-- **Role:** Read-Only Administrator
+- **Role:** Read-Only Administrator (default) — covers every `signon` check and runs the per-app network-zone check against the apps the service app can see (typically only the service app's own row).
- **Resources:** All resources
Save the changes.
+To additionally evaluate the first-party application checks (Okta Admin Console / Okta Dashboard idle timeout, MFA, and phishing-resistant authentication) and to widen the per-app network-zone check to the full org-wide app inventory, assign **Super Administrator** instead. Without Super Administrator, the five first-party checks return MANUAL and the network-zone check is limited to the service app's own visibility — the rest of the scan still runs. See [Required Admin Role](#required-admin-role) for the full breakdown.
+

### 7. [Optional] Verify DPoP setting
@@ -130,8 +174,8 @@ export OKTA_PRIVATE_KEY_FILE="/secure/path/to/prowler-okta.pem"
# or
export OKTA_PRIVATE_KEY="$(cat /secure/path/to/prowler-okta.pem)"
-# Optional — defaults to "okta.policies.read"
-export OKTA_SCOPES="okta.policies.read"
+# Optional — defaults to "okta.policies.read,okta.brands.read,okta.apps.read,okta.authenticators.read,okta.networkZones.read,okta.apiTokens.read,okta.roles.read,okta.groups.read,okta.logStreams.read,okta.idps.read"
+export OKTA_SCOPES="okta.policies.read,okta.brands.read,okta.apps.read,okta.authenticators.read,okta.networkZones.read,okta.apiTokens.read,okta.roles.read,okta.groups.read,okta.logStreams.read,okta.idps.read"
uv run python prowler-cli.py okta
```
@@ -172,8 +216,12 @@ Prowler validates credentials at startup by listing one sign-on policy. This err
Raised when the credential probe succeeds at the OAuth layer but the request is rejected because the service app lacks the required scope or admin role:
-- **`invalid_scope`** — the `okta.policies.read` scope is not granted on the service app. Grant it from **Okta API Scopes**.
-- **`Forbidden` / `not authorized`** — the **Read-Only Administrator** role is not assigned to the service app. Assign it from **Admin roles**.
+- **`invalid_scope`** — one of the requested scopes (`okta.policies.read`, `okta.brands.read`, `okta.apps.read`, `okta.authenticators.read`, `okta.networkZones.read`, `okta.apiTokens.read`, `okta.roles.read`, `okta.groups.read`, `okta.logStreams.read`, and `okta.idps.read`) is not granted on the service app. Grant the missing scope from **Okta API Scopes**.
+- **`Forbidden` / `not authorized`** — no admin role is assigned to the service app. Assign **Read-Only Administrator** (or **Super Administrator** for the first-party application checks) from **Admin roles**.
+
+### Application-service checks return MANUAL on first-party apps
+
+When the service app runs with Read-Only Administrator, the five application-service checks targeting the Okta Admin Console and Okta Dashboard return MANUAL. This is by design — Okta restricts the underlying endpoints (`/api/v1/first-party-app-settings/{appName}` and `/api/v1/apps` for first-party app `name` values `saasure` / `okta_enduser`) to **Super Administrator**. Assign the Super Administrator role to the service app to evaluate those checks. See [Required Admin Role](#required-admin-role) for the full list.
### `invalid_dpop_proof`
diff --git a/docs/user-guide/providers/okta/getting-started-okta.mdx b/docs/user-guide/providers/okta/getting-started-okta.mdx
index 6486736bd5..dbe63807d7 100644
--- a/docs/user-guide/providers/okta/getting-started-okta.mdx
+++ b/docs/user-guide/providers/okta/getting-started-okta.mdx
@@ -12,7 +12,7 @@ Set up authentication for Okta with the [Okta Authentication](/user-guide/provid
- An Okta organization. The UI examples below use **Identity Engine** terminology such as **Global Session Policy**; Classic Engine exposes the equivalent sign-on policy concepts under older names.
- A **Super Administrator** account on that organization for the one-time service-app setup.
-- An **API Services** app integration in the Okta Admin Console with the `okta.policies.read` scope granted and the **Read-Only Administrator** role assigned.
+- An **API Services** app integration in the Okta Admin Console with the `okta.policies.read`, `okta.brands.read`, `okta.apps.read`, `okta.authenticators.read`, `okta.networkZones.read`, `okta.apiTokens.read`, `okta.roles.read`, `okta.groups.read`, `okta.logStreams.read`, and `okta.idps.read` scopes granted and an admin role assigned. **Read-Only Administrator** covers the Sign-On, Network, API Token, User, System Log, and Identity Provider checks, and runs the per-app application network-zone check against the apps the service app can see (under Read-Only Administrator that is typically only the service app's own row — the rest of the org's app inventory stays invisible). **Super Administrator** is required additionally to evaluate the five first-party application checks (Okta Admin Console / Okta Dashboard idle timeout, MFA, phishing-resistant authentication) and to widen the application network-zone check to the full app inventory — see [Okta Authentication](/user-guide/providers/okta/authentication#required-admin-role) for the full breakdown.
- Python 3.10+ and Prowler 5.27.0 or later installed locally.
@@ -26,10 +26,51 @@ Set up authentication for Okta with the [Okta Authentication](/user-guide/provid
## Prowler Cloud
+
+
+### Step 1: Add the Provider
+
+1. Go to [Prowler Cloud](https://cloud.prowler.com/) or launch [Prowler Local Server](/user-guide/tutorials/prowler-app).
+2. Navigate to "Configuration" > "Providers".
+
+ 
+
+3. Click "Add Provider".
+
+ 
+
+4. Select "Okta".
+
+ 
+
+5. Enter the **Org Domain** of the target Okta organization and an optional alias, then click "Next".
+
+ 
+
-Prowler Cloud onboarding for Okta is coming soon. Track the [Prowler GitHub repository](https://github.com/prowler-cloud/prowler) for release updates. Use the [Prowler CLI](#prowler-cli) workflow below in the meantime.
+The Org Domain must be the bare hostname of an Okta-managed organization — for example, `acme.okta.com`, `acme.oktapreview.com`, `acme.okta-emea.com`, `acme.okta-gov.com`, `acme.okta.mil`, `acme.okta-miltest.com`, or `acme.trex-govcloud.com`. Omit the `https://` scheme, any path, and any trailing slash.
+### Step 2: Provide Credentials
+
+Prowler Cloud authenticates to Okta with the **OAuth 2.0 Private Key JWT** flow exposed by an Okta **API Services** app. The service application, keypair, scope grants, and Read-Only Administrator role are set up once in the Okta Admin Console — full instructions are in the [Okta Authentication](/user-guide/providers/okta/authentication) guide.
+
+1. Enter the **Client ID** of the Okta API Services app (for example, `0oa123456789abcdef`).
+2. Paste the **Private Key** whose matching public key (JWK) is registered on the service app. Both PEM-encoded RSA keys and JWK JSON documents are accepted.
+3. Click "Next".
+
+ 
+
+
+The private key is transmitted over TLS and stored as an encrypted secret in the backend. Rotate or revoke the matching public key from the Okta Admin Console at any time to invalidate the credential without changes on the Prowler side.
+
+
+### Step 3: Launch the Scan
+
+1. Review the connection summary. Prowler Cloud runs a credential probe against the Okta Management API before saving — a failed probe surfaces the underlying Okta error (`invalid_scope`, `Forbidden`, invalid credentials, etc.) so the configuration can be corrected before the first scan.
+2. Choose the scan schedule: run a single scan or set up daily scans (every 24 hours).
+3. Click **Launch Scan** to start auditing the Okta organization.
+
---
## Prowler CLI
@@ -44,8 +85,8 @@ Follow the [Okta Authentication](/user-guide/providers/okta/authentication) guid
export OKTA_ORG_DOMAIN="acme.okta.com"
export OKTA_CLIENT_ID="0oa1234567890abcdef"
export OKTA_PRIVATE_KEY_FILE="/secure/path/to/prowler-okta.pem"
-# Optional — defaults to "okta.policies.read"
-export OKTA_SCOPES="okta.policies.read"
+# Optional — defaults to "okta.policies.read,okta.brands.read,okta.apps.read,okta.authenticators.read,okta.networkZones.read,okta.apiTokens.read,okta.roles.read,okta.groups.read,okta.logStreams.read,okta.idps.read"
+export OKTA_SCOPES="okta.policies.read,okta.brands.read,okta.apps.read,okta.authenticators.read,okta.networkZones.read,okta.apiTokens.read,okta.roles.read,okta.groups.read,okta.logStreams.read,okta.idps.read"
```
The private key file may contain either a PEM-encoded RSA key or a JWK JSON document.
@@ -87,6 +128,9 @@ okta:
# okta.signon_global_session_idle_timeout_15min
# Defaults to 15 minutes per DISA STIG V-273186.
okta_max_session_idle_minutes: 15
+ # okta.application_admin_console_session_idle_timeout_15min
+ # Defaults to 15 minutes per DISA STIG V-273187.
+ okta_admin_console_idle_timeout_max_minutes: 15
```
To use a custom configuration:
@@ -99,9 +143,16 @@ prowler okta --config-file /path/to/config.yaml
Prowler for Okta includes security checks across the following services:
-| Service | Description |
-| ----------- | ----------------------------------------------------------------------------------- |
-| **Sign-On** | Global session policy controls (idle timeout, lifetime, rule priority and ordering) |
+| Service | Description |
+| --------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- |
+| **Sign-On** | Global session policy controls (idle timeout, lifetime, rule priority and ordering) |
+| **Application** | Okta Admin Console sign-on settings plus Authentication Policy controls for Okta applications (session idle, MFA, phishing resistance, network zones) |
+| **Authenticator** | Password Policy controls plus Okta Verify FIPS and Smart Card IdP authenticator status |
+| **Network** | Network Zone blocklists for anonymized proxy sources |
+| **API Token** | API token owner-role validation and Network Zone restrictions |
+| **User** | User lifecycle automations (inactivity-based deprovisioning) |
+| **System Log** | Log Stream configuration that off-loads audit records to a central SIEM |
+| **Identity Provider** | Identity Providers, including Smart Card (X509) IdP status and certificate-chain visibility |
## Troubleshooting
@@ -113,20 +164,29 @@ This is stricter than simply finding the same timeout value somewhere else in th
### Default Scopes
-Prowler requests a fixed set of OAuth scopes on every token exchange. The default is a single scope that covers the bundled initial check:
+Prowler requests a fixed set of OAuth scopes on every token exchange. The defaults cover every bundled check across the Sign-On, Application, Authenticator, Network, API Token, User, System Log, and Identity Provider services:
- `okta.policies.read`
+- `okta.brands.read`
+- `okta.apps.read`
+- `okta.authenticators.read`
+- `okta.networkZones.read`
+- `okta.apiTokens.read`
+- `okta.roles.read`
+- `okta.groups.read`
+- `okta.logStreams.read`
+- `okta.idps.read`
-The service app must have that scope granted in the **Okta API Scopes** tab. When the granted set is narrower than the requested set, the token request fails with an `invalid_scope` error and the scan stops at provider initialization.
+The service app must have these scopes granted in the **Okta API Scopes** tab. `okta.groups.read` is required so the API token Super Admin check can resolve admin roles inherited via group membership; without it the check falls back to direct-only role assignments and emits a best-effort caveat. When the granted set is narrower than the requested set, the token request fails with an `invalid_scope` error and the scan stops at provider initialization.
When additional checks are enabled — or when running against a service app that exposes a different scope set — override the default with `OKTA_SCOPES` (comma-separated string for the env var) or `--okta-scopes` (space-separated list for the CLI):
```bash
# Environment variable — comma-separated
-export OKTA_SCOPES="okta.policies.read,okta.apps.read,okta.users.read"
+export OKTA_SCOPES="okta.policies.read,okta.brands.read,okta.apps.read,okta.authenticators.read,okta.networkZones.read,okta.apiTokens.read,okta.roles.read,okta.groups.read,okta.logStreams.read,okta.idps.read,okta.users.read"
# CLI flag — space-separated
-prowler okta --okta-scopes okta.policies.read okta.apps.read okta.users.read
+prowler okta --okta-scopes okta.policies.read okta.brands.read okta.apps.read okta.authenticators.read okta.networkZones.read okta.apiTokens.read okta.roles.read okta.groups.read okta.logStreams.read okta.idps.read okta.users.read
```
For the full catalog of OAuth scopes exposed by the Okta Management API, refer to the [Okta OAuth 2.0 scopes documentation](https://developer.okta.com/docs/api/oauth2/).
diff --git a/docs/user-guide/providers/okta/images/okta-credentials-form.png b/docs/user-guide/providers/okta/images/okta-credentials-form.png
new file mode 100644
index 0000000000..c37553c59d
Binary files /dev/null and b/docs/user-guide/providers/okta/images/okta-credentials-form.png differ
diff --git a/docs/user-guide/providers/okta/images/okta-org-domain-form.png b/docs/user-guide/providers/okta/images/okta-org-domain-form.png
new file mode 100644
index 0000000000..fb145d2877
Binary files /dev/null and b/docs/user-guide/providers/okta/images/okta-org-domain-form.png differ
diff --git a/docs/user-guide/providers/okta/images/select-okta-provider.png b/docs/user-guide/providers/okta/images/select-okta-provider.png
new file mode 100644
index 0000000000..1a854bab37
Binary files /dev/null and b/docs/user-guide/providers/okta/images/select-okta-provider.png differ
diff --git a/docs/user-guide/providers/okta/retry-configuration.mdx b/docs/user-guide/providers/okta/retry-configuration.mdx
new file mode 100644
index 0000000000..1575cf6329
--- /dev/null
+++ b/docs/user-guide/providers/okta/retry-configuration.mdx
@@ -0,0 +1,123 @@
+---
+title: "Okta Rate Limit Configuration in Prowler"
+---
+
+import { VersionBadge } from "/snippets/version-badge.mdx"
+
+
+
+Prowler's Okta Provider manages API rate limits with two complementary controls:
+
+- **Request throttling (proactive):** Prowler paces outbound requests through a shared limiter so scans stay under Okta's rate limits and rarely trigger a rate-limit response in the first place.
+- **Retries (reactive):** When Okta still returns a rate-limit response (HTTP 429), the official Okta Python SDK reads the `X-Rate-Limit-Reset` header and waits until the window resets before retrying. This acts as a safety net for occasional bursts.
+
+Both controls are configurable through the configuration file or command line flags.
+
+## Request Throttling (Requests per Second)
+
+Throttling is the primary control for avoiding rate limits. Prowler limits the aggregate number of Okta API requests per second across every service in a scan.
+
+### Using the Command Line Flag
+
+```bash
+prowler okta --okta-requests-per-second 4
+```
+
+Set the value to `0` to disable throttling.
+
+### Using the Configuration File
+
+```yaml
+okta:
+ # Maximum aggregate Okta API requests per second. Default: 4. Set to 0 to disable.
+ okta_requests_per_second: 4
+```
+
+Okta enforces rate limits per endpoint, so this single global cap is a deliberately simple control. Lower the value if scans still hit limits on large organizations; raise it to scan faster when the organization has generous limits.
+
+## Retries
+
+Retries cover the cases throttling does not prevent, such as short bursts or per-endpoint limits lower than the global cap.
+
+### Using the Command Line Flag
+
+```bash
+prowler okta --okta-retries-max-attempts 8
+```
+
+### Using the Configuration File
+
+```yaml
+okta:
+ # Maximum retries on HTTP 429. Default: 5.
+ okta_max_retries: 8
+ # Per-request timeout in seconds. Default: 300.
+ okta_request_timeout: 300
+```
+
+The command line flags override the configuration file values.
+
+## How It Works
+
+- **Automatic detection:** The Okta SDK retries the retryable statuses 429, 503, and 504.
+- **Reset-aware backoff:** On a 429 response the SDK sleeps until the `X-Rate-Limit-Reset` window before each retry, rather than using a fixed delay.
+- **Bounded attempts:** `okta_max_retries` caps how many times a single request is retried. The Okta SDK default is 2, which is often too low for large organizations, so Prowler defaults to 5.
+
+## Request Timeout
+
+The `okta_request_timeout` setting plays a dual role in the Okta SDK:
+
+- It is the per-request socket timeout, bounding how long a single HTTP call can hang.
+- It is also the total wall-clock budget for the whole retry-and-backoff loop of one request.
+
+For this reason, the value defaults to 300 seconds rather than 0 (no timeout). A value of 0 leaves hung connections unbounded, while a value that is too low cuts the rate-limit waits short and reintroduces the errors. As a guideline, keep `okta_request_timeout` greater than or equal to `okta_max_retries` multiplied by 60 when raising the retry count, because Okta reset windows are typically up to one minute.
+
+## Error Example Handled
+
+```
+Okta HTTP 429: Too Many Requests. Hit rate limit. Retry request in 42 seconds.
+```
+
+## Validation
+
+### Debug Logging
+
+To confirm that throttling and retries are active, run a scan with debug logging:
+
+```bash
+prowler okta --okta-requests-per-second 4 --log-level DEBUG --log-file debuglogs.txt
+```
+
+### Check the Messages
+
+```bash
+grep -i "throttling\|rate limit\|retry" debuglogs.txt
+```
+
+### Expected Output
+
+When throttling is enabled, Prowler logs the configured rate at startup:
+
+```
+Okta request throttling enabled at 4 req/s
+```
+
+If a rate limit is still hit, the SDK logs the backoff:
+
+```
+Hit rate limit. Retry request in 42 seconds.
+```
+
+## Troubleshooting
+
+If scans continue to hit rate limits:
+
+1. Lower `--okta-requests-per-second` so requests are paced more conservatively.
+2. Raise `--okta-retries-max-attempts` (and keep `okta_request_timeout` proportionally large) so the safety net absorbs more bursts.
+3. Review the rate-limit allocation for the Okta organization and request an increase if needed.
+4. Verify throttling and retry behavior with debug logging.
+
+## Official References
+
+- [Okta Rate Limits](https://developer.okta.com/docs/reference/rate-limits/)
+- [Okta SDK for Python](https://github.com/okta/okta-sdk-python)
diff --git a/docs/user-guide/providers/stackit/authentication.mdx b/docs/user-guide/providers/stackit/authentication.mdx
new file mode 100644
index 0000000000..dabc407c50
--- /dev/null
+++ b/docs/user-guide/providers/stackit/authentication.mdx
@@ -0,0 +1,100 @@
+---
+title: 'StackIT Authentication'
+---
+
+Prowler authenticates with StackIT using a **service account key file**. The StackIT SDK signs the RSA challenge in the key file and mints/refreshes access tokens internally for the life of the scan, so no manual token rotation is needed.
+
+## Service Account Key
+
+StackIT uses RSA key-pair based service account keys. They are issued once, must be stored securely, and are read by the SDK on every scan to mint short-lived access tokens transparently.
+
+### Option 1: Create the Key via the StackIT Portal
+
+1. Open the [StackIT Portal](https://portal.stackit.cloud/) and select your project.
+2. In the left sidebar, click **Service Accounts**.
+3. Create a service account if you do not have one already. Assign:
+ - `iaas.viewer` for the IaaS security group checks currently shipped, or
+ - `project.owner` if you want to cover any future service Prowler adds.
+4. Open the service account and go to **Service Account Keys**.
+5. Click **Create key** and choose **STACKIT-generated key pair** (recommended). Download the resulting JSON file and store it securely (for example, `~/.stackit/sa-key.json`). The private material is only shown once.
+
+### Option 2: Create the Key via the StackIT CLI
+
+```bash
+# Install the StackIT CLI from https://github.com/stackitcloud/stackit-cli first
+stackit service-account key create --email my-service-account@example.com
+```
+
+## Project ID
+
+Your StackIT project ID is a UUID. You can find it in:
+
+1. The portal URL when viewing the project: `https://portal.stackit.cloud/projects/{PROJECT_ID}/...`
+2. The project settings page
+3. `stackit project list`
+
+## Passing Credentials to Prowler
+
+You can give Prowler either the **path** to the key file on disk or the **inline JSON content** of the key. Both go through the same StackIT SDK flow and refresh access tokens internally.
+
+### Option A: Key File Path (workstation, persistent agents)
+
+Recommended when the key is stored on disk.
+
+```bash
+export STACKIT_SERVICE_ACCOUNT_KEY_PATH="$HOME/.stackit/sa-key.json"
+export STACKIT_PROJECT_ID="12345678-1234-1234-1234-123456789abc"
+
+prowler stackit
+```
+
+Or as CLI flags:
+
+```bash
+prowler stackit \
+ --stackit-service-account-key-path ~/.stackit/sa-key.json \
+ --stackit-project-id 12345678-1234-1234-1234-123456789abc
+```
+
+
+Keep the key file outside of source control and lock it down with `chmod 600 ~/.stackit/sa-key.json`. Anyone with the JSON can mint access tokens for the service account.
+
+
+### Option B: Inline Key Content (CI/CD, secret managers)
+
+Recommended when the key is fetched at run time from a secret manager (GitHub Actions secret, AWS Secrets Manager, HashiCorp Vault, etc.) and you do not want to write it to disk.
+
+```bash
+export STACKIT_SERVICE_ACCOUNT_KEY="$(vault kv get -field=key stackit/sa)"
+export STACKIT_PROJECT_ID="12345678-1234-1234-1234-123456789abc"
+
+prowler stackit
+```
+
+
+Prefer the `STACKIT_SERVICE_ACCOUNT_KEY` environment variable over the matching CLI flag (`--stackit-service-account-key`); passing the secret on the command line leaks it through process listings and shell history.
+
+
+When both the inline content and a key path are set, the inline content wins.
+
+## Credential Lookup Order
+
+Prowler resolves credentials in this order:
+
+1. CLI arguments: `--stackit-service-account-key`, `--stackit-service-account-key-path`, `--stackit-project-id`
+2. Environment variables: `STACKIT_SERVICE_ACCOUNT_KEY`, `STACKIT_SERVICE_ACCOUNT_KEY_PATH`, `STACKIT_PROJECT_ID`
+
+When both the inline key and the key file path are set, the inline content takes precedence.
+
+## Token Lifetime
+
+Access tokens are minted on demand by the SDK from the key file and refreshed before they expire. There is nothing to rotate while Prowler is running.
+
+## Troubleshooting
+
+| Symptom | Likely Cause | Fix |
+|---------|--------------|-----|
+| `401 Unauthorized` during scan | Key file is missing fields, the public key is no longer registered, or the key was revoked | Re-issue the service account key in the StackIT portal and update `STACKIT_SERVICE_ACCOUNT_KEY_PATH` |
+| `403 Forbidden` during scan | Service account lacks role on the project | Re-check role assignment in the StackIT portal; `iaas.viewer` is the minimum for the shipped IaaS checks |
+| `StackIT project ID must be a valid UUID` | The project ID is not in UUID format | Copy the UUID from the portal URL or `stackit project list` |
+| `StackIT service account credentials are required` | None of the four credential inputs is set | Export `STACKIT_SERVICE_ACCOUNT_KEY_PATH` or `STACKIT_SERVICE_ACCOUNT_KEY` (or use their CLI counterparts) before running Prowler |
diff --git a/docs/user-guide/providers/stackit/getting-started-stackit.mdx b/docs/user-guide/providers/stackit/getting-started-stackit.mdx
new file mode 100644
index 0000000000..6c56008a89
--- /dev/null
+++ b/docs/user-guide/providers/stackit/getting-started-stackit.mdx
@@ -0,0 +1,141 @@
+---
+title: 'Getting Started With StackIT'
+---
+
+Prowler supports [StackIT](https://www.stackit.de/) from the CLI. This guide walks you through the requirements and how to run scans.
+
+
+StackIT support in Prowler is community-maintained. For commercial support or to request additional service coverage, [contact us](https://prowler.com/contact).
+
+
+## Prerequisites
+
+Before running Prowler with the StackIT provider, ensure you have:
+
+1. A StackIT account with at least one project
+2. A StackIT service account key file with permissions on the project (`iaas.viewer` is enough for the currently shipped IaaS checks; `project.owner` works for any future service). See the [Authentication guide](/user-guide/providers/stackit/authentication) for the full setup.
+3. Access to Prowler CLI (see [Installation](/getting-started/installation/prowler-cli))
+
+## Prowler CLI
+
+### Step 1: Point Prowler at the Service Account Key
+
+Prowler authenticates with a StackIT service account key. The SDK signs the RSA challenge in the key and refreshes access tokens internally for the life of the scan, so there is no manual token rotation.
+
+**On a workstation or persistent agent** (key on disk):
+
+```bash
+export STACKIT_SERVICE_ACCOUNT_KEY_PATH="$HOME/.stackit/sa-key.json"
+export STACKIT_PROJECT_ID="12345678-1234-1234-1234-123456789abc"
+```
+
+**In CI/CD** (key in a secret manager, never written to disk):
+
+```bash
+export STACKIT_SERVICE_ACCOUNT_KEY="$(vault kv get -field=key stackit/sa)"
+export STACKIT_PROJECT_ID="12345678-1234-1234-1234-123456789abc"
+```
+
+CLI flags work too:
+
+```bash
+prowler stackit \
+ --stackit-service-account-key-path ~/.stackit/sa-key.json \
+ --stackit-project-id 12345678-1234-1234-1234-123456789abc
+```
+
+
+For the inline key, prefer the `STACKIT_SERVICE_ACCOUNT_KEY` env var over the matching CLI flag; passing the secret on the command line leaks it through process listings and shell history.
+
+Keep the key file outside of source control and lock it down with `chmod 600 ~/.stackit/sa-key.json`. Anyone with the JSON can mint access tokens for the service account.
+
+
+### Step 2: Run Your First Scan
+
+```bash
+prowler stackit
+```
+
+Prowler will discover and audit the project's IaaS security groups across the available StackIT regions.
+
+**Scan specific regions:**
+
+```bash
+prowler stackit --stackit-region eu01 eu02
+```
+
+**Run specific security checks:**
+
+```bash
+prowler stackit --checks iaas_security_group_ssh_unrestricted
+
+# List all available checks
+prowler stackit --list-checks
+```
+
+**Filter by check severity:**
+
+```bash
+prowler stackit --severity critical high
+```
+
+**Generate specific output formats:**
+
+```bash
+# JSON only
+prowler stackit --output-modes json
+
+# CSV and HTML
+prowler stackit --output-modes csv html
+
+# Custom output directory
+prowler stackit --output-directory /path/to/reports/
+```
+
+**Use a mutelist to suppress findings:**
+
+```yaml
+# mutelist.yaml
+Mutelist:
+ Accounts:
+ "12345678-1234-1234-1234-123456789abc":
+ Checks:
+ iaas_security_group_ssh_unrestricted:
+ Regions:
+ - "*"
+ Resources:
+ - "test-sg-id"
+ Tags: []
+```
+
+```bash
+prowler stackit --mutelist-file mutelist.yaml
+```
+
+### Step 3: Review the Results
+
+Prowler outputs findings to the console and writes reports to the `output/` directory by default:
+
+- CSV: `output/prowler-output-stackit-{project_id}-{timestamp}.csv`
+- JSON: `output/prowler-output-stackit-{project_id}-{timestamp}.json`
+- HTML: `output/prowler-output-stackit-{project_id}-{timestamp}.html`
+
+## Supported StackIT Services
+
+| Service | StackIT API | Description | Example Checks |
+|---------|-------------|-------------|----------------|
+| **IaaS** | `iaas` | Virtual machines, network interfaces, security groups | `iaas_security_group_ssh_unrestricted`, `iaas_security_group_rdp_unrestricted`, `iaas_security_group_database_unrestricted`, `iaas_security_group_all_traffic_unrestricted` |
+
+Additional services will be added in future releases. Track progress in the [Prowler release notes](https://github.com/prowler-cloud/prowler/releases).
+
+## Troubleshooting
+
+### Authentication Errors
+
+If the scan fails with a 401 error, the service account key is no longer valid (revoked, rotated or the key file is incomplete). Re-issue the key in the [StackIT portal](https://portal.stackit.cloud/) and update `STACKIT_SERVICE_ACCOUNT_KEY_PATH`.
+
+### Permission Errors
+
+If checks fail with a 403 error, the service account is missing the required role on the project. Re-check the role assignment in the StackIT portal (`iaas.viewer` is the minimum for the shipped IaaS checks).
+
+For detailed setup steps, see the [Authentication guide](/user-guide/providers/stackit/authentication).
diff --git a/docs/user-guide/providers/vercel/getting-started-vercel.mdx b/docs/user-guide/providers/vercel/getting-started-vercel.mdx
index c39c5f1e6a..da02019b6f 100644
--- a/docs/user-guide/providers/vercel/getting-started-vercel.mdx
+++ b/docs/user-guide/providers/vercel/getting-started-vercel.mdx
@@ -28,7 +28,7 @@ Set up authentication for Vercel with the [Vercel Authentication](/user-guide/pr
### Step 1: Add the Provider
-1. Go to [Prowler Cloud](https://cloud.prowler.com/) or launch [Prowler App](/user-guide/tutorials/prowler-app).
+1. Go to [Prowler Cloud](https://cloud.prowler.com/) or launch [Prowler Local Server](/user-guide/tutorials/prowler-app).
2. Navigate to "Configuration" > "Providers".

diff --git a/docs/user-guide/tutorials/aws-organizations-bulk-provisioning.mdx b/docs/user-guide/tutorials/aws-organizations-bulk-provisioning.mdx
index 88a084dd17..188ac3540d 100644
--- a/docs/user-guide/tutorials/aws-organizations-bulk-provisioning.mdx
+++ b/docs/user-guide/tutorials/aws-organizations-bulk-provisioning.mdx
@@ -9,9 +9,9 @@ The tool, `aws_org_generator.py`, complements the [Bulk Provider Provisioning
**Native AWS Organizations support is now available in Prowler Cloud.** You can onboard all accounts via the UI wizard — with automatic discovery, hierarchical tree selection, connection testing, and bulk scan launch — without any scripts or YAML files.
-See [AWS Organizations in Prowler Cloud](/user-guide/tutorials/prowler-cloud-aws-organizations).
+See [AWS Organizations](/user-guide/tutorials/prowler-cloud-aws-organizations) in Prowler Cloud.
-The CLI-based tool below remains useful for self-hosted Prowler App and advanced automation scenarios.
+The CLI-based tool below remains useful for Prowler Local Server and advanced automation scenarios.
{/* TODO: Add screenshot of the tool in action */}
@@ -32,9 +32,9 @@ The AWS Organizations Bulk Provisioning tool simplifies multi-account onboarding
* Python 3.7 or higher
* AWS credentials with Organizations read access
* ProwlerRole (or custom role) deployed across all target accounts
-* Prowler API key (from Prowler Cloud or self-hosted Prowler App)
- * For self-hosted Prowler App, remember to [point to your API base URL](./bulk-provider-provisioning#custom-api-endpoints)
- * Learn how to create API keys: [Prowler App API Keys](../tutorials/prowler-app-api-keys)
+* Prowler API key (from Prowler Cloud or Prowler Local Server)
+ * For Prowler Local Server, remember to [point to your API base URL](./bulk-provider-provisioning#custom-api-endpoints)
+ * Learn how to create API keys: [Prowler Cloud API Keys](../tutorials/prowler-app-api-keys)
### Deploying ProwlerRole Across AWS Organizations
@@ -97,13 +97,13 @@ export PROWLER_API_KEY="pk_example-api-key"
To create an API key:
-1. Log in to Prowler Cloud or Prowler App
+1. Log in to Prowler Cloud or Prowler Local Server
2. Click **Profile** → **Account**
3. Click **Create API Key**
4. Provide a descriptive name and optionally set an expiration date
5. Copy the generated API key (it will only be shown once)
-For detailed instructions, see: [Prowler App API Keys](../tutorials/prowler-app-api-keys)
+For detailed instructions, see: [Prowler Cloud API Keys](../tutorials/prowler-app-api-keys)
## Basic Usage
@@ -272,6 +272,8 @@ python aws_org_generator.py \
4. Deploy to all organizational units
5. Use a unique external ID (e.g., `prowler-org-2024-abc123`)
+ Alternatively, deploy the same template as a **single stack** with `DeployStackSet=true` and `AWSOrganizationalUnitId` set to your root/OU ID — it creates the StackSet for you. See [Native CloudFormation StackSet Deployment](../providers/aws/organizations#native-cloudformation-stackset-deployment-recommended).
+
{/* TODO: Add screenshot of CloudFormation StackSets deployment */}
@@ -324,7 +326,7 @@ python aws_org_generator.py \
- Provision all accounts to Prowler Cloud or Prowler App:
+ Provision all accounts to Prowler Cloud or Prowler Local Server:
```bash
# Set Prowler API key
@@ -487,7 +489,7 @@ grep "provider: aws" aws-org-accounts.yaml | wc -l
Learn how to bulk provision providers in Prowler.
-
+
Detailed instructions on how to use Prowler.
diff --git a/docs/user-guide/tutorials/bulk-provider-provisioning.mdx b/docs/user-guide/tutorials/bulk-provider-provisioning.mdx
index 3ad5d9c0d5..cc759b6f85 100644
--- a/docs/user-guide/tutorials/bulk-provider-provisioning.mdx
+++ b/docs/user-guide/tutorials/bulk-provider-provisioning.mdx
@@ -10,7 +10,7 @@ The tool is available in the Prowler repository at: [util/prowler-bulk-provision
## Overview
-The Bulk Provider Provisioning tool automates the creation of cloud providers in Prowler App or Prowler Cloud by:
+The Bulk Provider Provisioning tool automates the creation of cloud providers in Prowler Cloud or Prowler Local Server by:
* Reading provider configurations from YAML files
* Creating providers with appropriate authentication credentials
@@ -26,9 +26,9 @@ The Bulk Provider Provisioning tool automates the creation of cloud providers in
### Requirements
* Python 3.7 or higher
-* Prowler API key (from Prowler Cloud or self-hosted Prowler App)
- * For self-hosted Prowler App, remember to [point to your API base URL](#custom-api-endpoints)
- * Learn how to create API keys: [Prowler App API Keys](../tutorials/prowler-app-api-keys)
+* Prowler API key (from Prowler Cloud or Prowler Local Server)
+ * For Prowler Local Server, remember to [point to your API base URL](#custom-api-endpoints)
+ * Learn how to create API keys: [Prowler Cloud API Keys](../tutorials/prowler-app-api-keys)
* Authentication credentials for target cloud providers
### Installation
@@ -51,13 +51,13 @@ export PROWLER_API_KEY="pk_example-api-key"
To create an API key:
-1. Log in to Prowler Cloud or Prowler App
+1. Log in to Prowler Cloud or Prowler Local Server
2. Click **Profile** → **Account**
3. Click **Create API Key**
4. Provide a descriptive name and optionally set an expiration date
5. Copy the generated API key (it will only be shown once)
-For detailed instructions, see: [Prowler App API Keys](../tutorials/prowler-app-api-keys)
+For detailed instructions, see: [Prowler Cloud API Keys](../tutorials/prowler-app-api-keys)
## Configuration File Structure
@@ -261,7 +261,7 @@ python prowler_bulk_provisioning.py providers.yaml --concurrency 10
### Custom API Endpoints
-For self-hosted Prowler App installations:
+For Prowler Local Server installations:
```bash
python prowler_bulk_provisioning.py providers.yaml \
diff --git a/docs/user-guide/tutorials/prowler-app-alerts.mdx b/docs/user-guide/tutorials/prowler-alerts.mdx
similarity index 98%
rename from docs/user-guide/tutorials/prowler-app-alerts.mdx
rename to docs/user-guide/tutorials/prowler-alerts.mdx
index f607844f97..fc205b72be 100644
--- a/docs/user-guide/tutorials/prowler-app-alerts.mdx
+++ b/docs/user-guide/tutorials/prowler-alerts.mdx
@@ -1,17 +1,17 @@
---
title: 'Alerts'
+sidebarTitle: 'Alerts'
description: 'Create email alerts from Prowler Cloud findings to monitor relevant security changes after scans or in daily digests.'
---
import { VersionBadge } from "/snippets/version-badge.mdx"
+import { SubscriptionBanner } from "/snippets/subscription-banner.mdx"
Alerts notify recipients by email when security findings match saved filter conditions. Use Alerts to track high-priority findings, monitor specific providers or services, and keep teams informed about scan results that match defined criteria.
-
-This feature is available exclusively in **Prowler Cloud** with a paid subscription.
-
+
## Prerequisites
diff --git a/docs/user-guide/tutorials/prowler-app-api-keys.mdx b/docs/user-guide/tutorials/prowler-app-api-keys.mdx
index 4e51e62bac..c7d5f86ec4 100644
--- a/docs/user-guide/tutorials/prowler-app-api-keys.mdx
+++ b/docs/user-guide/tutorials/prowler-app-api-keys.mdx
@@ -3,14 +3,17 @@ title: 'API Keys'
---
import { VersionBadge } from "/snippets/version-badge.mdx"
+import { AppliesTo } from "/snippets/applies-to.mdx"
-API key authentication in Prowler App provides an alternative to JWT tokens and empowers automation, CI/CD pipelines, and third-party integrations. This guide explains how to create, manage, and safeguard API keys when working with the Prowler API.
+
+
+API key authentication in Prowler Cloud provides an alternative to JWT tokens and empowers automation, CI/CD pipelines, and third-party integrations. This guide explains how to create, manage, and safeguard API keys when working with the Prowler API.
## API Key Advantages
-- **Programmatic access:** Enables automated workflows and scripts to interact with Prowler App.
+- **Programmatic access:** Enables automated workflows and scripts to interact with Prowler Cloud.
- **Long-lived authentication:** Allows optional expiration dates, with a default of 1 year.
- **Granular control:** Supports multiple keys with distinct names and purposes.
- **Secure automation:** Simplifies safe integration into CI/CD pipelines and infrastructure-as-code tooling.
@@ -19,7 +22,7 @@ API key authentication in Prowler App provides an alternative to JWT tokens and
API keys provide a secure authentication mechanism for accessing the Prowler API:
-1. API keys are created through Prowler App with a user-defined name and optional expiration date.
+1. API keys are created through Prowler Cloud with a user-defined name and optional expiration date.
2. The full API key appears only once upon creation and cannot be retrieved later.
3. Each API key consists of a prefix (visible in the interface) and an encrypted secret portion.
4. Requests include the API key in the header as `Authorization: Api-Key `.
@@ -63,13 +66,13 @@ Creating, viewing, or managing API keys requires the **MANAGE_ACCOUNT** RBAC per
Without this permission, the API Keys section remains hidden. Access requests should be routed through the tenant administrator.
-For more information about RBAC permissions, refer to the [Prowler App RBAC documentation](/user-guide/tutorials/prowler-app-rbac).
+For more information about RBAC permissions, refer to the [Prowler Cloud RBAC documentation](/user-guide/tutorials/prowler-app-rbac).
## Creating API Keys
-Follow these steps to create an API key in Prowler App:
+Follow these steps to create an API key in Prowler Cloud:
-1. Navigate to **Profile** → **Account** in Prowler App.
+1. Navigate to **Profile** → **Account** in Prowler Cloud.
2. Select the **Create API Key** button.

@@ -206,7 +209,7 @@ When using API keys in CI/CD pipelines:
* Ensure the key has not been revoked by checking the Revoked column in the API Keys list.
* Confirm that the key has not expired by reviewing the expiration date.
* Confirm that the correct API key format is in use, including both prefix and secret portions.
-* Verify that the key prefix matches what is displayed in Prowler App.
+* Verify that the key prefix matches what is displayed in Prowler Cloud.
### API Key Not Working After Creation
diff --git a/docs/user-guide/tutorials/prowler-app-attack-paths.mdx b/docs/user-guide/tutorials/prowler-app-attack-paths.mdx
index ad0b1fcce1..22e56e5634 100644
--- a/docs/user-guide/tutorials/prowler-app-attack-paths.mdx
+++ b/docs/user-guide/tutorials/prowler-app-attack-paths.mdx
@@ -3,13 +3,16 @@ title: "Attack Paths"
description: "Identify privilege escalation chains and security misconfigurations across cloud environments using graph-based analysis."
---
-import { VersionBadge } from "/snippets/version-badge.mdx"
+import { VersionBadge } from "/snippets/version-badge.mdx";
+import { AppliesTo } from "/snippets/applies-to.mdx";
+
+
Attack Paths analyzes relationships between cloud resources, permissions, and security findings to detect how privileges can be escalated and how misconfigurations can be exploited by threat actors.
-By mapping these relationships as a graph, Attack Paths reveals risks that individual security checks cannot detect on their own — such as an IAM role that can escalate its own permissions, or a chain of policies that grants unintended access to sensitive resources.
+By mapping these relationships as a graph, Attack Paths reveals risks that individual security checks cannot detect on their own, such as an IAM role that can escalate its own permissions, or a chain of policies that grants unintended access to sensitive resources.
Attack Paths is currently available for **AWS** providers. Support for
@@ -20,14 +23,14 @@ By mapping these relationships as a graph, Attack Paths reveals risks that indiv
The following prerequisites are required for Attack Paths:
-- **An AWS provider is configured** with valid credentials in Prowler App. For setup instructions, see [Getting Started with AWS](/user-guide/providers/aws/getting-started-aws).
-- **At least one scan has completed** on the configured AWS provider. Attack Paths scans run automatically alongside regular security scans — no separate configuration is required.
+- **An AWS provider is configured** with valid credentials in Prowler Cloud. For setup instructions, see [Getting Started with AWS](/user-guide/providers/aws/getting-started-aws).
+- **At least one scan has completed** on the configured AWS provider and produced graph data. Attack Paths scans run automatically alongside regular security scans, no separate configuration is required.
## How Attack Paths Scans Work
Attack Paths scans are generated automatically when a security scan runs on an AWS provider. Each completed scan produces graph data that maps relationships between IAM principals, policies, trust configurations, and other resources.
-Once the scan finishes and the graph data is ready, the scan appears in the Attack Paths scan table with a **Completed** status. Scans that are still processing display as **Executing** or **Scheduled**.
+Once the scan finishes and graph data is ready, the scan appears in the Attack Paths scan table with a **Completed** status and a check in the **Graph** column. Scans that are still queued or running remain visible, but they cannot be selected until graph data is ready.
Since Prowler scans all configured providers every **24 hours** by default,
@@ -41,25 +44,28 @@ To open Attack Paths, click **Attack Paths** in the left navigation menu.
-The main interface is divided into two areas:
+The Attack Paths page guides you through the workflow on one page:
-- **Left panel:** A table listing all available Attack Paths scans
-- **Right panel:** The query selector, parameter form, and execute controls
+- Select a scan with graph data.
+- Choose a built-in query or a custom openCypher query.
+- Add parameters when the selected query requires them.
+- Execute the query and explore the resulting graph.
## Selecting a Scan
The scans table displays all Attack Paths scans with the following columns:
-- **Provider / Account:** The AWS provider alias and account identifier
-- **Last scan date:** When the scan completed
-- **Status:** Current state of the scan (Completed, Executing, Scheduled, or Failed)
-- **Progress:** Completion percentage for in-progress scans
-- **Duration:** Total scan time
+- **Select:** A radio button used to choose a scan. The radio button is disabled when graph data is not available.
+- **Provider:** The AWS provider alias and account identifier.
+- **Last Scan Date:** When the scan completed.
+- **Status:** Current state of the scan, such as **Completed**, **Executing**, **Scheduled**, or **Failed**.
+- **Graph:** Whether Attack Paths graph data is available for the scan.
+- **Duration:** Total scan time.
-To select a scan for analysis, click **Select** on any row with a **Completed** status.
+To select a scan for analysis, click the radio button on any row with a **Completed** status and available graph data.
- Only scans with a **Completed** status and ready graph data can be selected.
- Scans that are still executing or have failed appear with disabled action
- buttons.
+ Only scans with graph data can be selected. Disabled rows include a tooltip
+ that explains why the graph is not available yet.
## Choosing a Query
-After selecting a scan, the right panel activates a query dropdown. Each query targets a specific type of privilege escalation or misconfiguration pattern.
+After selecting a scan, the query selector becomes available. Each query targets a specific privilege escalation, exposure, inventory, or misconfiguration pattern.
To choose a query, click the dropdown and select from the available options. Each option displays:
-- **Query name:** A descriptive title (e.g., "IAM Privilege Escalation via AssumeRole")
-- **Short description:** A brief summary of what the query detects
+- **Query name:** A descriptive title, such as **Internet-Exposed EC2 with Sensitive S3 Access**.
+- **Short description:** A brief summary of what the query detects.
-Once selected, a description card appears below the dropdown with additional context about the query, including attribution links to external references when available.
+Once selected, a description panel appears below the dropdown with more context about the query.
## Configuring Query Parameters
-Some queries accept optional or required parameters to narrow the scope of the analysis. When a query has parameters, a dynamic form appears below the query description.
+Some queries accept optional or required parameters to narrow the scope of the analysis. When a query has parameters, a form appears below the query description.
-- **Required fields** are marked with an asterisk (\*) and must be filled before executing
-- **Optional fields** refine the query results but are not mandatory
+- **Required fields** are marked with an asterisk (\*) and must be filled before executing.
+- **Optional fields** refine the query results but are not mandatory.
+- Queries without parameters show no parameter form.
-If a query requires no parameters, the form displays a message confirming that the query is ready to execute.
+For example, **Internet-Exposed EC2 with Sensitive S3 Access** uses **Tag key** and **Tag value** fields to identify sensitive S3 buckets.
(policy:AWSPolicy)-[:STATEMENT]->(stmt:AWSPolicyStatement {effect: 'Allow'})
+MATCH (stmt)-[:HAS_ACTION]->(a:AWSPolicyStatementActionItem)
+WHERE a.value = '*'
+RETURN DISTINCT principal.arn AS principal, policy.arn AS policy
LIMIT 25
```
@@ -173,9 +178,89 @@ RETURN r.name AS role_name, r.arn AS role_arn, p.arn AS trusted_service
LIMIT 25
```
+### Working with List-Typed Properties
+
+Some Cartography node properties carry a list of values, such as `action`, `resource`, `notaction`, and `notresource` on `AWSPolicyStatement` nodes, the algorithms on `KMSKey`, the container-definition lists on `ECSContainerDefinition`, and many others. The Attack Paths graph models each such property as a set of child item nodes connected to the parent by a typed edge. To read the values, traverse the edge; the parent does not carry the list as a single field.
+
+The naming convention for any list-typed property on a parent label is:
+
+- **Child label:** `Item`. Example: `AWSPolicyStatement.resource` resolves to `AWSPolicyStatementResourceItem`.
+- **Edge type:** `HAS_`. Example: `resource` resolves to `HAS_RESOURCE`.
+- **Child property:** `value` for scalar lists (one string per list element). List-of-dict properties (rare; for example `SecretsManagerSecretVersion.tags`) carry the original dict keys as named fields on the child node.
+
+To express "at least one item in the list satisfies a predicate", traverse the `HAS_*` edge in its own `MATCH` clause and apply the predicate in the attached `WHERE`. `RETURN DISTINCT` collapses duplicate parent rows produced when multiple child items satisfy the filter:
+
+```cypher
+MATCH (stmt:AWSPolicyStatement {effect: 'Allow'})
+MATCH (stmt)-[:HAS_ACTION]->(a:AWSPolicyStatementActionItem)
+WHERE toLower(a.value) STARTS WITH 's3:get'
+ OR toLower(a.value) STARTS WITH 's3:list'
+RETURN DISTINCT stmt
+LIMIT 25
+```
+
+To check whether every item in the list satisfies a predicate, count the counter-examples and require zero, together with a guard that ensures at least one item is attached. This is the one case where the pattern-comprehension form is the right tool:
+
+```cypher
+MATCH (stmt:AWSPolicyStatement)
+WHERE size([
+ (stmt)-[:HAS_ACTION]->(a:AWSPolicyStatementActionItem)
+ WHERE NOT toLower(a.value) STARTS WITH 's3:'
+ | a
+ ]) = 0
+ AND size([(stmt)-[:HAS_ACTION]->(a:AWSPolicyStatementActionItem) | a]) > 0
+RETURN stmt
+LIMIT 25
+```
+
+For the "is any item of this list a substring of a dynamic value" case, such as "does any resource pattern in this policy match a target role ARN", add the `HAS_*` traversal as its own `MATCH` and check the substring relationship between the item value and the dynamic node in `WHERE`:
+
+```cypher
+MATCH (role:AWSRole)
+WHERE role.name = 'Admin'
+MATCH (principal:AWSPrincipal)-[:POLICY]->(:AWSPolicy)-[:STATEMENT]->(stmt:AWSPolicyStatement {effect: 'Allow'})
+MATCH (stmt)-[:HAS_RESOURCE]->(r:AWSPolicyStatementResourceItem)
+WHERE r.value = '*'
+ OR r.value CONTAINS role.name
+ OR role.arn CONTAINS r.value
+RETURN DISTINCT principal.arn AS principal, stmt, role
+LIMIT 25
+```
+
+To return the list of values directly, collect them from the child items:
+
+```cypher
+MATCH (stmt:AWSPolicyStatement {effect: 'Allow'})
+OPTIONAL MATCH (stmt)-[:HAS_ACTION]->(a:AWSPolicyStatementActionItem)
+RETURN stmt, collect(a.value) AS actions
+LIMIT 25
+```
+
+### Working with JSON-Encoded Properties
+
+Some Cartography properties represent nested objects, most notably `condition` on `AWSPolicyStatement` and `S3PolicyStatement` nodes. In the Attack Paths graph, object-typed properties are stored as JSON-encoded strings to keep the schema portable across graph backends. The value looks like:
+
+```
+'{"StringEquals":{"aws:SourceAccount":"123456789012"}}'
+```
+
+There is no JSON parser available at query time, so use `CONTAINS` for substring checks against keys or known values:
+
+```cypher
+MATCH (stmt:AWSPolicyStatement)
+WHERE stmt.effect = 'Allow'
+ AND stmt.condition CONTAINS '"aws:SourceAccount"'
+RETURN stmt
+LIMIT 25
+```
+
+When a query needs to inspect the structured members of a condition (for example, evaluate every operator and key), fetch the rows first and parse the JSON in application code. Cypher cannot navigate JSON object keys or values.
+
### Tips for Writing Queries
- Start small with `LIMIT` to inspect the shape of the data before broadening the pattern.
+- Traverse `HAS_*` edges to reach list-typed property values (for example `action`, `resource`). The parent node does not carry the list as a single field; see [Working with List-Typed Properties](#working-with-list-typed-properties) for the patterns.
+- On large scans, avoid broad disconnected patterns such as `MATCH (a:Label), (b:OtherLabel)`. Bind one side with a selective predicate first, and use `WITH DISTINCT` between expanding traversals when duplicates are possible.
- Use `RETURN` projections (`RETURN n.name, n.region`) instead of returning whole nodes to keep responses compact.
- Combine resource nodes with `ProwlerFinding` nodes via `HAS_FINDING` to correlate misconfigurations with the affected resources.
- When a query times out or returns no rows, simplify the pattern step by step until the first variant runs successfully, then add constraints back.
@@ -192,64 +277,88 @@ In addition to the upstream schema, Prowler enriches the graph with:
- **`ProwlerFinding`** nodes representing Prowler check results, linked to affected resources via `HAS_FINDING` relationships.
- **`Internet`** nodes used to model exposure paths from the public internet to internal resources.
+- **List-typed properties** such as `action` or `resource` on `AWSPolicyStatement`, the algorithm lists on `KMSKey`, and similar lists on other node types are modeled as child item nodes linked by typed `HAS_*` edges. See [Working with List-Typed Properties](#working-with-list-typed-properties) for the read pattern.
+- **Object-typed properties** such as `condition` on `AWSPolicyStatement` are stored as JSON-encoded strings. See [Working with JSON-Encoded Properties](#working-with-json-encoded-properties) for the read pattern.
AI assistants connected through Prowler MCP Server can fetch the exact
Cartography schema for the active scan via the
- `prowler_app_get_attack_paths_cartography_schema` tool. This guarantees that
+ `prowler_get_attack_paths_cartography_schema` tool. This guarantees that
generated queries match the schema version pinned by the running Prowler
release.
## Executing a Query
-To run the selected query against the scan data, click **Execute Query**. The button displays a loading state while the query processes.
+To run the selected query against the scan data, click **Execute Query**. The button is disabled until a query is selected and all required parameters are valid.
+
+The button displays a loading state while the query runs. After the query completes, the graph appears below the query builder.
If the query returns no results, an informational message appears. Common reasons include:
-- **No matching patterns found:** The scanned environment does not contain the privilege escalation chain the query targets
-- **Insufficient permissions:** The scan credentials may not have captured all the data the query needs
+- **No matching patterns found:** The scanned environment does not contain the pattern the query targets.
+- **Not enough permissions:** The scan credentials may not have captured all the data the query needs.
+- **Server unavailable:** The graph service may be temporarily unavailable.
## Exploring the Graph
-After a successful execution, the graph visualization renders below the query builder in a full-width panel. The graph maps relationships between cloud resources, IAM entities, and security findings.
+After a successful execution, the graph visualization renders below the query builder. The graph maps relationships between cloud resources, IAM entities, public exposure, and security findings.
### Node Types
-- **Resource nodes** (rounded pills): Represent cloud resources such as IAM roles, policies, EC2 instances, and S3 buckets. Each resource type has a distinct color.
-- **Finding nodes** (hexagons): Represent Prowler security findings linked to resources in the graph. Colors indicate severity level (critical, high, medium, low).
+- **Provider root nodes:** Represent the AWS account or provider root for the selected scan.
+- **Resource nodes:** Represent cloud resources such as IAM roles, policies, EC2 instances, security groups, and S3 buckets.
+- **Internet nodes:** Represent exposure from the public internet.
+- **Finding nodes:** Represent Prowler findings linked to resources. Finding colors indicate risk level, such as critical, high, medium, or low.
### Edge Types
-- **Solid lines:** Direct relationships between resources (e.g., a role attached to a policy)
-- **Dashed lines:** Connections between resources and their associated findings
+- **Normal edges:** Direct relationships between graph nodes, such as role-to-policy or resource-to-security-group relationships.
+- **Finding edges:** Dashed relationships between resources and their associated findings.
+- **Highlighted paths:** Green edges that show the active path when you hover a node or focus a finding.
-A **legend** at the bottom of the graph lists all node types and edge types present in the current view.
+The standard graph view includes a minimap and a legend below the canvas. The legend shows the provider roots, visible node types, finding risk levels, node states, and edge types present in the current view.
## Interacting with the Graph
-### Filtering by Node
+The graph banner describes the main interactions:
-Click any node in the graph to filter the view and display only paths that pass through that node. When a filter is active:
+- Click a finding to focus its connected path.
+- Click a resource with findings to show or hide its related findings.
+- Hover a node to highlight its connected path.
-- An information banner shows which node is selected
-- Click **Back to Full View** to restore the complete graph
+### Showing Related Findings
+
+Resource nodes with related findings are clickable. Click one of these resources to show its finding nodes. Click the resource again to hide them.
+
+The graph automatically fits the selected resource and its related findings when the findings are shown.
+
+### Focusing a Finding Path
+
+Click a finding node to focus the graph on the path connected to that finding. When the graph is focused:
+
+- The graph shows **Back to Full View**.
+- The status banner shows the selected finding.
+- The graph keeps only the connected path in view.
+- The finding detail drawer opens.
+
+After you close the drawer, the graph remains focused on the selected path.
@@ -258,29 +367,29 @@ Click any node in the graph to filter the view and display only paths that pass
The toolbar in the top-right corner of the graph provides:
- **Zoom in / Zoom out:** Adjust the zoom level
-- **Fit to screen:** Reset the view to fit all nodes
-- **Export:** Download the current graph as an SVG file
-- **Fullscreen:** Open the graph in a full-screen modal with a side-by-side node detail panel
+- **Fit graph to view:** Reset the view to fit the visible graph
+- **Export graph:** Download the current graph as a PNG file
+- **Fullscreen:** Open the graph in a full-size modal
Use **Ctrl + Scroll** (or **Cmd + Scroll** on macOS) to zoom directly within
the graph area.
-## Viewing Node Details
+## Viewing Finding Details
-Click any node to open the **Node Details** panel below the graph. This panel displays:
+Click a finding node to open the finding detail drawer. The drawer uses the same finding detail layout as the Findings page and includes:
-- **Node type:** The resource category (e.g., "IAM Role," "EC2 Instance")
-- **Properties:** All attributes of the selected node, including identifiers, timestamps, and configuration details
-- **Related findings** (for resource nodes): A list of Prowler findings linked to the resource, with severity, title, and status
-- **Affected resources** (for finding nodes): A list of resources associated with the finding
+- The finding title, status, and severity.
+- The affected resource summary.
+- Overview, remediation, evidence, related findings, scans, and events tabs when data is available.
+- A Lighthouse AI action when the account has access to Lighthouse AI.
-For finding nodes, a "View Finding" button links directly to the finding detail page for further investigation.
+Resource nodes do not open a node detail panel. When a resource has related findings, clicking it expands or collapses those finding nodes in the graph.
@@ -288,16 +397,28 @@ For finding nodes, a "View Finding" button links directly to the finding detail
To expand the graph for detailed exploration, click the fullscreen icon in the graph toolbar. The fullscreen modal provides:
-- The full graph visualization with all zoom and export controls
-- A side panel for node details that appears when a node is selected
-- All filtering and interaction capabilities available in the standard view
+- The graph in a full-size modal.
+- The same zoom, fit, and export controls.
+- The same node expansion, finding focus, hover highlight, and minimap interactions available in the standard view.
+## Available Queries
+
+The query selector includes custom openCypher and built-in AWS queries for common security investigation workflows. Available queries are loaded from the selected scan and may change as new query packs are added.
+
+Available queries include:
+
+- **Custom openCypher query:** Write and run a read-only graph query.
+- **Exposure queries:** Find internet-exposed EC2 instances, load balancers, open security groups, and resources by public IP.
+- **Inventory queries:** List resources such as RDS instances.
+- **Misconfiguration queries:** Find unencrypted RDS instances, public S3 buckets, and wildcard IAM statements.
+- **Privilege escalation queries:** Detect IAM and AWS service paths based on known attack techniques, including queries based on [pathfinding.cloud](https://pathfinding.cloud) research by Datadog.
+
## Using Attack Paths with the MCP Server and Lighthouse AI
Attack Paths capabilities are also available through the [Prowler MCP Server](/getting-started/products/prowler-mcp), enabling interaction with Attack Paths data via AI assistants like Claude Desktop, Cursor, and other MCP clients.
@@ -306,10 +427,10 @@ Attack Paths capabilities are also available through the [Prowler MCP Server](/g
The following MCP tools are available for Attack Paths:
-- **`prowler_app_list_attack_paths_scans`** - List and filter Attack Paths scans
-- **`prowler_app_list_attack_paths_queries`** - Discover available queries for a completed scan
-- **`prowler_app_run_attack_paths_query`** - Execute a query and retrieve graph results with nodes and relationships
-- **`prowler_app_get_attack_paths_cartography_schema`** - Retrieve the Cartography graph schema for custom openCypher queries
+- **`prowler_list_attack_paths_scans`** - List and filter Attack Paths scans.
+- **`prowler_list_attack_paths_queries`** - Discover available queries for a completed scan.
+- **`prowler_run_attack_paths_query`** - Execute a query and retrieve graph results with nodes and relationships.
+- **`prowler_get_attack_paths_cartography_schema`** - Retrieve the Cartography graph schema for custom openCypher queries.
### Example Questions
@@ -324,111 +445,8 @@ Ask through the MCP Server or Lighthouse AI:
- "Are there any CloudFormation stacks that could be hijacked for privilege escalation?"
- "Show me all roles that can be assumed for lateral movement"
-### Supported Queries
-
-Attack Paths currently supports the following built-in queries for AWS:
-
-#### Custom Attack Path Queries
-
-| Query | Description |
-|---|---|
-| **Internet-Exposed EC2 with Sensitive S3 Access** | Find SSH-exposed EC2 instances that can assume roles to read tagged sensitive S3 buckets |
-
-#### Basic Resource Queries
-
-| Query | Description |
-|---|---|
-| **RDS Instances Inventory** | List all provisioned RDS database instances in the account |
-| **Unencrypted RDS Instances** | Find RDS instances with storage encryption disabled |
-| **S3 Buckets with Anonymous Access** | Find S3 buckets that allow anonymous access |
-| **IAM Statements Allowing All Actions** | Find IAM policy statements that allow all actions via wildcard (\*) |
-| **IAM Statements Allowing Policy Deletion** | Find IAM policy statements that allow iam:DeletePolicy |
-| **IAM Statements Allowing Create Actions** | Find IAM policy statements that allow any create action |
-
-#### Network Exposure Queries
-
-| Query | Description |
-|---|---|
-| **Internet-Exposed EC2 Instances** | Find EC2 instances flagged as exposed to the internet |
-| **Open Security Groups on Internet-Facing Resources** | Find internet-facing resources with security groups allowing inbound from 0.0.0.0/0 |
-| **Internet-Exposed Classic Load Balancers** | Find Classic Load Balancers exposed to the internet with their listeners |
-| **Internet-Exposed ALB/NLB Load Balancers** | Find ELBv2 (ALB/NLB) load balancers exposed to the internet with their listeners |
-| **Resource Lookup by Public IP** | Find the AWS resource associated with a given public IP address |
-
-#### Privilege Escalation Queries
-
-These queries are based on research from [pathfinding.cloud](https://pathfinding.cloud) by Datadog.
-
-| Query | Description |
-|---|---|
-| **App Runner Service Creation with Privileged Role (APPRUNNER-001)** | Create an App Runner service with a privileged IAM role to gain its permissions |
-| **App Runner Service Update for Role Access (APPRUNNER-002)** | Update an existing App Runner service to leverage its already-attached privileged role |
-| **Bedrock Code Interpreter with Privileged Role (BEDROCK-001)** | Create a Bedrock AgentCore Code Interpreter with a privileged role attached |
-| **Bedrock Code Interpreter Session Hijacking (BEDROCK-002)** | Start a session on an existing Bedrock code interpreter to exfiltrate its privileged role credentials |
-| **CloudFormation Stack Creation with Privileged Role (CLOUDFORMATION-001)** | Create a CloudFormation stack with a privileged role to provision arbitrary AWS resources |
-| **CloudFormation Stack Update for Role Access (CLOUDFORMATION-002)** | Update an existing CloudFormation stack to leverage its already-attached privileged service role |
-| **CloudFormation StackSet Creation with Privileged Role (CLOUDFORMATION-003)** | Create a CloudFormation StackSet with a privileged execution role to provision arbitrary resources across accounts |
-| **CloudFormation StackSet Update with Privileged Role (CLOUDFORMATION-004)** | Update an existing CloudFormation StackSet to inject malicious resources using a privileged execution role |
-| **CloudFormation Change Set Privilege Escalation (CLOUDFORMATION-005)** | Create and execute a change set on an existing stack to leverage its privileged service role |
-| **CodeBuild Project Creation with Privileged Role (CODEBUILD-001)** | Create a CodeBuild project with a privileged role to execute arbitrary code via a malicious buildspec |
-| **CodeBuild Buildspec Override for Role Access (CODEBUILD-002)** | Start a build on an existing CodeBuild project with a buildspec override to execute code with its privileged role |
-| **CodeBuild Batch Buildspec Override for Role Access (CODEBUILD-003)** | Start a batch build on an existing CodeBuild project with a buildspec override to execute code with its privileged role |
-| **CodeBuild Batch Project Creation with Privileged Role (CODEBUILD-004)** | Create a CodeBuild project configured for batch builds with a privileged role to execute arbitrary code via a malicious buildspec |
-| **Data Pipeline Creation with Privileged Role (DATAPIPELINE-001)** | Create a Data Pipeline with a privileged role to execute arbitrary commands on provisioned infrastructure |
-| **EC2 Instance Launch with Privileged Role (EC2-001)** | Launch EC2 instances with privileged IAM roles to gain their permissions via IMDS |
-| **EC2 Role Hijacking via UserData Injection (EC2-002)** | Inject malicious scripts into EC2 instance userData to gain the attached role's permissions |
-| **Spot Instance Launch with Privileged Role (EC2-003)** | Launch EC2 Spot Instances with privileged IAM roles to gain their permissions via IMDS |
-| **Launch Template Poisoning for Role Access (EC2-004)** | Inject malicious userData into launch templates that reference privileged roles, no PassRole needed |
-| **EC2 Instance Connect SSH Access for Role Credentials (EC2INSTANCECONNECT-003)** | Push a temporary SSH key to an EC2 instance via Instance Connect to access its attached role credentials through IMDS |
-| **ECS Service Creation with Privileged Role (ECS-001 - New Cluster)** | Create an ECS cluster and service with a privileged Fargate task role to execute arbitrary code |
-| **ECS Task Execution with Privileged Role (ECS-002 - New Cluster)** | Create an ECS cluster and run a one-off Fargate task with a privileged role to execute arbitrary code |
-| **ECS Service Creation with Privileged Role (ECS-003 - Existing Cluster)** | Deploy a Fargate service with a privileged role on an existing ECS cluster |
-| **ECS Task Execution with Privileged Role (ECS-004 - Existing Cluster)** | Run a one-off Fargate task with a privileged role on an existing ECS cluster |
-| **ECS Task Start with Privileged Role on EC2 (ECS-005 - Existing Cluster)** | Register a task definition with a privileged role and start it on an EC2 container instance to execute arbitrary code |
-| **ECS Exec Container Hijacking for Role Credentials (ECS-006)** | Shell into a running ECS container via ECS Exec to steal the attached task role's credentials |
-| **Glue Dev Endpoint with Privileged Role (GLUE-001)** | Create a Glue development endpoint with a privileged role attached to gain its permissions |
-| **Glue Dev Endpoint SSH Hijacking via Update (GLUE-002)** | Update an existing Glue development endpoint to inject an SSH public key and access its attached role credentials |
-| **Glue Job Creation with Privileged Role (GLUE-003)** | Create a Glue job with a privileged role and start it to execute arbitrary code with that role's permissions |
-| **Glue Job Creation with Scheduled Trigger and Privileged Role (GLUE-004)** | Create a Glue job with a privileged role and a scheduled trigger to persistently execute arbitrary code |
-| **Glue Job Hijacking via Update with Privileged Role (GLUE-005)** | Update an existing Glue job to attach a privileged role and inject malicious code, then start it to gain that role's permissions |
-| **Glue Job Hijacking with Scheduled Trigger and Privileged Role (GLUE-006)** | Update an existing Glue job to attach a privileged role and inject malicious code, then create a scheduled trigger for persistent automated execution |
-| **Policy Version Override for Self-Escalation (IAM-001)** | Create a new version of an attached policy with administrative permissions, instantly escalating the principal's own privileges |
-| **Access Key Creation for Lateral Movement (IAM-002)** | Create access keys for other IAM users to gain their permissions and move laterally across the account |
-| **Access Key Rotation Attack for Lateral Movement (IAM-003)** | Delete and recreate access keys for other IAM users to bypass the two-key limit and gain their permissions |
-| **Console Login Profile Creation for Lateral Movement (IAM-004)** | Create console login profiles for other IAM users to access the AWS Console with their permissions |
-| **Inline Policy Injection for Self-Escalation (IAM-005)** | Attach an inline policy with administrative permissions to your own role, instantly escalating privileges |
-| **Console Password Override for Lateral Movement (IAM-006)** | Change the console password of other IAM users to log in as them and gain their permissions |
-| **Inline Policy Injection on User for Self-Escalation (IAM-007)** | Attach an inline policy with administrative permissions to your own IAM user, instantly escalating privileges |
-| **Managed Policy Attachment on User for Self-Escalation (IAM-008)** | Attach existing managed policies with administrative permissions to your own IAM user, instantly escalating privileges |
-| **Managed Policy Attachment on Role for Self-Escalation (IAM-009)** | Attach existing managed policies with administrative permissions to your own IAM role, instantly escalating privileges |
-| **Managed Policy Attachment on Group for Self-Escalation (IAM-010)** | Attach existing managed policies with administrative permissions to a group you belong to, escalating privileges for all group members |
-| **Inline Policy Injection on Group for Self-Escalation (IAM-011)** | Attach an inline policy with administrative permissions to a group you belong to, escalating privileges for all group members |
-| **Trust Policy Hijacking for Role Assumption (IAM-012)** | Modify a role's trust policy to allow yourself to assume it, gaining the role's permissions |
-| **Group Membership Hijacking for Privilege Escalation (IAM-013)** | Add yourself to a privileged IAM group to inherit its permissions, gaining access to all policies attached to the group |
-| **Managed Policy Attachment with Role Assumption for Lateral Movement (IAM-014)** | Attach administrative managed policies to another role you can assume, then assume it to gain elevated privileges |
-| **Managed Policy Attachment with Access Key Creation for Lateral Movement (IAM-015)** | Attach administrative managed policies to another IAM user and create access keys for them to gain programmatic access with elevated privileges |
-| **Policy Version Override with Role Assumption for Lateral Movement (IAM-016)** | Create a new version of a customer-managed policy attached to another role with administrative permissions, then assume that role to gain elevated access |
-| **Inline Policy Injection with Role Assumption for Lateral Movement (IAM-017)** | Attach an inline policy with administrative permissions to another role you can assume, then assume it to gain elevated privileges |
-| **Inline Policy Injection with Access Key Creation for Lateral Movement (IAM-018)** | Attach an inline policy with administrative permissions to another IAM user and create access keys for them to gain programmatic access with elevated privileges |
-| **Managed Policy Attachment with Trust Policy Hijacking for Privilege Escalation (IAM-019)** | Attach administrative managed policies to a role and modify its trust policy to allow yourself to assume it, gaining elevated privileges without prior assume-role access |
-| **Policy Version Override with Trust Policy Hijacking for Privilege Escalation (IAM-020)** | Create a new version of a customer-managed policy attached to a role with administrative permissions and modify its trust policy to assume it, without prior assume-role access |
-| **Inline Policy Injection with Trust Policy Hijacking for Privilege Escalation (IAM-021)** | Add an inline policy with administrative permissions to a role and modify its trust policy to allow yourself to assume it, gaining elevated privileges without prior assume-role access |
-| **Lambda Function Creation with Privileged Role (LAMBDA-001)** | Create a Lambda function with a privileged IAM role and invoke it to execute code with that role's permissions |
-| **Lambda Function Creation with Event Source Trigger (LAMBDA-002)** | Create a Lambda function with a privileged IAM role and an event source mapping to trigger it automatically, executing code with the role's permissions |
-| **Lambda Function Code Injection (LAMBDA-003)** | Modify the code of an existing Lambda function to execute arbitrary commands with the function's execution role permissions |
-| **Lambda Function Code Injection with Direct Invocation (LAMBDA-004)** | Modify the code of an existing Lambda function and invoke it directly to execute arbitrary commands with the function's execution role permissions |
-| **Lambda Function Code Injection with Resource Policy Grant (LAMBDA-005)** | Modify the code of an existing Lambda function and grant yourself invocation permission via its resource-based policy to execute code with the function's execution role |
-| **Lambda Function Creation with Resource Policy Invocation (LAMBDA-006)** | Create a Lambda function with a privileged IAM role and grant yourself invocation permission via its resource-based policy to execute code with the role's permissions |
-| **SageMaker Notebook Creation with Privileged Role (SAGEMAKER-001)** | Create a SageMaker notebook instance with a privileged IAM role to execute arbitrary code with the role's permissions via the Jupyter environment |
-| **SageMaker Training Job Creation with Privileged Role (SAGEMAKER-002)** | Create a SageMaker training job with a privileged IAM role to execute arbitrary container code with the role's permissions |
-| **SageMaker Processing Job Creation with Privileged Role (SAGEMAKER-003)** | Create a SageMaker processing job with a privileged IAM role to execute arbitrary container code with the role's permissions |
-| **SageMaker Presigned Notebook URL for Privilege Escalation (SAGEMAKER-004)** | Generate a presigned URL to access an existing SageMaker notebook instance and execute code with its execution role's permissions |
-| **SageMaker Notebook Lifecycle Config Injection (SAGEMAKER-005)** | Inject a malicious lifecycle configuration into an existing SageMaker notebook to execute code with the notebook's execution role during startup |
-| **SSM Session Access for EC2 Role Credentials (SSM-001)** | Start an SSM session on an EC2 instance to access its attached role credentials through IMDS |
-| **SSM Send Command for EC2 Role Credentials (SSM-002)** | Execute commands on an EC2 instance via SSM Run Command to access its attached role credentials through IMDS |
-| **Role Assumption for Privilege Escalation (STS-001)** | Assume IAM roles with elevated permissions by exploiting bidirectional trust between the starting principal and the target role |
-
These tools enable workflows such as:
+
- Asking an AI assistant to identify privilege escalation paths in a specific AWS account
- Automating attack path analysis across multiple scans
- Combining attack path data with findings and compliance information for comprehensive security reports
diff --git a/docs/user-guide/tutorials/prowler-app-finding-groups.mdx b/docs/user-guide/tutorials/prowler-app-finding-groups.mdx
index e1bf83a50f..c5234bb41f 100644
--- a/docs/user-guide/tutorials/prowler-app-finding-groups.mdx
+++ b/docs/user-guide/tutorials/prowler-app-finding-groups.mdx
@@ -1,12 +1,16 @@
---
title: 'Finding Groups'
+sidebarTitle: 'Groups'
description: 'Organize and triage security findings by check to reduce noise and prioritize remediation effectively.'
---
import { VersionBadge } from "/snippets/version-badge.mdx"
+import { AppliesTo } from "/snippets/applies-to.mdx"
+
+
Finding Groups transforms security findings triage by grouping them by check instead of displaying a flat list. This dramatically reduces noise and enables faster, more effective prioritization.
## Triage Challenges with Flat Finding Lists
@@ -112,7 +116,7 @@ This provides full context without leaving the drawer.
## Getting Started
-1. Navigate to the **Findings** section in Prowler Cloud/App.
+1. Navigate to the **Findings** section in Prowler Cloud.
2. Toggle to the **Grouped View** to see findings organized by check.
3. Select any group row to expand and see affected resources.
4. Select a resource to open the detail drawer with full context.
diff --git a/docs/user-guide/tutorials/prowler-app-findings-triage.mdx b/docs/user-guide/tutorials/prowler-app-findings-triage.mdx
new file mode 100644
index 0000000000..bbceaef992
--- /dev/null
+++ b/docs/user-guide/tutorials/prowler-app-findings-triage.mdx
@@ -0,0 +1,125 @@
+---
+title: "Findings Triage"
+sidebarTitle: 'Triage'
+description: "Track finding review status and team notes in Prowler Cloud."
+---
+
+import { VersionBadge } from "/snippets/version-badge.mdx"
+import { SubscriptionBanner } from "/snippets/subscription-banner.mdx"
+
+
+
+Findings Triage lets teams track review status and notes for individual findings in Prowler Cloud. Use it to record investigation state, remediation work, accepted risk, or false positive decisions without leaving the Findings workflow.
+
+
+
+## What Is Findings Triage?
+
+Findings Triage adds a **Triage** status and team note workflow to individual finding rows. It is available from:
+
+- Expanded rows in **Finding Groups**
+- Standalone finding tables
+- Finding and resource detail drawers, including related findings tables
+
+Finding Groups rows do not show triage controls because a group row represents several findings. Expand a group to work with each affected resource.
+
+
+
+## Required Permissions
+
+To update triage statuses and notes, the user role must have the **Manage Scans** permission. For more information, see [Role-Based Access Control (RBAC)](/user-guide/tutorials/prowler-app-rbac).
+
+Users without this permission can still see existing triage context when it is available, but cannot change statuses or save notes.
+
+## Triage Statuses
+
+The status selector includes manual statuses. Prowler also sets automatic statuses after scans.
+
+| Status | Type | Use It When |
+| --- | --- | --- |
+| **Open** | Manual | A failed finding has not been reviewed yet. A failed finding with no saved triage state also appears as **Open**. |
+| **Under Review** | Manual | A team is investigating the finding. |
+| **Remediating** | Manual | Work is in progress to fix the finding. |
+| **Risk Accepted** | Manual | The team accepts the risk and wants to mute the finding. |
+| **False Positive** | Manual | The finding does not apply and should be muted. |
+| **Resolved** | Automatic | A finding changed from `FAIL` to `PASS` in a later scan. A passed finding with no saved triage state also appears as **Resolved**. |
+| **Reopened** | Automatic | A finding changed from `PASS` to `FAIL` in a later scan. |
+
+
+
+Resolved and Reopened are not manual selector options.
+
+These automatic states keep triage tied to the finding UID across scans, even when each scan creates a new finding snapshot.
+
+## Change a Triage Status
+
+
+
+ Go to **Findings** in Prowler Cloud.
+
+
+ Expand a Finding Group, open a resource findings table, or use a standalone finding row.
+
+
+ In the **Triage** column, click the current status.
+
+
+ Select **Open**, **Under Review**, **Remediating**, **Risk Accepted**, or **False Positive**.
+
+
+
+Changing a finding to **Risk Accepted** or **False Positive** will mute the finding. Prowler asks for confirmation and creates a mute rule for the finding.
+
+## Add or Edit a Triage Note
+
+Triage notes are visible only to the team in the current organization. Each note supports up to 500 characters.
+
+
+
+ On an individual finding row, click the actions menu.
+
+
+ Click **Add Triage Note**. If a note already exists, click **Open note**.
+
+
+ Optionally change the status, then write the note.
+
+
+ Click **Save changes**.
+
+
+
+
+
+To remove an existing note, clear the note text and save the change.
+
+## Mutelist Behavior
+
+Findings Triage uses Mutelist when a status means the finding should be muted:
+
+- **Risk Accepted** creates a mute rule because the team accepts the finding as a known risk.
+- **False Positive** creates a mute rule because the finding should not count as an active issue.
+
+Use [Simple Mutelist](/user-guide/tutorials/prowler-app-simple-mutelist) to review, disable, or delete mute rules created through this workflow. For pattern-based muting, use [Advanced Mutelist](/user-guide/tutorials/prowler-app-mute-findings).
+
+
+Muting a finding does not fix the underlying configuration. Review the finding before using **Risk Accepted** or **False Positive**.
+
+
+## Troubleshooting
+
+### Triage controls do not appear
+
+Make sure the row is an individual finding row. Finding Groups rows do not show triage controls. Expand a group to see affected resources and their triage controls.
+
+### Changes cannot be saved
+
+Confirm that the user role has **Manage Scans** permission. Prowler Local Server does not support Findings Triage writes.
+
+### Resolved or Reopened is missing from the selector
+
+This is expected. Prowler sets **Resolved** and **Reopened** automatically from scan result changes.
+
+### Risk Accepted or False Positive muted a finding
+
+This is expected. Those statuses create a mute rule through Mutelist.
diff --git a/docs/user-guide/tutorials/prowler-app-github-action.mdx b/docs/user-guide/tutorials/prowler-app-github-action.mdx
index 5b80511b89..c874a37164 100644
--- a/docs/user-guide/tutorials/prowler-app-github-action.mdx
+++ b/docs/user-guide/tutorials/prowler-app-github-action.mdx
@@ -18,7 +18,7 @@ Source: [`prowler-cloud/prowler`](https://github.com/prowler-cloud/prowler) · M
| `provider` | yes | — | Cloud provider to scan (`aws`, `azure`, `gcp`, `github`, `kubernetes`, `iac`, `cloudflare`, etc.) |
| `image-tag` | no | `stable` | Docker image tag — `stable` (latest release), `latest` (master, not stable), or `` (pinned). See [available tags](https://hub.docker.com/r/prowlercloud/prowler/tags). |
| `output-formats` | no | `json-ocsf` | Output format(s) for scan results. Space-separated (e.g. `sarif json-ocsf`) |
-| `push-to-cloud` | no | `false` | Push findings to [Prowler Cloud](/user-guide/tutorials/prowler-app-import-findings). When `true`, `PROWLER_CLOUD_API_KEY` is auto-forwarded |
+| `push-to-cloud` | no | `false` | Push findings to [Prowler Cloud](/user-guide/tutorials/prowler-import-findings). When `true`, `PROWLER_CLOUD_API_KEY` is auto-forwarded |
| `flags` | no | `""` | Additional CLI flags (e.g. `--severity critical high`). Values with spaces can be quoted: `--resource-tag 'Environment=My Server'` |
| `extra-env` | no | `""` | Space-, newline-, or comma-separated list of env var **names** to forward to the container (see [Authentication](#authentication)) |
| `upload-sarif` | no | `false` | Upload SARIF results to GitHub Code Scanning |
@@ -43,7 +43,7 @@ Source: [`prowler-cloud/prowler`](https://github.com/prowler-cloud/prowler) · M
### Push findings to Prowler Cloud
-Send scan results directly to [Prowler Cloud](/user-guide/tutorials/prowler-app-import-findings) for centralized visibility, compliance tracking, and team collaboration.
+Send scan results directly to [Prowler Cloud](/user-guide/tutorials/prowler-import-findings) for centralized visibility, compliance tracking, and team collaboration.
```yaml
- uses: prowler-cloud/prowler@5.25
diff --git a/docs/user-guide/tutorials/prowler-app-jira-integration.mdx b/docs/user-guide/tutorials/prowler-app-jira-integration.mdx
index 01779a6a96..83554ee8c0 100644
--- a/docs/user-guide/tutorials/prowler-app-jira-integration.mdx
+++ b/docs/user-guide/tutorials/prowler-app-jira-integration.mdx
@@ -1,13 +1,17 @@
---
title: "Jira Integration"
+sidebarTitle: 'Jira'
---
import { VersionBadge } from "/snippets/version-badge.mdx"
+import { AppliesTo } from "/snippets/applies-to.mdx"
-Prowler App enables automatic export of security findings to Jira, providing seamless integration with Atlassian's work item tracking and project management platform. This comprehensive guide demonstrates how to configure and manage Jira integrations to streamline security incident management and enhance team collaboration across security workflows.
+
-Integrating Prowler App with Jira provides:
+Prowler Cloud enables automatic export of security findings to Jira, providing seamless integration with Atlassian's work item tracking and project management platform. This comprehensive guide demonstrates how to configure and manage Jira integrations to streamline security incident management and enhance team collaboration across security workflows.
+
+Integrating Prowler Cloud with Jira provides:
* **Streamlined management:** Convert security findings directly into actionable Jira work items
* **Enhanced team collaboration:** Leverage existing project management workflows for security remediation
@@ -22,9 +26,9 @@ When enabled and configured:
## Configuration
-To configure Jira integration in Prowler App:
+To configure Jira integration in Prowler Cloud:
-1. Navigate to **Integrations** in the Prowler App interface
+1. Navigate to **Integrations** in Prowler Cloud
2. Locate the **Jira** card and click **Manage**, then select **Add integration**

@@ -50,7 +54,7 @@ Once configured successfully, the integration is ready to send findings to Jira.
To manually send individual findings to Jira:
-1. Navigate to the **Findings** section in Prowler App
+1. Navigate to the **Findings** section in Prowler Cloud
2. Select one finding you want to export
3. Click the action button on the table row and select **Send to Jira**
4. Select the Jira integration and project
diff --git a/docs/user-guide/tutorials/prowler-app-lighthouse-multi-llm.mdx b/docs/user-guide/tutorials/prowler-app-lighthouse-multi-llm.mdx
index 0a0179ee79..eee7564bcb 100644
--- a/docs/user-guide/tutorials/prowler-app-lighthouse-multi-llm.mdx
+++ b/docs/user-guide/tutorials/prowler-app-lighthouse-multi-llm.mdx
@@ -1,5 +1,6 @@
---
title: 'Using Multiple LLM Providers with Lighthouse'
+sidebarTitle: 'Multiple LLM Providers'
---
import { VersionBadge } from "/snippets/version-badge.mdx"
@@ -32,13 +33,13 @@ All three providers can be configured for a tenant, but only one can be set as t
When visiting Lighthouse AI chat, the default provider's default model loads automatically. Users can switch to any available LLM model (including those from non-default providers) using the dropdown in chat.
-
+
## Configuring Providers
Navigate to **Configuration** → **Lighthouse AI** to see all three provider options with a **Connect** button under each.
-
+
### Connecting a Provider
@@ -128,6 +129,25 @@ To connect a provider:
3. Configure in Lighthouse AI:
- **API Key**: OpenRouter API key
- **Base URL**: `https://openrouter.ai/api/v1`
+
+ ### Base URL Validation
+
+ To prevent server-side request forgery (SSRF), Prowler API validates the base URL before connecting to it:
+
+ - The URL must use HTTPS.
+ - The host must resolve to a public IP address. Private, loopback, link-local, and cloud metadata addresses are rejected.
+
+
+ This validation can break configurations that point to internal endpoints, such as a self-hosted Ollama server. This is intentional: it fixes a security issue where the Prowler API could be directed to internal services. Internal endpoints must now be allowed explicitly through `LIGHTHOUSE_AI_OPENAI_COMPATIBLE_ALLOWED_HOSTS`.
+
+
+ To allow internal endpoints, set a comma-separated list of hostnames or IP addresses in the Prowler API environment (for Docker Compose deployments, the shared `.env` file):
+
+ ```bash
+ LIGHTHOUSE_AI_OPENAI_COMPATIBLE_ALLOWED_HOSTS=custom-openai.internal,10.0.0.20
+ ```
+
+ Hosts in this list skip the public-endpoint validation. HTTPS is still required, so the endpoint needs a certificate the Prowler API trusts.
@@ -139,7 +159,7 @@ To set a different provider as default:
2. Click **Configure** under the desired provider to set as default
3. Click **Set as Default**
-
+
## Updating Provider Credentials
diff --git a/docs/user-guide/tutorials/prowler-app-lighthouse.mdx b/docs/user-guide/tutorials/prowler-app-lighthouse.mdx
index b119893cb5..5b4a0f5847 100644
--- a/docs/user-guide/tutorials/prowler-app-lighthouse.mdx
+++ b/docs/user-guide/tutorials/prowler-app-lighthouse.mdx
@@ -8,6 +8,10 @@ import { VersionBadge } from "/snippets/version-badge.mdx"
Prowler Lighthouse AI integrates Large Language Models (LLMs) with Prowler security findings data.
+
+Using Prowler Cloud? Lighthouse AI on Prowler Cloud adds persistent chat sessions, GPT-5.5 as the default model, a dedicated agentic (chat) view, and transparent reasoning. See [Lighthouse AI on Prowler Cloud](/getting-started/products/prowler-cloud-lighthouse).
+
+
Behind the scenes, Lighthouse AI works as follows:
- Lighthouse AI runs as a [Langchain agent](https://docs.langchain.com/oss/javascript/langchain/agents) in NextJS
@@ -15,7 +19,7 @@ Behind the scenes, Lighthouse AI works as follows:
- The agent accesses Prowler data through [Prowler MCP](https://docs.prowler.com/getting-started/products/prowler-mcp), which exposes tools from multiple sources, including:
- Prowler Hub
- Prowler Docs
- - Prowler App
+ - Prowler Local Server
- Instead of calling every tool directly, the agent uses two meta-tools:
- `describe_tool` to retrieve a tool schema and parameter requirements.
- `execute_tool` to run the selected tool with the required input.
@@ -47,7 +51,7 @@ Getting started with Prowler Lighthouse AI is easy:
For detailed configuration instructions for each provider, see [Using Multiple LLM Providers with Lighthouse](/user-guide/tutorials/prowler-app-lighthouse-multi-llm).
-
+
### Adding Business Context
diff --git a/docs/user-guide/tutorials/prowler-app-multi-tenant.mdx b/docs/user-guide/tutorials/prowler-app-multi-tenant.mdx
index 26f73000b3..8de9365fe9 100644
--- a/docs/user-guide/tutorials/prowler-app-multi-tenant.mdx
+++ b/docs/user-guide/tutorials/prowler-app-multi-tenant.mdx
@@ -1,12 +1,16 @@
---
title: 'Managing Organizations (Multi-Tenant)'
+sidebarTitle: 'Organizations'
---
import { VersionBadge } from "/snippets/version-badge.mdx"
+import { AppliesTo } from "/snippets/applies-to.mdx"
-Prowler App supports multi-tenancy through **Organizations**, allowing users to belong to multiple isolated environments within a single account. Each organization maintains its own providers, scans, findings, and user memberships, ensuring complete data separation between teams or business units.
+
+
+Prowler Cloud supports multi-tenancy through **Organizations**, allowing users to belong to multiple isolated environments within a single account. Each organization maintains its own providers, scans, findings, and user memberships, ensuring complete data separation between teams or business units.
## Key Concepts
@@ -67,7 +71,14 @@ The currently active organization is indicated by an **Active** badge. Switching
## Editing an Organization Name
-Organization owners with the **Manage Account** permission can rename an organization:
+Renaming an organization requires **both** of the following conditions to be met:
+
+* The user's **membership role** in that organization must be `owner` (visible as the `owner` badge in the Organizations card).
+* The user must have a role that grants the **Manage Account** permission.
+
+Users who only meet one of the two conditions will not see the **Edit** button. For example, a user whose membership role is `member` will not see the **Edit** button even if their role grants `Manage Account`.
+
+To rename an organization:
1. Navigate to the **Profile** page.
@@ -121,7 +132,7 @@ When invited to join an organization, the invited user receives a link to accept
1. Open the invitation link.
-2. If already authenticated, the invitation is accepted automatically and the user is redirected to Prowler App.
+2. If already authenticated, the invitation is accepted automatically and the user is redirected to Prowler Cloud.
3. If not authenticated, choose **I have an account -- Sign in**, authenticate with existing credentials, and the invitation is accepted upon sign-in.
@@ -175,6 +186,6 @@ If the expelled organization was the user's **only** organization, the account i
| View organizations | Any authenticated user |
| Create an organization | Any authenticated user |
| Switch organizations | Any authenticated user |
-| Edit organization name | Organization owner with **Manage Account** permission |
-| Delete an organization | Organization owner with **Manage Account** permission; must belong to more than one organization |
+| Edit organization name | Membership role `owner` **and** a role with **Manage Account** permission |
+| Delete an organization | Membership role `owner` **and** a role with **Manage Account** permission; must belong to more than one organization |
| Expel a user from an organization | Organization owner (no additional permission required); last remaining owner cannot expel themselves |
diff --git a/docs/user-guide/tutorials/prowler-app-mute-findings.mdx b/docs/user-guide/tutorials/prowler-app-mute-findings.mdx
index 82623b54cf..c2ada47e9c 100644
--- a/docs/user-guide/tutorials/prowler-app-mute-findings.mdx
+++ b/docs/user-guide/tutorials/prowler-app-mute-findings.mdx
@@ -1,11 +1,14 @@
---
-title: 'Advanced Mutelist (YAML)'
+title: 'Advanced Mutelist'
---
import { VersionBadge } from "/snippets/version-badge.mdx"
+import { AppliesTo } from "/snippets/applies-to.mdx"
-Prowler App allows users to mute specific findings to focus on the most critical security issues. This guide demonstrates how to use the Advanced Mutelist feature with YAML configuration for complex, pattern-based muting rules.
+
+
+Prowler Cloud allows users to mute specific findings to focus on the most critical security issues. This guide demonstrates how to use the Advanced Mutelist feature with YAML configuration for complex, pattern-based muting rules.
For muting individual findings without YAML configuration, use [Simple Mutelist](/user-guide/tutorials/prowler-app-simple-mutelist) to mute findings directly from the Findings table.
@@ -31,8 +34,8 @@ Advanced Mutelist requires the **Manage Account** permission. See [RBAC Administ
Before muting findings, ensure:
-- Valid access to Prowler App with appropriate permissions
-- A provider added to the Prowler App
+- Valid access to Prowler Cloud with appropriate permissions
+- A provider added to Prowler Cloud
- Understanding of the security implications of muting specific findings
@@ -43,7 +46,7 @@ Muting findings does not resolve underlying security issues. Review each finding
To configure Advanced Mutelist:
-1. Log into Prowler App
+1. Log into Prowler Cloud
2. Navigate to the Providers page

3. Connect a provider to enable Mutelist configuration
@@ -423,7 +426,7 @@ Mutelist:
### Priority: Advanced vs. Simple Mutelist
-When both Advanced Mutelist (YAML) and [Simple Mutelist](/user-guide/tutorials/prowler-app-simple-mutelist) rules match the same finding, the **Advanced Mutelist takes higher priority**. The finding will be muted with the reason "Muted by mutelist". If a finding is not matched by the Advanced Mutelist but matches a Simple Mutelist rule, the Simple rule's custom justification is used instead.
+When both Advanced Mutelist and [Simple Mutelist](/user-guide/tutorials/prowler-app-simple-mutelist) rules match the same finding, the **Advanced Mutelist takes higher priority**. The finding will be muted with the reason "Muted by mutelist". If a finding is not matched by the Advanced Mutelist but matches a Simple Mutelist rule, the Simple rule's custom justification is used instead.
### Best Practices
@@ -436,7 +439,7 @@ When both Advanced Mutelist (YAML) and [Simple Mutelist](/user-guide/tutorials/p
### Validation
-Prowler App validates your mutelist configuration and will display errors for:
+Prowler Cloud validates the mutelist configuration and will display errors for:
- Invalid YAML syntax
- Missing required fields
diff --git a/docs/user-guide/tutorials/prowler-app-rbac.mdx b/docs/user-guide/tutorials/prowler-app-rbac.mdx
index cccbbc27cc..4533e0423c 100644
--- a/docs/user-guide/tutorials/prowler-app-rbac.mdx
+++ b/docs/user-guide/tutorials/prowler-app-rbac.mdx
@@ -1,12 +1,16 @@
---
title: 'Managing Users and Role-Based Access Control (RBAC)'
+sidebarTitle: 'Users & RBAC'
---
import { VersionBadge } from "/snippets/version-badge.mdx"
+import { AppliesTo } from "/snippets/applies-to.mdx"
-**Prowler App** supports multiple users within a single tenant, enabling seamless collaboration by allowing team members to easily share insights and manage security findings.
+
+
+**Prowler Cloud** supports multiple users within a single tenant, enabling seamless collaboration by allowing team members to easily share insights and manage security findings.
[Roles](#roles) help you control user permissions, determining what actions each user can perform and the data they can access within Prowler. By default, each account includes an immutable **admin** role, ensuring that your account always retains administrative access.
@@ -40,6 +44,11 @@ Follow these steps to edit a user of your account:
+
+Users can edit their own account details. Editing another user's account details requires the **Invite and Manage Users** or **admin** permission.
+
+
+
#### Removing a User
Follow these steps to remove a user of your account:
@@ -47,7 +56,11 @@ Follow these steps to remove a user of your account:
1. Navigate to **Users** from the side menu.
2. Click the delete button of your current user.
-> **Note: Each user will be able to delete himself and not others, regardless of his permissions.**
+> **Note: Each user can only delete their own account, regardless of their permissions. For this reason, the delete button is only shown on your own row and not on other users' rows.**
+
+Deleting a user removes the **entire user account** from Prowler, not just its membership in your organization. Because a single account can belong to more than one tenant, allowing one administrator to delete it outright could affect organizations they don't manage and irreversibly remove another person's identity. To keep this destructive action under the control of the account owner, the API only permits a user to delete themselves (it rejects any other target with a `400` response), and the UI mirrors this by showing the delete button exclusively on your own row.
+
+To remove **another** user from your organization, use the [_Expel from organization_](/user-guide/tutorials/prowler-app-multi-tenant#expelling-a-user-from-an-organization) action instead. Expelling removes the user's membership, role grants, and active sessions for your tenant only, and deletes the underlying account just for that user if your organization was their last remaining membership. This action is reserved for tenant **owners**.
@@ -115,7 +128,7 @@ To resend the invitation to the user, it is necessary to explicitly **delete the
## Managing Groups and Roles
-The Roles section in Prowler is designed to facilitate the assignment of custom user privileges. This section allows administrators to define roles with specific permissions for Prowler administrative tasks and Account visibility.
+Roles combine administrative permissions with provider visibility. Administrative permissions control the actions a role can perform. Provider Groups and Unlimited Visibility control the providers, resources, findings, scans, and compliance results the role can access.
**Only users that have the _Manage Account_ or _admin_ permission can access this section.**
@@ -123,47 +136,53 @@ The Roles section in Prowler is designed to facilitate the assignment of custom
### Provider Groups
-Provider Groups control visibility across specific providers. When creating a new role, you can assign specific groups to define their Provider visibility. This ensures that users with that role have access only to the Providers that are required.
+Provider Groups limit visibility to selected providers. Assigning one or more Provider Groups to a role grants access to the providers in those groups and their resources, findings, scans, and compliance results.
-By default, a new user role does not have visibility into any group.
+New roles have no provider visibility by default. Assign at least one Provider Group or enable **Unlimited Visibility** before assigning the role to users who need access to provider data.
-Alternatively, to grant the role unlimited visibility across all providers, check the Grant Unlimited Visibility checkbox.
+**Unlimited Visibility** grants organization-wide visibility across every provider, regardless of the Provider Groups assigned to the role. It does not grant administrative permissions.
#### Creating a Provider Group
Follow these steps to create a provider group in your account:
-1. Navigate to **Provider Groups** from the side menu..
+1. Click **Providers** in the side menu.
-2. In this view you can select the provider groups you want to assign to one or more roles.
+2. Select the **Provider Groups** tab.
-3. Click the **Create Group** button on the center of the screen.
+3. Enter a group name in the **Create a new provider group** form.
-
+4. Select the providers that the group controls. Optionally, select the roles that should use the group.
+
+5. Click **Create Group**.
+
+
#### Editing a Provider Group
Follow these steps to edit a provider group on your account:
-1. Navigate to **Provider Groups** from the side menu.
+1. Click **Providers** in the side menu and select the **Provider Groups** tab.
-2. Click the edit button of the provider group you want to modify.
+2. Open the actions menu for the Provider Group and click **Edit Provider Group**.
-
+
-3. Change the provider group parameters you need and save the changes.
+3. Update the group name, providers, or roles, and save the changes.
-
+
#### Removing a Provider Group
-Follow these steps to remove a provider group of your account:
+Follow these steps to remove a provider group from your account:
-1. Navigate to **Provider Groups** from the side menu.
+1. Click **Providers** in the side menu and select the **Provider Groups** tab.
-2. Click on the delete button of the provider group you want to remove.
+2. Open the actions menu for the Provider Group and click **Delete Provider Group**.
-
+3. Confirm the deletion.
+
+
### Roles
@@ -173,19 +192,20 @@ Follow these steps to create a role for your account:
1. Navigate to **Roles** from the side menu.
-2. Click on the **Add Role** button on the top right-hand corner of the screen.
+2. Click **Add Role**.
-
+3. Enter the role name and select the required administrative permissions.
-3. In the Add Role screen, enter the role name, the administration permissions and the groups of providers to which the Role will have access to.
-
-4. In the Groups and Account Visibility section, you will see a list of available groups with checkboxes next to them. To assign a group to the user role, simply click the checkbox next to the group name. If you need to assign multiple groups, repeat the process for each group you wish to add.
+4. Configure **Visibility**:
+ - To grant organization-wide visibility, select **Enable Unlimited Visibility for this role**.
+ - To limit visibility, leave Unlimited Visibility cleared and select one or more Provider Groups.
+5. Click **Add Role**.
-To assign read-only access, select only the `Unlimited Visibility` permission when creating the role. Then, go to the Users page and assign this role to the appropriate user.
+To grant read-only access across the organization, enable **Unlimited Visibility** without selecting administrative permissions. Then, assign the role from the **Users** page.
#### Editing a Role
@@ -194,25 +214,21 @@ Follow these steps to edit a role on your account:
1. Navigate to **Roles** from the side menu.
-2. Click on the edit button of the role you want to modify.
+2. Open the actions menu for the role and click **Edit Role**.
-
-
-3. Adjust the settings as needed and save the changes.
-
-
+3. Update the role name, administrative permissions, Unlimited Visibility setting, or Provider Groups.
+4. Save the changes.
#### Removing a Role
-Follow these steps to remove a role of your account:
+Follow these steps to remove a role from your account:
1. Navigate to **Roles** from the side menu.
-2. Click on the delete button of the role you want to remove.
-
-
+2. Open the actions menu for the role and click **Delete Role**.
+3. Confirm the deletion.
## RBAC Administrative Permissions
@@ -222,8 +238,8 @@ Assign administrative permissions by selecting from the following options:
|------------|-------|-------------|
| Invite and Manage Users | All | Invite new users and manage existing ones. |
| Manage Account | All | Adjust account settings, delete users and read/manage users permissions. |
-| Manage Scans | All | Run and review scans. |
-| Manage Providers | All | Add or modify connected providers. |
+| Manage Scans | All | Run and review scans, and manage [Scan Configuration](/user-guide/tutorials/prowler-app-scan-configuration) settings. |
+| Manage Providers | All | Add or modify connected providers, and attach or detach providers from a [Scan Configuration](/user-guide/tutorials/prowler-app-scan-configuration) (in addition to Manage Scans). |
| Manage Integrations | All | Add or modify the Prowler Integrations. |
| Manage Ingestions | Prowler Cloud | Allow or deny the ability to submit findings ingestion batches via the API. |
| Manage Billing | Prowler Cloud | Access and manage billing settings and subscription information. |
@@ -239,7 +255,7 @@ To grant all administrative permissions, select the **Grant all admin permission
The following permissions are available exclusively in **Prowler Cloud**:
-**Manage Ingestions:** Submit and manage findings ingestion jobs via the API. Required to upload OCSF scan results using the `--push-to-cloud` CLI flag or the ingestion endpoints. See [Import Findings](/user-guide/tutorials/prowler-app-import-findings) for details.
+**Manage Ingestions:** Submit and manage findings ingestion jobs via the API. Required to upload OCSF scan results using the `--push-to-cloud` CLI flag or the ingestion endpoints. See [Import Findings](/user-guide/tutorials/prowler-import-findings) for details.
**Manage Billing:** Access and manage billing settings, subscription plans, and payment methods.
diff --git a/docs/user-guide/tutorials/prowler-app-s3-integration.mdx b/docs/user-guide/tutorials/prowler-app-s3-integration.mdx
index 284b10eaaf..9b7d7a9a2e 100644
--- a/docs/user-guide/tutorials/prowler-app-s3-integration.mdx
+++ b/docs/user-guide/tutorials/prowler-app-s3-integration.mdx
@@ -1,12 +1,16 @@
---
title: 'Amazon S3 Integration'
+sidebarTitle: 'Amazon S3'
---
import { VersionBadge } from "/snippets/version-badge.mdx"
+import { AppliesTo } from "/snippets/applies-to.mdx"
-**Prowler App** allows automatic export of scan results to Amazon S3 buckets, providing seamless integration with existing data workflows and storage infrastructure. This comprehensive guide demonstrates configuration and management of Amazon S3 integrations to streamline security finding management and reporting.
+
+
+**Prowler Cloud** allows automatic export of scan results to Amazon S3 buckets, providing seamless integration with existing data workflows and storage infrastructure. This comprehensive guide demonstrates configuration and management of Amazon S3 integrations to streamline security finding management and reporting.
When enabled and configured, scan results are automatically stored in the configured bucket. Results are provided in `csv`, `html` and `json-ocsf` formats, offering flexibility for custom integrations:
@@ -201,7 +205,7 @@ Replace `` with the AWS account ID that contains the IAM role
### Available Templates
-**Prowler App** provides Infrastructure as Code (IaC) templates to automate IAM role setup with S3 integration permissions.
+**Prowler Cloud** provides Infrastructure as Code (IaC) templates to automate IAM role setup with S3 integration permissions.
Templates are optional. Custom IAM roles or static credentials can be used instead.
@@ -259,8 +263,8 @@ If using Prowler's CloudFormation template, execute the following command to upd
- `EnableS3Integration`: Select "true"
- `S3IntegrationBucketName`: Your bucket name
- `S3IntegrationBucketAccountId`: Bucket owner's AWS account ID
-5. In the "Configure stack options" screen, again, leave everything as it is and click on "Next"
-6. Finally, under "Review Prowler", at the bottom click on "Submit"
+5. In the "Configure stack options" screen, again, leave everything as it is and click "Next"
+6. Finally, under "Review Prowler", at the bottom click "Submit"
#### Terraform
@@ -278,7 +282,7 @@ If using Prowler's CloudFormation template, execute the following command to upd
3. Edit `terraform.tfvars` with your specific values:
```hcl
- # Required: External ID from Prowler App
+ # Required: External ID from Prowler Cloud
external_id = "your-unique-external-id-here"
# S3 Integration Configuration
@@ -310,11 +314,11 @@ For detailed information, refer to the [Terraform README](https://github.com/pro
## Configuration
-Once the required permissions are set up, proceed to configure the S3 integration in **Prowler App**.
+Once the required permissions are set up, proceed to configure the S3 integration in **Prowler Cloud**.
1. Navigate to "Integrations"

-2. Locate the Amazon S3 Integration card and click on the "Configure" button
+2. Locate the Amazon S3 Integration card and click the "Configure" button

3. Click the "Add Integration" button

diff --git a/docs/user-guide/tutorials/prowler-app-scan-configuration.mdx b/docs/user-guide/tutorials/prowler-app-scan-configuration.mdx
new file mode 100644
index 0000000000..60e0db2f4c
--- /dev/null
+++ b/docs/user-guide/tutorials/prowler-app-scan-configuration.mdx
@@ -0,0 +1,231 @@
+---
+title: 'Scan Configuration'
+sidebarTitle: 'Configuration'
+---
+
+import { VersionBadge } from "/snippets/version-badge.mdx"
+import { SubscriptionBanner } from "/snippets/subscription-banner.mdx"
+
+
+
+Scan Configuration lets you override, per provider, specific values in the default configuration Prowler's checks use during a scan. Each configuration can modify how specific checks behave, such as thresholds, allowed values, and retention windows, or exclude checks and services from the scan scope. Attach it to the providers that should use it on their next scan.
+
+
+
+## What Is a Scan Configuration?
+
+A Scan Configuration lets you **override specific values, per provider**, on top of Prowler's defaults. It's merged with those defaults, not a full replacement:
+
+- A provider type you don't add a section for keeps using `config.yaml` untouched.
+- **A provider type you do add a section for has its keys merged over `config.yaml` for that provider.** Only the keys you set are overridden; every key you leave out keeps its default from `config.yaml`, because each check falls back to the default configuration when a value isn't provided. See [How It's Applied](#how-its-applied) for details.
+- It is stored per organization and applied to the **providers you attach** to it.
+- **Attaching a provider is optional at creation time.** You can save a Scan Configuration with no providers attached and associate them later, either from the configuration's editor or from the **Providers** page (see [Attaching Providers](#attaching-providers)). It has no effect on any scan until at least one provider is attached.
+- A provider can be attached to **at most one** Scan Configuration at a time.
+- Changes take effect on the provider's **next scan** and do not re-run past scans.
+
+
+
+The full set of configurable values and their defaults lives in [`prowler/config/config.yaml`](https://github.com/prowler-cloud/prowler/blob/master/prowler/config/config.yaml). For what each key means and which checks read it, see the [Configuration File tutorial](/user-guide/cli/tutorials/configuration_file).
+
+
+## Required Permissions
+
+Scan Configuration access is governed by Role-Based Access Control (RBAC). See [RBAC Administrative Permissions](/user-guide/tutorials/prowler-app-rbac#rbac-administrative-permissions) for details on each permission.
+
+- **Viewing** a Scan Configuration doesn't require any specific permission.
+- **Creating, editing, and deleting** a Scan Configuration requires the **Manage Scans** permission.
+- **Attaching or detaching providers** requires the **Manage Providers** permission as well, in addition to Manage Scans. This applies to explicit changes to the attached providers, and to deleting a Scan Configuration that still has providers attached (deleting it detaches them too).
+- Attaching or detaching a provider also requires that provider to be **visible to your role**. Visibility comes from the [Provider Groups](/user-guide/tutorials/prowler-app-rbac#provider-groups) assigned to your role, or from **Unlimited Visibility**. You can't attach a provider you can't see, and you can't detach one either, whether by removing it from the list or by deleting the Scan Configuration it's attached to.
+
+## Config Schema
+
+The YAML follows the structure of `config.yaml`: a mapping keyed by provider, with each provider section holding the keys you want to change. You only list the keys you want to override; they're merged over that provider's `config.yaml` defaults.
+
+```yaml
+aws:
+ max_unused_access_keys_days: 30
+ max_console_access_days: 30
+ max_security_group_rules: 25
+
+azure:
+ defender_attack_path_minimal_risk_level: "Critical"
+
+gcp:
+ storage_min_retention_days: 30
+```
+
+### Limiting the Scan Scope
+
+
+
+Use `excluded_checks` to skip individual checks and `excluded_services` to skip every check in a service for the matching provider type:
+
+```yaml
+aws:
+ excluded_checks:
+ - s3_bucket_public_access
+ excluded_services:
+ - ec2
+```
+
+
+When a Scan Configuration excludes checks or services, Prowler calculates overviews, aggregations, and other result-based information from the reduced scan scope. The displayed information reflects only the checks and services that ran, not a complete assessment of the provider. Consider the applied Scan Configuration when interpreting totals and security posture.
+
+
+## Creating a Scan Configuration
+
+
+
+ On the **Scan** page (under **Configuration**), click **New Scan Configuration**.
+
+
+ Give the configuration a descriptive **Name** (3–100 characters), e.g. `stricter-iam-aws`.
+
+
+ In the **Configuration (YAML)** field, add the keys you want to change, grouped by provider. Only the keys you set are overridden; every other key keeps its `config.yaml` default. The editor is pre-filled with a representative default placeholder you can use as a starting point.
+
+
+ Under **Attach to providers**, pick the providers that should use this configuration. This is optional, you can save without any provider and attach them later, either by editing this configuration or from the **Providers** page (see [Attaching Providers](#attaching-providers)).
+
+
+ Click **Save**. The server validates the configuration values and, if everything is valid, stores it and attaches the selected providers.
+
+
+
+
+You don't need to fill in every provider. A section you don't include leaves that provider's `config.yaml` untouched. Within a section you do include, only add the keys you want to change; the rest keep their `config.yaml` defaults. The placeholder shown in the editor is just an example; if you leave the field with only the placeholder (greyed-out) text, nothing is saved.
+
+
+## How Validation Works
+
+Prowler checks your configuration in two stages, the same way the Advanced Mutelist editor does:
+
+1. **As you type: is it well-formed?** The editor checks that what you've written is valid YAML. If something is off, you'll see an `Invalid YAML format` message and **Save** stays disabled until you fix it. Once it's clean, it shows **Valid YAML format**.
+2. **When you save: are the values allowed?** Saving checks that each value is one Prowler accepts, the right type, within range, and one of the allowed options where a key only takes a fixed set of choices. If a value isn't allowed, the editor points to the exact key and explains why, right beneath the field, so you can correct it and save again.
+
+For example, `azure.defender_attack_path_minimal_risk_level` only accepts `Low`, `Medium`, `High`, or `Critical`. Saving any other value returns an inline error like:
+
+```
+azure.defender_attack_path_minimal_risk_level: Input should be 'Low', 'Medium', 'High' or 'Critical'
+```
+
+
+**Valid YAML format** only means the text is well-formed; it doesn't mean your values are accepted. Those are checked when you save.
+
+Be careful with indentation. A line like `azure: defender_attack_path_minimal_risk_level: Critical` (no line break and indent after `azure:`) is still valid YAML, but Prowler reads it as one long key name instead of a setting inside the `azure` section, so the value is silently ignored. Always nest provider keys:
+
+```yaml
+azure:
+ defender_attack_path_minimal_risk_level: "Critical"
+```
+
+
+
+Prowler won't flag a section or key it doesn't recognize. It accepts them without error, so custom checks and plugins can add their own settings. The trade-off: a typo in a key or section name isn't rejected either, and that misspelled setting simply won't apply. Double-check your spelling against `config.yaml`.
+
+
+## Attaching Providers
+
+A Scan Configuration only has an effect once it's attached to one or more providers. There are two ways to manage attachments.
+
+### From the Scan Config Editor
+
+In the **Attach to providers** field, select the providers that should use this configuration. Providers already attached to **another** configuration are hidden from the selector, since each provider can belong to only one configuration at a time.
+
+### From the Provider's Row Menu
+
+You can also manage a provider's configuration from **Providers**:
+
+
+
+ On the **Providers** page, open the **⋮** menu on a provider row.
+
+
+
+
+ In the dialog, pick a configuration from the dropdown to apply it (picking a different one moves the provider), or pick **Default** to detach it and go back to the built-in `config.yaml` defaults. Then click **Save**.
+
+
+
+
+This dialog only **associates or disassociates** an existing configuration. To create or edit the configuration's YAML, use the **Scan Config** view (a link is provided in the dialog).
+
+
+
+Because a provider can belong to only one configuration, associating a provider that is already attached elsewhere **moves** it to the new configuration automatically; it is removed from the previous one.
+
+
+## Editing and Deleting
+
+On the **Scan Config** page, open the **⋮** menu on a configuration row:
+
+- **Edit:** Choose **Edit** to open the editor, change its name, YAML, or attached providers, and click **Update**. Editing the YAML always happens here, never from the provider row.
+- **Delete:** Choose **Delete** (in the danger zone) and confirm. Providers that were attached fall back to the built-in defaults from `config.yaml` on their next scan.
+
+## How It's Applied
+
+When a scan runs for a provider:
+
+1. If the provider is attached to a Scan Configuration **and that configuration has a section for the provider's type** (e.g. `aws` for an AWS provider), Prowler merges that section over `config.yaml` for that provider's scan: the keys you set win, and every key you didn't set keeps its `config.yaml` default.
+2. If the provider is attached to a Scan Configuration, but that configuration **has no section for the provider's type** (for example, a configuration that only defines an `aws` section, attached to a GCP provider), the scan uses the built-in defaults from `config.yaml` for that provider, exactly as if no Scan Configuration were attached at all.
+3. If the provider isn't attached to any Scan Configuration, the built-in defaults from `config.yaml` are used.
+
+
+The merge is per key. A key you don't set keeps its `config.yaml` default, because each check falls back to the default configuration when a value isn't provided. You only need to list the keys you want to change.
+
+
+
+A single Scan Configuration can hold sections for several provider types at once (see [Config Schema](#config-schema)) and be attached to providers of different types. Each provider only ever picks up the section matching its own type; the rest of the YAML is ignored for that provider.
+
+
+## Effect on Compliance Results
+
+Some compliance requirements only hold if the checks they map to ran with a strict-enough configuration. For example, a requirement expecting unused access keys to be disabled within 45 days loses its meaning if a Scan Configuration raises `max_unused_access_keys_days` to 120: the check would still PASS, but the requirement wouldn't really be met.
+
+When a scan's applied configuration doesn't meet a requirement's expectations, Prowler marks that requirement as **FAIL** on the Compliance page, even if every individual finding passed. The requirement shows an info icon (and, when expanded, an inline alert) with:
+
+> Marked as FAIL because the applied scan configuration does not meet this requirement, even though all findings passed.
+
+This only affects requirements built around a configurable check that declares this kind of expectation; requirements without one are never affected by an attached Scan Configuration.
+
+## Common Examples
+
+Each example below shows only the keys being changed for that provider.
+
+
+Only the keys shown are overridden. Every other key for that provider keeps its `config.yaml` default (see [How It's Applied](#how-its-applied)).
+
+
+**Stricter IAM (Identity and Access Management) hygiene for AWS:**
+
+```yaml
+aws:
+ max_unused_access_keys_days: 30
+ max_console_access_days: 30
+ max_unused_sagemaker_access_days: 45
+```
+
+**Raise Azure Defender attack-path sensitivity:**
+
+```yaml
+azure:
+ defender_attack_path_minimal_risk_level: "Critical"
+```
+
+**Tighten GCP storage retention and key rotation:**
+
+```yaml
+gcp:
+ storage_min_retention_days: 30
+ secretmanager_max_rotation_days: 30
+```
+
+## Troubleshooting
+
+### A Provider Doesn't Appear in the Selector
+
+The provider is already attached to another Scan Configuration. Detach it there first, or use the provider row menu to move it.
+
+### You Can't Attach or Detach Providers
+
+If you can edit the configuration but not change its providers, or an error mentions a provider ID that "wasn't found", you're missing the **Manage Providers** permission or the provider isn't visible to your role. A provider outside your visibility is reported the same way as one that doesn't exist, so it isn't revealed to roles that shouldn't see it. See [Required Permissions](#required-permissions).
diff --git a/docs/user-guide/tutorials/prowler-app-security-hub-integration.mdx b/docs/user-guide/tutorials/prowler-app-security-hub-integration.mdx
index 592ff2b26d..977ad8e6f1 100644
--- a/docs/user-guide/tutorials/prowler-app-security-hub-integration.mdx
+++ b/docs/user-guide/tutorials/prowler-app-security-hub-integration.mdx
@@ -1,13 +1,17 @@
---
title: "AWS Security Hub Integration"
+sidebarTitle: 'AWS Security Hub'
---
import { VersionBadge } from "/snippets/version-badge.mdx"
+import { AppliesTo } from "/snippets/applies-to.mdx"
-Prowler App enables automatic export of security findings to AWS Security Hub, providing seamless integration with AWS's native security and compliance service. This comprehensive guide demonstrates how to configure and manage AWS Security Hub integrations to centralize security findings and enhance compliance tracking across AWS environments.
+
-Integrating Prowler App with AWS Security Hub provides:
+Prowler Cloud enables automatic export of security findings to AWS Security Hub, providing seamless integration with AWS's native security and compliance service. This comprehensive guide demonstrates how to configure and manage AWS Security Hub integrations to centralize security findings and enhance compliance tracking across AWS environments.
+
+Integrating Prowler Cloud with AWS Security Hub provides:
* **Centralized security visibility:** Consolidate findings from multiple AWS accounts and regions
* **Native AWS integration:** Leverage existing AWS security workflows and compliance frameworks
@@ -30,7 +34,7 @@ Refer to [AWS Security Hub pricing](https://aws.amazon.com/security-hub/pricing/
## Prerequisites
-Before configuring AWS Security Hub Integration in Prowler App, complete these steps:
+Before configuring AWS Security Hub Integration in Prowler Cloud, complete these steps:
### AWS Security Hub Setup
@@ -42,9 +46,9 @@ Configure AWS credentials by following the [AWS authentication setup guide](/use
## Configuration
-To configure AWS Security Hub integration in Prowler App:
+To configure AWS Security Hub integration in Prowler Cloud:
-1. Navigate to **Integrations** in the Prowler App interface
+1. Navigate to **Integrations** in Prowler Cloud
2. Locate the **AWS Security Hub** card and click **Manage**, then select **Add integration**

diff --git a/docs/user-guide/tutorials/prowler-app-simple-mutelist.mdx b/docs/user-guide/tutorials/prowler-app-simple-mutelist.mdx
index c0bd7db9b4..4b0d59e24a 100644
--- a/docs/user-guide/tutorials/prowler-app-simple-mutelist.mdx
+++ b/docs/user-guide/tutorials/prowler-app-simple-mutelist.mdx
@@ -3,10 +3,13 @@ title: "Simple Mutelist"
---
import { VersionBadge } from "/snippets/version-badge.mdx";
+import { AppliesTo } from "/snippets/applies-to.mdx";
-Prowler App provides Simple Mutelist, an intuitive way to mute findings directly from the Findings page without writing YAML configuration. This feature streamlines the muting workflow by allowing individual or bulk muting with just a few clicks.
+
+
+Prowler Cloud provides Simple Mutelist, an intuitive way to mute findings directly from the Findings page without writing YAML configuration. This feature streamlines the muting workflow by allowing individual or bulk muting with just a few clicks.
## What Is Simple Mutelist?
diff --git a/docs/user-guide/tutorials/prowler-app-social-login.mdx b/docs/user-guide/tutorials/prowler-app-social-login.mdx
index 45fe8ec62f..62de6fbc9c 100644
--- a/docs/user-guide/tutorials/prowler-app-social-login.mdx
+++ b/docs/user-guide/tutorials/prowler-app-social-login.mdx
@@ -1,12 +1,16 @@
---
title: 'Social Login Configuration'
+sidebarTitle: 'Social Login'
---
import { VersionBadge } from "/snippets/version-badge.mdx"
+import { AppliesTo } from "/snippets/applies-to.mdx"
-**Prowler App** supports social login using Google and GitHub OAuth providers. This document guides you through configuring the required environment variables to enable social authentication.
+
+
+Prowler supports social login using Google and GitHub OAuth providers. In **Prowler Cloud** social login is available out of the box. In **Prowler Local Server**, enable it by configuring the environment variables described in this guide.
## Configuring Social Login Credentials
diff --git a/docs/user-guide/tutorials/prowler-app-sso-entra.mdx b/docs/user-guide/tutorials/prowler-app-sso-entra.mdx
index 37f3bbdda5..07cf4709be 100644
--- a/docs/user-guide/tutorials/prowler-app-sso-entra.mdx
+++ b/docs/user-guide/tutorials/prowler-app-sso-entra.mdx
@@ -2,7 +2,11 @@
title: 'Entra ID Configuration'
---
-This page provides instructions for creating and configuring a Microsoft Entra ID (formerly Azure AD) application to use SAML SSO with Prowler App.
+import { AppliesTo } from "/snippets/applies-to.mdx"
+
+
+
+This page provides instructions for creating and configuring a Microsoft Entra ID (formerly Azure AD) application to use SAML SSO with Prowler Cloud.
You can find a walkthrough video [here](https://www.youtube.com/watch?v=zegqm55oJVk).
@@ -28,7 +32,7 @@ You can find a walkthrough video [here](https://www.youtube.com/watch?v=zegqm55o

-6. Enter the "Identifier (Entity ID)" and "Reply URL (Assertion Consumer Service URL)". These values can be obtained from the SAML SSO integration setup in Prowler App. For detailed instructions, refer to the [SAML SSO Configuration](/user-guide/tutorials/prowler-app-sso) page.
+6. Enter the "Identifier (Entity ID)" and "Reply URL (Assertion Consumer Service URL)". These values can be obtained from the SAML SSO integration setup in Prowler Cloud. For detailed instructions, refer to the [SAML SSO Configuration](/user-guide/tutorials/prowler-app-sso) page.

@@ -44,4 +48,4 @@ You can find a walkthrough video [here](https://www.youtube.com/watch?v=zegqm55o

-10. Save the downloaded Metadata XML to a file. To complete the setup, upload this file during the Prowler App integration. (See the [SAML SSO Configuration](/user-guide/tutorials/prowler-app-sso) page for details).
+10. Save the downloaded Metadata XML to a file. To complete the setup, upload this file during the SAML SSO integration setup in Prowler Cloud. (See the [SAML SSO Configuration](/user-guide/tutorials/prowler-app-sso) page for details).
diff --git a/docs/user-guide/tutorials/prowler-app-sso-google-workspace.mdx b/docs/user-guide/tutorials/prowler-app-sso-google-workspace.mdx
index 2a349c0204..ce0e5edc79 100644
--- a/docs/user-guide/tutorials/prowler-app-sso-google-workspace.mdx
+++ b/docs/user-guide/tutorials/prowler-app-sso-google-workspace.mdx
@@ -2,20 +2,24 @@
title: 'SAML SSO: Google Workspace'
---
-This page explains how to configure SAML-based Single Sign-On (SSO) in Prowler App using **Google Workspace** as the Identity Provider (IdP). The setup is divided into two parts: create a custom SAML app in Google Admin Console, then complete the configuration in Prowler App.
+import { AppliesTo } from "/snippets/applies-to.mdx"
+
+
+
+This page explains how to configure SAML-based Single Sign-On (SSO) in Prowler Cloud using **Google Workspace** as the Identity Provider (IdP). The setup is divided into two parts: create a custom SAML app in Google Admin Console, then complete the configuration in Prowler Cloud.
**Parallel Setup Required**
-Google Admin Console requires the ACS URL and Entity ID from Prowler App, while Prowler App displays these values only after opening the SAML configuration dialog. To work around this, open Prowler App in a separate browser tab, navigate to the profile page, open the "Configure SAML SSO" dialog, and copy the ACS URL and Entity ID before proceeding with the Google configuration.
+Google Admin Console requires the ACS URL and Entity ID from Prowler Cloud, while Prowler Cloud displays these values only after opening the SAML configuration dialog. To work around this, open Prowler Cloud in a separate browser tab, navigate to the profile page, open the "Configure SAML SSO" dialog, and copy the ACS URL and Entity ID before proceeding with the Google configuration.
## Prerequisites
- **Google Workspace**: Super Admin access (or delegated admin with app management permissions).
-- **Prowler App**: Administrator access to the organization (role with "Manage Account" permission).
-- Prowler App version **5.9.0** or later.
+- **Prowler Cloud**: Administrator access to the organization (role with "Manage Account" permission).
+- Prowler version **5.9.0** or later.
---
@@ -44,7 +48,7 @@ On the **Google Identity Provider details** screen:
1. Google displays two options:
- **Option 1**: Click "Download Metadata" to save the XML file directly. This is the recommended approach.
- **Option 2**: Manually copy the **SSO URL**, **Entity ID**, and **Certificate**.
-2. Download the metadata. This file is required to complete the Prowler App configuration in Part B.
+2. Download the metadata. This file is required to complete the configuration in Prowler Cloud (Part B).
3. Click "Continue".

@@ -52,18 +56,18 @@ On the **Google Identity Provider details** screen:
**Save the Metadata File**
-Download and save the IdP metadata XML file before proceeding. This file cannot be easily retrieved later and is required to complete the SAML configuration in Prowler App.
+Download and save the IdP metadata XML file before proceeding. This file cannot be easily retrieved later and is required to complete the SAML configuration in Prowler Cloud.
### Step 4: Configure the Service Provider Details
-Enter the following values obtained from the SAML SSO configuration dialog in Prowler App (see [Part B, Step 1](#step-1-open-the-saml-configuration-dialog) for details on where to find them):
+Enter the following values obtained from the SAML SSO configuration dialog in Prowler Cloud (see [Part B, Step 1](#step-1-open-the-saml-configuration-dialog) for details on where to find them):
| Google Workspace Field | Value |
|------------------------|-------|
-| **ACS URL** | The Assertion Consumer Service (ACS) URL displayed in Prowler App (e.g., `https://api.prowler.com/api/v1/accounts/saml/your-domain.com/acs/`). Self-hosted deployments use a different base URL. |
-| **Entity ID** | The Audience URI displayed in Prowler App (e.g., `urn:prowler.com:sp`). |
+| **ACS URL** | The Assertion Consumer Service (ACS) URL displayed in Prowler Cloud (e.g., `https://api.prowler.com/api/v1/accounts/saml/your-domain.com/acs/`). Prowler Local Server deployments use a different base URL. |
+| **Entity ID** | The Audience URI displayed in Prowler Cloud (e.g., `urn:prowler.com:sp`). |
| **Name ID format** | Select `EMAIL` from the dropdown. |
| **Name ID** | Select `Basic Information > Primary email` from the dropdown. |
@@ -82,7 +86,7 @@ Click "Add mapping" for each entry:
| `Basic Information > First name` | `firstName` | Yes | |
| `Basic Information > Last name` | `lastName` | Yes | |
| `Employee Details > Department` | `userType` | No | Determines the Prowler role. **Case-sensitive.** |
-| `Employee Details > Organization` | `organization` | No | Company name displayed in Prowler App profile. |
+| `Employee Details > Organization` | `organization` | No | Company name displayed in the user profile in Prowler Cloud. |
**Remember the Mapped Fields**
@@ -98,7 +102,7 @@ Click "Finish" to create the SAML app.
**Dynamic Updates**
-Prowler App updates user attributes each time a user logs in. Any changes made in Google Workspace are reflected on the next login.
+Prowler Cloud updates user attributes each time a user logs in. Any changes made in Google Workspace are reflected on the next login.
@@ -108,10 +112,10 @@ Prowler App updates user attributes each time a user logs in. Any changes made i
The `userType` attribute controls which Prowler role is assigned to the user:
- If `userType` matches an existing Prowler role name, the user receives that role automatically.
-- If `userType` does not match any existing role, Prowler App creates a new role with that name **without permissions**.
-- If `userType` is not set, the user receives the `no_permissions` role.
+- If `userType` does not match any existing role, Prowler Cloud creates a new role with that name **with read-only access** (visibility over all providers, no management permissions). A Prowler administrator can adjust its permissions afterward through the [RBAC Management](/user-guide/tutorials/prowler-app-rbac) tab.
+- If `userType` is not set, the user's existing roles are left unchanged.
-In all cases where the resulting role has no permissions, a Prowler administrator must configure the appropriate permissions through the [RBAC Management](/user-guide/tutorials/prowler-app-rbac) tab. The `userType` value is **case-sensitive** - for example, `Backend` and `backend` are treated as different roles.
+The `userType` value is **case-sensitive** - for example, `Backend` and `backend` are treated as different roles.
@@ -148,13 +152,13 @@ If attempting to use the "Test SAML login" option in Google Admin Console and re
---
-## Part B - Prowler App Configuration
+## Part B - Prowler Cloud Configuration
### Step 1: Open the SAML Configuration Dialog
1. Navigate to the profile settings page:
- **Prowler Cloud**: `https://cloud.prowler.com/profile`
- - **Self-hosted**: `http://{your-domain}/profile`
+ - **Prowler Local Server**: `http://{your-domain}/profile`
2. Find the "SAML SSO Integration" card and click "Enable" (or "Update" if already configured).
3. The "Configure SAML SSO" dialog opens, displaying:
- **ACS URL**: The Assertion Consumer Service URL (copy this value for Part A, Step 4). This URL updates dynamically when the email domain is entered.
@@ -162,21 +166,21 @@ If attempting to use the "Test SAML login" option in Google Admin Console and re
- **Name ID Format**: The expected format (`urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress`).
- **Supported Assertion Attributes**: The list of accepted attributes (`firstName`, `lastName`, `userType`, `organization`).
-
+
### Step 2: Enter the Email Domain and Upload Metadata
-1. Enter the **email domain** for the organization (e.g., `prowler.cloud`). Prowler App uses this domain to identify users who should authenticate via SAML. The ACS URL updates automatically to reflect the configured domain.
+1. Enter the **email domain** for the organization (e.g., `prowler.cloud`). Prowler Cloud uses this domain to identify users who should authenticate via SAML. The ACS URL updates automatically to reflect the configured domain.
2. Upload the **metadata XML file** downloaded in Part A, Step 3.
3. Click "Save".
-
+
### Step 3: Verify the Enabled Status
The "SAML SSO Integration" card should now display a **"Status: Enabled"** indicator with a checkmark, confirming that the configuration is complete.
-
+
---
@@ -214,30 +218,30 @@ To test the `userType` → role mapping, set the **Department** attribute in the
1. Navigate to the Prowler login page.
2. Click "Continue with SAML SSO".
3. Enter an email from the configured domain (e.g., `adrian@prowler.cloud`).
-4. Click "Log in". The browser redirects to Google for authentication and returns to Prowler App upon success.
+4. Click "Log in". The browser redirects to Google for authentication and returns to Prowler Cloud upon success.
-
+
### Verify User Profile and Role Mapping
-After a successful SSO login, the user profile in Prowler App reflects the attributes sent by Google Workspace:
+After a successful SSO login, the user profile in Prowler Cloud reflects the attributes sent by Google Workspace:
- **Name**: Populated from the `firstName` and `lastName` attributes.
-- **Role**: Created automatically from the `userType` attribute (e.g., `Backend`). If the role did not exist previously, it is created with no permissions by default.
-- **Permissions**: In the screenshot below, the user has no permissions because the `Backend` role did not exist prior to login and was created automatically without any permissions. To resolve this, a Prowler administrator can either:
- - Assign the appropriate permissions to the new role via the [RBAC Management](/user-guide/tutorials/prowler-app-rbac) tab.
+- **Role**: Created automatically from the `userType` attribute (e.g., `Backend`). If the role did not exist previously, it is created with read-only access by default.
+- **Permissions**: If the assigned permissions need to be adjusted, a Prowler administrator can either:
+ - Edit the permissions of the new role via the [RBAC Management](/user-guide/tutorials/prowler-app-rbac) tab.
- Set the `userType` attribute in the IdP to match an existing Prowler role that already has the desired permissions. The updated role is applied on the next SAML login.
For more details on role assignment behavior and attribute mapping, refer to the [SAML SSO Configuration](/user-guide/tutorials/prowler-app-sso#configure-attribute-mapping-in-the-idp) page.
-
+
### IdP-Initiated SSO (from Google)
1. Sign in to Google Workspace with an account that has access to the Prowler SAML app.
2. Open the Google Workspace app launcher (the grid icon in the top-right corner of any Google page).
-3. Click the Prowler app tile.
-4. The browser redirects directly to Prowler App, authenticated.
+3. Click the Prowler tile.
+4. The browser redirects directly to Prowler Cloud, authenticated.
For more information on the SSO login flows, refer to the [SAML SSO Configuration](/user-guide/tutorials/prowler-app-sso#idp-initiated-sso) page.
@@ -270,7 +274,7 @@ Prowler does not allow two tenants to share the same email domain. If the domain
**Just-in-Time Provisioning**
-Users who authenticate via SAML for the first time are automatically created in Prowler App. No prior invitation is needed. User attributes (`firstName`, `lastName`, `userType`) are updated on every login from the Google directory.
+Users who authenticate via SAML for the first time are automatically created in Prowler Cloud. No prior invitation is needed. User attributes (`firstName`, `lastName`, `userType`) are updated on every login from the Google directory.
@@ -278,10 +282,10 @@ Users who authenticate via SAML for the first time are automatically created in
## Quick Summary
-1. In **Google Admin Console**, create a custom SAML app using the ACS URL and Entity ID from Prowler App.
+1. In **Google Admin Console**, create a custom SAML app using the ACS URL and Entity ID from Prowler Cloud.
2. Configure **attribute mapping**: `firstName`, `lastName`, and optionally `userType` and `organization`.
3. **Download the metadata XML** from Google.
4. **Enable the app** in Google Workspace for the relevant users or groups.
-5. In **Prowler App**, enter the email domain, upload the metadata XML, and save.
+5. In **Prowler Cloud**, enter the email domain, upload the metadata XML, and save.
6. Verify the SAML SSO Integration shows **"Status: Enabled"**.
7. Test login via "Continue with SAML SSO" on the Prowler login page.
diff --git a/docs/user-guide/tutorials/prowler-app-sso.mdx b/docs/user-guide/tutorials/prowler-app-sso.mdx
index d4d2c18d11..bb993942cd 100644
--- a/docs/user-guide/tutorials/prowler-app-sso.mdx
+++ b/docs/user-guide/tutorials/prowler-app-sso.mdx
@@ -1,18 +1,22 @@
---
title: 'SAML Single Sign-On (SSO)'
+sidebarTitle: 'SAML SSO'
---
import { VersionBadge } from "/snippets/version-badge.mdx"
+import { AppliesTo } from "/snippets/applies-to.mdx"
-This guide provides comprehensive instructions to configure SAML-based Single Sign-On (SSO) in Prowler App. This configuration allows users to authenticate using the organization's Identity Provider (IdP).
+
+
+This guide provides comprehensive instructions to configure SAML-based Single Sign-On (SSO) in Prowler Cloud. This configuration allows users to authenticate using the organization's Identity Provider (IdP).
This document is divided into two main sections:
-- **[User Guide](#user-guide-configuration)**: For organization administrators to configure SAML SSO through Prowler App.
+- **[User Guide](#user-guide-configuration)**: For organization administrators to configure SAML SSO through Prowler Cloud.
-- **[Developer and Administrator Guide](#developer-and-administrator-guide)**: For developers and system administrators running self-hosted Prowler App instances, providing technical details on environment configuration, API usage, and testing.
+- **[Developer and Administrator Guide](#developer-and-administrator-guide)**: For developers and system administrators running Prowler Local Server instances, providing technical details on environment configuration, API usage, and testing.
---
@@ -43,7 +47,7 @@ If the SAML configuration is removed, users who previously authenticated via SAM
#### Step 1: Access Profile Settings
-To access the account settings, click the "Account" button in the top-right corner of Prowler App, or navigate directly to `https://cloud.prowler.com/profile` (or `http://localhost:3000/profile` for local setups).
+To access the account settings, click the "Account" button in the top-right corner of Prowler Cloud, or navigate directly to `https://cloud.prowler.com/profile` (or `http://localhost:3000/profile` for local setups).

@@ -63,12 +67,12 @@ Choose a Method:
- Prowler App displays the SAML configuration information needed to configure the IdP. Use this information to create a new SAML application in the IdP.
+ Prowler Cloud displays the SAML configuration information needed to configure the IdP. Use this information to create a new SAML application in the IdP.
1. **Assertion Consumer Service (ACS) URL**: The endpoint in Prowler that will receive the SAML assertion from the IdP.
2. **Audience URI (Entity ID)**: A unique identifier for the Prowler application (Service Provider).
- To configure the IdP, copy the **ACS URL** and **Audience URI** from Prowler App and use them to set up a new SAML application.
+ To configure the IdP, copy the **ACS URL** and **Audience URI** from Prowler Cloud and use them to set up a new SAML application.

@@ -81,13 +85,13 @@ Choose a Method:
**Configure Attribute Mapping in the IdP**
- For Prowler App to correctly identify and provision users, configure the IdP to send the following attributes in the SAML assertion:
+ For Prowler Cloud to correctly identify and provision users, configure the IdP to send the following attributes in the SAML assertion:
| Attribute Name | Description | Required |
|----------------|---------------------------------------------------------------------------------------------------------|----------|
| `firstName` | The user's first name. | Yes |
| `lastName` | The user's last name. | Yes |
- | `userType` | Determines which Prowler role the user receives (e.g., `admin`, `auditor`). If a role with that name already exists, the user receives it automatically; if it does not exist, Prowler App creates a new role with that name without permissions. If `userType` is not defined, the user is assigned the `no_permissions` role. Role permissions can be edited in the [RBAC Management tab](/user-guide/tutorials/prowler-app-rbac). | No |
+ | `userType` | Determines which Prowler role the user receives (e.g., `admin`, `auditor`). If a role with that name already exists, the user receives it automatically; if it does not exist, Prowler Cloud creates a new role with that name with read-only access (visibility over all providers, no management permissions). If `userType` is not defined, the user's existing roles are left unchanged. Role permissions can be edited in the [RBAC Management tab](/user-guide/tutorials/prowler-app-rbac). | No |
| `organization` | The user's company name. | No |
@@ -98,9 +102,15 @@ Choose a Method:
+ **Single-Value `userType` Required**
+
+ Map `userType` to an IdP attribute that always contains a single value. If the IdP sends multiple values, Prowler Cloud uses only the first value and does not assign multiple roles or select the highest-privilege role.
+
+
+
**Dynamic Updates**
- Prowler App updates these attributes each time a user logs in. Any changes made in the Identity Provider (IdP) will be reflected when the user logs in again.
+ Prowler Cloud updates these attributes each time a user logs in. Any changes made in the Identity Provider (IdP) will be reflected when the user logs in again.
@@ -132,34 +142,31 @@ Choose a Method:

- 7. **Configure User Attributes in Okta**: Okta acts as the central source for user profile information. Prowler App maps the following Okta user profile attributes during each SAML login:
+ 7. **Configure User Attributes in Okta**: Okta acts as the central source for user profile information. Prowler Cloud maps the following Okta user profile attributes during each SAML login:
- * **First name** (`firstName`): Maps to the user's first name in Prowler App.
- * **Last name** (`lastName`): Maps to the user's last name in Prowler App.
+ * **First name** (`firstName`): Maps to the user's first name in Prowler Cloud.
+ * **Last name** (`lastName`): Maps to the user's last name in Prowler Cloud.

- * **Organization** (`organization`): Maps to the company name displayed in Prowler App. This attribute is optional.
- * **User type** (`userType`): Determines the Prowler role assigned to the user. This attribute is **case-sensitive** and must match the exact name of an existing role in Prowler App.
+ * **Organization** (`organization`): Maps to the company name displayed in Prowler Cloud. This attribute is optional.
+ * **User type** (`userType`): Determines the Prowler role assigned to the user. This attribute is **case-sensitive**: if it matches the exact name of an existing role in Prowler Cloud the user receives that role; if no role with that name exists, a new one is created with read-only access.

- To modify these values, edit the user's profile directly in the Okta admin console under the "Profile" tab. Changes are reflected in Prowler App the next time the user logs in via SAML.
+ To modify these values, edit the user's profile directly in the Okta admin console under the "Profile" tab. Changes are reflected in Prowler Cloud the next time the user logs in via SAML.
**User Type and Role Assignment**
The `userType` attribute controls which Prowler role is assigned to the user:
- * If a role with the specified name already exists in Prowler App, the user automatically receives that role.
- * If the role does not exist, Prowler App creates a new role with that exact name but without any permissions, preventing the user from performing any actions.
- * If `userType` is not defined in the user's Okta profile, the user is assigned the `no_permissions` role.
+ * If a role with the specified name already exists in Prowler Cloud, the user automatically receives that role.
+ * If the role does not exist, Prowler Cloud creates a new role with that exact name with read-only access: the user can see all providers and their findings but cannot manage anything. A Prowler administrator (a user whose role includes the "Manage Account" permission) can adjust its permissions afterward through the [RBAC Management tab](/user-guide/tutorials/prowler-app-rbac).
+ * If `userType` is not defined in the user's Okta profile, the user's existing roles in Prowler Cloud are left unchanged.
+ * `userType` must contain a single value. If the IdP sends multiple values, Prowler Cloud uses only the first value and does not assign multiple roles.
- In all cases where the resulting role has no permissions, a Prowler administrator (a user whose role includes the "Manage Account" permission) must configure the appropriate permissions through the [RBAC Management tab](/user-guide/tutorials/prowler-app-rbac).
-
- This behavior is intentional: by defaulting to no permissions, Prowler App ensures that a misconfiguration in Okta cannot inadvertently grant elevated access.
-
- **Example:** To assign the `IT` role to a user, set the `userType` value to `IT` in Okta. If a role named `IT` already exists in Prowler App, the user receives it automatically upon login. If it does not exist, Prowler App creates a new role called `IT` without permissions, and a Prowler administrator must configure the desired permissions for it.
+ **Example:** To assign the `IT` role to a user, set the `userType` value to `IT` in Okta. If a role named `IT` already exists in Prowler Cloud, the user receives it automatically upon login. If it does not exist, Prowler Cloud creates a new role called `IT` with read-only access, and a Prowler administrator can adjust its permissions as needed.
@@ -175,11 +182,11 @@ Choose a Method:
Once the IdP is configured, it provides a **metadata XML file**. This file contains the IdP's configuration information, such as its public key and login URL.
-To complete the Prowler App configuration:
+To complete the Prowler Cloud configuration:
1. Return to the Prowler SAML configuration page.
-2. Enter the **email domain** for the organization (e.g., `mycompany.com`). Prowler App uses this to identify users who should authenticate via SAML.
+2. Enter the **email domain** for the organization (e.g., `mycompany.com`). Prowler Cloud uses this to identify users who should authenticate via SAML.
3. Upload the **metadata XML file** downloaded from the IdP.
@@ -207,7 +214,7 @@ SAML SSO can be disabled by removing the existing configuration from the integra
Once SAML SSO is configured, users can access Prowler Cloud directly from their Identity Provider's dashboard:
1. Navigate to the IdP dashboard or portal
-2. Click on the Prowler Cloud application tile
+2. Click the Prowler Cloud application tile
3. The system automatically authenticates users and redirects them to Prowler Cloud
This method is convenient for users who primarily work from the IdP portal and prefer a seamless single-click access.
@@ -222,7 +229,7 @@ Users can also initiate the login process directly from Prowler's login page:
3. Enter their email address from the configured domain

4. The system redirects users to the IdP for authentication
-5. After successful authentication, users are returned to Prowler App
+5. After successful authentication, users are returned to Prowler Cloud
This method is useful when users bookmark Prowler or navigate directly to the application.
@@ -230,11 +237,11 @@ This method is useful when users bookmark Prowler or navigate directly to the ap
## Developer and Administrator Guide
-This section provides technical details for developers and administrators of self-hosted Prowler instances.
+This section provides technical details for developers and administrators of Prowler Local Server instances.
### Environment Configuration
-For self-hosted deployments, several environment variables must be configured to ensure SAML SSO functions correctly. These variables are typically set in an `.env` file.
+For Prowler Local Server deployments, several environment variables must be configured to ensure SAML SSO functions correctly. These variables are typically set in an `.env` file.
| Variable | Description | Example |
|---------------------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------|-----------------------------------------------------------|
diff --git a/docs/user-guide/tutorials/prowler-app.mdx b/docs/user-guide/tutorials/prowler-app.mdx
index 5e99a41ae7..6c3131d136 100644
--- a/docs/user-guide/tutorials/prowler-app.mdx
+++ b/docs/user-guide/tutorials/prowler-app.mdx
@@ -1,34 +1,34 @@
---
title: 'Prowler Cloud'
+sidebarTitle: 'Getting Started'
---
+import { ProviderCards } from "/snippets/provider-cards.mdx"
+
**Prowler Cloud** is a web application that simplifies running Prowler. This tutorial will guide you through setting up and using it.
-We refer to **Prowler App** as the self-hosted version of **Prowler Cloud**.
+**Prowler Local Server** is the self-hosted version of **Prowler Cloud**. See [Prowler product families](/getting-started/products) for every official product name.
## Accessing Prowler Cloud and API Documentation
If you are a [Prowler Cloud](https://cloud.prowler.com/sign-in) user, you can access API docs at [https://api.prowler.com/api/v1/docs](https://api.prowler.com/api/v1/docs)
-**For Prowler App users**
+**For Prowler Local Server users**
-After [installing](/getting-started/installation/prowler-app) **Prowler App**, access it at [http://localhost:3000](http://localhost:3000).
+After [installing](/getting-started/installation/prowler-app) **Prowler Local Server**, access it at [http://localhost:3000](http://localhost:3000).
To view the auto-generated **Prowler API** documentation, navigate to [http://localhost:8080/api/v1/docs](http://localhost:8080/api/v1/docs). This documentation provides details on available endpoints, parameters, and responses.
-## **Step 1: Sign Up**
-
-### **Sign Up with Email**
-
+## Step 1: Sign Up
+### Sign Up with Email
To get started, sign up using your email and password:
-### **Sign Up with Social Login**
-
+### Sign Up with Social Login
If Social Login is enabled, you can sign up using your preferred provider (e.g., Google, GitHub).
@@ -44,16 +44,14 @@ If your email is not registered, a new account will be created using your social
See [how to configure Social Login for Prowler](/user-guide/tutorials/prowler-app-social-login) to enable this feature in your own deployments.
-## **Step 2: Log In**
-
-Once registered, log in with your email and password to access Prowler App.
+## Step 2: Log In
+Once registered, log in with your email and password to access Prowler Cloud.
Upon logging in, the Overview page will display. At this stage, no data is present: add a provider to begin scanning your cloud environment.
-## **Step 3: Add a Provider**
-
+## Step 3: Add a Provider
To perform security scans, link a cloud provider account. Prowler supports the following providers and more:
- **AWS**
@@ -77,48 +75,21 @@ Steps to add a provider:
-## **Step 4: Configure the Provider**
-
+## Step 4: Configure the Provider
Select the cloud provider to scan and configure authentication credentials. Each provider has specific requirements and authentication methods.
For detailed instructions on configuring credentials for each provider, refer to the provider-specific getting started guides:
-
-
- Configure AWS authentication using IAM Access Keys or Assumed Role credentials.
-
-
- Set up Azure authentication using Service Principal credentials.
-
-
- Configure GCP authentication with Service Account or Application Default Credentials.
-
-
- Connect OCI with API key credentials to scan compartments and regions.
-
-
- Set up Kubernetes authentication using kubeconfig files for cluster access.
-
-
- Configure M365 authentication with Application Certificate or Client Secret.
-
-
- Set up GitHub authentication using Personal Access Token, OAuth App, or GitHub App.
-
-
- Scan IaC public or private repositories for security issues.
-
-
-## **Step 5: Test Connection**
+
-After adding your credentials of your cloud account, click the `Launch` button to verify that Prowler App can successfully connect to your provider:
+## Step 5: Test Connection
+After adding your credentials of your cloud account, click the `Launch` button to verify that Prowler can successfully connect to your provider:
-## **Step 6: Scan started**
-
+## Step 6: Scan Started
After successfully adding and testing your credentials, Prowler will start scanning your cloud environment, click the `Go to Scans` button to see the progress:
@@ -127,8 +98,7 @@ After successfully adding and testing your credentials, Prowler will start scann
Prowler will automatically scan all configured providers every **24 hours**, ensuring your cloud environment stays continuously monitored.
-## **Step 7: Monitor Scan Progress**
-
+## Step 7: Monitor Scan Progress
Track the progress of your scan in the `Scans` section:
@@ -146,8 +116,7 @@ Each dashboard handles scan data differently:
When a new scan completes or a new data ingestion is processed, the dashboards automatically reflect the updated results.
-## **Step 8: Analyze the Findings**
-
+## Step 8: Analyze the Findings
While the scan is running, start exploring the findings in these sections:
- **Overview**: High-level summary of the scans.
@@ -168,8 +137,7 @@ While the scan is running, start exploring the findings in these sections:
To view all `new` findings that have not been seen prior to this scan, click the `Delta` filter and select `new`. To view all `changed` findings that have had a status change (from `PASS` to `FAIL` for example), click the `Delta` filter and select `changed`.
-## **Step 9: Download the Outputs**
-
+## Step 9: Download the Outputs
Once a scan is complete, navigate to the Scan Jobs section to download the output files generated by Prowler:
@@ -190,8 +158,7 @@ The `zip` file unpacks into a folder named like `prowler-output--
-## **Step 10: Download specified compliance report**
-
+## Step 10: Download Specified Compliance Report
Once your scan has finished, you don’t need to grab the entire ZIP—just pull down the specific compliance report you want:
- Navigate to the **Compliance** section of the UI.
diff --git a/docs/user-guide/tutorials/prowler-cloud-aws-organizations.mdx b/docs/user-guide/tutorials/prowler-cloud-aws-organizations.mdx
index d9c17aaa5f..8b2124e004 100644
--- a/docs/user-guide/tutorials/prowler-cloud-aws-organizations.mdx
+++ b/docs/user-guide/tutorials/prowler-cloud-aws-organizations.mdx
@@ -1,17 +1,20 @@
---
-title: 'AWS Organizations in Prowler Cloud'
+title: 'AWS Organizations'
description: 'Onboard all AWS accounts in your Organization through a single guided wizard'
---
import { VersionBadge } from "/snippets/version-badge.mdx"
+import { SubscriptionBanner } from "/snippets/subscription-banner.mdx"
-Prowler Cloud enables you to onboard all AWS accounts in your Organization through a single guided wizard. Instead of connecting accounts one by one, you can discover every account in your AWS Organization, select the ones you want to monitor, test connectivity, and launch scans — all from the Prowler Cloud UI.
+Prowler Cloud onboards every AWS account in your Organization through a single guided wizard. Instead of connecting accounts one by one, you can discover every account in your AWS Organization, select the ones you want to monitor, test connectivity, and launch scans — all from the Prowler Cloud UI.
-
-This feature is **exclusively available in Prowler Cloud**. For CLI-based multi-account scanning, see [AWS Organizations in Prowler CLI](/user-guide/providers/aws/organizations).
-
+
+For CLI-based multi-account scanning, see [AWS Organizations in Prowler CLI](/user-guide/providers/aws/organizations).
+
+
+To follow this guide you need an active [Prowler Cloud](https://cloud.prowler.com) account and access to your AWS Organization [management account](https://docs.aws.amazon.com/organizations/latest/userguide/orgs_introduction.html) (or a registered delegated administrator account).
## Overview
@@ -22,227 +25,19 @@ This feature is **exclusively available in Prowler Cloud**. For CLI-based multi-
| **Individual accounts** | A few AWS accounts | Connect each account one by one with its own IAM role. |
| **AWS Organizations** | 10+ accounts, or any org-managed environment | Connect once to your management account, discover all member accounts automatically, and scan them in bulk. |
-### How it works
+### How It Works
-Before using the AWS Organizations wizard, you need to deploy **two IAM roles** in your AWS environment. The onboarding follows this sequence:
+
+
+Onboarding deploys the **ProwlerScan Identity and Access Management (IAM) role** in your management account and in every member account. A **single CloudFormation stack** — launched from the wizard's **Create Stack in Management Account** button ([Step 2](#step-2-authenticate-with-your-management-account)) — creates the management account role **and** a service-managed StackSet that rolls the role out to your member accounts in one operation. Prefer to deploy the roles yourself? See [Deploy the Roles Manually](#deploy-the-roles-manually).
-
+
-## Key Concepts
+## Step 1: Start the Organization Wizard
-### What is an External ID?
-
-An **External ID** is a security token that Prowler generates unique to your tenant. When Prowler assumes the IAM role in your AWS account, it presents this External ID to prove its identity.
-
-This prevents the [confused deputy problem](https://docs.aws.amazon.com/IAM/latest/UserGuide/confused-deputy.html) — a scenario where an unauthorized party could trick AWS into granting access to your account. By requiring the External ID, only your specific Prowler tenant can assume the role.
-
-You don't need to create the External ID yourself — Prowler generates it automatically and displays it in the wizard for you to copy.
-
-### Two Roles Architecture
-
-Prowler requires **two separate IAM roles** deployed in different places, each with a distinct purpose:
-
-| Role | Where it lives | What it does | How to deploy it |
-|------|---------------|--------------|------------------|
-| **ProwlerScan** (management account) | Your management (root) account only | Discovers the Organization structure **and** scans the management account. Has additional Organizations discovery permissions. | Via **Quick Create** link or **manually** in the IAM Console ([Step 1](#step-1-create-the-management-account-role)). Cannot be deployed via StackSet. |
-| **ProwlerScan** (member accounts) | Every member account | Scans the account for security findings. | Via **CloudFormation StackSet** ([Step 2](#step-2-deploy-the-cloudformation-stackset)). Automated across all accounts. |
-
-
-
-
-
-
-**Same name, different permissions.** Both roles are named `ProwlerScan` — Prowler expects a consistent role name across all accounts. The management account role has the same scanning permissions as member accounts, plus additional Organizations discovery permissions (see [Step 1](#step-1-create-the-management-account-role) for the full list).
-
-
-### What is a CloudFormation StackSet?
-
-A [CloudFormation StackSet](https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/what-is-cfnstacksets.html) lets you deploy the same CloudFormation template across multiple AWS accounts in a single operation. Prowler uses a StackSet to deploy the **ProwlerScan** IAM role into every member account of your organization, so you don't have to create the role manually in each account.
-
-## Prerequisites
-
-### Prowler Cloud Account
-
-You need an active [Prowler Cloud](https://cloud.prowler.com) account. Each AWS account you connect will count as a provider in your subscription. See [Billing Impact](#billing-impact) for details.
-
-### AWS Organization Enabled
-
-Your AWS environment must have [AWS Organizations](https://docs.aws.amazon.com/organizations/latest/userguide/orgs_introduction.html) enabled. You will need access to the **management account** (or a delegated administrator account) to provide the Organization ID and IAM Role ARN.
-
-## Step 1: Create the Management Account Role
-
-The first role you need to create is the **management account role**. This role allows Prowler to discover your Organization structure — listing accounts, OUs, and hierarchy.
-
-
-**StackSets do not deploy to the management account.** Organizational CloudFormation StackSets with service-managed permissions only target member accounts — this is an AWS limitation, not a Prowler one. You must create the management account role separately, either via the Quick Create link ([Option A](#option-a-quick-create-link-fastest)) or manually ([Option B](#option-b-create-the-role-manually)).
-
-
-
-**The role must be named `ProwlerScan`** — the same name as the role deployed to member accounts via StackSet. Prowler expects a consistent role name across all accounts in the Organization. If you use a different name, connection tests and scans will fail for the management account.
-
-
-### Option A: Quick Create Link (Fastest)
-
-The Prowler wizard provides a one-click link that opens the AWS Console with the CloudFormation template pre-configured. This creates a **CloudFormation Stack** (not a StackSet) that deploys the ProwlerScan role with Organizations permissions enabled in your management account.
-
-
-**[Open Quick Create Stack in AWS Console →](https://us-east-1.console.aws.amazon.com/cloudformation/home?region=us-east-1#/stacks/quickcreate?templateURL=https%3A%2F%2Fprowler-cloud-public.s3.eu-west-1.amazonaws.com%2Fpermissions%2Ftemplates%2Faws%2Fcloudformation%2Fprowler-scan-role.yml&stackName=Prowler¶m_EnableOrganizations=true)**
-
-Opens the CloudFormation Console with the Prowler scan role template and `EnableOrganizations=true` pre-filled. You will need to enter the **ExternalId** parameter manually — copy it from the Prowler wizard ([Step 4](#step-4-authenticate-with-your-management-account)).
-
-
-1. Click **[Open Quick Create Stack in AWS Console →](https://us-east-1.console.aws.amazon.com/cloudformation/home?region=us-east-1#/stacks/quickcreate?templateURL=https%3A%2F%2Fprowler-cloud-public.s3.eu-west-1.amazonaws.com%2Fpermissions%2Ftemplates%2Faws%2Fcloudformation%2Fprowler-scan-role.yml&stackName=Prowler¶m_EnableOrganizations=true)** or use the **Create Stack in Management Account** button in the Prowler wizard (which also pre-fills the ExternalId).
-2. Enter the **ExternalId** parameter if not pre-filled.
-3. Check **"I acknowledge that AWS CloudFormation might create IAM resources with custom names"** and click **Create stack**.
-4. Wait for the stack to reach **CREATE_COMPLETE** status.
-
-Take note of the **Role ARN** from the stack's **Outputs** tab — you will need it in the wizard.
-
-### Option B: Create the Role Manually
-
-1. Sign in to the [AWS IAM Console](https://console.aws.amazon.com/iam/) in your **management account**.
-
-2. Go to **Roles > Create role** and select **Custom trust policy**.
-
-3. Paste the following trust policy. This allows Prowler Cloud to assume the role using your tenant's External ID (you will get this from the Prowler wizard in [Step 3](#step-3-start-the-organization-wizard)):
-
-```json
-{
- "Version": "2012-10-17",
- "Statement": [
- {
- "Effect": "Allow",
- "Principal": {
- "AWS": "arn:aws:iam::232136659152:root"
- },
- "Action": "sts:AssumeRole",
- "Condition": {
- "StringEquals": {
- "sts:ExternalId": ""
- },
- "StringLike": {
- "aws:PrincipalArn": "arn:aws:iam::232136659152:role/prowler*"
- }
- }
- }
- ]
-}
-```
-
-Replace `` with the External ID shown in the Prowler wizard.
-
-4. Attach the following AWS managed policies:
- - **SecurityAudit**
- - **ViewOnlyAccess**
-
- This allows Prowler to also scan the management account for security findings, just like any other account.
-
-5. Create an additional inline policy with the following permissions. These are specific to the management account and allow Prowler to discover your Organization structure:
-
-```json
-{
- "Version": "2012-10-17",
- "Statement": [
- {
- "Sid": "ProwlerOrganizationDiscovery",
- "Effect": "Allow",
- "Action": [
- "organizations:DescribeAccount",
- "organizations:DescribeOrganization",
- "organizations:ListAccounts",
- "organizations:ListAccountsForParent",
- "organizations:ListOrganizationalUnitsForParent",
- "organizations:ListRoots",
- "organizations:ListTagsForResource"
- ],
- "Resource": "*"
- },
- {
- "Sid": "ProwlerStackSetManagement",
- "Effect": "Allow",
- "Action": [
- "organizations:RegisterDelegatedAdministrator",
- "iam:CreateServiceLinkedRole"
- ],
- "Resource": "*"
- }
- ]
-}
-```
-
-
-You can optionally restrict the `Resource` field to your specific Organization ARN (e.g., `arn:aws:organizations::123456789012:organization/o-abc123def4`) instead of `"*"` to minimize the blast radius.
-
-
-6. Name the role **`ProwlerScan`** and click **Create role**. Take note of the **Role ARN** — you will need it in the Prowler wizard.
-
-The ARN follows this format: `arn:aws:iam:::role/ProwlerScan`
-
-
-The role **must** be named `ProwlerScan`. Do not use a different name.
-
-
-
-If you just created the role, it may take up to **60 seconds** for AWS to propagate it. If you get an error in the Prowler wizard, wait a moment and try again.
-
-
-## Step 2: Deploy the CloudFormation StackSet
-
-After creating the management account role, the next step is to deploy the **ProwlerScan** role to your member accounts using a CloudFormation StackSet. This is the recommended method for consistent, scalable deployment across your entire organization.
-
-The StackSet uses **service-managed permissions**, which means AWS Organizations handles the cross-account deployment automatically — you don't need to create execution roles manually in each account. The StackSet deploys the ProwlerScan IAM role in every target member account, enabling Prowler to assume that role for cross-account scanning.
-
-
-**Trusted access required:** CloudFormation StackSets must have trusted access enabled in your management account. Verify this in the AWS Console under **AWS Organizations > Settings > Trusted access for AWS CloudFormation StackSets**.
-
-
-
-**The Quick Create link creates a Stack, not a StackSet.** The link in the Prowler wizard creates a CloudFormation **Stack** that deploys the ProwlerScan role in your management account only ([Step 1](#step-1-create-the-management-account-role)). To deploy the role across **member accounts**, you must create a StackSet manually as described below. AWS does not support Quick Create links for StackSets.
-
-
-
-**[Open StackSets Console →](https://us-east-1.console.aws.amazon.com/cloudformation/home?region=us-east-1#/stacksets/create)**
-
-Opens the CloudFormation StackSets creation page directly. You will need to paste the template URL and ExternalId manually.
-
-
-1. Click the link above or navigate to **CloudFormation > StackSets > Create StackSet** in your management account.
-2. Choose **Service-managed permissions**.
-3. Select **Amazon S3 URL** as the template source and paste the following URL:
- ```
- https://prowler-cloud-public.s3.eu-west-1.amazonaws.com/permissions/templates/aws/cloudformation/prowler-scan-role.yml
- ```
-4. Set the **ExternalId** parameter to the External ID shown in the Prowler wizard.
-5. Choose your deployment targets (entire organization or specific OUs).
-6. Select the AWS regions where you want the role deployed.
-7. Click **Create StackSet**.
-
-### Verify StackSet Deployment
-
-After deploying, verify that all stack instances completed successfully:
-
-1. In the CloudFormation Console, go to **StackSets** and select your Prowler StackSet.
-2. Click the **Stack instances** tab.
-3. Confirm that all instances show **Status: CURRENT** and **Stack status: CREATE_COMPLETE**.
-
-Deployment typically takes **2–5 minutes** for medium-sized organizations. Large organizations (500+ accounts) may take longer.
-
-
-**Prefer Terraform?** You can deploy the ProwlerScan role using Terraform instead. See the [StackSets deployment guide](/user-guide/providers/aws/organizations#deploying-prowler-iam-roles-across-aws-organizations) for the Terraform module.
-
-
-### Key Considerations
-
-- **Service-managed permissions**: Always select **Service-managed permissions** when creating the StackSet. This lets AWS Organizations manage the deployment automatically across current and future member accounts.
-- **Least privilege**: The ProwlerScan role deployed by the StackSet uses `SecurityAudit` and `ViewOnlyAccess` — AWS managed policies that grant read-only access — plus a small set of additional read-only permissions for services not covered by those policies. See the [CloudFormation template](https://prowler-cloud-public.s3.eu-west-1.amazonaws.com/permissions/templates/aws/cloudformation/prowler-scan-role.yml) for the full list. Prowler does not make any changes to your accounts.
-- **New accounts**: When you add new accounts to your AWS Organization, the StackSet automatically deploys the ProwlerScan role to them if you targeted the organization root or the relevant OU. Combined with Prowler's 6-hour automatic sync, new accounts are onboarded end-to-end without manual intervention.
-- **Management account**: Organizational StackSets **do not deploy to the management account itself**. If you want to scan the management account, you need to create the ProwlerScan role there separately using a regular CloudFormation Stack.
-
-## Step 3: Start the Organization Wizard
-
-Now that both roles are deployed — the management account role (Step 1) and the ProwlerScan role in member accounts (Step 2) — you can start the Prowler wizard.
+The Prowler wizard walks you through the entire flow: deploying both roles from a single CloudFormation stack, discovering your accounts, testing connectivity, and launching scans.
### Open the Wizard
@@ -279,29 +74,50 @@ Now that both roles are deployed — the management account role (Step 1) and th
Click **Next** to proceed to the authentication phase.
-## Step 4: Authenticate with Your Management Account
+## Step 2: Authenticate with Your Management Account
-The wizard's **Authentication Details** page guides you through three actions: deploying the roles in AWS, entering the management account Role ARN, and confirming the deployment.
+The **Authentication Details** page guides you through three actions: deploying the roles in AWS, entering the deployment account Role ARN, and confirming the deployment. The deployment account is either the management account or, when delegated administrator mode is selected, the delegated administrator account.
### External ID
-The wizard displays a **Prowler External ID** at the top — auto-generated and unique to your tenant. Click the copy icon to copy it. You will need this External ID for both the management account Stack and the member accounts StackSet.
+The wizard displays a **Prowler External ID** at the top — auto-generated and unique to your tenant. Click the copy icon to copy it. The External ID is pre-filled into the deployment link, and the single stack applies it to both the management account role and the member-account StackSet. Learn more in [What Is an External ID?](#what-is-an-external-id).
### Deploy the Roles
-The wizard provides two deployment actions:
+
-1. **Create Stack in Management Account** — opens a Quick Create link that deploys the ProwlerScan role with `EnableOrganizations=true` in your management account ([Step 1](#step-1-create-the-management-account-role)). The External ID is pre-filled.
+The wizard deploys the deployment account role and the member-account StackSet in a **single** CloudFormation Stack:
-2. **Open StackSets Console** — links to the CloudFormation StackSets console where you create a StackSet for member accounts ([Step 2](#step-2-deploy-the-cloudformation-stackset)). Copy the template URL shown in the wizard and paste the External ID manually.
+
+**Prefer to use your own role?** You do not have to use the Quick Create template. Create the ProwlerScan role yourself — through the IAM Console, Terraform, or your own CloudFormation [(Following this guide)](#deploy-the-roles-manually) — and paste its ARN into the Role ARN field below. The role must use the external ID from the earlier step and include the trust policy and permissions described in [Deploy the Roles Manually](#deploy-the-roles-manually).
+
+
+1. **Organizational Unit or Root ID** — enter the AWS OU (`ou-xxxx-yyyyyyyy`) or organization root (`r-xxxx`) you want to onboard. Prowler rolls the ProwlerScan role out to every member account under this target. Find it in the [AWS Organizations Console](https://console.aws.amazon.com/organizations/); use the **root ID** (`r-`) to cover the entire organization or an **OU ID** (`ou-`) to target a specific unit.
+
+2. *(Optional)* Check **"I'm deploying from a delegated administrator account"** if you launch the stack from a delegated administrator account instead of the management account.
+
+3. **Create Stack in Management Account** — or **Create Stack in Delegated Administrator Account** when delegated administrator mode is selected — opens a Quick Create link that deploys, in a single stack: the ProwlerScan role in the account where you launch the stack (`DeployLocalRole`, with `EnableOrganizations=true`) **and** a service-managed StackSet (`DeployStackSet`) that rolls the role out to your member accounts. The External ID, OU/Root ID, and deployment options are pre-filled.
-
+
-### Enter the Management Account Role ARN
+
+**Finding your Organizational Unit or Root ID.** In the [AWS Organizations Console](https://console.aws.amazon.com/organizations/) the root (`r-…`) and OU (`ou-…`) IDs appear in the account tree, or run these from your management account:
-Paste the **Role ARN** of the management account role you created in [Step 1](#step-1-create-the-management-account-role) into the **Management Account Role ARN** field.
+```bash
+# Root ID — deploys the role to the entire organization
+aws organizations list-roots --query 'Roots[0].Id' --output text
+
+# OU IDs under the root — to target a specific unit instead
+aws organizations list-organizational-units-for-parent --parent-id r-xxxx \
+ --query 'OrganizationalUnits[].{Name:Name,Id:Id}' --output table
+```
+
+
+### Enter the Deployment Account Role ARN
+
+Paste the **Role ARN** created by the stack above into the **Management Account Role ARN** field or, when delegated administrator mode is selected, the **Delegated Administrator Account Role ARN** field.
The ARN follows this format:
```
@@ -311,12 +127,16 @@ arn:aws:iam:::role/ProwlerScan
For example: `arn:aws:iam::123456789012:role/ProwlerScan`
-
+
+
+It may take up to **60 seconds** for AWS to generate the IAM Role ARN after the stack completes. If the wizard reports an error, wait a moment and try again.
+
+
### Confirm and Discover
-1. Check the box: **"The Stack and StackSet have been successfully deployed in AWS"**.
+1. Check the box: **"The Stack has been successfully deployed in AWS"**.
2. Click **Authenticate**.
Here's what happens behind the scenes:
@@ -324,7 +144,7 @@ Here's what happens behind the scenes:
- An asynchronous discovery is triggered to query your AWS Organization structure.
- You will see a **"Gathering AWS Accounts..."** spinner — this typically takes **30 seconds to 2 minutes** depending on your organization size.
-## Step 5: Select Accounts to Scan
+## Step 3: Select Accounts to Scan
### Understanding the Tree View
@@ -335,6 +155,7 @@ Once discovery completes, the wizard displays a **hierarchical tree view** of yo
- The tree supports up to **5 levels of nesting** (Root > OUs > Sub-OUs > Accounts).
+- If you deployed the stack for just one OU, that OU will be preselected in the tree.
- **Selecting an OU** automatically selects all accounts within it.
- **Individual overrides**: deselect specific accounts even if the parent OU is selected.
- The header shows **"X of Y accounts selected"** to track your selection.
@@ -351,14 +172,12 @@ Only **ACTIVE** accounts can be selected for scanning:
| **CLOSED** | No | Account has been closed. |
-**Your existing data is safe.** If an AWS account is already connected to Prowler as an individual provider, it will appear in the tree with a checkmark indicator.
+**Your existing data is safe.** If an AWS account is already connected to Prowler as an individual provider, it appears in the tree with a checkmark indicator.
When you proceed:
- The existing provider is **linked** to the organization — it is **not** duplicated.
- All your **historical scan data and findings are preserved** — nothing is overwritten.
- There is **no additional billing** — the existing provider is reused.
-
-This is completely safe. You are simply associating the account with the organization for easier management.
### Custom Aliases
@@ -367,14 +186,9 @@ You can edit the display name for each account before connecting. This alias is
### Blocked Accounts
-Some accounts may appear as **blocked** (grayed out, not selectable). This happens when:
-- The account is **already linked to a different organization** in Prowler (`linked_to_other_organization`).
+Some accounts may appear as **blocked** (grayed out, not selectable) when the account is **already linked to a different organization** in Prowler (`linked_to_other_organization`). Hover over the blocked account to see the specific reason.
-Hover over the blocked account to see the specific reason.
-
-## Step 6: Test Connections
-
-### How Connection Testing Works
+## Step 4: Test Connections
Click **Test Connections** to verify that Prowler can assume the **ProwlerScan** role in each selected member account.
@@ -382,157 +196,225 @@ Click **Test Connections** to verify that Prowler can assume the **ProwlerScan**
-- Each account shows a real-time status indicator:
- - **Spinner** — test in progress
- - **Green checkmark (✓)** — connection successful
- - **Red icon (✗)** — connection failed (hover to see the error)
-
-### All Tests Pass
+Each account shows a real-time status indicator:
+- **Spinner** — test in progress
+- **Green checkmark (✓)** — connection successful
+- **Red icon (✗)** — connection failed (hover to see the error)
If every account connects successfully, you automatically advance to the next step.
-### Some Tests Fail
+### When Some Tests Fail
-An error banner appears: **"There was a problem connecting to some accounts."**
-
-You have two options:
+An error banner appears: **"There was a problem connecting to some accounts."** You have two options:
**a) Fix and retry:**
1. Go to the AWS Console and verify the StackSet deployed to the failing accounts.
2. Check that the External ID in the StackSet matches the one shown in Prowler.
3. Return to Prowler and click **Test Connections** — only the **failed accounts are re-tested** (smart retry). Accounts that already passed are not tested again.
-
-
-
-
**b) Skip and continue:**
-Click **Skip Connection Validation** to proceed with only the accounts that connected successfully. The failed accounts will not be scanned.
+Click **Skip Connection Validation** to proceed with only the accounts that connected successfully. The failed accounts will not be scanned. This option is only available when at least one account connected successfully.
-
-**Skip Connection Validation** is only available when at least one account connected successfully.
-
+If **no accounts** connected successfully, you cannot proceed. Fix the underlying connection issues — see [Troubleshooting](#troubleshooting) — and retry before launching scans.
-### All Tests Fail
+## Step 5: Launch Scans
-If **no accounts** connected successfully, you cannot proceed:
+The Organizations wizard uses the same schedule controls described in [Scan Scheduling](/user-guide/tutorials/prowler-scan-scheduling#schedule-options).
-> *"No accounts connected successfully. Fix the connection errors and retry before launching scans."*
-
-You must fix the underlying connection issues before continuing. See [Updating Credentials](#updating-credentials) below.
-
-### Updating Credentials
-
-If connection tests fail, here's how to fix common issues:
-
-1. Open the [CloudFormation Console](https://console.aws.amazon.com/cloudformation/) and check that your StackSet instances show **CREATE_COMPLETE** for the failing accounts. If not, update the StackSet to include the missing OUs.
-2. Compare the **ExternalId** parameter in your StackSet with the External ID displayed in the Prowler wizard. They must match exactly.
-3. After fixing the issue in AWS, return to Prowler and click **Test Connections**. Only the previously failed accounts will be re-tested.
-
-## Step 7: Launch Scans
-
-### Choose Scan Schedule
-
-| Schedule Option | Description |
-|-----------------|-------------|
-| **Scan Daily (every 24 hours)** | Creates a recurring daily scan for all connected accounts (default). |
-| **Run a single scan (no recurring schedule)** | Launches a one-time scan. |
-
-### Launch
-
-Click **Launch scan**. A toast notification confirms: *"Scan Launched — Daily scan scheduled for X accounts"* with a link to the Scans page. You will be redirected to the **Providers** page.
-
-Scans are only launched for accounts that are accessible (passed connection testing) and were selected.
+Click **Save**, **Save and launch scan**, or **Launch scan**, depending on the selected schedule option. A toast notification confirms whether the schedule was saved, scans were launched, or both, and includes a link to the **Scans** page. Prowler then redirects to the **Providers** page. Scans launch only for accounts that passed connection testing and were selected.
-### What Happens Next
-
+After launching:
- Scans appear in the **Scans** page as they start and complete.
- Results populate the **Overview** and **Findings** pages.
-- Prowler runs an **automatic sync every 6 hours** to detect new accounts added to your Organization or accounts that have been removed. New accounts are onboarded automatically based on the parent OU configuration.
+- Prowler runs an **automatic sync every 6 hours** to detect accounts added to or removed from your Organization. New accounts under the targeted OU or root are onboarded automatically.
## Billing Impact
Each AWS account you connect through the Organizations wizard counts as one **provider** in your Prowler Cloud subscription.
- **Already-connected accounts**: if an account was already linked as a provider, adding it to the organization does **not** incur additional billing. The existing provider is reused.
-- **Large organizations**: connecting a 500-account organization will result in up to 500 providers on your subscription. Review your plan limits before proceeding.
+- **Large organizations**: connecting a 500-account organization results in up to 500 providers on your subscription. Review your plan limits before proceeding.
- **Deleted providers**: if you later remove an account, the deleted provider no longer counts toward your subscription.
-For pricing details, see [Prowler Cloud Pricing](/getting-started/products/prowler-cloud-pricing).
+For pricing details, see [Prowler Cloud Pricing](https://prowler.com/pricing).
## Troubleshooting
-### Invalid AWS Organization ID
+### Only Some Accounts Connect
-*"Must be a valid AWS Organization ID"*
+Discovery succeeds and the tree view appears, but only one account — or a handful — passes the connection test. This almost always means the ProwlerScan role reached the deployment account but not every member account.
-- Verify the Organization ID format: `o-` followed by 10–32 lowercase alphanumeric characters (e.g., `o-abc123def4`)
-- Copy it directly from the [AWS Organizations Console](https://console.aws.amazon.com/organizations/) to avoid typos
+- **Confirm the StackSet deployed.** Open the [CloudFormation Console](https://console.aws.amazon.com/cloudformation/) in the deployment account, select your Prowler StackSet, open the **Stack instances** tab, and confirm every instance shows **Status: CURRENT** and **Stack status: CREATE_COMPLETE**. Instances still in progress or in a failed state explain the missing accounts.
+- **Check the targeted OU or root.** The single stack only rolls the role out to accounts under the **Organizational Unit or Root ID** you entered in [Step 2](#step-2-authenticate-with-your-management-account). Accounts in other OUs are not covered — redeploy targeting the organization root (`r-`) or add the missing OUs.
+- **Verify the deployment account.** The role is created only in the account where you launched the stack. If you deployed from a **delegated administrator account**, confirm that account is a **registered delegated administrator** for CloudFormation StackSets (registered through AWS Organizations), not just a regular member account. A regular member account cannot create a service-managed StackSet, so only its own role is created — leaving every other account without the role.
+- **Suspended accounts** cannot be scanned. Deselect them and proceed.
-### Invalid IAM Role ARN
+### No Accounts Connect
-*"Must be a valid IAM Role ARN"*
+No account passes the connection test.
-- Verify the ARN format: `arn:aws:iam::<12-digit-account-id>:role/`
-- Copy the ARN directly from the [IAM Console](https://console.aws.amazon.com/iam/) in your management account
+- **External ID mismatch.** Compare the **ExternalId** parameter in your StackSet with the External ID shown in the Prowler wizard. They must match exactly.
+- **StackSet not deployed.** Confirm the StackSet exists and its instances reached **CREATE_COMPLETE**. If you deployed the roles manually, verify [trusted access for CloudFormation StackSets](#member-account-role-stackset) is enabled.
+- **IP-based policies.** If your accounts restrict access by IP, allow the [Prowler Cloud egress IPs](/security/networking).
-### Authentication Failed
+### Authentication Fails or Times Out
-*"Authentication failed. Please verify the StackSet deployment and Role ARN"*
+*"Authentication failed. Please verify the StackSet deployment and Role ARN"* or *"Authentication timed out"*
-- Verify the management account role exists and was created in [Step 1](#step-1-create-the-management-account-role)
-- Confirm the trust policy includes the correct External ID from the wizard
-- Check the role has all Organizations discovery permissions listed in [Step 1](#step-1-create-the-management-account-role)
-- Double-check the Role ARN format and account ID for typos
+- Verify the deployment account role exists and is named exactly `ProwlerScan`.
+- Confirm the trust policy includes the correct External ID from the wizard.
+- Check the role has the Organizations discovery permissions listed in [Deploy the Roles Manually](#management-account-role).
+- Double-check the Role ARN format and account ID for typos.
+- Retry — the role can take up to **60 seconds** to propagate, and a second attempt often succeeds. For very large organizations (500+ accounts), allow extra time for discovery.
-### Authentication Timed Out
+### Invalid Organization ID or Role ARN
-*"Authentication timed out"*
+*"Must be a valid AWS Organization ID"* or *"Must be a valid IAM Role ARN"*
-- Retry the authentication step — the second attempt often succeeds
-- Check for AWS API rate limiting on the Organizations service
-- For very large organizations (500+ accounts), allow extra time for discovery
-
-### Connection Test Fails for All Accounts
-
-No accounts pass the connection test.
-
-- Verify the CloudFormation StackSet was deployed — complete [Step 2](#step-2-deploy-the-cloudformation-stackset) and wait for stack instances to reach **CREATE_COMPLETE**
-- Check that the **ExternalId** parameter in the StackSet matches the External ID shown in the Prowler wizard
-- If your accounts use IP-based IAM policies, allow [Prowler Cloud public IPs](/user-guide/tutorials/prowler-cloud-public-ips)
-
-### Connection Test Fails for Some Accounts
-
-Some accounts show a red icon while others pass.
-
-- Expand the StackSet deployment to include the OUs containing the failing accounts
-- Suspended accounts cannot be scanned — deselect them and proceed
-- Ensure the [STS regional endpoint](https://docs.aws.amazon.com/IAM/latest/UserGuide/id_credentials_temp_enable-regions.html) is enabled in the account's region
-- After fixing, click **Test Connections** — only the failed accounts will be re-tested
-
-### No Accounts Connected Successfully
-
-*"No accounts connected successfully. Fix the connection errors and retry before launching scans."*
-
-- Hover over the red icon on each account to see the specific error
-- Fix the underlying issues using the guidance above
-- Click **Test Connections** to retry
+- Organization ID format: `o-` followed by 10–32 lowercase alphanumeric characters (e.g., `o-abc123def4`).
+- Role ARN format: `arn:aws:iam::<12-digit-account-id>:role/ProwlerScan`.
+- Copy both directly from the AWS Console to avoid typos.
### Failed to Apply Discovery
*"Failed to apply discovery"*
-- Check the `blocked_reasons` field for any blocked accounts
-- Retry the operation
-- If the error persists, contact [Prowler Support](mailto:support@prowler.com)
+- Check the `blocked_reasons` field for any blocked accounts and retry the operation.
+- If the error persists, contact [Prowler Support](mailto:support@prowler.com).
+
+## Deploy the Roles Manually
+
+The wizard's **Create Stack** button is the fastest path, but you can create both roles yourself — for example with Terraform or your own CloudFormation — and paste the management account Role ARN into [Step 2](#step-2-authenticate-with-your-management-account). Both roles must be named `ProwlerScan`, since Prowler expects a consistent role name across all accounts.
+
+
+**Prefer Terraform?** You can deploy the ProwlerScan role across the organization with Terraform instead of CloudFormation. See the [StackSets deployment guide](/user-guide/providers/aws/organizations#deploying-prowler-iam-roles-across-aws-organizations) for the module.
+
+
+### Management Account Role
+
+The management account role lets Prowler discover your Organization structure — listing accounts, OUs, and hierarchy — and scan the management account itself. StackSets with service-managed permissions do not deploy to the management account, so this role is always created separately from the member-account StackSet.
+
+1. Sign in to the [AWS IAM Console](https://console.aws.amazon.com/iam/) in your **management account** (or delegated administrator account).
+2. Go to **Roles > Create role** and select **Custom trust policy**.
+3. Paste the following trust policy, replacing `` with the External ID shown in the Prowler wizard:
+
+```json
+{
+ "Version": "2012-10-17",
+ "Statement": [
+ {
+ "Effect": "Allow",
+ "Principal": {
+ "AWS": "arn:aws:iam::232136659152:root"
+ },
+ "Action": "sts:AssumeRole",
+ "Condition": {
+ "StringEquals": {
+ "sts:ExternalId": ""
+ },
+ "StringLike": {
+ "aws:PrincipalArn": "arn:aws:iam::232136659152:role/prowler*"
+ }
+ }
+ }
+ ]
+}
+```
+
+4. Attach the AWS managed policies **SecurityAudit** and **ViewOnlyAccess** so Prowler can scan the management account for security findings.
+5. Add an inline policy with the Organizations discovery permissions:
+
+```json
+{
+ "Version": "2012-10-17",
+ "Statement": [
+ {
+ "Sid": "ProwlerOrganizationDiscovery",
+ "Effect": "Allow",
+ "Action": [
+ "organizations:DescribeAccount",
+ "organizations:DescribeOrganization",
+ "organizations:ListAccounts",
+ "organizations:ListAccountsForParent",
+ "organizations:ListOrganizationalUnitsForParent",
+ "organizations:ListRoots",
+ "organizations:ListTagsForResource"
+ ],
+ "Resource": "*"
+ },
+ {
+ "Sid": "ProwlerStackSetManagement",
+ "Effect": "Allow",
+ "Action": [
+ "organizations:RegisterDelegatedAdministrator",
+ "iam:CreateServiceLinkedRole"
+ ],
+ "Resource": "*"
+ }
+ ]
+}
+```
+
+
+You can restrict the `Resource` field to your specific Organization ARN (e.g., `arn:aws:organizations::123456789012:organization/o-abc123def4`) instead of `"*"` to minimize the blast radius.
+
+
+6. Name the role **`ProwlerScan`** and click **Create role**. The ARN follows the format `arn:aws:iam:::role/ProwlerScan` — paste it into the wizard.
+
+### Member Account Role (StackSet)
+
+Deploy the ProwlerScan role to every member account with a [CloudFormation StackSet](https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/what-is-cfnstacksets.html), so you don't create the role manually in each account.
+
+
+**Trusted access required.** CloudFormation StackSets must have trusted access enabled in your management account. Verify this under **AWS Organizations > Settings > Trusted access for AWS CloudFormation StackSets**.
+
+
+1. In your management account, navigate to **CloudFormation > StackSets > Create StackSet** ([open directly](https://us-east-1.console.aws.amazon.com/cloudformation/home?region=us-east-1#/stacksets/create)).
+2. Choose **Service-managed permissions** so AWS Organizations deploys the role automatically across current and future member accounts.
+3. Select **Amazon S3 URL** as the template source and paste:
+ ```
+ https://prowler-cloud-public.s3.eu-west-1.amazonaws.com/permissions/templates/aws/cloudformation/prowler-scan-role.yml
+ ```
+4. Set the **ExternalId** parameter to the External ID shown in the Prowler wizard.
+5. Choose your deployment targets (entire organization or specific OUs) and regions, then click **Create StackSet**.
+6. Open the **Stack instances** tab and confirm every instance shows **Status: CURRENT** and **Stack status: CREATE_COMPLETE**. Deployment typically takes **2–5 minutes**; large organizations (500+ accounts) may take longer.
+
+The StackSet role uses read-only access only (`SecurityAudit`, `ViewOnlyAccess`, plus a small set of additional read-only permissions). Prowler makes no changes to your accounts. See the [CloudFormation template](https://prowler-cloud-public.s3.eu-west-1.amazonaws.com/permissions/templates/aws/cloudformation/prowler-scan-role.yml) for the full list. When you add new accounts under the targeted OU or root, the StackSet deploys the role automatically, and Prowler's 6-hour sync onboards them end-to-end.
+
+## Key Concepts
+
+### What Is an External ID?
+
+An **External ID** is a security token that Prowler generates unique to your tenant. When Prowler assumes the IAM role in your AWS account, it presents this External ID to prove its identity.
+
+This prevents the [confused deputy problem](https://docs.aws.amazon.com/IAM/latest/UserGuide/confused-deputy.html) — a scenario where an unauthorized party could trick AWS into granting access to your account. By requiring the External ID, only your specific Prowler tenant can assume the role. Prowler generates it automatically and displays it in the wizard for you to copy.
+
+### Two Roles Architecture
+
+Prowler uses **two IAM roles**, both named `ProwlerScan` but deployed in different places:
+
+| Role | Where it lives | What it does |
+|------|---------------|--------------|
+| **ProwlerScan** (management account) | Your management (or delegated administrator) account | Discovers the Organization structure **and** scans that account. Includes additional Organizations discovery permissions. |
+| **ProwlerScan** (member accounts) | Every member account | Scans the account for security findings. |
+
+Both roles share the name `ProwlerScan` because Prowler expects a consistent role name across all accounts. The single CloudFormation stack in [Step 2](#step-2-authenticate-with-your-management-account) deploys both at once.
+
+
+
+
+
+### What Is a CloudFormation StackSet?
+
+A [CloudFormation StackSet](https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/what-is-cfnstacksets.html) deploys the same CloudFormation template across multiple AWS accounts in a single operation. Prowler uses a service-managed StackSet to deploy the **ProwlerScan** IAM role into every member account of your organization, so you don't create the role manually in each account. StackSets do not deploy to the management account, which is why that role is created separately.
## What's Next
diff --git a/docs/user-guide/tutorials/prowler-cloud-azure-management-groups.mdx b/docs/user-guide/tutorials/prowler-cloud-azure-management-groups.mdx
new file mode 100644
index 0000000000..98cfe095ce
--- /dev/null
+++ b/docs/user-guide/tutorials/prowler-cloud-azure-management-groups.mdx
@@ -0,0 +1,11 @@
+---
+title: 'Azure Management Groups'
+description: 'Onboard all Azure subscriptions in your management groups through a single guided wizard'
+tag: "Coming Soon"
+---
+
+Onboarding Azure management groups through a single guided wizard is coming soon to Prowler Cloud.
+
+Today, Azure subscriptions are onboarded individually. See [Getting Started with Azure](/user-guide/providers/azure/getting-started-azure) and [Bulk Provider Provisioning](/user-guide/tutorials/bulk-provider-provisioning) to automate onboarding multiple subscriptions.
+
+Keep an eye on the [changelog](https://github.com/prowler-cloud/prowler/releases) for updates.
diff --git a/docs/user-guide/tutorials/prowler-cloud-gcp-organizations.mdx b/docs/user-guide/tutorials/prowler-cloud-gcp-organizations.mdx
new file mode 100644
index 0000000000..c0c11747a9
--- /dev/null
+++ b/docs/user-guide/tutorials/prowler-cloud-gcp-organizations.mdx
@@ -0,0 +1,11 @@
+---
+title: 'GCP Organizations'
+description: 'Onboard all GCP projects in your organization through a single guided wizard'
+tag: "Coming Soon"
+---
+
+Onboarding a full GCP organization through a single guided wizard is coming soon to Prowler Cloud.
+
+Today, GCP projects are onboarded individually. See [Getting Started with GCP](/user-guide/providers/gcp/getting-started-gcp) and [Bulk Provider Provisioning](/user-guide/tutorials/bulk-provider-provisioning) to automate onboarding multiple projects.
+
+Keep an eye on the [changelog](https://github.com/prowler-cloud/prowler/releases) for updates.
diff --git a/docs/user-guide/tutorials/prowler-cloud-lighthouse-multi-llm.mdx b/docs/user-guide/tutorials/prowler-cloud-lighthouse-multi-llm.mdx
new file mode 100644
index 0000000000..0cb251df1f
--- /dev/null
+++ b/docs/user-guide/tutorials/prowler-cloud-lighthouse-multi-llm.mdx
@@ -0,0 +1,82 @@
+---
+title: 'Using Multiple LLM Providers'
+sidebarTitle: 'Multiple LLM Providers'
+---
+
+import { SubscriptionBanner } from "/snippets/subscription-banner.mdx"
+
+Lighthouse AI on Prowler Cloud supports multiple Large Language Model (LLM) providers, so teams can choose the option that best fits their infrastructure, compliance requirements, and cost considerations. This guide explains how to configure and manage providers for Lighthouse AI on Prowler Cloud.
+
+
+
+For a feature overview, see [Lighthouse AI on Prowler Cloud](/getting-started/products/prowler-cloud-lighthouse).
+
+## Supported Providers
+
+- **OpenAI:** GPT models, including the default GPT-5.5.
+- **Amazon Bedrock:** AWS-hosted access to Claude, Llama, Titan, and other models.
+- **OpenAI Compatible:** Any service exposing an OpenAI-compatible API endpoint, such as OpenRouter.
+
+## Model Requirements
+
+Lighthouse AI requires models that support all of the following:
+
+- **Text input:** Ability to receive text prompts.
+- **Text output:** Ability to generate text responses.
+- **Tool calling:** Ability to invoke tools to retrieve data from Prowler.
+
+Models without these capabilities are not going to work well.
+
+## Configuring Providers
+
+Navigate to **Configuration** → **Lighthouse AI** to see the available providers, each with its own connection option.
+
+To connect a provider:
+
+1. Click the desired provider.
+2. Enter the required credentials.
+3. Click **Save**. The connection is validated automatically before the provider becomes available.
+
+
+
+The required information differs per provider:
+
+
+
+ - **API Key:** OpenAI API key (starts with `sk-` or `sk-proj-`), created from the [OpenAI platform](https://platform.openai.com/api-keys).
+
+ Confirm the OpenAI account has sufficient credits and that the default GPT-5.5 model is not blocked in the organization settings.
+
+
+ Connect using either an Amazon Bedrock API key or AWS IAM credentials.
+
+ **Amazon Bedrock API key**
+ - **Bedrock API Key:** The key generated from Amazon Bedrock.
+ - **AWS Region:** Region where Bedrock is available.
+
+ **AWS IAM Access Keys**
+ - **AWS Access Key ID** and **AWS Secret Access Key** for the IAM user.
+ - **AWS Region:** Region where Bedrock is available.
+ - The IAM user requires the `AmazonBedrockLimitedAccess` managed policy.
+
+
+ Connect to any service exposing an OpenAI-compatible API endpoint that Prowler Cloud can reach over the Internet.
+
+ - **API Key:** API key from the compatible service.
+ - **Base URL:** API endpoint URL including the version (for example, `https://openrouter.ai/api/v1`).
+
+ ### Example: OpenRouter
+
+ 1. Create an account at [OpenRouter](https://openrouter.ai/).
+ 2. Generate an API key from the OpenRouter dashboard.
+ 3. Enter the API key and set the base URL to `https://openrouter.ai/api/v1`.
+
+
+
+## Model Recommendations
+
+GPT-5.5 is the default and recommended model for Lighthouse AI on Prowler Cloud. Models from Amazon Bedrock and OpenAI-compatible endpoints can also be used, but performance is not guaranteed. Ensure any selected model supports text input, text output, and tool calling.
+
+## Getting Help
+
+For issues or suggestions with Lighthouse AI on Prowler Cloud, request support at [support.prowler.com](https://support.prowler.com) or [reach out through our Slack channel](https://goto.prowler.com/slack).
diff --git a/docs/user-guide/tutorials/prowler-cloud-public-ips.mdx b/docs/user-guide/tutorials/prowler-cloud-public-ips.mdx
deleted file mode 100644
index f3b285056c..0000000000
--- a/docs/user-guide/tutorials/prowler-cloud-public-ips.mdx
+++ /dev/null
@@ -1,29 +0,0 @@
----
-title: 'Prowler Cloud Public IPs'
----
-
-## Overview
-
-Prowler Cloud uses a dedicated egress IPv4 address for all outbound connections to customer infrastructure. This enables organizations to implement network-level security controls by whitelisting Prowler's IP address.
-
-## Use Cases
-
-Whitelisting Prowler's egress IP address enables:
-
-- **Credential Usage Control**: Restrict where cloud provider credentials can be used from across AWS, Azure, GCP, and other providers
-- **Kubernetes Security**: Limit inbound HTTPS traffic to clusters by allowing only Prowler's IP address
-- **Compliance Requirements**: Meet security policies requiring allowlisting of external services
-
-## Query the Egress IP Address
-
-Retrieve Prowler Cloud's current egress IP address using the following command:
-
-```bash
-dig egress.prowler.com +short
-```
-
-This command returns the IPv4 address that Prowler Cloud uses for all outbound connections to customer infrastructure.
-
-
-The egress IP address is stable, but it is recommended to periodically verify it remains current by querying `egress.prowler.com`.
-
diff --git a/docs/user-guide/tutorials/prowler-app-import-findings.mdx b/docs/user-guide/tutorials/prowler-import-findings.mdx
similarity index 96%
rename from docs/user-guide/tutorials/prowler-app-import-findings.mdx
rename to docs/user-guide/tutorials/prowler-import-findings.mdx
index 9f1002c2a9..530cd160ee 100644
--- a/docs/user-guide/tutorials/prowler-app-import-findings.mdx
+++ b/docs/user-guide/tutorials/prowler-import-findings.mdx
@@ -1,19 +1,19 @@
---
title: 'Import Findings'
+sidebarTitle: 'Import Findings'
description: 'Upload OCSF scan results to Prowler Cloud from external sources or the CLI'
---
import { VersionBadge } from "/snippets/version-badge.mdx"
+import { SubscriptionBanner } from "/snippets/subscription-banner.mdx"
Findings Ingestion enables uploading OCSF (Open Cybersecurity Schema Framework) scan results to Prowler Cloud. This feature supports importing findings from Prowler CLI output files that use the [Detection Finding](https://schema.ocsf.io/classes/detection_finding) class.
-
-This feature is available exclusively in **Prowler Cloud** with a paid subscription.
-
+
-## OCSF Detection Finding format
+## OCSF Detection Finding Format
The ingestion API accepts `.ocsf.json` files containing a JSON array of OCSF Detection Finding records. Each finding represents a security check result from Prowler.
@@ -130,11 +130,11 @@ The ingestion API accepts `.ocsf.json` files containing a JSON array of OCSF Det
Only **Detection Finding** (`class_uid: 2004`) records are accepted. Other OCSF classes are not supported for ingestion.
-## Required permissions
+## Required Permissions
The **Manage Ingestions** RBAC permission controls access to the ingestion endpoints. Without this permission, findings cannot be submitted via the API or `--push-to-cloud`.
-For more information about RBAC permissions, refer to the [Prowler App RBAC documentation](/user-guide/tutorials/prowler-app-rbac).
+For more information about RBAC permissions, refer to the [Prowler Cloud RBAC documentation](/user-guide/tutorials/prowler-app-rbac).
## Using the CLI
@@ -145,7 +145,7 @@ The `--push-to-cloud` flag uploads scan results directly to Prowler Cloud after
- A valid Prowler Cloud API key (see [API Keys](/user-guide/tutorials/prowler-app-api-keys))
- The `PROWLER_CLOUD_API_KEY` environment variable configured
-### Basic usage
+### Basic Usage
```bash
export PROWLER_CLOUD_API_KEY="pk_your_api_key_here"
@@ -153,7 +153,7 @@ export PROWLER_CLOUD_API_KEY="pk_your_api_key_here"
prowler aws --push-to-cloud
```
-### Combining with output formats
+### Combining with Output Formats
When using `--push-to-cloud` with custom output formats that exclude OCSF, Prowler generates a temporary OCSF file for upload:
@@ -169,7 +169,7 @@ When default output formats include OCSF, Prowler reuses the existing file. Defa
prowler aws --services accessanalyzer --push-to-cloud -o /tmp/scan-output
```
-### CLI output examples
+### CLI Output Examples
**Successful upload:**
```
@@ -228,7 +228,7 @@ curl -X POST \
https://api.prowler.com/api/v1/ingestions
```
-### Submit an ingestion batch
+### Submit an Ingestion Batch
Upload a `.ocsf.json` file containing a JSON array of OCSF Detection Finding records. See [OCSF Detection Finding format](#ocsf-detection-finding-format) for the expected structure.
@@ -266,7 +266,7 @@ curl -X POST \
}
```
-### Get ingestion status
+### Get Ingestion Status
Monitor the progress of an ingestion job.
@@ -304,7 +304,7 @@ curl -X GET \
}
```
-### List ingestion jobs
+### List Ingestion Jobs
Retrieve a list of ingestion jobs for the tenant.
@@ -332,7 +332,7 @@ curl -X GET \
"https://api.prowler.com/api/v1/ingestions?filter[status]=completed&page[size]=10"
```
-### Get ingestion errors
+### Get Ingestion Errors
Retrieve error details for a specific ingestion job.
@@ -346,7 +346,7 @@ curl -X GET \
https://api.prowler.com/api/v1/ingestions/3650fef9-8e5f-4808-a95f-74f0afae8499/errors
```
-## Ingestion status values
+## Ingestion Status Values
| Status | Description |
|--------|-------------|
@@ -355,7 +355,7 @@ curl -X GET \
| `completed` | All records processed successfully |
| `failed` | Job encountered errors during processing |
-## CI/CD integration
+## CI/CD Integration
Automate findings ingestion in CI/CD pipelines by setting the API key as a secret.
@@ -391,7 +391,7 @@ prowler_scan:
PROWLER_CLOUD_API_KEY: $PROWLER_CLOUD_API_KEY
```
-## Billing impact
+## Billing Impact
Each unique cloud account discovered in ingested OCSF findings counts as one **provider** in the Prowler Cloud subscription.
diff --git a/docs/user-guide/tutorials/prowler-scan-scheduling.mdx b/docs/user-guide/tutorials/prowler-scan-scheduling.mdx
new file mode 100644
index 0000000000..f909ed2fa0
--- /dev/null
+++ b/docs/user-guide/tutorials/prowler-scan-scheduling.mdx
@@ -0,0 +1,107 @@
+---
+title: 'Scan Scheduling'
+sidebarTitle: 'Scheduling'
+description: 'Create, edit, and monitor recurring scans in Prowler Cloud and Prowler Private Cloud.'
+---
+
+import { VersionBadge } from "/snippets/version-badge.mdx"
+import { SubscriptionBanner } from "/snippets/subscription-banner.mdx"
+
+
+
+Scan Scheduling lets Prowler run recurring scans for connected providers. Use it to keep findings, compliance results, and resource inventory up to date without launching every scan manually.
+
+
+
+## Prerequisites
+
+Before creating or editing scan schedules, ensure that:
+
+* At least one provider is connected.
+* The user role includes the **Manage Scans** permission, configured through Role-Based Access Control (RBAC). See [RBAC Administrative Permissions](/user-guide/tutorials/prowler-app-rbac#rbac-administrative-permissions) for details.
+
+## Schedule Options
+
+A Prowler Cloud or Prowler Private Cloud subscription supports the following custom recurring schedule options. Prowler Local Server runs a daily scan automatically and does not expose custom cadence controls.
+
+| Schedule Option | Description | Prowler Cloud & Prowler Private Cloud | Prowler Local Server |
+|-----------------|-------------|---------------------------------------|----------------------|
+| Daily | Runs one scan every day at the selected time. | Yes | Automatic |
+| Every 48 hours | Runs one scan every 48 hours, anchored to the selected time. | Yes | — |
+| Weekly | Runs one scan every week on the selected day and time. | Yes | — |
+| Monthly | Runs one scan every month on the selected day, from day 1 to day 28. | Yes | — |
+
+The scan time is always selected on the hour (for example, 14:00); minutes cannot be set. The schedule time uses the browser timezone when the schedule is saved. Prowler displays the next scheduled scan in that timezone.
+
+## Create a Schedule From Scans
+
+To create a schedule from the **Scans** page:
+
+1. Navigate to **Scans**.
+2. Click **Launch Scan**.
+3. Select a connected provider.
+4. Select **On a schedule**.
+5. Choose the **Scan Time** and **Repeats** values.
+6. Optional: select **Launch an initial scan now for immediate findings** to run a scan immediately after saving the recurring schedule.
+7. Click **Save Schedule**.
+
+
+
+
+
+After the schedule is saved, Prowler shows a confirmation toast with a link to the **Scheduled** tab.
+
+## Edit Schedules From Providers
+
+The **Providers** page shows each provider's current schedule in the **Scan Schedule** column. Providers without a recurring schedule show **None**.
+
+
+
+
+
+To edit a provider schedule:
+
+1. Navigate to **Providers**.
+2. Open the provider row actions menu.
+3. Click **Edit Scan Schedule**.
+4. Update the schedule fields.
+5. Click **Save**.
+
+
+
+
+
+To stop automatic scans for a provider, click **Remove Scan Schedule** in the edit modal. Removing a schedule stops future automatic scans; existing completed scan results remain available.
+
+## Bulk Edit Schedules
+
+Use bulk schedule editing when several providers need the same recurring cadence.
+
+To bulk edit provider schedules:
+
+1. Navigate to **Providers**.
+2. Select the provider rows that should receive the same schedule.
+3. Open the selected-row actions menu.
+4. Click **Edit Scan Schedule (N)**, where **N** is the number of selected providers.
+5. Save the schedule.
+
+For AWS Organizations and Organizational Unit rows, **Edit Scan Schedule** applies the schedule to the connected child providers in that group.
+
+
+Bulk schedule edits apply one schedule to every selected provider. If the wrong providers are selected, Prowler applies the same cadence to unintended providers. To recover, reopen bulk edit with the correct selection or update affected provider schedules individually.
+
+
+## Review Scheduled Scans
+
+To review upcoming scheduled scans:
+
+1. Navigate to **Scans**.
+2. Click the **Scheduled** tab.
+
+The **Scheduled** tab shows configured schedules, next scan time, and last scan time. Pending rows represent configured schedules that have not started their next scan yet.
+
+
+
+
+
+To edit a schedule from this tab, open the row actions menu and click **Edit Scan Schedule**.
diff --git a/mcp_server/.env.template b/mcp_server/.env.template
index 11b8caa724..10aabd84cf 100644
--- a/mcp_server/.env.template
+++ b/mcp_server/.env.template
@@ -1,3 +1,3 @@
-PROWLER_APP_API_KEY="pk_your_api_key_here"
+PROWLER_API_KEY="pk_your_api_key_here"
API_BASE_URL="https://api.prowler.com/api/v1"
PROWLER_MCP_TRANSPORT_MODE="stdio"
diff --git a/mcp_server/AGENTS.md b/mcp_server/AGENTS.md
index c8f77bd4b1..1d3d808e6a 100644
--- a/mcp_server/AGENTS.md
+++ b/mcp_server/AGENTS.md
@@ -2,7 +2,7 @@
> **Skills Reference**: See [`prowler-mcp`](../skills/prowler-mcp/SKILL.md)
-### Auto-invoke Skills
+## Auto-invoke Skills
When performing these actions, ALWAYS invoke the corresponding skill FIRST:
@@ -25,7 +25,7 @@ The Prowler MCP Server provides AI agents access to the Prowler ecosystem throug
## CRITICAL RULES
### Tool Implementation
-- ALWAYS: Extend `BaseTool` ABC for Prowler App tools (auto-registration)
+- ALWAYS: Extend `BaseTool` ABC for Prowler tools (auto-registration)
- ALWAYS: Use `@mcp.tool()` decorator for Hub/Docs tools
- NEVER: Manually register BaseTool subclasses
- NEVER: Import tools directly in server.py
@@ -56,7 +56,7 @@ await prowler_mcp_server.import_server(docs_mcp_server, prefix="prowler_docs")
### Tool Naming
- `prowler_hub_*` - Catalog and compliance (no auth)
- `prowler_docs_*` - Documentation search (no auth)
-- `prowler_app_*` - Cloud/App management (auth required)
+- `prowler_*` - Prowler Cloud, Private Cloud & Local Server management (auth required)
---
@@ -68,7 +68,7 @@ Python 3.12+ | FastMCP 2.13.1 | httpx (async) | Pydantic | uv
## PROJECT STRUCTURE
-```
+```text
mcp_server/prowler_mcp_server/
├── server.py # Main orchestration
├── prowler_hub/server.py # Hub tools (no auth)
diff --git a/mcp_server/CHANGELOG.md b/mcp_server/CHANGELOG.md
index 4679f37581..5898f816c9 100644
--- a/mcp_server/CHANGELOG.md
+++ b/mcp_server/CHANGELOG.md
@@ -2,6 +2,32 @@
All notable changes to the **Prowler MCP Server** are documented in this file.
+
+
+## [0.8.0] (Prowler v5.35.0)
+
+### 🔄 Changed
+
+- Core Prowler tool namespace from the `prowler_app_*` prefix to `prowler_*` [(#12017)](https://github.com/prowler-cloud/prowler/pull/12017)
+
+---
+
+## [0.7.2] (Prowler v5.28.1)
+
+### 🐞 Fixed
+
+- Preserve authorization header in HTTP mode [(#11366)](https://github.com/prowler-cloud/prowler/pull/11366)
+
+---
+
+## [0.7.1] (Prowler v5.28.0)
+
+### 🔐 Security
+
+- `fastmcp` from 2.14.0 to 3.2.4 for GHSA-5h2m-4q8j-pqpj, GHSA-rww4-4w9c-7733, and GHSA-vv7q-7jx5-f767, which also pulls fixed `jaraco.context`, `python-multipart`, `starlette`, and drops the vulnerable `lupa`/`urllib3` transitive deps [(#11284)](https://github.com/prowler-cloud/prowler/pull/11284)
+
+---
+
## [0.7.0] (Prowler v5.27.0)
### 🚀 Added
@@ -36,6 +62,8 @@ All notable changes to the **Prowler MCP Server** are documented in this file.
- Attack Path tool to get Neo4j DB schema [(#10321)](https://github.com/prowler-cloud/prowler/pull/10321)
+---
+
## [0.4.0] (Prowler v5.19.0)
### 🚀 Added
diff --git a/mcp_server/Dockerfile b/mcp_server/Dockerfile
index d8377a1762..195bfe1eae 100644
--- a/mcp_server/Dockerfile
+++ b/mcp_server/Dockerfile
@@ -1,7 +1,7 @@
# =============================================================================
# Build stage - Install dependencies and build the application
# =============================================================================
-FROM ghcr.io/astral-sh/uv:python3.13-alpine@sha256:8f53782bb232ab0b5558f3071e86e2bbfde884e18815f2b19cc57f2d336e9ee2 AS builder
+FROM ghcr.io/astral-sh/uv:0.11.21-python3.13-alpine3.23@sha256:f09cc61ffc001f202701fdeae14dbdd50f6ca4cfcf248f41fd3234a302c8534f AS builder
WORKDIR /app
@@ -25,7 +25,7 @@ RUN --mount=type=cache,target=/root/.cache/uv \
# =============================================================================
# Final stage - Minimal runtime environment
# =============================================================================
-FROM python:3.13-alpine@sha256:bb1f2fdb1065c85468775c9d680dcd344f6442a2d1181ef7916b60a623f11d40
+FROM python:3.13.14-alpine3.23@sha256:9fdbf2e3e82628351513560b121e2ee6ce31cac212be9e070c5a5e2769fb5e76
LABEL maintainer="https://github.com/prowler-cloud"
diff --git a/mcp_server/README.md b/mcp_server/README.md
index 644e4be31c..c0e4fdb0fe 100644
--- a/mcp_server/README.md
+++ b/mcp_server/README.md
@@ -6,9 +6,9 @@
## Key Capabilities
-### Prowler Cloud and Prowler App (Self-Managed)
+### Prowler Cloud, Prowler Private Cloud & Prowler Local Server
-Full access to Prowler Cloud platform and self-managed Prowler App for:
+Full access to your Prowler data (Prowler Cloud, Prowler Private Cloud, or Prowler Local Server) for:
- **Findings Analysis**: Query, filter, and analyze security findings across all your cloud environments
- **Finding Groups Analysis**: Triage findings grouped by check ID and drill down into affected resources
- **Provider Management**: Create, configure, and manage your configured Prowler providers (AWS, Azure, GCP, etc.)
@@ -49,7 +49,7 @@ For comprehensive guides and tutorials, see the official documentation:
Prowler MCP Server can be used in three ways:
-### 1. Prowler Cloud MCP Server (Recommended)
+### 1. Hosted Prowler MCP (Recommended)
**Use Prowler's managed MCP server at `https://mcp.prowler.com/mcp`**
@@ -83,14 +83,14 @@ npm install --save-exact mcp-remote@0.1.38
### 2. Local STDIO Mode
-**Run the server locally on your machine**
+Run the server locally on your machine:
- Runs as a subprocess of your MCP client
- Requires Python 3.12+ or Docker
### 3. Self-Hosted HTTP Mode
-**Deploy your own remote MCP server**
+Deploy your own remote MCP server:
- Full control over deployment
- Requires Python 3.12+ or Docker
@@ -126,13 +126,13 @@ For complete tool descriptions and parameters, see the [Tools Reference](https:/
### Tool Naming Convention
All tools follow a consistent naming pattern with prefixes:
-- `prowler_app_*` - Prowler Cloud and App (Self-Managed) management tools
+- `prowler_*` - Prowler Cloud, Prowler Private Cloud & Prowler Local Server management tools
- `prowler_hub_*` - Prowler Hub catalog and compliance tools
- `prowler_docs_*` - Prowler documentation search and retrieval
## Architecture
-```
+```text
prowler_mcp_server/
├── server.py # Main orchestrator (imports sub-servers with prefixes)
├── main.py # CLI entry point
@@ -146,7 +146,7 @@ prowler_mcp_server/
**Key Features:**
- **Modular Design**: Three independent sub-servers with prefixed namespacing
-- **Auto-Discovery**: Prowler App tools are automatically discovered and registered
+- **Auto-Discovery**: Prowler tools are automatically discovered and registered
- **LLM Optimization**: Response models minimize token usage by excluding empty values
- **Dual Transport**: Supports both STDIO (local) and HTTP (remote) modes
@@ -154,34 +154,37 @@ prowler_mcp_server/
The Prowler MCP Server enables powerful workflows through AI assistants:
-**Security Operations**
+### Security Operations
+
- "Show me all critical findings from my AWS production accounts"
- "Register my new AWS account in Prowler and run a scheduled scan every day"
- "List all muted findings and detect what findgings are muted by a not enough good reason in relation to their severity"
-**Security Research**
+### Security Research
+
- "Explain what the S3 bucket public access Prowler check does"
- "Find all Prowler checks related to encryption at rest"
- "What is the latest version of the CIS that Prowler is covering per provider?"
-**Documentation & Learning**
+### Documentation & Learning
+
- "How do I configure Prowler to scan my GCP organization?"
- "What authentication methods does Prowler support for Azure?"
- "How can I contribute with a new security check to Prowler?"
## Requirements
-**For Prowler Cloud MCP Server:**
-- Prowler Cloud account and API key (only for Prowler Cloud/App features)
+**For the hosted Prowler MCP:**
+- Prowler Cloud account and API key (only for Prowler features)
**For self-hosted STDIO/HTTP Mode:**
- Python 3.12+ or Docker
- Network access to:
- `https://hub.prowler.com` (for Prowler Hub)
- `https://docs.prowler.com` (for Prowler Documentation)
- - Prowler Cloud API or self-hosted Prowler App API (for Prowler Cloud/App features)
+ - Prowler Cloud API or Prowler Local Server API (for Prowler features)
-> **No Authentication Required**: Prowler Hub and Prowler Documentation features work without authentication. A Prowler API key is only required to access Prowler Cloud or Prowler App (Self-Managed) features.
+> **No Authentication Required**: Prowler Hub and Prowler Documentation features work without authentication. A Prowler API key is only required to access Prowler features (Prowler Cloud, Prowler Private Cloud, or Prowler Local Server).
## Configuring MCP Hosts
@@ -197,7 +200,7 @@ For developers looking to extend the MCP server with new tools or features:
## Related Products
- **[Prowler Hub](https://hub.prowler.com)**: Browse security checks and compliance frameworks
-- **[Prowler Cloud](https://cloud.prowler.com)**: Managed Prowler platform
+- **[Prowler Cloud](https://cloud.prowler.com)**: Fully managed Prowler in the cloud
- **[Lighthouse AI](https://docs.prowler.com/getting-started/products/prowler-lighthouse-ai)**: AI security analyst
## License
diff --git a/tests/__init__.py b/mcp_server/changelog.d/.gitkeep
similarity index 100%
rename from tests/__init__.py
rename to mcp_server/changelog.d/.gitkeep
diff --git a/mcp_server/changelog.d/README.md b/mcp_server/changelog.d/README.md
new file mode 100644
index 0000000000..0853174f30
--- /dev/null
+++ b/mcp_server/changelog.d/README.md
@@ -0,0 +1,10 @@
+# Changelog fragments
+
+Each PR adds one small file here instead of editing `CHANGELOG.md` directly, so concurrent PRs never conflict.
+
+- Filename: `..md`, e.g. `my-new-check.added.md` (slug is free-form: letters, digits, `.`, `_`, `-`)
+- `` is one of: `added`, `changed`, `deprecated`, `removed`, `fixed`, `security`
+- Content: one line with the changelog entry text, without the PR link and without a trailing period (the PR link is attached automatically at release time)
+- A PR adds as many fragment files as entries it needs, freely mixing types (one file per entry); same-type entries just use different slugs
+
+Fragments are compiled into `CHANGELOG.md` when a release is prepared. Full conventions: `skills/prowler-changelog/SKILL.md`.
diff --git a/mcp_server/prowler_mcp_server/__init__.py b/mcp_server/prowler_mcp_server/__init__.py
index fe7af2dcea..1956a6ec41 100644
--- a/mcp_server/prowler_mcp_server/__init__.py
+++ b/mcp_server/prowler_mcp_server/__init__.py
@@ -5,7 +5,7 @@ This package provides MCP tools for accessing:
- Prowler Hub: All security artifacts (detections, remediations and frameworks) supported by Prowler
"""
-__version__ = "0.5.0"
+__version__ = "0.8.0"
__author__ = "Prowler Team"
__email__ = "engineering@prowler.com"
diff --git a/mcp_server/prowler_mcp_server/prowler_app/models/__init__.py b/mcp_server/prowler_mcp_server/prowler_app/models/__init__.py
index 899ab4e866..1b15e35ac9 100644
--- a/mcp_server/prowler_mcp_server/prowler_app/models/__init__.py
+++ b/mcp_server/prowler_mcp_server/prowler_app/models/__init__.py
@@ -1,4 +1,4 @@
-"""Pydantic models for Prowler App MCP Server."""
+"""Pydantic models for Prowler MCP Server."""
from prowler_mcp_server.prowler_app.models.base import MinimalSerializerMixin
from prowler_mcp_server.prowler_app.models.findings import (
diff --git a/mcp_server/prowler_mcp_server/prowler_app/models/attack_paths.py b/mcp_server/prowler_mcp_server/prowler_app/models/attack_paths.py
index cfd704eeca..bbe2eb7401 100644
--- a/mcp_server/prowler_mcp_server/prowler_app/models/attack_paths.py
+++ b/mcp_server/prowler_mcp_server/prowler_app/models/attack_paths.py
@@ -12,9 +12,10 @@ for optimal LLM token usage.
from typing import Any, Literal
-from prowler_mcp_server.prowler_app.models.base import MinimalSerializerMixin
from pydantic import BaseModel, ConfigDict, Field
+from prowler_mcp_server.prowler_app.models.base import MinimalSerializerMixin
+
class AttackPathScan(MinimalSerializerMixin, BaseModel):
"""Simplified attack paths scan representation for list operations.
diff --git a/mcp_server/prowler_mcp_server/prowler_app/models/compliance.py b/mcp_server/prowler_mcp_server/prowler_app/models/compliance.py
index 4dfe5c4839..c68bd0df85 100644
--- a/mcp_server/prowler_mcp_server/prowler_app/models/compliance.py
+++ b/mcp_server/prowler_mcp_server/prowler_app/models/compliance.py
@@ -2,7 +2,6 @@
from typing import Any, Literal
-from prowler_mcp_server.prowler_app.models.base import MinimalSerializerMixin
from pydantic import (
BaseModel,
ConfigDict,
@@ -11,6 +10,8 @@ from pydantic import (
model_serializer,
)
+from prowler_mcp_server.prowler_app.models.base import MinimalSerializerMixin
+
class ComplianceRequirementAttribute(MinimalSerializerMixin, BaseModel):
"""Requirement attributes including associated check IDs.
diff --git a/mcp_server/prowler_mcp_server/prowler_app/models/finding_groups.py b/mcp_server/prowler_mcp_server/prowler_app/models/finding_groups.py
index 9ff9822977..c2429012c3 100644
--- a/mcp_server/prowler_mcp_server/prowler_app/models/finding_groups.py
+++ b/mcp_server/prowler_mcp_server/prowler_app/models/finding_groups.py
@@ -6,7 +6,6 @@ from pydantic import Field
from prowler_mcp_server.prowler_app.models.base import MinimalSerializerMixin
-
FindingStatus = Literal["FAIL", "PASS", "MANUAL"]
FindingSeverity = Literal["critical", "high", "medium", "low", "informational"]
FindingDelta = Literal["new", "changed"]
@@ -76,9 +75,7 @@ class SimplifiedFindingGroup(MinimalSerializerMixin):
)
muted_count: int = Field(description="Total muted findings in this group", ge=0)
new_count: int = Field(description="Number of new non-muted findings", ge=0)
- changed_count: int = Field(
- description="Number of changed non-muted findings", ge=0
- )
+ changed_count: int = Field(description="Number of changed non-muted findings", ge=0)
first_seen_at: str | None = Field(
default=None, description="First time this group was detected"
)
@@ -109,18 +106,12 @@ class DetailedFindingGroup(SimplifiedFindingGroup):
new_pass_count: int = Field(description="New non-muted PASS findings", ge=0)
new_pass_muted_count: int = Field(description="New muted PASS findings", ge=0)
new_manual_count: int = Field(description="New non-muted MANUAL findings", ge=0)
- new_manual_muted_count: int = Field(
- description="New muted MANUAL findings", ge=0
- )
- changed_fail_count: int = Field(
- description="Changed non-muted FAIL findings", ge=0
- )
+ new_manual_muted_count: int = Field(description="New muted MANUAL findings", ge=0)
+ changed_fail_count: int = Field(description="Changed non-muted FAIL findings", ge=0)
changed_fail_muted_count: int = Field(
description="Changed muted FAIL findings", ge=0
)
- changed_pass_count: int = Field(
- description="Changed non-muted PASS findings", ge=0
- )
+ changed_pass_count: int = Field(description="Changed non-muted PASS findings", ge=0)
changed_pass_muted_count: int = Field(
description="Changed muted PASS findings", ge=0
)
@@ -237,7 +228,7 @@ class FindingGroupResource(MinimalSerializerMixin):
resource: FindingGroupResourceInfo = Field(description="Affected resource")
provider: FindingGroupProviderInfo = Field(description="Affected provider")
finding_id: str = Field(
- description="Finding UUID to use with prowler_app_get_finding_details"
+ description="Finding UUID to use with prowler_get_finding_details"
)
status: FindingStatus = Field(description="Finding status for this resource")
severity: FindingSeverity = Field(description="Finding severity")
diff --git a/mcp_server/prowler_mcp_server/prowler_app/models/findings.py b/mcp_server/prowler_mcp_server/prowler_app/models/findings.py
index 5ef8702ce4..1373e36294 100644
--- a/mcp_server/prowler_mcp_server/prowler_app/models/findings.py
+++ b/mcp_server/prowler_mcp_server/prowler_app/models/findings.py
@@ -2,9 +2,10 @@
from typing import Literal
-from prowler_mcp_server.prowler_app.models.base import MinimalSerializerMixin
from pydantic import BaseModel, ConfigDict, Field
+from prowler_mcp_server.prowler_app.models.base import MinimalSerializerMixin
+
class CheckRemediation(MinimalSerializerMixin, BaseModel):
"""Remediation information for a security check."""
diff --git a/mcp_server/prowler_mcp_server/prowler_app/models/muting.py b/mcp_server/prowler_mcp_server/prowler_app/models/muting.py
index 779acdde5e..e50a150d20 100644
--- a/mcp_server/prowler_mcp_server/prowler_app/models/muting.py
+++ b/mcp_server/prowler_mcp_server/prowler_app/models/muting.py
@@ -2,9 +2,10 @@
from typing import Any
-from prowler_mcp_server.prowler_app.models.base import MinimalSerializerMixin
from pydantic import BaseModel, ConfigDict, Field
+from prowler_mcp_server.prowler_app.models.base import MinimalSerializerMixin
+
class MutelistResponse(MinimalSerializerMixin, BaseModel):
"""Simplified mutelist response with Prowler configuration.
diff --git a/mcp_server/prowler_mcp_server/prowler_app/models/providers.py b/mcp_server/prowler_mcp_server/prowler_app/models/providers.py
index 07f3be1d37..af9509a963 100644
--- a/mcp_server/prowler_mcp_server/prowler_app/models/providers.py
+++ b/mcp_server/prowler_mcp_server/prowler_app/models/providers.py
@@ -2,9 +2,10 @@
from typing import Any, Literal
-from prowler_mcp_server.prowler_app.models.base import MinimalSerializerMixin
from pydantic import BaseModel
+from prowler_mcp_server.prowler_app.models.base import MinimalSerializerMixin
+
class SimplifiedProvider(MinimalSerializerMixin, BaseModel):
"""Simplified provider for list/search operations."""
diff --git a/mcp_server/prowler_mcp_server/prowler_app/models/resources.py b/mcp_server/prowler_mcp_server/prowler_app/models/resources.py
index 823134794f..13cb1ed4dd 100644
--- a/mcp_server/prowler_mcp_server/prowler_app/models/resources.py
+++ b/mcp_server/prowler_mcp_server/prowler_app/models/resources.py
@@ -1,8 +1,9 @@
"""Pydantic models for simplified resources responses."""
-from prowler_mcp_server.prowler_app.models.base import MinimalSerializerMixin
from pydantic import BaseModel
+from prowler_mcp_server.prowler_app.models.base import MinimalSerializerMixin
+
class SimplifiedResource(MinimalSerializerMixin, BaseModel):
"""Simplified resource with only LLM-relevant information for list operations."""
diff --git a/mcp_server/prowler_mcp_server/prowler_app/models/scans.py b/mcp_server/prowler_mcp_server/prowler_app/models/scans.py
index 696ac7837d..f8eef988ce 100644
--- a/mcp_server/prowler_mcp_server/prowler_app/models/scans.py
+++ b/mcp_server/prowler_mcp_server/prowler_app/models/scans.py
@@ -11,9 +11,10 @@ for optimal LLM token usage.
from typing import Any, Literal
-from prowler_mcp_server.prowler_app.models.base import MinimalSerializerMixin
from pydantic import BaseModel, ConfigDict, Field
+from prowler_mcp_server.prowler_app.models.base import MinimalSerializerMixin
+
class SimplifiedScan(MinimalSerializerMixin, BaseModel):
"""Simplified scan representation for list operations.
diff --git a/mcp_server/prowler_mcp_server/prowler_app/server.py b/mcp_server/prowler_mcp_server/prowler_app/server.py
index 39b22d095c..e8e854144f 100644
--- a/mcp_server/prowler_mcp_server/prowler_app/server.py
+++ b/mcp_server/prowler_mcp_server/prowler_app/server.py
@@ -1,4 +1,5 @@
from fastmcp import FastMCP
+
from prowler_mcp_server.prowler_app.utils.tool_loader import load_all_tools
# Initialize MCP server
diff --git a/mcp_server/prowler_mcp_server/prowler_app/tools/__init__.py b/mcp_server/prowler_mcp_server/prowler_app/tools/__init__.py
index 4d740b6efe..8b5be76076 100644
--- a/mcp_server/prowler_mcp_server/prowler_app/tools/__init__.py
+++ b/mcp_server/prowler_mcp_server/prowler_app/tools/__init__.py
@@ -1,4 +1,4 @@
-"""Domain-specific tools for Prowler App MCP Server.
+"""Domain-specific tools for Prowler MCP Server.
Each module in this package contains a BaseTool subclass that registers
and implements tools for a specific domain (findings, providers, scans, etc.).
diff --git a/mcp_server/prowler_mcp_server/prowler_app/tools/attack_paths.py b/mcp_server/prowler_mcp_server/prowler_app/tools/attack_paths.py
index ff9b8045a4..5bd66760fa 100644
--- a/mcp_server/prowler_mcp_server/prowler_app/tools/attack_paths.py
+++ b/mcp_server/prowler_mcp_server/prowler_app/tools/attack_paths.py
@@ -1,4 +1,4 @@
-"""Attack Paths tools for Prowler App MCP Server.
+"""Attack Paths tools for Prowler MCP Server.
This module provides tools for analyzing Attack Paths data from Neo4j graph database.
Attack Paths help identify security risks by tracing potential attack vectors
@@ -7,6 +7,8 @@ through cloud infrastructure relationships.
from typing import Any, Literal
+from pydantic import Field
+
from prowler_mcp_server.prowler_app.models.attack_paths import (
AttackPathCartographySchema,
AttackPathQuery,
@@ -14,23 +16,22 @@ from prowler_mcp_server.prowler_app.models.attack_paths import (
AttackPathScansListResponse,
)
from prowler_mcp_server.prowler_app.tools.base import BaseTool
-from pydantic import Field
class AttackPathsTools(BaseTool):
"""Tools for Attack Paths analysis.
Provides tools for:
- - prowler_app_list_attack_paths_scans: Find completed scans ready for analysis
- - prowler_app_list_attack_paths_queries: Discover available queries for a scan
- - prowler_app_run_attack_paths_query: Execute query and analyze attack paths
+ - prowler_list_attack_paths_scans: Find completed scans ready for analysis
+ - prowler_list_attack_paths_queries: Discover available queries for a scan
+ - prowler_run_attack_paths_query: Execute query and analyze attack paths
"""
async def list_attack_paths_scans(
self,
provider_id: list[str] = Field(
default=[],
- description="Filter by Prowler's internal UUID(s) (v4) for specific provider(s). Use `prowler_app_search_providers` tool to find provider IDs",
+ description="Filter by Prowler's internal UUID(s) (v4) for specific provider(s). Use `prowler_search_providers` tool to find provider IDs",
),
provider_type: list[str] = Field(
default=[],
@@ -72,8 +73,8 @@ class AttackPathsTools(BaseTool):
Workflow:
1. Use this tool to find completed attack paths scans
- 2. Use prowler_app_list_attack_paths_queries to see available queries for a scan
- 3. Use prowler_app_run_attack_paths_query to execute analysis
+ 2. Use prowler_list_attack_paths_queries to see available queries for a scan
+ 3. Use prowler_run_attack_paths_query to execute analysis
"""
try:
# Validate pagination
@@ -112,7 +113,7 @@ class AttackPathsTools(BaseTool):
async def list_attack_paths_queries(
self,
scan_id: str = Field(
- description="UUID of a COMPLETED attack paths scan. Use `prowler_app_list_attack_paths_scans` with state=['completed'] to find scan IDs"
+ description="UUID of a COMPLETED attack paths scan. Use `prowler_list_attack_paths_scans` with state=['completed'] to find scan IDs"
),
) -> list[dict[str, Any]]:
"""Discover available Attack Paths queries for a completed scan.
@@ -132,9 +133,9 @@ class AttackPathsTools(BaseTool):
- aws-ec2-instances-internet-exposed: Find internet-exposed EC2 instances
Workflow:
- 1. Use prowler_app_list_attack_paths_scans to find a completed scan
+ 1. Use prowler_list_attack_paths_scans to find a completed scan
2. Use this tool to discover available queries
- 3. Use prowler_app_run_attack_paths_query with query_id and any required parameters
+ 3. Use prowler_run_attack_paths_query with query_id and any required parameters
"""
try:
api_response = await self.api_client.get(
@@ -157,7 +158,7 @@ class AttackPathsTools(BaseTool):
description="UUID of a COMPLETED attack paths scan. The scan must be in 'completed' state"
),
query_id: str = Field(
- description="Query ID to execute (e.g., 'aws-internet-exposed-ec2-sensitive-s3-access'). Use `prowler_app_list_attack_paths_queries` to discover available queries"
+ description="Query ID to execute (e.g., 'aws-internet-exposed-ec2-sensitive-s3-access'). Use `prowler_list_attack_paths_queries` to discover available queries"
),
parameters: dict[str, str] = Field(
default_factory=dict,
@@ -193,7 +194,7 @@ class AttackPathsTools(BaseTool):
Workflow:
1. Ensure scan is completed
- 2. List available queries (use prowler_app_list_attack_paths_queries)
+ 2. List available queries (use prowler_list_attack_paths_queries)
3. Execute this tool with appropriate parameters
4. Analyze the returned graph for security insights
"""
@@ -230,7 +231,7 @@ class AttackPathsTools(BaseTool):
async def get_attack_paths_cartography_schema(
self,
scan_id: str = Field(
- description="UUID of a COMPLETED attack paths scan. Use `prowler_app_list_attack_paths_scans` with state=['completed'] to find scan IDs"
+ description="UUID of a COMPLETED attack paths scan. Use `prowler_list_attack_paths_scans` with state=['completed'] to find scan IDs"
),
) -> dict[str, Any]:
"""Retrieve the Cartography graph schema for a completed attack paths scan.
@@ -252,10 +253,10 @@ class AttackPathsTools(BaseTool):
- schema_content: Full Cartography schema markdown with node/relationship definitions
Workflow:
- 1. Use prowler_app_list_attack_paths_scans to find a completed scan
+ 1. Use prowler_list_attack_paths_scans to find a completed scan
2. Use this tool to get the schema for the scan's provider
3. Use the schema to craft custom openCypher queries
- 4. Execute queries with prowler_app_run_attack_paths_query
+ 4. Execute queries with prowler_run_attack_paths_query
"""
try:
api_response = await self.api_client.get(
diff --git a/mcp_server/prowler_mcp_server/prowler_app/tools/compliance.py b/mcp_server/prowler_mcp_server/prowler_app/tools/compliance.py
index 81f16a83e9..33cdd22a69 100644
--- a/mcp_server/prowler_mcp_server/prowler_app/tools/compliance.py
+++ b/mcp_server/prowler_mcp_server/prowler_app/tools/compliance.py
@@ -1,4 +1,4 @@
-"""Compliance framework tools for Prowler App MCP Server.
+"""Compliance framework tools for Prowler MCP Server.
This module provides tools for viewing compliance status and requirement details
across all cloud providers.
@@ -6,13 +6,14 @@ across all cloud providers.
from typing import Any
+from pydantic import Field
+
from prowler_mcp_server.prowler_app.models.compliance import (
ComplianceFrameworksListResponse,
ComplianceRequirementAttributesListResponse,
ComplianceRequirementsListResponse,
)
from prowler_mcp_server.prowler_app.tools.base import BaseTool
-from pydantic import Field
class ComplianceTools(BaseTool):
@@ -49,7 +50,7 @@ class ComplianceTools(BaseTool):
if not scans_data:
raise ValueError(
f"No completed scans found for provider {provider_id}. "
- "Run a scan first using prowler_app_trigger_scan."
+ "Run a scan first using prowler_trigger_scan."
)
scan_id = scans_data[0]["id"]
@@ -59,11 +60,11 @@ class ComplianceTools(BaseTool):
self,
scan_id: str | None = Field(
default=None,
- description="UUID of a specific scan to get compliance data for. Required if provider_id is not specified. Use `prowler_app_list_scans` to find scan IDs.",
+ description="UUID of a specific scan to get compliance data for. Required if provider_id is not specified. Use `prowler_list_scans` to find scan IDs.",
),
provider_id: str | None = Field(
default=None,
- description="Prowler's internal UUID (v4) for a specific provider. If provided without scan_id, the tool will automatically find the latest completed scan for this provider. Use `prowler_app_search_providers` tool to find provider IDs.",
+ description="Prowler's internal UUID (v4) for a specific provider. If provided without scan_id, the tool will automatically find the latest completed scan for this provider. Use `prowler_search_providers` tool to find provider IDs.",
),
) -> dict[str, Any]:
"""Get high-level compliance overview across all frameworks for a specific scan.
@@ -89,11 +90,11 @@ class ComplianceTools(BaseTool):
Workflow:
1. Use this tool to get an overview of all compliance frameworks
- 2. Use prowler_app_get_compliance_framework_state_details with a specific compliance_id to see which requirements failed
+ 2. Use prowler_get_compliance_framework_state_details with a specific compliance_id to see which requirements failed
"""
if not scan_id and not provider_id:
return {
- "error": "Either scan_id or provider_id must be provided. Use prowler_app_search_providers to find provider IDs or prowler_app_list_scans to find scan IDs."
+ "error": "Either scan_id or provider_id must be provided. Use prowler_search_providers to find provider IDs or prowler_list_scans to find scan IDs."
}
elif scan_id and provider_id:
return {
@@ -253,7 +254,7 @@ class ComplianceTools(BaseTool):
async def get_compliance_framework_state_details(
self,
compliance_id: str = Field(
- description="Compliance framework ID to get details for (e.g., 'cis_1.5_aws', 'pci_dss_v4.0_aws'). You can get compliance IDs from prowler_app_get_compliance_overview or consulting Prowler Hub/Prowler Documentation that you can also find in form of tools in this MCP Server",
+ description="Compliance framework ID to get details for (e.g., 'cis_1.5_aws', 'pci_dss_v4.0_aws'). You can get compliance IDs from prowler_get_compliance_overview or consulting Prowler Hub/Prowler Documentation that you can also find in form of tools in this MCP Server",
),
scan_id: str | None = Field(
default=None,
@@ -261,14 +262,14 @@ class ComplianceTools(BaseTool):
),
provider_id: str | None = Field(
default=None,
- description="Prowler's internal UUID (v4) for a specific provider. If provided without scan_id, the tool will automatically find the latest completed scan for this provider. Use `prowler_app_search_providers` tool to find provider IDs.",
+ description="Prowler's internal UUID (v4) for a specific provider. If provided without scan_id, the tool will automatically find the latest completed scan for this provider. Use `prowler_search_providers` tool to find provider IDs.",
),
) -> dict[str, Any]:
"""Get detailed requirement-level breakdown for a specific compliance framework.
IMPORTANT: This tool returns DETAILED requirement information for a single compliance framework,
focusing on FAILED requirements and their associated FAILED finding IDs.
- Use this after prowler_app_get_compliance_overview to drill down into specific frameworks.
+ Use this after prowler_get_compliance_overview to drill down into specific frameworks.
The markdown report includes:
@@ -279,7 +280,7 @@ class ComplianceTools(BaseTool):
2. Failed Requirements Breakdown:
- Each failed requirement's ID and description
- Associated failed finding IDs for each failed requirement
- - Use prowler_app_get_finding_details with these finding IDs for more details and remediation guidance
+ - Use prowler_get_finding_details with these finding IDs for more details and remediation guidance
Default behavior:
- Requires either scan_id OR provider_id
@@ -288,14 +289,14 @@ class ComplianceTools(BaseTool):
- Only shows failed requirements with their associated failed finding IDs
Workflow:
- 1. Use prowler_app_get_compliance_overview to identify frameworks with failures
+ 1. Use prowler_get_compliance_overview to identify frameworks with failures
2. Use this tool with the compliance_id to see failed requirements and their finding IDs
- 3. Use prowler_app_get_finding_details with the finding IDs to get remediation guidance
+ 3. Use prowler_get_finding_details with the finding IDs to get remediation guidance
"""
# Validate that either scan_id or provider_id is provided
if not scan_id and not provider_id:
return {
- "error": "Either scan_id or provider_id must be provided. Use prowler_app_search_providers to find provider IDs or prowler_app_list_scans to find scan IDs."
+ "error": "Either scan_id or provider_id must be provided. Use prowler_search_providers to find provider IDs or prowler_list_scans to find scan IDs."
}
# Resolve provider_id to latest scan_id if needed
@@ -394,7 +395,7 @@ class ComplianceTools(BaseTool):
report_lines.append("**Failed Finding IDs**: None found")
report_lines.append("")
report_lines.append(
- "*Use `prowler_app_get_finding_details` with these finding IDs to get remediation guidance.*"
+ "*Use `prowler_get_finding_details` with these finding IDs to get remediation guidance.*"
)
report_lines.append("")
diff --git a/mcp_server/prowler_mcp_server/prowler_app/tools/finding_groups.py b/mcp_server/prowler_mcp_server/prowler_app/tools/finding_groups.py
index 363e45d970..05adf8db2b 100644
--- a/mcp_server/prowler_mcp_server/prowler_app/tools/finding_groups.py
+++ b/mcp_server/prowler_mcp_server/prowler_app/tools/finding_groups.py
@@ -1,4 +1,4 @@
-"""Finding Groups tools for Prowler App MCP Server.
+"""Finding Groups tools for Prowler MCP Server.
This module provides read-only tools for finding group triage and drill-downs.
"""
@@ -15,7 +15,6 @@ from prowler_mcp_server.prowler_app.models.finding_groups import (
)
from prowler_mcp_server.prowler_app.tools.base import BaseTool
-
StatusFilter = Literal["FAIL", "PASS", "MANUAL"]
SeverityFilter = Literal["critical", "high", "medium", "low", "informational"]
DeltaFilter = Literal["new", "changed"]
@@ -234,8 +233,8 @@ class FindingGroupsTools(BaseTool):
`date_to`, this uses `/finding-groups` with a maximum 2-day date window.
Use this tool to find noisy or high-impact checks, then call
- prowler_app_get_finding_group_details for complete counters or
- prowler_app_list_finding_group_resources to drill into affected resources.
+ prowler_get_finding_group_details for complete counters or
+ prowler_list_finding_group_resources to drill into affected resources.
"""
try:
self.api_client.validate_page_size(page_size)
@@ -424,7 +423,7 @@ class FindingGroupsTools(BaseTool):
Default behavior returns FAIL, unmuted resources so the result is
actionable. Set `include_muted=True` to include accepted/suppressed
resources too. Each row includes nested resource and provider data plus
- `finding_id`. Use `prowler_app_get_finding_details(finding_id)` to
+ `finding_id`. Use `prowler_get_finding_details(finding_id)` to
retrieve complete remediation guidance for a specific resource finding.
"""
try:
@@ -464,9 +463,7 @@ class FindingGroupsTools(BaseTool):
clean_params = self.api_client.build_filter_params(params)
api_response = await self.api_client.get(endpoint, params=clean_params)
- response = FindingGroupResourcesListResponse.from_api_response(
- api_response
- )
+ response = FindingGroupResourcesListResponse.from_api_response(api_response)
return response.model_dump()
except Exception as e:
self.logger.error(f"Error listing finding group resources: {e}")
diff --git a/mcp_server/prowler_mcp_server/prowler_app/tools/findings.py b/mcp_server/prowler_mcp_server/prowler_app/tools/findings.py
index ec492c6a43..b556101cab 100644
--- a/mcp_server/prowler_mcp_server/prowler_app/tools/findings.py
+++ b/mcp_server/prowler_mcp_server/prowler_app/tools/findings.py
@@ -1,4 +1,4 @@
-"""Security Findings tools for Prowler App MCP Server.
+"""Security Findings tools for Prowler MCP Server.
This module provides tools for searching, viewing, and analyzing security findings
across all cloud providers.
@@ -92,7 +92,7 @@ class FindingsTools(BaseTool):
"""Search and filter security findings across all cloud providers with rich filtering capabilities.
IMPORTANT: This tool returns LIGHTWEIGHT findings. Use this for fast searching and filtering across many findings.
- For complete details use prowler_app_get_finding_details on specific findings.
+ For complete details use prowler_get_finding_details on specific findings.
Default behavior:
- Returns latest findings from most recent scans (no date parameters needed)
@@ -111,7 +111,7 @@ class FindingsTools(BaseTool):
Workflow:
1. Use this tool to search and filter findings by severity, status, provider, service, region, etc.
- 2. Use prowler_app_get_finding_details with the finding 'id' to get complete information about the finding
+ 2. Use prowler_get_finding_details with the finding 'id' to get complete information about the finding
"""
# Validate page_size parameter
self.api_client.validate_page_size(page_size)
@@ -187,9 +187,9 @@ class FindingsTools(BaseTool):
"""Retrieve comprehensive details about a specific security finding by its ID.
IMPORTANT: This tool returns COMPLETE finding details.
- Use this after finding a specific finding via prowler_app_search_security_findings
+ Use this after finding a specific finding via prowler_search_security_findings
- This tool provides ALL information that prowler_app_search_security_findings returns PLUS:
+ This tool provides ALL information that prowler_search_security_findings returns PLUS:
1. Check Metadata (information about the check script that generated the finding):
- title: Human-readable phrase used to summarize the check
@@ -217,7 +217,7 @@ class FindingsTools(BaseTool):
- resource_ids: List of UUIDs for cloud resources associated with this finding
Workflow:
- 1. Use prowler_app_search_security_findings to browse and filter findings
+ 1. Use prowler_search_security_findings to browse and filter findings
2. Use this tool with the finding 'id' to get remediation guidance and complete context
"""
params = {
diff --git a/mcp_server/prowler_mcp_server/prowler_app/tools/muting.py b/mcp_server/prowler_mcp_server/prowler_app/tools/muting.py
index 639f1ec3b7..37e1504165 100644
--- a/mcp_server/prowler_mcp_server/prowler_app/tools/muting.py
+++ b/mcp_server/prowler_mcp_server/prowler_app/tools/muting.py
@@ -1,4 +1,4 @@
-"""Muting tools for Prowler App MCP Server.
+"""Muting tools for Prowler MCP Server.
This module provides tools for managing finding muting in Prowler, including:
- Mutelist management (pattern-based bulk muting)
@@ -43,7 +43,7 @@ class MutingTools(BaseTool):
Workflow:
1. Use this tool to check if a mutelist is configured
2. Examine current muting patterns before making updates
- 3. Use prowler_app_set_mutelist to create or update the configuration
+ 3. Use prowler_set_mutelist to create or update the configuration
"""
self.logger.info("Retrieving mutelist configuration...")
@@ -61,7 +61,7 @@ class MutingTools(BaseTool):
if len(data) == 0:
return {
"error": "No mutelist found",
- "message": "No mutelist configuration exists for this tenant. Use prowler_app_set_mutelist to create one.",
+ "message": "No mutelist configuration exists for this tenant. Use prowler_set_mutelist to create one.",
}
# Return the first (and only) mutelist
@@ -116,10 +116,10 @@ Structure:
- Exceptions: Accounts, Regions, Resources to exclude from muting
Workflow:
- 1. Use prowler_app_get_mutelist to check existing configuration
+ 1. Use prowler_get_mutelist to check existing configuration
2. Build configuration object following Prowler mutelist format
3. Use this tool to create or update the mutelist
- 4. Verify with prowler_app_get_mutelist
+ 4. Verify with prowler_get_mutelist
"""
self.logger.info("Setting mutelist configuration...")
@@ -171,12 +171,12 @@ Structure:
"""Remove the mutelist configuration from the tenant.
WARNING: This is a destructive operation that cannot be undone.
- - The mutelist will need to be re-created with prowler_app_set_mutelist
+ - The mutelist will need to be re-created with prowler_set_mutelist
- New findings from future scans will NOT be muted by the deleted mutelist
- Previously muted findings remain muted (deletion doesn't un-mute them)
Workflow:
- 1. Use prowler_app_get_mutelist to confirm what will be deleted
+ 1. Use prowler_get_mutelist to confirm what will be deleted
2. Use this tool to permanently remove the mutelist
3. New scans will no longer apply mutelist-based muting
"""
@@ -229,7 +229,7 @@ Structure:
"""Search and filter mute rules with pagination support.
IMPORTANT: This tool returns LIGHTWEIGHT mute rules without the full list of finding UIDs.
- Use prowler_app_get_mute_rule to get complete details including all finding UIDs and creator information.
+ Use prowler_get_mute_rule to get complete details including all finding UIDs and creator information.
Default behavior:
- Returns all mute rules (both enabled and disabled)
@@ -237,15 +237,15 @@ Structure:
- Includes basic rule information without full finding UID lists
Each mute rule includes:
- - Core identification: id (UUID for prowler_app_get_mute_rule), name
+ - Core identification: id (UUID for prowler_get_mute_rule), name
- Contextual information: reason, enabled status
- State tracking: finding_count (number of findings currently muted)
- Temporal data: inserted_at, updated_at timestamps
Workflow:
1. Use this tool to search and filter mute rules by name, enabled status, or keywords
- 2. Use prowler_app_get_mute_rule with the mute rule 'id' to get complete details including all finding UIDs
- 3. Use prowler_app_update_mute_rule or prowler_app_delete_mute_rule to modify rules
+ 2. Use prowler_get_mute_rule with the mute rule 'id' to get complete details including all finding UIDs
+ 3. Use prowler_update_mute_rule or prowler_delete_mute_rule to modify rules
"""
self.logger.info("Listing mute rules...")
self.api_client.validate_page_size(page_size)
@@ -289,17 +289,17 @@ Structure:
"""Retrieve comprehensive details about a specific mute rule by its ID.
IMPORTANT: This tool returns COMPLETE mute rule details including the full list of finding UIDs.
- Use this after finding a rule via prowler_app_list_mute_rules.
+ Use this after finding a rule via prowler_list_mute_rules.
- This tool provides ALL information that prowler_app_list_mute_rules returns PLUS:
+ This tool provides ALL information that prowler_list_mute_rules returns PLUS:
- finding_uids: Complete list of finding UIDs that are muted by this rule
- user_creator_id: UUID of the user who created the rule (audit trail)
Workflow:
- 1. Use prowler_app_list_mute_rules to find rules by name or filter criteria
+ 1. Use prowler_list_mute_rules to find rules by name or filter criteria
2. Use this tool with the rule 'id' to get complete details
3. Examine finding_uids list to understand which findings are muted
- 4. Use prowler_app_update_mute_rule or prowler_app_delete_mute_rule to modify if needed
+ 4. Use prowler_update_mute_rule or prowler_delete_mute_rule to modify if needed
"""
self.logger.info(f"Retrieving mute rule {rule_id}...")
@@ -323,7 +323,7 @@ Structure:
description="Reason for muting these findings. Document why this security issue is acceptable or intentional (e.g., 'Development environment with controlled access', 'Legacy application requires IMDSv1')."
),
finding_ids: list[str] = Field(
- description="List of finding IDs (UUIDs) to mute. Get these from the prowler_app_search_security_findings tool. Must provide at least 1 finding ID."
+ description="List of finding IDs (UUIDs) to mute. Get these from the prowler_search_security_findings tool. Must provide at least 1 finding ID."
),
) -> dict[str, Any]:
"""Create a new mute rule to mute specific findings with documentation and audit trail.
@@ -337,15 +337,15 @@ Structure:
- Records creator for audit trail
The mute rule includes:
- - Core identification: id (UUID for prowler_app_get_mute_rule), name, reason
+ - Core identification: id (UUID for prowler_get_mute_rule), name, reason
- Configuration: enabled status, finding_uids list
- Audit trail: user_creator_id (UUID of the Prowler user from the tenant that created the rule), timestamps when the rule was created and last modified
Workflow:
- 1. Use prowler_app_search_security_findings to identify findings to mute
+ 1. Use prowler_search_security_findings to identify findings to mute
2. Use this tool with finding IDs, descriptive name, and documented reason
- 3. Verify with prowler_app_get_mute_rule to confirm rule creation
- 4. Check findings are muted with prowler_app_search_security_findings (filter by muted=true)
+ 3. Verify with prowler_get_mute_rule to confirm rule creation
+ 4. Check findings are muted with prowler_search_security_findings (filter by muted=true)
"""
self.logger.info(f"Creating mute rule '{name}'...")
@@ -399,9 +399,9 @@ Structure:
- enabled: Toggle rule active status (doesn't affect already-muted findings)
Workflow:
- 1. Use prowler_app_get_mute_rule to see current rule state
+ 1. Use prowler_get_mute_rule to see current rule state
2. Use this tool to update name, reason, or enabled status
- 3. Verify changes with prowler_app_get_mute_rule
+ 3. Verify changes with prowler_get_mute_rule
"""
self.logger.info(f"Updating mute rule {rule_id}...")
@@ -451,9 +451,9 @@ Structure:
- Cannot be undone - rule must be recreated to restore
Workflow:
- 1. Use prowler_app_get_mute_rule to review what will be deleted
+ 1. Use prowler_get_mute_rule to review what will be deleted
2. Use this tool to permanently remove the rule
- 3. Verify deletion with prowler_app_list_mute_rules (rule should no longer appear)
+ 3. Verify deletion with prowler_list_mute_rules (rule should no longer appear)
"""
self.logger.info(f"Deleting mute rule {rule_id}...")
diff --git a/mcp_server/prowler_mcp_server/prowler_app/tools/providers.py b/mcp_server/prowler_mcp_server/prowler_app/tools/providers.py
index b22d57d7b9..3ba417d677 100644
--- a/mcp_server/prowler_mcp_server/prowler_app/tools/providers.py
+++ b/mcp_server/prowler_mcp_server/prowler_app/tools/providers.py
@@ -1,4 +1,4 @@
-"""Provider Management tools for Prowler App MCP Server.
+"""Provider Management tools for Prowler MCP Server.
This module provides tools for managing provider connections,
including searching, connecting, and deleting providers.
@@ -19,9 +19,9 @@ class ProvidersTools(BaseTool):
"""Tools for provider management operations
Provides tools for:
- - prowler_app_search_providers: Search and view configured providers with their connection status
- - prowler_app_connect_provider: Connect or register a provider for security scanning in Prowler
- - prowler_app_delete_provider: Permanently remove a provider from Prowler
+ - prowler_search_providers: Search and view configured providers with their connection status
+ - prowler_connect_provider: Connect or register a provider for security scanning in Prowler
+ - prowler_delete_provider: Permanently remove a provider from Prowler
"""
async def search_providers(
@@ -145,7 +145,7 @@ class ProvidersTools(BaseTool):
) -> dict[str, Any]:
"""Register a provider to be scanned with Prowler.
- This tool will register a provider in Prowler App, even if the UID is wrong.
+ This tool will register a provider in Prowler, even if the UID is wrong.
If the provider is already registered, it will be updated with the new provided alias or credentials if provided.
If credentials are provided, they will be added to the indicated provider, if the provider does not exist, it will be created and the credentials will be added to it.
If the connection test is successful, the provider will be connected.
@@ -292,13 +292,13 @@ class ProvidersTools(BaseTool):
async def delete_provider(
self,
provider_id: str = Field(
- description="Prowler's internal UUID (v4) for the provider to permanently remove, generated when the provider was registered in the system. Use `prowler_app_search_providers` tool to find the provider_id if you only know the alias or the provider's own identifier (provider_uid)"
+ description="Prowler's internal UUID (v4) for the provider to permanently remove, generated when the provider was registered in the system. Use `prowler_search_providers` tool to find the provider_id if you only know the alias or the provider's own identifier (provider_uid)"
),
) -> dict[str, Any]:
"""Permanently remove a registered provider from Prowler.
WARNING: This is a destructive operation that cannot be undone. The provider will need to be
- re-added with prowler_app_connect_provider if you want to scan it again.
+ re-added with prowler_connect_provider if you want to scan it again.
The tool always returns the deletion status and message.
"""
diff --git a/mcp_server/prowler_mcp_server/prowler_app/tools/resources.py b/mcp_server/prowler_mcp_server/prowler_app/tools/resources.py
index 042232e6a5..88fcca25ae 100644
--- a/mcp_server/prowler_mcp_server/prowler_app/tools/resources.py
+++ b/mcp_server/prowler_mcp_server/prowler_app/tools/resources.py
@@ -1,4 +1,4 @@
-"""Cloud Resources tools for Prowler App MCP Server.
+"""Cloud Resources tools for Prowler MCP Server.
This module provides tools for searching, viewing, and analyzing cloud resources
across all providers.
@@ -6,6 +6,8 @@ across all providers.
from typing import Any
+from pydantic import Field
+
from prowler_mcp_server.prowler_app.models.resources import (
DetailedResource,
ResourceEventsResponse,
@@ -13,7 +15,6 @@ from prowler_mcp_server.prowler_app.models.resources import (
ResourcesMetadataResponse,
)
from prowler_mcp_server.prowler_app.tools.base import BaseTool
-from pydantic import Field
class ResourcesTools(BaseTool):
@@ -85,7 +86,7 @@ class ResourcesTools(BaseTool):
IMPORTANT: This tool returns LIGHTWEIGHT resource information. Use this for fast searching
and filtering across many resources. For complete configuration details, metadata, and finding
- relationships, use prowler_app_get_resource on specific resources of interest.
+ relationships, use prowler_get_resource on specific resources of interest.
This is the primary tool for browsing resources with rich filtering capabilities.
Returns current state by default (latest scan per provider). Specify dates to query
@@ -101,16 +102,16 @@ class ResourcesTools(BaseTool):
- With dates: queries historical resource state (2-day maximum range between date_from and date_to)
Each resource includes:
- - Core identification: id (UUID for prowler_app_get_resource), uid, name
+ - Core identification: id (UUID for prowler_get_resource), uid, name
- Location context: region, service, type
- Security context: failed_findings_count (number of active security issues)
- Tags: tags associated with the resource
Useful Workflow:
1. Use this tool to search and filter resources by provider, region, service, tags, etc.
- 2. Use prowler_app_get_resource with the resource 'id' to get complete configuration and metadata
- 3. Use prowler_app_search_security_findings to find security issues for specific resources
- 4. Use prowler_app_get_finding_details to get details about the security issues for specific resources
+ 2. Use prowler_get_resource with the resource 'id' to get complete configuration and metadata
+ 3. Use prowler_search_security_findings to find security issues for specific resources
+ 4. Use prowler_get_finding_details to get details about the security issues for specific resources
"""
# Validate page_size parameter
self.api_client.validate_page_size(page_size)
@@ -176,15 +177,15 @@ class ResourcesTools(BaseTool):
async def get_resource(
self,
resource_id: str = Field(
- description="Prowler's internal UUID (v4) for the resource to retrieve, generated when the resource was discovered in the system. Use `prowler_app_list_resources` tool to find the right ID"
+ description="Prowler's internal UUID (v4) for the resource to retrieve, generated when the resource was discovered in the system. Use `prowler_list_resources` tool to find the right ID"
),
) -> dict[str, Any]:
"""Retrieve comprehensive details about a specific resource by its ID.
IMPORTANT: This tool provides COMPLETE resource details with all available information.
- Use this after finding a specific resource via prowler_app_list_resources.
+ Use this after finding a specific resource via prowler_list_resources.
- This tool provides ALL information that prowler_app_list_resources returns PLUS:
+ This tool provides ALL information that prowler_list_resources returns PLUS:
1. Configuration Details:
- metadata: Provider-specific configuration (tags, policies, encryption settings, network rules)
@@ -196,12 +197,12 @@ class ResourcesTools(BaseTool):
3. Security Relationships:
- finding_ids: Prowler's internal UUIDs (v4) of all security findings associated with this resource
- - Use prowler_app_get_finding_details on these IDs to get remediation guidance
+ - Use prowler_get_finding_details on these IDs to get remediation guidance
Useful Workflow:
- 1. Use prowler_app_list_resources to browse and filter across many resources
+ 1. Use prowler_list_resources to browse and filter across many resources
2. Use this tool to drill down into specific resources of interest
- 3. Use prowler_app_get_finding_details to get details about the security issues for specific resources
+ 3. Use prowler_get_finding_details to get details about the security issues for specific resources
"""
params = {}
@@ -347,7 +348,7 @@ class ResourcesTools(BaseTool):
async def get_resource_events(
self,
resource_id: str = Field(
- description="Prowler's internal UUID (v4) for the resource. Use `prowler_app_list_resources` to find the right ID, or get it from a finding's resource relationship via `prowler_app_get_finding_details`."
+ description="Prowler's internal UUID (v4) for the resource. Use `prowler_list_resources` to find the right ID, or get it from a finding's resource relationship via `prowler_get_finding_details`."
),
lookback_days: int = Field(
default=90,
@@ -385,8 +386,8 @@ class ResourcesTools(BaseTool):
- Identifying unauthorized or unexpected modifications
Workflows:
- 1. Resource browsing: prowler_app_list_resources → find resource → this tool for event history
- 2. Incident investigation: prowler_app_get_finding_details → get resource ID from finding → this tool to identify who caused the issue, what they changed, and when
+ 1. Resource browsing: prowler_list_resources → find resource → this tool for event history
+ 2. Incident investigation: prowler_get_finding_details → get resource ID from finding → this tool to identify who caused the issue, what they changed, and when
"""
params = {
"lookback_days": lookback_days,
diff --git a/mcp_server/prowler_mcp_server/prowler_app/tools/scans.py b/mcp_server/prowler_mcp_server/prowler_app/tools/scans.py
index 1df636ffc0..21d1431b71 100644
--- a/mcp_server/prowler_mcp_server/prowler_app/tools/scans.py
+++ b/mcp_server/prowler_mcp_server/prowler_app/tools/scans.py
@@ -1,4 +1,4 @@
-"""Security Scans tools for Prowler App MCP Server.
+"""Security Scans tools for Prowler MCP Server.
This module provides tools for managing and monitoring Prowler security scans.
"""
@@ -20,18 +20,18 @@ class ScansTools(BaseTool):
"""Tools for security scan operations.
Provides tools for:
- - prowler_app_list_scans: Search and filter scans with rich filtering capabilities
- - prowler_app_get_scan: Get comprehensive details about a specific scan
- - prowler_app_trigger_scan: Trigger manual security scans for providers
- - prowler_app_schedule_daily_scan: Schedule automated daily scans for continuous monitoring
- - prowler_app_update_scan: Update scan names for better organization
+ - prowler_list_scans: Search and filter scans with rich filtering capabilities
+ - prowler_get_scan: Get comprehensive details about a specific scan
+ - prowler_trigger_scan: Trigger manual security scans for providers
+ - prowler_schedule_daily_scan: Schedule automated daily scans for continuous monitoring
+ - prowler_update_scan: Update scan names for better organization
"""
async def list_scans(
self,
provider_id: list[str] = Field(
default=[],
- description="Filter by Prowler's internal UUID(s) (v4) for specific provider(s), generated when the provider was registered. Use `prowler_app_search_providers` tool to find provider IDs",
+ description="Filter by Prowler's internal UUID(s) (v4) for specific provider(s), generated when the provider was registered. Use `prowler_search_providers` tool to find provider IDs",
),
provider_type: list[str] = Field(
default=[],
@@ -56,7 +56,7 @@ class ScansTools(BaseTool):
),
trigger: Literal["manual", "scheduled"] | None = Field(
default=None,
- description="Filter by how the scan was initiated. Options: 'manual' (user-initiated via prowler_app_trigger_scan), 'scheduled' (automated via prowler_app_schedule_daily_scan)",
+ description="Filter by how the scan was initiated. Options: 'manual' (user-initiated via prowler_trigger_scan), 'scheduled' (automated via prowler_schedule_daily_scan)",
),
name: str | None = Field(
default=None,
@@ -75,7 +75,7 @@ class ScansTools(BaseTool):
IMPORTANT: This tool returns LIGHTWEIGHT scan information. Use this for fast searching and filtering
across many scans. For complete scan details including progress, duration, and resource counts,
- use prowler_app_get_scan on specific scans of interest.
+ use prowler_get_scan on specific scans of interest.
Default behavior:
- Returns all scans
@@ -83,15 +83,15 @@ class ScansTools(BaseTool):
- Includes all scan states (available, scheduled, executing, completed, failed, cancelled)
Each scan includes:
- - Core identification: id (UUID for prowler_app_get_scan), name
+ - Core identification: id (UUID for prowler_get_scan), name
- Execution context: state, trigger (manual/scheduled)
- Temporal data: started_at, completed_at
- Provider relationship: provider_id
Workflow:
1. Use this tool to search and filter scans by provider, state, or date range
- 2. Use prowler_app_get_scan with the scan 'id' to get progress, duration, and resource counts
- 3. Use prowler_app_search_security_findings filtered by scan dates to analyze scan results
+ 2. Use prowler_get_scan with the scan 'id' to get progress, duration, and resource counts
+ 3. Use prowler_search_security_findings filtered by scan dates to analyze scan results
"""
# Validate pagination
self.api_client.validate_page_size(page_size)
@@ -128,15 +128,15 @@ class ScansTools(BaseTool):
async def get_scan(
self,
scan_id: str = Field(
- description="Prowler's internal UUID (v4) for the scan to retrieve, generated when the scan was created (e.g., '123e4567-e89b-12d3-a456-426614174000'). Use `prowler_app_list_scans` tool to find scan IDs"
+ description="Prowler's internal UUID (v4) for the scan to retrieve, generated when the scan was created (e.g., '123e4567-e89b-12d3-a456-426614174000'). Use `prowler_list_scans` tool to find scan IDs"
),
) -> dict[str, Any]:
"""Retrieve comprehensive details about a specific scan by its ID.
IMPORTANT: This tool returns COMPLETE scan details.
- Use this after finding a specific scan via prowler_app_list_scans.
+ Use this after finding a specific scan via prowler_list_scans.
- This tool provides ALL information that prowler_app_list_scans returns PLUS:
+ This tool provides ALL information that prowler_list_scans returns PLUS:
1. Execution Details:
- progress: Scan completion progress as percentage (0-100%)
@@ -155,9 +155,9 @@ class ScansTools(BaseTool):
- Understanding scan scheduling patterns
Workflow:
- 1. Use prowler_app_list_scans to browse and filter scans
+ 1. Use prowler_list_scans to browse and filter scans
2. Use this tool with the scan 'id' to monitor progress or view detailed results
- 3. For completed scans, use prowler_app_search_security_findings filtered by date to analyze findings
+ 3. For completed scans, use prowler_search_security_findings filtered by date to analyze findings
"""
# Fetch scan with all fields
params = {
@@ -172,7 +172,7 @@ class ScansTools(BaseTool):
async def trigger_scan(
self,
provider_id: str = Field(
- description="Prowler's internal UUID (v4) for the provider to scan, generated when the provider was registered in the system (e.g., '4d0e2614-6385-4fa7-bf0b-c2e2f75c6877'). Use `prowler_app_search_providers` tool to find the provider ID"
+ description="Prowler's internal UUID (v4) for the provider to scan, generated when the provider was registered in the system (e.g., '4d0e2614-6385-4fa7-bf0b-c2e2f75c6877'). Use `prowler_search_providers` tool to find the provider ID"
),
name: str | None = Field(
default=None,
@@ -182,14 +182,14 @@ class ScansTools(BaseTool):
"""Trigger a manual security scan for a provider.
IMPORTANT: This tool returns immediately once the scan is created.
- The scan will continue running in the background. Use `prowler_app_get_scan`
+ The scan will continue running in the background. Use `prowler_get_scan`
with the returned scan ID to monitor progress and check when it completes.
Example Useful Workflow:
- 1. Use `prowler_app_search_providers` to find the provider_id you want to scan
+ 1. Use `prowler_search_providers` to find the provider_id you want to scan
2. Use this tool to trigger the scan
- 3. Use `prowler_app_get_scan` with the returned scan 'id' to monitor progress
- 4. Once completed, use `prowler_app_search_security_findings` to analyze results
+ 3. Use `prowler_get_scan` with the returned scan 'id' to monitor progress
+ 4. Once completed, use `prowler_search_security_findings` to analyze results
"""
try:
# Build request data
@@ -231,7 +231,7 @@ class ScansTools(BaseTool):
return ScanCreationResult(
scan=scan_info,
status="success",
- message=f"Scan {scan_id} created successfully. The scan may take some time to complete. Use prowler_app_get_scan tool with this ID to monitor progress.",
+ message=f"Scan {scan_id} created successfully. The scan may take some time to complete. Use prowler_get_scan tool with this ID to monitor progress.",
).model_dump()
except Exception as e:
@@ -245,7 +245,7 @@ class ScansTools(BaseTool):
async def schedule_daily_scan(
self,
provider_id: str = Field(
- description="Prowler's internal UUID (v4) for the provider to scan, generated when the provider was registered in the system (e.g., '4d0e2614-6385-4fa7-bf0b-c2e2f75c6877'). Use `prowler_app_search_providers` tool to find the provider ID"
+ description="Prowler's internal UUID (v4) for the provider to scan, generated when the provider was registered in the system (e.g., '4d0e2614-6385-4fa7-bf0b-c2e2f75c6877'). Use `prowler_search_providers` tool to find the provider ID"
),
) -> dict[str, Any]:
"""Schedule automated daily scans for a provider for continuous security monitoring.
@@ -256,17 +256,17 @@ class ScansTools(BaseTool):
you're not actively using the system.
IMPORTANT: This tool returns immediately once the daily schedule is created.
- The schedule will be set up in the background. Use `prowler_app_list_scans`
+ The schedule will be set up in the background. Use `prowler_list_scans`
filtered by provider_id and trigger='scheduled' to view scheduled scans.
IMPORTANT: This creates a PERSISTENT schedule. The provider will be scanned
automatically every 24 hours until the provider is deleted.
Example Useful Workflow:
- 1. Use `prowler_app_search_providers` to find the provider_id you want to monitor
+ 1. Use `prowler_search_providers` to find the provider_id you want to monitor
2. Use this tool to create the daily schedule
- 3. Use `prowler_app_list_scans` filtered by provider_id to view scheduled and completed scans
- 4. Monitor findings over time with `prowler_app_search_security_findings`
+ 3. Use `prowler_list_scans` filtered by provider_id to view scheduled and completed scans
+ 4. Monitor findings over time with `prowler_search_security_findings`
"""
self.logger.info(f"Creating daily schedule for provider {provider_id}")
task_response = await self.api_client.post(
@@ -285,7 +285,7 @@ class ScansTools(BaseTool):
)
if task_state == "available":
- return_message = "Daily schedule created successfully. The schedule is being set up in the background. Use prowler_app_list_scans with provider_id filter to view scheduled scans."
+ return_message = "Daily schedule created successfully. The schedule is being set up in the background. Use prowler_list_scans with provider_id filter to view scheduled scans."
else:
return_message = "Daily schedule creation failed. Please try again later."
@@ -297,7 +297,7 @@ class ScansTools(BaseTool):
async def update_scan(
self,
scan_id: str = Field(
- description="Prowler's internal UUID (v4) for the scan to update, generated when the scan was created (e.g., '123e4567-e89b-12d3-a456-426614174000'). Use `prowler_app_list_scans` tool to find the scan ID if you only know the provider or scan name. Returns an error if the scan ID is invalid or not found."
+ description="Prowler's internal UUID (v4) for the scan to update, generated when the scan was created (e.g., '123e4567-e89b-12d3-a456-426614174000'). Use `prowler_list_scans` tool to find the scan ID if you only know the provider or scan name. Returns an error if the scan ID is invalid or not found."
),
name: str = Field(
description="New human-friendly name for the scan (3-100 characters). Use descriptive names to improve organization and tracking, e.g., 'Production Security Audit - Q4 2025', 'Post-Deployment Compliance Check'. IMPORTANT: Only the scan name can be updated - other attributes (state, progress, duration) are read-only and managed by the system."
@@ -309,7 +309,7 @@ class ScansTools(BaseTool):
(state, progress, duration, etc.) are read-only and managed by the system.
Example Useful Workflow:
- 1. Use `prowler_app_list_scans` to find the scan you want to rename
+ 1. Use `prowler_list_scans` to find the scan you want to rename
2. Use this tool with the scan 'id' and new name
"""
api_response = await self.api_client.patch(
diff --git a/mcp_server/prowler_mcp_server/prowler_app/utils/api_client.py b/mcp_server/prowler_mcp_server/prowler_app/utils/api_client.py
index 4b6d0e77f5..187364bee1 100644
--- a/mcp_server/prowler_mcp_server/prowler_app/utils/api_client.py
+++ b/mcp_server/prowler_mcp_server/prowler_app/utils/api_client.py
@@ -1,12 +1,13 @@
-"""Shared API client utilities for Prowler App tools."""
+"""Shared API client utilities for Prowler tools."""
import asyncio
from datetime import datetime, timedelta
-from enum import Enum
-from typing import Any, Dict
+from enum import StrEnum
+from typing import Any
from urllib.parse import urlparse
import httpx
+
from prowler_mcp_server import __version__
from prowler_mcp_server.lib.logger import logger
from prowler_mcp_server.prowler_app.utils.auth import ProwlerAppAuth
@@ -14,7 +15,7 @@ from prowler_mcp_server.prowler_app.utils.auth import ProwlerAppAuth
ALLOWED_EXTERNAL_DOMAINS: frozenset[str] = frozenset({"raw.githubusercontent.com"})
-class HTTPMethod(str, Enum):
+class HTTPMethod(StrEnum):
"""HTTP methods enum."""
GET = "GET"
@@ -30,7 +31,7 @@ class SingletonMeta(type):
All calls to the constructor return the same instance.
"""
- _instances: Dict[type, Any] = {}
+ _instances: dict[type, Any] = {}
def __call__(cls, *args, **kwargs):
"""Control instance creation to ensure singleton behavior."""
diff --git a/mcp_server/prowler_mcp_server/prowler_app/utils/auth.py b/mcp_server/prowler_mcp_server/prowler_app/utils/auth.py
index 32ab72e574..eff5d3a117 100644
--- a/mcp_server/prowler_mcp_server/prowler_app/utils/auth.py
+++ b/mcp_server/prowler_mcp_server/prowler_app/utils/auth.py
@@ -2,15 +2,15 @@ import base64
import json
import os
from datetime import datetime
-from typing import Dict, Optional
from fastmcp.server.dependencies import get_http_headers
+
from prowler_mcp_server import __version__
from prowler_mcp_server.lib.logger import logger
class ProwlerAppAuth:
- """Handles authentication for Prowler App API using API keys or JWT tokens."""
+ """Handles authentication for Prowler API using API keys or JWT tokens."""
def __init__(
self,
@@ -18,21 +18,25 @@ class ProwlerAppAuth:
base_url: str = os.getenv("API_BASE_URL", "https://api.prowler.com/api/v1"),
):
self.base_url = base_url.rstrip("/")
- logger.info(f"Using Prowler App API base URL: {self.base_url}")
+ logger.info(f"Using Prowler API base URL: {self.base_url}")
self.mode = mode
- self.access_token: Optional[str] = None
- self.api_key: Optional[str] = None
+ self.access_token: str | None = None
+ self.api_key: str | None = None
if mode == "stdio": # STDIO mode
- self.api_key = os.getenv("PROWLER_APP_API_KEY")
+ # PROWLER_API_KEY is the current variable; PROWLER_APP_API_KEY is kept
+ # as a backward-compatible fallback so existing setups keep working.
+ self.api_key = os.getenv("PROWLER_API_KEY") or os.getenv(
+ "PROWLER_APP_API_KEY"
+ )
if not self.api_key:
- raise ValueError("PROWLER_APP_API_KEY environment variable is required")
+ raise ValueError("PROWLER_API_KEY environment variable is required")
if not self.api_key.startswith("pk_"):
- raise ValueError("Prowler App API key format is incorrect")
+ raise ValueError("Prowler API key format is incorrect")
- def _parse_jwt(self, token: str) -> Optional[Dict]:
+ def _parse_jwt(self, token: str) -> dict | None:
"""Parse JWT token and return payload
Args:
@@ -68,7 +72,7 @@ class ProwlerAppAuth:
async def authenticate(self) -> str:
"""Authenticate and return token (API key for STDIO, API key or JWT for HTTP)."""
if self.mode == "http":
- headers = get_http_headers()
+ headers = get_http_headers(include={"authorization"})
authorization_header = headers.get("authorization", None)
if not authorization_header:
@@ -109,7 +113,7 @@ class ProwlerAppAuth:
else:
return await self.authenticate()
- def get_headers(self, token: str) -> Dict[str, str]:
+ def get_headers(self, token: str) -> dict[str, str]:
"""Get headers for API requests with authentication."""
if token.startswith("pk_"):
authorization_header = f"Api-Key {token}"
diff --git a/mcp_server/prowler_mcp_server/prowler_app/utils/tool_loader.py b/mcp_server/prowler_mcp_server/prowler_app/utils/tool_loader.py
index 834bcaef7f..3a00474b5f 100644
--- a/mcp_server/prowler_mcp_server/prowler_app/utils/tool_loader.py
+++ b/mcp_server/prowler_mcp_server/prowler_app/utils/tool_loader.py
@@ -8,22 +8,32 @@ import importlib
import pkgutil
from fastmcp import FastMCP
+
from prowler_mcp_server.lib.logger import logger
from prowler_mcp_server.prowler_app.tools.base import BaseTool
-def load_all_tools(mcp: FastMCP) -> None:
- """Auto-discover and load all BaseTool subclasses from the tools package.
+def load_all_tools(
+ mcp: FastMCP,
+ tools_package: str = "prowler_mcp_server.prowler_app.tools",
+) -> None:
+ """Auto-discover and load all BaseTool subclasses from a tools package.
This function:
- 1. Dynamically imports all Python modules in the tools package
- 2. Discovers all concrete BaseTool subclasses
+ 1. Dynamically imports all Python modules in the given tools package
+ 2. Discovers all concrete BaseTool subclasses defined in that package
3. Instantiates each tool class
4. Registers all tools with the provided FastMCP instance
+ ``BaseTool.__subclasses__()`` returns every subclass in the process, so the
+ discovered classes are filtered by ``__module__`` prefix. This keeps sibling
+ sub-servers (e.g. ``prowler_app`` and ``prowler_cloud``) from cross-registering
+ each other's tools, regardless of import order.
+
Args:
mcp: The FastMCP instance to register tools with
- TOOLS_PACKAGE: The package path containing tool modules (default: prowler_mcp_server.prowler_app.tools)
+ tools_package: The package path containing tool modules
+ (default: prowler_mcp_server.prowler_app.tools)
Example:
from fastmcp import FastMCP
@@ -32,7 +42,7 @@ def load_all_tools(mcp: FastMCP) -> None:
app = FastMCP("prowler-app")
load_all_tools(app)
"""
- TOOLS_PACKAGE = "prowler_mcp_server.prowler_app.tools"
+ TOOLS_PACKAGE = tools_package
logger.info(f"Auto-discovering tools from package: {TOOLS_PACKAGE}")
# Import the tools package
@@ -58,11 +68,14 @@ def load_all_tools(mcp: FastMCP) -> None:
except Exception as e:
logger.error(f"Failed to import module {module_name}: {e}")
- # Discover all concrete BaseTool subclasses
+ # Discover all concrete BaseTool subclasses defined in this package only.
+ # __subclasses__() is process-wide, so filter by module to avoid sibling
+ # sub-servers cross-registering each other's tools.
concrete_tools = [
tool_class
for tool_class in BaseTool.__subclasses__()
if not getattr(tool_class, "__abstractmethods__", None)
+ and tool_class.__module__.startswith(TOOLS_PACKAGE)
]
logger.info(f"Discovered {len(concrete_tools)} tool classes")
diff --git a/mcp_server/prowler_mcp_server/prowler_documentation/search_engine.py b/mcp_server/prowler_mcp_server/prowler_documentation/search_engine.py
index a366c39c30..ba6f2a12e7 100644
--- a/mcp_server/prowler_mcp_server/prowler_documentation/search_engine.py
+++ b/mcp_server/prowler_mcp_server/prowler_documentation/search_engine.py
@@ -1,7 +1,8 @@
import httpx
-from prowler_mcp_server import __version__
from pydantic import BaseModel, Field
+from prowler_mcp_server import __version__
+
class SearchResult(BaseModel):
"""Search result model."""
diff --git a/mcp_server/prowler_mcp_server/server.py b/mcp_server/prowler_mcp_server/server.py
index e6cf399e72..a46ca12672 100644
--- a/mcp_server/prowler_mcp_server/server.py
+++ b/mcp_server/prowler_mcp_server/server.py
@@ -1,44 +1,43 @@
-import asyncio
-
from fastmcp import FastMCP
+from starlette.responses import JSONResponse
+
from prowler_mcp_server import __version__
from prowler_mcp_server.lib.logger import logger
-from starlette.responses import JSONResponse
prowler_mcp_server = FastMCP("prowler-mcp-server")
-async def setup_main_server():
+def setup_main_server():
"""Set up the main Prowler MCP server with all available integrations."""
- # Import Prowler Hub tools with prowler_hub_ prefix
+ # Mount Prowler Hub tools with prowler_hub_ namespace
try:
- logger.info("Importing Prowler Hub server...")
+ logger.info("Mounting Prowler Hub server...")
from prowler_mcp_server.prowler_hub.server import hub_mcp_server
- await prowler_mcp_server.import_server(hub_mcp_server, prefix="prowler_hub")
- logger.info("Successfully imported Prowler Hub server")
+ prowler_mcp_server.mount(hub_mcp_server, namespace="prowler_hub")
+ logger.info("Successfully mounted Prowler Hub server")
except Exception as e:
- logger.error(f"Failed to import Prowler Hub server: {e}")
+ logger.error(f"Failed to mount Prowler Hub server: {e}")
- # Import Prowler App tools with prowler_app_ prefix
+ # Mount core Prowler tools with prowler_ namespace
try:
- logger.info("Importing Prowler App server...")
+ logger.info("Mounting Prowler tools server...")
from prowler_mcp_server.prowler_app.server import app_mcp_server
- await prowler_mcp_server.import_server(app_mcp_server, prefix="prowler_app")
- logger.info("Successfully imported Prowler App server")
+ prowler_mcp_server.mount(app_mcp_server, namespace="prowler")
+ logger.info("Successfully mounted Prowler tools server")
except Exception as e:
- logger.error(f"Failed to import Prowler App server: {e}")
+ logger.error(f"Failed to mount Prowler tools server: {e}")
- # Import Prowler Documentation tools with prowler_docs_ prefix
+ # Mount Prowler Documentation tools with prowler_docs_ namespace
try:
- logger.info("Importing Prowler Documentation server...")
+ logger.info("Mounting Prowler Documentation server...")
from prowler_mcp_server.prowler_documentation.server import docs_mcp_server
- await prowler_mcp_server.import_server(docs_mcp_server, prefix="prowler_docs")
- logger.info("Successfully imported Prowler Documentation server")
+ prowler_mcp_server.mount(docs_mcp_server, namespace="prowler_docs")
+ logger.info("Successfully mounted Prowler Documentation server")
except Exception as e:
- logger.error(f"Failed to import Prowler Documentation server: {e}")
+ logger.error(f"Failed to mount Prowler Documentation server: {e}")
# Response follows the IETF Health Check Response Format
@@ -60,13 +59,6 @@ async def health_check(_request) -> JSONResponse:
)
-# Get or create the event loop
-try:
- loop = asyncio.get_running_loop()
- # If we have a running loop, schedule the setup as a task
- loop.create_task(setup_main_server())
-except RuntimeError:
- # No running loop, use asyncio.run (for standalone execution)
- asyncio.run(setup_main_server())
+setup_main_server()
app = prowler_mcp_server.http_app()
diff --git a/mcp_server/pyproject.toml b/mcp_server/pyproject.toml
index 1d18dfdc8e..f27e08b743 100644
--- a/mcp_server/pyproject.toml
+++ b/mcp_server/pyproject.toml
@@ -4,25 +4,46 @@ requires = ["setuptools>=61.0", "wheel"]
[dependency-groups]
dev = [
- "pytest==8.3.5"
+ "bandit==1.8.3",
+ "pytest==9.0.3",
+ "ruff==0.15.11",
+ "vulture==2.14"
]
[project]
dependencies = [
- "fastmcp==2.14.0",
+ "fastmcp==3.2.4",
"httpx==0.28.1"
]
description = "MCP server for Prowler ecosystem"
name = "prowler-mcp"
readme = "README.md"
requires-python = ">=3.12"
-version = "0.5.0"
+version = "0.8.0"
[project.scripts]
prowler-mcp = "prowler_mcp_server.main:main"
+[tool.pytest]
+
[tool.pytest.ini_options]
testpaths = ["tests"]
+# Shared ruff baseline (kept in sync with api/pyproject.toml).
+# target-version tracks this project's lowest supported Python.
+[tool.ruff]
+target-version = "py312"
+
+[tool.ruff.lint]
+# Defaults (E4/E7/E9, F) plus import sorting, modern-syntax upgrades, and
+# comprehension lints — all mechanically auto-fixable. flake8-bugbear (B) is a
+# good next step but needs manual cleanup, so it is left out of the shared
+# baseline for now.
+extend-select = [
+ "I", # isort — import ordering
+ "UP", # pyupgrade — modern syntax for the min supported Python
+ "C4" # flake8-comprehensions
+]
+
[tool.uv]
package = true
diff --git a/mcp_server/towncrier.toml b/mcp_server/towncrier.toml
new file mode 100644
index 0000000000..528e6129ec
--- /dev/null
+++ b/mcp_server/towncrier.toml
@@ -0,0 +1,39 @@
+[tool.towncrier]
+directory = "changelog.d"
+filename = "CHANGELOG.md"
+start_string = "\n"
+title_format = "## [{version}] ({name})"
+issue_format = "[(#{issue})](https://github.com/prowler-cloud/prowler/pull/{issue})"
+template = "../.github/towncrier/template.md.jinja"
+underlines = ["", "", ""]
+ignore = [".gitkeep", "README.md"]
+
+[[tool.towncrier.type]]
+directory = "added"
+name = "🚀 Added"
+showcontent = true
+
+[[tool.towncrier.type]]
+directory = "changed"
+name = "🔄 Changed"
+showcontent = true
+
+[[tool.towncrier.type]]
+directory = "deprecated"
+name = "⚠️ Deprecated"
+showcontent = true
+
+[[tool.towncrier.type]]
+directory = "removed"
+name = "❌ Removed"
+showcontent = true
+
+[[tool.towncrier.type]]
+directory = "fixed"
+name = "🐞 Fixed"
+showcontent = true
+
+[[tool.towncrier.type]]
+directory = "security"
+name = "🔐 Security"
+showcontent = true
diff --git a/mcp_server/uv.lock b/mcp_server/uv.lock
index 6d6d271cd9..a9afbeafd7 100644
--- a/mcp_server/uv.lock
+++ b/mcp_server/uv.lock
@@ -2,6 +2,18 @@ version = 1
revision = 3
requires-python = ">=3.12"
+[[package]]
+name = "aiofile"
+version = "3.9.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "caio" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/67/e2/d7cb819de8df6b5c1968a2756c3cb4122d4fa2b8fc768b53b7c9e5edb646/aiofile-3.9.0.tar.gz", hash = "sha256:e5ad718bb148b265b6df1b3752c4d1d83024b93da9bd599df74b9d9ffcf7919b", size = 17943, upload-time = "2024-10-08T10:39:35.846Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/50/25/da1f0b4dd970e52bf5a36c204c107e11a0c6d3ed195eba0bfbc664c312b2/aiofile-3.9.0-py3-none-any.whl", hash = "sha256:ce2f6c1571538cbdfa0143b04e16b208ecb0e9cb4148e528af8a640ed51cc8aa", size = 19539, upload-time = "2024-10-08T10:39:32.955Z" },
+]
+
[[package]]
name = "annotated-types"
version = "0.7.0"
@@ -13,64 +25,100 @@ wheels = [
[[package]]
name = "anyio"
-version = "4.10.0"
+version = "4.13.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "idna" },
- { name = "sniffio" },
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/f1/b4/636b3b65173d3ce9a38ef5f0522789614e590dab6a8d505340a4efe4c567/anyio-4.10.0.tar.gz", hash = "sha256:3f3fae35c96039744587aa5b8371e7e8e603c0702999535961dd336026973ba6", size = 213252, upload-time = "2025-08-04T08:54:26.451Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/19/14/2c5dd9f512b66549ae92767a9c7b330ae88e1932ca57876909410251fe13/anyio-4.13.0.tar.gz", hash = "sha256:334b70e641fd2221c1505b3890c69882fe4a2df910cba14d97019b90b24439dc", size = 231622, upload-time = "2026-03-24T12:59:09.671Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/6f/12/e5e0282d673bb9746bacfb6e2dba8719989d3660cdb2ea79aee9a9651afb/anyio-4.10.0-py3-none-any.whl", hash = "sha256:60e474ac86736bbfd6f210f7a61218939c318f43f9972497381f1c5e930ed3d1", size = 107213, upload-time = "2025-08-04T08:54:24.882Z" },
+ { url = "https://files.pythonhosted.org/packages/da/42/e921fccf5015463e32a3cf6ee7f980a6ed0f395ceeaa45060b61d86486c2/anyio-4.13.0-py3-none-any.whl", hash = "sha256:08b310f9e24a9594186fd75b4f73f4a4152069e3853f1ed8bfbf58369f4ad708", size = 114353, upload-time = "2026-03-24T12:59:08.246Z" },
]
[[package]]
name = "attrs"
-version = "25.3.0"
+version = "26.1.0"
source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/5a/b0/1367933a8532ee6ff8d63537de4f1177af4bff9f3e829baf7331f595bb24/attrs-25.3.0.tar.gz", hash = "sha256:75d7cefc7fb576747b2c81b4442d4d4a1ce0900973527c011d1030fd3bf4af1b", size = 812032, upload-time = "2025-03-13T11:10:22.779Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/9a/8e/82a0fe20a541c03148528be8cac2408564a6c9a0cc7e9171802bc1d26985/attrs-26.1.0.tar.gz", hash = "sha256:d03ceb89cb322a8fd706d4fb91940737b6642aa36998fe130a9bc96c985eff32", size = 952055, upload-time = "2026-03-19T14:22:25.026Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/77/06/bb80f5f86020c4551da315d78b3ab75e8228f89f0162f2c3a819e407941a/attrs-25.3.0-py3-none-any.whl", hash = "sha256:427318ce031701fea540783410126f03899a97ffc6f61596ad581ac2e40e3bc3", size = 63815, upload-time = "2025-03-13T11:10:21.14Z" },
+ { url = "https://files.pythonhosted.org/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl", hash = "sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309", size = 67548, upload-time = "2026-03-19T14:22:23.645Z" },
]
[[package]]
name = "authlib"
-version = "1.6.9"
+version = "1.7.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "cryptography" },
+ { name = "joserfc" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/af/98/00d3dd826d46959ad8e32af2dbb2398868fd9fd0683c26e56d0789bd0e68/authlib-1.6.9.tar.gz", hash = "sha256:d8f2421e7e5980cc1ddb4e32d3f5fa659cfaf60d8eaf3281ebed192e4ab74f04", size = 165134, upload-time = "2026-03-02T07:44:01.998Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/36/98/7d93f30d029643c0275dbc0bd6d5a6f670661ee6c9a94d93af7ab4887600/authlib-1.7.2.tar.gz", hash = "sha256:2cea25fefcd4e7173bdf1372c0afc265c8034b23a8cd5dcb6a9164b826c64231", size = 176511, upload-time = "2026-05-06T08:10:23.116Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/53/23/b65f568ed0c22f1efacb744d2db1a33c8068f384b8c9b482b52ebdbc3ef6/authlib-1.6.9-py2.py3-none-any.whl", hash = "sha256:f08b4c14e08f0861dc18a32357b33fbcfd2ea86cfe3fe149484b4d764c4a0ac3", size = 244197, upload-time = "2026-03-02T07:44:00.307Z" },
+ { url = "https://files.pythonhosted.org/packages/fb/95/adcb68e20c34162e9135f370d6e31737719c2b6f94bc953fe7ed1f10fe21/authlib-1.7.2-py2.py3-none-any.whl", hash = "sha256:3e1faedc9d87e7d56a164eca3ccb6ace0d61b94abe83e92242f8dc8bba9b4a9f", size = 259548, upload-time = "2026-05-06T08:10:21.436Z" },
+]
+
+[[package]]
+name = "bandit"
+version = "1.8.3"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "colorama", marker = "sys_platform == 'win32'" },
+ { name = "pyyaml" },
+ { name = "rich" },
+ { name = "stevedore" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/1a/a5/144a45f8e67df9d66c3bc3f7e69a39537db8bff1189ab7cff4e9459215da/bandit-1.8.3.tar.gz", hash = "sha256:f5847beb654d309422985c36644649924e0ea4425c76dec2e89110b87506193a", size = 4232005, upload-time = "2025-02-17T05:24:57.031Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/88/85/db74b9233e0aa27ec96891045c5e920a64dd5cbccd50f8e64e9460f48d35/bandit-1.8.3-py3-none-any.whl", hash = "sha256:28f04dc0d258e1dd0f99dee8eefa13d1cb5e3fde1a5ab0c523971f97b289bcd8", size = 129078, upload-time = "2025-02-17T05:24:54.068Z" },
]
[[package]]
name = "beartype"
-version = "0.22.6"
+version = "0.22.9"
source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/88/e2/105ceb1704cb80fe4ab3872529ab7b6f365cf7c74f725e6132d0efcf1560/beartype-0.22.6.tar.gz", hash = "sha256:97fbda69c20b48c5780ac2ca60ce3c1bb9af29b3a1a0216898ffabdd523e48f4", size = 1588975, upload-time = "2025-11-20T04:47:14.736Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/c7/94/1009e248bbfbab11397abca7193bea6626806be9a327d399810d523a07cb/beartype-0.22.9.tar.gz", hash = "sha256:8f82b54aa723a2848a56008d18875f91c1db02c32ef6a62319a002e3e25a975f", size = 1608866, upload-time = "2025-12-13T06:50:30.72Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/98/c9/ceecc71fe2c9495a1d8e08d44f5f31f5bca1350d5b2e27a4b6265424f59e/beartype-0.22.6-py3-none-any.whl", hash = "sha256:0584bc46a2ea2a871509679278cda992eadde676c01356ab0ac77421f3c9a093", size = 1324807, upload-time = "2025-11-20T04:47:11.837Z" },
+ { url = "https://files.pythonhosted.org/packages/71/cc/18245721fa7747065ab478316c7fea7c74777d07f37ae60db2e84f8172e8/beartype-0.22.9-py3-none-any.whl", hash = "sha256:d16c9bbc61ea14637596c5f6fbff2ee99cbe3573e46a716401734ef50c3060c2", size = 1333658, upload-time = "2025-12-13T06:50:28.266Z" },
]
[[package]]
name = "cachetools"
-version = "6.2.2"
+version = "7.1.1"
source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/fb/44/ca1675be2a83aeee1886ab745b28cda92093066590233cc501890eb8417a/cachetools-6.2.2.tar.gz", hash = "sha256:8e6d266b25e539df852251cfd6f990b4bc3a141db73b939058d809ebd2590fc6", size = 31571, upload-time = "2025-11-13T17:42:51.465Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/ff/e2/85f227594656000ff4d8adadae91a21f536d4a84c6c716a86bd6685874be/cachetools-7.1.1.tar.gz", hash = "sha256:27bdf856d68fd3c71c26c01b5edc312124ed427524d1ddb31aa2b7746fe20d4b", size = 40202, upload-time = "2026-05-03T20:00:29.391Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/e6/46/eb6eca305c77a4489affe1c5d8f4cae82f285d9addd8de4ec084a7184221/cachetools-6.2.2-py3-none-any.whl", hash = "sha256:6c09c98183bf58560c97b2abfcedcbaf6a896a490f534b031b661d3723b45ace", size = 11503, upload-time = "2025-11-13T17:42:50.232Z" },
+ { url = "https://files.pythonhosted.org/packages/bf/0f/f897abe4ea0a8c408ae65c8c83bffab4936ad65d6032d4fb4cd35bbdc3ee/cachetools-7.1.1-py3-none-any.whl", hash = "sha256:0335cd7a0952d2b22327441fb0628139e234c565559eeb91a8a4ac7551c5353d", size = 16775, upload-time = "2026-05-03T20:00:27.857Z" },
+]
+
+[[package]]
+name = "caio"
+version = "0.9.25"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/92/88/b8527e1b00c1811db339a1df8bd1ae49d146fcea9d6a5c40e3a80aaeb38d/caio-0.9.25.tar.gz", hash = "sha256:16498e7f81d1d0f5a4c0ad3f2540e65fe25691376e0a5bd367f558067113ed10", size = 26781, upload-time = "2025-12-26T15:21:36.501Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/d3/25/79c98ebe12df31548ba4eaf44db11b7cad6b3e7b4203718335620939083c/caio-0.9.25-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:fb7ff95af4c31ad3f03179149aab61097a71fd85e05f89b4786de0359dffd044", size = 36983, upload-time = "2025-12-26T15:21:36.075Z" },
+ { url = "https://files.pythonhosted.org/packages/a3/2b/21288691f16d479945968a0a4f2856818c1c5be56881d51d4dac9b255d26/caio-0.9.25-cp312-cp312-manylinux2010_x86_64.manylinux2014_x86_64.manylinux_2_12_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:97084e4e30dfa598449d874c4d8e0c8d5ea17d2f752ef5e48e150ff9d240cd64", size = 82012, upload-time = "2025-12-26T15:22:20.983Z" },
+ { url = "https://files.pythonhosted.org/packages/03/c4/8a1b580875303500a9c12b9e0af58cb82e47f5bcf888c2457742a138273c/caio-0.9.25-cp312-cp312-manylinux_2_34_aarch64.whl", hash = "sha256:4fa69eba47e0f041b9d4f336e2ad40740681c43e686b18b191b6c5f4c5544bfb", size = 81502, upload-time = "2026-03-04T22:08:22.381Z" },
+ { url = "https://files.pythonhosted.org/packages/d1/1c/0fe770b8ffc8362c48134d1592d653a81a3d8748d764bec33864db36319d/caio-0.9.25-cp312-cp312-manylinux_2_34_x86_64.whl", hash = "sha256:6bebf6f079f1341d19f7386db9b8b1f07e8cc15ae13bfdaff573371ba0575d69", size = 80200, upload-time = "2026-03-04T22:08:23.382Z" },
+ { url = "https://files.pythonhosted.org/packages/31/57/5e6ff127e6f62c9f15d989560435c642144aa4210882f9494204bc892305/caio-0.9.25-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:d6c2a3411af97762a2b03840c3cec2f7f728921ff8adda53d7ea2315a8563451", size = 36979, upload-time = "2025-12-26T15:21:35.484Z" },
+ { url = "https://files.pythonhosted.org/packages/a3/9f/f21af50e72117eb528c422d4276cbac11fb941b1b812b182e0a9c70d19c5/caio-0.9.25-cp313-cp313-manylinux2010_x86_64.manylinux2014_x86_64.manylinux_2_12_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0998210a4d5cd5cb565b32ccfe4e53d67303f868a76f212e002a8554692870e6", size = 81900, upload-time = "2025-12-26T15:22:21.919Z" },
+ { url = "https://files.pythonhosted.org/packages/9c/12/c39ae2a4037cb10ad5eb3578eb4d5f8c1a2575c62bba675f3406b7ef0824/caio-0.9.25-cp313-cp313-manylinux_2_34_aarch64.whl", hash = "sha256:1a177d4777141b96f175fe2c37a3d96dec7911ed9ad5f02bac38aaa1c936611f", size = 81523, upload-time = "2026-03-04T22:08:25.187Z" },
+ { url = "https://files.pythonhosted.org/packages/22/59/f8f2e950eb4f1a5a3883e198dca514b9d475415cb6cd7b78b9213a0dd45a/caio-0.9.25-cp313-cp313-manylinux_2_34_x86_64.whl", hash = "sha256:9ed3cfb28c0e99fec5e208c934e5c157d0866aa9c32aa4dc5e9b6034af6286b7", size = 80243, upload-time = "2026-03-04T22:08:26.449Z" },
+ { url = "https://files.pythonhosted.org/packages/69/ca/a08fdc7efdcc24e6a6131a93c85be1f204d41c58f474c42b0670af8c016b/caio-0.9.25-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:fab6078b9348e883c80a5e14b382e6ad6aabbc4429ca034e76e730cf464269db", size = 36978, upload-time = "2025-12-26T15:21:41.055Z" },
+ { url = "https://files.pythonhosted.org/packages/5e/6c/d4d24f65e690213c097174d26eda6831f45f4734d9d036d81790a27e7b78/caio-0.9.25-cp314-cp314-manylinux2010_x86_64.manylinux2014_x86_64.manylinux_2_12_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:44a6b58e52d488c75cfaa5ecaa404b2b41cc965e6c417e03251e868ecd5b6d77", size = 81832, upload-time = "2025-12-26T15:22:22.757Z" },
+ { url = "https://files.pythonhosted.org/packages/87/a4/e534cf7d2d0e8d880e25dd61e8d921ffcfe15bd696734589826f5a2df727/caio-0.9.25-cp314-cp314-manylinux_2_34_aarch64.whl", hash = "sha256:628a630eb7fb22381dd8e3c8ab7f59e854b9c806639811fc3f4310c6bd711d79", size = 81565, upload-time = "2026-03-04T22:08:27.483Z" },
+ { url = "https://files.pythonhosted.org/packages/3f/ed/bf81aeac1d290017e5e5ac3e880fd56ee15e50a6d0353986799d1bc5cfd5/caio-0.9.25-cp314-cp314-manylinux_2_34_x86_64.whl", hash = "sha256:0ba16aa605ccb174665357fc729cf500679c2d94d5f1458a6f0d5ca48f2060a7", size = 80071, upload-time = "2026-03-04T22:08:28.751Z" },
+ { url = "https://files.pythonhosted.org/packages/86/93/1f76c8d1bafe3b0614e06b2195784a3765bbf7b0a067661af9e2dd47fc33/caio-0.9.25-py3-none-any.whl", hash = "sha256:06c0bb02d6b929119b1cfbe1ca403c768b2013a369e2db46bfa2a5761cf82e40", size = 19087, upload-time = "2025-12-26T15:22:00.221Z" },
]
[[package]]
name = "certifi"
-version = "2025.8.3"
+version = "2026.4.22"
source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/dc/67/960ebe6bf230a96cda2e0abcf73af550ec4f090005363542f0765df162e0/certifi-2025.8.3.tar.gz", hash = "sha256:e564105f78ded564e3ae7c923924435e1daa7463faeab5bb932bc53ffae63407", size = 162386, upload-time = "2025-08-03T03:07:47.08Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/25/ee/6caf7a40c36a1220410afe15a1cc64993a1f864871f698c0f93acb72842a/certifi-2026.4.22.tar.gz", hash = "sha256:8d455352a37b71bf76a79caa83a3d6c25afee4a385d632127b6afb3963f1c580", size = 137077, upload-time = "2026-04-22T11:26:11.191Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/e5/48/1549795ba7742c948d2ad169c1c8cdbae65bc450d6cd753d124b17c8cd32/certifi-2025.8.3-py3-none-any.whl", hash = "sha256:f6c12493cfb1b06ba2ff328595af9350c65d6644968e5d3a2ffd78699af217a5", size = 161216, upload-time = "2025-08-03T03:07:45.777Z" },
+ { url = "https://files.pythonhosted.org/packages/22/30/7cd8fdcdfbc5b869528b079bfb76dcdf6056b1a2097a662e5e8c04f42965/certifi-2026.4.22-py3-none-any.whl", hash = "sha256:3cb2210c8f88ba2318d29b0388d1023c8492ff72ecdde4ebdaddbb13a31b1c4a", size = 135707, upload-time = "2026-04-22T11:26:09.372Z" },
]
[[package]]
@@ -130,67 +178,16 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/ae/3a/dbeec9d1ee0844c679f6bb5d6ad4e9f198b1224f4e7a32825f47f6192b0c/cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9", size = 184195, upload-time = "2025-09-08T23:23:43.004Z" },
]
-[[package]]
-name = "charset-normalizer"
-version = "3.4.3"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/83/2d/5fd176ceb9b2fc619e63405525573493ca23441330fcdaee6bef9460e924/charset_normalizer-3.4.3.tar.gz", hash = "sha256:6fce4b8500244f6fcb71465d4a4930d132ba9ab8e71a7859e6a5d59851068d14", size = 122371, upload-time = "2025-08-09T07:57:28.46Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/e9/5e/14c94999e418d9b87682734589404a25854d5f5d0408df68bc15b6ff54bb/charset_normalizer-3.4.3-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:e28e334d3ff134e88989d90ba04b47d84382a828c061d0d1027b1b12a62b39b1", size = 205655, upload-time = "2025-08-09T07:56:08.475Z" },
- { url = "https://files.pythonhosted.org/packages/7d/a8/c6ec5d389672521f644505a257f50544c074cf5fc292d5390331cd6fc9c3/charset_normalizer-3.4.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0cacf8f7297b0c4fcb74227692ca46b4a5852f8f4f24b3c766dd94a1075c4884", size = 146223, upload-time = "2025-08-09T07:56:09.708Z" },
- { url = "https://files.pythonhosted.org/packages/fc/eb/a2ffb08547f4e1e5415fb69eb7db25932c52a52bed371429648db4d84fb1/charset_normalizer-3.4.3-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c6fd51128a41297f5409deab284fecbe5305ebd7e5a1f959bee1c054622b7018", size = 159366, upload-time = "2025-08-09T07:56:11.326Z" },
- { url = "https://files.pythonhosted.org/packages/82/10/0fd19f20c624b278dddaf83b8464dcddc2456cb4b02bb902a6da126b87a1/charset_normalizer-3.4.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3cfb2aad70f2c6debfbcb717f23b7eb55febc0bb23dcffc0f076009da10c6392", size = 157104, upload-time = "2025-08-09T07:56:13.014Z" },
- { url = "https://files.pythonhosted.org/packages/16/ab/0233c3231af734f5dfcf0844aa9582d5a1466c985bbed6cedab85af9bfe3/charset_normalizer-3.4.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1606f4a55c0fd363d754049cdf400175ee96c992b1f8018b993941f221221c5f", size = 151830, upload-time = "2025-08-09T07:56:14.428Z" },
- { url = "https://files.pythonhosted.org/packages/ae/02/e29e22b4e02839a0e4a06557b1999d0a47db3567e82989b5bb21f3fbbd9f/charset_normalizer-3.4.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:027b776c26d38b7f15b26a5da1044f376455fb3766df8fc38563b4efbc515154", size = 148854, upload-time = "2025-08-09T07:56:16.051Z" },
- { url = "https://files.pythonhosted.org/packages/05/6b/e2539a0a4be302b481e8cafb5af8792da8093b486885a1ae4d15d452bcec/charset_normalizer-3.4.3-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:42e5088973e56e31e4fa58eb6bd709e42fc03799c11c42929592889a2e54c491", size = 160670, upload-time = "2025-08-09T07:56:17.314Z" },
- { url = "https://files.pythonhosted.org/packages/31/e7/883ee5676a2ef217a40ce0bffcc3d0dfbf9e64cbcfbdf822c52981c3304b/charset_normalizer-3.4.3-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:cc34f233c9e71701040d772aa7490318673aa7164a0efe3172b2981218c26d93", size = 158501, upload-time = "2025-08-09T07:56:18.641Z" },
- { url = "https://files.pythonhosted.org/packages/c1/35/6525b21aa0db614cf8b5792d232021dca3df7f90a1944db934efa5d20bb1/charset_normalizer-3.4.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:320e8e66157cc4e247d9ddca8e21f427efc7a04bbd0ac8a9faf56583fa543f9f", size = 153173, upload-time = "2025-08-09T07:56:20.289Z" },
- { url = "https://files.pythonhosted.org/packages/50/ee/f4704bad8201de513fdc8aac1cabc87e38c5818c93857140e06e772b5892/charset_normalizer-3.4.3-cp312-cp312-win32.whl", hash = "sha256:fb6fecfd65564f208cbf0fba07f107fb661bcd1a7c389edbced3f7a493f70e37", size = 99822, upload-time = "2025-08-09T07:56:21.551Z" },
- { url = "https://files.pythonhosted.org/packages/39/f5/3b3836ca6064d0992c58c7561c6b6eee1b3892e9665d650c803bd5614522/charset_normalizer-3.4.3-cp312-cp312-win_amd64.whl", hash = "sha256:86df271bf921c2ee3818f0522e9a5b8092ca2ad8b065ece5d7d9d0e9f4849bcc", size = 107543, upload-time = "2025-08-09T07:56:23.115Z" },
- { url = "https://files.pythonhosted.org/packages/65/ca/2135ac97709b400c7654b4b764daf5c5567c2da45a30cdd20f9eefe2d658/charset_normalizer-3.4.3-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:14c2a87c65b351109f6abfc424cab3927b3bdece6f706e4d12faaf3d52ee5efe", size = 205326, upload-time = "2025-08-09T07:56:24.721Z" },
- { url = "https://files.pythonhosted.org/packages/71/11/98a04c3c97dd34e49c7d247083af03645ca3730809a5509443f3c37f7c99/charset_normalizer-3.4.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:41d1fc408ff5fdfb910200ec0e74abc40387bccb3252f3f27c0676731df2b2c8", size = 146008, upload-time = "2025-08-09T07:56:26.004Z" },
- { url = "https://files.pythonhosted.org/packages/60/f5/4659a4cb3c4ec146bec80c32d8bb16033752574c20b1252ee842a95d1a1e/charset_normalizer-3.4.3-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1bb60174149316da1c35fa5233681f7c0f9f514509b8e399ab70fea5f17e45c9", size = 159196, upload-time = "2025-08-09T07:56:27.25Z" },
- { url = "https://files.pythonhosted.org/packages/86/9e/f552f7a00611f168b9a5865a1414179b2c6de8235a4fa40189f6f79a1753/charset_normalizer-3.4.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:30d006f98569de3459c2fc1f2acde170b7b2bd265dc1943e87e1a4efe1b67c31", size = 156819, upload-time = "2025-08-09T07:56:28.515Z" },
- { url = "https://files.pythonhosted.org/packages/7e/95/42aa2156235cbc8fa61208aded06ef46111c4d3f0de233107b3f38631803/charset_normalizer-3.4.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:416175faf02e4b0810f1f38bcb54682878a4af94059a1cd63b8747244420801f", size = 151350, upload-time = "2025-08-09T07:56:29.716Z" },
- { url = "https://files.pythonhosted.org/packages/c2/a9/3865b02c56f300a6f94fc631ef54f0a8a29da74fb45a773dfd3dcd380af7/charset_normalizer-3.4.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:6aab0f181c486f973bc7262a97f5aca3ee7e1437011ef0c2ec04b5a11d16c927", size = 148644, upload-time = "2025-08-09T07:56:30.984Z" },
- { url = "https://files.pythonhosted.org/packages/77/d9/cbcf1a2a5c7d7856f11e7ac2d782aec12bdfea60d104e60e0aa1c97849dc/charset_normalizer-3.4.3-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:fdabf8315679312cfa71302f9bd509ded4f2f263fb5b765cf1433b39106c3cc9", size = 160468, upload-time = "2025-08-09T07:56:32.252Z" },
- { url = "https://files.pythonhosted.org/packages/f6/42/6f45efee8697b89fda4d50580f292b8f7f9306cb2971d4b53f8914e4d890/charset_normalizer-3.4.3-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:bd28b817ea8c70215401f657edef3a8aa83c29d447fb0b622c35403780ba11d5", size = 158187, upload-time = "2025-08-09T07:56:33.481Z" },
- { url = "https://files.pythonhosted.org/packages/70/99/f1c3bdcfaa9c45b3ce96f70b14f070411366fa19549c1d4832c935d8e2c3/charset_normalizer-3.4.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:18343b2d246dc6761a249ba1fb13f9ee9a2bcd95decc767319506056ea4ad4dc", size = 152699, upload-time = "2025-08-09T07:56:34.739Z" },
- { url = "https://files.pythonhosted.org/packages/a3/ad/b0081f2f99a4b194bcbb1934ef3b12aa4d9702ced80a37026b7607c72e58/charset_normalizer-3.4.3-cp313-cp313-win32.whl", hash = "sha256:6fb70de56f1859a3f71261cbe41005f56a7842cc348d3aeb26237560bfa5e0ce", size = 99580, upload-time = "2025-08-09T07:56:35.981Z" },
- { url = "https://files.pythonhosted.org/packages/9a/8f/ae790790c7b64f925e5c953b924aaa42a243fb778fed9e41f147b2a5715a/charset_normalizer-3.4.3-cp313-cp313-win_amd64.whl", hash = "sha256:cf1ebb7d78e1ad8ec2a8c4732c7be2e736f6e5123a4146c5b89c9d1f585f8cef", size = 107366, upload-time = "2025-08-09T07:56:37.339Z" },
- { url = "https://files.pythonhosted.org/packages/8e/91/b5a06ad970ddc7a0e513112d40113e834638f4ca1120eb727a249fb2715e/charset_normalizer-3.4.3-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:3cd35b7e8aedeb9e34c41385fda4f73ba609e561faedfae0a9e75e44ac558a15", size = 204342, upload-time = "2025-08-09T07:56:38.687Z" },
- { url = "https://files.pythonhosted.org/packages/ce/ec/1edc30a377f0a02689342f214455c3f6c2fbedd896a1d2f856c002fc3062/charset_normalizer-3.4.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b89bc04de1d83006373429975f8ef9e7932534b8cc9ca582e4db7d20d91816db", size = 145995, upload-time = "2025-08-09T07:56:40.048Z" },
- { url = "https://files.pythonhosted.org/packages/17/e5/5e67ab85e6d22b04641acb5399c8684f4d37caf7558a53859f0283a650e9/charset_normalizer-3.4.3-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2001a39612b241dae17b4687898843f254f8748b796a2e16f1051a17078d991d", size = 158640, upload-time = "2025-08-09T07:56:41.311Z" },
- { url = "https://files.pythonhosted.org/packages/f1/e5/38421987f6c697ee3722981289d554957c4be652f963d71c5e46a262e135/charset_normalizer-3.4.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8dcfc373f888e4fb39a7bc57e93e3b845e7f462dacc008d9749568b1c4ece096", size = 156636, upload-time = "2025-08-09T07:56:43.195Z" },
- { url = "https://files.pythonhosted.org/packages/a0/e4/5a075de8daa3ec0745a9a3b54467e0c2967daaaf2cec04c845f73493e9a1/charset_normalizer-3.4.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:18b97b8404387b96cdbd30ad660f6407799126d26a39ca65729162fd810a99aa", size = 150939, upload-time = "2025-08-09T07:56:44.819Z" },
- { url = "https://files.pythonhosted.org/packages/02/f7/3611b32318b30974131db62b4043f335861d4d9b49adc6d57c1149cc49d4/charset_normalizer-3.4.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ccf600859c183d70eb47e05a44cd80a4ce77394d1ac0f79dbd2dd90a69a3a049", size = 148580, upload-time = "2025-08-09T07:56:46.684Z" },
- { url = "https://files.pythonhosted.org/packages/7e/61/19b36f4bd67f2793ab6a99b979b4e4f3d8fc754cbdffb805335df4337126/charset_normalizer-3.4.3-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:53cd68b185d98dde4ad8990e56a58dea83a4162161b1ea9272e5c9182ce415e0", size = 159870, upload-time = "2025-08-09T07:56:47.941Z" },
- { url = "https://files.pythonhosted.org/packages/06/57/84722eefdd338c04cf3030ada66889298eaedf3e7a30a624201e0cbe424a/charset_normalizer-3.4.3-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:30a96e1e1f865f78b030d65241c1ee850cdf422d869e9028e2fc1d5e4db73b92", size = 157797, upload-time = "2025-08-09T07:56:49.756Z" },
- { url = "https://files.pythonhosted.org/packages/72/2a/aff5dd112b2f14bcc3462c312dce5445806bfc8ab3a7328555da95330e4b/charset_normalizer-3.4.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:d716a916938e03231e86e43782ca7878fb602a125a91e7acb8b5112e2e96ac16", size = 152224, upload-time = "2025-08-09T07:56:51.369Z" },
- { url = "https://files.pythonhosted.org/packages/b7/8c/9839225320046ed279c6e839d51f028342eb77c91c89b8ef2549f951f3ec/charset_normalizer-3.4.3-cp314-cp314-win32.whl", hash = "sha256:c6dbd0ccdda3a2ba7c2ecd9d77b37f3b5831687d8dc1b6ca5f56a4880cc7b7ce", size = 100086, upload-time = "2025-08-09T07:56:52.722Z" },
- { url = "https://files.pythonhosted.org/packages/ee/7a/36fbcf646e41f710ce0a563c1c9a343c6edf9be80786edeb15b6f62e17db/charset_normalizer-3.4.3-cp314-cp314-win_amd64.whl", hash = "sha256:73dc19b562516fc9bcf6e5d6e596df0b4eb98d87e4f79f3ae71840e6ed21361c", size = 107400, upload-time = "2025-08-09T07:56:55.172Z" },
- { url = "https://files.pythonhosted.org/packages/8a/1f/f041989e93b001bc4e44bb1669ccdcf54d3f00e628229a85b08d330615c5/charset_normalizer-3.4.3-py3-none-any.whl", hash = "sha256:ce571ab16d890d23b5c278547ba694193a45011ff86a9162a71307ed9f86759a", size = 53175, upload-time = "2025-08-09T07:57:26.864Z" },
-]
-
[[package]]
name = "click"
-version = "8.3.0"
+version = "8.3.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "colorama", marker = "sys_platform == 'win32'" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/46/61/de6cd827efad202d7057d93e0fed9294b96952e188f7384832791c7b2254/click-8.3.0.tar.gz", hash = "sha256:e7b8232224eba16f4ebe410c25ced9f7875cb5f3263ffc93cc3e8da705e229c4", size = 276943, upload-time = "2025-09-18T17:32:23.696Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/bb/63/f9e1ea081ce35720d8b92acde70daaedace594dc93b693c869e0d5910718/click-8.3.3.tar.gz", hash = "sha256:398329ad4837b2ff7cbe1dd166a4c0f8900c3ca3a218de04466f38f6497f18a2", size = 328061, upload-time = "2026-04-22T15:11:27.506Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/db/d3/9dcc0f5797f070ec8edf30fbadfb200e71d9db6b84d211e3b2085a7589a0/click-8.3.0-py3-none-any.whl", hash = "sha256:9b9f285302c6e3064f4330c05f05b81945b2a39544279343e6e7c5f27a9baddc", size = 107295, upload-time = "2025-09-18T17:32:22.42Z" },
-]
-
-[[package]]
-name = "cloudpickle"
-version = "3.1.2"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/27/fb/576f067976d320f5f0114a8d9fa1215425441bb35627b1993e5afd8111e5/cloudpickle-3.1.2.tar.gz", hash = "sha256:7fda9eb655c9c230dab534f1983763de5835249750e85fbcef43aaa30a9a2414", size = 22330, upload-time = "2025-11-03T09:25:26.604Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/88/39/799be3f2f0f38cc727ee3b4f1445fe6d5e4133064ec2e4115069418a5bb6/cloudpickle-3.1.2-py3-none-any.whl", hash = "sha256:9acb47f6afd73f60dc1df93bb801b472f05ff42fa6c84167d25cb206be1fbf4a", size = 22228, upload-time = "2025-11-03T09:25:25.534Z" },
+ { url = "https://files.pythonhosted.org/packages/ae/44/c1221527f6a71a01ec6fbad7fa78f1d50dfa02217385cf0fa3eec7087d59/click-8.3.3-py3-none-any.whl", hash = "sha256:a2bf429bb3033c89fa4936ffb35d5cb471e3719e1f3c8a7c3fff0b8314305613", size = 110502, upload-time = "2026-04-22T15:11:25.044Z" },
]
[[package]]
@@ -204,60 +201,60 @@ wheels = [
[[package]]
name = "cryptography"
-version = "47.0.0"
+version = "48.0.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "cffi", marker = "platform_python_implementation != 'PyPy'" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/ef/b2/7ffa7fe8207a8c42147ffe70c3e360b228160c1d85dc3faff16aaa3244c0/cryptography-47.0.0.tar.gz", hash = "sha256:9f8e55fe4e63613a5e1cc5819030f27b97742d720203a087802ce4ce9ceb52bb", size = 830863, upload-time = "2026-04-24T19:54:57.056Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/9f/a9/db8f313fdcd85d767d4973515e1db101f9c71f95fced83233de224673757/cryptography-48.0.0.tar.gz", hash = "sha256:5c3932f4436d1cccb036cb0eaef46e6e2db91035166f1ad6505c3c9d5a635920", size = 832984, upload-time = "2026-05-04T22:59:38.133Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/a4/98/40dfe932134bdcae4f6ab5927c87488754bf9eb79297d7e0070b78dd58e9/cryptography-47.0.0-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:160ad728f128972d362e714054f6ba0067cab7fb350c5202a9ae8ae4ce3ef1a0", size = 7912214, upload-time = "2026-04-24T19:53:03.864Z" },
- { url = "https://files.pythonhosted.org/packages/34/c6/2733531243fba725f58611b918056b277692f1033373dcc8bd01af1c05d4/cryptography-47.0.0-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b9a8943e359b7615db1a3ba587994618e094ff3d6fa5a390c73d079ce18b3973", size = 4644617, upload-time = "2026-04-24T19:53:06.909Z" },
- { url = "https://files.pythonhosted.org/packages/00/e3/b27be1a670a9b87f855d211cf0e1174a5d721216b7616bd52d8581d912ed/cryptography-47.0.0-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f5c15764f261394b22aef6b00252f5195f46f2ca300bec57149474e2538b31f8", size = 4668186, upload-time = "2026-04-24T19:53:09.053Z" },
- { url = "https://files.pythonhosted.org/packages/81/b9/8443cfe5d17d482d348cee7048acf502bb89a51b6382f06240fd290d4ca3/cryptography-47.0.0-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:9c59ab0e0fa3a180a5a9c59f3a5abe3ef90d474bc56d7fadfbe80359491b615b", size = 4651244, upload-time = "2026-04-24T19:53:11.217Z" },
- { url = "https://files.pythonhosted.org/packages/5d/5e/13ed0cdd0eb88ba159d6dd5ebfece8cb901dbcf1ae5ac4072e28b55d3153/cryptography-47.0.0-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:34b4358b925a5ea3e14384ca781a2c0ef7ac219b57bb9eacc4457078e2b19f92", size = 5252906, upload-time = "2026-04-24T19:53:13.532Z" },
- { url = "https://files.pythonhosted.org/packages/64/16/ed058e1df0f33d440217cd120d41d5dda9dd215a80b8187f68483185af82/cryptography-47.0.0-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:0024b87d47ae2399165a6bfb20d24888881eeab83ae2566d62467c5ff0030ce7", size = 4701842, upload-time = "2026-04-24T19:53:15.618Z" },
- { url = "https://files.pythonhosted.org/packages/02/e0/3d30986b30fdbd9e969abbdf8ba00ed0618615144341faeb57f395a084fe/cryptography-47.0.0-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:1e47422b5557bb82d3fff997e8d92cff4e28b9789576984f08c248d2b3535d93", size = 4289313, upload-time = "2026-04-24T19:53:17.755Z" },
- { url = "https://files.pythonhosted.org/packages/df/fd/32db38e3ad0cb331f0691cb4c7a8a6f176f679124dee746b3af6633db4d9/cryptography-47.0.0-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:6f29f36582e6151d9686235e586dd35bb67491f024767d10b842e520dc6a07ac", size = 4650964, upload-time = "2026-04-24T19:53:20.062Z" },
- { url = "https://files.pythonhosted.org/packages/86/53/5395d944dfd48cb1f67917f533c609c34347185ef15eb4308024c876f274/cryptography-47.0.0-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:a9b761f012a943b7de0e828843c5688d0de94a0578d44d6c85a1bae32f87791f", size = 5207817, upload-time = "2026-04-24T19:53:22.498Z" },
- { url = "https://files.pythonhosted.org/packages/34/4f/e5711b28e1901f7d480a2b1b688b645aa4c77c73f10731ed17e7f7db3f0d/cryptography-47.0.0-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:4e1de79e047e25d6e9f8cea71c86b4a53aced64134f0f003bbcbf3655fd172c8", size = 4701544, upload-time = "2026-04-24T19:53:24.356Z" },
- { url = "https://files.pythonhosted.org/packages/22/22/c8ddc25de3010fc8da447648f5a092c40e7a8fadf01dd6d255d9c0b9373d/cryptography-47.0.0-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:ef6b3634087f18d2155b1e8ce264e5345a753da2c5fa9815e7d41315c90f8318", size = 4783536, upload-time = "2026-04-24T19:53:26.665Z" },
- { url = "https://files.pythonhosted.org/packages/66/b6/d4a68f4ea999c6d89e8498579cba1c5fcba4276284de7773b17e4fa69293/cryptography-47.0.0-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:11dbb9f50a0f1bb9757b3d8c27c1101780efb8f0bdecfb12439c22a74d64c001", size = 4926106, upload-time = "2026-04-24T19:53:28.686Z" },
- { url = "https://files.pythonhosted.org/packages/54/ed/5f524db1fade9c013aa618e1c99c6ed05e8ffc9ceee6cda22fed22dda3f4/cryptography-47.0.0-cp311-abi3-win32.whl", hash = "sha256:7fda2f02c9015db3f42bb8a22324a454516ed10a8c29ca6ece6cdbb5efe2a203", size = 3258581, upload-time = "2026-04-24T19:53:31.058Z" },
- { url = "https://files.pythonhosted.org/packages/b2/dc/1b901990b174786569029f67542b3edf72ac068b6c3c8683c17e6a2f5363/cryptography-47.0.0-cp311-abi3-win_amd64.whl", hash = "sha256:f5c3296dab66202f1b18a91fa266be93d6aa0c2806ea3d67762c69f60adc71aa", size = 3775309, upload-time = "2026-04-24T19:53:33.054Z" },
- { url = "https://files.pythonhosted.org/packages/14/88/7aa18ad9c11bc87689affa5ce4368d884b517502d75739d475fc6f4a03c7/cryptography-47.0.0-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:be12cb6a204f77ed968bcefe68086eb061695b540a3dd05edac507a3111b25f0", size = 7904299, upload-time = "2026-04-24T19:53:35.003Z" },
- { url = "https://files.pythonhosted.org/packages/07/55/c18f75724544872f234678fdedc871391722cb34a2aee19faa9f63100bb2/cryptography-47.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:2ebd84adf0728c039a3be2700289378e1c164afc6748df1a5ed456767bef9ba7", size = 4631180, upload-time = "2026-04-24T19:53:37.517Z" },
- { url = "https://files.pythonhosted.org/packages/ee/65/31a5cc0eaca99cec5bafffe155d407115d96136bb161e8b49e0ef73f09a7/cryptography-47.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7f68d6fbc7fbbcfb0939fea72c3b96a9f9a6edfc0e1b1d29778a2066030418b1", size = 4653529, upload-time = "2026-04-24T19:53:39.775Z" },
- { url = "https://files.pythonhosted.org/packages/e5/bc/641c0519a495f3bfd0421b48d7cd325c4336578523ccd76ea322b6c29c7a/cryptography-47.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:6651d32eff255423503aa276739da98c30f26c40cbeffcc6048e0d54ef704c0c", size = 4638570, upload-time = "2026-04-24T19:53:42.129Z" },
- { url = "https://files.pythonhosted.org/packages/2b/f2/300327b0a47f6dc94dd8b71b57052aefe178bb51745073d73d80604f11ab/cryptography-47.0.0-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:3fb8fa48075fad7193f2e5496135c6a76ac4b2aa5a38433df0a539296b377829", size = 5238019, upload-time = "2026-04-24T19:53:44.577Z" },
- { url = "https://files.pythonhosted.org/packages/e9/5a/5b5cf994391d4bf9d9c7efd4c66aabe4d95227256627f8fea6cff7dfadbd/cryptography-47.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:11438c7518132d95f354fa01a4aa2f806d172a061a7bed18cf18cbdacdb204d7", size = 4686832, upload-time = "2026-04-24T19:53:47.015Z" },
- { url = "https://files.pythonhosted.org/packages/dc/2c/ae950e28fd6475c852fc21a44db3e6b5bcc1261d1e370f2b6e42fa800fef/cryptography-47.0.0-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:8c1a736bbb3288005796c3f7ccb9453360d7fed483b13b9f468aea5171432923", size = 4269301, upload-time = "2026-04-24T19:53:48.97Z" },
- { url = "https://files.pythonhosted.org/packages/67/fb/6a39782e150ffe5cc1b0018cb6ddc48bf7ca62b498d7539ffc8a758e977d/cryptography-47.0.0-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:f1557695e5c2b86e204f6ce9470497848634100787935ab7adc5397c54abd7ab", size = 4638110, upload-time = "2026-04-24T19:53:51.011Z" },
- { url = "https://files.pythonhosted.org/packages/8e/d7/0b3c71090a76e5c203164a47688b697635ece006dcd2499ab3a4dbd3f0bd/cryptography-47.0.0-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:f9a034b642b960767fb343766ae5ba6ad653f2e890ddd82955aef288ffea8736", size = 5194988, upload-time = "2026-04-24T19:53:52.962Z" },
- { url = "https://files.pythonhosted.org/packages/63/33/63a961498a9df51721ab578c5a2622661411fc520e00bd83b0cc64eb20c4/cryptography-47.0.0-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:b1c76fca783aa7698eb21eb14f9c4aa09452248ee54a627d125025a43f83e7a7", size = 4686563, upload-time = "2026-04-24T19:53:55.274Z" },
- { url = "https://files.pythonhosted.org/packages/b7/bf/5ee5b145248f92250de86145d1c1d6edebbd57a7fe7caa4dedb5d4cf06a1/cryptography-47.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:4f7722c97826770bab8ae92959a2e7b20a5e9e9bf4deae68fd86c3ca457bab52", size = 4770094, upload-time = "2026-04-24T19:53:57.753Z" },
- { url = "https://files.pythonhosted.org/packages/92/43/21d220b2da5d517773894dacdcdb5c682c28d3fffce65548cb06e87d5501/cryptography-47.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:09f6d7bf6724f8db8b32f11eccf23efc8e759924bc5603800335cf8859a3ddbd", size = 4913811, upload-time = "2026-04-24T19:54:00.236Z" },
- { url = "https://files.pythonhosted.org/packages/31/98/dc4ad376ac5f1a1a7d4a83f7b0c6f2bcad36b5d2d8f30aeb482d3a7d9582/cryptography-47.0.0-cp314-cp314t-win32.whl", hash = "sha256:6eebcaf0df1d21ce1f90605c9b432dd2c4f4ab665ac29a40d5e3fc68f51b5e63", size = 3237158, upload-time = "2026-04-24T19:54:02.606Z" },
- { url = "https://files.pythonhosted.org/packages/bc/da/97f62d18306b5133468bc3f8cc73a3111e8cdc8cf8d3e69474d6e5fd2d1b/cryptography-47.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:51c9313e90bd1690ec5a75ed047c27c0b8e6c570029712943d6116ef9a90620b", size = 3758706, upload-time = "2026-04-24T19:54:04.433Z" },
- { url = "https://files.pythonhosted.org/packages/e0/34/a4fae8ae7c3bc227460c9ae43f56abf1b911da0ec29e0ebac53bb0a4b6b7/cryptography-47.0.0-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:14432c8a9bcb37009784f9594a62fae211a2ae9543e96c92b2a8e4c3cd5cd0c4", size = 7904072, upload-time = "2026-04-24T19:54:06.411Z" },
- { url = "https://files.pythonhosted.org/packages/01/64/d7b1e54fdb69f22d24a64bb3e88dc718b31c7fb10ef0b9691a3cf7eeea6e/cryptography-47.0.0-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:07efe86201817e7d3c18781ca9770bc0db04e1e48c994be384e4602bc38f8f27", size = 4635767, upload-time = "2026-04-24T19:54:08.519Z" },
- { url = "https://files.pythonhosted.org/packages/8b/7b/cca826391fb2a94efdcdfe4631eb69306ee1cff0b22f664a412c90713877/cryptography-47.0.0-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:2b45761c6ec22b7c726d6a829558777e32d0f1c8be7c3f3480f9c912d5ee8a10", size = 4654350, upload-time = "2026-04-24T19:54:10.795Z" },
- { url = "https://files.pythonhosted.org/packages/4c/65/4b57bcc823f42a991627c51c2f68c9fd6eb1393c1756aac876cba2accae2/cryptography-47.0.0-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:edd4da498015da5b9f26d38d3bfc2e90257bfa9cbed1f6767c282a0025ae649b", size = 4643394, upload-time = "2026-04-24T19:54:13.275Z" },
- { url = "https://files.pythonhosted.org/packages/f4/c4/2c5fbeea70adbbca2bbae865e1d605d6a4a7f8dbd9d33eaf69645087f06c/cryptography-47.0.0-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:9af828c0d5a65c70ec729cd7495a4bf1a67ecb66417b8f02ff125ab8a6326a74", size = 5225777, upload-time = "2026-04-24T19:54:15.18Z" },
- { url = "https://files.pythonhosted.org/packages/7e/b8/ac57107ef32749d2b244e36069bb688792a363aaaa3acc9e3cf84c130315/cryptography-47.0.0-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:256d07c78a04d6b276f5df935a9923275f53bd1522f214447fdf365494e2d515", size = 4688771, upload-time = "2026-04-24T19:54:17.835Z" },
- { url = "https://files.pythonhosted.org/packages/56/fc/9f1de22ff8be99d991f240a46863c52d475404c408886c5a38d2b5c3bb26/cryptography-47.0.0-cp38-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:5d0e362ff51041b0c0d219cc7d6924d7b8996f57ce5712bdcef71eb3c65a59cc", size = 4270753, upload-time = "2026-04-24T19:54:19.963Z" },
- { url = "https://files.pythonhosted.org/packages/00/68/d70c852797aa68e8e48d12e5a87170c43f67bb4a59403627259dd57d15de/cryptography-47.0.0-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:1581aef4219f7ca2849d0250edaa3866212fb74bf5667284f46aa92f9e65c1ca", size = 4642911, upload-time = "2026-04-24T19:54:21.818Z" },
- { url = "https://files.pythonhosted.org/packages/a5/51/661cbee74f594c5d97ff82d34f10d5551c085ca4668645f4606ebd22bd5d/cryptography-47.0.0-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:a49a3eb5341b9503fa3000a9a0db033161db90d47285291f53c2a9d2cd1b7f76", size = 5181411, upload-time = "2026-04-24T19:54:24.376Z" },
- { url = "https://files.pythonhosted.org/packages/94/87/f2b6c374a82cf076cfa1416992ac8e8ec94d79facc37aec87c1a5cb72352/cryptography-47.0.0-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:2207a498b03275d0051589e326b79d4cf59985c99031b05bb292ac52631c37fe", size = 4688262, upload-time = "2026-04-24T19:54:26.946Z" },
- { url = "https://files.pythonhosted.org/packages/14/e2/8b7462f4acf21ec509616f0245018bb197194ab0b65c2ea21a0bdd53c0eb/cryptography-47.0.0-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:7a02675e2fabd0c0fc04c868b8781863cbf1967691543c22f5470500ff840b31", size = 4775506, upload-time = "2026-04-24T19:54:28.926Z" },
- { url = "https://files.pythonhosted.org/packages/70/75/158e494e4c08dc05e039da5bb48553826bd26c23930cf8d3cd5f21fa8921/cryptography-47.0.0-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:80887c5cbd1774683cb126f0ab4184567f080071d5acf62205acb354b4b753b7", size = 4912060, upload-time = "2026-04-24T19:54:30.869Z" },
- { url = "https://files.pythonhosted.org/packages/06/bd/0a9d3edbf5eadbac926d7b9b3cd0c4be584eeeae4a003d24d9eda4affbbd/cryptography-47.0.0-cp38-abi3-win32.whl", hash = "sha256:ed67ea4e0cfb5faa5bc7ecb6e2b8838f3807a03758eec239d6c21c8769355310", size = 3248487, upload-time = "2026-04-24T19:54:33.494Z" },
- { url = "https://files.pythonhosted.org/packages/60/80/5681af756d0da3a599b7bdb586fac5a1540f1bcefd2717a20e611ddade45/cryptography-47.0.0-cp38-abi3-win_amd64.whl", hash = "sha256:835d2d7f47cdc53b3224e90810fb1d36ca94ea29cc1801fb4c1bc43876735769", size = 3755737, upload-time = "2026-04-24T19:54:35.408Z" },
+ { url = "https://files.pythonhosted.org/packages/df/3d/01f6dd9190170a5a241e0e98c2d04be3664a9e6f5b9b872cde63aff1c3dd/cryptography-48.0.0-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:0c558d2cdffd8f4bbb30fc7134c74d2ca9a476f830bb053074498fbc86f41ed6", size = 8001587, upload-time = "2026-05-04T22:57:36.803Z" },
+ { url = "https://files.pythonhosted.org/packages/b2/6e/e90527eef33f309beb811cf7c982c3aeffcce8e3edb178baa4ca3ae4a6fa/cryptography-48.0.0-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f5333311663ea94f75dd408665686aaf426563556bb5283554a3539177e03b8c", size = 4690433, upload-time = "2026-05-04T22:57:40.373Z" },
+ { url = "https://files.pythonhosted.org/packages/90/04/673510ed51ddff56575f306cf1617d80411ee76831ccd3097599140efdfe/cryptography-48.0.0-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7995ef305d7165c3f11ae07f2517e5a4f1d5c18da1376a0a9ed496336b69e5f3", size = 4710620, upload-time = "2026-05-04T22:57:42.935Z" },
+ { url = "https://files.pythonhosted.org/packages/14/d5/e9c4ef932c8d800490c34d8bd589d64a31d5890e27ec9e9ad532be893294/cryptography-48.0.0-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:40ba1f85eaa6959837b1d51c9767e230e14612eea4ef110ee8854ada22da1bf5", size = 4696283, upload-time = "2026-05-04T22:57:45.294Z" },
+ { url = "https://files.pythonhosted.org/packages/0c/29/174b9dfb60b12d59ecfc6cfa04bc88c21b42a54f01b8aae09bb6e51e4c7f/cryptography-48.0.0-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:369a6348999f94bbd53435c894377b20ab95f25a9065c283570e70150d8abc3c", size = 5296573, upload-time = "2026-05-04T22:57:47.933Z" },
+ { url = "https://files.pythonhosted.org/packages/95/38/0d29a6fd7d0d1373f0c0c88a04ba20e359b257753ac497564cd660fc1d55/cryptography-48.0.0-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:a0e692c683f4df67815a2d258b324e66f4738bd7a96a218c826dce4f4bd05d8f", size = 4743677, upload-time = "2026-05-04T22:57:50.067Z" },
+ { url = "https://files.pythonhosted.org/packages/30/be/eef653013d5c63b6a490529e0316f9ac14a37602965d4903efed1399f32b/cryptography-48.0.0-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:18349bbc56f4743c8b12dc32e2bccb2cf83ee8b69a3bba74ef8ae857e26b3d25", size = 4330808, upload-time = "2026-05-04T22:57:52.301Z" },
+ { url = "https://files.pythonhosted.org/packages/84/9e/500463e87abb7a0a0f9f256ec21123ecde0a7b5541a15e840ea54551fd81/cryptography-48.0.0-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:7e8eac43dfca5c4cccc6dad9a80504436fca53bb9bc3100a2386d730fbe6b602", size = 4695941, upload-time = "2026-05-04T22:57:54.603Z" },
+ { url = "https://files.pythonhosted.org/packages/e3/dc/7303087450c2ec9e7fbb750e17c2abfbc658f23cbd0e54009509b7cc4091/cryptography-48.0.0-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:9ccdac7d40688ecb5a3b4a604b8a88c8002e3442d6c60aead1db2a89a041560c", size = 5252579, upload-time = "2026-05-04T22:57:57.207Z" },
+ { url = "https://files.pythonhosted.org/packages/d0/c0/7101d3b7215edcdc90c45da544961fd8ed2d6448f77577460fa75a8443f7/cryptography-48.0.0-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:bd72e68b06bb1e96913f97dd4901119bc17f39d4586a5adf2d3e47bc2b9d58b5", size = 4743326, upload-time = "2026-05-04T22:57:59.535Z" },
+ { url = "https://files.pythonhosted.org/packages/ac/d8/5b833bad13016f562ab9d063d68199a4bd121d18458e439515601d3357ec/cryptography-48.0.0-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:59baa2cb386c4f0b9905bd6eb4c2a79a69a128408fd31d32ca4d7102d4156321", size = 4826672, upload-time = "2026-05-04T22:58:01.996Z" },
+ { url = "https://files.pythonhosted.org/packages/98/e1/7074eb8bf3c135558c73fc2bcf0f5633f912e6fb87e868a55c454080ef09/cryptography-48.0.0-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:9249e3cd978541d665967ac2cb2787fd6a62bddf1e75b3e347a594d7dacf4f74", size = 4972574, upload-time = "2026-05-04T22:58:03.968Z" },
+ { url = "https://files.pythonhosted.org/packages/04/70/e5a1b41d325f797f39427aa44ef8baf0be500065ab6d8e10369d850d4a4f/cryptography-48.0.0-cp311-abi3-win32.whl", hash = "sha256:9c459db21422be75e2809370b829a87eb37f74cd785fc4aa9ea1e5f43b47cda4", size = 3294868, upload-time = "2026-05-04T22:58:06.467Z" },
+ { url = "https://files.pythonhosted.org/packages/f4/ac/8ac51b4a5fc5932eb7ee5c517ba7dc8cd834f0048962b6b352f00f41ebf9/cryptography-48.0.0-cp311-abi3-win_amd64.whl", hash = "sha256:5b012212e08b8dd5edc78ef54da83dd9892fd9105323b3993eff6bea65dc21d7", size = 3817107, upload-time = "2026-05-04T22:58:08.845Z" },
+ { url = "https://files.pythonhosted.org/packages/6b/84/70e3feea9feea87fd7cbe77efb2712ae1e3e6edf10749dc6e95f4e60e455/cryptography-48.0.0-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:3cb07a3ed6431663cd321ea8a000a1314c74211f823e4177fefa2255e057d1ec", size = 7986556, upload-time = "2026-05-04T22:58:11.172Z" },
+ { url = "https://files.pythonhosted.org/packages/89/6e/18e07a618bb5442ba10cf4df16e99c071365528aa570dfcb8c02e25a303b/cryptography-48.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8c7378637d7d88016fa6791c159f698b3d3eed28ebf844ac36b9dc04a14dae18", size = 4684776, upload-time = "2026-05-04T22:58:13.712Z" },
+ { url = "https://files.pythonhosted.org/packages/be/6a/4ea3b4c6c6759794d5ee2103c304a5076dc4b19ae1f9fe47dba439e159e9/cryptography-48.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:cc90c0b39b2e3c65ef52c804b72e3c58f8a04ab2a1871272798e5f9572c17d20", size = 4698121, upload-time = "2026-05-04T22:58:16.448Z" },
+ { url = "https://files.pythonhosted.org/packages/2f/59/6ff6ad6cae03bb887da2a5860b2c9805f8dac969ef01ce563336c49bd1d1/cryptography-48.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:76341972e1eff8b4bea859f09c0d3e64b96ce931b084f9b9b7db8ef364c30eff", size = 4690042, upload-time = "2026-05-04T22:58:18.544Z" },
+ { url = "https://files.pythonhosted.org/packages/ca/b4/fc334ed8cfd705aca282fe4d8f5ae64a8e0f74932e9feecb344610cf6e4d/cryptography-48.0.0-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:55b7718303bf06a5753dcdccf2f3945cf18ad7bffde41b61226e4db31ab89a9c", size = 5282526, upload-time = "2026-05-04T22:58:20.75Z" },
+ { url = "https://files.pythonhosted.org/packages/11/08/9f8c5386cc4cd90d8255c7cdd0f5baf459a08502a09de30dc51f553d38dc/cryptography-48.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:a64697c641c7b1b2178e573cbc31c7c6684cd56883a478d75143dbb7118036db", size = 4733116, upload-time = "2026-05-04T22:58:23.627Z" },
+ { url = "https://files.pythonhosted.org/packages/b8/77/99307d7574045699f8805aa500fa0fb83422d115b5400a064ddd306d7750/cryptography-48.0.0-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:561215ea3879cb1cbbf272867e2efda62476f240fb58c64de6b393ae19246741", size = 4316030, upload-time = "2026-05-04T22:58:25.581Z" },
+ { url = "https://files.pythonhosted.org/packages/fd/36/a608b98337af3cb2aff4818e406649d30572b7031918b04c87d979495348/cryptography-48.0.0-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:ad64688338ed4bc1a6618076ba75fd7194a5f1797ac60b47afe926285adb3166", size = 4689640, upload-time = "2026-05-04T22:58:27.747Z" },
+ { url = "https://files.pythonhosted.org/packages/dd/a6/825010a291b4438aecc1f568bc428189fc1175515223632477c07dc0a6df/cryptography-48.0.0-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:906cbf0670286c6e0044156bc7d4af9cbb0ef6db9f73e52c3ec56ba6bdde5336", size = 5237657, upload-time = "2026-05-04T22:58:29.848Z" },
+ { url = "https://files.pythonhosted.org/packages/b9/09/4e76a09b4caa29aad535ddc806f5d4c5d01885bd978bd984fbc6ca032cae/cryptography-48.0.0-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:ea8990436d914540a40ab24b6a77c0969695ed52f4a4874c5137ccf7045a7057", size = 4732362, upload-time = "2026-05-04T22:58:32.009Z" },
+ { url = "https://files.pythonhosted.org/packages/18/78/444fa04a77d0cb95f417dda20d450e13c56ba8e5220fc892a1658f44f882/cryptography-48.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c18684a7f0cc9a3cb60328f496b8e3372def7c5d2df39ac267878b05565aaaae", size = 4819580, upload-time = "2026-05-04T22:58:34.254Z" },
+ { url = "https://files.pythonhosted.org/packages/38/85/ea67067c70a1fd4be2c63d35eeed82658023021affccc7b17705f8527dd2/cryptography-48.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:9be5aafa5736574f8f15f262adc81b2a9869e2cfe9014d52a44633905b40d52c", size = 4963283, upload-time = "2026-05-04T22:58:36.376Z" },
+ { url = "https://files.pythonhosted.org/packages/75/54/cc6d0f3deac3e81c7f847e8a189a12b6cdd65059b43dad25d4316abd849a/cryptography-48.0.0-cp314-cp314t-win32.whl", hash = "sha256:c17dfe85494deaeddc5ce251aebd1d60bbe6afc8b62071bb0b469431a000124f", size = 3270954, upload-time = "2026-05-04T22:58:38.791Z" },
+ { url = "https://files.pythonhosted.org/packages/49/67/cc947e288c0758a4e5473d1dcb743037ab7785541265a969240b8885441a/cryptography-48.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:27241b1dc9962e056062a8eef1991d02c3a24569c95975bd2322a8a52c6e5e12", size = 3797313, upload-time = "2026-05-04T22:58:40.746Z" },
+ { url = "https://files.pythonhosted.org/packages/f2/63/61d4a4e1c6b6bab6ce1e213cd36a24c415d90e76d78c5eb8577c5541d2e8/cryptography-48.0.0-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:58d00498e8933e4a194f3076aee1b4a97dfec1a6da444535755822fe5d8b0b86", size = 7983482, upload-time = "2026-05-04T22:58:43.769Z" },
+ { url = "https://files.pythonhosted.org/packages/d5/ac/f5b5995b87770c693e2596559ffafe195b4033a57f14a82268a2842953f3/cryptography-48.0.0-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:614d0949f4790582d2cc25553abd09dd723025f0c0e7c67376a1d77196743d6e", size = 4683266, upload-time = "2026-05-04T22:58:46.064Z" },
+ { url = "https://files.pythonhosted.org/packages/ec/c6/8b14f67e18338fbc4adb76f66c001f5c3610b3e2d1837f268f47a347dbbb/cryptography-48.0.0-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7ce4bfae76319a532a2dc68f82cc32f5676ee792a983187dac07183690e5c66f", size = 4696228, upload-time = "2026-05-04T22:58:48.22Z" },
+ { url = "https://files.pythonhosted.org/packages/ea/73/f808fbae9514bd91b47875b003f13e284c8c6bdfd904b7944e803937eec1/cryptography-48.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:2eb992bbd4661238c5a397594c83f5b4dc2bc5b848c365c8f991b6780efcc5c7", size = 4689097, upload-time = "2026-05-04T22:58:50.9Z" },
+ { url = "https://files.pythonhosted.org/packages/93/01/d86632d7d28db8ae83221995752eeb6639ffb374c2d22955648cf8d52797/cryptography-48.0.0-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:22a5cb272895dce158b2cacdfdc3debd299019659f42947dbdac6f32d68fe832", size = 5283582, upload-time = "2026-05-04T22:58:53.017Z" },
+ { url = "https://files.pythonhosted.org/packages/02/e1/50edc7a50334807cc4791fc4a0ce7468b4a1416d9138eab358bfc9a3d70b/cryptography-48.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:2b4d59804e8408e2fea7d1fbaf218e5ec984325221db76e6a241a9abd6cdd95c", size = 4730479, upload-time = "2026-05-04T22:58:55.611Z" },
+ { url = "https://files.pythonhosted.org/packages/6f/af/99a582b1b1641ff5911ac559beb45097cf79efd4ead4657f578ef1af2d47/cryptography-48.0.0-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:984a20b0f62a26f48a3396c72e4bc34c66e356d356bf370053066b3b6d54634a", size = 4326481, upload-time = "2026-05-04T22:58:57.607Z" },
+ { url = "https://files.pythonhosted.org/packages/90/ee/89aa26a06ef0a7d7611788ffd571a7c50e368cc6a4d5eef8b4884e866edb/cryptography-48.0.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:5a5ed8fde7a1d09376ca0b40e68cd59c69fe23b1f9768bd5824f54681626032a", size = 4688713, upload-time = "2026-05-04T22:59:00.077Z" },
+ { url = "https://files.pythonhosted.org/packages/70/ba/bcb1b0bb7a33d4c7c0c4d4c7874b4a62ae4f56113a5f4baefa362dfb1f0f/cryptography-48.0.0-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:8cd666227ef7af430aa5914a9910e0ddd703e75f039cef0825cd0da71b6b711a", size = 5238165, upload-time = "2026-05-04T22:59:02.317Z" },
+ { url = "https://files.pythonhosted.org/packages/c9/70/ca4003b1ce5ca3dc3186ada51908c8a9b9ff7d5cab83cc0d43ee14ec144f/cryptography-48.0.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:9071196d81abc88b3516ac8cdfad32e2b66dd4a5393a8e68a961e9161ddc6239", size = 4729947, upload-time = "2026-05-04T22:59:05.255Z" },
+ { url = "https://files.pythonhosted.org/packages/44/a0/4ec7cf774207905aef1a8d11c3750d5a1db805eb380ee4e16df317870128/cryptography-48.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:1e2d54c8be6152856a36f0882ab231e70f8ec7f14e93cf87db8a2ed056bf160c", size = 4822059, upload-time = "2026-05-04T22:59:07.802Z" },
+ { url = "https://files.pythonhosted.org/packages/1e/75/a2e55f99c16fcac7b5d6c1eb19ad8e00799854d6be5ca845f9259eae1681/cryptography-48.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:a5da777e32ffed6f85a7b2b3f7c5cbc88c146bfcd0a1d7baf5fcc6c52ee35dd4", size = 4960575, upload-time = "2026-05-04T22:59:09.851Z" },
+ { url = "https://files.pythonhosted.org/packages/b8/23/6e6f32143ab5d8b36ca848a502c4bcd477ae75b9e1677e3530d669062578/cryptography-48.0.0-cp39-abi3-win32.whl", hash = "sha256:77a2ccbbe917f6710e05ba9adaa25fb5075620bf3ea6fb751997875aff4ae4bd", size = 3279117, upload-time = "2026-05-04T22:59:12.019Z" },
+ { url = "https://files.pythonhosted.org/packages/9d/9a/0fea98a70cf1749d41d738836f6349d97945f7c89433a259a6c2642eefeb/cryptography-48.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:16cd65b9330583e4619939b3a3843eec1e6e789744bb01e7c7e2e62e33c239c8", size = 3792100, upload-time = "2026-05-04T22:59:14.884Z" },
]
[[package]]
name = "cyclopts"
-version = "4.2.5"
+version = "4.12.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "attrs" },
@@ -265,18 +262,9 @@ dependencies = [
{ name = "rich" },
{ name = "rich-rst" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/94/f0/5a83ac365800969552a7eb261b03860dea38647fb6cc31574bdbcdc9f0f5/cyclopts-4.2.5.tar.gz", hash = "sha256:91aff5da5b8b841ccb32b5142c2d12fd93678fbc9eba7ef7319452323e24d6d4", size = 149497, upload-time = "2025-11-22T02:33:37.526Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/a4/c3/d3f095120329616cc364af2bedcffd518d4db18c978f2f6c892d29e6af2f/cyclopts-4.12.0.tar.gz", hash = "sha256:86bfb5b35cb078decc1cca6c1be41f9a0e6202dc43b4f6056d5cfc6d1f4a69d1", size = 176123, upload-time = "2026-05-13T13:26:31.243Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/b6/00/a9b81bdba88e2904602e970e46ffd18b6a833d902f18d91bdce6fc271c49/cyclopts-4.2.5-py3-none-any.whl", hash = "sha256:361be316ce7f6ce674cad8d34bf6c5e39c34daaeceae40632a55b599472975c7", size = 185196, upload-time = "2025-11-22T02:33:36.103Z" },
-]
-
-[[package]]
-name = "diskcache"
-version = "5.6.3"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/3f/21/1c1ffc1a039ddcc459db43cc108658f32c57d271d7289a2794e401d0fdb6/diskcache-5.6.3.tar.gz", hash = "sha256:2c3a3fa2743d8535d832ec61c2054a1641f41775aa7c556758a109941e33e4fc", size = 67916, upload-time = "2023-08-31T06:12:00.316Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/3f/27/4570e78fc0bf5ea0ca45eb1de3818a23787af9b390c0b0a0033a1b8236f9/diskcache-5.6.3-py3-none-any.whl", hash = "sha256:5e31b2d5fbad117cc363ebaf6b689474db18a1f6438bc82358b024abd4c2ca19", size = 45550, upload-time = "2023-08-31T06:11:58.822Z" },
+ { url = "https://files.pythonhosted.org/packages/4e/d0/17b6b7d5f64dea337a7a409a1e4e0eeceda724046b9acd158fd1aa2f2328/cyclopts-4.12.0-py3-none-any.whl", hash = "sha256:ee03d2b9ef790d866cb3823a7e54b2be5252c82d34536579846fce068b30c38f", size = 213706, upload-time = "2026-05-13T13:26:29.744Z" },
]
[[package]]
@@ -290,20 +278,20 @@ wheels = [
[[package]]
name = "docstring-parser"
-version = "0.17.0"
+version = "0.18.0"
source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/b2/9d/c3b43da9515bd270df0f80548d9944e389870713cc1fe2b8fb35fe2bcefd/docstring_parser-0.17.0.tar.gz", hash = "sha256:583de4a309722b3315439bb31d64ba3eebada841f2e2cee23b99df001434c912", size = 27442, upload-time = "2025-07-21T07:35:01.868Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/e0/4d/f332313098c1de1b2d2ff91cf2674415cc7cddab2ca1b01ae29774bd5fdf/docstring_parser-0.18.0.tar.gz", hash = "sha256:292510982205c12b1248696f44959db3cdd1740237a968ea1e2e7a900eeb2015", size = 29341, upload-time = "2026-04-14T04:09:19.867Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/55/e2/2537ebcff11c1ee1ff17d8d0b6f4db75873e3b0fb32c2d4a2ee31ecb310a/docstring_parser-0.17.0-py3-none-any.whl", hash = "sha256:cf2569abd23dce8099b300f9b4fa8191e9582dda731fd533daf54c4551658708", size = 36896, upload-time = "2025-07-21T07:35:00.684Z" },
+ { url = "https://files.pythonhosted.org/packages/a7/5f/ed01f9a3cdffbd5a008556fc7b2a08ddb1cc6ace7effa7340604b1d16699/docstring_parser-0.18.0-py3-none-any.whl", hash = "sha256:b3fcbed555c47d8479be0796ef7e19c2670d428d72e96da63f3a40122860374b", size = 22484, upload-time = "2026-04-14T04:09:18.638Z" },
]
[[package]]
name = "docutils"
-version = "0.22.2"
+version = "0.22.4"
source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/4a/c0/89fe6215b443b919cb98a5002e107cb5026854ed1ccb6b5833e0768419d1/docutils-0.22.2.tar.gz", hash = "sha256:9fdb771707c8784c8f2728b67cb2c691305933d68137ef95a75db5f4dfbc213d", size = 2289092, upload-time = "2025-09-20T17:55:47.994Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/ae/b6/03bb70946330e88ffec97aefd3ea75ba575cb2e762061e0e62a213befee8/docutils-0.22.4.tar.gz", hash = "sha256:4db53b1fde9abecbb74d91230d32ab626d94f6badfc575d6db9194a49df29968", size = 2291750, upload-time = "2025-12-18T19:00:26.443Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/66/dd/f95350e853a4468ec37478414fc04ae2d61dad7a947b3015c3dcc51a09b9/docutils-0.22.2-py3-none-any.whl", hash = "sha256:b0e98d679283fc3bb0ead8a5da7f501baa632654e7056e9c5846842213d674d8", size = 632667, upload-time = "2025-09-20T17:55:43.052Z" },
+ { url = "https://files.pythonhosted.org/packages/02/10/5da547df7a391dcde17f59520a231527b8571e6f46fc8efb02ccb370ab12/docutils-0.22.4-py3-none-any.whl", hash = "sha256:d0013f540772d1420576855455d050a2180186c91c15779301ac2ccb3eeb68de", size = 633196, upload-time = "2025-12-18T19:00:18.077Z" },
]
[[package]]
@@ -321,59 +309,56 @@ wheels = [
[[package]]
name = "exceptiongroup"
-version = "1.3.0"
+version = "1.3.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/0b/9f/a65090624ecf468cdca03533906e7c69ed7588582240cfe7cc9e770b50eb/exceptiongroup-1.3.0.tar.gz", hash = "sha256:b241f5885f560bc56a59ee63ca4c6a8bfa46ae4ad651af316d4e81817bb9fd88", size = 29749, upload-time = "2025-05-10T17:42:51.123Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/36/f4/c6e662dade71f56cd2f3735141b265c3c79293c109549c1e6933b0651ffc/exceptiongroup-1.3.0-py3-none-any.whl", hash = "sha256:4d111e6e0c13d0644cad6ddaa7ed0261a0b36971f6d23e7ec9b4b9097da78a10", size = 16674, upload-time = "2025-05-10T17:42:49.33Z" },
-]
-
-[[package]]
-name = "fakeredis"
-version = "2.33.0"
-source = { registry = "https://pypi.org/simple" }
-dependencies = [
- { name = "redis" },
- { name = "sortedcontainers" },
-]
-sdist = { url = "https://files.pythonhosted.org/packages/5f/f9/57464119936414d60697fcbd32f38909bb5688b616ae13de6e98384433e0/fakeredis-2.33.0.tar.gz", hash = "sha256:d7bc9a69d21df108a6451bbffee23b3eba432c21a654afc7ff2d295428ec5770", size = 175187, upload-time = "2025-12-16T19:45:52.269Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/6e/78/a850fed8aeef96d4a99043c90b818b2ed5419cd5b24a4049fd7cfb9f1471/fakeredis-2.33.0-py3-none-any.whl", hash = "sha256:de535f3f9ccde1c56672ab2fdd6a8efbc4f2619fc2f1acc87b8737177d71c965", size = 119605, upload-time = "2025-12-16T19:45:51.08Z" },
-]
-
-[package.optional-dependencies]
-lua = [
- { name = "lupa" },
+ { url = "https://files.pythonhosted.org/packages/8a/0e/97c33bf5009bdbac74fd2beace167cab3f978feb69cc36f1ef79360d6c4e/exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598", size = 16740, upload-time = "2025-11-21T23:01:53.443Z" },
]
[[package]]
name = "fastmcp"
-version = "2.14.0"
+version = "3.2.4"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "authlib" },
{ name = "cyclopts" },
{ name = "exceptiongroup" },
+ { name = "griffelib" },
{ name = "httpx" },
+ { name = "jsonref" },
{ name = "jsonschema-path" },
{ name = "mcp" },
{ name = "openapi-pydantic" },
+ { name = "opentelemetry-api" },
+ { name = "packaging" },
{ name = "platformdirs" },
- { name = "py-key-value-aio", extra = ["disk", "keyring", "memory"] },
+ { name = "py-key-value-aio", extra = ["filetree", "keyring", "memory"] },
{ name = "pydantic", extra = ["email"] },
- { name = "pydocket" },
{ name = "pyperclip" },
{ name = "python-dotenv" },
+ { name = "pyyaml" },
{ name = "rich" },
+ { name = "uncalled-for" },
{ name = "uvicorn" },
+ { name = "watchfiles" },
{ name = "websockets" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/35/50/9bb042a2d290ccadb35db3580ac507f192e1a39c489eb8faa167cd5e3b57/fastmcp-2.14.0.tar.gz", hash = "sha256:c1f487b36a3e4b043dbf3330e588830047df2e06f8ef0920d62dfb34d0905727", size = 8232562, upload-time = "2025-12-11T23:04:27.134Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/9c/13/29544fbc6dfe45ea38046af0067311e0bad7acc7d1f2ad38bb08f2409fe2/fastmcp-3.2.4.tar.gz", hash = "sha256:083ecb75b44a4169e7fc0f632f94b781bdb0ff877c6b35b9877cbb566fd4d4d1", size = 28746127, upload-time = "2026-04-14T01:42:24.174Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/54/73/b5656172a6beb2eacec95f04403ddea1928e4b22066700fd14780f8f45d1/fastmcp-2.14.0-py3-none-any.whl", hash = "sha256:7b374c0bcaf1ef1ef46b9255ea84c607f354291eaf647ff56a47c69f5ec0c204", size = 398965, upload-time = "2025-12-11T23:04:25.587Z" },
+ { url = "https://files.pythonhosted.org/packages/cf/76/b310d52fa0e30d39bd937eb58ec2c1f1ea1b5f519f0575e9dd9612f01deb/fastmcp-3.2.4-py3-none-any.whl", hash = "sha256:e6c9c429171041455e47ab94bb3f83c4657622a0ec28922f6940053959bd58a9", size = 728599, upload-time = "2026-04-14T01:42:26.85Z" },
+]
+
+[[package]]
+name = "griffelib"
+version = "2.0.2"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/9d/82/74f4a3310cdabfbb10da554c3a672847f1ed33c6f61dd472681ce7f1fe67/griffelib-2.0.2.tar.gz", hash = "sha256:3cf20b3bc470e83763ffbf236e0076b1211bac1bc67de13daf494640f2de707e", size = 166461, upload-time = "2026-03-27T11:34:51.091Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/11/8c/c9138d881c79aa0ea9ed83cbd58d5ca75624378b38cee225dcf5c42cc91f/griffelib-2.0.2-py3-none-any.whl", hash = "sha256:925c857658fb1ba40c0772c37acbc2ab650bd794d9c1b9726922e36ea4117ea1", size = 142357, upload-time = "2026-03-27T11:34:46.275Z" },
]
[[package]]
@@ -415,20 +400,20 @@ wheels = [
[[package]]
name = "httpx-sse"
-version = "0.4.1"
+version = "0.4.3"
source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/6e/fa/66bd985dd0b7c109a3bcb89272ee0bfb7e2b4d06309ad7b38ff866734b2a/httpx_sse-0.4.1.tar.gz", hash = "sha256:8f44d34414bc7b21bf3602713005c5df4917884f76072479b21f68befa4ea26e", size = 12998, upload-time = "2025-06-24T13:21:05.71Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/0f/4c/751061ffa58615a32c31b2d82e8482be8dd4a89154f003147acee90f2be9/httpx_sse-0.4.3.tar.gz", hash = "sha256:9b1ed0127459a66014aec3c56bebd93da3c1bc8bb6618c8082039a44889a755d", size = 15943, upload-time = "2025-10-10T21:48:22.271Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/25/0a/6269e3473b09aed2dab8aa1a600c70f31f00ae1349bee30658f7e358a159/httpx_sse-0.4.1-py3-none-any.whl", hash = "sha256:cba42174344c3a5b06f255ce65b350880f962d99ead85e776f23c6618a377a37", size = 8054, upload-time = "2025-06-24T13:21:04.772Z" },
+ { url = "https://files.pythonhosted.org/packages/d2/fd/6668e5aec43ab844de6fc74927e155a3b37bf40d7c3790e49fc0406b6578/httpx_sse-0.4.3-py3-none-any.whl", hash = "sha256:0ac1c9fe3c0afad2e0ebb25a934a59f4c7823b60792691f779fad2c5568830fc", size = 8960, upload-time = "2025-10-10T21:48:21.158Z" },
]
[[package]]
name = "idna"
-version = "3.10"
+version = "3.15"
source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/f1/70/7703c29685631f5a7590aa73f1f1d3fa9a380e654b86af429e0934a32f7d/idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9", size = 190490, upload-time = "2024-09-15T18:07:39.745Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/82/77/7b3966d0b9d1d31a36ddf1746926a11dface89a83409bf1483f0237aa758/idna-3.15.tar.gz", hash = "sha256:ca962446ea538f7092a95e057da437618e886f4d349216d2b1e294abfdb65fdc", size = 199245, upload-time = "2026-05-12T22:45:57.011Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/76/c6/c88e154df9c4e1a2a66ccf0005a88dfb2650c1dffb6f5ce603dfbd452ce3/idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3", size = 70442, upload-time = "2024-09-15T18:07:37.964Z" },
+ { url = "https://files.pythonhosted.org/packages/d2/23/408243171aa9aaba178d3e2559159c24c1171a641aa83b67bdd3394ead8e/idna-3.15-py3-none-any.whl", hash = "sha256:048adeaf8c2d788c40fee287673ccaa74c24ffd8dcf09ffa555a2fbb59f10ac8", size = 72340, upload-time = "2026-05-12T22:45:55.733Z" },
]
[[package]]
@@ -466,23 +451,23 @@ wheels = [
[[package]]
name = "jaraco-context"
-version = "6.0.1"
+version = "6.1.2"
source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/df/ad/f3777b81bf0b6e7bc7514a1656d3e637b2e8e15fab2ce3235730b3e7a4e6/jaraco_context-6.0.1.tar.gz", hash = "sha256:9bae4ea555cf0b14938dc0aee7c9f32ed303aa20a3b73e7dc80111628792d1b3", size = 13912, upload-time = "2024-08-20T03:39:27.358Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/af/50/4763cd07e722bb6285316d390a164bc7e479db9d90daa769f22578f698b4/jaraco_context-6.1.2.tar.gz", hash = "sha256:f1a6c9d391e661cc5b8d39861ff077a7dc24dc23833ccee564b234b81c82dfe3", size = 16801, upload-time = "2026-03-20T22:13:33.922Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/ff/db/0c52c4cf5e4bd9f5d7135ec7669a3a767af21b3a308e1ed3674881e52b62/jaraco.context-6.0.1-py3-none-any.whl", hash = "sha256:f797fc481b490edb305122c9181830a3a5b76d84ef6d1aef2fb9b47ab956f9e4", size = 6825, upload-time = "2024-08-20T03:39:25.966Z" },
+ { url = "https://files.pythonhosted.org/packages/f2/58/bc8954bda5fcda97bd7c19be11b85f91973d67a706ed4a3aec33e7de22db/jaraco_context-6.1.2-py3-none-any.whl", hash = "sha256:bf8150b79a2d5d91ae48629d8b427a8f7ba0e1097dd6202a9059f29a36379535", size = 7871, upload-time = "2026-03-20T22:13:32.808Z" },
]
[[package]]
name = "jaraco-functools"
-version = "4.3.0"
+version = "4.4.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "more-itertools" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/f7/ed/1aa2d585304ec07262e1a83a9889880701079dde796ac7b1d1826f40c63d/jaraco_functools-4.3.0.tar.gz", hash = "sha256:cfd13ad0dd2c47a3600b439ef72d8615d482cedcff1632930d6f28924d92f294", size = 19755, upload-time = "2025-08-18T20:05:09.91Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/0f/27/056e0638a86749374d6f57d0b0db39f29509cce9313cf91bdc0ac4d91084/jaraco_functools-4.4.0.tar.gz", hash = "sha256:da21933b0417b89515562656547a77b4931f98176eb173644c0d35032a33d6bb", size = 19943, upload-time = "2025-12-21T09:29:43.6Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/b4/09/726f168acad366b11e420df31bf1c702a54d373a83f968d94141a8c3fde0/jaraco_functools-4.3.0-py3-none-any.whl", hash = "sha256:227ff8ed6f7b8f62c56deff101545fa7543cf2c8e7b82a7c2116e672f29c26e8", size = 10408, upload-time = "2025-08-18T20:05:08.69Z" },
+ { url = "https://files.pythonhosted.org/packages/fd/c4/813bb09f0985cb21e959f21f2464169eca882656849adf727ac7bb7e1767/jaraco_functools-4.4.0-py3-none-any.whl", hash = "sha256:9eec1e36f45c818d9bf307c8948eb03b2b56cd44087b3cdc989abca1f20b9176", size = 10481, upload-time = "2025-12-21T09:29:42.27Z" },
]
[[package]]
@@ -494,9 +479,30 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/b2/a3/e137168c9c44d18eff0376253da9f1e9234d0239e0ee230d2fee6cea8e55/jeepney-0.9.0-py3-none-any.whl", hash = "sha256:97e5714520c16fc0a45695e5365a2e11b81ea79bba796e26f9f1d178cb182683", size = 49010, upload-time = "2025-02-27T18:51:00.104Z" },
]
+[[package]]
+name = "joserfc"
+version = "1.6.5"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "cryptography" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/3b/dc/5f768c2e391e9afabe5d18e3221346deb5fb6338565f1ccc9e7c6d7befdd/joserfc-1.6.5.tar.gz", hash = "sha256:1482a7db78fb4602e44ed89e51b599d052e091288c7c532c5b694e20149dec48", size = 231881, upload-time = "2026-05-06T04:58:13.408Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/54/3b/ad1cb22e75c963b1f07c8a2329bf47227ce7e4361df5eb2fb101b2ce33ef/joserfc-1.6.5-py3-none-any.whl", hash = "sha256:e9878a0f8243fe7b95e11fdda81374ca9f7a689e302751579d3dfdeec559675e", size = 70464, upload-time = "2026-05-06T04:58:11.668Z" },
+]
+
+[[package]]
+name = "jsonref"
+version = "1.1.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/aa/0d/c1f3277e90ccdb50d33ed5ba1ec5b3f0a242ed8c1b1a85d3afeb68464dca/jsonref-1.1.0.tar.gz", hash = "sha256:32fe8e1d85af0fdefbebce950af85590b22b60f9e95443176adbde4e1ecea552", size = 8814, upload-time = "2023-01-16T16:10:04.455Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/0c/ec/e1db9922bceb168197a558a2b8c03a7963f1afe93517ddd3cf99f202f996/jsonref-1.1.0-py3-none-any.whl", hash = "sha256:590dc7773df6c21cbf948b5dac07a72a251db28b0238ceecce0a2abfa8ec30a9", size = 9425, upload-time = "2023-01-16T16:10:02.255Z" },
+]
+
[[package]]
name = "jsonschema"
-version = "4.25.1"
+version = "4.26.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "attrs" },
@@ -504,24 +510,23 @@ dependencies = [
{ name = "referencing" },
{ name = "rpds-py" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/74/69/f7185de793a29082a9f3c7728268ffb31cb5095131a9c139a74078e27336/jsonschema-4.25.1.tar.gz", hash = "sha256:e4a9655ce0da0c0b67a085847e00a3a51449e1157f4f75e9fb5aa545e122eb85", size = 357342, upload-time = "2025-08-18T17:03:50.038Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/b3/fc/e067678238fa451312d4c62bf6e6cf5ec56375422aee02f9cb5f909b3047/jsonschema-4.26.0.tar.gz", hash = "sha256:0c26707e2efad8aa1bfc5b7ce170f3fccc2e4918ff85989ba9ffa9facb2be326", size = 366583, upload-time = "2026-01-07T13:41:07.246Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/bf/9c/8c95d856233c1f82500c2450b8c68576b4cf1c871db3afac5c34ff84e6fd/jsonschema-4.25.1-py3-none-any.whl", hash = "sha256:3fba0169e345c7175110351d456342c364814cfcf3b964ba4587f22915230a63", size = 90040, upload-time = "2025-08-18T17:03:48.373Z" },
+ { url = "https://files.pythonhosted.org/packages/69/90/f63fb5873511e014207a475e2bb4e8b2e570d655b00ac19a9a0ca0a385ee/jsonschema-4.26.0-py3-none-any.whl", hash = "sha256:d489f15263b8d200f8387e64b4c3a75f06629559fb73deb8fdfb525f2dab50ce", size = 90630, upload-time = "2026-01-07T13:41:05.306Z" },
]
[[package]]
name = "jsonschema-path"
-version = "0.3.4"
+version = "0.4.6"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pathable" },
{ name = "pyyaml" },
{ name = "referencing" },
- { name = "requests" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/6e/45/41ebc679c2a4fced6a722f624c18d658dee42612b83ea24c1caf7c0eb3a8/jsonschema_path-0.3.4.tar.gz", hash = "sha256:8365356039f16cc65fddffafda5f58766e34bebab7d6d105616ab52bc4297001", size = 11159, upload-time = "2025-01-24T14:33:16.547Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/01/86/cfee6dd25843bec0760f456599a4f7e7e40221a934b9229fda0662c859bc/jsonschema_path-0.4.6.tar.gz", hash = "sha256:c89eb635f4d497c9ac328eeff359c489755838806a7d033510a692e9576f5c4b", size = 15302, upload-time = "2026-04-27T18:57:08.412Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/cb/58/3485da8cb93d2f393bce453adeef16896751f14ba3e2024bc21dc9597646/jsonschema_path-0.3.4-py3-none-any.whl", hash = "sha256:f502191fdc2b22050f9a81c9237be9d27145b9001c55842bece5e94e382e52f8", size = 14810, upload-time = "2025-01-24T14:33:14.652Z" },
+ { url = "https://files.pythonhosted.org/packages/6c/43/3d3065c05a04bb550c143bfbb8e4fd7022cd327e1082bf257bac74923783/jsonschema_path-0.4.6-py3-none-any.whl", hash = "sha256:451354b5311fa955c3144e6e4e255388c751c0121c5570ec5bb9291dd42d08c9", size = 19565, upload-time = "2026-04-27T18:57:06.792Z" },
]
[[package]]
@@ -553,73 +558,21 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/81/db/e655086b7f3a705df045bf0933bdd9c2f79bb3c97bfef1384598bb79a217/keyring-25.7.0-py3-none-any.whl", hash = "sha256:be4a0b195f149690c166e850609a477c532ddbfbaed96a404d4e43f8d5e2689f", size = 39160, upload-time = "2025-11-16T16:26:08.402Z" },
]
-[[package]]
-name = "lupa"
-version = "2.6"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/b8/1c/191c3e6ec6502e3dbe25a53e27f69a5daeac3e56de1f73c0138224171ead/lupa-2.6.tar.gz", hash = "sha256:9a770a6e89576be3447668d7ced312cd6fd41d3c13c2462c9dc2c2ab570e45d9", size = 7240282, upload-time = "2025-10-24T07:20:29.738Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/94/86/ce243390535c39d53ea17ccf0240815e6e457e413e40428a658ea4ee4b8d/lupa-2.6-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:47ce718817ef1cc0c40d87c3d5ae56a800d61af00fbc0fad1ca9be12df2f3b56", size = 951707, upload-time = "2025-10-24T07:18:03.884Z" },
- { url = "https://files.pythonhosted.org/packages/86/85/cedea5e6cbeb54396fdcc55f6b741696f3f036d23cfaf986d50d680446da/lupa-2.6-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:7aba985b15b101495aa4b07112cdc08baa0c545390d560ad5cfde2e9e34f4d58", size = 1916703, upload-time = "2025-10-24T07:18:05.6Z" },
- { url = "https://files.pythonhosted.org/packages/24/be/3d6b5f9a8588c01a4d88129284c726017b2089f3a3fd3ba8bd977292fea0/lupa-2.6-cp312-cp312-macosx_11_0_x86_64.whl", hash = "sha256:b766f62f95b2739f2248977d29b0722e589dcf4f0ccfa827ccbd29f0148bd2e5", size = 985152, upload-time = "2025-10-24T07:18:08.561Z" },
- { url = "https://files.pythonhosted.org/packages/eb/23/9f9a05beee5d5dce9deca4cb07c91c40a90541fc0a8e09db4ee670da550f/lupa-2.6-cp312-cp312-manylinux2010_i686.manylinux_2_12_i686.manylinux_2_28_i686.whl", hash = "sha256:00a934c23331f94cb51760097ebfab14b005d55a6b30a2b480e3c53dd2fa290d", size = 1159599, upload-time = "2025-10-24T07:18:10.346Z" },
- { url = "https://files.pythonhosted.org/packages/40/4e/e7c0583083db9d7f1fd023800a9767d8e4391e8330d56c2373d890ac971b/lupa-2.6-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:21de9f38bd475303e34a042b7081aabdf50bd9bafd36ce4faea2f90fd9f15c31", size = 1038686, upload-time = "2025-10-24T07:18:12.112Z" },
- { url = "https://files.pythonhosted.org/packages/1c/9f/5a4f7d959d4feba5e203ff0c31889e74d1ca3153122be4a46dca7d92bf7c/lupa-2.6-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cf3bda96d3fc41237e964a69c23647d50d4e28421111360274d4799832c560e9", size = 2071956, upload-time = "2025-10-24T07:18:14.572Z" },
- { url = "https://files.pythonhosted.org/packages/92/34/2f4f13ca65d01169b1720176aedc4af17bc19ee834598c7292db232cb6dc/lupa-2.6-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5a76ead245da54801a81053794aa3975f213221f6542d14ec4b859ee2e7e0323", size = 1057199, upload-time = "2025-10-24T07:18:16.379Z" },
- { url = "https://files.pythonhosted.org/packages/35/2a/5f7d2eebec6993b0dcd428e0184ad71afb06a45ba13e717f6501bfed1da3/lupa-2.6-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:8dd0861741caa20886ddbda0a121d8e52fb9b5bb153d82fa9bba796962bf30e8", size = 1173693, upload-time = "2025-10-24T07:18:18.153Z" },
- { url = "https://files.pythonhosted.org/packages/e4/29/089b4d2f8e34417349af3904bb40bec40b65c8731f45e3fd8d497ca573e5/lupa-2.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:239e63948b0b23023f81d9a19a395e768ed3da6a299f84e7963b8f813f6e3f9c", size = 2164394, upload-time = "2025-10-24T07:18:20.403Z" },
- { url = "https://files.pythonhosted.org/packages/f3/1b/79c17b23c921f81468a111cad843b076a17ef4b684c4a8dff32a7969c3f0/lupa-2.6-cp312-cp312-win32.whl", hash = "sha256:325894e1099499e7a6f9c351147661a2011887603c71086d36fe0f964d52d1ce", size = 1420647, upload-time = "2025-10-24T07:18:23.368Z" },
- { url = "https://files.pythonhosted.org/packages/b8/15/5121e68aad3584e26e1425a5c9a79cd898f8a152292059e128c206ee817c/lupa-2.6-cp312-cp312-win_amd64.whl", hash = "sha256:c735a1ce8ee60edb0fe71d665f1e6b7c55c6021f1d340eb8c865952c602cd36f", size = 1688529, upload-time = "2025-10-24T07:18:25.523Z" },
- { url = "https://files.pythonhosted.org/packages/28/1d/21176b682ca5469001199d8b95fa1737e29957a3d185186e7a8b55345f2e/lupa-2.6-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:663a6e58a0f60e7d212017d6678639ac8df0119bc13c2145029dcba084391310", size = 947232, upload-time = "2025-10-24T07:18:27.878Z" },
- { url = "https://files.pythonhosted.org/packages/ce/4c/d327befb684660ca13cf79cd1f1d604331808f9f1b6fb6bf57832f8edf80/lupa-2.6-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:d1f5afda5c20b1f3217a80e9bc1b77037f8a6eb11612fd3ada19065303c8f380", size = 1908625, upload-time = "2025-10-24T07:18:29.944Z" },
- { url = "https://files.pythonhosted.org/packages/66/8e/ad22b0a19454dfd08662237a84c792d6d420d36b061f239e084f29d1a4f3/lupa-2.6-cp313-cp313-macosx_11_0_x86_64.whl", hash = "sha256:26f2b3c085fe76e9119e48c1013c1cccdc1f51585d456858290475aa38e7089e", size = 981057, upload-time = "2025-10-24T07:18:31.553Z" },
- { url = "https://files.pythonhosted.org/packages/5c/48/74859073ab276bd0566c719f9ca0108b0cfc1956ca0d68678d117d47d155/lupa-2.6-cp313-cp313-manylinux2010_i686.manylinux_2_12_i686.manylinux_2_28_i686.whl", hash = "sha256:60d2f902c7b96fb8ab98493dcff315e7bb4d0b44dc9dd76eb37de575025d5685", size = 1156227, upload-time = "2025-10-24T07:18:33.981Z" },
- { url = "https://files.pythonhosted.org/packages/09/6c/0e9ded061916877253c2266074060eb71ed99fb21d73c8c114a76725bce2/lupa-2.6-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a02d25dee3a3250967c36590128d9220ae02f2eda166a24279da0b481519cbff", size = 1035752, upload-time = "2025-10-24T07:18:36.32Z" },
- { url = "https://files.pythonhosted.org/packages/dd/ef/f8c32e454ef9f3fe909f6c7d57a39f950996c37a3deb7b391fec7903dab7/lupa-2.6-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6eae1ee16b886b8914ff292dbefbf2f48abfbdee94b33a88d1d5475e02423203", size = 2069009, upload-time = "2025-10-24T07:18:38.072Z" },
- { url = "https://files.pythonhosted.org/packages/53/dc/15b80c226a5225815a890ee1c11f07968e0aba7a852df41e8ae6fe285063/lupa-2.6-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:b0edd5073a4ee74ab36f74fe61450148e6044f3952b8d21248581f3c5d1a58be", size = 1056301, upload-time = "2025-10-24T07:18:40.165Z" },
- { url = "https://files.pythonhosted.org/packages/31/14/2086c1425c985acfb30997a67e90c39457122df41324d3c179d6ee2292c6/lupa-2.6-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:0c53ee9f22a8a17e7d4266ad48e86f43771951797042dd51d1494aaa4f5f3f0a", size = 1170673, upload-time = "2025-10-24T07:18:42.426Z" },
- { url = "https://files.pythonhosted.org/packages/10/e5/b216c054cf86576c0191bf9a9f05de6f7e8e07164897d95eea0078dca9b2/lupa-2.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:de7c0f157a9064a400d828789191a96da7f4ce889969a588b87ec80de9b14772", size = 2162227, upload-time = "2025-10-24T07:18:46.112Z" },
- { url = "https://files.pythonhosted.org/packages/59/2f/33ecb5bedf4f3bc297ceacb7f016ff951331d352f58e7e791589609ea306/lupa-2.6-cp313-cp313-win32.whl", hash = "sha256:ee9523941ae0a87b5b703417720c5d78f72d2f5bc23883a2ea80a949a3ed9e75", size = 1419558, upload-time = "2025-10-24T07:18:48.371Z" },
- { url = "https://files.pythonhosted.org/packages/f9/b4/55e885834c847ea610e111d87b9ed4768f0afdaeebc00cd46810f25029f6/lupa-2.6-cp313-cp313-win_amd64.whl", hash = "sha256:b1335a5835b0a25ebdbc75cf0bda195e54d133e4d994877ef025e218c2e59db9", size = 1683424, upload-time = "2025-10-24T07:18:50.976Z" },
- { url = "https://files.pythonhosted.org/packages/66/9d/d9427394e54d22a35d1139ef12e845fd700d4872a67a34db32516170b746/lupa-2.6-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:dcb6d0a3264873e1653bc188499f48c1fb4b41a779e315eba45256cfe7bc33c1", size = 953818, upload-time = "2025-10-24T07:18:53.378Z" },
- { url = "https://files.pythonhosted.org/packages/10/41/27bbe81953fb2f9ecfced5d9c99f85b37964cfaf6aa8453bb11283983721/lupa-2.6-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:a37e01f2128f8c36106726cb9d360bac087d58c54b4522b033cc5691c584db18", size = 1915850, upload-time = "2025-10-24T07:18:55.259Z" },
- { url = "https://files.pythonhosted.org/packages/a3/98/f9ff60db84a75ba8725506bbf448fb085bc77868a021998ed2a66d920568/lupa-2.6-cp314-cp314-macosx_11_0_x86_64.whl", hash = "sha256:458bd7e9ff3c150b245b0fcfbb9bd2593d1152ea7f0a7b91c1d185846da033fe", size = 982344, upload-time = "2025-10-24T07:18:57.05Z" },
- { url = "https://files.pythonhosted.org/packages/41/f7/f39e0f1c055c3b887d86b404aaf0ca197b5edfd235a8b81b45b25bac7fc3/lupa-2.6-cp314-cp314-manylinux2010_i686.manylinux_2_12_i686.manylinux_2_28_i686.whl", hash = "sha256:052ee82cac5206a02df77119c325339acbc09f5ce66967f66a2e12a0f3211cad", size = 1156543, upload-time = "2025-10-24T07:18:59.251Z" },
- { url = "https://files.pythonhosted.org/packages/9e/9c/59e6cffa0d672d662ae17bd7ac8ecd2c89c9449dee499e3eb13ca9cd10d9/lupa-2.6-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96594eca3c87dd07938009e95e591e43d554c1dbd0385be03c100367141db5a8", size = 1047974, upload-time = "2025-10-24T07:19:01.449Z" },
- { url = "https://files.pythonhosted.org/packages/23/c6/a04e9cef7c052717fcb28fb63b3824802488f688391895b618e39be0f684/lupa-2.6-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e8faddd9d198688c8884091173a088a8e920ecc96cda2ffed576a23574c4b3f6", size = 2073458, upload-time = "2025-10-24T07:19:03.369Z" },
- { url = "https://files.pythonhosted.org/packages/e6/10/824173d10f38b51fc77785228f01411b6ca28826ce27404c7c912e0e442c/lupa-2.6-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:daebb3a6b58095c917e76ba727ab37b27477fb926957c825205fbda431552134", size = 1067683, upload-time = "2025-10-24T07:19:06.2Z" },
- { url = "https://files.pythonhosted.org/packages/b6/dc/9692fbcf3c924d9c4ece2d8d2f724451ac2e09af0bd2a782db1cef34e799/lupa-2.6-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:f3154e68972befe0f81564e37d8142b5d5d79931a18309226a04ec92487d4ea3", size = 1171892, upload-time = "2025-10-24T07:19:08.544Z" },
- { url = "https://files.pythonhosted.org/packages/84/ff/e318b628d4643c278c96ab3ddea07fc36b075a57383c837f5b11e537ba9d/lupa-2.6-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:e4dadf77b9fedc0bfa53417cc28dc2278a26d4cbd95c29f8927ad4d8fe0a7ef9", size = 2166641, upload-time = "2025-10-24T07:19:10.485Z" },
- { url = "https://files.pythonhosted.org/packages/12/f7/a6f9ec2806cf2d50826980cdb4b3cffc7691dc6f95e13cc728846d5cb793/lupa-2.6-cp314-cp314-win32.whl", hash = "sha256:cb34169c6fa3bab3e8ac58ca21b8a7102f6a94b6a5d08d3636312f3f02fafd8f", size = 1456857, upload-time = "2025-10-24T07:19:37.989Z" },
- { url = "https://files.pythonhosted.org/packages/c5/de/df71896f25bdc18360fdfa3b802cd7d57d7fede41a0e9724a4625b412c85/lupa-2.6-cp314-cp314-win_amd64.whl", hash = "sha256:b74f944fe46c421e25d0f8692aef1e842192f6f7f68034201382ac440ef9ea67", size = 1731191, upload-time = "2025-10-24T07:19:40.281Z" },
- { url = "https://files.pythonhosted.org/packages/47/3c/a1f23b01c54669465f5f4c4083107d496fbe6fb45998771420e9aadcf145/lupa-2.6-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:0e21b716408a21ab65723f8841cf7f2f37a844b7a965eeabb785e27fca4099cf", size = 999343, upload-time = "2025-10-24T07:19:12.519Z" },
- { url = "https://files.pythonhosted.org/packages/c5/6d/501994291cb640bfa2ccf7f554be4e6914afa21c4026bd01bff9ca8aac57/lupa-2.6-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:589db872a141bfff828340079bbdf3e9a31f2689f4ca0d88f97d9e8c2eae6142", size = 2000730, upload-time = "2025-10-24T07:19:14.869Z" },
- { url = "https://files.pythonhosted.org/packages/53/a5/457ffb4f3f20469956c2d4c4842a7675e884efc895b2f23d126d23e126cc/lupa-2.6-cp314-cp314t-macosx_11_0_x86_64.whl", hash = "sha256:cd852a91a4a9d4dcbb9a58100f820a75a425703ec3e3f049055f60b8533b7953", size = 1021553, upload-time = "2025-10-24T07:19:17.123Z" },
- { url = "https://files.pythonhosted.org/packages/51/6b/36bb5a5d0960f2a5c7c700e0819abb76fd9bf9c1d8a66e5106416d6e9b14/lupa-2.6-cp314-cp314t-manylinux2010_i686.manylinux_2_12_i686.manylinux_2_28_i686.whl", hash = "sha256:0334753be028358922415ca97a64a3048e4ed155413fc4eaf87dd0a7e2752983", size = 1133275, upload-time = "2025-10-24T07:19:20.51Z" },
- { url = "https://files.pythonhosted.org/packages/19/86/202ff4429f663013f37d2229f6176ca9f83678a50257d70f61a0a97281bf/lupa-2.6-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:661d895cd38c87658a34780fac54a690ec036ead743e41b74c3fb81a9e65a6aa", size = 1038441, upload-time = "2025-10-24T07:19:22.509Z" },
- { url = "https://files.pythonhosted.org/packages/a7/42/d8125f8e420714e5b52e9c08d88b5329dfb02dcca731b4f21faaee6cc5b5/lupa-2.6-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6aa58454ccc13878cc177c62529a2056be734da16369e451987ff92784994ca7", size = 2058324, upload-time = "2025-10-24T07:19:24.979Z" },
- { url = "https://files.pythonhosted.org/packages/2b/2c/47bf8b84059876e877a339717ddb595a4a7b0e8740bacae78ba527562e1c/lupa-2.6-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:1425017264e470c98022bba8cff5bd46d054a827f5df6b80274f9cc71dafd24f", size = 1060250, upload-time = "2025-10-24T07:19:27.262Z" },
- { url = "https://files.pythonhosted.org/packages/c2/06/d88add2b6406ca1bdec99d11a429222837ca6d03bea42ca75afa169a78cb/lupa-2.6-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:224af0532d216e3105f0a127410f12320f7c5f1aa0300bdf9646b8d9afb0048c", size = 1151126, upload-time = "2025-10-24T07:19:29.522Z" },
- { url = "https://files.pythonhosted.org/packages/b4/a0/89e6a024c3b4485b89ef86881c9d55e097e7cb0bdb74efb746f2fa6a9a76/lupa-2.6-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:9abb98d5a8fd27c8285302e82199f0e56e463066f88f619d6594a450bf269d80", size = 2153693, upload-time = "2025-10-24T07:19:31.379Z" },
- { url = "https://files.pythonhosted.org/packages/b6/36/a0f007dc58fc1bbf51fb85dcc82fcb1f21b8c4261361de7dab0e3d8521ef/lupa-2.6-cp314-cp314t-win32.whl", hash = "sha256:1849efeba7a8f6fb8aa2c13790bee988fd242ae404bd459509640eeea3d1e291", size = 1590104, upload-time = "2025-10-24T07:19:33.514Z" },
- { url = "https://files.pythonhosted.org/packages/7d/5e/db903ce9cf82c48d6b91bf6d63ae4c8d0d17958939a4e04ba6b9f38b8643/lupa-2.6-cp314-cp314t-win_amd64.whl", hash = "sha256:fc1498d1a4fc028bc521c26d0fad4ca00ed63b952e32fb95949bda76a04bad52", size = 1913818, upload-time = "2025-10-24T07:19:36.039Z" },
-]
-
[[package]]
name = "markdown-it-py"
-version = "4.0.0"
+version = "4.2.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "mdurl" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/5b/f5/4ec618ed16cc4f8fb3b701563655a69816155e79e24a17b651541804721d/markdown_it_py-4.0.0.tar.gz", hash = "sha256:cb0a2b4aa34f932c007117b194e945bd74e0ec24133ceb5bac59009cda1cb9f3", size = 73070, upload-time = "2025-08-11T12:57:52.854Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/06/ff/7841249c247aa650a76b9ee4bbaeae59370dc8bfd2f6c01f3630c35eb134/markdown_it_py-4.2.0.tar.gz", hash = "sha256:04a21681d6fbb623de53f6f364d352309d4094dd4194040a10fd51833e418d49", size = 82454, upload-time = "2026-05-07T12:08:28.36Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/94/54/e7d793b573f298e1c9013b8c4dade17d481164aa517d1d7148619c2cedbf/markdown_it_py-4.0.0-py3-none-any.whl", hash = "sha256:87327c59b172c5011896038353a81343b6754500a08cd7a4973bb48c6d578147", size = 87321, upload-time = "2025-08-11T12:57:51.923Z" },
+ { url = "https://files.pythonhosted.org/packages/b3/81/4da04ced5a082363ecfa159c010d200ecbd959ae410c10c0264a38cac0f5/markdown_it_py-4.2.0-py3-none-any.whl", hash = "sha256:9f7ebbcd14fe59494226453aed97c1070d83f8d24b6fc3a3bcf9a38092641c4a", size = 91687, upload-time = "2026-05-07T12:08:27.182Z" },
]
[[package]]
name = "mcp"
-version = "1.26.0"
+version = "1.27.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio" },
@@ -637,9 +590,9 @@ dependencies = [
{ name = "typing-inspection" },
{ name = "uvicorn", marker = "sys_platform != 'emscripten'" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/fc/6d/62e76bbb8144d6ed86e202b5edd8a4cb631e7c8130f3f4893c3f90262b10/mcp-1.26.0.tar.gz", hash = "sha256:db6e2ef491eecc1a0d93711a76f28dec2e05999f93afd48795da1c1137142c66", size = 608005, upload-time = "2026-01-24T19:40:32.468Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/38/83/d1efe7c2980d8a3afa476f4e3d42d53dd54c0ab94c27bee5d755b45c8b73/mcp-1.27.1.tar.gz", hash = "sha256:0f47e1820f8f8f941466b39749eb1d1839a04caddca2bc60e9d46e8a99914924", size = 608458, upload-time = "2026-05-08T16:50:12.601Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/fd/d9/eaa1f80170d2b7c5ba23f3b59f766f3a0bb41155fbc32a69adfa1adaaef9/mcp-1.26.0-py3-none-any.whl", hash = "sha256:904a21c33c25aa98ddbeb47273033c435e595bbacfdb177f4bd87f6dceebe1ca", size = 233615, upload-time = "2026-01-24T19:40:30.652Z" },
+ { url = "https://files.pythonhosted.org/packages/fd/73/42d9596facebdb533b7f0b86c1b0364ef350d1f8ba78b1052e8a58b48b65/mcp-1.27.1-py3-none-any.whl", hash = "sha256:1af3c4203b329430fde7a87b4fcb6392a041f5cb851fd68fc674016ab4e7c06f", size = 216260, upload-time = "2026-05-08T16:50:10.547Z" },
]
[[package]]
@@ -653,11 +606,11 @@ wheels = [
[[package]]
name = "more-itertools"
-version = "10.8.0"
+version = "11.0.2"
source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/ea/5d/38b681d3fce7a266dd9ab73c66959406d565b3e85f21d5e66e1181d93721/more_itertools-10.8.0.tar.gz", hash = "sha256:f638ddf8a1a0d134181275fb5d58b086ead7c6a72429ad725c67503f13ba30bd", size = 137431, upload-time = "2025-09-02T15:23:11.018Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/a2/f7/139d22fef48ac78127d18e01d80cf1be40236ae489769d17f35c3d425293/more_itertools-11.0.2.tar.gz", hash = "sha256:392a9e1e362cbc106a2457d37cabf9b36e5e12efd4ebff1654630e76597df804", size = 144659, upload-time = "2026-04-09T15:01:33.297Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/a4/8e/469e5a4a2f5855992e425f3cb33804cc07bf18d48f2db061aec61ce50270/more_itertools-10.8.0-py3-none-any.whl", hash = "sha256:52d4362373dcf7c52546bc4af9a86ee7c4579df9a8dc268be0a2f949d376cc9b", size = 69667, upload-time = "2025-09-02T15:23:09.635Z" },
+ { url = "https://files.pythonhosted.org/packages/cb/98/6af411189d9413534c3eb691182bff1f5c6d44ed2f93f2edfe52a1bbceb8/more_itertools-11.0.2-py3-none-any.whl", hash = "sha256:6e35b35f818b01f691643c6c611bc0902f2e92b46c18fffa77ae1e7c46e912e4", size = 71939, upload-time = "2026-04-09T15:01:32.21Z" },
]
[[package]]
@@ -674,15 +627,15 @@ wheels = [
[[package]]
name = "opentelemetry-api"
-version = "1.39.1"
+version = "1.41.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "importlib-metadata" },
{ name = "typing-extensions" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/97/b9/3161be15bb8e3ad01be8be5a968a9237c3027c5be504362ff800fca3e442/opentelemetry_api-1.39.1.tar.gz", hash = "sha256:fbde8c80e1b937a2c61f20347e91c0c18a1940cecf012d62e65a7caf08967c9c", size = 65767, upload-time = "2025-12-11T13:32:39.182Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/fa/fc/b7564cbef36601aef0d6c9bc01f7badb64be8e862c2e1c3c5c3b43b53e4f/opentelemetry_api-1.41.1.tar.gz", hash = "sha256:0ad1814d73b875f84494387dae86ce0b12c68556331ce6ce8fe789197c949621", size = 71416, upload-time = "2026-04-24T13:15:38.262Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/cf/df/d3f1ddf4bb4cb50ed9b1139cc7b1c54c34a1e7ce8fd1b9a37c0d1551a6bd/opentelemetry_api-1.39.1-py3-none-any.whl", hash = "sha256:2edd8463432a7f8443edce90972169b195e7d6a05500cd29e6d13898187c9950", size = 66356, upload-time = "2025-12-11T13:32:17.304Z" },
+ { url = "https://files.pythonhosted.org/packages/29/59/3e7118ed140f76b0982ba4321bdaed1997a0473f9720de2d10788a577033/opentelemetry_api-1.41.1-py3-none-any.whl", hash = "sha256:a22df900e75c76dc08440710e51f52f1aa6b451b429298896023e60db5b3139f", size = 69007, upload-time = "2026-04-24T13:15:15.662Z" },
]
[[package]]
@@ -696,29 +649,20 @@ wheels = [
[[package]]
name = "pathable"
-version = "0.4.4"
+version = "0.5.0"
source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/67/93/8f2c2075b180c12c1e9f6a09d1a985bc2036906b13dff1d8917e395f2048/pathable-0.4.4.tar.gz", hash = "sha256:6905a3cd17804edfac7875b5f6c9142a218c7caef78693c2dbbbfbac186d88b2", size = 8124, upload-time = "2025-01-10T18:43:13.247Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/72/55/b748445cb4ea6b125626f15379be7c96d1035d4fa3e8fee362fa92298abf/pathable-0.5.0.tar.gz", hash = "sha256:d81938348a1cacb525e7c75166270644782c0fb9c8cecc16be033e71427e0ef1", size = 16655, upload-time = "2026-02-20T08:47:00.748Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/7d/eb/b6260b31b1a96386c0a880edebe26f89669098acea8e0318bff6adb378fd/pathable-0.4.4-py3-none-any.whl", hash = "sha256:5ae9e94793b6ef5a4cbe0a7ce9dbbefc1eec38df253763fd0aeeacf2762dbbc2", size = 9592, upload-time = "2025-01-10T18:43:11.88Z" },
-]
-
-[[package]]
-name = "pathvalidate"
-version = "3.3.1"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/fa/2a/52a8da6fe965dea6192eb716b357558e103aea0a1e9a8352ad575a8406ca/pathvalidate-3.3.1.tar.gz", hash = "sha256:b18c07212bfead624345bb8e1d6141cdcf15a39736994ea0b94035ad2b1ba177", size = 63262, upload-time = "2025-06-15T09:07:20.736Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/9a/70/875f4a23bfc4731703a5835487d0d2fb999031bd415e7d17c0ae615c18b7/pathvalidate-3.3.1-py3-none-any.whl", hash = "sha256:5263baab691f8e1af96092fa5137ee17df5bdfbd6cff1fcac4d6ef4bc2e1735f", size = 24305, upload-time = "2025-06-15T09:07:19.117Z" },
+ { url = "https://files.pythonhosted.org/packages/52/96/5a770e5c461462575474468e5af931cff9de036e7c2b4fea23c1c58d2cbe/pathable-0.5.0-py3-none-any.whl", hash = "sha256:646e3d09491a6351a0c82632a09c02cdf70a252e73196b36d8a15ba0a114f0a6", size = 16867, upload-time = "2026-02-20T08:46:59.536Z" },
]
[[package]]
name = "platformdirs"
-version = "4.5.0"
+version = "4.9.6"
source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/61/33/9611380c2bdb1225fdef633e2a9610622310fed35ab11dac9620972ee088/platformdirs-4.5.0.tar.gz", hash = "sha256:70ddccdd7c99fc5942e9fc25636a8b34d04c24b335100223152c2803e4063312", size = 21632, upload-time = "2025-10-08T17:44:48.791Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/9f/4a/0883b8e3802965322523f0b200ecf33d31f10991d0401162f4b23c698b42/platformdirs-4.9.6.tar.gz", hash = "sha256:3bfa75b0ad0db84096ae777218481852c0ebc6c727b3168c1b9e0118e458cf0a", size = 29400, upload-time = "2026-04-09T00:04:10.812Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/73/cb/ac7874b3e5d58441674fb70742e6c374b28b0c7cb988d37d991cde47166c/platformdirs-4.5.0-py3-none-any.whl", hash = "sha256:e578a81bb873cbb89a41fcc904c7ef523cc18284b7e3b3ccf06aca1403b7ebd3", size = 18651, upload-time = "2025-10-08T17:44:47.223Z" },
+ { url = "https://files.pythonhosted.org/packages/75/a6/a0a304dc33b49145b21f4808d763822111e67d1c3a32b524a1baf947b6e1/platformdirs-4.9.6-py3-none-any.whl", hash = "sha256:e61adb1d5e5cb3441b4b7710bea7e4c12250ca49439228cc1021c00dcfac0917", size = 21348, upload-time = "2026-04-09T00:04:09.463Z" },
]
[[package]]
@@ -730,18 +674,9 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
]
-[[package]]
-name = "prometheus-client"
-version = "0.24.1"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/f0/58/a794d23feb6b00fc0c72787d7e87d872a6730dd9ed7c7b3e954637d8f280/prometheus_client-0.24.1.tar.gz", hash = "sha256:7e0ced7fbbd40f7b84962d5d2ab6f17ef88a72504dcf7c0b40737b43b2a461f9", size = 85616, upload-time = "2026-01-14T15:26:26.965Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/74/c3/24a2f845e3917201628ecaba4f18bab4d18a337834c1df2a159ee9d22a42/prometheus_client-0.24.1-py3-none-any.whl", hash = "sha256:150db128af71a5c2482b36e588fc8a6b95e498750da4b17065947c16070f4055", size = 64057, upload-time = "2026-01-14T15:26:24.42Z" },
-]
-
[[package]]
name = "prowler-mcp"
-version = "0.5.0"
+version = "0.8.0"
source = { editable = "." }
dependencies = [
{ name = "fastmcp" },
@@ -750,35 +685,43 @@ dependencies = [
[package.dev-dependencies]
dev = [
+ { name = "bandit" },
{ name = "pytest" },
+ { name = "ruff" },
+ { name = "vulture" },
]
[package.metadata]
requires-dist = [
- { name = "fastmcp", specifier = "==2.14.0" },
+ { name = "fastmcp", specifier = "==3.2.4" },
{ name = "httpx", specifier = "==0.28.1" },
]
[package.metadata.requires-dev]
-dev = [{ name = "pytest", specifier = "==8.3.5" }]
+dev = [
+ { name = "bandit", specifier = "==1.8.3" },
+ { name = "pytest", specifier = "==9.0.3" },
+ { name = "ruff", specifier = "==0.15.11" },
+ { name = "vulture", specifier = "==2.14" },
+]
[[package]]
name = "py-key-value-aio"
-version = "0.3.0"
+version = "0.4.4"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "beartype" },
- { name = "py-key-value-shared" },
+ { name = "typing-extensions" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/93/ce/3136b771dddf5ac905cc193b461eb67967cf3979688c6696e1f2cdcde7ea/py_key_value_aio-0.3.0.tar.gz", hash = "sha256:858e852fcf6d696d231266da66042d3355a7f9871650415feef9fca7a6cd4155", size = 50801, upload-time = "2025-11-17T16:50:04.711Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/04/3c/0397c072a38d4bc580994b42e0c90c5f44f679303489e4376289534735e5/py_key_value_aio-0.4.4.tar.gz", hash = "sha256:e3012e6243ed7cc09bb05457bd4d03b1ba5c2b1ca8700096b3927db79ffbbe55", size = 92300, upload-time = "2026-02-16T21:21:43.245Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/99/10/72f6f213b8f0bce36eff21fda0a13271834e9eeff7f9609b01afdc253c79/py_key_value_aio-0.3.0-py3-none-any.whl", hash = "sha256:1c781915766078bfd608daa769fefb97e65d1d73746a3dfb640460e322071b64", size = 96342, upload-time = "2025-11-17T16:50:03.801Z" },
+ { url = "https://files.pythonhosted.org/packages/32/69/f1b537ee70b7def42d63124a539ed3026a11a3ffc3086947a1ca6e861868/py_key_value_aio-0.4.4-py3-none-any.whl", hash = "sha256:18e17564ecae61b987f909fc2cd41ee2012c84b4b1dcb8c055cf8b4bc1bf3f5d", size = 152291, upload-time = "2026-02-16T21:21:44.241Z" },
]
[package.optional-dependencies]
-disk = [
- { name = "diskcache" },
- { name = "pathvalidate" },
+filetree = [
+ { name = "aiofile" },
+ { name = "anyio" },
]
keyring = [
{ name = "keyring" },
@@ -786,35 +729,19 @@ keyring = [
memory = [
{ name = "cachetools" },
]
-redis = [
- { name = "redis" },
-]
-
-[[package]]
-name = "py-key-value-shared"
-version = "0.3.0"
-source = { registry = "https://pypi.org/simple" }
-dependencies = [
- { name = "beartype" },
- { name = "typing-extensions" },
-]
-sdist = { url = "https://files.pythonhosted.org/packages/7b/e4/1971dfc4620a3a15b4579fe99e024f5edd6e0967a71154771a059daff4db/py_key_value_shared-0.3.0.tar.gz", hash = "sha256:8fdd786cf96c3e900102945f92aa1473138ebe960ef49da1c833790160c28a4b", size = 11666, upload-time = "2025-11-17T16:50:06.849Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/51/e4/b8b0a03ece72f47dce2307d36e1c34725b7223d209fc679315ffe6a4e2c3/py_key_value_shared-0.3.0-py3-none-any.whl", hash = "sha256:5b0efba7ebca08bb158b1e93afc2f07d30b8f40c2fc12ce24a4c0d84f42f9298", size = 19560, upload-time = "2025-11-17T16:50:05.954Z" },
-]
[[package]]
name = "pycparser"
-version = "2.23"
+version = "3.0"
source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/fe/cf/d2d3b9f5699fb1e4615c8e32ff220203e43b248e1dfcc6736ad9057731ca/pycparser-2.23.tar.gz", hash = "sha256:78816d4f24add8f10a06d6f05b4d424ad9e96cfebf68a4ddc99c65c0720d00c2", size = 173734, upload-time = "2025-09-09T13:23:47.91Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492, upload-time = "2026-01-21T14:26:51.89Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/a0/e3/59cd50310fc9b59512193629e1984c1f95e5c8ae6e5d8c69532ccc65a7fe/pycparser-2.23-py3-none-any.whl", hash = "sha256:e5c6e8d3fbad53479cab09ac03729e0a9faf2bee3db8208a550daf5af81a5934", size = 118140, upload-time = "2025-09-09T13:23:46.651Z" },
+ { url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" },
]
[[package]]
name = "pydantic"
-version = "2.11.9"
+version = "2.13.4"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "annotated-types" },
@@ -822,9 +749,9 @@ dependencies = [
{ name = "typing-extensions" },
{ name = "typing-inspection" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/ff/5d/09a551ba512d7ca404d785072700d3f6727a02f6f3c24ecfd081c7cf0aa8/pydantic-2.11.9.tar.gz", hash = "sha256:6b8ffda597a14812a7975c90b82a8a2e777d9257aba3453f973acd3c032a18e2", size = 788495, upload-time = "2025-09-13T11:26:39.325Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/18/a5/b60d21ac674192f8ab0ba4e9fd860690f9b4a6e51ca5df118733b487d8d6/pydantic-2.13.4.tar.gz", hash = "sha256:c40756b57adaa8b1efeeced5c196f3f3b7c435f90e84ea7f443901bec8099ef6", size = 844775, upload-time = "2026-05-06T13:43:05.343Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/3e/d3/108f2006987c58e76691d5ae5d200dd3e0f532cb4e5fa3560751c3a1feba/pydantic-2.11.9-py3-none-any.whl", hash = "sha256:c42dd626f5cfc1c6950ce6205ea58c93efa406da65f479dcb4029d5934857da2", size = 444855, upload-time = "2025-09-13T11:26:36.909Z" },
+ { url = "https://files.pythonhosted.org/packages/fd/7b/122376b1fd3c62c1ed9dc80c931ace4844b3c55407b6fb2d199377c9736f/pydantic-2.13.4-py3-none-any.whl", hash = "sha256:45a282cde31d808236fd7ea9d919b128653c8b38b393d1c4ab335c62924d9aba", size = 472262, upload-time = "2026-05-06T13:43:02.641Z" },
]
[package.optional-dependencies]
@@ -834,79 +761,91 @@ email = [
[[package]]
name = "pydantic-core"
-version = "2.33.2"
+version = "2.46.4"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "typing-extensions" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/ad/88/5f2260bdfae97aabf98f1778d43f69574390ad787afb646292a638c923d4/pydantic_core-2.33.2.tar.gz", hash = "sha256:7cb8bc3605c29176e1b105350d2e6474142d7c1bd1d9327c4a9bdb46bf827acc", size = 435195, upload-time = "2025-04-23T18:33:52.104Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/9d/56/921726b776ace8d8f5db44c4ef961006580d91dc52b803c489fafd1aa249/pydantic_core-2.46.4.tar.gz", hash = "sha256:62f875393d7f270851f20523dd2e29f082bcc82292d66db2b64ea71f64b6e1c1", size = 471464, upload-time = "2026-05-06T13:37:06.98Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/18/8a/2b41c97f554ec8c71f2a8a5f85cb56a8b0956addfe8b0efb5b3d77e8bdc3/pydantic_core-2.33.2-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:a7ec89dc587667f22b6a0b6579c249fca9026ce7c333fc142ba42411fa243cdc", size = 2009000, upload-time = "2025-04-23T18:31:25.863Z" },
- { url = "https://files.pythonhosted.org/packages/a1/02/6224312aacb3c8ecbaa959897af57181fb6cf3a3d7917fd44d0f2917e6f2/pydantic_core-2.33.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:3c6db6e52c6d70aa0d00d45cdb9b40f0433b96380071ea80b09277dba021ddf7", size = 1847996, upload-time = "2025-04-23T18:31:27.341Z" },
- { url = "https://files.pythonhosted.org/packages/d6/46/6dcdf084a523dbe0a0be59d054734b86a981726f221f4562aed313dbcb49/pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4e61206137cbc65e6d5256e1166f88331d3b6238e082d9f74613b9b765fb9025", size = 1880957, upload-time = "2025-04-23T18:31:28.956Z" },
- { url = "https://files.pythonhosted.org/packages/ec/6b/1ec2c03837ac00886ba8160ce041ce4e325b41d06a034adbef11339ae422/pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:eb8c529b2819c37140eb51b914153063d27ed88e3bdc31b71198a198e921e011", size = 1964199, upload-time = "2025-04-23T18:31:31.025Z" },
- { url = "https://files.pythonhosted.org/packages/2d/1d/6bf34d6adb9debd9136bd197ca72642203ce9aaaa85cfcbfcf20f9696e83/pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c52b02ad8b4e2cf14ca7b3d918f3eb0ee91e63b3167c32591e57c4317e134f8f", size = 2120296, upload-time = "2025-04-23T18:31:32.514Z" },
- { url = "https://files.pythonhosted.org/packages/e0/94/2bd0aaf5a591e974b32a9f7123f16637776c304471a0ab33cf263cf5591a/pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:96081f1605125ba0855dfda83f6f3df5ec90c61195421ba72223de35ccfb2f88", size = 2676109, upload-time = "2025-04-23T18:31:33.958Z" },
- { url = "https://files.pythonhosted.org/packages/f9/41/4b043778cf9c4285d59742281a769eac371b9e47e35f98ad321349cc5d61/pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8f57a69461af2a5fa6e6bbd7a5f60d3b7e6cebb687f55106933188e79ad155c1", size = 2002028, upload-time = "2025-04-23T18:31:39.095Z" },
- { url = "https://files.pythonhosted.org/packages/cb/d5/7bb781bf2748ce3d03af04d5c969fa1308880e1dca35a9bd94e1a96a922e/pydantic_core-2.33.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:572c7e6c8bb4774d2ac88929e3d1f12bc45714ae5ee6d9a788a9fb35e60bb04b", size = 2100044, upload-time = "2025-04-23T18:31:41.034Z" },
- { url = "https://files.pythonhosted.org/packages/fe/36/def5e53e1eb0ad896785702a5bbfd25eed546cdcf4087ad285021a90ed53/pydantic_core-2.33.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:db4b41f9bd95fbe5acd76d89920336ba96f03e149097365afe1cb092fceb89a1", size = 2058881, upload-time = "2025-04-23T18:31:42.757Z" },
- { url = "https://files.pythonhosted.org/packages/01/6c/57f8d70b2ee57fc3dc8b9610315949837fa8c11d86927b9bb044f8705419/pydantic_core-2.33.2-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:fa854f5cf7e33842a892e5c73f45327760bc7bc516339fda888c75ae60edaeb6", size = 2227034, upload-time = "2025-04-23T18:31:44.304Z" },
- { url = "https://files.pythonhosted.org/packages/27/b9/9c17f0396a82b3d5cbea4c24d742083422639e7bb1d5bf600e12cb176a13/pydantic_core-2.33.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:5f483cfb75ff703095c59e365360cb73e00185e01aaea067cd19acffd2ab20ea", size = 2234187, upload-time = "2025-04-23T18:31:45.891Z" },
- { url = "https://files.pythonhosted.org/packages/b0/6a/adf5734ffd52bf86d865093ad70b2ce543415e0e356f6cacabbc0d9ad910/pydantic_core-2.33.2-cp312-cp312-win32.whl", hash = "sha256:9cb1da0f5a471435a7bc7e439b8a728e8b61e59784b2af70d7c169f8dd8ae290", size = 1892628, upload-time = "2025-04-23T18:31:47.819Z" },
- { url = "https://files.pythonhosted.org/packages/43/e4/5479fecb3606c1368d496a825d8411e126133c41224c1e7238be58b87d7e/pydantic_core-2.33.2-cp312-cp312-win_amd64.whl", hash = "sha256:f941635f2a3d96b2973e867144fde513665c87f13fe0e193c158ac51bfaaa7b2", size = 1955866, upload-time = "2025-04-23T18:31:49.635Z" },
- { url = "https://files.pythonhosted.org/packages/0d/24/8b11e8b3e2be9dd82df4b11408a67c61bb4dc4f8e11b5b0fc888b38118b5/pydantic_core-2.33.2-cp312-cp312-win_arm64.whl", hash = "sha256:cca3868ddfaccfbc4bfb1d608e2ccaaebe0ae628e1416aeb9c4d88c001bb45ab", size = 1888894, upload-time = "2025-04-23T18:31:51.609Z" },
- { url = "https://files.pythonhosted.org/packages/46/8c/99040727b41f56616573a28771b1bfa08a3d3fe74d3d513f01251f79f172/pydantic_core-2.33.2-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:1082dd3e2d7109ad8b7da48e1d4710c8d06c253cbc4a27c1cff4fbcaa97a9e3f", size = 2015688, upload-time = "2025-04-23T18:31:53.175Z" },
- { url = "https://files.pythonhosted.org/packages/3a/cc/5999d1eb705a6cefc31f0b4a90e9f7fc400539b1a1030529700cc1b51838/pydantic_core-2.33.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f517ca031dfc037a9c07e748cefd8d96235088b83b4f4ba8939105d20fa1dcd6", size = 1844808, upload-time = "2025-04-23T18:31:54.79Z" },
- { url = "https://files.pythonhosted.org/packages/6f/5e/a0a7b8885c98889a18b6e376f344da1ef323d270b44edf8174d6bce4d622/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0a9f2c9dd19656823cb8250b0724ee9c60a82f3cdf68a080979d13092a3b0fef", size = 1885580, upload-time = "2025-04-23T18:31:57.393Z" },
- { url = "https://files.pythonhosted.org/packages/3b/2a/953581f343c7d11a304581156618c3f592435523dd9d79865903272c256a/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2b0a451c263b01acebe51895bfb0e1cc842a5c666efe06cdf13846c7418caa9a", size = 1973859, upload-time = "2025-04-23T18:31:59.065Z" },
- { url = "https://files.pythonhosted.org/packages/e6/55/f1a813904771c03a3f97f676c62cca0c0a4138654107c1b61f19c644868b/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1ea40a64d23faa25e62a70ad163571c0b342b8bf66d5fa612ac0dec4f069d916", size = 2120810, upload-time = "2025-04-23T18:32:00.78Z" },
- { url = "https://files.pythonhosted.org/packages/aa/c3/053389835a996e18853ba107a63caae0b9deb4a276c6b472931ea9ae6e48/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0fb2d542b4d66f9470e8065c5469ec676978d625a8b7a363f07d9a501a9cb36a", size = 2676498, upload-time = "2025-04-23T18:32:02.418Z" },
- { url = "https://files.pythonhosted.org/packages/eb/3c/f4abd740877a35abade05e437245b192f9d0ffb48bbbbd708df33d3cda37/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9fdac5d6ffa1b5a83bca06ffe7583f5576555e6c8b3a91fbd25ea7780f825f7d", size = 2000611, upload-time = "2025-04-23T18:32:04.152Z" },
- { url = "https://files.pythonhosted.org/packages/59/a7/63ef2fed1837d1121a894d0ce88439fe3e3b3e48c7543b2a4479eb99c2bd/pydantic_core-2.33.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:04a1a413977ab517154eebb2d326da71638271477d6ad87a769102f7c2488c56", size = 2107924, upload-time = "2025-04-23T18:32:06.129Z" },
- { url = "https://files.pythonhosted.org/packages/04/8f/2551964ef045669801675f1cfc3b0d74147f4901c3ffa42be2ddb1f0efc4/pydantic_core-2.33.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:c8e7af2f4e0194c22b5b37205bfb293d166a7344a5b0d0eaccebc376546d77d5", size = 2063196, upload-time = "2025-04-23T18:32:08.178Z" },
- { url = "https://files.pythonhosted.org/packages/26/bd/d9602777e77fc6dbb0c7db9ad356e9a985825547dce5ad1d30ee04903918/pydantic_core-2.33.2-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:5c92edd15cd58b3c2d34873597a1e20f13094f59cf88068adb18947df5455b4e", size = 2236389, upload-time = "2025-04-23T18:32:10.242Z" },
- { url = "https://files.pythonhosted.org/packages/42/db/0e950daa7e2230423ab342ae918a794964b053bec24ba8af013fc7c94846/pydantic_core-2.33.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:65132b7b4a1c0beded5e057324b7e16e10910c106d43675d9bd87d4f38dde162", size = 2239223, upload-time = "2025-04-23T18:32:12.382Z" },
- { url = "https://files.pythonhosted.org/packages/58/4d/4f937099c545a8a17eb52cb67fe0447fd9a373b348ccfa9a87f141eeb00f/pydantic_core-2.33.2-cp313-cp313-win32.whl", hash = "sha256:52fb90784e0a242bb96ec53f42196a17278855b0f31ac7c3cc6f5c1ec4811849", size = 1900473, upload-time = "2025-04-23T18:32:14.034Z" },
- { url = "https://files.pythonhosted.org/packages/a0/75/4a0a9bac998d78d889def5e4ef2b065acba8cae8c93696906c3a91f310ca/pydantic_core-2.33.2-cp313-cp313-win_amd64.whl", hash = "sha256:c083a3bdd5a93dfe480f1125926afcdbf2917ae714bdb80b36d34318b2bec5d9", size = 1955269, upload-time = "2025-04-23T18:32:15.783Z" },
- { url = "https://files.pythonhosted.org/packages/f9/86/1beda0576969592f1497b4ce8e7bc8cbdf614c352426271b1b10d5f0aa64/pydantic_core-2.33.2-cp313-cp313-win_arm64.whl", hash = "sha256:e80b087132752f6b3d714f041ccf74403799d3b23a72722ea2e6ba2e892555b9", size = 1893921, upload-time = "2025-04-23T18:32:18.473Z" },
- { url = "https://files.pythonhosted.org/packages/a4/7d/e09391c2eebeab681df2b74bfe6c43422fffede8dc74187b2b0bf6fd7571/pydantic_core-2.33.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:61c18fba8e5e9db3ab908620af374db0ac1baa69f0f32df4f61ae23f15e586ac", size = 1806162, upload-time = "2025-04-23T18:32:20.188Z" },
- { url = "https://files.pythonhosted.org/packages/f1/3d/847b6b1fed9f8ed3bb95a9ad04fbd0b212e832d4f0f50ff4d9ee5a9f15cf/pydantic_core-2.33.2-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:95237e53bb015f67b63c91af7518a62a8660376a6a0db19b89acc77a4d6199f5", size = 1981560, upload-time = "2025-04-23T18:32:22.354Z" },
- { url = "https://files.pythonhosted.org/packages/6f/9a/e73262f6c6656262b5fdd723ad90f518f579b7bc8622e43a942eec53c938/pydantic_core-2.33.2-cp313-cp313t-win_amd64.whl", hash = "sha256:c2fc0a768ef76c15ab9238afa6da7f69895bb5d1ee83aeea2e3509af4472d0b9", size = 1935777, upload-time = "2025-04-23T18:32:25.088Z" },
+ { url = "https://files.pythonhosted.org/packages/ce/8c/af022f0af448d7747c5154288d46b5f2bc5f17366eaa0e23e9aa04d59f3b/pydantic_core-2.46.4-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:3245406455a5d98187ec35530fd772b1d799b26667980872c8d4614991e2c4a2", size = 2106158, upload-time = "2026-05-06T13:38:57.215Z" },
+ { url = "https://files.pythonhosted.org/packages/19/95/6195171e385007300f0f5574592e467c568becce2d937a0b6804f218bc49/pydantic_core-2.46.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:962ccbab7b642487b1d8b7df90ef677e03134cf1fd8880bf698649b22a69371f", size = 1951724, upload-time = "2026-05-06T13:37:02.697Z" },
+ { url = "https://files.pythonhosted.org/packages/8e/bc/f47d1ff9cbb1620e1b5b697eef06010035735f07820180e74178226b27b3/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8233f2947cf85404441fd7e0085f53b10c93e0ee78611099b5c7237e36aacbf7", size = 1975742, upload-time = "2026-05-06T13:37:09.448Z" },
+ { url = "https://files.pythonhosted.org/packages/5b/11/9b9a5b0306345664a2da6410877af6e8082481b5884b3ddd78d47c6013ce/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3a233125ac121aa3ffba9a2b59edfc4a985a76092dc8279586ab4b71390875e7", size = 2052418, upload-time = "2026-05-06T13:37:38.234Z" },
+ { url = "https://files.pythonhosted.org/packages/f1/b7/a65fec226f5d78fc39f4a13c4cc0c768c22b113438f60c14adc9d2865038/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5b712b53160b79a5850310b912a5ef8e57e56947c8ad690c227f5c9d7e561712", size = 2232274, upload-time = "2026-05-06T13:38:27.753Z" },
+ { url = "https://files.pythonhosted.org/packages/68/f0/92039db98b907ef49269a8271f67db9cb78ae2fc68062ef7e4e77adb5f61/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9401557acd873c3a7f3eb9383edef8ac4968f9510e340f4808d427e75667e7b4", size = 2309940, upload-time = "2026-05-06T13:38:05.353Z" },
+ { url = "https://files.pythonhosted.org/packages/5f/97/2aab507d3d00ca626e8e57c1eac6a79e4e5fbcc63eb99733ff55d1717f65/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:926c9541b14b12b1681dca8a0b75feb510b06c6341b70a8e500c2fdcff837cce", size = 2094516, upload-time = "2026-05-06T13:39:10.577Z" },
+ { url = "https://files.pythonhosted.org/packages/22/37/a8aca44d40d737dde2bc05b3c6c07dff0de07ce6f82e9f3167aeaf4d5dea/pydantic_core-2.46.4-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:56cb4851bcaf3d117eddcef4fe66afd750a50274b0da8e22be256d10e5611987", size = 2136854, upload-time = "2026-05-06T13:40:22.59Z" },
+ { url = "https://files.pythonhosted.org/packages/24/99/fcef1b79238c06a8cbec70819ac722ba76e02bc8ada9b0fd66eba40da01b/pydantic_core-2.46.4-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c68fcd102d71ea85c5b2dfac3f4f8476eff42a9e078fd5faefff6d145063536b", size = 2180306, upload-time = "2026-05-06T13:40:10.666Z" },
+ { url = "https://files.pythonhosted.org/packages/ae/6c/fc44000918855b42779d007ae63b0532794739027b2f417321cddbc44f6a/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:b2f69dec1725e79a012d920df1707de5caf7ed5e08f3be4435e25803efc47458", size = 2190044, upload-time = "2026-05-06T13:40:43.231Z" },
+ { url = "https://files.pythonhosted.org/packages/6b/65/d9cadc9f1920d7a127ad2edba16c1db7916e59719285cd6c94600b0080ba/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:8d0820e8192167f80d88d64038e609c31452eeca865b4e1d9950a27a4609b00b", size = 2329133, upload-time = "2026-05-06T13:39:57.365Z" },
+ { url = "https://files.pythonhosted.org/packages/d0/cf/c873d91679f3a30bcf5e7ac280ce5573483e72295307685120d0d5ad3416/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:fbdb89b3e1c94a30cc5edfce477c6e6a5dc4d8f84665b455c27582f211a1c72c", size = 2374464, upload-time = "2026-05-06T13:38:06.976Z" },
+ { url = "https://files.pythonhosted.org/packages/47/bd/6f2fc8188f31bf10590f1e98e7b306336161fac930a8c514cd7bd828c7dc/pydantic_core-2.46.4-cp312-cp312-win32.whl", hash = "sha256:9aa768456404a8bf48a4406685ac2bec8e72b62c69313734fa3b73cf33b3a894", size = 1974823, upload-time = "2026-05-06T13:40:47.985Z" },
+ { url = "https://files.pythonhosted.org/packages/40/8c/985c1d41ea1107c2534abd9870e4ed5c8e7669b5c308297835c001e7a1c4/pydantic_core-2.46.4-cp312-cp312-win_amd64.whl", hash = "sha256:e9c26f834c65f5752f3f06cb08cb86a913ceb7274d0db6e267808a708b46bc89", size = 2072919, upload-time = "2026-05-06T13:39:21.153Z" },
+ { url = "https://files.pythonhosted.org/packages/c4/ba/f463d006e0c47373ca7ec5e1a261c59dc01ef4d62b2657af925fb0deee3a/pydantic_core-2.46.4-cp312-cp312-win_arm64.whl", hash = "sha256:4fc73cb559bdb54b1134a706a2802a4cddd27a0633f5abb7e53056268751ac6a", size = 2027604, upload-time = "2026-05-06T13:39:03.753Z" },
+ { url = "https://files.pythonhosted.org/packages/51/a2/5d30b469c5267a17b39dec53208222f76a8d351dfac4af661888c5aee77d/pydantic_core-2.46.4-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:5d5902252db0d3cedf8d4a1bc68f70eeb430f7e4c7104c8c476753519b423008", size = 2106306, upload-time = "2026-05-06T13:37:48.029Z" },
+ { url = "https://files.pythonhosted.org/packages/c1/81/4fa520eaffa8bd7d1525e644cd6d39e7d60b1592bc5b516693c7340b50f1/pydantic_core-2.46.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c94f0688e7b8d0a67abf40e57a7eaaecd17cc9586706a31b76c031f63df052b4", size = 1951906, upload-time = "2026-05-06T13:37:17.012Z" },
+ { url = "https://files.pythonhosted.org/packages/03/d5/fd02da45b659668b05923b17ba3a0100a0a3d5541e3bd8fcc4ecb711309e/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f027324c56cd5406ca49c124b0db10e56c69064fec039acc571c29020cc87c76", size = 1976802, upload-time = "2026-05-06T13:37:35.113Z" },
+ { url = "https://files.pythonhosted.org/packages/21/f2/95727e1368be3d3ed485eaab7adbd7dda408f33f7a36e8b48e0144002b91/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e739fee756ba1010f8bcccb534252e85a35fe45ae92c295a06059ce58b74ccd3", size = 2052446, upload-time = "2026-05-06T13:37:12.313Z" },
+ { url = "https://files.pythonhosted.org/packages/9c/86/5d99feea3f77c7234b8718075b23db11532773c1a0dbd9b9490215dc2eeb/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9d56801be94b86a9da183e5f3766e6310752b99ff647e38b09a9500d88e46e76", size = 2232757, upload-time = "2026-05-06T13:39:01.149Z" },
+ { url = "https://files.pythonhosted.org/packages/d2/3a/508ac615935ef7588cf6d9e9b91309fdc2da751af865e02a9098de88258c/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2412e734dcb48da14d4e4006b82b46b74f2518b8a26ee7e58c6844a6cd6d03c4", size = 2309275, upload-time = "2026-05-06T13:37:41.406Z" },
+ { url = "https://files.pythonhosted.org/packages/07/f8/41db9de19d7987d6b04715a02b3b40aea467000275d9d758ffaa31af7d50/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9551187363ffc0de2a00b2e47c25aeaeb1020b69b668762966df15fc5659dd5a", size = 2094467, upload-time = "2026-05-06T13:39:18.847Z" },
+ { url = "https://files.pythonhosted.org/packages/2c/e2/f35033184cb11d0052daf4416e8e10a502ea2ac006fc4f459aee872727d1/pydantic_core-2.46.4-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:0186750b482eefa11d7f435892b09c5c606193ef3375bcf94aa00ae6bfb66262", size = 2134417, upload-time = "2026-05-06T13:40:17.944Z" },
+ { url = "https://files.pythonhosted.org/packages/7e/7b/6ceeb1cc90e193862f444ebe373d8fdf613f0a82572dde03fb10734c6c71/pydantic_core-2.46.4-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5855698a4856556d86e8e6cd8434bc3ac0314ee8e12089ae0e143f64c6256e4e", size = 2179782, upload-time = "2026-05-06T13:40:32.618Z" },
+ { url = "https://files.pythonhosted.org/packages/5a/f2/c8d7773ede6af08036423a00ae0ceffce266c3c52a096c435d68c896083f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:cbaf13819775b7f769bf4a1f066cb6df7a28d4480081a589828ef190226881cd", size = 2188782, upload-time = "2026-05-06T13:36:51.018Z" },
+ { url = "https://files.pythonhosted.org/packages/59/31/0c864784e31f09f05cdd87606f08923b9c9e7f6e51dd27f20f62f975ce9f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:633147d34cf4550417f12e2b1a0383973bdf5cdfde212cb09e9a581cf10820be", size = 2328334, upload-time = "2026-05-06T13:40:37.764Z" },
+ { url = "https://files.pythonhosted.org/packages/c2/eb/4f6c8a41efa30baa755590f4141abf3a8c370fab610915733e74134a7270/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:82cf5301172168103724d49a1444d3378cb20cdee30b116a1bd6031236298a5d", size = 2372986, upload-time = "2026-05-06T13:39:34.152Z" },
+ { url = "https://files.pythonhosted.org/packages/5b/24/b375a480d53113860c299764bfe9f349a3dc9108b3adc0d7f0d786492ebf/pydantic_core-2.46.4-cp313-cp313-win32.whl", hash = "sha256:9fa8ae11da9e2b3126c6426f147e0fba88d96d65921799bb30c6abd1cb2c97fb", size = 1973693, upload-time = "2026-05-06T13:37:55.072Z" },
+ { url = "https://files.pythonhosted.org/packages/7e/e8/cff247591966f2d22ec8c003cd7587e27b7ba7b81ab2fb888e3ab75dc285/pydantic_core-2.46.4-cp313-cp313-win_amd64.whl", hash = "sha256:6b3ace8194b0e5204818c92802dcdca7fc6d88aabbb799d7c795540d9cd6d292", size = 2071819, upload-time = "2026-05-06T13:38:49.139Z" },
+ { url = "https://files.pythonhosted.org/packages/c6/1a/f4aee670d5670e9e148e0c82c7db98d780be566c6e6a97ee8035528ca0b3/pydantic_core-2.46.4-cp313-cp313-win_arm64.whl", hash = "sha256:184c081504d17f1c1066e430e117142b2c77d9448a97f7b65c6ac9fd9aee238d", size = 2027411, upload-time = "2026-05-06T13:40:45.796Z" },
+ { url = "https://files.pythonhosted.org/packages/8d/74/228a26ddad29c6672b805d9fd78e8d251cd04004fa7eed0e622096cd0250/pydantic_core-2.46.4-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:428e04521a40150c85216fc8b85e8d39fece235a9cf5e383761238c7fa9b96fb", size = 2102079, upload-time = "2026-05-06T13:38:41.019Z" },
+ { url = "https://files.pythonhosted.org/packages/ad/1f/8970b150a4b4365623ae00fc88603491f763c627311ae8031e3111356d6e/pydantic_core-2.46.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:23ace664830ee0bfe014a0c7bc248b1f7f25ed7ad103852c317624a1083af462", size = 1952179, upload-time = "2026-05-06T13:36:59.812Z" },
+ { url = "https://files.pythonhosted.org/packages/95/30/5211a831ae054928054b2f79731661087a2bc5c01e825c672b3a4a8f1b3e/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce5c1d2a8b27468f433ca974829c44060b8097eedc39933e3c206a90ee49c4a9", size = 1978926, upload-time = "2026-05-06T13:37:39.933Z" },
+ { url = "https://files.pythonhosted.org/packages/57/e9/689668733b1eb67adeef047db3c2e8788fcf65a7fd9c9e2b46b7744fe245/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7283d57845ecf5a163403eb0702dfc220cc4fbdd18919cb5ccea4f95ee1cdab4", size = 2046785, upload-time = "2026-05-06T13:38:01.995Z" },
+ { url = "https://files.pythonhosted.org/packages/60/d9/6715260422ff50a2109878fd24d948a6c3446bb2664f34ee78cd972b3acd/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8daafc69c93ee8a0204506a3b6b30f586ef54028f52aeeeb5c4cfc5184fd5914", size = 2228733, upload-time = "2026-05-06T13:40:50.371Z" },
+ { url = "https://files.pythonhosted.org/packages/18/ae/fdb2f64316afca925640f8e70bb1a564b0ec2721c1389e25b8eb4bf9a299/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cd2213145bcc2ba85884d0ac63d222fece9209678f77b9b4d76f054c561adb28", size = 2307534, upload-time = "2026-05-06T13:37:21.531Z" },
+ { url = "https://files.pythonhosted.org/packages/89/1d/8eff589b45bb8190a9d12c49cfad0f176a5cbd1534908a6b5125e2886239/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7a5f930472650a82629163023e630d160863fce524c616f4e5186e5de9d9a49b", size = 2099732, upload-time = "2026-05-06T13:39:31.942Z" },
+ { url = "https://files.pythonhosted.org/packages/06/d5/ee5a3366637fee41dee51a1fc91562dcf12ddbc68fda34e6b253da2324bb/pydantic_core-2.46.4-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:c1b3f518abeca3aa13c712fd202306e145abf59a18b094a6bafb2d2bbf59192c", size = 2129627, upload-time = "2026-05-06T13:37:25.033Z" },
+ { url = "https://files.pythonhosted.org/packages/94/33/2414be571d2c6a6c4d08be21f9292b6d3fdb08949a97b6dfe985017821db/pydantic_core-2.46.4-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1a7dd0b3ee80d90150e3495a3a13ac34dbcbfd4f012996a6a1d8900e91b5c0fb", size = 2179141, upload-time = "2026-05-06T13:37:14.046Z" },
+ { url = "https://files.pythonhosted.org/packages/7b/79/7daa95be995be0eecc4cf75064cb33f9bbbfe3fe0158caf2f0d4a996a5c7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:3fb702cd90b0446a3a1c5e470bfa0dd23c0233b676a9099ddcc964fa6ca13898", size = 2184325, upload-time = "2026-05-06T13:36:53.615Z" },
+ { url = "https://files.pythonhosted.org/packages/9f/cb/d0a382f5c0de8a222dc61c65348e0ce831b1f68e0a018450d31c2cace3a5/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:b8458003118a712e66286df6a707db01c52c0f52f7db8e4a38f0da1d3b94fc4e", size = 2323990, upload-time = "2026-05-06T13:40:29.971Z" },
+ { url = "https://files.pythonhosted.org/packages/05/db/d9ba624cc4a5aced1598e88c04fdbd8310c8a69b9d38b9a3d39ce3a61ed7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:372429a130e469c9cd698925ce5fc50940b7a1336b0d82038e63d5bbc4edc519", size = 2369978, upload-time = "2026-05-06T13:37:23.027Z" },
+ { url = "https://files.pythonhosted.org/packages/f2/20/d15df15ba918c423461905802bfd2981c3af0bfa0e40d05e13edbfa48bc3/pydantic_core-2.46.4-cp314-cp314-win32.whl", hash = "sha256:85bb3611ff1802f3ee7fdd7dbff26b56f343fb432d57a4728fdd49b6ef35e2f4", size = 1966354, upload-time = "2026-05-06T13:38:03.499Z" },
+ { url = "https://files.pythonhosted.org/packages/fc/b6/6b8de4c0a7d7ab3004c439c80c5c1e0a3e8d78bbae19379b01960383d9e5/pydantic_core-2.46.4-cp314-cp314-win_amd64.whl", hash = "sha256:811ff8e9c313ab425368bcbb36e5c4ebd7108c2bbf4e4089cfbb0b01eff63fac", size = 2072238, upload-time = "2026-05-06T13:39:40.807Z" },
+ { url = "https://files.pythonhosted.org/packages/32/36/51eb763beec1f4cf59b1db243a7dcc39cbb41230f050a09b9d69faaf0a48/pydantic_core-2.46.4-cp314-cp314-win_arm64.whl", hash = "sha256:bfec22eab3c8cc2ceec0248aec886624116dc079afa027ecc8ad4a7e62010f8a", size = 2018251, upload-time = "2026-05-06T13:37:26.72Z" },
+ { url = "https://files.pythonhosted.org/packages/e8/91/855af51d625b23aa987116a19e231d2aaef9c4a415273ddc189b79a45fee/pydantic_core-2.46.4-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:af8244b2bef6aaad6d92cda81372de7f8c8d36c9f0c3ea36e827c60e7d9467a0", size = 2099593, upload-time = "2026-05-06T13:39:47.682Z" },
+ { url = "https://files.pythonhosted.org/packages/fb/1b/8784a54c65edb5f49f0a14d6977cf1b209bba85a4c77445b255c2de58ab3/pydantic_core-2.46.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5a4330cdbc57162e4b3aa303f588ba752257694c9c9be3e7ebb11b4aca659b5d", size = 1935226, upload-time = "2026-05-06T13:40:40.428Z" },
+ { url = "https://files.pythonhosted.org/packages/e8/e7/1955d28d1afc56dd4b3ad7cc0cf39df1b9852964cf16e5d13912756d6d6b/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:29c61fc04a3d840155ff08e475a04809278972fe6aef51e2720554e96367e34b", size = 1974605, upload-time = "2026-05-06T13:37:32.029Z" },
+ { url = "https://files.pythonhosted.org/packages/93/e2/3fedbf0ba7a22850e6e9fd78117f1c0f10f950182344d8a6c535d468fdd8/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c50f2528cf200c5eed56faf3f4e22fcd5f38c157a8b78576e6ba3168ec35f000", size = 2030777, upload-time = "2026-05-06T13:38:55.239Z" },
+ { url = "https://files.pythonhosted.org/packages/f8/61/46be275fcaaba0b4f5b9669dd852267ce1ff616592dccf7a7845588df091/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0cbe8b01f948de4286c74cdd6c667aceb38f5c1e26f0693b3983d9d74887c65e", size = 2236641, upload-time = "2026-05-06T13:37:08.096Z" },
+ { url = "https://files.pythonhosted.org/packages/60/db/12e93e46a8bac9988be3c016860f83293daea8c716c029c9ace279036f2f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:617d7e2ca7dcb8c5cf6bcb8c59b8832c94b36196bbf1cbd1bfb56ed341905edd", size = 2286404, upload-time = "2026-05-06T13:40:20.221Z" },
+ { url = "https://files.pythonhosted.org/packages/e2/4a/4d8b19008f38d31c53b8219cfedc2e3d5de5fe99d90076b7e767de29274f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7027560ee92211647d0d34e3f7cd6f50da56399d26a9c8ad0da286d3869a53f3", size = 2109219, upload-time = "2026-05-06T13:38:12.153Z" },
+ { url = "https://files.pythonhosted.org/packages/88/70/3cbc40978fefb7bb09c6708d40d4ad1a5d70fd7213c3d17f971de868ec1f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:f99626688942fb746e545232e7726926f3be91b5975f8b55327665fafda991c7", size = 2110594, upload-time = "2026-05-06T13:40:02.971Z" },
+ { url = "https://files.pythonhosted.org/packages/9d/20/b8d36736216e29491125531685b2f9e61aa5b4b2599893f8268551da3338/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fc3e9034a63de20e15e8ade85358bc6efc614008cab72898b4b4952bea0509ff", size = 2159542, upload-time = "2026-05-06T13:39:27.506Z" },
+ { url = "https://files.pythonhosted.org/packages/1d/a2/367df868eb584dacf6bf82a389272406d7178e301c4ac82545ab98bc2dd9/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:97e7cf2be5c77b7d1a9713a05605d49460d02c6078d38d8bef3cbe323c548424", size = 2168146, upload-time = "2026-05-06T13:38:31.93Z" },
+ { url = "https://files.pythonhosted.org/packages/c1/b8/4460f77f7e201893f649a29ab355dddd3beee8a97bcb1a320db414f9a06e/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:3bf92c5d0e00fefaab325a4d27828fe6b6e2a21848686b5b60d2d9eeb09d76c6", size = 2306309, upload-time = "2026-05-06T13:37:44.717Z" },
+ { url = "https://files.pythonhosted.org/packages/64/c4/be2639293acd87dc8ddbcec41a73cee9b2ebf996fe6d892a1a74e88ad3f7/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:3ecbc122d18468d06ca279dc26a8c2e2d5acb10943bb35e36ae92096dc3b5565", size = 2369736, upload-time = "2026-05-06T13:37:05.645Z" },
+ { url = "https://files.pythonhosted.org/packages/30/a6/9f9f380dbb301f67023bf8f707aaa75daadf84f7152d95c410fd7e81d994/pydantic_core-2.46.4-cp314-cp314t-win32.whl", hash = "sha256:e846ae7835bf0703ae43f534ab79a867146dadd59dc9ca5c8b53d5c8f7c9ef02", size = 1955575, upload-time = "2026-05-06T13:38:51.116Z" },
+ { url = "https://files.pythonhosted.org/packages/40/1f/f1eb9eb350e795d1af8586289746f5c5677d16043040d63710e22abc43c9/pydantic_core-2.46.4-cp314-cp314t-win_amd64.whl", hash = "sha256:2108ba5c1c1eca18030634489dc544844144ee36357f2f9f780b93e7ddbb44b5", size = 2051624, upload-time = "2026-05-06T13:38:21.672Z" },
+ { url = "https://files.pythonhosted.org/packages/f6/d2/42dd53d0a85c27606f316d3aa5d2869c4e8470a5ed6dec30e4a1abe19192/pydantic_core-2.46.4-cp314-cp314t-win_arm64.whl", hash = "sha256:4fcbe087dbc2068af7eda3aa87634eba216dbda64d1ae73c8684b621d33f6596", size = 2017325, upload-time = "2026-05-06T13:40:52.723Z" },
+ { url = "https://files.pythonhosted.org/packages/9d/1d/8987ad40f65ae1432753072f214fb5c74fe47ffbd0698bb9cbbb585664f8/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:1d8ba486450b14f3b1d63bc521d410ec7565e52f887b9fb671791886436a42f7", size = 2095527, upload-time = "2026-05-06T13:39:52.283Z" },
+ { url = "https://files.pythonhosted.org/packages/64/d3/84c282a7eee1d3ac4c0377546ef5a1ea436ce26840d9ac3b7ed54a377507/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:3009f12e4e90b7f88b4f9adb1b0c4a3d58fe7820f3238c190047209d148026df", size = 1936024, upload-time = "2026-05-06T13:40:15.671Z" },
+ { url = "https://files.pythonhosted.org/packages/d7/ca/eac61596cdeb4d7e174d3dc0bd8a6238f14f75f97a24e7b7db4c7e7340a0/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ad785e92e6dc634c21555edc8bd6b64957ab844541bcb96a1366c202951ae526", size = 1990696, upload-time = "2026-05-06T13:38:34.717Z" },
+ { url = "https://files.pythonhosted.org/packages/fa/c3/7c8b240552251faf6b3a957db200fcfbbcec36763c050428b601e0c9b83b/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:00c603d540afdd6b80eb39f078f33ebd46211f02f33e34a32d9f053bba711de0", size = 2147590, upload-time = "2026-05-06T13:39:29.883Z" },
]
[[package]]
name = "pydantic-settings"
-version = "2.10.1"
+version = "2.14.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pydantic" },
{ name = "python-dotenv" },
{ name = "typing-inspection" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/68/85/1ea668bbab3c50071ca613c6ab30047fb36ab0da1b92fa8f17bbc38fd36c/pydantic_settings-2.10.1.tar.gz", hash = "sha256:06f0062169818d0f5524420a360d632d5857b83cffd4d42fe29597807a1614ee", size = 172583, upload-time = "2025-06-24T13:26:46.841Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/07/60/1d1e59c9c90d54591469ada7d268251f71c24bdb765f1a8a832cee8c6653/pydantic_settings-2.14.1.tar.gz", hash = "sha256:e874d3bec7e787b0c9958277956ed9b4dd5de6a80e162188fdaff7c5e26fd5fa", size = 235551, upload-time = "2026-05-08T13:40:06.542Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/58/f0/427018098906416f580e3cf1366d3b1abfb408a0652e9f31600c24a1903c/pydantic_settings-2.10.1-py3-none-any.whl", hash = "sha256:a60952460b99cf661dc25c29c0ef171721f98bfcb52ef8d9ea4c943d7c8cc796", size = 45235, upload-time = "2025-06-24T13:26:45.485Z" },
-]
-
-[[package]]
-name = "pydocket"
-version = "0.17.2"
-source = { registry = "https://pypi.org/simple" }
-dependencies = [
- { name = "cloudpickle" },
- { name = "fakeredis", extra = ["lua"] },
- { name = "opentelemetry-api" },
- { name = "prometheus-client" },
- { name = "py-key-value-aio", extra = ["memory", "redis"] },
- { name = "python-json-logger" },
- { name = "redis" },
- { name = "rich" },
- { name = "typer" },
- { name = "typing-extensions" },
-]
-sdist = { url = "https://files.pythonhosted.org/packages/90/17/1fb6309e40bbee999c5d881b8213a1078968412d855e064a9a94cfb9eeef/pydocket-0.17.2.tar.gz", hash = "sha256:8f02c68952701eb1b3a70d439b76392d15f1eb9568d0bde6a69997ea5c79c89f", size = 329829, upload-time = "2026-01-26T16:07:56.217Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/53/74/4c9b70753d5721165047e6428ac239ca083118474794deaca5a27d0ab212/pydocket-0.17.2-py3-none-any.whl", hash = "sha256:f43743b84b4e3d614d99b0cad2deebab028104c217745406ecf9e1efb8926e04", size = 91628, upload-time = "2026-01-26T16:07:55.018Z" },
+ { url = "https://files.pythonhosted.org/packages/ae/8d/f1af3832f5e6eb13ba94ee809e72b8ecb5eef226d27ee0bef7d963d943c7/pydantic_settings-2.14.1-py3-none-any.whl", hash = "sha256:6e3c7edfd8277687cdc598f56e5cff0e9bfff0910a3749deaa8d4401c3a2b9de", size = 60964, upload-time = "2026-05-08T13:40:04.958Z" },
]
[[package]]
@@ -920,11 +859,11 @@ wheels = [
[[package]]
name = "pyjwt"
-version = "2.12.1"
+version = "2.13.0"
source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/c2/27/a3b6e5bf6ff856d2509292e95c8f57f0df7017cf5394921fc4e4ef40308a/pyjwt-2.12.1.tar.gz", hash = "sha256:c74a7a2adf861c04d002db713dd85f84beb242228e671280bf709d765b03672b", size = 102564, upload-time = "2026-03-13T19:27:37.25Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/3b/81/58d0ac84e1ef3a3843791d6954d94c0b33d526c75eeb1efbce9d0a4c4077/pyjwt-2.13.0.tar.gz", hash = "sha256:41571c89ca91598c79e8ef18a2d07367d4810fbbd6f637794879baf1b7703423", size = 107515, upload-time = "2026-05-21T19:54:36.618Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/e5/7a/8dd906bd22e79e47397a61742927f6747fe93242ef86645ee9092e610244/pyjwt-2.12.1-py3-none-any.whl", hash = "sha256:28ca37c070cad8ba8cd9790cd940535d40274d22f80ab87f3ac6a713e6e8454c", size = 29726, upload-time = "2026-03-13T19:27:35.677Z" },
+ { url = "https://files.pythonhosted.org/packages/a3/5e/ecf12fdb62546d64385c158514e9b2b671f7832108ef2ecd2020ce0af2d1/pyjwt-2.13.0-py3-none-any.whl", hash = "sha256:66adcc2aff09b3f1bbd95fc1e1577df8ac8723c978552fd43304c8a290ac5728", size = 31274, upload-time = "2026-05-21T19:54:35.362Z" },
]
[package.optional-dependencies]
@@ -934,53 +873,45 @@ crypto = [
[[package]]
name = "pyperclip"
-version = "1.10.0"
+version = "1.11.0"
source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/15/99/25f4898cf420efb6f45f519de018f4faea5391114a8618b16736ef3029f1/pyperclip-1.10.0.tar.gz", hash = "sha256:180c8346b1186921c75dfd14d9048a6b5d46bfc499778811952c6dd6eb1ca6be", size = 12193, upload-time = "2025-09-18T00:54:00.384Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/e8/52/d87eba7cb129b81563019d1679026e7a112ef76855d6159d24754dbd2a51/pyperclip-1.11.0.tar.gz", hash = "sha256:244035963e4428530d9e3a6101a1ef97209c6825edab1567beac148ccc1db1b6", size = 12185, upload-time = "2025-09-26T14:40:37.245Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/1e/bc/22540e73c5f5ae18f02924cd3954a6c9a4aa6b713c841a94c98335d333a1/pyperclip-1.10.0-py3-none-any.whl", hash = "sha256:596fbe55dc59263bff26e61d2afbe10223e2fccb5210c9c96a28d6887cfcc7ec", size = 11062, upload-time = "2025-09-18T00:53:59.252Z" },
+ { url = "https://files.pythonhosted.org/packages/df/80/fc9d01d5ed37ba4c42ca2b55b4339ae6e200b456be3a1aaddf4a9fa99b8c/pyperclip-1.11.0-py3-none-any.whl", hash = "sha256:299403e9ff44581cb9ba2ffeed69c7aa96a008622ad0c46cb575ca75b5b84273", size = 11063, upload-time = "2025-09-26T14:40:36.069Z" },
]
[[package]]
name = "pytest"
-version = "8.3.5"
+version = "9.0.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "colorama", marker = "sys_platform == 'win32'" },
{ name = "iniconfig" },
{ name = "packaging" },
{ name = "pluggy" },
+ { name = "pygments" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/ae/3c/c9d525a414d506893f0cd8a8d0de7706446213181570cdbd766691164e40/pytest-8.3.5.tar.gz", hash = "sha256:f4efe70cc14e511565ac476b57c279e12a855b11f48f212af1080ef2263d3845", size = 1450891, upload-time = "2025-03-02T12:54:54.503Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/7d/0d/549bd94f1a0a402dc8cf64563a117c0f3765662e2e668477624baeec44d5/pytest-9.0.3.tar.gz", hash = "sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c", size = 1572165, upload-time = "2026-04-07T17:16:18.027Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/30/3d/64ad57c803f1fa1e963a7946b6e0fea4a70df53c1a7fed304586539c2bac/pytest-8.3.5-py3-none-any.whl", hash = "sha256:c69214aa47deac29fad6c2a4f590b9c4a9fdb16a403176fe154b79c0b4d4d820", size = 343634, upload-time = "2025-03-02T12:54:52.069Z" },
+ { url = "https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249, upload-time = "2026-04-07T17:16:16.13Z" },
]
[[package]]
name = "python-dotenv"
-version = "1.1.1"
+version = "1.2.2"
source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/f6/b0/4bc07ccd3572a2f9df7e6782f52b0c6c90dcbb803ac4a167702d7d0dfe1e/python_dotenv-1.1.1.tar.gz", hash = "sha256:a8a6399716257f45be6a007360200409fce5cda2661e3dec71d23dc15f6189ab", size = 41978, upload-time = "2025-06-24T04:21:07.341Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/82/ed/0301aeeac3e5353ef3d94b6ec08bbcabd04a72018415dcb29e588514bba8/python_dotenv-1.2.2.tar.gz", hash = "sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3", size = 50135, upload-time = "2026-03-01T16:00:26.196Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/5f/ed/539768cf28c661b5b068d66d96a2f155c4971a5d55684a514c1a0e0dec2f/python_dotenv-1.1.1-py3-none-any.whl", hash = "sha256:31f23644fe2602f88ff55e1f5c79ba497e01224ee7737937930c448e4d0e24dc", size = 20556, upload-time = "2025-06-24T04:21:06.073Z" },
-]
-
-[[package]]
-name = "python-json-logger"
-version = "4.0.0"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/29/bf/eca6a3d43db1dae7070f70e160ab20b807627ba953663ba07928cdd3dc58/python_json_logger-4.0.0.tar.gz", hash = "sha256:f58e68eb46e1faed27e0f574a55a0455eecd7b8a5b88b85a784519ba3cff047f", size = 17683, upload-time = "2025-10-06T04:15:18.984Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/51/e5/fecf13f06e5e5f67e8837d777d1bc43fac0ed2b77a676804df5c34744727/python_json_logger-4.0.0-py3-none-any.whl", hash = "sha256:af09c9daf6a813aa4cc7180395f50f2a9e5fa056034c9953aec92e381c5ba1e2", size = 15548, upload-time = "2025-10-06T04:15:17.553Z" },
+ { url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" },
]
[[package]]
name = "python-multipart"
-version = "0.0.20"
+version = "0.0.28"
source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/f3/87/f44d7c9f274c7ee665a29b885ec97089ec5dc034c7f3fafa03da9e39a09e/python_multipart-0.0.20.tar.gz", hash = "sha256:8dd0cab45b8e23064ae09147625994d090fa46f5b0d1e13af944c331a7fa9d13", size = 37158, upload-time = "2024-12-16T19:45:46.972Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/82/54/a85eb421fbdd5007bc5af39d0f4ed9fa609e0fedbfdc2adcf0b34526870e/python_multipart-0.0.28.tar.gz", hash = "sha256:8550da197eac0f7ab748961fc9509b999fa2662ea25cef857f05249f6893c0f8", size = 45314, upload-time = "2026-05-10T11:05:16.596Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/45/58/38b5afbc1a800eeea951b9285d3912613f2603bdf897a4ab0f4bd7f405fc/python_multipart-0.0.20-py3-none-any.whl", hash = "sha256:8a62d3a8335e06589fe01f2a3e178cdcc632f3fbe0d492ad9ee0ec35aab1f104", size = 24546, upload-time = "2024-12-16T19:45:44.423Z" },
+ { url = "https://files.pythonhosted.org/packages/f3/a2/43bbc5860b5034e2af4ef99a0e04d726ff329c43e192ef3abaa8d7ecfce5/python_multipart-0.0.28-py3-none-any.whl", hash = "sha256:10faac07eb966c3f48dc415f9dee46c04cb10d58d30a35677db8027c825ed9b6", size = 29438, upload-time = "2026-05-10T11:05:15.052Z" },
]
[[package]]
@@ -1010,173 +941,194 @@ wheels = [
[[package]]
name = "pyyaml"
-version = "6.0.2"
+version = "6.0.3"
source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/54/ed/79a089b6be93607fa5cdaedf301d7dfb23af5f25c398d5ead2525b063e17/pyyaml-6.0.2.tar.gz", hash = "sha256:d584d9ec91ad65861cc08d42e834324ef890a082e591037abe114850ff7bbc3e", size = 130631, upload-time = "2024-08-06T20:33:50.674Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/86/0c/c581167fc46d6d6d7ddcfb8c843a4de25bdd27e4466938109ca68492292c/PyYAML-6.0.2-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:c70c95198c015b85feafc136515252a261a84561b7b1d51e3384e0655ddf25ab", size = 183873, upload-time = "2024-08-06T20:32:25.131Z" },
- { url = "https://files.pythonhosted.org/packages/a8/0c/38374f5bb272c051e2a69281d71cba6fdb983413e6758b84482905e29a5d/PyYAML-6.0.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ce826d6ef20b1bc864f0a68340c8b3287705cae2f8b4b1d932177dcc76721725", size = 173302, upload-time = "2024-08-06T20:32:26.511Z" },
- { url = "https://files.pythonhosted.org/packages/c3/93/9916574aa8c00aa06bbac729972eb1071d002b8e158bd0e83a3b9a20a1f7/PyYAML-6.0.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1f71ea527786de97d1a0cc0eacd1defc0985dcf6b3f17bb77dcfc8c34bec4dc5", size = 739154, upload-time = "2024-08-06T20:32:28.363Z" },
- { url = "https://files.pythonhosted.org/packages/95/0f/b8938f1cbd09739c6da569d172531567dbcc9789e0029aa070856f123984/PyYAML-6.0.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9b22676e8097e9e22e36d6b7bda33190d0d400f345f23d4065d48f4ca7ae0425", size = 766223, upload-time = "2024-08-06T20:32:30.058Z" },
- { url = "https://files.pythonhosted.org/packages/b9/2b/614b4752f2e127db5cc206abc23a8c19678e92b23c3db30fc86ab731d3bd/PyYAML-6.0.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:80bab7bfc629882493af4aa31a4cfa43a4c57c83813253626916b8c7ada83476", size = 767542, upload-time = "2024-08-06T20:32:31.881Z" },
- { url = "https://files.pythonhosted.org/packages/d4/00/dd137d5bcc7efea1836d6264f049359861cf548469d18da90cd8216cf05f/PyYAML-6.0.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:0833f8694549e586547b576dcfaba4a6b55b9e96098b36cdc7ebefe667dfed48", size = 731164, upload-time = "2024-08-06T20:32:37.083Z" },
- { url = "https://files.pythonhosted.org/packages/c9/1f/4f998c900485e5c0ef43838363ba4a9723ac0ad73a9dc42068b12aaba4e4/PyYAML-6.0.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:8b9c7197f7cb2738065c481a0461e50ad02f18c78cd75775628afb4d7137fb3b", size = 756611, upload-time = "2024-08-06T20:32:38.898Z" },
- { url = "https://files.pythonhosted.org/packages/df/d1/f5a275fdb252768b7a11ec63585bc38d0e87c9e05668a139fea92b80634c/PyYAML-6.0.2-cp312-cp312-win32.whl", hash = "sha256:ef6107725bd54b262d6dedcc2af448a266975032bc85ef0172c5f059da6325b4", size = 140591, upload-time = "2024-08-06T20:32:40.241Z" },
- { url = "https://files.pythonhosted.org/packages/0c/e8/4f648c598b17c3d06e8753d7d13d57542b30d56e6c2dedf9c331ae56312e/PyYAML-6.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:7e7401d0de89a9a855c839bc697c079a4af81cf878373abd7dc625847d25cbd8", size = 156338, upload-time = "2024-08-06T20:32:41.93Z" },
- { url = "https://files.pythonhosted.org/packages/ef/e3/3af305b830494fa85d95f6d95ef7fa73f2ee1cc8ef5b495c7c3269fb835f/PyYAML-6.0.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:efdca5630322a10774e8e98e1af481aad470dd62c3170801852d752aa7a783ba", size = 181309, upload-time = "2024-08-06T20:32:43.4Z" },
- { url = "https://files.pythonhosted.org/packages/45/9f/3b1c20a0b7a3200524eb0076cc027a970d320bd3a6592873c85c92a08731/PyYAML-6.0.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:50187695423ffe49e2deacb8cd10510bc361faac997de9efef88badc3bb9e2d1", size = 171679, upload-time = "2024-08-06T20:32:44.801Z" },
- { url = "https://files.pythonhosted.org/packages/7c/9a/337322f27005c33bcb656c655fa78325b730324c78620e8328ae28b64d0c/PyYAML-6.0.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0ffe8360bab4910ef1b9e87fb812d8bc0a308b0d0eef8c8f44e0254ab3b07133", size = 733428, upload-time = "2024-08-06T20:32:46.432Z" },
- { url = "https://files.pythonhosted.org/packages/a3/69/864fbe19e6c18ea3cc196cbe5d392175b4cf3d5d0ac1403ec3f2d237ebb5/PyYAML-6.0.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:17e311b6c678207928d649faa7cb0d7b4c26a0ba73d41e99c4fff6b6c3276484", size = 763361, upload-time = "2024-08-06T20:32:51.188Z" },
- { url = "https://files.pythonhosted.org/packages/04/24/b7721e4845c2f162d26f50521b825fb061bc0a5afcf9a386840f23ea19fa/PyYAML-6.0.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:70b189594dbe54f75ab3a1acec5f1e3faa7e8cf2f1e08d9b561cb41b845f69d5", size = 759523, upload-time = "2024-08-06T20:32:53.019Z" },
- { url = "https://files.pythonhosted.org/packages/2b/b2/e3234f59ba06559c6ff63c4e10baea10e5e7df868092bf9ab40e5b9c56b6/PyYAML-6.0.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:41e4e3953a79407c794916fa277a82531dd93aad34e29c2a514c2c0c5fe971cc", size = 726660, upload-time = "2024-08-06T20:32:54.708Z" },
- { url = "https://files.pythonhosted.org/packages/fe/0f/25911a9f080464c59fab9027482f822b86bf0608957a5fcc6eaac85aa515/PyYAML-6.0.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:68ccc6023a3400877818152ad9a1033e3db8625d899c72eacb5a668902e4d652", size = 751597, upload-time = "2024-08-06T20:32:56.985Z" },
- { url = "https://files.pythonhosted.org/packages/14/0d/e2c3b43bbce3cf6bd97c840b46088a3031085179e596d4929729d8d68270/PyYAML-6.0.2-cp313-cp313-win32.whl", hash = "sha256:bc2fa7c6b47d6bc618dd7fb02ef6fdedb1090ec036abab80d4681424b84c1183", size = 140527, upload-time = "2024-08-06T20:33:03.001Z" },
- { url = "https://files.pythonhosted.org/packages/fa/de/02b54f42487e3d3c6efb3f89428677074ca7bf43aae402517bc7cca949f3/PyYAML-6.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:8388ee1976c416731879ac16da0aff3f63b286ffdd57cdeb95f3f2e085687563", size = 156446, upload-time = "2024-08-06T20:33:04.33Z" },
-]
-
-[[package]]
-name = "redis"
-version = "7.1.0"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/43/c8/983d5c6579a411d8a99bc5823cc5712768859b5ce2c8afe1a65b37832c81/redis-7.1.0.tar.gz", hash = "sha256:b1cc3cfa5a2cb9c2ab3ba700864fb0ad75617b41f01352ce5779dabf6d5f9c3c", size = 4796669, upload-time = "2025-11-19T15:54:39.961Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/89/f0/8956f8a86b20d7bb9d6ac0187cf4cd54d8065bc9a1a09eb8011d4d326596/redis-7.1.0-py3-none-any.whl", hash = "sha256:23c52b208f92b56103e17c5d06bdc1a6c2c0b3106583985a76a18f83b265de2b", size = 354159, upload-time = "2025-11-19T15:54:38.064Z" },
+ { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" },
+ { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" },
+ { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" },
+ { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" },
+ { url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" },
+ { url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" },
+ { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" },
+ { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" },
+ { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" },
+ { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" },
+ { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" },
+ { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" },
+ { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" },
+ { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" },
+ { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" },
+ { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" },
+ { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" },
+ { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" },
+ { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" },
+ { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" },
+ { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" },
+ { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" },
+ { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" },
+ { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" },
+ { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" },
+ { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" },
+ { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" },
+ { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" },
+ { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" },
+ { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" },
+ { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" },
+ { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" },
+ { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" },
+ { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" },
+ { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" },
+ { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" },
+ { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" },
+ { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" },
]
[[package]]
name = "referencing"
-version = "0.36.2"
+version = "0.37.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "attrs" },
{ name = "rpds-py" },
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/2f/db/98b5c277be99dd18bfd91dd04e1b759cad18d1a338188c936e92f921c7e2/referencing-0.36.2.tar.gz", hash = "sha256:df2e89862cd09deabbdba16944cc3f10feb6b3e6f18e902f7cc25609a34775aa", size = 74744, upload-time = "2025-01-25T08:48:16.138Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/22/f5/df4e9027acead3ecc63e50fe1e36aca1523e1719559c499951bb4b53188f/referencing-0.37.0.tar.gz", hash = "sha256:44aefc3142c5b842538163acb373e24cce6632bd54bdb01b21ad5863489f50d8", size = 78036, upload-time = "2025-10-13T15:30:48.871Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/c1/b1/3baf80dc6d2b7bc27a95a67752d0208e410351e3feb4eb78de5f77454d8d/referencing-0.36.2-py3-none-any.whl", hash = "sha256:e8699adbbf8b5c7de96d8ffa0eb5c158b3beafce084968e2ea8bb08c6794dcd0", size = 26775, upload-time = "2025-01-25T08:48:14.241Z" },
-]
-
-[[package]]
-name = "requests"
-version = "2.33.1"
-source = { registry = "https://pypi.org/simple" }
-dependencies = [
- { name = "certifi" },
- { name = "charset-normalizer" },
- { name = "idna" },
- { name = "urllib3" },
-]
-sdist = { url = "https://files.pythonhosted.org/packages/5f/a4/98b9c7c6428a668bf7e42ebb7c79d576a1c3c1e3ae2d47e674b468388871/requests-2.33.1.tar.gz", hash = "sha256:18817f8c57c6263968bc123d237e3b8b08ac046f5456bd1e307ee8f4250d3517", size = 134120, upload-time = "2026-03-30T16:09:15.531Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/d7/8e/7540e8a2036f79a125c1d2ebadf69ed7901608859186c856fa0388ef4197/requests-2.33.1-py3-none-any.whl", hash = "sha256:4e6d1ef462f3626a1f0a0a9c42dd93c63bad33f9f1c1937509b8c5c8718ab56a", size = 64947, upload-time = "2026-03-30T16:09:13.83Z" },
+ { url = "https://files.pythonhosted.org/packages/2c/58/ca301544e1fa93ed4f80d724bf5b194f6e4b945841c5bfd555878eea9fcb/referencing-0.37.0-py3-none-any.whl", hash = "sha256:381329a9f99628c9069361716891d34ad94af76e461dcb0335825aecc7692231", size = 26766, upload-time = "2025-10-13T15:30:47.625Z" },
]
[[package]]
name = "rich"
-version = "14.1.0"
+version = "15.0.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "markdown-it-py" },
{ name = "pygments" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/fe/75/af448d8e52bf1d8fa6a9d089ca6c07ff4453d86c65c145d0a300bb073b9b/rich-14.1.0.tar.gz", hash = "sha256:e497a48b844b0320d45007cdebfeaeed8db2a4f4bcf49f15e455cfc4af11eaa8", size = 224441, upload-time = "2025-07-25T07:32:58.125Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/c0/8f/0722ca900cc807c13a6a0c696dacf35430f72e0ec571c4275d2371fca3e9/rich-15.0.0.tar.gz", hash = "sha256:edd07a4824c6b40189fb7ac9bc4c52536e9780fbbfbddf6f1e2502c31b068c36", size = 230680, upload-time = "2026-04-12T08:24:00.75Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/e3/30/3c4d035596d3cf444529e0b2953ad0466f6049528a879d27534700580395/rich-14.1.0-py3-none-any.whl", hash = "sha256:536f5f1785986d6dbdea3c75205c473f970777b4a0d6c6dd1b696aa05a3fa04f", size = 243368, upload-time = "2025-07-25T07:32:56.73Z" },
+ { url = "https://files.pythonhosted.org/packages/82/3b/64d4899d73f91ba49a8c18a8ff3f0ea8f1c1d75481760df8c68ef5235bf5/rich-15.0.0-py3-none-any.whl", hash = "sha256:33bd4ef74232fb73fe9279a257718407f169c09b78a87ad3d296f548e27de0bb", size = 310654, upload-time = "2026-04-12T08:24:02.83Z" },
]
[[package]]
name = "rich-rst"
-version = "1.3.1"
+version = "1.3.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "docutils" },
{ name = "rich" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/b0/69/5514c3a87b5f10f09a34bb011bc0927bc12c596c8dae5915604e71abc386/rich_rst-1.3.1.tar.gz", hash = "sha256:fad46e3ba42785ea8c1785e2ceaa56e0ffa32dbe5410dec432f37e4107c4f383", size = 13839, upload-time = "2024-04-30T04:40:38.125Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/bc/6d/a506aaa4a9eaa945ed8ab2b7347859f53593864289853c5d6d62b77246e0/rich_rst-1.3.2.tar.gz", hash = "sha256:a1196fdddf1e364b02ec68a05e8ff8f6914fee10fbca2e6b6735f166bb0da8d4", size = 14936, upload-time = "2025-10-14T16:49:45.332Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/fd/bc/cc4e3dbc5e7992398dcb7a8eda0cbcf4fb792a0cdb93f857b478bf3cf884/rich_rst-1.3.1-py3-none-any.whl", hash = "sha256:498a74e3896507ab04492d326e794c3ef76e7cda078703aa592d1853d91098c1", size = 11621, upload-time = "2024-04-30T04:40:32.619Z" },
+ { url = "https://files.pythonhosted.org/packages/13/2f/b4530fbf948867702d0a3f27de4a6aab1d156f406d72852ab902c4d04de9/rich_rst-1.3.2-py3-none-any.whl", hash = "sha256:a99b4907cbe118cf9d18b0b44de272efa61f15117c61e39ebdc431baf5df722a", size = 12567, upload-time = "2025-10-14T16:49:42.953Z" },
]
[[package]]
name = "rpds-py"
-version = "0.27.1"
+version = "0.30.0"
source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/e9/dd/2c0cbe774744272b0ae725f44032c77bdcab6e8bcf544bffa3b6e70c8dba/rpds_py-0.27.1.tar.gz", hash = "sha256:26a1c73171d10b7acccbded82bf6a586ab8203601e565badc74bbbf8bc5a10f8", size = 27479, upload-time = "2025-08-27T12:16:36.024Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/20/af/3f2f423103f1113b36230496629986e0ef7e199d2aa8392452b484b38ced/rpds_py-0.30.0.tar.gz", hash = "sha256:dd8ff7cf90014af0c0f787eea34794ebf6415242ee1d6fa91eaba725cc441e84", size = 69469, upload-time = "2025-11-30T20:24:38.837Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/bd/fe/38de28dee5df58b8198c743fe2bea0c785c6d40941b9950bac4cdb71a014/rpds_py-0.27.1-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:ae2775c1973e3c30316892737b91f9283f9908e3cc7625b9331271eaaed7dc90", size = 361887, upload-time = "2025-08-27T12:13:10.233Z" },
- { url = "https://files.pythonhosted.org/packages/7c/9a/4b6c7eedc7dd90986bf0fab6ea2a091ec11c01b15f8ba0a14d3f80450468/rpds_py-0.27.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2643400120f55c8a96f7c9d858f7be0c88d383cd4653ae2cf0d0c88f668073e5", size = 345795, upload-time = "2025-08-27T12:13:11.65Z" },
- { url = "https://files.pythonhosted.org/packages/6f/0e/e650e1b81922847a09cca820237b0edee69416a01268b7754d506ade11ad/rpds_py-0.27.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:16323f674c089b0360674a4abd28d5042947d54ba620f72514d69be4ff64845e", size = 385121, upload-time = "2025-08-27T12:13:13.008Z" },
- { url = "https://files.pythonhosted.org/packages/1b/ea/b306067a712988e2bff00dcc7c8f31d26c29b6d5931b461aa4b60a013e33/rpds_py-0.27.1-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9a1f4814b65eacac94a00fc9a526e3fdafd78e439469644032032d0d63de4881", size = 398976, upload-time = "2025-08-27T12:13:14.368Z" },
- { url = "https://files.pythonhosted.org/packages/2c/0a/26dc43c8840cb8fe239fe12dbc8d8de40f2365e838f3d395835dde72f0e5/rpds_py-0.27.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7ba32c16b064267b22f1850a34051121d423b6f7338a12b9459550eb2096e7ec", size = 525953, upload-time = "2025-08-27T12:13:15.774Z" },
- { url = "https://files.pythonhosted.org/packages/22/14/c85e8127b573aaf3a0cbd7fbb8c9c99e735a4a02180c84da2a463b766e9e/rpds_py-0.27.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e5c20f33fd10485b80f65e800bbe5f6785af510b9f4056c5a3c612ebc83ba6cb", size = 407915, upload-time = "2025-08-27T12:13:17.379Z" },
- { url = "https://files.pythonhosted.org/packages/ed/7b/8f4fee9ba1fb5ec856eb22d725a4efa3deb47f769597c809e03578b0f9d9/rpds_py-0.27.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:466bfe65bd932da36ff279ddd92de56b042f2266d752719beb97b08526268ec5", size = 386883, upload-time = "2025-08-27T12:13:18.704Z" },
- { url = "https://files.pythonhosted.org/packages/86/47/28fa6d60f8b74fcdceba81b272f8d9836ac0340570f68f5df6b41838547b/rpds_py-0.27.1-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:41e532bbdcb57c92ba3be62c42e9f096431b4cf478da9bc3bc6ce5c38ab7ba7a", size = 405699, upload-time = "2025-08-27T12:13:20.089Z" },
- { url = "https://files.pythonhosted.org/packages/d0/fd/c5987b5e054548df56953a21fe2ebed51fc1ec7c8f24fd41c067b68c4a0a/rpds_py-0.27.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f149826d742b406579466283769a8ea448eed82a789af0ed17b0cd5770433444", size = 423713, upload-time = "2025-08-27T12:13:21.436Z" },
- { url = "https://files.pythonhosted.org/packages/ac/ba/3c4978b54a73ed19a7d74531be37a8bcc542d917c770e14d372b8daea186/rpds_py-0.27.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:80c60cfb5310677bd67cb1e85a1e8eb52e12529545441b43e6f14d90b878775a", size = 562324, upload-time = "2025-08-27T12:13:22.789Z" },
- { url = "https://files.pythonhosted.org/packages/b5/6c/6943a91768fec16db09a42b08644b960cff540c66aab89b74be6d4a144ba/rpds_py-0.27.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:7ee6521b9baf06085f62ba9c7a3e5becffbc32480d2f1b351559c001c38ce4c1", size = 593646, upload-time = "2025-08-27T12:13:24.122Z" },
- { url = "https://files.pythonhosted.org/packages/11/73/9d7a8f4be5f4396f011a6bb7a19fe26303a0dac9064462f5651ced2f572f/rpds_py-0.27.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a512c8263249a9d68cac08b05dd59d2b3f2061d99b322813cbcc14c3c7421998", size = 558137, upload-time = "2025-08-27T12:13:25.557Z" },
- { url = "https://files.pythonhosted.org/packages/6e/96/6772cbfa0e2485bcceef8071de7821f81aeac8bb45fbfd5542a3e8108165/rpds_py-0.27.1-cp312-cp312-win32.whl", hash = "sha256:819064fa048ba01b6dadc5116f3ac48610435ac9a0058bbde98e569f9e785c39", size = 221343, upload-time = "2025-08-27T12:13:26.967Z" },
- { url = "https://files.pythonhosted.org/packages/67/b6/c82f0faa9af1c6a64669f73a17ee0eeef25aff30bb9a1c318509efe45d84/rpds_py-0.27.1-cp312-cp312-win_amd64.whl", hash = "sha256:d9199717881f13c32c4046a15f024971a3b78ad4ea029e8da6b86e5aa9cf4594", size = 232497, upload-time = "2025-08-27T12:13:28.326Z" },
- { url = "https://files.pythonhosted.org/packages/e1/96/2817b44bd2ed11aebacc9251da03689d56109b9aba5e311297b6902136e2/rpds_py-0.27.1-cp312-cp312-win_arm64.whl", hash = "sha256:33aa65b97826a0e885ef6e278fbd934e98cdcfed80b63946025f01e2f5b29502", size = 222790, upload-time = "2025-08-27T12:13:29.71Z" },
- { url = "https://files.pythonhosted.org/packages/cc/77/610aeee8d41e39080c7e14afa5387138e3c9fa9756ab893d09d99e7d8e98/rpds_py-0.27.1-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:e4b9fcfbc021633863a37e92571d6f91851fa656f0180246e84cbd8b3f6b329b", size = 361741, upload-time = "2025-08-27T12:13:31.039Z" },
- { url = "https://files.pythonhosted.org/packages/3a/fc/c43765f201c6a1c60be2043cbdb664013def52460a4c7adace89d6682bf4/rpds_py-0.27.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:1441811a96eadca93c517d08df75de45e5ffe68aa3089924f963c782c4b898cf", size = 345574, upload-time = "2025-08-27T12:13:32.902Z" },
- { url = "https://files.pythonhosted.org/packages/20/42/ee2b2ca114294cd9847d0ef9c26d2b0851b2e7e00bf14cc4c0b581df0fc3/rpds_py-0.27.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:55266dafa22e672f5a4f65019015f90336ed31c6383bd53f5e7826d21a0e0b83", size = 385051, upload-time = "2025-08-27T12:13:34.228Z" },
- { url = "https://files.pythonhosted.org/packages/fd/e8/1e430fe311e4799e02e2d1af7c765f024e95e17d651612425b226705f910/rpds_py-0.27.1-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d78827d7ac08627ea2c8e02c9e5b41180ea5ea1f747e9db0915e3adf36b62dcf", size = 398395, upload-time = "2025-08-27T12:13:36.132Z" },
- { url = "https://files.pythonhosted.org/packages/82/95/9dc227d441ff2670651c27a739acb2535ccaf8b351a88d78c088965e5996/rpds_py-0.27.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ae92443798a40a92dc5f0b01d8a7c93adde0c4dc965310a29ae7c64d72b9fad2", size = 524334, upload-time = "2025-08-27T12:13:37.562Z" },
- { url = "https://files.pythonhosted.org/packages/87/01/a670c232f401d9ad461d9a332aa4080cd3cb1d1df18213dbd0d2a6a7ab51/rpds_py-0.27.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c46c9dd2403b66a2a3b9720ec4b74d4ab49d4fabf9f03dfdce2d42af913fe8d0", size = 407691, upload-time = "2025-08-27T12:13:38.94Z" },
- { url = "https://files.pythonhosted.org/packages/03/36/0a14aebbaa26fe7fab4780c76f2239e76cc95a0090bdb25e31d95c492fcd/rpds_py-0.27.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2efe4eb1d01b7f5f1939f4ef30ecea6c6b3521eec451fb93191bf84b2a522418", size = 386868, upload-time = "2025-08-27T12:13:40.192Z" },
- { url = "https://files.pythonhosted.org/packages/3b/03/8c897fb8b5347ff6c1cc31239b9611c5bf79d78c984430887a353e1409a1/rpds_py-0.27.1-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:15d3b4d83582d10c601f481eca29c3f138d44c92187d197aff663a269197c02d", size = 405469, upload-time = "2025-08-27T12:13:41.496Z" },
- { url = "https://files.pythonhosted.org/packages/da/07/88c60edc2df74850d496d78a1fdcdc7b54360a7f610a4d50008309d41b94/rpds_py-0.27.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4ed2e16abbc982a169d30d1a420274a709949e2cbdef119fe2ec9d870b42f274", size = 422125, upload-time = "2025-08-27T12:13:42.802Z" },
- { url = "https://files.pythonhosted.org/packages/6b/86/5f4c707603e41b05f191a749984f390dabcbc467cf833769b47bf14ba04f/rpds_py-0.27.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a75f305c9b013289121ec0f1181931975df78738cdf650093e6b86d74aa7d8dd", size = 562341, upload-time = "2025-08-27T12:13:44.472Z" },
- { url = "https://files.pythonhosted.org/packages/b2/92/3c0cb2492094e3cd9baf9e49bbb7befeceb584ea0c1a8b5939dca4da12e5/rpds_py-0.27.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:67ce7620704745881a3d4b0ada80ab4d99df390838839921f99e63c474f82cf2", size = 592511, upload-time = "2025-08-27T12:13:45.898Z" },
- { url = "https://files.pythonhosted.org/packages/10/bb/82e64fbb0047c46a168faa28d0d45a7851cd0582f850b966811d30f67ad8/rpds_py-0.27.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9d992ac10eb86d9b6f369647b6a3f412fc0075cfd5d799530e84d335e440a002", size = 557736, upload-time = "2025-08-27T12:13:47.408Z" },
- { url = "https://files.pythonhosted.org/packages/00/95/3c863973d409210da7fb41958172c6b7dbe7fc34e04d3cc1f10bb85e979f/rpds_py-0.27.1-cp313-cp313-win32.whl", hash = "sha256:4f75e4bd8ab8db624e02c8e2fc4063021b58becdbe6df793a8111d9343aec1e3", size = 221462, upload-time = "2025-08-27T12:13:48.742Z" },
- { url = "https://files.pythonhosted.org/packages/ce/2c/5867b14a81dc217b56d95a9f2a40fdbc56a1ab0181b80132beeecbd4b2d6/rpds_py-0.27.1-cp313-cp313-win_amd64.whl", hash = "sha256:f9025faafc62ed0b75a53e541895ca272815bec18abe2249ff6501c8f2e12b83", size = 232034, upload-time = "2025-08-27T12:13:50.11Z" },
- { url = "https://files.pythonhosted.org/packages/c7/78/3958f3f018c01923823f1e47f1cc338e398814b92d83cd278364446fac66/rpds_py-0.27.1-cp313-cp313-win_arm64.whl", hash = "sha256:ed10dc32829e7d222b7d3b93136d25a406ba9788f6a7ebf6809092da1f4d279d", size = 222392, upload-time = "2025-08-27T12:13:52.587Z" },
- { url = "https://files.pythonhosted.org/packages/01/76/1cdf1f91aed5c3a7bf2eba1f1c4e4d6f57832d73003919a20118870ea659/rpds_py-0.27.1-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:92022bbbad0d4426e616815b16bc4127f83c9a74940e1ccf3cfe0b387aba0228", size = 358355, upload-time = "2025-08-27T12:13:54.012Z" },
- { url = "https://files.pythonhosted.org/packages/c3/6f/bf142541229374287604caf3bb2a4ae17f0a580798fd72d3b009b532db4e/rpds_py-0.27.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:47162fdab9407ec3f160805ac3e154df042e577dd53341745fc7fb3f625e6d92", size = 342138, upload-time = "2025-08-27T12:13:55.791Z" },
- { url = "https://files.pythonhosted.org/packages/1a/77/355b1c041d6be40886c44ff5e798b4e2769e497b790f0f7fd1e78d17e9a8/rpds_py-0.27.1-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fb89bec23fddc489e5d78b550a7b773557c9ab58b7946154a10a6f7a214a48b2", size = 380247, upload-time = "2025-08-27T12:13:57.683Z" },
- { url = "https://files.pythonhosted.org/packages/d6/a4/d9cef5c3946ea271ce2243c51481971cd6e34f21925af2783dd17b26e815/rpds_py-0.27.1-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e48af21883ded2b3e9eb48cb7880ad8598b31ab752ff3be6457001d78f416723", size = 390699, upload-time = "2025-08-27T12:13:59.137Z" },
- { url = "https://files.pythonhosted.org/packages/3a/06/005106a7b8c6c1a7e91b73169e49870f4af5256119d34a361ae5240a0c1d/rpds_py-0.27.1-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6f5b7bd8e219ed50299e58551a410b64daafb5017d54bbe822e003856f06a802", size = 521852, upload-time = "2025-08-27T12:14:00.583Z" },
- { url = "https://files.pythonhosted.org/packages/e5/3e/50fb1dac0948e17a02eb05c24510a8fe12d5ce8561c6b7b7d1339ab7ab9c/rpds_py-0.27.1-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:08f1e20bccf73b08d12d804d6e1c22ca5530e71659e6673bce31a6bb71c1e73f", size = 402582, upload-time = "2025-08-27T12:14:02.034Z" },
- { url = "https://files.pythonhosted.org/packages/cb/b0/f4e224090dc5b0ec15f31a02d746ab24101dd430847c4d99123798661bfc/rpds_py-0.27.1-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0dc5dceeaefcc96dc192e3a80bbe1d6c410c469e97bdd47494a7d930987f18b2", size = 384126, upload-time = "2025-08-27T12:14:03.437Z" },
- { url = "https://files.pythonhosted.org/packages/54/77/ac339d5f82b6afff1df8f0fe0d2145cc827992cb5f8eeb90fc9f31ef7a63/rpds_py-0.27.1-cp313-cp313t-manylinux_2_31_riscv64.whl", hash = "sha256:d76f9cc8665acdc0c9177043746775aa7babbf479b5520b78ae4002d889f5c21", size = 399486, upload-time = "2025-08-27T12:14:05.443Z" },
- { url = "https://files.pythonhosted.org/packages/d6/29/3e1c255eee6ac358c056a57d6d6869baa00a62fa32eea5ee0632039c50a3/rpds_py-0.27.1-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:134fae0e36022edad8290a6661edf40c023562964efea0cc0ec7f5d392d2aaef", size = 414832, upload-time = "2025-08-27T12:14:06.902Z" },
- { url = "https://files.pythonhosted.org/packages/3f/db/6d498b844342deb3fa1d030598db93937a9964fcf5cb4da4feb5f17be34b/rpds_py-0.27.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:eb11a4f1b2b63337cfd3b4d110af778a59aae51c81d195768e353d8b52f88081", size = 557249, upload-time = "2025-08-27T12:14:08.37Z" },
- { url = "https://files.pythonhosted.org/packages/60/f3/690dd38e2310b6f68858a331399b4d6dbb9132c3e8ef8b4333b96caf403d/rpds_py-0.27.1-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:13e608ac9f50a0ed4faec0e90ece76ae33b34c0e8656e3dceb9a7db994c692cd", size = 587356, upload-time = "2025-08-27T12:14:10.034Z" },
- { url = "https://files.pythonhosted.org/packages/86/e3/84507781cccd0145f35b1dc32c72675200c5ce8d5b30f813e49424ef68fc/rpds_py-0.27.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:dd2135527aa40f061350c3f8f89da2644de26cd73e4de458e79606384f4f68e7", size = 555300, upload-time = "2025-08-27T12:14:11.783Z" },
- { url = "https://files.pythonhosted.org/packages/e5/ee/375469849e6b429b3516206b4580a79e9ef3eb12920ddbd4492b56eaacbe/rpds_py-0.27.1-cp313-cp313t-win32.whl", hash = "sha256:3020724ade63fe320a972e2ffd93b5623227e684315adce194941167fee02688", size = 216714, upload-time = "2025-08-27T12:14:13.629Z" },
- { url = "https://files.pythonhosted.org/packages/21/87/3fc94e47c9bd0742660e84706c311a860dcae4374cf4a03c477e23ce605a/rpds_py-0.27.1-cp313-cp313t-win_amd64.whl", hash = "sha256:8ee50c3e41739886606388ba3ab3ee2aae9f35fb23f833091833255a31740797", size = 228943, upload-time = "2025-08-27T12:14:14.937Z" },
- { url = "https://files.pythonhosted.org/packages/70/36/b6e6066520a07cf029d385de869729a895917b411e777ab1cde878100a1d/rpds_py-0.27.1-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:acb9aafccaae278f449d9c713b64a9e68662e7799dbd5859e2c6b3c67b56d334", size = 362472, upload-time = "2025-08-27T12:14:16.333Z" },
- { url = "https://files.pythonhosted.org/packages/af/07/b4646032e0dcec0df9c73a3bd52f63bc6c5f9cda992f06bd0e73fe3fbebd/rpds_py-0.27.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:b7fb801aa7f845ddf601c49630deeeccde7ce10065561d92729bfe81bd21fb33", size = 345676, upload-time = "2025-08-27T12:14:17.764Z" },
- { url = "https://files.pythonhosted.org/packages/b0/16/2f1003ee5d0af4bcb13c0cf894957984c32a6751ed7206db2aee7379a55e/rpds_py-0.27.1-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fe0dd05afb46597b9a2e11c351e5e4283c741237e7f617ffb3252780cca9336a", size = 385313, upload-time = "2025-08-27T12:14:19.829Z" },
- { url = "https://files.pythonhosted.org/packages/05/cd/7eb6dd7b232e7f2654d03fa07f1414d7dfc980e82ba71e40a7c46fd95484/rpds_py-0.27.1-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b6dfb0e058adb12d8b1d1b25f686e94ffa65d9995a5157afe99743bf7369d62b", size = 399080, upload-time = "2025-08-27T12:14:21.531Z" },
- { url = "https://files.pythonhosted.org/packages/20/51/5829afd5000ec1cb60f304711f02572d619040aa3ec033d8226817d1e571/rpds_py-0.27.1-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ed090ccd235f6fa8bb5861684567f0a83e04f52dfc2e5c05f2e4b1309fcf85e7", size = 523868, upload-time = "2025-08-27T12:14:23.485Z" },
- { url = "https://files.pythonhosted.org/packages/05/2c/30eebca20d5db95720ab4d2faec1b5e4c1025c473f703738c371241476a2/rpds_py-0.27.1-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bf876e79763eecf3e7356f157540d6a093cef395b65514f17a356f62af6cc136", size = 408750, upload-time = "2025-08-27T12:14:24.924Z" },
- { url = "https://files.pythonhosted.org/packages/90/1a/cdb5083f043597c4d4276eae4e4c70c55ab5accec078da8611f24575a367/rpds_py-0.27.1-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:12ed005216a51b1d6e2b02a7bd31885fe317e45897de81d86dcce7d74618ffff", size = 387688, upload-time = "2025-08-27T12:14:27.537Z" },
- { url = "https://files.pythonhosted.org/packages/7c/92/cf786a15320e173f945d205ab31585cc43969743bb1a48b6888f7a2b0a2d/rpds_py-0.27.1-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:ee4308f409a40e50593c7e3bb8cbe0b4d4c66d1674a316324f0c2f5383b486f9", size = 407225, upload-time = "2025-08-27T12:14:28.981Z" },
- { url = "https://files.pythonhosted.org/packages/33/5c/85ee16df5b65063ef26017bef33096557a4c83fbe56218ac7cd8c235f16d/rpds_py-0.27.1-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:0b08d152555acf1f455154d498ca855618c1378ec810646fcd7c76416ac6dc60", size = 423361, upload-time = "2025-08-27T12:14:30.469Z" },
- { url = "https://files.pythonhosted.org/packages/4b/8e/1c2741307fcabd1a334ecf008e92c4f47bb6f848712cf15c923becfe82bb/rpds_py-0.27.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:dce51c828941973a5684d458214d3a36fcd28da3e1875d659388f4f9f12cc33e", size = 562493, upload-time = "2025-08-27T12:14:31.987Z" },
- { url = "https://files.pythonhosted.org/packages/04/03/5159321baae9b2222442a70c1f988cbbd66b9be0675dd3936461269be360/rpds_py-0.27.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:c1476d6f29eb81aa4151c9a31219b03f1f798dc43d8af1250a870735516a1212", size = 592623, upload-time = "2025-08-27T12:14:33.543Z" },
- { url = "https://files.pythonhosted.org/packages/ff/39/c09fd1ad28b85bc1d4554a8710233c9f4cefd03d7717a1b8fbfd171d1167/rpds_py-0.27.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:3ce0cac322b0d69b63c9cdb895ee1b65805ec9ffad37639f291dd79467bee675", size = 558800, upload-time = "2025-08-27T12:14:35.436Z" },
- { url = "https://files.pythonhosted.org/packages/c5/d6/99228e6bbcf4baa764b18258f519a9035131d91b538d4e0e294313462a98/rpds_py-0.27.1-cp314-cp314-win32.whl", hash = "sha256:dfbfac137d2a3d0725758cd141f878bf4329ba25e34979797c89474a89a8a3a3", size = 221943, upload-time = "2025-08-27T12:14:36.898Z" },
- { url = "https://files.pythonhosted.org/packages/be/07/c802bc6b8e95be83b79bdf23d1aa61d68324cb1006e245d6c58e959e314d/rpds_py-0.27.1-cp314-cp314-win_amd64.whl", hash = "sha256:a6e57b0abfe7cc513450fcf529eb486b6e4d3f8aee83e92eb5f1ef848218d456", size = 233739, upload-time = "2025-08-27T12:14:38.386Z" },
- { url = "https://files.pythonhosted.org/packages/c8/89/3e1b1c16d4c2d547c5717377a8df99aee8099ff050f87c45cb4d5fa70891/rpds_py-0.27.1-cp314-cp314-win_arm64.whl", hash = "sha256:faf8d146f3d476abfee026c4ae3bdd9ca14236ae4e4c310cbd1cf75ba33d24a3", size = 223120, upload-time = "2025-08-27T12:14:39.82Z" },
- { url = "https://files.pythonhosted.org/packages/62/7e/dc7931dc2fa4a6e46b2a4fa744a9fe5c548efd70e0ba74f40b39fa4a8c10/rpds_py-0.27.1-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:ba81d2b56b6d4911ce735aad0a1d4495e808b8ee4dc58715998741a26874e7c2", size = 358944, upload-time = "2025-08-27T12:14:41.199Z" },
- { url = "https://files.pythonhosted.org/packages/e6/22/4af76ac4e9f336bfb1a5f240d18a33c6b2fcaadb7472ac7680576512b49a/rpds_py-0.27.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:84f7d509870098de0e864cad0102711c1e24e9b1a50ee713b65928adb22269e4", size = 342283, upload-time = "2025-08-27T12:14:42.699Z" },
- { url = "https://files.pythonhosted.org/packages/1c/15/2a7c619b3c2272ea9feb9ade67a45c40b3eeb500d503ad4c28c395dc51b4/rpds_py-0.27.1-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a9e960fc78fecd1100539f14132425e1d5fe44ecb9239f8f27f079962021523e", size = 380320, upload-time = "2025-08-27T12:14:44.157Z" },
- { url = "https://files.pythonhosted.org/packages/a2/7d/4c6d243ba4a3057e994bb5bedd01b5c963c12fe38dde707a52acdb3849e7/rpds_py-0.27.1-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:62f85b665cedab1a503747617393573995dac4600ff51869d69ad2f39eb5e817", size = 391760, upload-time = "2025-08-27T12:14:45.845Z" },
- { url = "https://files.pythonhosted.org/packages/b4/71/b19401a909b83bcd67f90221330bc1ef11bc486fe4e04c24388d28a618ae/rpds_py-0.27.1-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fed467af29776f6556250c9ed85ea5a4dd121ab56a5f8b206e3e7a4c551e48ec", size = 522476, upload-time = "2025-08-27T12:14:47.364Z" },
- { url = "https://files.pythonhosted.org/packages/e4/44/1a3b9715c0455d2e2f0f6df5ee6d6f5afdc423d0773a8a682ed2b43c566c/rpds_py-0.27.1-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f2729615f9d430af0ae6b36cf042cb55c0936408d543fb691e1a9e36648fd35a", size = 403418, upload-time = "2025-08-27T12:14:49.991Z" },
- { url = "https://files.pythonhosted.org/packages/1c/4b/fb6c4f14984eb56673bc868a66536f53417ddb13ed44b391998100a06a96/rpds_py-0.27.1-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1b207d881a9aef7ba753d69c123a35d96ca7cb808056998f6b9e8747321f03b8", size = 384771, upload-time = "2025-08-27T12:14:52.159Z" },
- { url = "https://files.pythonhosted.org/packages/c0/56/d5265d2d28b7420d7b4d4d85cad8ef891760f5135102e60d5c970b976e41/rpds_py-0.27.1-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:639fd5efec029f99b79ae47e5d7e00ad8a773da899b6309f6786ecaf22948c48", size = 400022, upload-time = "2025-08-27T12:14:53.859Z" },
- { url = "https://files.pythonhosted.org/packages/8f/e9/9f5fc70164a569bdd6ed9046486c3568d6926e3a49bdefeeccfb18655875/rpds_py-0.27.1-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fecc80cb2a90e28af8a9b366edacf33d7a91cbfe4c2c4544ea1246e949cfebeb", size = 416787, upload-time = "2025-08-27T12:14:55.673Z" },
- { url = "https://files.pythonhosted.org/packages/d4/64/56dd03430ba491db943a81dcdef115a985aac5f44f565cd39a00c766d45c/rpds_py-0.27.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:42a89282d711711d0a62d6f57d81aa43a1368686c45bc1c46b7f079d55692734", size = 557538, upload-time = "2025-08-27T12:14:57.245Z" },
- { url = "https://files.pythonhosted.org/packages/3f/36/92cc885a3129993b1d963a2a42ecf64e6a8e129d2c7cc980dbeba84e55fb/rpds_py-0.27.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:cf9931f14223de59551ab9d38ed18d92f14f055a5f78c1d8ad6493f735021bbb", size = 588512, upload-time = "2025-08-27T12:14:58.728Z" },
- { url = "https://files.pythonhosted.org/packages/dd/10/6b283707780a81919f71625351182b4f98932ac89a09023cb61865136244/rpds_py-0.27.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:f39f58a27cc6e59f432b568ed8429c7e1641324fbe38131de852cd77b2d534b0", size = 555813, upload-time = "2025-08-27T12:15:00.334Z" },
- { url = "https://files.pythonhosted.org/packages/04/2e/30b5ea18c01379da6272a92825dd7e53dc9d15c88a19e97932d35d430ef7/rpds_py-0.27.1-cp314-cp314t-win32.whl", hash = "sha256:d5fa0ee122dc09e23607a28e6d7b150da16c662e66409bbe85230e4c85bb528a", size = 217385, upload-time = "2025-08-27T12:15:01.937Z" },
- { url = "https://files.pythonhosted.org/packages/32/7d/97119da51cb1dd3f2f3c0805f155a3aa4a95fa44fe7d78ae15e69edf4f34/rpds_py-0.27.1-cp314-cp314t-win_amd64.whl", hash = "sha256:6567d2bb951e21232c2f660c24cf3470bb96de56cdcb3f071a83feeaff8a2772", size = 230097, upload-time = "2025-08-27T12:15:03.961Z" },
+ { url = "https://files.pythonhosted.org/packages/03/e7/98a2f4ac921d82f33e03f3835f5bf3a4a40aa1bfdc57975e74a97b2b4bdd/rpds_py-0.30.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:a161f20d9a43006833cd7068375a94d035714d73a172b681d8881820600abfad", size = 375086, upload-time = "2025-11-30T20:22:17.93Z" },
+ { url = "https://files.pythonhosted.org/packages/4d/a1/bca7fd3d452b272e13335db8d6b0b3ecde0f90ad6f16f3328c6fb150c889/rpds_py-0.30.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6abc8880d9d036ecaafe709079969f56e876fcf107f7a8e9920ba6d5a3878d05", size = 359053, upload-time = "2025-11-30T20:22:19.297Z" },
+ { url = "https://files.pythonhosted.org/packages/65/1c/ae157e83a6357eceff62ba7e52113e3ec4834a84cfe07fa4b0757a7d105f/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ca28829ae5f5d569bb62a79512c842a03a12576375d5ece7d2cadf8abe96ec28", size = 390763, upload-time = "2025-11-30T20:22:21.661Z" },
+ { url = "https://files.pythonhosted.org/packages/d4/36/eb2eb8515e2ad24c0bd43c3ee9cd74c33f7ca6430755ccdb240fd3144c44/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a1010ed9524c73b94d15919ca4d41d8780980e1765babf85f9a2f90d247153dd", size = 408951, upload-time = "2025-11-30T20:22:23.408Z" },
+ { url = "https://files.pythonhosted.org/packages/d6/65/ad8dc1784a331fabbd740ef6f71ce2198c7ed0890dab595adb9ea2d775a1/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f8d1736cfb49381ba528cd5baa46f82fdc65c06e843dab24dd70b63d09121b3f", size = 514622, upload-time = "2025-11-30T20:22:25.16Z" },
+ { url = "https://files.pythonhosted.org/packages/63/8e/0cfa7ae158e15e143fe03993b5bcd743a59f541f5952e1546b1ac1b5fd45/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d948b135c4693daff7bc2dcfc4ec57237a29bd37e60c2fabf5aff2bbacf3e2f1", size = 414492, upload-time = "2025-11-30T20:22:26.505Z" },
+ { url = "https://files.pythonhosted.org/packages/60/1b/6f8f29f3f995c7ffdde46a626ddccd7c63aefc0efae881dc13b6e5d5bb16/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:47f236970bccb2233267d89173d3ad2703cd36a0e2a6e92d0560d333871a3d23", size = 394080, upload-time = "2025-11-30T20:22:27.934Z" },
+ { url = "https://files.pythonhosted.org/packages/6d/d5/a266341051a7a3ca2f4b750a3aa4abc986378431fc2da508c5034d081b70/rpds_py-0.30.0-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:2e6ecb5a5bcacf59c3f912155044479af1d0b6681280048b338b28e364aca1f6", size = 408680, upload-time = "2025-11-30T20:22:29.341Z" },
+ { url = "https://files.pythonhosted.org/packages/10/3b/71b725851df9ab7a7a4e33cf36d241933da66040d195a84781f49c50490c/rpds_py-0.30.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a8fa71a2e078c527c3e9dc9fc5a98c9db40bcc8a92b4e8858e36d329f8684b51", size = 423589, upload-time = "2025-11-30T20:22:31.469Z" },
+ { url = "https://files.pythonhosted.org/packages/00/2b/e59e58c544dc9bd8bd8384ecdb8ea91f6727f0e37a7131baeff8d6f51661/rpds_py-0.30.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:73c67f2db7bc334e518d097c6d1e6fed021bbc9b7d678d6cc433478365d1d5f5", size = 573289, upload-time = "2025-11-30T20:22:32.997Z" },
+ { url = "https://files.pythonhosted.org/packages/da/3e/a18e6f5b460893172a7d6a680e86d3b6bc87a54c1f0b03446a3c8c7b588f/rpds_py-0.30.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:5ba103fb455be00f3b1c2076c9d4264bfcb037c976167a6047ed82f23153f02e", size = 599737, upload-time = "2025-11-30T20:22:34.419Z" },
+ { url = "https://files.pythonhosted.org/packages/5c/e2/714694e4b87b85a18e2c243614974413c60aa107fd815b8cbc42b873d1d7/rpds_py-0.30.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7cee9c752c0364588353e627da8a7e808a66873672bcb5f52890c33fd965b394", size = 563120, upload-time = "2025-11-30T20:22:35.903Z" },
+ { url = "https://files.pythonhosted.org/packages/6f/ab/d5d5e3bcedb0a77f4f613706b750e50a5a3ba1c15ccd3665ecc636c968fd/rpds_py-0.30.0-cp312-cp312-win32.whl", hash = "sha256:1ab5b83dbcf55acc8b08fc62b796ef672c457b17dbd7820a11d6c52c06839bdf", size = 223782, upload-time = "2025-11-30T20:22:37.271Z" },
+ { url = "https://files.pythonhosted.org/packages/39/3b/f786af9957306fdc38a74cef405b7b93180f481fb48453a114bb6465744a/rpds_py-0.30.0-cp312-cp312-win_amd64.whl", hash = "sha256:a090322ca841abd453d43456ac34db46e8b05fd9b3b4ac0c78bcde8b089f959b", size = 240463, upload-time = "2025-11-30T20:22:39.021Z" },
+ { url = "https://files.pythonhosted.org/packages/f3/d2/b91dc748126c1559042cfe41990deb92c4ee3e2b415f6b5234969ffaf0cc/rpds_py-0.30.0-cp312-cp312-win_arm64.whl", hash = "sha256:669b1805bd639dd2989b281be2cfd951c6121b65e729d9b843e9639ef1fd555e", size = 230868, upload-time = "2025-11-30T20:22:40.493Z" },
+ { url = "https://files.pythonhosted.org/packages/ed/dc/d61221eb88ff410de3c49143407f6f3147acf2538c86f2ab7ce65ae7d5f9/rpds_py-0.30.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:f83424d738204d9770830d35290ff3273fbb02b41f919870479fab14b9d303b2", size = 374887, upload-time = "2025-11-30T20:22:41.812Z" },
+ { url = "https://files.pythonhosted.org/packages/fd/32/55fb50ae104061dbc564ef15cc43c013dc4a9f4527a1f4d99baddf56fe5f/rpds_py-0.30.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e7536cd91353c5273434b4e003cbda89034d67e7710eab8761fd918ec6c69cf8", size = 358904, upload-time = "2025-11-30T20:22:43.479Z" },
+ { url = "https://files.pythonhosted.org/packages/58/70/faed8186300e3b9bdd138d0273109784eea2396c68458ed580f885dfe7ad/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2771c6c15973347f50fece41fc447c054b7ac2ae0502388ce3b6738cd366e3d4", size = 389945, upload-time = "2025-11-30T20:22:44.819Z" },
+ { url = "https://files.pythonhosted.org/packages/bd/a8/073cac3ed2c6387df38f71296d002ab43496a96b92c823e76f46b8af0543/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0a59119fc6e3f460315fe9d08149f8102aa322299deaa5cab5b40092345c2136", size = 407783, upload-time = "2025-11-30T20:22:46.103Z" },
+ { url = "https://files.pythonhosted.org/packages/77/57/5999eb8c58671f1c11eba084115e77a8899d6e694d2a18f69f0ba471ec8b/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:76fec018282b4ead0364022e3c54b60bf368b9d926877957a8624b58419169b7", size = 515021, upload-time = "2025-11-30T20:22:47.458Z" },
+ { url = "https://files.pythonhosted.org/packages/e0/af/5ab4833eadc36c0a8ed2bc5c0de0493c04f6c06de223170bd0798ff98ced/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:692bef75a5525db97318e8cd061542b5a79812d711ea03dbc1f6f8dbb0c5f0d2", size = 414589, upload-time = "2025-11-30T20:22:48.872Z" },
+ { url = "https://files.pythonhosted.org/packages/b7/de/f7192e12b21b9e9a68a6d0f249b4af3fdcdff8418be0767a627564afa1f1/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9027da1ce107104c50c81383cae773ef5c24d296dd11c99e2629dbd7967a20c6", size = 394025, upload-time = "2025-11-30T20:22:50.196Z" },
+ { url = "https://files.pythonhosted.org/packages/91/c4/fc70cd0249496493500e7cc2de87504f5aa6509de1e88623431fec76d4b6/rpds_py-0.30.0-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:9cf69cdda1f5968a30a359aba2f7f9aa648a9ce4b580d6826437f2b291cfc86e", size = 408895, upload-time = "2025-11-30T20:22:51.87Z" },
+ { url = "https://files.pythonhosted.org/packages/58/95/d9275b05ab96556fefff73a385813eb66032e4c99f411d0795372d9abcea/rpds_py-0.30.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a4796a717bf12b9da9d3ad002519a86063dcac8988b030e405704ef7d74d2d9d", size = 422799, upload-time = "2025-11-30T20:22:53.341Z" },
+ { url = "https://files.pythonhosted.org/packages/06/c1/3088fc04b6624eb12a57eb814f0d4997a44b0d208d6cace713033ff1a6ba/rpds_py-0.30.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:5d4c2aa7c50ad4728a094ebd5eb46c452e9cb7edbfdb18f9e1221f597a73e1e7", size = 572731, upload-time = "2025-11-30T20:22:54.778Z" },
+ { url = "https://files.pythonhosted.org/packages/d8/42/c612a833183b39774e8ac8fecae81263a68b9583ee343db33ab571a7ce55/rpds_py-0.30.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ba81a9203d07805435eb06f536d95a266c21e5b2dfbf6517748ca40c98d19e31", size = 599027, upload-time = "2025-11-30T20:22:56.212Z" },
+ { url = "https://files.pythonhosted.org/packages/5f/60/525a50f45b01d70005403ae0e25f43c0384369ad24ffe46e8d9068b50086/rpds_py-0.30.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:945dccface01af02675628334f7cf49c2af4c1c904748efc5cf7bbdf0b579f95", size = 563020, upload-time = "2025-11-30T20:22:58.2Z" },
+ { url = "https://files.pythonhosted.org/packages/0b/5d/47c4655e9bcd5ca907148535c10e7d489044243cc9941c16ed7cd53be91d/rpds_py-0.30.0-cp313-cp313-win32.whl", hash = "sha256:b40fb160a2db369a194cb27943582b38f79fc4887291417685f3ad693c5a1d5d", size = 223139, upload-time = "2025-11-30T20:23:00.209Z" },
+ { url = "https://files.pythonhosted.org/packages/f2/e1/485132437d20aa4d3e1d8b3fb5a5e65aa8139f1e097080c2a8443201742c/rpds_py-0.30.0-cp313-cp313-win_amd64.whl", hash = "sha256:806f36b1b605e2d6a72716f321f20036b9489d29c51c91f4dd29a3e3afb73b15", size = 240224, upload-time = "2025-11-30T20:23:02.008Z" },
+ { url = "https://files.pythonhosted.org/packages/24/95/ffd128ed1146a153d928617b0ef673960130be0009c77d8fbf0abe306713/rpds_py-0.30.0-cp313-cp313-win_arm64.whl", hash = "sha256:d96c2086587c7c30d44f31f42eae4eac89b60dabbac18c7669be3700f13c3ce1", size = 230645, upload-time = "2025-11-30T20:23:03.43Z" },
+ { url = "https://files.pythonhosted.org/packages/ff/1b/b10de890a0def2a319a2626334a7f0ae388215eb60914dbac8a3bae54435/rpds_py-0.30.0-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:eb0b93f2e5c2189ee831ee43f156ed34e2a89a78a66b98cadad955972548be5a", size = 364443, upload-time = "2025-11-30T20:23:04.878Z" },
+ { url = "https://files.pythonhosted.org/packages/0d/bf/27e39f5971dc4f305a4fb9c672ca06f290f7c4e261c568f3dea16a410d47/rpds_py-0.30.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:922e10f31f303c7c920da8981051ff6d8c1a56207dbdf330d9047f6d30b70e5e", size = 353375, upload-time = "2025-11-30T20:23:06.342Z" },
+ { url = "https://files.pythonhosted.org/packages/40/58/442ada3bba6e8e6615fc00483135c14a7538d2ffac30e2d933ccf6852232/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cdc62c8286ba9bf7f47befdcea13ea0e26bf294bda99758fd90535cbaf408000", size = 383850, upload-time = "2025-11-30T20:23:07.825Z" },
+ { url = "https://files.pythonhosted.org/packages/14/14/f59b0127409a33c6ef6f5c1ebd5ad8e32d7861c9c7adfa9a624fc3889f6c/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:47f9a91efc418b54fb8190a6b4aa7813a23fb79c51f4bb84e418f5476c38b8db", size = 392812, upload-time = "2025-11-30T20:23:09.228Z" },
+ { url = "https://files.pythonhosted.org/packages/b3/66/e0be3e162ac299b3a22527e8913767d869e6cc75c46bd844aa43fb81ab62/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1f3587eb9b17f3789ad50824084fa6f81921bbf9a795826570bda82cb3ed91f2", size = 517841, upload-time = "2025-11-30T20:23:11.186Z" },
+ { url = "https://files.pythonhosted.org/packages/3d/55/fa3b9cf31d0c963ecf1ba777f7cf4b2a2c976795ac430d24a1f43d25a6ba/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:39c02563fc592411c2c61d26b6c5fe1e51eaa44a75aa2c8735ca88b0d9599daa", size = 408149, upload-time = "2025-11-30T20:23:12.864Z" },
+ { url = "https://files.pythonhosted.org/packages/60/ca/780cf3b1a32b18c0f05c441958d3758f02544f1d613abf9488cd78876378/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:51a1234d8febafdfd33a42d97da7a43f5dcb120c1060e352a3fbc0c6d36e2083", size = 383843, upload-time = "2025-11-30T20:23:14.638Z" },
+ { url = "https://files.pythonhosted.org/packages/82/86/d5f2e04f2aa6247c613da0c1dd87fcd08fa17107e858193566048a1e2f0a/rpds_py-0.30.0-cp313-cp313t-manylinux_2_31_riscv64.whl", hash = "sha256:eb2c4071ab598733724c08221091e8d80e89064cd472819285a9ab0f24bcedb9", size = 396507, upload-time = "2025-11-30T20:23:16.105Z" },
+ { url = "https://files.pythonhosted.org/packages/4b/9a/453255d2f769fe44e07ea9785c8347edaf867f7026872e76c1ad9f7bed92/rpds_py-0.30.0-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:6bdfdb946967d816e6adf9a3d8201bfad269c67efe6cefd7093ef959683c8de0", size = 414949, upload-time = "2025-11-30T20:23:17.539Z" },
+ { url = "https://files.pythonhosted.org/packages/a3/31/622a86cdc0c45d6df0e9ccb6becdba5074735e7033c20e401a6d9d0e2ca0/rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:c77afbd5f5250bf27bf516c7c4a016813eb2d3e116139aed0096940c5982da94", size = 565790, upload-time = "2025-11-30T20:23:19.029Z" },
+ { url = "https://files.pythonhosted.org/packages/1c/5d/15bbf0fb4a3f58a3b1c67855ec1efcc4ceaef4e86644665fff03e1b66d8d/rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:61046904275472a76c8c90c9ccee9013d70a6d0f73eecefd38c1ae7c39045a08", size = 590217, upload-time = "2025-11-30T20:23:20.885Z" },
+ { url = "https://files.pythonhosted.org/packages/6d/61/21b8c41f68e60c8cc3b2e25644f0e3681926020f11d06ab0b78e3c6bbff1/rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:4c5f36a861bc4b7da6516dbdf302c55313afa09b81931e8280361a4f6c9a2d27", size = 555806, upload-time = "2025-11-30T20:23:22.488Z" },
+ { url = "https://files.pythonhosted.org/packages/f9/39/7e067bb06c31de48de3eb200f9fc7c58982a4d3db44b07e73963e10d3be9/rpds_py-0.30.0-cp313-cp313t-win32.whl", hash = "sha256:3d4a69de7a3e50ffc214ae16d79d8fbb0922972da0356dcf4d0fdca2878559c6", size = 211341, upload-time = "2025-11-30T20:23:24.449Z" },
+ { url = "https://files.pythonhosted.org/packages/0a/4d/222ef0b46443cf4cf46764d9c630f3fe4abaa7245be9417e56e9f52b8f65/rpds_py-0.30.0-cp313-cp313t-win_amd64.whl", hash = "sha256:f14fc5df50a716f7ece6a80b6c78bb35ea2ca47c499e422aa4463455dd96d56d", size = 225768, upload-time = "2025-11-30T20:23:25.908Z" },
+ { url = "https://files.pythonhosted.org/packages/86/81/dad16382ebbd3d0e0328776d8fd7ca94220e4fa0798d1dc5e7da48cb3201/rpds_py-0.30.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:68f19c879420aa08f61203801423f6cd5ac5f0ac4ac82a2368a9fcd6a9a075e0", size = 362099, upload-time = "2025-11-30T20:23:27.316Z" },
+ { url = "https://files.pythonhosted.org/packages/2b/60/19f7884db5d5603edf3c6bce35408f45ad3e97e10007df0e17dd57af18f8/rpds_py-0.30.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:ec7c4490c672c1a0389d319b3a9cfcd098dcdc4783991553c332a15acf7249be", size = 353192, upload-time = "2025-11-30T20:23:29.151Z" },
+ { url = "https://files.pythonhosted.org/packages/bf/c4/76eb0e1e72d1a9c4703c69607cec123c29028bff28ce41588792417098ac/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f251c812357a3fed308d684a5079ddfb9d933860fc6de89f2b7ab00da481e65f", size = 384080, upload-time = "2025-11-30T20:23:30.785Z" },
+ { url = "https://files.pythonhosted.org/packages/72/87/87ea665e92f3298d1b26d78814721dc39ed8d2c74b86e83348d6b48a6f31/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ac98b175585ecf4c0348fd7b29c3864bda53b805c773cbf7bfdaffc8070c976f", size = 394841, upload-time = "2025-11-30T20:23:32.209Z" },
+ { url = "https://files.pythonhosted.org/packages/77/ad/7783a89ca0587c15dcbf139b4a8364a872a25f861bdb88ed99f9b0dec985/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3e62880792319dbeb7eb866547f2e35973289e7d5696c6e295476448f5b63c87", size = 516670, upload-time = "2025-11-30T20:23:33.742Z" },
+ { url = "https://files.pythonhosted.org/packages/5b/3c/2882bdac942bd2172f3da574eab16f309ae10a3925644e969536553cb4ee/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4e7fc54e0900ab35d041b0601431b0a0eb495f0851a0639b6ef90f7741b39a18", size = 408005, upload-time = "2025-11-30T20:23:35.253Z" },
+ { url = "https://files.pythonhosted.org/packages/ce/81/9a91c0111ce1758c92516a3e44776920b579d9a7c09b2b06b642d4de3f0f/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:47e77dc9822d3ad616c3d5759ea5631a75e5809d5a28707744ef79d7a1bcfcad", size = 382112, upload-time = "2025-11-30T20:23:36.842Z" },
+ { url = "https://files.pythonhosted.org/packages/cf/8e/1da49d4a107027e5fbc64daeab96a0706361a2918da10cb41769244b805d/rpds_py-0.30.0-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:b4dc1a6ff022ff85ecafef7979a2c6eb423430e05f1165d6688234e62ba99a07", size = 399049, upload-time = "2025-11-30T20:23:38.343Z" },
+ { url = "https://files.pythonhosted.org/packages/df/5a/7ee239b1aa48a127570ec03becbb29c9d5a9eb092febbd1699d567cae859/rpds_py-0.30.0-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4559c972db3a360808309e06a74628b95eaccbf961c335c8fe0d590cf587456f", size = 415661, upload-time = "2025-11-30T20:23:40.263Z" },
+ { url = "https://files.pythonhosted.org/packages/70/ea/caa143cf6b772f823bc7929a45da1fa83569ee49b11d18d0ada7f5ee6fd6/rpds_py-0.30.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:0ed177ed9bded28f8deb6ab40c183cd1192aa0de40c12f38be4d59cd33cb5c65", size = 565606, upload-time = "2025-11-30T20:23:42.186Z" },
+ { url = "https://files.pythonhosted.org/packages/64/91/ac20ba2d69303f961ad8cf55bf7dbdb4763f627291ba3d0d7d67333cced9/rpds_py-0.30.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:ad1fa8db769b76ea911cb4e10f049d80bf518c104f15b3edb2371cc65375c46f", size = 591126, upload-time = "2025-11-30T20:23:44.086Z" },
+ { url = "https://files.pythonhosted.org/packages/21/20/7ff5f3c8b00c8a95f75985128c26ba44503fb35b8e0259d812766ea966c7/rpds_py-0.30.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:46e83c697b1f1c72b50e5ee5adb4353eef7406fb3f2043d64c33f20ad1c2fc53", size = 553371, upload-time = "2025-11-30T20:23:46.004Z" },
+ { url = "https://files.pythonhosted.org/packages/72/c7/81dadd7b27c8ee391c132a6b192111ca58d866577ce2d9b0ca157552cce0/rpds_py-0.30.0-cp314-cp314-win32.whl", hash = "sha256:ee454b2a007d57363c2dfd5b6ca4a5d7e2c518938f8ed3b706e37e5d470801ed", size = 215298, upload-time = "2025-11-30T20:23:47.696Z" },
+ { url = "https://files.pythonhosted.org/packages/3e/d2/1aaac33287e8cfb07aab2e6b8ac1deca62f6f65411344f1433c55e6f3eb8/rpds_py-0.30.0-cp314-cp314-win_amd64.whl", hash = "sha256:95f0802447ac2d10bcc69f6dc28fe95fdf17940367b21d34e34c737870758950", size = 228604, upload-time = "2025-11-30T20:23:49.501Z" },
+ { url = "https://files.pythonhosted.org/packages/e8/95/ab005315818cc519ad074cb7784dae60d939163108bd2b394e60dc7b5461/rpds_py-0.30.0-cp314-cp314-win_arm64.whl", hash = "sha256:613aa4771c99f03346e54c3f038e4cc574ac09a3ddfb0e8878487335e96dead6", size = 222391, upload-time = "2025-11-30T20:23:50.96Z" },
+ { url = "https://files.pythonhosted.org/packages/9e/68/154fe0194d83b973cdedcdcc88947a2752411165930182ae41d983dcefa6/rpds_py-0.30.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:7e6ecfcb62edfd632e56983964e6884851786443739dbfe3582947e87274f7cb", size = 364868, upload-time = "2025-11-30T20:23:52.494Z" },
+ { url = "https://files.pythonhosted.org/packages/83/69/8bbc8b07ec854d92a8b75668c24d2abcb1719ebf890f5604c61c9369a16f/rpds_py-0.30.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a1d0bc22a7cdc173fedebb73ef81e07faef93692b8c1ad3733b67e31e1b6e1b8", size = 353747, upload-time = "2025-11-30T20:23:54.036Z" },
+ { url = "https://files.pythonhosted.org/packages/ab/00/ba2e50183dbd9abcce9497fa5149c62b4ff3e22d338a30d690f9af970561/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0d08f00679177226c4cb8c5265012eea897c8ca3b93f429e546600c971bcbae7", size = 383795, upload-time = "2025-11-30T20:23:55.556Z" },
+ { url = "https://files.pythonhosted.org/packages/05/6f/86f0272b84926bcb0e4c972262f54223e8ecc556b3224d281e6598fc9268/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5965af57d5848192c13534f90f9dd16464f3c37aaf166cc1da1cae1fd5a34898", size = 393330, upload-time = "2025-11-30T20:23:57.033Z" },
+ { url = "https://files.pythonhosted.org/packages/cb/e9/0e02bb2e6dc63d212641da45df2b0bf29699d01715913e0d0f017ee29438/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9a4e86e34e9ab6b667c27f3211ca48f73dba7cd3d90f8d5b11be56e5dbc3fb4e", size = 518194, upload-time = "2025-11-30T20:23:58.637Z" },
+ { url = "https://files.pythonhosted.org/packages/ee/ca/be7bca14cf21513bdf9c0606aba17d1f389ea2b6987035eb4f62bd923f25/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e5d3e6b26f2c785d65cc25ef1e5267ccbe1b069c5c21b8cc724efee290554419", size = 408340, upload-time = "2025-11-30T20:24:00.2Z" },
+ { url = "https://files.pythonhosted.org/packages/c2/c7/736e00ebf39ed81d75544c0da6ef7b0998f8201b369acf842f9a90dc8fce/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:626a7433c34566535b6e56a1b39a7b17ba961e97ce3b80ec62e6f1312c025551", size = 383765, upload-time = "2025-11-30T20:24:01.759Z" },
+ { url = "https://files.pythonhosted.org/packages/4a/3f/da50dfde9956aaf365c4adc9533b100008ed31aea635f2b8d7b627e25b49/rpds_py-0.30.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:acd7eb3f4471577b9b5a41baf02a978e8bdeb08b4b355273994f8b87032000a8", size = 396834, upload-time = "2025-11-30T20:24:03.687Z" },
+ { url = "https://files.pythonhosted.org/packages/4e/00/34bcc2565b6020eab2623349efbdec810676ad571995911f1abdae62a3a0/rpds_py-0.30.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fe5fa731a1fa8a0a56b0977413f8cacac1768dad38d16b3a296712709476fbd5", size = 415470, upload-time = "2025-11-30T20:24:05.232Z" },
+ { url = "https://files.pythonhosted.org/packages/8c/28/882e72b5b3e6f718d5453bd4d0d9cf8df36fddeb4ddbbab17869d5868616/rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:74a3243a411126362712ee1524dfc90c650a503502f135d54d1b352bd01f2404", size = 565630, upload-time = "2025-11-30T20:24:06.878Z" },
+ { url = "https://files.pythonhosted.org/packages/3b/97/04a65539c17692de5b85c6e293520fd01317fd878ea1995f0367d4532fb1/rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:3e8eeb0544f2eb0d2581774be4c3410356eba189529a6b3e36bbbf9696175856", size = 591148, upload-time = "2025-11-30T20:24:08.445Z" },
+ { url = "https://files.pythonhosted.org/packages/85/70/92482ccffb96f5441aab93e26c4d66489eb599efdcf96fad90c14bbfb976/rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:dbd936cde57abfee19ab3213cf9c26be06d60750e60a8e4dd85d1ab12c8b1f40", size = 556030, upload-time = "2025-11-30T20:24:10.956Z" },
+ { url = "https://files.pythonhosted.org/packages/20/53/7c7e784abfa500a2b6b583b147ee4bb5a2b3747a9166bab52fec4b5b5e7d/rpds_py-0.30.0-cp314-cp314t-win32.whl", hash = "sha256:dc824125c72246d924f7f796b4f63c1e9dc810c7d9e2355864b3c3a73d59ade0", size = 211570, upload-time = "2025-11-30T20:24:12.735Z" },
+ { url = "https://files.pythonhosted.org/packages/d0/02/fa464cdfbe6b26e0600b62c528b72d8608f5cc49f96b8d6e38c95d60c676/rpds_py-0.30.0-cp314-cp314t-win_amd64.whl", hash = "sha256:27f4b0e92de5bfbc6f86e43959e6edd1425c33b5e69aab0984a72047f2bcf1e3", size = 226532, upload-time = "2025-11-30T20:24:14.634Z" },
+]
+
+[[package]]
+name = "ruff"
+version = "0.15.11"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/e4/8d/192f3d7103816158dfd5ea50d098ef2aec19194e6cbccd4b3485bdb2eb2d/ruff-0.15.11.tar.gz", hash = "sha256:f092b21708bf0e7437ce9ada249dfe688ff9a0954fc94abab05dcea7dcd29c33", size = 4637264, upload-time = "2026-04-16T18:46:26.58Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/02/1e/6aca3427f751295ab011828e15e9bf452200ac74484f1db4be0197b8170b/ruff-0.15.11-py3-none-linux_armv6l.whl", hash = "sha256:e927cfff503135c558eb581a0c9792264aae9507904eb27809cdcff2f2c847b7", size = 10607943, upload-time = "2026-04-16T18:46:05.967Z" },
+ { url = "https://files.pythonhosted.org/packages/e7/26/1341c262e74f36d4e84f3d6f4df0ac68cd53331a66bfc5080daa17c84c0b/ruff-0.15.11-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:7a1b5b2938d8f890b76084d4fa843604d787a912541eae85fd7e233398bbb73e", size = 10988592, upload-time = "2026-04-16T18:46:00.742Z" },
+ { url = "https://files.pythonhosted.org/packages/03/71/850b1d6ffa9564fbb6740429bad53df1094082fe515c8c1e74b6d8d05f18/ruff-0.15.11-py3-none-macosx_11_0_arm64.whl", hash = "sha256:d4176f3d194afbdaee6e41b9ccb1a2c287dba8700047df474abfbe773825d1cb", size = 10338501, upload-time = "2026-04-16T18:46:03.723Z" },
+ { url = "https://files.pythonhosted.org/packages/f2/11/cc1284d3e298c45a817a6aadb6c3e1d70b45c9b36d8d9cce3387b495a03a/ruff-0.15.11-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3b17c886fb88203ced3afe7f14e8d5ae96e9d2f4ccc0ee66aa19f2c2675a27e4", size = 10670693, upload-time = "2026-04-16T18:46:41.941Z" },
+ { url = "https://files.pythonhosted.org/packages/ce/9e/f8288b034ab72b371513c13f9a41d9ba3effac54e24bfb467b007daee2ca/ruff-0.15.11-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:49fafa220220afe7758a487b048de4c8f9f767f37dfefad46b9dd06759d003eb", size = 10416177, upload-time = "2026-04-16T18:46:21.717Z" },
+ { url = "https://files.pythonhosted.org/packages/85/71/504d79abfd3d92532ba6bbe3d1c19fada03e494332a59e37c7c2dabae427/ruff-0.15.11-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f2ab8427e74a00d93b8bda1307b1e60970d40f304af38bccb218e056c220120d", size = 11221886, upload-time = "2026-04-16T18:46:15.086Z" },
+ { url = "https://files.pythonhosted.org/packages/43/5a/947e6ab7a5ad603d65b474be15a4cbc6d29832db5d762cd142e4e3a74164/ruff-0.15.11-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:195072c0c8e1fc8f940652073df082e37a5d9cb43b4ab1e4d0566ab8977a13b7", size = 12075183, upload-time = "2026-04-16T18:46:07.944Z" },
+ { url = "https://files.pythonhosted.org/packages/9f/a1/0b7bb6268775fdd3a0818aee8efd8f5b4e231d24dd4d528ced2534023182/ruff-0.15.11-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a3a0996d486af3920dec930a2e7daed4847dfc12649b537a9335585ada163e9e", size = 11516575, upload-time = "2026-04-16T18:46:31.687Z" },
+ { url = "https://files.pythonhosted.org/packages/30/c3/bb5168fc4d233cc06e95f482770d0f3c87945a0cd9f614b90ea8dc2f2833/ruff-0.15.11-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1bef2cb556d509259f1fe440bb9cd33c756222cf0a7afe90d15edf0866702431", size = 11306537, upload-time = "2026-04-16T18:46:36.988Z" },
+ { url = "https://files.pythonhosted.org/packages/e4/92/4cfae6441f3967317946f3b788136eecf093729b94d6561f963ed810c82e/ruff-0.15.11-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:030d921a836d7d4a12cf6e8d984a88b66094ccb0e0f17ddd55067c331191bf19", size = 11296813, upload-time = "2026-04-16T18:46:24.182Z" },
+ { url = "https://files.pythonhosted.org/packages/43/26/972784c5dde8313acde8ac71ba8ac65475b85db4a2352a76c9934361f9bc/ruff-0.15.11-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:0e783b599b4577788dbbb66b9addcef87e9a8832f4ce0c19e34bf55543a2f890", size = 10633136, upload-time = "2026-04-16T18:46:39.802Z" },
+ { url = "https://files.pythonhosted.org/packages/5b/53/3985a4f185020c2f367f2e08a103032e12564829742a1b417980ce1514a0/ruff-0.15.11-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:ae90592246625ba4a34349d68ec28d4400d75182b71baa196ddb9f82db025ef5", size = 10424701, upload-time = "2026-04-16T18:46:10.381Z" },
+ { url = "https://files.pythonhosted.org/packages/d3/57/bf0dfb32241b56c83bb663a826133da4bf17f682ba8c096973065f6e6a68/ruff-0.15.11-py3-none-musllinux_1_2_i686.whl", hash = "sha256:1f111d62e3c983ed20e0ca2e800f8d77433a5b1161947df99a5c2a3fb60514f0", size = 10873887, upload-time = "2026-04-16T18:46:29.157Z" },
+ { url = "https://files.pythonhosted.org/packages/02/05/e48076b2a57dc33ee8c7a957296f97c744ca891a8ffb4ffb1aaa3b3f517d/ruff-0.15.11-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:06f483d6646f59eaffba9ae30956370d3a886625f511a3108994000480621d1c", size = 11404316, upload-time = "2026-04-16T18:46:19.462Z" },
+ { url = "https://files.pythonhosted.org/packages/88/27/0195d15fe7a897cbcba0904792c4b7c9fdd958456c3a17d2ea6093716a9a/ruff-0.15.11-py3-none-win32.whl", hash = "sha256:476a2aa56b7da0b73a3ee80b6b2f0e19cce544245479adde7baa65466664d5f3", size = 10655535, upload-time = "2026-04-16T18:46:12.47Z" },
+ { url = "https://files.pythonhosted.org/packages/3a/5e/c927b325bd4c1d3620211a4b96f47864633199feed60fa936025ab27e090/ruff-0.15.11-py3-none-win_amd64.whl", hash = "sha256:8b6756d88d7e234fb0c98c91511aae3cd519d5e3ed271cae31b20f39cb2a12a3", size = 11779692, upload-time = "2026-04-16T18:46:17.268Z" },
+ { url = "https://files.pythonhosted.org/packages/63/b6/aeadee5443e49baa2facd51131159fd6301cc4ccfc1541e4df7b021c37dd/ruff-0.15.11-py3-none-win_arm64.whl", hash = "sha256:063fed18cc1bbe0ee7393957284a6fe8b588c6a406a285af3ee3f46da2391ee4", size = 11032614, upload-time = "2026-04-16T18:46:34.487Z" },
]
[[package]]
@@ -1192,71 +1144,39 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/b7/46/f5af3402b579fd5e11573ce652019a67074317e18c1935cc0b4ba9b35552/secretstorage-3.5.0-py3-none-any.whl", hash = "sha256:0ce65888c0725fcb2c5bc0fdb8e5438eece02c523557ea40ce0703c266248137", size = 15554, upload-time = "2025-11-23T19:02:51.545Z" },
]
-[[package]]
-name = "shellingham"
-version = "1.5.4"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/58/15/8b3609fd3830ef7b27b655beb4b4e9c62313a4e8da8c676e142cc210d58e/shellingham-1.5.4.tar.gz", hash = "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de", size = 10310, upload-time = "2023-10-24T04:13:40.426Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755, upload-time = "2023-10-24T04:13:38.866Z" },
-]
-
-[[package]]
-name = "sniffio"
-version = "1.3.1"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/a2/87/a6771e1546d97e7e041b6ae58d80074f81b7d5121207425c964ddf5cfdbd/sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc", size = 20372, upload-time = "2024-02-25T23:20:04.057Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235, upload-time = "2024-02-25T23:20:01.196Z" },
-]
-
-[[package]]
-name = "sortedcontainers"
-version = "2.4.0"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/e8/c4/ba2f8066cceb6f23394729afe52f3bf7adec04bf9ed2c820b39e19299111/sortedcontainers-2.4.0.tar.gz", hash = "sha256:25caa5a06cc30b6b83d11423433f65d1f9d76c4c6a0c90e3379eaa43b9bfdb88", size = 30594, upload-time = "2021-05-16T22:03:42.897Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/32/46/9cb0e58b2deb7f82b84065f37f3bffeb12413f947f9388e4cac22c4621ce/sortedcontainers-2.4.0-py2.py3-none-any.whl", hash = "sha256:a163dcaede0f1c021485e957a39245190e74249897e2ae4b2aa38595db237ee0", size = 29575, upload-time = "2021-05-16T22:03:41.177Z" },
-]
-
[[package]]
name = "sse-starlette"
-version = "3.0.2"
+version = "3.4.4"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio" },
+ { name = "starlette" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/42/6f/22ed6e33f8a9e76ca0a412405f31abb844b779d52c5f96660766edcd737c/sse_starlette-3.0.2.tar.gz", hash = "sha256:ccd60b5765ebb3584d0de2d7a6e4f745672581de4f5005ab31c3a25d10b52b3a", size = 20985, upload-time = "2025-07-27T09:07:44.565Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/f7/2b/58abc2d1fd397e7dde08e947e05c884d8ef2f78d5e2588c17a12d42d6994/sse_starlette-3.4.4.tar.gz", hash = "sha256:07e0fa0460138baf25cdd5fb28683472c3995dc1642225191b3832d62526bcb0", size = 31819, upload-time = "2026-05-12T17:37:17.019Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/ef/10/c78f463b4ef22eef8491f218f692be838282cd65480f6e423d7730dfd1fb/sse_starlette-3.0.2-py3-none-any.whl", hash = "sha256:16b7cbfddbcd4eaca11f7b586f3b8a080f1afe952c15813455b162edea619e5a", size = 11297, upload-time = "2025-07-27T09:07:43.268Z" },
+ { url = "https://files.pythonhosted.org/packages/dc/67/805710444ea8cc75fbf70b920ed431a560c4bf9c57f7d5a3117213189399/sse_starlette-3.4.4-py3-none-any.whl", hash = "sha256:3f4dd50d8aed2771a091f3a83000323fc3844541c16b4fe585ae2420cc6df973", size = 16514, upload-time = "2026-05-12T17:37:15.601Z" },
]
[[package]]
name = "starlette"
-version = "0.48.0"
+version = "1.3.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio" },
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/a7/a5/d6f429d43394057b67a6b5bbe6eae2f77a6bf7459d961fdb224bf206eee6/starlette-0.48.0.tar.gz", hash = "sha256:7e8cee469a8ab2352911528110ce9088fdc6a37d9876926e73da7ce4aa4c7a46", size = 2652949, upload-time = "2025-09-13T08:41:05.699Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/eb/e3/7c1dc7381d9f8ab7d854328ebfa884e62cb3f3d8549ddfd37c7814f42afa/starlette-1.3.1.tar.gz", hash = "sha256:05d0213193f2fbaae60e2ecb593b4add4262ad4e46536b54abe36f11a71724e0", size = 2703240, upload-time = "2026-06-12T09:23:11.602Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/be/72/2db2f49247d0a18b4f1bb9a5a39a0162869acf235f3a96418363947b3d46/starlette-0.48.0-py3-none-any.whl", hash = "sha256:0764ca97b097582558ecb498132ed0c7d942f233f365b86ba37770e026510659", size = 73736, upload-time = "2025-09-13T08:41:03.869Z" },
+ { url = "https://files.pythonhosted.org/packages/ec/bb/2799cc2ede3ed41131f8975621e7213dfc7ef4acbbaadfa440f32500c370/starlette-1.3.1-py3-none-any.whl", hash = "sha256:c7372aae11c3c3f26a42df7bd626cec2f47d03483d261d369516a615a53714c6", size = 73632, upload-time = "2026-06-12T09:23:10.017Z" },
]
[[package]]
-name = "typer"
-version = "0.21.1"
+name = "stevedore"
+version = "5.7.0"
source = { registry = "https://pypi.org/simple" }
-dependencies = [
- { name = "click" },
- { name = "rich" },
- { name = "shellingham" },
- { name = "typing-extensions" },
-]
-sdist = { url = "https://files.pythonhosted.org/packages/36/bf/8825b5929afd84d0dabd606c67cd57b8388cb3ec385f7ef19c5cc2202069/typer-0.21.1.tar.gz", hash = "sha256:ea835607cd752343b6b2b7ce676893e5a0324082268b48f27aa058bdb7d2145d", size = 110371, upload-time = "2026-01-06T11:21:10.989Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/a2/6d/90764092216fa560f6587f83bb70113a8ba510ba436c6476a2b47359057c/stevedore-5.7.0.tar.gz", hash = "sha256:31dd6fe6b3cbe921e21dcefabc9a5f1cf848cf538a1f27543721b8ca09948aa3", size = 516200, upload-time = "2026-02-20T13:27:06.765Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/a0/1d/d9257dd49ff2ca23ea5f132edf1281a0c4f9de8a762b9ae399b670a59235/typer-0.21.1-py3-none-any.whl", hash = "sha256:7985e89081c636b88d172c2ee0cfe33c253160994d47bdfdc302defd7d1f1d01", size = 47381, upload-time = "2026-01-06T11:21:09.824Z" },
+ { url = "https://files.pythonhosted.org/packages/69/06/36d260a695f383345ab5bbc3fd447249594ae2fa8dfd19c533d5ae23f46b/stevedore-5.7.0-py3-none-any.whl", hash = "sha256:fd25efbb32f1abb4c9e502f385f0018632baac11f9ee5d1b70f88cc5e22ad4ed", size = 54483, upload-time = "2026-02-20T13:27:05.561Z" },
]
[[package]]
@@ -1270,74 +1190,167 @@ wheels = [
[[package]]
name = "typing-inspection"
-version = "0.4.1"
+version = "0.4.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "typing-extensions" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/f8/b1/0c11f5058406b3af7609f121aaa6b609744687f1d158b3c3a5bf4cc94238/typing_inspection-0.4.1.tar.gz", hash = "sha256:6ae134cc0203c33377d43188d4064e9b357dba58cff3185f22924610e70a9d28", size = 75726, upload-time = "2025-05-21T18:55:23.885Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/17/69/cd203477f944c353c31bade965f880aa1061fd6bf05ded0726ca845b6ff7/typing_inspection-0.4.1-py3-none-any.whl", hash = "sha256:389055682238f53b04f7badcb49b989835495a96700ced5dab2d8feae4b26f51", size = 14552, upload-time = "2025-05-21T18:55:22.152Z" },
+ { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" },
]
[[package]]
-name = "urllib3"
-version = "2.5.0"
+name = "uncalled-for"
+version = "0.3.2"
source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/15/22/9ee70a2574a4f4599c47dd506532914ce044817c7752a79b6a51286319bc/urllib3-2.5.0.tar.gz", hash = "sha256:3fc47733c7e419d4bc3f6b3dc2b4f890bb743906a30d56ba4a5bfa4bbff92760", size = 393185, upload-time = "2025-06-18T14:07:41.644Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/b5/82/345cc927f7fbdae6065e7768759932fcc827fc20b29b45dfbafa2f1f7da4/uncalled_for-0.3.2.tar.gz", hash = "sha256:89f5dbcd71e2b8f47c030b1fa302e6cce2ec795d1ac565eeb6525c5fe55cb8a2", size = 50032, upload-time = "2026-05-06T13:38:25.204Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/a7/c2/fe1e52489ae3122415c51f387e221dd0773709bad6c6cdaa599e8a2c5185/urllib3-2.5.0-py3-none-any.whl", hash = "sha256:e6b01673c0fa6a13e374b50871808eb3bf7046c4b125b216f6bf1cc604cff0dc", size = 129795, upload-time = "2025-06-18T14:07:40.39Z" },
+ { url = "https://files.pythonhosted.org/packages/3b/25/2c87754f3a9e692315f7b811244090e68f362979fc8886b3fbd2985a1d8c/uncalled_for-0.3.2-py3-none-any.whl", hash = "sha256:0ff60b142c7d1f8070bde9d42afaa70aedc77dcc10998c227687e9c15713418e", size = 11444, upload-time = "2026-05-06T13:38:24.025Z" },
]
[[package]]
name = "uvicorn"
-version = "0.36.0"
+version = "0.46.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "click" },
{ name = "h11" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/ef/5e/f0cd46063a02fd8515f0e880c37d2657845b7306c16ce6c4ffc44afd9036/uvicorn-0.36.0.tar.gz", hash = "sha256:527dc68d77819919d90a6b267be55f0e76704dca829d34aea9480be831a9b9d9", size = 80032, upload-time = "2025-09-20T01:07:14.418Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/1f/93/041fca8274050e40e6791f267d82e0e2e27dd165627bd640d3e0e378d877/uvicorn-0.46.0.tar.gz", hash = "sha256:fb9da0926999cc6cb22dc7cd71a94a632f078e6ae47ff683c5c420750fb7413d", size = 88758, upload-time = "2026-04-23T07:16:00.151Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/96/06/5cc0542b47c0338c1cb676b348e24a1c29acabc81000bced518231dded6f/uvicorn-0.36.0-py3-none-any.whl", hash = "sha256:6bb4ba67f16024883af8adf13aba3a9919e415358604ce46780d3f9bdc36d731", size = 67675, upload-time = "2025-09-20T01:07:12.984Z" },
+ { url = "https://files.pythonhosted.org/packages/31/a3/5b1562db76a5a488274b2332a97199b32d0442aca0ed193697fd47786316/uvicorn-0.46.0-py3-none-any.whl", hash = "sha256:bbebbcbed972d162afca128605223022bedd345b7bc7855ce66deb31487a9048", size = 70926, upload-time = "2026-04-23T07:15:58.355Z" },
+]
+
+[[package]]
+name = "vulture"
+version = "2.14"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/8e/25/925f35db758a0f9199113aaf61d703de891676b082bd7cf73ea01d6000f7/vulture-2.14.tar.gz", hash = "sha256:cb8277902a1138deeab796ec5bef7076a6e0248ca3607a3f3dee0b6d9e9b8415", size = 58823, upload-time = "2024-12-08T17:39:43.319Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/a0/56/0cc15b8ff2613c1d5c3dc1f3f576ede1c43868c1bc2e5ccaa2d4bcd7974d/vulture-2.14-py2.py3-none-any.whl", hash = "sha256:d9a90dba89607489548a49d557f8bac8112bd25d3cbc8aeef23e860811bd5ed9", size = 28915, upload-time = "2024-12-08T17:39:40.573Z" },
+]
+
+[[package]]
+name = "watchfiles"
+version = "1.1.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "anyio" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/c2/c9/8869df9b2a2d6c59d79220a4db37679e74f807c559ffe5265e08b227a210/watchfiles-1.1.1.tar.gz", hash = "sha256:a173cb5c16c4f40ab19cecf48a534c409f7ea983ab8fed0741304a1c0a31b3f2", size = 94440, upload-time = "2025-10-14T15:06:21.08Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/74/d5/f039e7e3c639d9b1d09b07ea412a6806d38123f0508e5f9b48a87b0a76cc/watchfiles-1.1.1-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:8c89f9f2f740a6b7dcc753140dd5e1ab9215966f7a3530d0c0705c83b401bd7d", size = 404745, upload-time = "2025-10-14T15:04:46.731Z" },
+ { url = "https://files.pythonhosted.org/packages/a5/96/a881a13aa1349827490dab2d363c8039527060cfcc2c92cc6d13d1b1049e/watchfiles-1.1.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:bd404be08018c37350f0d6e34676bd1e2889990117a2b90070b3007f172d0610", size = 391769, upload-time = "2025-10-14T15:04:48.003Z" },
+ { url = "https://files.pythonhosted.org/packages/4b/5b/d3b460364aeb8da471c1989238ea0e56bec24b6042a68046adf3d9ddb01c/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8526e8f916bb5b9a0a777c8317c23ce65de259422bba5b31325a6fa6029d33af", size = 449374, upload-time = "2025-10-14T15:04:49.179Z" },
+ { url = "https://files.pythonhosted.org/packages/b9/44/5769cb62d4ed055cb17417c0a109a92f007114a4e07f30812a73a4efdb11/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2edc3553362b1c38d9f06242416a5d8e9fe235c204a4072e988ce2e5bb1f69f6", size = 459485, upload-time = "2025-10-14T15:04:50.155Z" },
+ { url = "https://files.pythonhosted.org/packages/19/0c/286b6301ded2eccd4ffd0041a1b726afda999926cf720aab63adb68a1e36/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:30f7da3fb3f2844259cba4720c3fc7138eb0f7b659c38f3bfa65084c7fc7abce", size = 488813, upload-time = "2025-10-14T15:04:51.059Z" },
+ { url = "https://files.pythonhosted.org/packages/c7/2b/8530ed41112dd4a22f4dcfdb5ccf6a1baad1ff6eed8dc5a5f09e7e8c41c7/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f8979280bdafff686ba5e4d8f97840f929a87ed9cdf133cbbd42f7766774d2aa", size = 594816, upload-time = "2025-10-14T15:04:52.031Z" },
+ { url = "https://files.pythonhosted.org/packages/ce/d2/f5f9fb49489f184f18470d4f99f4e862a4b3e9ac2865688eb2099e3d837a/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:dcc5c24523771db3a294c77d94771abcfcb82a0e0ee8efd910c37c59ec1b31bb", size = 475186, upload-time = "2025-10-14T15:04:53.064Z" },
+ { url = "https://files.pythonhosted.org/packages/cf/68/5707da262a119fb06fbe214d82dd1fe4a6f4af32d2d14de368d0349eb52a/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1db5d7ae38ff20153d542460752ff397fcf5c96090c1230803713cf3147a6803", size = 456812, upload-time = "2025-10-14T15:04:55.174Z" },
+ { url = "https://files.pythonhosted.org/packages/66/ab/3cbb8756323e8f9b6f9acb9ef4ec26d42b2109bce830cc1f3468df20511d/watchfiles-1.1.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:28475ddbde92df1874b6c5c8aaeb24ad5be47a11f87cde5a28ef3835932e3e94", size = 630196, upload-time = "2025-10-14T15:04:56.22Z" },
+ { url = "https://files.pythonhosted.org/packages/78/46/7152ec29b8335f80167928944a94955015a345440f524d2dfe63fc2f437b/watchfiles-1.1.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:36193ed342f5b9842edd3532729a2ad55c4160ffcfa3700e0d54be496b70dd43", size = 622657, upload-time = "2025-10-14T15:04:57.521Z" },
+ { url = "https://files.pythonhosted.org/packages/0a/bf/95895e78dd75efe9a7f31733607f384b42eb5feb54bd2eb6ed57cc2e94f4/watchfiles-1.1.1-cp312-cp312-win32.whl", hash = "sha256:859e43a1951717cc8de7f4c77674a6d389b106361585951d9e69572823f311d9", size = 272042, upload-time = "2025-10-14T15:04:59.046Z" },
+ { url = "https://files.pythonhosted.org/packages/87/0a/90eb755f568de2688cb220171c4191df932232c20946966c27a59c400850/watchfiles-1.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:91d4c9a823a8c987cce8fa2690923b069966dabb196dd8d137ea2cede885fde9", size = 288410, upload-time = "2025-10-14T15:05:00.081Z" },
+ { url = "https://files.pythonhosted.org/packages/36/76/f322701530586922fbd6723c4f91ace21364924822a8772c549483abed13/watchfiles-1.1.1-cp312-cp312-win_arm64.whl", hash = "sha256:a625815d4a2bdca61953dbba5a39d60164451ef34c88d751f6c368c3ea73d404", size = 278209, upload-time = "2025-10-14T15:05:01.168Z" },
+ { url = "https://files.pythonhosted.org/packages/bb/f4/f750b29225fe77139f7ae5de89d4949f5a99f934c65a1f1c0b248f26f747/watchfiles-1.1.1-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:130e4876309e8686a5e37dba7d5e9bc77e6ed908266996ca26572437a5271e18", size = 404321, upload-time = "2025-10-14T15:05:02.063Z" },
+ { url = "https://files.pythonhosted.org/packages/2b/f9/f07a295cde762644aa4c4bb0f88921d2d141af45e735b965fb2e87858328/watchfiles-1.1.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:5f3bde70f157f84ece3765b42b4a52c6ac1a50334903c6eaf765362f6ccca88a", size = 391783, upload-time = "2025-10-14T15:05:03.052Z" },
+ { url = "https://files.pythonhosted.org/packages/bc/11/fc2502457e0bea39a5c958d86d2cb69e407a4d00b85735ca724bfa6e0d1a/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:14e0b1fe858430fc0251737ef3824c54027bedb8c37c38114488b8e131cf8219", size = 449279, upload-time = "2025-10-14T15:05:04.004Z" },
+ { url = "https://files.pythonhosted.org/packages/e3/1f/d66bc15ea0b728df3ed96a539c777acfcad0eb78555ad9efcaa1274688f0/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f27db948078f3823a6bb3b465180db8ebecf26dd5dae6f6180bd87383b6b4428", size = 459405, upload-time = "2025-10-14T15:05:04.942Z" },
+ { url = "https://files.pythonhosted.org/packages/be/90/9f4a65c0aec3ccf032703e6db02d89a157462fbb2cf20dd415128251cac0/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:059098c3a429f62fc98e8ec62b982230ef2c8df68c79e826e37b895bc359a9c0", size = 488976, upload-time = "2025-10-14T15:05:05.905Z" },
+ { url = "https://files.pythonhosted.org/packages/37/57/ee347af605d867f712be7029bb94c8c071732a4b44792e3176fa3c612d39/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bfb5862016acc9b869bb57284e6cb35fdf8e22fe59f7548858e2f971d045f150", size = 595506, upload-time = "2025-10-14T15:05:06.906Z" },
+ { url = "https://files.pythonhosted.org/packages/a8/78/cc5ab0b86c122047f75e8fc471c67a04dee395daf847d3e59381996c8707/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:319b27255aacd9923b8a276bb14d21a5f7ff82564c744235fc5eae58d95422ae", size = 474936, upload-time = "2025-10-14T15:05:07.906Z" },
+ { url = "https://files.pythonhosted.org/packages/62/da/def65b170a3815af7bd40a3e7010bf6ab53089ef1b75d05dd5385b87cf08/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c755367e51db90e75b19454b680903631d41f9e3607fbd941d296a020c2d752d", size = 456147, upload-time = "2025-10-14T15:05:09.138Z" },
+ { url = "https://files.pythonhosted.org/packages/57/99/da6573ba71166e82d288d4df0839128004c67d2778d3b566c138695f5c0b/watchfiles-1.1.1-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:c22c776292a23bfc7237a98f791b9ad3144b02116ff10d820829ce62dff46d0b", size = 630007, upload-time = "2025-10-14T15:05:10.117Z" },
+ { url = "https://files.pythonhosted.org/packages/a8/51/7439c4dd39511368849eb1e53279cd3454b4a4dbace80bab88feeb83c6b5/watchfiles-1.1.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:3a476189be23c3686bc2f4321dd501cb329c0a0469e77b7b534ee10129ae6374", size = 622280, upload-time = "2025-10-14T15:05:11.146Z" },
+ { url = "https://files.pythonhosted.org/packages/95/9c/8ed97d4bba5db6fdcdb2b298d3898f2dd5c20f6b73aee04eabe56c59677e/watchfiles-1.1.1-cp313-cp313-win32.whl", hash = "sha256:bf0a91bfb5574a2f7fc223cf95eeea79abfefa404bf1ea5e339c0c1560ae99a0", size = 272056, upload-time = "2025-10-14T15:05:12.156Z" },
+ { url = "https://files.pythonhosted.org/packages/1f/f3/c14e28429f744a260d8ceae18bf58c1d5fa56b50d006a7a9f80e1882cb0d/watchfiles-1.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:52e06553899e11e8074503c8e716d574adeeb7e68913115c4b3653c53f9bae42", size = 288162, upload-time = "2025-10-14T15:05:13.208Z" },
+ { url = "https://files.pythonhosted.org/packages/dc/61/fe0e56c40d5cd29523e398d31153218718c5786b5e636d9ae8ae79453d27/watchfiles-1.1.1-cp313-cp313-win_arm64.whl", hash = "sha256:ac3cc5759570cd02662b15fbcd9d917f7ecd47efe0d6b40474eafd246f91ea18", size = 277909, upload-time = "2025-10-14T15:05:14.49Z" },
+ { url = "https://files.pythonhosted.org/packages/79/42/e0a7d749626f1e28c7108a99fb9bf524b501bbbeb9b261ceecde644d5a07/watchfiles-1.1.1-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:563b116874a9a7ce6f96f87cd0b94f7faf92d08d0021e837796f0a14318ef8da", size = 403389, upload-time = "2025-10-14T15:05:15.777Z" },
+ { url = "https://files.pythonhosted.org/packages/15/49/08732f90ce0fbbc13913f9f215c689cfc9ced345fb1bcd8829a50007cc8d/watchfiles-1.1.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3ad9fe1dae4ab4212d8c91e80b832425e24f421703b5a42ef2e4a1e215aff051", size = 389964, upload-time = "2025-10-14T15:05:16.85Z" },
+ { url = "https://files.pythonhosted.org/packages/27/0d/7c315d4bd5f2538910491a0393c56bf70d333d51bc5b34bee8e68e8cea19/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce70f96a46b894b36eba678f153f052967a0d06d5b5a19b336ab0dbbd029f73e", size = 448114, upload-time = "2025-10-14T15:05:17.876Z" },
+ { url = "https://files.pythonhosted.org/packages/c3/24/9e096de47a4d11bc4df41e9d1e61776393eac4cb6eb11b3e23315b78b2cc/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:cb467c999c2eff23a6417e58d75e5828716f42ed8289fe6b77a7e5a91036ca70", size = 460264, upload-time = "2025-10-14T15:05:18.962Z" },
+ { url = "https://files.pythonhosted.org/packages/cc/0f/e8dea6375f1d3ba5fcb0b3583e2b493e77379834c74fd5a22d66d85d6540/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:836398932192dae4146c8f6f737d74baeac8b70ce14831a239bdb1ca882fc261", size = 487877, upload-time = "2025-10-14T15:05:20.094Z" },
+ { url = "https://files.pythonhosted.org/packages/ac/5b/df24cfc6424a12deb41503b64d42fbea6b8cb357ec62ca84a5a3476f654a/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:743185e7372b7bc7c389e1badcc606931a827112fbbd37f14c537320fca08620", size = 595176, upload-time = "2025-10-14T15:05:21.134Z" },
+ { url = "https://files.pythonhosted.org/packages/8f/b5/853b6757f7347de4e9b37e8cc3289283fb983cba1ab4d2d7144694871d9c/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:afaeff7696e0ad9f02cbb8f56365ff4686ab205fcf9c4c5b6fdfaaa16549dd04", size = 473577, upload-time = "2025-10-14T15:05:22.306Z" },
+ { url = "https://files.pythonhosted.org/packages/e1/f7/0a4467be0a56e80447c8529c9fce5b38eab4f513cb3d9bf82e7392a5696b/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3f7eb7da0eb23aa2ba036d4f616d46906013a68caf61b7fdbe42fc8b25132e77", size = 455425, upload-time = "2025-10-14T15:05:23.348Z" },
+ { url = "https://files.pythonhosted.org/packages/8e/e0/82583485ea00137ddf69bc84a2db88bd92ab4a6e3c405e5fb878ead8d0e7/watchfiles-1.1.1-cp313-cp313t-musllinux_1_1_aarch64.whl", hash = "sha256:831a62658609f0e5c64178211c942ace999517f5770fe9436be4c2faeba0c0ef", size = 628826, upload-time = "2025-10-14T15:05:24.398Z" },
+ { url = "https://files.pythonhosted.org/packages/28/9a/a785356fccf9fae84c0cc90570f11702ae9571036fb25932f1242c82191c/watchfiles-1.1.1-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:f9a2ae5c91cecc9edd47e041a930490c31c3afb1f5e6d71de3dc671bfaca02bf", size = 622208, upload-time = "2025-10-14T15:05:25.45Z" },
+ { url = "https://files.pythonhosted.org/packages/c3/f4/0872229324ef69b2c3edec35e84bd57a1289e7d3fe74588048ed8947a323/watchfiles-1.1.1-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:d1715143123baeeaeadec0528bb7441103979a1d5f6fd0e1f915383fea7ea6d5", size = 404315, upload-time = "2025-10-14T15:05:26.501Z" },
+ { url = "https://files.pythonhosted.org/packages/7b/22/16d5331eaed1cb107b873f6ae1b69e9ced582fcf0c59a50cd84f403b1c32/watchfiles-1.1.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:39574d6370c4579d7f5d0ad940ce5b20db0e4117444e39b6d8f99db5676c52fd", size = 390869, upload-time = "2025-10-14T15:05:27.649Z" },
+ { url = "https://files.pythonhosted.org/packages/b2/7e/5643bfff5acb6539b18483128fdc0ef2cccc94a5b8fbda130c823e8ed636/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7365b92c2e69ee952902e8f70f3ba6360d0d596d9299d55d7d386df84b6941fb", size = 449919, upload-time = "2025-10-14T15:05:28.701Z" },
+ { url = "https://files.pythonhosted.org/packages/51/2e/c410993ba5025a9f9357c376f48976ef0e1b1aefb73b97a5ae01a5972755/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bfff9740c69c0e4ed32416f013f3c45e2ae42ccedd1167ef2d805c000b6c71a5", size = 460845, upload-time = "2025-10-14T15:05:30.064Z" },
+ { url = "https://files.pythonhosted.org/packages/8e/a4/2df3b404469122e8680f0fcd06079317e48db58a2da2950fb45020947734/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b27cf2eb1dda37b2089e3907d8ea92922b673c0c427886d4edc6b94d8dfe5db3", size = 489027, upload-time = "2025-10-14T15:05:31.064Z" },
+ { url = "https://files.pythonhosted.org/packages/ea/84/4587ba5b1f267167ee715b7f66e6382cca6938e0a4b870adad93e44747e6/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:526e86aced14a65a5b0ec50827c745597c782ff46b571dbfe46192ab9e0b3c33", size = 595615, upload-time = "2025-10-14T15:05:32.074Z" },
+ { url = "https://files.pythonhosted.org/packages/6a/0f/c6988c91d06e93cd0bb3d4a808bcf32375ca1904609835c3031799e3ecae/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:04e78dd0b6352db95507fd8cb46f39d185cf8c74e4cf1e4fbad1d3df96faf510", size = 474836, upload-time = "2025-10-14T15:05:33.209Z" },
+ { url = "https://files.pythonhosted.org/packages/b4/36/ded8aebea91919485b7bbabbd14f5f359326cb5ec218cd67074d1e426d74/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5c85794a4cfa094714fb9c08d4a218375b2b95b8ed1666e8677c349906246c05", size = 455099, upload-time = "2025-10-14T15:05:34.189Z" },
+ { url = "https://files.pythonhosted.org/packages/98/e0/8c9bdba88af756a2fce230dd365fab2baf927ba42cd47521ee7498fd5211/watchfiles-1.1.1-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:74d5012b7630714b66be7b7b7a78855ef7ad58e8650c73afc4c076a1f480a8d6", size = 630626, upload-time = "2025-10-14T15:05:35.216Z" },
+ { url = "https://files.pythonhosted.org/packages/2a/84/a95db05354bf2d19e438520d92a8ca475e578c647f78f53197f5a2f17aaf/watchfiles-1.1.1-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:8fbe85cb3201c7d380d3d0b90e63d520f15d6afe217165d7f98c9c649654db81", size = 622519, upload-time = "2025-10-14T15:05:36.259Z" },
+ { url = "https://files.pythonhosted.org/packages/1d/ce/d8acdc8de545de995c339be67711e474c77d643555a9bb74a9334252bd55/watchfiles-1.1.1-cp314-cp314-win32.whl", hash = "sha256:3fa0b59c92278b5a7800d3ee7733da9d096d4aabcfabb9a928918bd276ef9b9b", size = 272078, upload-time = "2025-10-14T15:05:37.63Z" },
+ { url = "https://files.pythonhosted.org/packages/c4/c9/a74487f72d0451524be827e8edec251da0cc1fcf111646a511ae752e1a3d/watchfiles-1.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:c2047d0b6cea13b3316bdbafbfa0c4228ae593d995030fda39089d36e64fc03a", size = 287664, upload-time = "2025-10-14T15:05:38.95Z" },
+ { url = "https://files.pythonhosted.org/packages/df/b8/8ac000702cdd496cdce998c6f4ee0ca1f15977bba51bdf07d872ebdfc34c/watchfiles-1.1.1-cp314-cp314-win_arm64.whl", hash = "sha256:842178b126593addc05acf6fce960d28bc5fae7afbaa2c6c1b3a7b9460e5be02", size = 277154, upload-time = "2025-10-14T15:05:39.954Z" },
+ { url = "https://files.pythonhosted.org/packages/47/a8/e3af2184707c29f0f14b1963c0aace6529f9d1b8582d5b99f31bbf42f59e/watchfiles-1.1.1-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:88863fbbc1a7312972f1c511f202eb30866370ebb8493aef2812b9ff28156a21", size = 403820, upload-time = "2025-10-14T15:05:40.932Z" },
+ { url = "https://files.pythonhosted.org/packages/c0/ec/e47e307c2f4bd75f9f9e8afbe3876679b18e1bcec449beca132a1c5ffb2d/watchfiles-1.1.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:55c7475190662e202c08c6c0f4d9e345a29367438cf8e8037f3155e10a88d5a5", size = 390510, upload-time = "2025-10-14T15:05:41.945Z" },
+ { url = "https://files.pythonhosted.org/packages/d5/a0/ad235642118090f66e7b2f18fd5c42082418404a79205cdfca50b6309c13/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3f53fa183d53a1d7a8852277c92b967ae99c2d4dcee2bfacff8868e6e30b15f7", size = 448408, upload-time = "2025-10-14T15:05:43.385Z" },
+ { url = "https://files.pythonhosted.org/packages/df/85/97fa10fd5ff3332ae17e7e40e20784e419e28521549780869f1413742e9d/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:6aae418a8b323732fa89721d86f39ec8f092fc2af67f4217a2b07fd3e93c6101", size = 458968, upload-time = "2025-10-14T15:05:44.404Z" },
+ { url = "https://files.pythonhosted.org/packages/47/c2/9059c2e8966ea5ce678166617a7f75ecba6164375f3b288e50a40dc6d489/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f096076119da54a6080e8920cbdaac3dbee667eb91dcc5e5b78840b87415bd44", size = 488096, upload-time = "2025-10-14T15:05:45.398Z" },
+ { url = "https://files.pythonhosted.org/packages/94/44/d90a9ec8ac309bc26db808a13e7bfc0e4e78b6fc051078a554e132e80160/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:00485f441d183717038ed2e887a7c868154f216877653121068107b227a2f64c", size = 596040, upload-time = "2025-10-14T15:05:46.502Z" },
+ { url = "https://files.pythonhosted.org/packages/95/68/4e3479b20ca305cfc561db3ed207a8a1c745ee32bf24f2026a129d0ddb6e/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a55f3e9e493158d7bfdb60a1165035f1cf7d320914e7b7ea83fe22c6023b58fc", size = 473847, upload-time = "2025-10-14T15:05:47.484Z" },
+ { url = "https://files.pythonhosted.org/packages/4f/55/2af26693fd15165c4ff7857e38330e1b61ab8c37d15dc79118cdba115b7a/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8c91ed27800188c2ae96d16e3149f199d62f86c7af5f5f4d2c61a3ed8cd3666c", size = 455072, upload-time = "2025-10-14T15:05:48.928Z" },
+ { url = "https://files.pythonhosted.org/packages/66/1d/d0d200b10c9311ec25d2273f8aad8c3ef7cc7ea11808022501811208a750/watchfiles-1.1.1-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:311ff15a0bae3714ffb603e6ba6dbfba4065ab60865d15a6ec544133bdb21099", size = 629104, upload-time = "2025-10-14T15:05:49.908Z" },
+ { url = "https://files.pythonhosted.org/packages/e3/bd/fa9bb053192491b3867ba07d2343d9f2252e00811567d30ae8d0f78136fe/watchfiles-1.1.1-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:a916a2932da8f8ab582f242c065f5c81bed3462849ca79ee357dd9551b0e9b01", size = 622112, upload-time = "2025-10-14T15:05:50.941Z" },
]
[[package]]
name = "websockets"
-version = "15.0.1"
+version = "16.0"
source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/21/e6/26d09fab466b7ca9c7737474c52be4f76a40301b08362eb2dbc19dcc16c1/websockets-15.0.1.tar.gz", hash = "sha256:82544de02076bafba038ce055ee6412d68da13ab47f0c60cab827346de828dee", size = 177016, upload-time = "2025-03-05T20:03:41.606Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/04/24/4b2031d72e840ce4c1ccb255f693b15c334757fc50023e4db9537080b8c4/websockets-16.0.tar.gz", hash = "sha256:5f6261a5e56e8d5c42a4497b364ea24d94d9563e8fbd44e78ac40879c60179b5", size = 179346, upload-time = "2026-01-10T09:23:47.181Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/51/6b/4545a0d843594f5d0771e86463606a3988b5a09ca5123136f8a76580dd63/websockets-15.0.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:3e90baa811a5d73f3ca0bcbf32064d663ed81318ab225ee4f427ad4e26e5aff3", size = 175437, upload-time = "2025-03-05T20:02:16.706Z" },
- { url = "https://files.pythonhosted.org/packages/f4/71/809a0f5f6a06522af902e0f2ea2757f71ead94610010cf570ab5c98e99ed/websockets-15.0.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:592f1a9fe869c778694f0aa806ba0374e97648ab57936f092fd9d87f8bc03665", size = 173096, upload-time = "2025-03-05T20:02:18.832Z" },
- { url = "https://files.pythonhosted.org/packages/3d/69/1a681dd6f02180916f116894181eab8b2e25b31e484c5d0eae637ec01f7c/websockets-15.0.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0701bc3cfcb9164d04a14b149fd74be7347a530ad3bbf15ab2c678a2cd3dd9a2", size = 173332, upload-time = "2025-03-05T20:02:20.187Z" },
- { url = "https://files.pythonhosted.org/packages/a6/02/0073b3952f5bce97eafbb35757f8d0d54812b6174ed8dd952aa08429bcc3/websockets-15.0.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e8b56bdcdb4505c8078cb6c7157d9811a85790f2f2b3632c7d1462ab5783d215", size = 183152, upload-time = "2025-03-05T20:02:22.286Z" },
- { url = "https://files.pythonhosted.org/packages/74/45/c205c8480eafd114b428284840da0b1be9ffd0e4f87338dc95dc6ff961a1/websockets-15.0.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0af68c55afbd5f07986df82831c7bff04846928ea8d1fd7f30052638788bc9b5", size = 182096, upload-time = "2025-03-05T20:02:24.368Z" },
- { url = "https://files.pythonhosted.org/packages/14/8f/aa61f528fba38578ec553c145857a181384c72b98156f858ca5c8e82d9d3/websockets-15.0.1-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:64dee438fed052b52e4f98f76c5790513235efaa1ef7f3f2192c392cd7c91b65", size = 182523, upload-time = "2025-03-05T20:02:25.669Z" },
- { url = "https://files.pythonhosted.org/packages/ec/6d/0267396610add5bc0d0d3e77f546d4cd287200804fe02323797de77dbce9/websockets-15.0.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:d5f6b181bb38171a8ad1d6aa58a67a6aa9d4b38d0f8c5f496b9e42561dfc62fe", size = 182790, upload-time = "2025-03-05T20:02:26.99Z" },
- { url = "https://files.pythonhosted.org/packages/02/05/c68c5adbf679cf610ae2f74a9b871ae84564462955d991178f95a1ddb7dd/websockets-15.0.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:5d54b09eba2bada6011aea5375542a157637b91029687eb4fdb2dab11059c1b4", size = 182165, upload-time = "2025-03-05T20:02:30.291Z" },
- { url = "https://files.pythonhosted.org/packages/29/93/bb672df7b2f5faac89761cb5fa34f5cec45a4026c383a4b5761c6cea5c16/websockets-15.0.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3be571a8b5afed347da347bfcf27ba12b069d9d7f42cb8c7028b5e98bbb12597", size = 182160, upload-time = "2025-03-05T20:02:31.634Z" },
- { url = "https://files.pythonhosted.org/packages/ff/83/de1f7709376dc3ca9b7eeb4b9a07b4526b14876b6d372a4dc62312bebee0/websockets-15.0.1-cp312-cp312-win32.whl", hash = "sha256:c338ffa0520bdb12fbc527265235639fb76e7bc7faafbb93f6ba80d9c06578a9", size = 176395, upload-time = "2025-03-05T20:02:33.017Z" },
- { url = "https://files.pythonhosted.org/packages/7d/71/abf2ebc3bbfa40f391ce1428c7168fb20582d0ff57019b69ea20fa698043/websockets-15.0.1-cp312-cp312-win_amd64.whl", hash = "sha256:fcd5cf9e305d7b8338754470cf69cf81f420459dbae8a3b40cee57417f4614a7", size = 176841, upload-time = "2025-03-05T20:02:34.498Z" },
- { url = "https://files.pythonhosted.org/packages/cb/9f/51f0cf64471a9d2b4d0fc6c534f323b664e7095640c34562f5182e5a7195/websockets-15.0.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ee443ef070bb3b6ed74514f5efaa37a252af57c90eb33b956d35c8e9c10a1931", size = 175440, upload-time = "2025-03-05T20:02:36.695Z" },
- { url = "https://files.pythonhosted.org/packages/8a/05/aa116ec9943c718905997412c5989f7ed671bc0188ee2ba89520e8765d7b/websockets-15.0.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5a939de6b7b4e18ca683218320fc67ea886038265fd1ed30173f5ce3f8e85675", size = 173098, upload-time = "2025-03-05T20:02:37.985Z" },
- { url = "https://files.pythonhosted.org/packages/ff/0b/33cef55ff24f2d92924923c99926dcce78e7bd922d649467f0eda8368923/websockets-15.0.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:746ee8dba912cd6fc889a8147168991d50ed70447bf18bcda7039f7d2e3d9151", size = 173329, upload-time = "2025-03-05T20:02:39.298Z" },
- { url = "https://files.pythonhosted.org/packages/31/1d/063b25dcc01faa8fada1469bdf769de3768b7044eac9d41f734fd7b6ad6d/websockets-15.0.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:595b6c3969023ecf9041b2936ac3827e4623bfa3ccf007575f04c5a6aa318c22", size = 183111, upload-time = "2025-03-05T20:02:40.595Z" },
- { url = "https://files.pythonhosted.org/packages/93/53/9a87ee494a51bf63e4ec9241c1ccc4f7c2f45fff85d5bde2ff74fcb68b9e/websockets-15.0.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3c714d2fc58b5ca3e285461a4cc0c9a66bd0e24c5da9911e30158286c9b5be7f", size = 182054, upload-time = "2025-03-05T20:02:41.926Z" },
- { url = "https://files.pythonhosted.org/packages/ff/b2/83a6ddf56cdcbad4e3d841fcc55d6ba7d19aeb89c50f24dd7e859ec0805f/websockets-15.0.1-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0f3c1e2ab208db911594ae5b4f79addeb3501604a165019dd221c0bdcabe4db8", size = 182496, upload-time = "2025-03-05T20:02:43.304Z" },
- { url = "https://files.pythonhosted.org/packages/98/41/e7038944ed0abf34c45aa4635ba28136f06052e08fc2168520bb8b25149f/websockets-15.0.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:229cf1d3ca6c1804400b0a9790dc66528e08a6a1feec0d5040e8b9eb14422375", size = 182829, upload-time = "2025-03-05T20:02:48.812Z" },
- { url = "https://files.pythonhosted.org/packages/e0/17/de15b6158680c7623c6ef0db361da965ab25d813ae54fcfeae2e5b9ef910/websockets-15.0.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:756c56e867a90fb00177d530dca4b097dd753cde348448a1012ed6c5131f8b7d", size = 182217, upload-time = "2025-03-05T20:02:50.14Z" },
- { url = "https://files.pythonhosted.org/packages/33/2b/1f168cb6041853eef0362fb9554c3824367c5560cbdaad89ac40f8c2edfc/websockets-15.0.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:558d023b3df0bffe50a04e710bc87742de35060580a293c2a984299ed83bc4e4", size = 182195, upload-time = "2025-03-05T20:02:51.561Z" },
- { url = "https://files.pythonhosted.org/packages/86/eb/20b6cdf273913d0ad05a6a14aed4b9a85591c18a987a3d47f20fa13dcc47/websockets-15.0.1-cp313-cp313-win32.whl", hash = "sha256:ba9e56e8ceeeedb2e080147ba85ffcd5cd0711b89576b83784d8605a7df455fa", size = 176393, upload-time = "2025-03-05T20:02:53.814Z" },
- { url = "https://files.pythonhosted.org/packages/1b/6c/c65773d6cab416a64d191d6ee8a8b1c68a09970ea6909d16965d26bfed1e/websockets-15.0.1-cp313-cp313-win_amd64.whl", hash = "sha256:e09473f095a819042ecb2ab9465aee615bd9c2028e4ef7d933600a8401c79561", size = 176837, upload-time = "2025-03-05T20:02:55.237Z" },
- { url = "https://files.pythonhosted.org/packages/fa/a8/5b41e0da817d64113292ab1f8247140aac61cbf6cfd085d6a0fa77f4984f/websockets-15.0.1-py3-none-any.whl", hash = "sha256:f7a866fbc1e97b5c617ee4116daaa09b722101d4a3c170c787450ba409f9736f", size = 169743, upload-time = "2025-03-05T20:03:39.41Z" },
+ { url = "https://files.pythonhosted.org/packages/84/7b/bac442e6b96c9d25092695578dda82403c77936104b5682307bd4deb1ad4/websockets-16.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:71c989cbf3254fbd5e84d3bff31e4da39c43f884e64f2551d14bb3c186230f00", size = 177365, upload-time = "2026-01-10T09:22:46.787Z" },
+ { url = "https://files.pythonhosted.org/packages/b0/fe/136ccece61bd690d9c1f715baaeefd953bb2360134de73519d5df19d29ca/websockets-16.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:8b6e209ffee39ff1b6d0fa7bfef6de950c60dfb91b8fcead17da4ee539121a79", size = 175038, upload-time = "2026-01-10T09:22:47.999Z" },
+ { url = "https://files.pythonhosted.org/packages/40/1e/9771421ac2286eaab95b8575b0cb701ae3663abf8b5e1f64f1fd90d0a673/websockets-16.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:86890e837d61574c92a97496d590968b23c2ef0aeb8a9bc9421d174cd378ae39", size = 175328, upload-time = "2026-01-10T09:22:49.809Z" },
+ { url = "https://files.pythonhosted.org/packages/18/29/71729b4671f21e1eaa5d6573031ab810ad2936c8175f03f97f3ff164c802/websockets-16.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9b5aca38b67492ef518a8ab76851862488a478602229112c4b0d58d63a7a4d5c", size = 184915, upload-time = "2026-01-10T09:22:51.071Z" },
+ { url = "https://files.pythonhosted.org/packages/97/bb/21c36b7dbbafc85d2d480cd65df02a1dc93bf76d97147605a8e27ff9409d/websockets-16.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e0334872c0a37b606418ac52f6ab9cfd17317ac26365f7f65e203e2d0d0d359f", size = 186152, upload-time = "2026-01-10T09:22:52.224Z" },
+ { url = "https://files.pythonhosted.org/packages/4a/34/9bf8df0c0cf88fa7bfe36678dc7b02970c9a7d5e065a3099292db87b1be2/websockets-16.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a0b31e0b424cc6b5a04b8838bbaec1688834b2383256688cf47eb97412531da1", size = 185583, upload-time = "2026-01-10T09:22:53.443Z" },
+ { url = "https://files.pythonhosted.org/packages/47/88/4dd516068e1a3d6ab3c7c183288404cd424a9a02d585efbac226cb61ff2d/websockets-16.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:485c49116d0af10ac698623c513c1cc01c9446c058a4e61e3bf6c19dff7335a2", size = 184880, upload-time = "2026-01-10T09:22:55.033Z" },
+ { url = "https://files.pythonhosted.org/packages/91/d6/7d4553ad4bf1c0421e1ebd4b18de5d9098383b5caa1d937b63df8d04b565/websockets-16.0-cp312-cp312-win32.whl", hash = "sha256:eaded469f5e5b7294e2bdca0ab06becb6756ea86894a47806456089298813c89", size = 178261, upload-time = "2026-01-10T09:22:56.251Z" },
+ { url = "https://files.pythonhosted.org/packages/c3/f0/f3a17365441ed1c27f850a80b2bc680a0fa9505d733fe152fdf5e98c1c0b/websockets-16.0-cp312-cp312-win_amd64.whl", hash = "sha256:5569417dc80977fc8c2d43a86f78e0a5a22fee17565d78621b6bb264a115d4ea", size = 178693, upload-time = "2026-01-10T09:22:57.478Z" },
+ { url = "https://files.pythonhosted.org/packages/cc/9c/baa8456050d1c1b08dd0ec7346026668cbc6f145ab4e314d707bb845bf0d/websockets-16.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:878b336ac47938b474c8f982ac2f7266a540adc3fa4ad74ae96fea9823a02cc9", size = 177364, upload-time = "2026-01-10T09:22:59.333Z" },
+ { url = "https://files.pythonhosted.org/packages/7e/0c/8811fc53e9bcff68fe7de2bcbe75116a8d959ac699a3200f4847a8925210/websockets-16.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:52a0fec0e6c8d9a784c2c78276a48a2bdf099e4ccc2a4cad53b27718dbfd0230", size = 175039, upload-time = "2026-01-10T09:23:01.171Z" },
+ { url = "https://files.pythonhosted.org/packages/aa/82/39a5f910cb99ec0b59e482971238c845af9220d3ab9fa76dd9162cda9d62/websockets-16.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e6578ed5b6981005df1860a56e3617f14a6c307e6a71b4fff8c48fdc50f3ed2c", size = 175323, upload-time = "2026-01-10T09:23:02.341Z" },
+ { url = "https://files.pythonhosted.org/packages/bd/28/0a25ee5342eb5d5f297d992a77e56892ecb65e7854c7898fb7d35e9b33bd/websockets-16.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:95724e638f0f9c350bb1c2b0a7ad0e83d9cc0c9259f3ea94e40d7b02a2179ae5", size = 184975, upload-time = "2026-01-10T09:23:03.756Z" },
+ { url = "https://files.pythonhosted.org/packages/f9/66/27ea52741752f5107c2e41fda05e8395a682a1e11c4e592a809a90c6a506/websockets-16.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c0204dc62a89dc9d50d682412c10b3542d748260d743500a85c13cd1ee4bde82", size = 186203, upload-time = "2026-01-10T09:23:05.01Z" },
+ { url = "https://files.pythonhosted.org/packages/37/e5/8e32857371406a757816a2b471939d51c463509be73fa538216ea52b792a/websockets-16.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:52ac480f44d32970d66763115edea932f1c5b1312de36df06d6b219f6741eed8", size = 185653, upload-time = "2026-01-10T09:23:06.301Z" },
+ { url = "https://files.pythonhosted.org/packages/9b/67/f926bac29882894669368dc73f4da900fcdf47955d0a0185d60103df5737/websockets-16.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6e5a82b677f8f6f59e8dfc34ec06ca6b5b48bc4fcda346acd093694cc2c24d8f", size = 184920, upload-time = "2026-01-10T09:23:07.492Z" },
+ { url = "https://files.pythonhosted.org/packages/3c/a1/3d6ccdcd125b0a42a311bcd15a7f705d688f73b2a22d8cf1c0875d35d34a/websockets-16.0-cp313-cp313-win32.whl", hash = "sha256:abf050a199613f64c886ea10f38b47770a65154dc37181bfaff70c160f45315a", size = 178255, upload-time = "2026-01-10T09:23:09.245Z" },
+ { url = "https://files.pythonhosted.org/packages/6b/ae/90366304d7c2ce80f9b826096a9e9048b4bb760e44d3b873bb272cba696b/websockets-16.0-cp313-cp313-win_amd64.whl", hash = "sha256:3425ac5cf448801335d6fdc7ae1eb22072055417a96cc6b31b3861f455fbc156", size = 178689, upload-time = "2026-01-10T09:23:10.483Z" },
+ { url = "https://files.pythonhosted.org/packages/f3/1d/e88022630271f5bd349ed82417136281931e558d628dd52c4d8621b4a0b2/websockets-16.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8cc451a50f2aee53042ac52d2d053d08bf89bcb31ae799cb4487587661c038a0", size = 177406, upload-time = "2026-01-10T09:23:12.178Z" },
+ { url = "https://files.pythonhosted.org/packages/f2/78/e63be1bf0724eeb4616efb1ae1c9044f7c3953b7957799abb5915bffd38e/websockets-16.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:daa3b6ff70a9241cf6c7fc9e949d41232d9d7d26fd3522b1ad2b4d62487e9904", size = 175085, upload-time = "2026-01-10T09:23:13.511Z" },
+ { url = "https://files.pythonhosted.org/packages/bb/f4/d3c9220d818ee955ae390cf319a7c7a467beceb24f05ee7aaaa2414345ba/websockets-16.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:fd3cb4adb94a2a6e2b7c0d8d05cb94e6f1c81a0cf9dc2694fb65c7e8d94c42e4", size = 175328, upload-time = "2026-01-10T09:23:14.727Z" },
+ { url = "https://files.pythonhosted.org/packages/63/bc/d3e208028de777087e6fb2b122051a6ff7bbcca0d6df9d9c2bf1dd869ae9/websockets-16.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:781caf5e8eee67f663126490c2f96f40906594cb86b408a703630f95550a8c3e", size = 185044, upload-time = "2026-01-10T09:23:15.939Z" },
+ { url = "https://files.pythonhosted.org/packages/ad/6e/9a0927ac24bd33a0a9af834d89e0abc7cfd8e13bed17a86407a66773cc0e/websockets-16.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:caab51a72c51973ca21fa8a18bd8165e1a0183f1ac7066a182ff27107b71e1a4", size = 186279, upload-time = "2026-01-10T09:23:17.148Z" },
+ { url = "https://files.pythonhosted.org/packages/b9/ca/bf1c68440d7a868180e11be653c85959502efd3a709323230314fda6e0b3/websockets-16.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:19c4dc84098e523fd63711e563077d39e90ec6702aff4b5d9e344a60cb3c0cb1", size = 185711, upload-time = "2026-01-10T09:23:18.372Z" },
+ { url = "https://files.pythonhosted.org/packages/c4/f8/fdc34643a989561f217bb477cbc47a3a07212cbda91c0e4389c43c296ebf/websockets-16.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:a5e18a238a2b2249c9a9235466b90e96ae4795672598a58772dd806edc7ac6d3", size = 184982, upload-time = "2026-01-10T09:23:19.652Z" },
+ { url = "https://files.pythonhosted.org/packages/dd/d1/574fa27e233764dbac9c52730d63fcf2823b16f0856b3329fc6268d6ae4f/websockets-16.0-cp314-cp314-win32.whl", hash = "sha256:a069d734c4a043182729edd3e9f247c3b2a4035415a9172fd0f1b71658a320a8", size = 177915, upload-time = "2026-01-10T09:23:21.458Z" },
+ { url = "https://files.pythonhosted.org/packages/8a/f1/ae6b937bf3126b5134ce1f482365fde31a357c784ac51852978768b5eff4/websockets-16.0-cp314-cp314-win_amd64.whl", hash = "sha256:c0ee0e63f23914732c6d7e0cce24915c48f3f1512ec1d079ed01fc629dab269d", size = 178381, upload-time = "2026-01-10T09:23:22.715Z" },
+ { url = "https://files.pythonhosted.org/packages/06/9b/f791d1db48403e1f0a27577a6beb37afae94254a8c6f08be4a23e4930bc0/websockets-16.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:a35539cacc3febb22b8f4d4a99cc79b104226a756aa7400adc722e83b0d03244", size = 177737, upload-time = "2026-01-10T09:23:24.523Z" },
+ { url = "https://files.pythonhosted.org/packages/bd/40/53ad02341fa33b3ce489023f635367a4ac98b73570102ad2cdd770dacc9a/websockets-16.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:b784ca5de850f4ce93ec85d3269d24d4c82f22b7212023c974c401d4980ebc5e", size = 175268, upload-time = "2026-01-10T09:23:25.781Z" },
+ { url = "https://files.pythonhosted.org/packages/74/9b/6158d4e459b984f949dcbbb0c5d270154c7618e11c01029b9bbd1bb4c4f9/websockets-16.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:569d01a4e7fba956c5ae4fc988f0d4e187900f5497ce46339c996dbf24f17641", size = 175486, upload-time = "2026-01-10T09:23:27.033Z" },
+ { url = "https://files.pythonhosted.org/packages/e5/2d/7583b30208b639c8090206f95073646c2c9ffd66f44df967981a64f849ad/websockets-16.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:50f23cdd8343b984957e4077839841146f67a3d31ab0d00e6b824e74c5b2f6e8", size = 185331, upload-time = "2026-01-10T09:23:28.259Z" },
+ { url = "https://files.pythonhosted.org/packages/45/b0/cce3784eb519b7b5ad680d14b9673a31ab8dcb7aad8b64d81709d2430aa8/websockets-16.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:152284a83a00c59b759697b7f9e9cddf4e3c7861dd0d964b472b70f78f89e80e", size = 186501, upload-time = "2026-01-10T09:23:29.449Z" },
+ { url = "https://files.pythonhosted.org/packages/19/60/b8ebe4c7e89fb5f6cdf080623c9d92789a53636950f7abacfc33fe2b3135/websockets-16.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:bc59589ab64b0022385f429b94697348a6a234e8ce22544e3681b2e9331b5944", size = 186062, upload-time = "2026-01-10T09:23:31.368Z" },
+ { url = "https://files.pythonhosted.org/packages/88/a8/a080593f89b0138b6cba1b28f8df5673b5506f72879322288b031337c0b8/websockets-16.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:32da954ffa2814258030e5a57bc73a3635463238e797c7375dc8091327434206", size = 185356, upload-time = "2026-01-10T09:23:32.627Z" },
+ { url = "https://files.pythonhosted.org/packages/c2/b6/b9afed2afadddaf5ebb2afa801abf4b0868f42f8539bfe4b071b5266c9fe/websockets-16.0-cp314-cp314t-win32.whl", hash = "sha256:5a4b4cc550cb665dd8a47f868c8d04c8230f857363ad3c9caf7a0c3bf8c61ca6", size = 178085, upload-time = "2026-01-10T09:23:33.816Z" },
+ { url = "https://files.pythonhosted.org/packages/9f/3e/28135a24e384493fa804216b79a6a6759a38cc4ff59118787b9fb693df93/websockets-16.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b14dc141ed6d2dde437cddb216004bcac6a1df0935d79656387bd41632ba0bbd", size = 178531, upload-time = "2026-01-10T09:23:35.016Z" },
+ { url = "https://files.pythonhosted.org/packages/6f/28/258ebab549c2bf3e64d2b0217b973467394a9cea8c42f70418ca2c5d0d2e/websockets-16.0-py3-none-any.whl", hash = "sha256:1637db62fad1dc833276dded54215f2c7fa46912301a24bd94d45d46a011ceec", size = 171598, upload-time = "2026-01-10T09:23:45.395Z" },
]
[[package]]
name = "zipp"
-version = "3.23.0"
+version = "3.23.1"
source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/e3/02/0f2892c661036d50ede074e376733dca2ae7c6eb617489437771209d4180/zipp-3.23.0.tar.gz", hash = "sha256:a07157588a12518c9d4034df3fbbee09c814741a33ff63c05fa29d26a2404166", size = 25547, upload-time = "2025-06-08T17:06:39.4Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/30/21/093488dfc7cc8964ded15ab726fad40f25fd3d788fd741cc1c5a17d78ee8/zipp-3.23.1.tar.gz", hash = "sha256:32120e378d32cd9714ad503c1d024619063ec28aad2248dc6672ad13edfa5110", size = 25965, upload-time = "2026-04-13T23:21:46.6Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/2e/54/647ade08bf0db230bfea292f893923872fd20be6ac6f53b2b936ba839d75/zipp-3.23.0-py3-none-any.whl", hash = "sha256:071652d6115ed432f5ce1d34c336c0adfd6a884660d1e9712a256d3d3bd4b14e", size = 10276, upload-time = "2025-06-08T17:06:38.034Z" },
+ { url = "https://files.pythonhosted.org/packages/08/8a/0861bec20485572fbddf3dfba2910e38fe249796cb73ecdeb74e07eeb8d3/zipp-3.23.1-py3-none-any.whl", hash = "sha256:0b3596c50a5c700c9cb40ba8d86d9f2cc4807e9bedb06bcdf7fac85633e444dc", size = 10378, upload-time = "2026-04-13T23:21:45.386Z" },
]
diff --git a/osv-scanner.toml b/osv-scanner.toml
new file mode 100644
index 0000000000..59408b8709
--- /dev/null
+++ b/osv-scanner.toml
@@ -0,0 +1,31 @@
+# osv-scanner per-vulnerability ignore list.
+#
+# Each [[IgnoredVulns]] entry must include a `reason` explaining why the
+# finding is accepted and an `ignoreUntil` date so the suppression auto-expires
+# and gets re-evaluated. See https://github.com/google/osv-scanner for the
+# config schema.
+
+[[IgnoredVulns]]
+id = "PYSEC-2025-183"
+ignoreUntil = 2026-08-20T00:00:00Z
+reason = """
+CVE-2025-45768 is disputed by the pyjwt maintainers. The advisory describes
+weak encryption, but the underlying issue is that callers may pick a short
+HMAC secret — key-length enforcement is the application's responsibility, not
+a defect in the library. We are on pyjwt 2.13.0 (which now also emits an
+InsecureKeyLengthWarning for short HMAC secrets) and enforce key strength in
+our own auth code, so this advisory does not apply.
+Re-evaluate when a non-disputed advisory or upstream fix lands.
+"""
+
+[[IgnoredVulns]]
+id = "PYSEC-2026-89"
+ignoreUntil = 2026-08-20T00:00:00Z
+reason = """
+False positive caused by a malformed PYSEC record. The equivalent GitHub
+Security Advisory (GHSA-5wmx-573v-2qwq) for CVE-2025-69534 declares the issue
+fixed in markdown 3.8.1. We are on markdown==3.10.2 (latest release, includes
+the fix), but the PYSEC entry's range is [{introduced: "0"}, {}] with no
+closing "fixed" event, so osv-scanner flags every version. There is no newer
+release to upgrade to. Re-evaluate once the PYSEC record is corrected upstream.
+"""
diff --git a/permissions/prowler-additions-policy.json b/permissions/prowler-additions-policy.json
index 906f06fbfb..6c58f6e184 100644
--- a/permissions/prowler-additions-policy.json
+++ b/permissions/prowler-additions-policy.json
@@ -4,6 +4,8 @@
{
"Action": [
"account:Get*",
+ "amplify:ListApps",
+ "amplify:ListBranches",
"appstream:Describe*",
"appstream:List*",
"backup:List*",
@@ -15,6 +17,9 @@
"codebuild:BatchGet*",
"codebuild:ListReportGroups",
"cognito-idp:GetUserPoolMfaConfig",
+ "datapipeline:DescribePipelines",
+ "datapipeline:GetPipelineDefinition",
+ "datapipeline:ListPipelines",
"dlm:Get*",
"drs:Describe*",
"ds:Get*",
@@ -36,7 +41,11 @@
"lightsail:GetRelationalDatabases",
"macie2:GetMacieSession",
"macie2:GetAutomatedDiscoveryConfiguration",
+ "rolesanywhere:ListTagsForResource",
+ "rolesanywhere:ListTrustAnchors",
"s3:GetAccountPublicAccessBlock",
+ "s3:GetObjectAcl",
+ "s3:ListBucket",
"shield:DescribeProtection",
"shield:GetSubscriptionState",
"securityhub:BatchImportFindings",
@@ -61,7 +70,9 @@
],
"Resource": [
"arn:*:apigateway:*::/restapis/*",
- "arn:*:apigateway:*::/apis/*"
+ "arn:*:apigateway:*::/apis/*",
+ "arn:*:apigateway:*::/domainnames",
+ "arn:*:apigateway:*::/domainnames/*"
],
"Sid": "AllowAPIGatewayReadOnly"
}
diff --git a/permissions/templates/cloudformation/prowler-scan-role.yml b/permissions/templates/cloudformation/prowler-scan-role.yml
index 395fed6424..c9eee71950 100644
--- a/permissions/templates/cloudformation/prowler-scan-role.yml
+++ b/permissions/templates/cloudformation/prowler-scan-role.yml
@@ -1,27 +1,24 @@
AWSTemplateFormatVersion: "2010-09-09"
-# You can invoke CloudFormation and pass the principal ARN from a command line like this:
-# aws cloudformation create-stack \
-# --capabilities CAPABILITY_IAM --capabilities CAPABILITY_NAMED_IAM \
-# --template-body "file://prowler-scan-role.yaml" \
-# --stack-name "ProwlerScanRole" \
-# --parameters "ParameterKey=ExternalId,ParameterValue=ProvidedExternalID"
-
Description: |
- This template creates the ProwlerScan IAM Role in this account with
- all read-only permissions to scan your account for security issues.
+ This template creates the ProwlerScan IAM Role either locally in this account or across
+ multiple accounts via StackSets. It can deploy both simultaneously or just one option.
+ The role includes all read-only permissions to scan your accounts for security issues.
Contains two AWS managed policies (SecurityAudit and ViewOnlyAccess) and an inline policy.
- It sets the trust policy on that IAM Role to permit Prowler to assume that role.
This template is designed to be used in Prowler Cloud, but can also be used in other Prowler deployments.
+
If you are deploying this template to be used in Prowler Cloud please do not edit the AccountId, IAMPrincipal and ExternalId parameters.
+
Parameters:
+ # Core Prowler IAM Role Parameters
ExternalId:
Description: |
- This is the External ID that Prowler will use to assume the role ProwlerScan IAM Role.
+ This is the External ID that Prowler will use to assume the ProwlerScan IAM Role.
Type: String
MinLength: 1
AllowedPattern: ".+"
ConstraintDescription: "ExternalId must not be empty."
+
AccountId:
Description: |
AWS Account ID that will assume the role created, if you are deploying this template to be used in Prowler Cloud please do not edit this.
@@ -31,11 +28,13 @@ Parameters:
MaxLength: 12
AllowedPattern: "[0-9]{12}"
ConstraintDescription: "AccountId must be a valid AWS Account ID."
+
IAMPrincipal:
Description: |
The IAM principal type and name that will be allowed to assume the role created, leave an * for all the IAM principals in your AWS account. If you are deploying this template to be used in Prowler Cloud please do not edit this.
Type: String
Default: role/prowler*
+
EnableOrganizations:
Description: |
Enable AWS Organizations discovery permissions. Set to true only when deploying this role in the management account.
@@ -45,6 +44,7 @@ Parameters:
AllowedValues:
- true
- false
+
EnableS3Integration:
Description: |
Enable S3 integration for storing Prowler scan reports.
@@ -53,25 +53,102 @@ Parameters:
AllowedValues:
- true
- false
+
S3IntegrationBucketName:
Description: |
The S3 bucket name where Prowler will store scan reports for your cloud providers.
Type: String
Default: ""
+
S3IntegrationBucketAccountId:
Description: |
The AWS Account ID owner of the S3 Bucket.
Type: String
Default: ""
+ # Deployment Control Parameters
+ DeployStackSet:
+ Description: |
+ Set to true to deploy the ProwlerScan role across multiple accounts using StackSets.
+ Requires delegated administrator permissions for CloudFormation StackSets.
+ Type: String
+ Default: false
+ AllowedValues:
+ - true
+ - false
+
+ DeployLocalRole:
+ Description: |
+ Set to true to deploy the ProwlerScan role in this account (the account where this template is deployed).
+ Can be used independently or in conjunction with StackSet deployment.
+ Type: String
+ Default: true
+ AllowedValues:
+ - true
+ - false
+
+ # StackSet Configuration Parameters
+ AWSOrganizationalUnitId:
+ Description: |
+ AWS Organizations OU to deploy this stackset to (e.g., ou-xxxx-yyyyyyyy or r-xxxx for root).
+ Only required if DeployStackSet is true.
+ Type: String
+ Default: ""
+ AllowedPattern: '^(ou-[a-z0-9]{4,32}-[a-z0-9]{8,32}|r-[a-z0-9]{4,32})?$'
+
+ RetainStacksOnAccountRemoval:
+ Description: |
+ When an account is removed from the Organization or OU, should the ProwlerScan role remain in that account?
+ False (Recommended for security): Automatically deletes the role when accounts leave, following principle of least privilege.
+ True: Retains the role even after account removal, useful if accounts may temporarily leave and rejoin.
+ Type: String
+ Default: false
+ AllowedValues:
+ - true
+ - false
+
+ DeployFromDelegatedAdmin:
+ Description: |
+ Is this StackSet being deployed from a Delegated Administrator account (not the Organization Management Account)?
+ True: Deploying from a delegated admin account - uses CallAs: DELEGATED_ADMIN.
+ False: Deploying from the Organization Management Account - omits CallAs property.
+ Only required if DeployStackSet is true.
+ Type: String
+ Default: false
+ AllowedValues:
+ - true
+ - false
+
+ FailureTolerancePercentage:
+ Description: |
+ The percentage of accounts in which stack operations can fail before CloudFormation stops the operation.
+ Only applies when DeployStackSet is true.
+ Type: Number
+ Default: 10
+ MinValue: 0
+ MaxValue: 100
+
Conditions:
OrganizationsEnabled: !Equals [!Ref EnableOrganizations, true]
S3IntegrationEnabled: !Equals [!Ref EnableS3Integration, true]
+ DeployStackSetEnabled: !Equals [!Ref DeployStackSet, true]
+ DeployLocalRoleEnabled: !Equals [!Ref DeployLocalRole, true]
+ UseDelegatedAdmin: !Equals [!Ref DeployFromDelegatedAdmin, true]
+Rules:
+ S3IntegrationRequiresParams:
+ RuleCondition: !Equals [!Ref EnableS3Integration, "true"]
+ Assertions:
+ - Assert: !Not [!Equals [!Ref S3IntegrationBucketName, ""]]
+ AssertDescription: "S3IntegrationBucketName is required when EnableS3Integration is true."
+ - Assert: !Not [!Equals [!Ref S3IntegrationBucketAccountId, ""]]
+ AssertDescription: "S3IntegrationBucketAccountId is required when EnableS3Integration is true."
Resources:
+ # Local ProwlerScan Role (deployed in this account)
ProwlerScan:
Type: AWS::IAM::Role
+ Condition: DeployLocalRoleEnabled
Properties:
RoleName: ProwlerScan
AssumeRolePolicyDocument:
@@ -88,8 +165,8 @@ Resources:
"aws:PrincipalArn": !Sub "arn:${AWS::Partition}:iam::${AccountId}:${IAMPrincipal}"
MaxSessionDuration: 3600
ManagedPolicyArns:
- - "arn:aws:iam::aws:policy/SecurityAudit"
- - "arn:aws:iam::aws:policy/job-function/ViewOnlyAccess"
+ - !Sub "arn:${AWS::Partition}:iam::aws:policy/SecurityAudit"
+ - !Sub "arn:${AWS::Partition}:iam::aws:policy/job-function/ViewOnlyAccess"
Policies:
- PolicyName: ProwlerScan
PolicyDocument:
@@ -99,9 +176,12 @@ Resources:
Effect: Allow
Action:
- "account:Get*"
+ - "amplify:ListApps"
+ - "amplify:ListBranches"
- "appstream:Describe*"
- "appstream:List*"
- "backup:List*"
+ - "backup:Get*"
- "bedrock:List*"
- "bedrock:Get*"
- "cloudtrail:GetInsightSelectors"
@@ -109,6 +189,9 @@ Resources:
- "codebuild:BatchGet*"
- "codebuild:ListReportGroups"
- "cognito-idp:GetUserPoolMfaConfig"
+ - "datapipeline:DescribePipelines"
+ - "datapipeline:GetPipelineDefinition"
+ - "datapipeline:ListPipelines"
- "dlm:Get*"
- "drs:Describe*"
- "ds:Get*"
@@ -124,15 +207,17 @@ Resources:
- "glue:GetConnections"
- "glue:GetSecurityConfiguration*"
- "glue:SearchTables"
+ - "glue:GetMLTransforms"
- "lambda:GetFunction*"
- "logs:FilterLogEvents"
- "lightsail:GetRelationalDatabases"
- "macie2:GetMacieSession"
- "macie2:GetAutomatedDiscoveryConfiguration"
+ - "rolesanywhere:ListTagsForResource"
+ - "rolesanywhere:ListTrustAnchors"
- "s3:GetAccountPublicAccessBlock"
- "shield:DescribeProtection"
- "shield:GetSubscriptionState"
- - "securityhub:BatchImportFindings"
- "securityhub:GetFindings"
- "servicecatalog:Describe*"
- "servicecatalog:List*"
@@ -143,13 +228,20 @@ Resources:
- "tag:GetTagKeys"
- "wellarchitected:List*"
Resource: "*"
+ - Sid: AllowSecurityHubImportFindings
+ Effect: Allow
+ Action:
+ - "securityhub:BatchImportFindings"
+ Resource: "*"
- Sid: AllowAPIGatewayReadOnly
Effect: Allow
Action:
- "apigateway:GET"
Resource:
- - "arn:*:apigateway:*::/restapis/*"
- - "arn:*:apigateway:*::/apis/*"
+ - !Sub "arn:${AWS::Partition}:apigateway:*::/restapis/*"
+ - !Sub "arn:${AWS::Partition}:apigateway:*::/apis/*"
+ - !Sub "arn:${AWS::Partition}:apigateway:*::/domainnames"
+ - !Sub "arn:${AWS::Partition}:apigateway:*::/domainnames/*"
- !If
- OrganizationsEnabled
- PolicyName: ProwlerOrganizations
@@ -171,8 +263,12 @@ Resources:
Effect: Allow
Action:
- "organizations:RegisterDelegatedAdministrator"
- - "iam:CreateServiceLinkedRole"
Resource: "*"
+ - Sid: AllowCreateStackSetSLR
+ Effect: Allow
+ Action:
+ - "iam:CreateServiceLinkedRole"
+ Resource: !Sub "arn:${AWS::Partition}:iam::*:role/aws-service-role/member.org.stacksets.cloudformation.amazonaws.com/*"
- !Ref AWS::NoValue
- !If
- S3IntegrationEnabled
@@ -215,12 +311,287 @@ Resources:
- Key: "Name"
Value: "ProwlerScan"
+ # StackSet for deploying ProwlerScan role across multiple accounts
+ ProwlerScanStackSet:
+ Type: AWS::CloudFormation::StackSet
+ Condition: DeployStackSetEnabled
+ Properties:
+ StackSetName: !Sub "${AWS::StackName}-ProwlerScan-StackSet"
+ Description: Organizational StackSet to Deploy ProwlerScan IAM Role across accounts
+ PermissionModel: SERVICE_MANAGED
+ CallAs: !If [UseDelegatedAdmin, DELEGATED_ADMIN, !Ref "AWS::NoValue"]
+ Capabilities:
+ - CAPABILITY_NAMED_IAM
+ AutoDeployment:
+ Enabled: True
+ RetainStacksOnAccountRemoval: !Ref RetainStacksOnAccountRemoval
+ OperationPreferences:
+ FailureTolerancePercentage: !Ref FailureTolerancePercentage
+ MaxConcurrentPercentage: 100
+ Parameters:
+ - ParameterKey: ExternalId
+ ParameterValue: !Ref ExternalId
+ - ParameterKey: AccountId
+ ParameterValue: !Ref AccountId
+ - ParameterKey: IAMPrincipal
+ ParameterValue: !Ref IAMPrincipal
+ - ParameterKey: EnableOrganizations
+ ParameterValue: !Ref EnableOrganizations
+ - ParameterKey: EnableS3Integration
+ ParameterValue: !Ref EnableS3Integration
+ - ParameterKey: S3IntegrationBucketName
+ ParameterValue: !Ref S3IntegrationBucketName
+ - ParameterKey: S3IntegrationBucketAccountId
+ ParameterValue: !Ref S3IntegrationBucketAccountId
+ StackInstancesGroup:
+ - DeploymentTargets:
+ OrganizationalUnitIds:
+ - !Ref AWSOrganizationalUnitId
+ Regions:
+ - us-east-1
+ TemplateBody: |
+ AWSTemplateFormatVersion: "2010-09-09"
+
+ Description: |
+ This template creates the ProwlerScan IAM Role in this account with
+ all read-only permissions to scan your account for security issues.
+ Contains two AWS managed policies (SecurityAudit and ViewOnlyAccess) and an inline policy.
+ It sets the trust policy on that IAM Role to permit Prowler to assume that role.
+ This template is designed to be used in Prowler Cloud, but can also be used in other Prowler deployments.
+
+ ** DEPLOYED VIA SERVICE-MANAGED STACKSET **
+ This stack was automatically deployed across your organization using CloudFormation StackSets
+ with SERVICE_MANAGED permissions. It will auto-deploy to new accounts and can be centrally managed.
+
+ Parameters:
+ ExternalId:
+ Description: |
+ This is the External ID that Prowler will use to assume the role ProwlerScan IAM Role.
+ Type: String
+ MinLength: 1
+ AllowedPattern: ".+"
+ ConstraintDescription: "ExternalId must not be empty."
+ AccountId:
+ Description: |
+ AWS Account ID that will assume the role created, if you are deploying this template to be used in Prowler Cloud please do not edit this.
+ Type: String
+ Default: "232136659152"
+ MinLength: 12
+ MaxLength: 12
+ AllowedPattern: "[0-9]{12}"
+ ConstraintDescription: "AccountId must be a valid AWS Account ID."
+ IAMPrincipal:
+ Description: |
+ The IAM principal type and name that will be allowed to assume the role created, leave an * for all the IAM principals in your AWS account. If you are deploying this template to be used in Prowler Cloud please do not edit this.
+ Type: String
+ Default: role/prowler*
+ EnableOrganizations:
+ Description: |
+ Enable AWS Organizations discovery permissions. Set to true only when deploying this role in the management account.
+ This adds read-only Organizations permissions (e.g. ListAccounts, DescribeOrganization) and StackSet management permissions.
+ Type: String
+ Default: false
+ AllowedValues:
+ - true
+ - false
+ EnableS3Integration:
+ Description: |
+ Enable S3 integration for storing Prowler scan reports.
+ Type: String
+ Default: false
+ AllowedValues:
+ - true
+ - false
+ S3IntegrationBucketName:
+ Description: |
+ The S3 bucket name where Prowler will store scan reports for your cloud providers.
+ Type: String
+ Default: ""
+ S3IntegrationBucketAccountId:
+ Description: |
+ The AWS Account ID owner of the S3 Bucket.
+ Type: String
+ Default: ""
+
+ Conditions:
+ OrganizationsEnabled: !Equals [!Ref EnableOrganizations, true]
+ S3IntegrationEnabled: !Equals [!Ref EnableS3Integration, true]
+
+ Resources:
+ ProwlerScan:
+ Type: AWS::IAM::Role
+ Properties:
+ RoleName: ProwlerScan
+ AssumeRolePolicyDocument:
+ Version: "2012-10-17"
+ Statement:
+ - Effect: Allow
+ Principal:
+ AWS: !Sub "arn:${AWS::Partition}:iam::${AccountId}:root"
+ Action: "sts:AssumeRole"
+ Condition:
+ StringEquals:
+ "sts:ExternalId": !Sub ${ExternalId}
+ StringLike:
+ "aws:PrincipalArn": !Sub "arn:${AWS::Partition}:iam::${AccountId}:${IAMPrincipal}"
+ MaxSessionDuration: 3600
+ ManagedPolicyArns:
+ - !Sub "arn:${AWS::Partition}:iam::aws:policy/SecurityAudit"
+ - !Sub "arn:${AWS::Partition}:iam::aws:policy/job-function/ViewOnlyAccess"
+ Policies:
+ - PolicyName: ProwlerScan
+ PolicyDocument:
+ Version: "2012-10-17"
+ Statement:
+ - Sid: AllowMoreReadOnly
+ Effect: Allow
+ Action:
+ - "account:Get*"
+ - "appstream:Describe*"
+ - "appstream:List*"
+ - "backup:List*"
+ - "backup:Get*"
+ - "bedrock:List*"
+ - "bedrock:Get*"
+ - "cloudtrail:GetInsightSelectors"
+ - "codeartifact:List*"
+ - "codebuild:BatchGet*"
+ - "codebuild:ListReportGroups"
+ - "cognito-idp:GetUserPoolMfaConfig"
+ - "dlm:Get*"
+ - "drs:Describe*"
+ - "ds:Get*"
+ - "ds:Describe*"
+ - "ds:List*"
+ - "dynamodb:GetResourcePolicy"
+ - "ec2:GetEbsEncryptionByDefault"
+ - "ec2:GetSnapshotBlockPublicAccessState"
+ - "ec2:GetInstanceMetadataDefaults"
+ - "ecr:Describe*"
+ - "ecr:GetRegistryScanningConfiguration"
+ - "elasticfilesystem:DescribeBackupPolicy"
+ - "glue:GetConnections"
+ - "glue:GetSecurityConfiguration*"
+ - "glue:SearchTables"
+ - "glue:GetMLTransforms"
+ - "lambda:GetFunction*"
+ - "logs:FilterLogEvents"
+ - "lightsail:GetRelationalDatabases"
+ - "macie2:GetMacieSession"
+ - "macie2:GetAutomatedDiscoveryConfiguration"
+ - "s3:GetAccountPublicAccessBlock"
+ - "shield:DescribeProtection"
+ - "shield:GetSubscriptionState"
+ - "securityhub:GetFindings"
+ - "servicecatalog:Describe*"
+ - "servicecatalog:List*"
+ - "ssm:GetDocument"
+ - "ssm-incidents:List*"
+ - "states:ListTagsForResource"
+ - "support:Describe*"
+ - "tag:GetTagKeys"
+ - "wellarchitected:List*"
+ Resource: "*"
+ - Sid: AllowSecurityHubImportFindings
+ Effect: Allow
+ Action:
+ - "securityhub:BatchImportFindings"
+ Resource: "*"
+ - Sid: AllowAPIGatewayReadOnly
+ Effect: Allow
+ Action:
+ - "apigateway:GET"
+ Resource:
+ - !Sub "arn:${AWS::Partition}:apigateway:*::/restapis/*"
+ - !Sub "arn:${AWS::Partition}:apigateway:*::/apis/*"
+ - !Sub "arn:${AWS::Partition}:apigateway:*::/domainnames"
+ - !Sub "arn:${AWS::Partition}:apigateway:*::/domainnames/*"
+ - !If
+ - OrganizationsEnabled
+ - PolicyName: ProwlerOrganizations
+ PolicyDocument:
+ Version: "2012-10-17"
+ Statement:
+ - Sid: AllowOrganizationsReadOnly
+ Effect: Allow
+ Action:
+ - "organizations:DescribeAccount"
+ - "organizations:DescribeOrganization"
+ - "organizations:ListAccounts"
+ - "organizations:ListAccountsForParent"
+ - "organizations:ListOrganizationalUnitsForParent"
+ - "organizations:ListRoots"
+ - "organizations:ListTagsForResource"
+ Resource: "*"
+ - Sid: AllowStackSetManagement
+ Effect: Allow
+ Action:
+ - "organizations:RegisterDelegatedAdministrator"
+ Resource: "*"
+ - Sid: AllowCreateStackSetSLR
+ Effect: Allow
+ Action:
+ - "iam:CreateServiceLinkedRole"
+ Resource: !Sub "arn:${AWS::Partition}:iam::*:role/aws-service-role/member.org.stacksets.cloudformation.amazonaws.com/*"
+ - !Ref AWS::NoValue
+ - !If
+ - S3IntegrationEnabled
+ - PolicyName: S3Integration
+ PolicyDocument:
+ Version: "2012-10-17"
+ Statement:
+ - Effect: Allow
+ Action:
+ - "s3:PutObject"
+ Resource:
+ - !Sub "arn:${AWS::Partition}:s3:::${S3IntegrationBucketName}/*"
+ Condition:
+ StringEquals:
+ "s3:ResourceAccount": !Sub ${S3IntegrationBucketAccountId}
+ - Effect: Allow
+ Action:
+ - "s3:ListBucket"
+ Resource:
+ - !Sub "arn:${AWS::Partition}:s3:::${S3IntegrationBucketName}"
+ Condition:
+ StringEquals:
+ "s3:ResourceAccount": !Sub ${S3IntegrationBucketAccountId}
+ - Effect: Allow
+ Action:
+ - "s3:DeleteObject"
+ Resource:
+ - !Sub "arn:${AWS::Partition}:s3:::${S3IntegrationBucketName}/*test-prowler-connection.txt"
+ Condition:
+ StringEquals:
+ "s3:ResourceAccount": !Sub ${S3IntegrationBucketAccountId}
+ - !Ref AWS::NoValue
+ Tags:
+ - Key: "Service"
+ Value: "https://prowler.com"
+ - Key: "Support"
+ Value: "support@prowler.com"
+ - Key: "CloudFormation"
+ Value: "true"
+ - Key: "Name"
+ Value: "ProwlerScan"
+
+ Outputs:
+ ProwlerScanRoleArn:
+ Description: "ARN of the ProwlerScan IAM Role"
+ Value: !GetAtt ProwlerScan.Arn
+ Export:
+ Name: !Sub "${AWS::StackName}-ProwlerScanRoleArn"
+
Metadata:
- AWS::CloudFormation::StackName: "Prowler"
AWS::CloudFormation::Interface:
ParameterGroups:
- Label:
- default: Required
+ default: Deployment Options
+ Parameters:
+ - DeployLocalRole
+ - DeployStackSet
+ - Label:
+ default: Required Prowler Configuration
Parameters:
- ExternalId
- AccountId
@@ -228,14 +599,27 @@ Metadata:
- EnableOrganizations
- EnableS3Integration
- Label:
- default: Optional
+ default: Optional S3 Integration
Parameters:
- S3IntegrationBucketName
- S3IntegrationBucketAccountId
+ - Label:
+ default: StackSet Configuration (Required if DeployStackSet is true)
+ Parameters:
+ - AWSOrganizationalUnitId
+ - DeployFromDelegatedAdmin
+ - RetainStacksOnAccountRemoval
+ - FailureTolerancePercentage
Outputs:
- ProwlerScanRoleArn:
- Description: "ARN of the ProwlerScan IAM Role"
+ LocalProwlerScanRoleArn:
+ Condition: DeployLocalRoleEnabled
+ Description: "ARN of the ProwlerScan IAM Role deployed locally in this account"
Value: !GetAtt ProwlerScan.Arn
Export:
Name: !Sub "${AWS::StackName}-ProwlerScanRoleArn"
+
+ StackSetId:
+ Condition: DeployStackSetEnabled
+ Description: "StackSet ID for the ProwlerScan role deployment across accounts"
+ Value: !Ref ProwlerScanStackSet
diff --git a/permissions/templates/terraform/README.md b/permissions/templates/terraform/README.md
index ec41103e5e..1bb037daa2 100644
--- a/permissions/templates/terraform/README.md
+++ b/permissions/templates/terraform/README.md
@@ -28,12 +28,12 @@ This Terraform configuration creates the necessary IAM role and policies to allo
### Usage Examples
-#### Basic deployment (without S3 integration):
+#### Basic deployment (without S3 integration)
```bash
terraform apply -var="external_id=your-external-id-here"
```
-#### With S3 integration enabled:
+#### With S3 integration enabled
```bash
terraform apply \
-var="external_id=your-external-id-here" \
@@ -42,14 +42,14 @@ terraform apply \
-var="s3_integration_bucket_account_id=123456789012"
```
-#### Using terraform.tfvars file (Recommended):
+#### Using terraform.tfvars file (Recommended)
```bash
cp terraform.tfvars.example terraform.tfvars
# Edit the file with your values
terraform apply
```
-#### Command line variables (Alternative):
+#### Command line variables (Alternative)
```bash
terraform apply -var="external_id=your-external-id-here"
```
diff --git a/prowler/AGENTS.md b/prowler/AGENTS.md
index 9c0895d3a1..ab3ba1ce67 100644
--- a/prowler/AGENTS.md
+++ b/prowler/AGENTS.md
@@ -7,7 +7,7 @@
> - [`prowler-compliance`](../skills/prowler-compliance/SKILL.md) - Compliance framework structure
> - [`pytest`](../skills/pytest/SKILL.md) - Generic pytest patterns
-### Auto-invoke Skills
+## Auto-invoke Skills
When performing these actions, ALWAYS invoke the corresponding skill FIRST:
@@ -44,7 +44,7 @@ The Prowler SDK is the core Python engine powering cloud security assessments ac
### Provider Architecture
-```
+```text
prowler/providers/{provider}/
├── {provider}_provider.py # Main provider class
├── models.py # Provider-specific models
@@ -91,7 +91,7 @@ Python 3.10+ | uv | pytest | moto (AWS mocking) | Pre-commit hooks (black, flake
## PROJECT STRUCTURE
-```
+```text
prowler/
├── __main__.py # CLI entry point
├── config/ # Global configuration
diff --git a/prowler/CHANGELOG.md b/prowler/CHANGELOG.md
index ce0b5cb581..408c76d3e1 100644
--- a/prowler/CHANGELOG.md
+++ b/prowler/CHANGELOG.md
@@ -2,16 +2,343 @@
All notable changes to the **Prowler SDK** are documented in this file.
-## [5.28.0] (Prowler UNRELEASED)
+
+
+## [5.35.0] (Prowler v5.35.0)
### 🚀 Added
-- `entra_app_registration_client_secret_unused` check for M365 provider [(#11232)](https://github.com/prowler-cloud/prowler/pull/11232)
-- `cloudsql_instance_cmek_encryption_enabled` check for GCP provider [(#11023)](https://github.com/prowler-cloud/prowler/pull/11023)
+- `excluded_checks` and `excluded_services` in scan configurations to narrow the execution scope [(#12028)](https://github.com/prowler-cloud/prowler/pull/12028)
+
+### 🔐 Security
+
+- Jira tenant information requests validate site names and do not follow redirects [(#12012)](https://github.com/prowler-cloud/prowler/pull/12012)
---
-## [5.27.1] (Prowler UNRELEASED)
+## [5.34.0] (Prowler v5.34.0)
+
+### 🚀 Added
+
+- `elbv2_listener_pqc_tls_enabled` check for AWS provider, verifying that ELBv2 listeners use post-quantum TLS policies [(#11254)](https://github.com/prowler-cloud/prowler/pull/11254)
+- `iaas_server_public_ip_attached` check for STACKIT provider, flagging IaaS servers that have a public IP address directly attached to a network interface [(#11549)](https://github.com/prowler-cloud/prowler/pull/11549)
+- Changelog fragment workflow for SDK, API, UI, and MCP Server releases, including PR attribution, fragment validation, release compilation, and preserved section ordering [(#11572)](https://github.com/prowler-cloud/prowler/pull/11572)
+- E2E Networks provider with 27 checks across compute nodes, networking, security groups, load balancers, block/file storage, and managed databases [(#11654)](https://github.com/prowler-cloud/prowler/pull/11654)
+- `datapipeline_pipeline_no_secrets_in_definition` check for AWS provider, scanning Data Pipeline object fields, parameter objects, and parameter values for hardcoded secrets with Kingfisher [(#11821)](https://github.com/prowler-cloud/prowler/pull/11821)
+- `amplify_app_no_secrets_in_environment` check for AWS provider, scanning Amplify app and branch environment variables and build settings for hardcoded secrets [(#11825)](https://github.com/prowler-cloud/prowler/pull/11825)
+- `ec2_ami_account_block_public_access` check for AWS provider, verifying AMI block public access is enabled at the account level in each Region so AMIs cannot be shared publicly [(#11828)](https://github.com/prowler-cloud/prowler/pull/11828)
+- `core_readonly_root_filesystem_enabled` check for Kubernetes provider, verifying that every container in each Pod explicitly sets `readOnlyRootFilesystem: true` in its security context [(#11835)](https://github.com/prowler-cloud/prowler/pull/11835)
+- `core_minimize_hostpath_volume_mounts` check for Kubernetes provider, detecting Pods that use `hostPath` volumes [(#11837)](https://github.com/prowler-cloud/prowler/pull/11837)
+- `app_function_ensure_http_is_redirected_to_https` check for Azure provider, verifying that Function Apps enforce HTTPS-only traffic [(#11929)](https://github.com/prowler-cloud/prowler/pull/11929)
+
+### 🔄 Changed
+
+- Add missing trailing newlines to compliance, region, and fixture data files for POSIX compliance [(#11765)](https://github.com/prowler-cloud/prowler/pull/11765)
+- Oracle Cloud API key authentication now uses an internal bootstrap region when no explicit scan region filter is provided [(#11853)](https://github.com/prowler-cloud/prowler/pull/11853)
+- Redesign the local dashboard sidebar and informational pages [(#11972)](https://github.com/prowler-cloud/prowler/pull/11972)
+
+---
+
+## [5.33.2] (Prowler v5.33.2)
+
+### 🐞 Fixed
+
+- EC2 AMI loading now targets Amazon-owned AMIs used by audited instances, reducing AWS API calls during EC2 scans [(#11958)](https://github.com/prowler-cloud/prowler/pull/11958)
+- `ec2_instance_account_imdsv2_enabled` findings now use regional resource ARNs, preventing findings from different AWS Regions from collapsing into one resource [(#11966)](https://github.com/prowler-cloud/prowler/pull/11966)
+
+---
+
+## [5.33.1] (Prowler v5.33.1)
+
+### 🐞 Fixed
+
+- ECS task definition resource limits now select the latest task definitions by registration date instead of relying on ARN ordering [(#11891)](https://github.com/prowler-cloud/prowler/pull/11891)
+- `dlm_ebs_snapshot_lifecycle_policy_exists` no longer initializes the full EC2 inventory just to detect EBS snapshots, avoiding slow scans when checking DLM lifecycle policies [(#11900)](https://github.com/prowler-cloud/prowler/pull/11900)
+- `dms_instance_no_public_access` no longer initializes the full EC2 service when there are no DMS replication instances [(#11902)](https://github.com/prowler-cloud/prowler/pull/11902)
+- `organizations_scp_check_deny_regions` no longer reports false `FAIL` for AWS Organizations that restrict regions with Allow-based SCPs; the Allow path now checks the statement `Effect` instead of an always-false comparison that made it unreachable [(#11915)](https://github.com/prowler-cloud/prowler/pull/11915)
+- Jira issue creation failures now preserve safe structured response details from Jira [(#11925)](https://github.com/prowler-cloud/prowler/pull/11925)
+- Azure Function App optional permission failures now log as warnings, and Function App environment variable fields use the correct spelling internally [(#11926)](https://github.com/prowler-cloud/prowler/pull/11926)
+
+---
+
+## [5.33.0] (Prowler v5.33.0)
+
+### 🐞 Fixed
+
+- Azure resource group scoped scans now keep subscription entries when scoped resource listing fails, clarify helper documentation and test organization, and align the resource group documentation example with the described values [(#11796)](https://github.com/prowler-cloud/prowler/pull/11796)
+- Azure `postgresql_flexible_server_log_retention_days_greater_3` check now queries the `logfiles.retention_days` configuration parameter instead of `log_retention_days` (which only exists on the retired Single Server), fixing false `FAIL` results on every Flexible Server regardless of the actual retention value [(#11761)](https://github.com/prowler-cloud/prowler/pull/11761)
+
+---
+
+## [5.32.1] (Prowler v5.32.1)
+
+### 🐞 Fixed
+
+- `KeyError: 'MANUAL'` crash while rendering the compliance summary table (e.g. CIS Microsoft 365) when a framework has manual, checks-less requirements with a Level 1/Level 2 profile; `MANUAL` findings are now skipped in the PASS/FAIL section tally instead of raising [(#11822)](https://github.com/prowler-cloud/prowler/issues/11822)
+
+---
+
+## [5.32.0] (Prowler v5.32.0)
+
+### 🚀 Added
+
+- `exchange_application_access_policy_restricts_mailbox_apps` check for M365 provider, verifying every service principal with Microsoft Graph application-level Exchange mailbox permissions is restricted by an Exchange Online Application Access Policy, preventing tenant-wide mailbox access by unscoped applications [(#11247)](https://github.com/prowler-cloud/prowler/pull/11247)
+- Per-requirement configuration validation for compliance frameworks via `ConfigRequirements`, so a requirement is reported as FAIL when its configurable checks ran with a configuration too loose to satisfy it (applied across all compliance outputs: CSV, OCSF, and console tables) [(#11669)](https://github.com/prowler-cloud/prowler/pull/11669)
+- `entra_conditional_access_policy_explicitly_targets_azure_devops` check for M365 provider, verifying at least one enabled Conditional Access policy explicitly includes the Azure DevOps cloud application instead of relying on a broad "All cloud apps" policy [(#11182)](https://github.com/prowler-cloud/prowler/pull/11182)
+- `entra_conditional_access_policy_no_exclusion_gaps` check for M365 provider, verifying every user, group, role, or application excluded from an enabled Conditional Access policy stays in scope of another enabled policy [(#11577)](https://github.com/prowler-cloud/prowler/pull/11577)
+- `entra_conditional_access_policy_groups_management_restricted` check for M365 provider, verifying every security group referenced by an enabled or report-only Conditional Access policy is management-restricted or role-assignable [(#11342)](https://github.com/prowler-cloud/prowler/pull/11342)
+- `stepfunctions_statemachine_encrypted_with_cmk` check for AWS provider, verifying that each Step Functions state machine uses a customer-managed KMS key for encryption at rest rather than the default AWS-owned key [(#11538)](https://github.com/prowler-cloud/prowler/pull/11538)
+- CIS Controls v8.1 universal compliance framework mapping existing checks across 18 providers (AWS, Azure, GCP, Kubernetes, M365, GitHub, AlibabaCloud, OracleCloud, GoogleWorkspace, Okta, Cloudflare, Vercel, MongoDB Atlas, OpenStack, Linode, StackIT, NHN, and Scaleway) to the 18 CIS Critical Security Controls and their Safeguards [(#11700)](https://github.com/prowler-cloud/prowler/pull/11700)
+- CIS Microsoft 365 Foundations Benchmark v7.0.0 compliance framework for the M365 provider [(#11699)](https://github.com/prowler-cloud/prowler/pull/11699)
+- `waf_regional_webacl_logging_enabled` check for AWS provider, verifying that each AWS WAF Classic Regional Web ACL has logging enabled to a Kinesis Data Firehose stream [(#11539)](https://github.com/prowler-cloud/prowler/pull/11539)
+- `sdk_only` provider property (default `true`) and `Provider.get_app_providers()`, so a provider (built-in or external) stays CLI/SDK-only and hidden from the app unless it declares `sdk_only = False` [(#11427)](https://github.com/prowler-cloud/prowler/pull/11427)
+- `Provider.get_scan_arguments()`, `Provider.get_connection_arguments()` and `Provider.get_credentials_schema()` contract methods, so a provider persisted as a stored uid plus a secret dict can be constructed and validated programmatically (to be consumed by the API in a later change) [(#11578)](https://github.com/prowler-cloud/prowler/pull/11578)
+- Okta API request throttling to proactively stay under rate limits, configurable via `okta_requests_per_second` in the config file and the `--okta-requests-per-second` CLI flag, plus configurable retries via `okta_max_retries` / `--okta-retries-max-attempts` as a safety net [(#11702)](https://github.com/prowler-cloud/prowler/pull/11702)
+- CIS Amazon Web Services Foundations Benchmark v7.0.0 compliance framework for the AWS provider, adding the new Organizations section (2.1.1-2.1.6), resource policy (2.21), web front-end access logging (4.10), and VPC Endpoints (6.8) recommendations [(#11707)](https://github.com/prowler-cloud/prowler/pull/11707)
+- CIS Microsoft Azure Foundations Benchmark v6.0.0 compliance framework for the Azure provider [(#11708)](https://github.com/prowler-cloud/prowler/pull/11708)
+- CIS Google Cloud Platform Foundation Benchmark v5.0.0 compliance framework for the GCP provider [(#11714)](https://github.com/prowler-cloud/prowler/pull/11714)
+- CIS Kubernetes Benchmark v2.0.1 compliance framework for the Kubernetes provider [(#11722)](https://github.com/prowler-cloud/prowler/pull/11722)
+- CIS GitHub Benchmark v1.2.0 compliance framework for the GitHub provider [(#11719)](https://github.com/prowler-cloud/prowler/pull/11719)
+- AWS Bedrock AgentCore privilege escalation paths in the IAM privilege escalation checks, covering Runtime, Harness, Code Interpreter and Custom Browser [(#11726)](https://github.com/prowler-cloud/prowler/pull/11726)
+- `--scan-secrets-validate` flag and `aws.secrets_validate` configuration option to optionally validate the secrets discovered by the secret-scanning checks against the provider APIs; secrets confirmed to be live are reported as critical [(#11694)](https://github.com/prowler-cloud/prowler/pull/11694)
+- `apigateway_restapi_no_secrets_in_stage_variables` check for AWS provider, scanning API Gateway REST API stage variables for hardcoded secrets such as passwords, API keys, and tokens [(#11188)](https://github.com/prowler-cloud/prowler/pull/11188)
+- `s3_bucket_object_public` check for AWS provider, spot-checking a configurable sample of object ACLs in each bucket and flagging objects granted to the AllUsers or AuthenticatedUsers groups; disabled by default and opted into via the `s3_bucket_object_public_enabled` configuration option [(#9517)](https://github.com/prowler-cloud/prowler/pull/9517)
+- Azure provider now supports `--azure-resource-group` to scope resource-level checks to specific resource groups across all accessible subscriptions [(#10657)](https://github.com/prowler-cloud/prowler/pull/10657)
+
+### 🔄 Changed
+
+- Replaced the `detect-secrets` library with [Kingfisher](https://github.com/mongodb/kingfisher) as the engine for the secret-scanning checks; scans run fully offline by default and obvious placeholder values are no longer reported as findings [(#11694)](https://github.com/prowler-cloud/prowler/pull/11694)
+- Removed the `detect_secrets_plugins` configuration option, which is no longer used by the new secret-scanning engine [(#11694)](https://github.com/prowler-cloud/prowler/pull/11694)
+- `awslambda_function_no_secrets_in_code` now supports a `secrets_ignore_files` audit-config option to skip files inside the deployment package by glob pattern (e.g. `*.deps.json`), suppressing .NET dependency-manifest false positives without masking real secrets [(#11222)](https://github.com/prowler-cloud/prowler/pull/11222)
+- AWS scans for EBS snapshots, Backup recovery points, CloudWatch log groups, Lambda functions, ECS task definitions, and CodeArtifact packages now support configurable resource analysis limits via `aws.max_scanned_resources_per_service`; limits are disabled by default and only positive values cap analyzed resources [(#11228)](https://github.com/prowler-cloud/prowler/pull/11228)
+
+### 🐞 Fixed
+
+- GitHub `repository_has_codeowners_file` check no longer flags archived repositories, since they are read-only and cannot be updated without first being unarchived, making the finding not actionable [(#11735)](https://github.com/prowler-cloud/prowler/pull/11735)
+- Report secret-scanning checks as `MANUAL` instead of `PASS` when the scanner fails (non-zero exit, timeout, unparseable output or missing binary), so a scanner failure is no longer indistinguishable from "no secrets found" [(#11694)](https://github.com/prowler-cloud/prowler/pull/11694)
+- Avoid a false `FAIL` in `cloudwatch_log_group_no_secrets_in_logs` when a multiline event's secrets are all removed by `secrets_ignore_patterns` during the rescan [(#11694)](https://github.com/prowler-cloud/prowler/pull/11694)
+- Key the `cloudwatch_log_group_no_secrets_in_logs` secret scan by log group ARN instead of name, so same-named log groups and streams in different regions no longer collide and reuse each other's findings [(#11694)](https://github.com/prowler-cloud/prowler/pull/11694)
+- Compliance frameworks contributed by several external packages under the same provider are now merged instead of overwritten, so every entry-point directory a provider contributes is discovered [(#11578)](https://github.com/prowler-cloud/prowler/pull/11578)
+- Azure PostgreSQL flexible server collection no longer drops the remaining servers in a subscription when one server fails to collect; the `connection_throttle.enable` parameter (removed in PostgreSQL 16+) is treated as absent only when the Azure SDK reports it as not found, so unexpected lookup failures are not silently reported as throttling disabled [(#11595)](https://github.com/prowler-cloud/prowler/pull/11595)
+- Azure `keyvault_logging_enabled` now accepts Key Vault diagnostic settings that enable the explicit `AuditEvent` category, avoiding false failures when Azure returns category-based logs without category groups [(#11660)](https://github.com/prowler-cloud/prowler/pull/11660)
+- GitHub default branch protection checks now evaluate repository rulesets in addition to classic branch protection, avoiding false positives for repositories that enforce protection through rulesets [(#11723)](https://github.com/prowler-cloud/prowler/pull/11723)
+- Okta, Alibaba Cloud and OpenStack scan-config sections are now validated against a registered schema instead of being silently accepted, so their configurable thresholds (session/idle timeouts, retention days, image-sharing and secret-scanning settings) log a warning and fall back to the built-in default whenever a value is out of range [(#11725)](https://github.com/prowler-cloud/prowler/pull/11725)
+
+---
+
+## [5.31.1] (Prowler v5.31.1)
+
+### 🐞 Fixed
+
+- Alibaba Cloud `ram_password_policy_number` and `cs_kubernetes_cluster_check_weekly` checks not being loaded due to missing implementation and package files [(#11683)](https://github.com/prowler-cloud/prowler/pull/11683)
+
+---
+
+## [5.31.0] (Prowler v5.31.0)
+
+### 🚀 Added
+
+- Support for Python 3.13 [(#9293)](https://github.com/prowler-cloud/prowler/pull/9293)
+- `securityhub_delegated_admin_enabled_all_regions` check for AWS provider, verifying that Security Hub has a delegated administrator, is active in all opted-in regions, and has organization auto-enable on [(#11259)](https://github.com/prowler-cloud/prowler/pull/11259)
+- `config_delegated_admin_and_org_aggregator_all_regions` check for AWS provider, verifying that AWS Config has a delegated administrator and an organization aggregator covering all AWS regions [(#11259)](https://github.com/prowler-cloud/prowler/pull/11259)
+- `sagemaker_clarify_exists` check for AWS provider [(#11211)](https://github.com/prowler-cloud/prowler/pull/11211)
+- `cloudsql_instance_high_availability_enabled` check for GCP provider, verifying Cloud SQL primary instances use `REGIONAL` availability for automatic zone failover [(#11024)](https://github.com/prowler-cloud/prowler/pull/11024)
+- `cloudfunction_function_inside_vpc` check for GCP provider, verifying Cloud Functions have a Serverless VPC Access connector for private egress [(#11021)](https://github.com/prowler-cloud/prowler/pull/11021)
+- `cloudfunction_function_not_publicly_accessible` check for GCP provider, detecting Cloud Functions with `allUsers` or `allAuthenticatedUsers` IAM invocation bindings [(#11022)](https://github.com/prowler-cloud/prowler/pull/11022)
+- `secretmanager_secret_not_publicly_accessible` check for GCP provider, detecting Secret Manager secrets with public IAM bindings [(#11025)](https://github.com/prowler-cloud/prowler/pull/11025)
+- `secretmanager_secret_rotation_enabled` check for GCP provider, verifying Secret Manager secrets have automatic rotation configured within 90 days [(#11026)](https://github.com/prowler-cloud/prowler/pull/11026)
+- `identity_storage_service_level_admins_scoped` check for OCI provider CIS 3.1 control 1.15, ensuring storage service-level administrators exclude delete permissions [(#11523)](https://github.com/prowler-cloud/prowler/pull/11523)
+- `cosmosdb_account_automatic_failover_enabled` check for Azure provider [(#11031)](https://github.com/prowler-cloud/prowler/pull/11031)
+- `cosmosdb_account_backup_policy_continuous` check for Azure provider [(#11032)](https://github.com/prowler-cloud/prowler/pull/11032)
+- `cosmosdb_account_minimum_tls_version` check for Azure provider, verifying Cosmos DB accounts enforce TLS 1.2 or higher for client connections [(#11033)](https://github.com/prowler-cloud/prowler/pull/11033)
+- `cosmosdb_account_public_network_access_disabled` check for Azure provider, verifying Cosmos DB accounts have public network access disabled so connectivity is restricted to private endpoints or VNet service endpoints [(#11034)](https://github.com/prowler-cloud/prowler/pull/11034)
+- `databricks_workspace_public_network_access_disabled` check for Azure provider, verifying Databricks workspaces have public network access disabled so connectivity is restricted to Azure Private Link private endpoints [(#11035)](https://github.com/prowler-cloud/prowler/pull/11035)
+- `databricks_workspace_no_public_ip_enabled` check for Azure provider, verifying Databricks workspaces use secure cluster connectivity (no public IP) so compute nodes are not assigned public IP addresses [(#11036)](https://github.com/prowler-cloud/prowler/pull/11036)
+- `defender_ensure_defender_cspm_is_on` check for Azure provider, verifying Microsoft Defender Cloud Security Posture Management (CSPM) is enabled on the Standard tier [(#11037)](https://github.com/prowler-cloud/prowler/pull/11037)
+- `mysql_flexible_server_geo_redundant_backup_enabled` check for Azure provider, verifying MySQL Flexible Servers have geo-redundant backup enabled so backups are replicated to the paired region [(#11041)](https://github.com/prowler-cloud/prowler/pull/11041)
+- `mysql_flexible_server_high_availability_enabled` check for Azure provider, verifying MySQL Flexible Servers have high availability enabled for automatic failover to a standby replica [(#11042)](https://github.com/prowler-cloud/prowler/pull/11042)
+- `postgresql_flexible_server_geo_redundant_backup_enabled` check for Azure provider, verifying PostgreSQL Flexible Servers have geo-redundant backup enabled so backups are replicated to the paired region [(#11045)](https://github.com/prowler-cloud/prowler/pull/11045)
+- `postgresql_flexible_server_high_availability_enabled` check for Azure provider, verifying PostgreSQL Flexible Servers have high availability enabled for automatic failover to a standby replica [(#11046)](https://github.com/prowler-cloud/prowler/pull/11046)
+- `aks_cluster_azure_monitor_enabled` check for Azure provider, verifying AKS clusters have Azure Monitor (Container Insights) enabled for metrics, logs, and alerting [(#11029)](https://github.com/prowler-cloud/prowler/pull/11029)
+- `aks_cluster_local_accounts_disabled` check for Azure provider, verifying AKS clusters have local accounts disabled so authentication is forced through Microsoft Entra ID [(#11030)](https://github.com/prowler-cloud/prowler/pull/11030)
+- `network_subnet_nsg_associated` check for Azure provider, verifying virtual network subnets have a network security group associated to enforce traffic filtering [(#11043)](https://github.com/prowler-cloud/prowler/pull/11043)
+- `network_vnet_ddos_protection_enabled` check for Azure provider, verifying virtual networks have Azure DDoS Network Protection enabled [(#11044)](https://github.com/prowler-cloud/prowler/pull/11044)
+- `entra_app_registration_credential_not_expired` check for Azure provider, verifying Entra ID app registration secrets and certificates are not expired, expiring within 30 days, or without an expiration date [(#11038)](https://github.com/prowler-cloud/prowler/pull/11038)
+- `entra_authentication_methods_policy_strong_auth_enforced` check for Azure provider, verifying the Entra ID authentication methods policy enforces MFA registration and enables at least one strong method (Microsoft Authenticator, FIDO2, or X.509 certificate) [(#11039)](https://github.com/prowler-cloud/prowler/pull/11039)
+- `entra_user_with_recent_sign_in` check for Azure provider, detecting stale enabled accounts that have not signed in within the last 90 days (requires Entra ID P1/P2 licensing for sign-in activity) [(#11040)](https://github.com/prowler-cloud/prowler/pull/11040)
+- `aks_cluster_auto_upgrade_enabled` check for Azure provider [(#11027)](https://github.com/prowler-cloud/prowler/pull/11027)
+- Public `Provider.get_class()` method that resolves a provider class by name for both built-in and external (entry-point) providers [(#11398)](https://github.com/prowler-cloud/prowler/pull/11398)
+- Jira timeout preventing the calls from hanging indefinitely when the Jira endpoint is unreachable or slow [(#11602)](https://github.com/prowler-cloud/prowler/pull/11602)
+- TLS certificate verification in the `codepipeline_project_repo_private` check, which previously used an unverified SSL context, leaving the repository-visibility probe open to MITM tampering [(#11603)](https://github.com/prowler-cloud/prowler/pull/11603)
+- Support for Linode cloud provider, with compute, networking and administration services [(#11633)](https://github.com/prowler-cloud/prowler/pull/11633)
+- DORA (Digital Operational Resilience Act, Regulation (EU) 2022/2554) compliance coverage for the Azure provider, mapping existing Azure checks across the five DORA pillars [(#11551)](https://github.com/prowler-cloud/prowler/pull/11551)
+- Rename DORA to DORA_2022_2554 to follow the naming _ in compliance frameworks [(#11551)](https://github.com/prowler-cloud/prowler/pull/11551)
+- `entra_directory_sync_object_takeover_blocked` check for the M365 provider, verifying that hybrid Entra tenants block cloud object takeover through both soft-match and hard-match directory synchronization [(#11098)](https://github.com/prowler-cloud/prowler/pull/11098)
+- `entra_conditional_access_policy_no_deleted_object_references` check for M365 provider [(#11236)](https://github.com/prowler-cloud/prowler/pull/11236)
+- `aks_cluster_defender_enabled` check for Azure provider, verifying that AKS clusters have Microsoft Defender security monitoring enabled [(#11028)](https://github.com/prowler-cloud/prowler/pull/11028)
+- `recovery_vault_has_protected_items` check for Azure provider, verifying that Recovery Services vaults have at least one protected backup item [(#11048)](https://github.com/prowler-cloud/prowler/pull/11048)
+- DORA (Digital Operational Resilience Act, Regulation (EU) 2022/2554) compliance coverage for the GCP provider, mapping existing GCP checks across the five DORA pillars [(#11642)](https://github.com/prowler-cloud/prowler/pull/11642)
+- DORA (Digital Operational Resilience Act, Regulation (EU) 2022/2554) compliance coverage for the Cloudflare provider, mapping existing Cloudflare edge/network checks across the applicable DORA pillars [(#11645)](https://github.com/prowler-cloud/prowler/pull/11645)
+- DORA (Digital Operational Resilience Act, Regulation (EU) 2022/2554) compliance coverage for the AlibabaCloud provider, mapping existing AlibabaCloud checks across the applicable DORA pillars [(#11646)](https://github.com/prowler-cloud/prowler/pull/11646)
+- `cloudfront_distributions_pqc_tls_enabled` check for AWS provider to verify CloudFront distributions enforce a post-quantum TLS 1.3 security policy [(#11317)](https://github.com/prowler-cloud/prowler/pull/11317)
+- `apigateway_domain_name_pqc_tls_enabled` check for AWS provider to verify API Gateway custom domain names use a post-quantum TLS security policy [(#11316)](https://github.com/prowler-cloud/prowler/pull/11316)
+- `transfer_server_pqc_ssh_kex_enabled` check for AWS provider to verify Transfer Family servers use a post-quantum hybrid SSH key exchange security policy [(#11315)](https://github.com/prowler-cloud/prowler/pull/11315)
+- `acmpca_certificate_authority_pqc_key_algorithm` check and new `acmpca` service for AWS provider to verify AWS Private CA certificate authorities use a post-quantum (ML-DSA) key algorithm [(#11318)](https://github.com/prowler-cloud/prowler/pull/11318)
+- `rolesanywhere_trust_anchor_pqc_pki` check and new `rolesanywhere` service for AWS provider to verify IAM Roles Anywhere trust anchors are backed by a post-quantum (ML-DSA) PKI [(#11319)](https://github.com/prowler-cloud/prowler/pull/11319)
+- Kubernetes core checks for container CPU limits, CPU requests, memory limits, memory requests, fixed image tags, liveness probes, and readiness probes [(#11373)](https://github.com/prowler-cloud/prowler/pull/11373)
+- `recovery_vault_backup_policy_retention_adequate` check for Azure provider, verifying Recovery Services backup policies retain daily backups for at least 30 days [(#11047)](https://github.com/prowler-cloud/prowler/pull/11047)
+
+### 🔄 Changed
+
+- Replaced the unmaintained `awsipranges` dependency with a small standard-library helper for the `route53_dangling_ip_subdomain_takeover` check [(#9293)](https://github.com/prowler-cloud/prowler/pull/9293)
+
+### 🐞 Fixed
+
+- Azure PostgreSQL flexible server inventory no longer aborts the whole subscription when the `connection_throttle.enable` parameter is missing (e.g. PostgreSQL v18), and logs the expected "Entra ID authentication not enabled" case as a warning instead of an error, so servers are still scanned [(#11045)](https://github.com/prowler-cloud/prowler/pull/11045)
+- `iam_policy_allows_privilege_escalation` now includes the `privilege-escalation` category [(#11648)](https://github.com/prowler-cloud/prowler/pull/11648)
+
+### 🔐 Security
+
+- `pytest` from 8.3.5 to 9.0.3, patching a known vulnerability in the SDK test dependency [(#11291)](https://github.com/prowler-cloud/prowler/pull/11291)
+- `black` from 25.1.0 to 26.3.1, patching a known vulnerability in the SDK formatter dependency [(#11290)](https://github.com/prowler-cloud/prowler/pull/11290)
+- `microsoft-kiota-*` to 1.9.9 and `aiohttp` to 3.14.0, patching known CVEs [(#11596)](https://github.com/prowler-cloud/prowler/pull/11596)
+- Container base image bumped to `python:3.12.13-slim-bookworm` (patches `libgnutls30` CVE-2026-33845 and CVE-2026-42010) and `trivy` bumped to 0.71.0 (patches embedded `golang.org/x/crypto` and Go stdlib CVEs); `.trivyignore` documents remaining bookworm criticals with no-fix or not-affected rationale [(#11592)](https://github.com/prowler-cloud/prowler/pull/11592)
+
+---
+
+## [5.30.3] (Prowler v5.30.3)
+
+### 🐞 Fixed
+
+- CLI compliance summary tables no longer undercount findings mapped to multiple sections nor double-count a single finding mapped to several requirements within the same group/split, and the Provider column no longer leaks a value from another framework [(#11567)](https://github.com/prowler-cloud/prowler/pull/11567)
+
+---
+
+## [5.30.2] (Prowler v5.30.2)
+
+### 🐞 Fixed
+
+- GCP `logging_log_metric_filter_and_alert_*` checks now credit org-level aggregated sinks filtered to the Admin Activity audit stream [(#11575)](https://github.com/prowler-cloud/prowler/pull/11575)
+- A broken built-in provider no longer aborts the CLI when a different provider was invoked [(#11618)](https://github.com/prowler-cloud/prowler/pull/11618)
+- GCP organization scans with `--organization-id` no longer silently fall back to the credentials' host project when the Cloud Asset API call fails [(#11280)](https://github.com/prowler-cloud/prowler/pull/11280)
+
+---
+
+## [5.30.0] (Prowler v5.30.0)
+
+### 🚀 Added
+
+- DISA Okta IDaaS STIG V1R2 compliance framework for the Okta provider, with a dedicated CSV output formatter and terminal summary table [(#11428)](https://github.com/prowler-cloud/prowler/pull/11428)
+- `sagemaker_models_monitor_enabled` check for AWS provider, verifying that each SageMaker monitoring schedule is in the `Scheduled` state so data and model drift is actively detected [(#11278)](https://github.com/prowler-cloud/prowler/pull/11278)
+- DORA (Digital Operational Resilience Act, Regulation (EU) 2022/2554) universal compliance framework with AWS provider coverage across the five DORA pillars [(#11131)](https://github.com/prowler-cloud/prowler/pull/11131)
+- Okta authenticator and password policy checks for STIG-aligned hardening requirements [(#11465)](https://github.com/prowler-cloud/prowler/pull/11465)
+- Okta network zone check to detect whether anonymized proxy traffic is blocked [(#11463)](https://github.com/prowler-cloud/prowler/pull/11463)
+- Okta API token checks for super admin ownership and network zone restrictions [(#11464)](https://github.com/prowler-cloud/prowler/pull/11464)
+- Support for external/custom providers, checks, and compliance frameworks without modifying core code [(#10700)](https://github.com/prowler-cloud/prowler/pull/10700)
+- `elbv2_alb_drop_invalid_header_fields_enabled` check for AWS provider, verifying Application Load Balancers have `routing.http.drop_invalid_header_fields.enabled` set to `true` to mitigate HTTP desync attacks (AWS FSBP ELB.4) [(#11471)](https://github.com/prowler-cloud/prowler/pull/11471)
+- `user`, `systemlog` and `idp` service for Okta provider with `user_inactivity_automation_35d_enabled`, `systemlog_streaming_enabled` and `idp_smart_card_dod_approved_ca` checks [(#11496)](https://github.com/prowler-cloud/prowler/pull/11496)
+- External multi-provider compliance frameworks can be registered via the `prowler.compliance.universal` entry point group [(#11490)](https://github.com/prowler-cloud/prowler/pull/11490)
+- AWS AI Security Framework support in the CLI dashboard [(#11475)](https://github.com/prowler-cloud/prowler/pull/11475)
+- `entra_service_principal_privileged_role_no_owners` check for M365 provider, failing when a service principal with a permanent Tier 0 directory role has owners on the service principal or its parent app registration [(#11070)](https://github.com/prowler-cloud/prowler/issues/11070)
+- `kms_key_rotation_max_90_days` check for GCP provider, verifying KMS customer-managed keys are rotated every 90 days or less in line with the CIS Benchmark [(#11516)](https://github.com/prowler-cloud/prowler/pull/11516)
+- `exchange_mailbox_primary_smtp_uses_custom_domain` check for M365 provider [(#11215)](https://github.com/prowler-cloud/prowler/pull/11215)
+- `bedrock_agent_role_least_privilege` check for AWS provider, flagging Bedrock Agent execution roles with full-access managed policies, broad `Resource:*` inline statements, or missing permissions boundaries [(#11335)](https://github.com/prowler-cloud/prowler/pull/11335)
+- STACKIT ObjectStorage service with Object Lock, default retention policy, and access key expiration checks [(#11397)](https://github.com/prowler-cloud/prowler/pull/11397)
+
+### 🐞 Fixed
+
+- `load_and_validate_config_file` now unwraps namespaced config for every built-in and external provider, and no longer leaks the full file as the provider's config when the file is namespaced [(#10700)](https://github.com/prowler-cloud/prowler/pull/10700)
+- `entra_users_mfa_capable` no longer flags pre-provisioned users with future `employeeHireDate`; future-hire date comparisons now tolerate naive datetimes [(#11511)](https://github.com/prowler-cloud/prowler/pull/11511)
+- M365 Admin Center group enumeration now follows Microsoft Graph pagination so group-scoped checks include groups beyond the first page [(#11510)](https://github.com/prowler-cloud/prowler/pull/11510)
+- GCP `kms_key_rotation_enabled` check now only verifies that automatic key rotation is enabled (any interval) instead of enforcing a 90-day period, resolving the mismatch between the check and its documentation; the CIS, Prowler ThreatScore, and CCC requirements that mandate a 90-day maximum were remapped to the new `kms_key_rotation_max_90_days` check [(#11516)](https://github.com/prowler-cloud/prowler/pull/11516)
+- AWS CloudWatch log metric filter checks now validate `filterPattern` clauses regardless of order [(#11345)](https://github.com/prowler-cloud/prowler/pull/11345)
+- AWS `bedrock_api_key_no_long_term_credentials` now applies severity per finding (never-expires keys correctly flag as critical, no leak across findings) and aligns title and wording with AWS guidance to prefer short-term Bedrock API keys [(#11526)](https://github.com/prowler-cloud/prowler/pull/11526)
+
+### 🔐 Security
+
+- `dulwich` from 0.23.0 to 1.2.5 and `pyjwt` from 2.12.1 to 2.13.0, patching `GHSA-897w-fcg9-f6xj` (arbitrary file write) and `PYSEC-2026-179` (HMAC/JWK key confusion) [(#11499)](https://github.com/prowler-cloud/prowler/pull/11499)
+
+---
+
+## [5.29.3] (Prowler v5.29.3)
+
+### 🐞 Fixed
+
+- GCP `logging_sink_created` now recognizes organization-level aggregated sinks with `includeChildren=True`, avoiding false failures for covered projects [(#11355)](https://github.com/prowler-cloud/prowler/pull/11355)
+- GCP `logging_log_metric_filter_and_alert_*` checks now recognize organization-level aggregated sinks with `includeChildren=True`, no longer false-failing projects covered by a central bucket-scoped metric + alert [(#11488)](https://github.com/prowler-cloud/prowler/pull/11488)
+- Jira integration no longer fails with `400 INVALID_INPUT` when a finding has empty fields [(#11474)](https://github.com/prowler-cloud/prowler/pull/11474)
+- GCP `iam_service_account_unused` now passes disabled service accounts instead of failing them, since a disabled account cannot authenticate or be used [(#11467)](https://github.com/prowler-cloud/prowler/pull/11467)
+
+---
+
+## [5.29.1] (Prowler v5.29.1)
+
+### 🐞 Fixed
+
+- OCSF output writer now re-raises I/O errors (e.g. `ENOSPC`) instead of logging them per finding and leaving a truncated file [(#11421)](https://github.com/prowler-cloud/prowler/pull/11421)
+
+---
+
+## [5.29.0] (Prowler v5.29.0)
+
+### 🚀 Added
+
+- `application` service for Okta provider with `application_admin_console_session_idle_timeout_15min`, `application_admin_console_mfa_required`, `application_admin_console_phishing_resistant_authentication`, `application_dashboard_mfa_required`, `application_dashboard_phishing_resistant_authentication`, and `application_authentication_policy_network_zone_enforced` checks [(#11358)](https://github.com/prowler-cloud/prowler/pull/11358)
+- AWS AI Security Framework compliance for AWS provider [(#11353)](https://github.com/prowler-cloud/prowler/pull/11353)
+- `storage_account_public_network_access_disabled` check for Azure provider and remapped the Azure CIS "Public Network Access is Disabled" requirements to it [(#11334)](https://github.com/prowler-cloud/prowler/pull/11334)
+- StackIT provider with service account key authentication [(#9237)](https://github.com/prowler-cloud/prowler/pull/9237)
+- 8 Rules service checks for Google Workspace provider using the Cloud Identity Policy API [(#11379)](https://github.com/prowler-cloud/prowler/pull/11379)
+- 12 Security service checks for Google Workspace provider using the Cloud Identity Policy API [(#11356)](https://github.com/prowler-cloud/prowler/pull/11356)
+
+### ⚠️ Deprecated
+
+- `s3_bucket_default_encryption` check for AWS provider since SSE-S3 is automatically applied to all S3 buckets by AWS as of January 5, 2023 and can no longer be disabled [(#11230)](https://github.com/prowler-cloud/prowler/pull/11230)
+
+### 🐞 Fixed
+
+- Broken documentation URLs in Google Workspace check metadata [(#11405)](https://github.com/prowler-cloud/prowler/pull/11405)
+- ENS RD 311/2022 (AWS) compliance mapping: `vpc_different_regions` was uncorrectly mapped under the `mp.com.4` family (Network segregation). That check is now mapped to a new `op.cont.2.aws.vpc.1` requirement under the Continuity of Service control [(#11372)](https://github.com/prowler-cloud/prowler/pull/11372)
+- Compliance CSV row count now matches the UI per requirement by sourcing rows from the framework JSON's `requirement.Checks` instead of the stale `finding.compliance` snapshot [(#11370)](https://github.com/prowler-cloud/prowler/pull/11370)
+- OpenStack provider exception codes moved from the `10000-10999` range, shared with the AlibabaCloud provider, to the free `17000-17999` range to keep error codes unambiguous [(#11382)](https://github.com/prowler-cloud/prowler/pull/11382)
+- Azure provider authentication against sovereign clouds (`AzureChinaCloud`, `AzureUSGovernment`) [(#10284)](https://github.com/prowler-cloud/prowler/pull/10284)
+
+---
+
+## [5.28.1] (Prowler v5.28.1)
+
+### 🐞 Fixed
+
+- `compute_project_os_login_enabled` and `compute_project_os_login_2fa_enabled` checks for GCP provider no longer false-FAIL on projects where the `enable-oslogin` / `enable-oslogin-2fa` metadata is not set explicitly but is inherited automatically from the `constraints/compute.requireOsLogin` org policy. The policy controller writes the inherited value in lowercase (`"true"`), but the service-layer parser compared it to the uppercase string literal `"TRUE"`. Comparison is now case-insensitive [(#11341)](https://github.com/prowler-cloud/prowler/pull/11341)
+- `storage_smb_channel_encryption_with_secure_algorithm` check for Azure provider no longer passes when a storage account allows a weak SMB channel encryption algorithm (e.g. `AES-128-CCM`/`AES-128-GCM`) alongside `AES-256-GCM`; it now requires every enabled algorithm to be in the recommended list, configurable via `azure.recommended_smb_channel_encryption_algorithms` (defaults to `AES-256-GCM` only, as required by CIS) [(#11327)](https://github.com/prowler-cloud/prowler/pull/11327)
+- Azure and M365 providers crashing with `RuntimeError: There is no current event loop` on Python 3.12 when called from threads without an active event loop (e.g. Celery workers) [(#11360)](https://github.com/prowler-cloud/prowler/pull/11360)
+
+---
+
+## [5.28.0] (Prowler v5.28.0)
+
+### 🚀 Added
+
+- Sites, Additional Google services, and Marketplace checks for Google Workspace provider using the Cloud Identity Policy API [(#11281)](https://github.com/prowler-cloud/prowler/pull/11281)
+- `entra_app_registration_client_secret_unused` check for M365 provider [(#11232)](https://github.com/prowler-cloud/prowler/pull/11232)
+- `cloudsql_instance_cmek_encryption_enabled` check for GCP provider [(#11023)](https://github.com/prowler-cloud/prowler/pull/11023)
+- Google Workspace Groups service with 3 new checks [(#11186)](https://github.com/prowler-cloud/prowler/pull/11186)
+- `ses_identity_dkim_enabled` check for AWS provider [(#10923)](https://github.com/prowler-cloud/prowler/pull/10923)
+- `sagemaker_models_registry_in_use` check for AWS provider, verifying that at least one SageMaker Model Package Group has an approved model package to enforce ML governance workflows [(#11196)](https://github.com/prowler-cloud/prowler/pull/11196)
+- `signon_dod_warning_banner_configured`, `signon_global_session_lifetime_18h`, `signon_global_session_cookies_not_persistent` and `signon_global_session_policy_network_zone_enforced` checks for Okta provider [(#11224)](https://github.com/prowler-cloud/prowler/pull/11224)
+
+### 🔄 Changed
+
+- `OktaProvider.test_connection` accepts an optional `provider_id` (org domain) and raises `OktaInvalidProviderIdError` (14007) when it doesn't match the authenticated org — guards against stored UID drifting from the credentials' org [(#11184)](https://github.com/prowler-cloud/prowler/pull/11184)
+- Use single-quoted strings for credential variables in the M365 provider PowerShell session, following PowerShell best practices for literal values [(#9997)](https://github.com/prowler-cloud/prowler/pull/9997)
+
+### 🐞 Fixed
+
+- OCI Audit service configuration lookup when the configured region differs from the tenancy home region [(#10347)](https://github.com/prowler-cloud/prowler/pull/10347)
+- Container image now uses an absolute `ENTRYPOINT` (`/home/prowler/.venv/bin/prowler`) so it works under any runtime `--workdir`. The relative entrypoint was breaking the official GitHub Action (`prowler-cloud/prowler@v5.27.0`) and any `docker run` with a custom `-w` [(#11313)](https://github.com/prowler-cloud/prowler/pull/11313)
+
+---
+
+## [5.27.1] (Prowler v5.27.1)
### 🐞 Fixed
@@ -28,6 +355,7 @@ All notable changes to the **Prowler SDK** are documented in this file.
- `entra_service_principal_no_secrets_for_permanent_tier0_roles` check for M365 provider [(#10788)](https://github.com/prowler-cloud/prowler/pull/10788)
- `iam_user_access_not_stale_to_sagemaker` check for AWS provider with configurable `max_unused_sagemaker_access_days` (default 90) [(#11000)](https://github.com/prowler-cloud/prowler/pull/11000)
- `cloudtrail_bedrock_logging_enabled` check for AWS provider [(#10858)](https://github.com/prowler-cloud/prowler/pull/10858)
+- Per-provider scan configuration schema with bounds validation that drops out-of-range values with a warning on config load [(#11518)](https://github.com/prowler-cloud/prowler/pull/11518)
- Okta provider with OAuth 2.0 authentication and `signon_global_session_idle_timeout_15min` check [(#11079)](https://github.com/prowler-cloud/prowler/pull/11079)
- `sagemaker_domain_sso_configured` check for AWS provider [(#11094)](https://github.com/prowler-cloud/prowler/pull/11094)
- Scaleway provider with `iam_api_keys_no_root_owned` check [(#11166)](https://github.com/prowler-cloud/prowler/pull/11166)
@@ -71,7 +399,6 @@ All notable changes to the **Prowler SDK** are documented in this file.
- `bedrock_prompt_management_exists` check for AWS provider [(#10878)](https://github.com/prowler-cloud/prowler/pull/10878)
- 8 Gmail attachment safety and spoofing protection checks for Google Workspace provider using the Cloud Identity Policy API [(#10980)](https://github.com/prowler-cloud/prowler/pull/10980)
- `bedrock_prompt_encrypted_with_cmk` check for AWS provider [(#10905)](https://github.com/prowler-cloud/prowler/pull/10905)
-
### 🔄 Changed
- Azure Network Watcher flow log checks now require workspace-backed Traffic Analytics for `network_flow_log_captured_sent` and align metadata with VNet-compatible flow log guidance [(#10645)](https://github.com/prowler-cloud/prowler/pull/10645)
diff --git a/prowler/__main__.py b/prowler/__main__.py
index 3328905153..12f4b24cac 100644
--- a/prowler/__main__.py
+++ b/prowler/__main__.py
@@ -10,7 +10,6 @@ from colorama import Fore, Style
from colorama import init as colorama_init
from prowler.config.config import (
- EXTERNAL_TOOL_PROVIDERS,
cloud_api_base_url,
csv_file_suffix,
get_available_compliance_frameworks,
@@ -20,7 +19,7 @@ from prowler.config.config import (
orange_color,
sarif_file_suffix,
)
-from prowler.lib.banner import print_banner
+from prowler.lib.banner import print_banner, print_prowler_cloud_banner
from prowler.lib.check.check import (
exclude_checks_to_run,
exclude_services_to_run,
@@ -85,11 +84,6 @@ from prowler.lib.outputs.compliance.compliance import (
display_compliance_table,
process_universal_compliance_frameworks,
)
-from prowler.lib.outputs.compliance.csa.csa_alibabacloud import AlibabaCloudCSA
-from prowler.lib.outputs.compliance.csa.csa_aws import AWSCSA
-from prowler.lib.outputs.compliance.csa.csa_azure import AzureCSA
-from prowler.lib.outputs.compliance.csa.csa_gcp import GCPCSA
-from prowler.lib.outputs.compliance.csa.csa_oraclecloud import OracleCloudCSA
from prowler.lib.outputs.compliance.ens.ens_aws import AWSENS
from prowler.lib.outputs.compliance.ens.ens_azure import AzureENS
from prowler.lib.outputs.compliance.ens.ens_gcp import GCPENS
@@ -108,6 +102,9 @@ from prowler.lib.outputs.compliance.mitre_attack.mitre_attack_azure import (
AzureMitreAttack,
)
from prowler.lib.outputs.compliance.mitre_attack.mitre_attack_gcp import GCPMitreAttack
+from prowler.lib.outputs.compliance.okta_idaas_stig.okta_idaas_stig_okta import (
+ OktaIDaaSSTIG,
+)
from prowler.lib.outputs.compliance.prowler_threatscore.prowler_threatscore_alibaba import (
ProwlerThreatScoreAlibaba,
)
@@ -143,6 +140,7 @@ from prowler.providers.azure.models import AzureOutputOptions
from prowler.providers.cloudflare.models import CloudflareOutputOptions
from prowler.providers.common.provider import Provider
from prowler.providers.common.quick_inventory import run_provider_quick_inventory
+from prowler.providers.e2enetworks.models import E2eNetworksOutputOptions
from prowler.providers.gcp.models import GCPOutputOptions
from prowler.providers.github.models import GithubOutputOptions
from prowler.providers.googleworkspace.models import GoogleWorkspaceOutputOptions
@@ -150,6 +148,7 @@ from prowler.providers.iac.models import IACOutputOptions
from prowler.providers.image.exceptions.exceptions import ImageBaseException
from prowler.providers.image.models import ImageOutputOptions
from prowler.providers.kubernetes.models import KubernetesOutputOptions
+from prowler.providers.linode.models import LinodeOutputOptions
from prowler.providers.llm.models import LLMOutputOptions
from prowler.providers.m365.models import M365OutputOptions
from prowler.providers.mongodbatlas.models import MongoDBAtlasOutputOptions
@@ -158,6 +157,7 @@ from prowler.providers.okta.models import OktaOutputOptions
from prowler.providers.openstack.models import OpenStackOutputOptions
from prowler.providers.oraclecloud.models import OCIOutputOptions
from prowler.providers.scaleway.models import ScalewayOutputOptions
+from prowler.providers.stackit.models import StackITOutputOptions
from prowler.providers.vercel.models import VercelOutputOptions
@@ -204,14 +204,15 @@ def prowler():
if not args.no_banner:
legend = args.verbose or getattr(args, "fixer", None)
- print_banner(legend)
+ print_banner(legend, provider)
# We treat the compliance framework as another output format
if compliance_framework:
args.output_formats.extend(compliance_framework)
- # If no input compliance framework, set all, unless a specific service or check is input
- # Skip for IAC and LLM providers that don't use compliance frameworks
- elif default_execution and provider not in ["iac", "llm"]:
+ # If no input compliance framework, set all, unless a specific service or check is input.
+ # Skip for tool-wrapper providers (iac, llm, image, and any external plug-in
+ # declaring `is_external_tool_provider = True`) — they don't use compliance frameworks.
+ elif default_execution and not Provider.is_tool_wrapper_provider(provider):
args.output_formats.extend(get_available_compliance_frameworks(provider))
# Set Logger configuration
@@ -249,7 +250,7 @@ def prowler():
universal_frameworks = {}
# Skip compliance frameworks for external-tool providers
- if provider not in EXTERNAL_TOOL_PROVIDERS:
+ if not Provider.is_tool_wrapper_provider(provider):
bulk_compliance_frameworks = Compliance.get_bulk(provider)
# Complete checks metadata with the compliance framework specification
bulk_checks_metadata = update_checks_metadata_with_compliance(
@@ -317,7 +318,7 @@ def prowler():
sys.exit()
# Skip service and check loading for external-tool providers
- if provider not in EXTERNAL_TOOL_PROVIDERS:
+ if not Provider.is_tool_wrapper_provider(provider):
# Import custom checks from folder
if checks_folder:
custom_checks = parse_checks_from_folder(global_provider, checks_folder)
@@ -416,6 +417,10 @@ def prowler():
output_options = OCIOutputOptions(
args, bulk_checks_metadata, global_provider.identity
)
+ elif provider == "stackit":
+ output_options = StackITOutputOptions(
+ args, bulk_checks_metadata, global_provider.identity
+ )
elif provider == "alibabacloud":
output_options = AlibabaCloudOutputOptions(
args, bulk_checks_metadata, global_provider.identity
@@ -428,6 +433,10 @@ def prowler():
output_options = VercelOutputOptions(
args, bulk_checks_metadata, global_provider.identity
)
+ elif provider == "e2enetworks":
+ output_options = E2eNetworksOutputOptions(
+ args, bulk_checks_metadata, global_provider.identity
+ )
elif provider == "okta":
output_options = OktaOutputOptions(
args, bulk_checks_metadata, global_provider.identity
@@ -436,6 +445,24 @@ def prowler():
output_options = ScalewayOutputOptions(
args, bulk_checks_metadata, global_provider.identity
)
+ elif provider == "linode":
+ output_options = LinodeOutputOptions(
+ args, bulk_checks_metadata, global_provider.identity
+ )
+ else:
+ # Dynamic fallback: any external/custom provider
+ try:
+ output_options = global_provider.get_output_options(
+ args, bulk_checks_metadata
+ )
+ except NotImplementedError:
+ # No provider-specific OutputOptions: use the generic default so the
+ # run still produces output instead of aborting.
+ from prowler.providers.common.models import default_output_options
+
+ output_options = default_output_options(
+ global_provider, args, bulk_checks_metadata
+ )
# Run the quick inventory for the provider if available
if hasattr(args, "quick_inventory") and args.quick_inventory:
@@ -445,7 +472,7 @@ def prowler():
# Execute checks
findings = []
- if provider in EXTERNAL_TOOL_PROVIDERS:
+ if Provider.is_tool_wrapper_provider(provider):
# For external-tool providers, run the scan directly
if provider == "llm":
@@ -455,12 +482,19 @@ def prowler():
findings = global_provider.run_scan(streaming_callback=streaming_callback)
else:
- # Original behavior for IAC and Image
- try:
+ if provider == "image":
+ try:
+ findings = global_provider.run()
+ except ImageBaseException as error:
+ logger.critical(f"{error}")
+ sys.exit(1)
+ else:
+ # IAC and external tool-wrapper providers registered via entry
+ # points. Unexpected failures propagate to the outer except
+ # Exception backstop further down in this file — keeping the
+ # branch free of an Image-specific catch that would otherwise
+ # mislead plug-in authors reading this code.
findings = global_provider.run()
- except ImageBaseException as error:
- logger.critical(f"{error}")
- sys.exit(1)
# Note: External tool providers don't support granular progress tracking since
# they run external tools as a black box and return all findings at once.
# Progress tracking would just be 0% → 100%.
@@ -801,18 +835,6 @@ def prowler():
)
generated_outputs["compliance"].append(c5)
c5.batch_write_data_to_file()
- elif compliance_name == "csa_ccm_4.0_aws":
- filename = (
- f"{output_options.output_directory}/compliance/"
- f"{output_options.output_filename}_{compliance_name}.csv"
- )
- csa_ccm_4_0_aws = AWSCSA(
- findings=finding_outputs,
- compliance=bulk_compliance_frameworks[compliance_name],
- file_path=filename,
- )
- generated_outputs["compliance"].append(csa_ccm_4_0_aws)
- csa_ccm_4_0_aws.batch_write_data_to_file()
else:
filename = (
f"{output_options.output_directory}/compliance/"
@@ -916,18 +938,6 @@ def prowler():
)
generated_outputs["compliance"].append(c5_azure)
c5_azure.batch_write_data_to_file()
- elif compliance_name == "csa_ccm_4.0_azure":
- filename = (
- f"{output_options.output_directory}/compliance/"
- f"{output_options.output_filename}_{compliance_name}.csv"
- )
- csa_ccm_4_0_azure = AzureCSA(
- findings=finding_outputs,
- compliance=bulk_compliance_frameworks[compliance_name],
- file_path=filename,
- )
- generated_outputs["compliance"].append(csa_ccm_4_0_azure)
- csa_ccm_4_0_azure.batch_write_data_to_file()
else:
filename = (
f"{output_options.output_directory}/compliance/"
@@ -1031,18 +1041,6 @@ def prowler():
)
generated_outputs["compliance"].append(c5_gcp)
c5_gcp.batch_write_data_to_file()
- elif compliance_name == "csa_ccm_4.0_gcp":
- filename = (
- f"{output_options.output_directory}/compliance/"
- f"{output_options.output_filename}_{compliance_name}.csv"
- )
- csa_ccm_4_0_gcp = GCPCSA(
- findings=finding_outputs,
- compliance=bulk_compliance_frameworks[compliance_name],
- file_path=filename,
- )
- generated_outputs["compliance"].append(csa_ccm_4_0_gcp)
- csa_ccm_4_0_gcp.batch_write_data_to_file()
else:
filename = (
f"{output_options.output_directory}/compliance/"
@@ -1277,18 +1275,6 @@ def prowler():
)
generated_outputs["compliance"].append(cis)
cis.batch_write_data_to_file()
- elif compliance_name == "csa_ccm_4.0_oraclecloud":
- filename = (
- f"{output_options.output_directory}/compliance/"
- f"{output_options.output_filename}_{compliance_name}.csv"
- )
- csa_ccm_4_0_oraclecloud = OracleCloudCSA(
- findings=finding_outputs,
- compliance=bulk_compliance_frameworks[compliance_name],
- file_path=filename,
- )
- generated_outputs["compliance"].append(csa_ccm_4_0_oraclecloud)
- csa_ccm_4_0_oraclecloud.batch_write_data_to_file()
else:
filename = (
f"{output_options.output_directory}/compliance/"
@@ -1317,18 +1303,6 @@ def prowler():
)
generated_outputs["compliance"].append(cis)
cis.batch_write_data_to_file()
- elif compliance_name == "csa_ccm_4.0_alibabacloud":
- filename = (
- f"{output_options.output_directory}/compliance/"
- f"{output_options.output_filename}_{compliance_name}.csv"
- )
- csa_ccm_4_0_alibabacloud = AlibabaCloudCSA(
- findings=finding_outputs,
- compliance=bulk_compliance_frameworks[compliance_name],
- file_path=filename,
- )
- generated_outputs["compliance"].append(csa_ccm_4_0_alibabacloud)
- csa_ccm_4_0_alibabacloud.batch_write_data_to_file()
elif compliance_name == "prowler_threatscore_alibabacloud":
filename = (
f"{output_options.output_directory}/compliance/"
@@ -1353,6 +1327,57 @@ def prowler():
)
generated_outputs["compliance"].append(generic_compliance)
generic_compliance.batch_write_data_to_file()
+ elif provider == "okta":
+ for compliance_name in input_compliance_frameworks:
+ if compliance_name.startswith("okta_idaas_stig"):
+ # Generate Okta IDaaS STIG Finding Object
+ filename = (
+ f"{output_options.output_directory}/compliance/"
+ f"{output_options.output_filename}_{compliance_name}.csv"
+ )
+ okta_idaas_stig = OktaIDaaSSTIG(
+ findings=finding_outputs,
+ compliance=bulk_compliance_frameworks[compliance_name],
+ file_path=filename,
+ )
+ generated_outputs["compliance"].append(okta_idaas_stig)
+ okta_idaas_stig.batch_write_data_to_file()
+ else:
+ filename = (
+ f"{output_options.output_directory}/compliance/"
+ f"{output_options.output_filename}_{compliance_name}.csv"
+ )
+ generic_compliance = GenericCompliance(
+ findings=finding_outputs,
+ compliance=bulk_compliance_frameworks[compliance_name],
+ file_path=filename,
+ )
+ generated_outputs["compliance"].append(generic_compliance)
+ generic_compliance.batch_write_data_to_file()
+ else:
+ # Dynamic fallback: any external/custom provider
+ try:
+ global_provider.generate_compliance_output(
+ finding_outputs,
+ bulk_compliance_frameworks,
+ input_compliance_frameworks,
+ output_options,
+ generated_outputs,
+ )
+ except NotImplementedError:
+ # Last resort: generic compliance
+ for compliance_name in input_compliance_frameworks:
+ filename = (
+ f"{output_options.output_directory}/compliance/"
+ f"{output_options.output_filename}_{compliance_name}.csv"
+ )
+ generic_compliance = GenericCompliance(
+ findings=finding_outputs,
+ compliance=bulk_compliance_frameworks[compliance_name],
+ file_path=filename,
+ )
+ generated_outputs["compliance"].append(generic_compliance)
+ generic_compliance.batch_write_data_to_file()
# AWS Security Hub Integration
if provider == "aws":
@@ -1461,6 +1486,10 @@ def prowler():
f"\nDetailed compliance results are in {Fore.YELLOW}{output_options.output_directory}/compliance/{Style.RESET_ALL}\n"
)
+ # Promote Prowler Cloud as the last thing the user sees after the results
+ if not args.no_banner and not args.only_logs:
+ print_prowler_cloud_banner(provider)
+
# If custom checks were passed, remove the modules
if checks_folder:
remove_custom_checks_module(checks_folder, provider)
diff --git a/tests/config/__init__.py b/prowler/changelog.d/.gitkeep
similarity index 100%
rename from tests/config/__init__.py
rename to prowler/changelog.d/.gitkeep
diff --git a/prowler/changelog.d/README.md b/prowler/changelog.d/README.md
new file mode 100644
index 0000000000..0853174f30
--- /dev/null
+++ b/prowler/changelog.d/README.md
@@ -0,0 +1,10 @@
+# Changelog fragments
+
+Each PR adds one small file here instead of editing `CHANGELOG.md` directly, so concurrent PRs never conflict.
+
+- Filename: `..md`, e.g. `my-new-check.added.md` (slug is free-form: letters, digits, `.`, `_`, `-`)
+- `` is one of: `added`, `changed`, `deprecated`, `removed`, `fixed`, `security`
+- Content: one line with the changelog entry text, without the PR link and without a trailing period (the PR link is attached automatically at release time)
+- A PR adds as many fragment files as entries it needs, freely mixing types (one file per entry); same-type entries just use different slugs
+
+Fragments are compiled into `CHANGELOG.md` when a release is prepared. Full conventions: `skills/prowler-changelog/SKILL.md`.
diff --git a/prowler/changelog.d/alibabacloud-security-group-policy-case.fixed.md b/prowler/changelog.d/alibabacloud-security-group-policy-case.fixed.md
new file mode 100644
index 0000000000..cef4ff076c
--- /dev/null
+++ b/prowler/changelog.d/alibabacloud-security-group-policy-case.fixed.md
@@ -0,0 +1 @@
+Alibaba Cloud SSH and RDP security group checks no longer produce false negatives when allowed rules use capitalized `Policy="Accept"` values
diff --git a/prowler/changelog.d/bucket-validation-syntaxwarning.fixed.md b/prowler/changelog.d/bucket-validation-syntaxwarning.fixed.md
new file mode 100644
index 0000000000..ceba09541c
--- /dev/null
+++ b/prowler/changelog.d/bucket-validation-syntaxwarning.fixed.md
@@ -0,0 +1 @@
+Fix invalid escape sequence `SyntaxWarning` raised on startup by the S3 bucket name validation regex
diff --git a/prowler/changelog.d/grouped-jira-dispatch.changed.md b/prowler/changelog.d/grouped-jira-dispatch.changed.md
new file mode 100644
index 0000000000..8dd2e43ab5
--- /dev/null
+++ b/prowler/changelog.d/grouped-jira-dispatch.changed.md
@@ -0,0 +1 @@
+Jira output rendering supports grouped Finding Group issues with caller-provided links and capped or uncapped finding copy
diff --git a/prowler/changelog.d/sagemaker-notebook-no-secrets.added.md b/prowler/changelog.d/sagemaker-notebook-no-secrets.added.md
new file mode 100644
index 0000000000..4789aa6aaf
--- /dev/null
+++ b/prowler/changelog.d/sagemaker-notebook-no-secrets.added.md
@@ -0,0 +1 @@
+`sagemaker_notebook_instance_no_secrets` check for AWS provider, scanning SageMaker notebook instance lifecycle configuration scripts (`OnCreate` and `OnStart`) for hardcoded secrets such as API keys, passwords, tokens, and connection strings
diff --git a/prowler/compliance/alibabacloud/cis_2.0_alibabacloud.json b/prowler/compliance/alibabacloud/cis_2.0_alibabacloud.json
index 7cda08efbb..9a05c1997f 100644
--- a/prowler/compliance/alibabacloud/cis_2.0_alibabacloud.json
+++ b/prowler/compliance/alibabacloud/cis_2.0_alibabacloud.json
@@ -109,6 +109,14 @@
],
"Checks": [
"ram_user_console_access_unused"
+ ],
+ "ConfigRequirements": [
+ {
+ "Check": "ram_user_console_access_unused",
+ "ConfigKey": "max_console_access_days",
+ "Operator": "lte",
+ "Value": 90
+ }
]
},
{
@@ -841,6 +849,14 @@
],
"Checks": [
"sls_logstore_retention_period"
+ ],
+ "ConfigRequirements": [
+ {
+ "Check": "sls_logstore_retention_period",
+ "ConfigKey": "min_log_retention_days",
+ "Operator": "gte",
+ "Value": 365
+ }
]
},
{
@@ -1353,6 +1369,14 @@
],
"Checks": [
"rds_instance_sql_audit_retention"
+ ],
+ "ConfigRequirements": [
+ {
+ "Check": "rds_instance_sql_audit_retention",
+ "ConfigKey": "min_rds_audit_retention_days",
+ "Operator": "gte",
+ "Value": 180
+ }
]
},
{
@@ -1551,6 +1575,14 @@
],
"Checks": [
"cs_kubernetes_cluster_check_recent"
+ ],
+ "ConfigRequirements": [
+ {
+ "Check": "cs_kubernetes_cluster_check_recent",
+ "ConfigKey": "max_cluster_check_days",
+ "Operator": "lte",
+ "Value": 7
+ }
]
},
{
diff --git a/prowler/compliance/alibabacloud/csa_ccm_4.0_alibabacloud.json b/prowler/compliance/alibabacloud/csa_ccm_4.0_alibabacloud.json
deleted file mode 100644
index 060b6e819e..0000000000
--- a/prowler/compliance/alibabacloud/csa_ccm_4.0_alibabacloud.json
+++ /dev/null
@@ -1,7305 +0,0 @@
-{
- "Framework": "CSA-CCM",
- "Name": "CSA Cloud Controls Matrix (CCM) v4.0.13",
- "Version": "4.0",
- "Provider": "alibabacloud",
- "Description": "The Cloud Security Alliance (CSA) Cloud Controls Matrix (CCM) is a cybersecurity control framework for cloud computing, composed of 197 control objectives structured in 17 domains covering all key aspects of cloud technology. The CCM can be used as a tool for the systematic assessment of a cloud implementation, and provides guidance on which security controls should be implemented by which actor within the cloud supply chain.",
- "Requirements": [
- {
- "Id": "A&A-02",
- "Description": "Conduct independent audit and assurance assessments according to relevant standards at least annually.",
- "Name": "Independent Assessments",
- "Attributes": [
- {
- "Section": "Audit & Assurance",
- "CCMLite": "Yes",
- "IaaS": "Shared",
- "PaaS": "Shared",
- "SaaS": "Shared",
- "ScopeApplicability": [
- {
- "ReferenceId": "AICPA TSC 2017",
- "Identifiers": [
- "CC4.1"
- ]
- },
- {
- "ReferenceId": "CCM v3.0.1",
- "Identifiers": [
- "AAC-02"
- ]
- },
- {
- "ReferenceId": "ENX ISA v6.0",
- "Identifiers": [
- "1.5.2",
- "5.2.6"
- ]
- },
- {
- "ReferenceId": "ISF SOGP 2022",
- "Identifiers": [
- "AS1.1",
- "AS2.1"
- ]
- },
- {
- "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019",
- "Identifiers": [
- "27001: A.18.2.1",
- "27002: 18.2.1"
- ]
- },
- {
- "ReferenceId": "ISO/IEC 27001:2022, 27002:2022",
- "Identifiers": [
- "27001: A.5.35",
- "27001: A.5.36"
- ]
- },
- {
- "ReferenceId": "NIST 800-53 rev 5",
- "Identifiers": [
- "CA-2",
- "CA-2(1)",
- "CA-2(2)",
- "CA-7",
- "CA-7(1)"
- ]
- },
- {
- "ReferenceId": "NIST CSF v2.0",
- "Identifiers": [
- "ID.IM-01"
- ]
- }
- ]
- }
- ],
- "Checks": [
- "securitycenter_advanced_or_enterprise_edition"
- ]
- },
- {
- "Id": "A&A-04",
- "Description": "Verify compliance with all relevant standards, regulations, legal/contractual, and statutory requirements applicable to the audit.",
- "Name": "Requirements Compliance",
- "Attributes": [
- {
- "Section": "Audit & Assurance",
- "CCMLite": "Yes",
- "IaaS": "Shared",
- "PaaS": "Shared",
- "SaaS": "Shared",
- "ScopeApplicability": [
- {
- "ReferenceId": "AICPA TSC 2017",
- "Identifiers": [
- "CC3.1"
- ]
- },
- {
- "ReferenceId": "CCM v3.0.1",
- "Identifiers": [
- "GRM-01",
- "GRM-03"
- ]
- },
- {
- "ReferenceId": "ENX ISA v6.0",
- "Identifiers": [
- "7.1.1"
- ]
- },
- {
- "ReferenceId": "ISF SOGP 2022",
- "Identifiers": [
- "AS1.1",
- "AS2.2"
- ]
- },
- {
- "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019",
- "Identifiers": [
- "27001: 9.3.2",
- "27001: A.18.2.2",
- "27002: 18.2.2",
- "27001: A.18.2.3",
- "27002: 18.2.3"
- ]
- },
- {
- "ReferenceId": "ISO/IEC 27001:2022, 27002:2022",
- "Identifiers": [
- "27001: 9.3.2",
- "27001: A.5.31",
- "27001: A.5.32",
- "27001: A.5.33",
- "27001: A.5.34",
- "27001: A.5.36"
- ]
- },
- {
- "ReferenceId": "NIST 800-53 rev 5",
- "Identifiers": [
- "CA-1"
- ]
- },
- {
- "ReferenceId": "NIST CSF v1.1",
- "Identifiers": [
- "ID.GV-3",
- "DE.DP-2"
- ]
- },
- {
- "ReferenceId": "NIST CSF v2.0",
- "Identifiers": [
- "ID.IM-01"
- ]
- }
- ]
- }
- ],
- "Checks": [
- "securitycenter_advanced_or_enterprise_edition"
- ]
- },
- {
- "Id": "AIS-04",
- "Description": "Define and implement a SDLC process for application design, development, deployment, and operation in accordance with security requirements defined by the organization.",
- "Name": "Secure Application Design and Development",
- "Attributes": [
- {
- "Section": "Application & Interface Security",
- "CCMLite": "Yes",
- "IaaS": "Shared",
- "PaaS": "Shared",
- "SaaS": "CSP-Owned",
- "ScopeApplicability": [
- {
- "ReferenceId": "AICPA TSC 2017",
- "Identifiers": [
- "CC6.8",
- "CC8.1"
- ]
- },
- {
- "ReferenceId": "CCM v3.0.1",
- "Identifiers": [
- "AIS-01",
- "AIS-03"
- ]
- },
- {
- "ReferenceId": "CIS v8.0",
- "Identifiers": [
- "16.1"
- ]
- },
- {
- "ReferenceId": "ENX ISA v6.0",
- "Identifiers": [
- "1.3.4",
- "5.3.1"
- ]
- },
- {
- "ReferenceId": "ISF SOGP 2022",
- "Identifiers": [
- "SD1.2"
- ]
- },
- {
- "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019",
- "Identifiers": [
- "27001: A.14.1.1",
- "27002: 14.1.1",
- "27017: 14.1.1",
- "27001: A.14.1.2",
- "27002: 14.1.2",
- "27017: 14.1.2",
- "27001: A.14.2.1",
- "27002: 14.2.1",
- "27017: 14.2.1"
- ]
- },
- {
- "ReferenceId": "ISO/IEC 27001:2022, 27002:2022",
- "Identifiers": [
- "27001: A.5.8",
- "27001: A.8.25",
- "27001: A.8.26",
- "27001: A.8.28"
- ]
- },
- {
- "ReferenceId": "NIST 800-53 rev 5",
- "Identifiers": [
- "PL-2",
- "PL-8",
- "PL-8(1)",
- "SA-3",
- "SA-3(1)",
- "SA-4",
- "SA-4(2)",
- "SA-4(3)",
- "SA-4(8)",
- "SA-4(9)",
- "SA-5",
- "SA-8",
- "SA-8(1)-(7)",
- "SA-8(9)-(13)",
- "SA-8(15)-(20)",
- "SA-8(22)",
- "SA-8(24)-(28)",
- "SA-8(30)-(33)",
- "SA-17",
- "SA-17(1)-(9)"
- ]
- },
- {
- "ReferenceId": "NIST CSF v1.1",
- "Identifiers": [
- "PR.DS-6",
- "PR.DS-7",
- "PR.IP-2"
- ]
- },
- {
- "ReferenceId": "NIST CSF v2.0",
- "Identifiers": [
- "ID.AM-08",
- "PR.IR-01",
- "PR.PS-01",
- "PR.PS-02",
- "PR.PS-06"
- ]
- },
- {
- "ReferenceId": "PCI DSS v3.2.1",
- "Identifiers": [
- "6.3"
- ]
- },
- {
- "ReferenceId": "PCI DSS v4.0",
- "Identifiers": [
- "6.2.1",
- "6.2.3",
- "6.5.2"
- ]
- }
- ]
- }
- ],
- "Checks": []
- },
- {
- "Id": "AIS-05",
- "Description": "Implement a testing strategy, including criteria for acceptance of new information systems, upgrades and new versions, which provides application security assurance and maintains compliance while enabling organizational speed of delivery goals. Automate when applicable and possible.",
- "Name": "Automated Application Security Testing",
- "Attributes": [
- {
- "Section": "Application & Interface Security",
- "CCMLite": "No",
- "IaaS": "Shared",
- "PaaS": "Shared",
- "SaaS": "Shared",
- "ScopeApplicability": [
- {
- "ReferenceId": "AICPA TSC 2017",
- "Identifiers": [
- "CC6.8",
- "CC8.1"
- ]
- },
- {
- "ReferenceId": "CCM v3.0.1",
- "Identifiers": [
- "AIS-01",
- "AIS-03"
- ]
- },
- {
- "ReferenceId": "CIS v8.0",
- "Identifiers": [
- "16.12",
- "16.13"
- ]
- },
- {
- "ReferenceId": "ISF SOGP 2022",
- "Identifiers": [
- "SD2.3",
- "SD2.5"
- ]
- },
- {
- "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019",
- "Identifiers": [
- "27001: A.14.2.8",
- "27001: A.14.2.9",
- "27001: A.12.1.2",
- "27002: 12.1.2",
- "27001: A.14.1.1",
- "27002: 14.1.1",
- "27001: A.14.2.2",
- "27002: 14.2.2"
- ]
- },
- {
- "ReferenceId": "ISO/IEC 27001:2022, 27002:2022",
- "Identifiers": [
- "27001: A.8.25",
- "27001: A.8.29",
- "27001: A.8.32",
- "27002: 8.25 (e)",
- "27002: 8.32 (d)"
- ]
- },
- {
- "ReferenceId": "NIST 800-53 rev 5",
- "Identifiers": [
- "SA-11",
- "SA-11(1)-(9)",
- "SI-6",
- "SI-6(2)",
- "SI-6(3)",
- "SI-10",
- "SI-10(1)-(6)"
- ]
- },
- {
- "ReferenceId": "NIST CSF v1.1",
- "Identifiers": [
- "PR.IP-2",
- "PR.PT-3",
- "PR.IP-12",
- "DE.CM-8"
- ]
- },
- {
- "ReferenceId": "NIST CSF v2.0",
- "Identifiers": [
- "ID.AM-08",
- "ID.RA-01",
- "PR.PS-01",
- "PR.PS-02"
- ]
- },
- {
- "ReferenceId": "PCI DSS v3.2.1",
- "Identifiers": [
- "A.3.2.2",
- "A.3.2.2.1",
- "6.6"
- ]
- },
- {
- "ReferenceId": "PCI DSS v4.0",
- "Identifiers": [
- "6.2.4",
- "6.4.1",
- "6.4.2",
- "6.5.1"
- ]
- }
- ]
- }
- ],
- "Checks": [
- "securitycenter_vulnerability_scan_enabled"
- ]
- },
- {
- "Id": "AIS-07",
- "Description": "Define and implement a process to remediate application security vulnerabilities, automating remediation when possible.",
- "Name": "Application Vulnerability Remediation",
- "Attributes": [
- {
- "Section": "Application & Interface Security",
- "CCMLite": "Yes",
- "IaaS": "Shared",
- "PaaS": "Shared",
- "SaaS": "Shared",
- "ScopeApplicability": [
- {
- "ReferenceId": "AICPA TSC 2017",
- "Identifiers": [
- "CC7.1",
- "CC7.4",
- "CC8.1"
- ]
- },
- {
- "ReferenceId": "CCM v3.0.1",
- "Identifiers": [
- "TVM-02"
- ]
- },
- {
- "ReferenceId": "CIS v8.0",
- "Identifiers": [
- "16.2",
- "16.6"
- ]
- },
- {
- "ReferenceId": "ISF SOGP 2022",
- "Identifiers": [
- "TM1.1"
- ]
- },
- {
- "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019",
- "Identifiers": [
- "27001: A.16.1.5",
- "27002: 16.1.5",
- "27017: 16.1.5",
- "27001: A.12.6.1",
- "27002: 12.6.1",
- "27017: 12.6.1",
- "27018: 12.6.1"
- ]
- },
- {
- "ReferenceId": "ISO/IEC 27001:2022, 27002:2022",
- "Identifiers": [
- "27001: A.5.26",
- "27001: A.8.8",
- "27002: 5.26 (j)"
- ]
- },
- {
- "ReferenceId": "NIST 800-53 rev 5",
- "Identifiers": [
- "SI-2",
- "SI-2(2)-(6)",
- "SA-11",
- "SA-11(2)",
- "SA-15",
- "SA-15(1)-(3)",
- "SA-15(5)-(8)",
- "SA-15(10)-(12)"
- ]
- },
- {
- "ReferenceId": "NIST CSF v1.1",
- "Identifiers": [
- "PR.IP-2",
- "PR.IP-12",
- "DE.CM-8",
- "RS.AN-5",
- "RS.MI-3",
- "PR.DS-6"
- ]
- },
- {
- "ReferenceId": "NIST CSF v2.0",
- "Identifiers": [
- "ID.AM-08",
- "ID.RA-01",
- "ID.RA-06",
- "ID.RA-08",
- "PR.PS-02",
- "PR.PS-06"
- ]
- },
- {
- "ReferenceId": "PCI DSS v3.2.1",
- "Identifiers": [
- "6.2",
- "6.5",
- "6.5.1-10"
- ]
- },
- {
- "ReferenceId": "PCI DSS v4.0",
- "Identifiers": [
- "6.3.1",
- "11.3.1",
- "11.3.1.1"
- ]
- }
- ]
- }
- ],
- "Checks": [
- "securitycenter_vulnerability_scan_enabled"
- ]
- },
- {
- "Id": "BCR-08",
- "Description": "Periodically backup data stored in the cloud. Ensure the confidentiality, integrity and availability of the backup, and verify data restoration from backup for resiliency.",
- "Name": "Backup",
- "Attributes": [
- {
- "Section": "Business Continuity Management and Operational Resilience",
- "CCMLite": "Yes",
- "IaaS": "Shared",
- "PaaS": "Shared",
- "SaaS": "Shared",
- "ScopeApplicability": [
- {
- "ReferenceId": "AICPA TSC 2017",
- "Identifiers": [
- "A1.2",
- "A1.3"
- ]
- },
- {
- "ReferenceId": "CCM v3.0.1",
- "Identifiers": [
- "BCR-11"
- ]
- },
- {
- "ReferenceId": "CIS v8.0",
- "Identifiers": [
- "11.1",
- "11.2",
- "11.3",
- "11.4",
- "11.5"
- ]
- },
- {
- "ReferenceId": "ENX ISA v6.0",
- "Identifiers": [
- "5.2.8",
- "5.2.9"
- ]
- },
- {
- "ReferenceId": "ISF SOGP 2022",
- "Identifiers": [
- "SY2.3"
- ]
- },
- {
- "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019",
- "Identifiers": [
- "27001: A.12.3",
- "27017: 12.3",
- "27018: 12.3.1"
- ]
- },
- {
- "ReferenceId": "ISO/IEC 27001:2022, 27002:2022",
- "Identifiers": [
- "27001: A.8.13",
- "27001: A.5.23",
- "27001: A.5.30",
- "27002: 8.13",
- "27002: 5.23 2nd (i)"
- ]
- },
- {
- "ReferenceId": "NIST 800-53 rev 5",
- "Identifiers": [
- "CP-4",
- "CP-4(4)",
- "CP-6",
- "CP-6(1)-(3)",
- "CP-9",
- "CP-9(1)",
- "CP-9(2)",
- "CP-10",
- "CP-10(2)",
- "CP-10(4)"
- ]
- },
- {
- "ReferenceId": "NIST CSF v1.1",
- "Identifiers": [
- "PR.IP-4",
- "PR.DS-1"
- ]
- },
- {
- "ReferenceId": "NIST CSF v2.0",
- "Identifiers": [
- "PR.DS-01",
- "PR.DS-11",
- "RC.RP-03"
- ]
- },
- {
- "ReferenceId": "PCI DSS v3.2.1",
- "Identifiers": [
- "9.5.1",
- "12.10.1"
- ]
- },
- {
- "ReferenceId": "PCI DSS v4.0",
- "Identifiers": [
- "12.10.1",
- "10.3.3"
- ]
- }
- ]
- }
- ],
- "Checks": []
- },
- {
- "Id": "BCR-09",
- "Description": "Establish, document, approve, communicate, apply, evaluate and maintain a disaster response plan to recover from natural and man-made disasters. Update the plan at least annually or upon significant changes.",
- "Name": "Disaster Response Plan",
- "Attributes": [
- {
- "Section": "Business Continuity Management and Operational Resilience",
- "CCMLite": "Yes",
- "IaaS": "Shared",
- "PaaS": "Shared",
- "SaaS": "Shared",
- "ScopeApplicability": [
- {
- "ReferenceId": "AICPA TSC 2017",
- "Identifiers": [
- "A1.2",
- "CC3.2"
- ]
- },
- {
- "ReferenceId": "ENX ISA v6.0",
- "Identifiers": [
- "5.2.8",
- "5.2.9",
- "1.6.1",
- "1.6.2",
- "1.6.3"
- ]
- },
- {
- "ReferenceId": "ISF SOGP 2022",
- "Identifiers": [
- "BC1.4",
- "BC2.1",
- "BC2.2"
- ]
- },
- {
- "ReferenceId": "ISO/IEC 27001:2022, 27002:2022",
- "Identifiers": [
- "27001: A.5.29",
- "27001: A.5.30",
- "27002: 5.29",
- "27002: 5.30"
- ]
- },
- {
- "ReferenceId": "NIST 800-53 rev 5",
- "Identifiers": [
- "CP-2(1)",
- "CP-2(2)",
- "CP-2(3)",
- "CP-2(5)",
- "CP-2(6)",
- "CP-2(7)",
- "CP-2(8)",
- "PE-13",
- "PE-13(1)",
- "PE-13(2)",
- "PE-13(4)"
- ]
- },
- {
- "ReferenceId": "NIST CSF v1.1",
- "Identifiers": [
- "PR.IP-9",
- "PR.IP-10",
- "RC.IM-1",
- "RC.IM-2"
- ]
- },
- {
- "ReferenceId": "NIST CSF v2.0",
- "Identifiers": [
- "ID.IM-04"
- ]
- }
- ]
- }
- ],
- "Checks": []
- },
- {
- "Id": "BCR-11",
- "Description": "Supplement business-critical equipment with redundant equipment independently located at a reasonable minimum distance in accordance with applicable industry standards.",
- "Name": "Equipment Redundancy",
- "Attributes": [
- {
- "Section": "Business Continuity Management and Operational Resilience",
- "CCMLite": "No",
- "IaaS": "CSP-Owned",
- "PaaS": "CSP-Owned",
- "SaaS": "CSP-Owned",
- "ScopeApplicability": [
- {
- "ReferenceId": "AICPA TSC 2017",
- "Identifiers": [
- "A1.2",
- "CC3.2"
- ]
- },
- {
- "ReferenceId": "CCM v3.0.1",
- "Identifiers": [
- "BCR-06"
- ]
- },
- {
- "ReferenceId": "ENX ISA v6.0",
- "Identifiers": [
- "5.2.8"
- ]
- },
- {
- "ReferenceId": "ISF SOGP 2022",
- "Identifiers": [
- "BC1.3"
- ]
- },
- {
- "ReferenceId": "ISO/IEC 27001:2022, 27002:2022",
- "Identifiers": [
- "27001: A.5.20",
- "27001: A.7.11",
- "27001: A.8.14",
- "27002: 5.20 (t)",
- "27002: 8.14 (c)"
- ]
- },
- {
- "ReferenceId": "NIST 800-53 rev 5",
- "Identifiers": [
- "CP-2",
- "CP-2(2)",
- "CP-4(3)",
- "CP-6",
- "CP-6(1)",
- "CP-7",
- "CP-8",
- "CP-8(1)-(3)",
- "CP-9",
- "CP-9(6)"
- ]
- },
- {
- "ReferenceId": "NIST CSF v1.1",
- "Identifiers": [
- "ID.BE-4",
- "ID.BE-5"
- ]
- },
- {
- "ReferenceId": "NIST CSF v2.0",
- "Identifiers": [
- "GV.OC-04",
- "GV.OC-05",
- "PR.IR-03"
- ]
- }
- ]
- }
- ],
- "Checks": []
- },
- {
- "Id": "CCC-04",
- "Description": "Restrict the unauthorized addition, removal, update, and management of organization assets.",
- "Name": "Unauthorized Change Protection",
- "Attributes": [
- {
- "Section": "Change Control and Configuration Management",
- "CCMLite": "Yes",
- "IaaS": "Shared",
- "PaaS": "Shared",
- "SaaS": "Shared",
- "ScopeApplicability": [
- {
- "ReferenceId": "AICPA TSC 2017",
- "Identifiers": [
- "CC8.1"
- ]
- },
- {
- "ReferenceId": "CCM v3.0.1",
- "Identifiers": [
- "CCC-04"
- ]
- },
- {
- "ReferenceId": "ENX ISA v6.0",
- "Identifiers": [
- "5.2.1",
- "1.3.4",
- "5.3.1"
- ]
- },
- {
- "ReferenceId": "ISF SOGP 2022",
- "Identifiers": [
- "SY2.4",
- "SM2.6"
- ]
- },
- {
- "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019",
- "Identifiers": [
- "27001: A.12.1.4",
- "27002: 12.1.4",
- "27001: A.12.4.2",
- "27002: 12.4.2",
- "27001: A.14.2.2",
- "27017: 14.2.2"
- ]
- },
- {
- "ReferenceId": "ISO/IEC 27001:2022, 27002:2022",
- "Identifiers": [
- "27001: A.8.3",
- "27001: A.8.4",
- "27001: A.8.15",
- "27001: A.8.31",
- "27001: A.8.32"
- ]
- },
- {
- "ReferenceId": "NIST 800-53 rev 5",
- "Identifiers": [
- "CA-7",
- "CA-7(4)",
- "CM-3",
- "CM-3(1)",
- "CM-3(5)",
- "CM-3(7)",
- "CM-3(8)",
- "CM-5",
- "CM-5(1)",
- "CM-5(4)",
- "CM-5(5)",
- "CM-6",
- "CM-6(1)",
- "CM-6(2)",
- "CM-7",
- "CM-7(1)",
- "CM-7(4)",
- "CM-7(5)",
- "CM-7(9)"
- ]
- },
- {
- "ReferenceId": "NIST CSF v1.1",
- "Identifiers": [
- "ID.AM-1",
- "ID.AM-2",
- "ID.AM-4",
- "PR.MA-1",
- "PR.MA-2",
- "PR.AC-1"
- ]
- },
- {
- "ReferenceId": "NIST CSF v2.0",
- "Identifiers": [
- "ID.AM-01",
- "ID.AM-02",
- "ID.AM-04",
- "ID.AM-08",
- "PR.PS-02",
- "PR.PS-03",
- "PR.PS-05",
- "PR.AA-05"
- ]
- },
- {
- "ReferenceId": "PCI DSS v3.2.1",
- "Identifiers": [
- "6.4.5.2"
- ]
- },
- {
- "ReferenceId": "PCI DSS v4.0",
- "Identifiers": [
- "6.5.1",
- "6.5.2"
- ]
- }
- ]
- }
- ],
- "Checks": [
- "actiontrail_multi_region_enabled"
- ]
- },
- {
- "Id": "CCC-07",
- "Description": "Implement detection measures with proactive notification in case of changes deviating from the established baseline.",
- "Name": "Detection of Baseline Deviation",
- "Attributes": [
- {
- "Section": "Change Control and Configuration Management",
- "CCMLite": "Yes",
- "IaaS": "Shared",
- "PaaS": "Shared",
- "SaaS": "Shared",
- "ScopeApplicability": [
- {
- "ReferenceId": "AICPA TSC 2017",
- "Identifiers": [
- "CC8.1"
- ]
- },
- {
- "ReferenceId": "CCM v3.0.1",
- "Identifiers": [
- "GRM-01"
- ]
- },
- {
- "ReferenceId": "ENX ISA v6.0",
- "Identifiers": [
- "1.5.1",
- "1.5.2"
- ]
- },
- {
- "ReferenceId": "ISF SOGP 2022",
- "Identifiers": [
- "SY2.4"
- ]
- },
- {
- "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019",
- "Identifiers": [
- "27001: A.14.2.2",
- "27001: A.14.2.4",
- "27001: A.12.4.1",
- "27002: 12.4.1 (g)",
- "27001: A.5.1.1",
- "27017: 5.1.1"
- ]
- },
- {
- "ReferenceId": "ISO/IEC 27001:2022, 27002:2022",
- "Identifiers": [
- "27001: A.8.9",
- "27001: A.8.15",
- "27002: 8.9"
- ]
- },
- {
- "ReferenceId": "NIST 800-53 rev 5",
- "Identifiers": [
- "CM-6",
- "CM-6(2)",
- "SI-2",
- "SI-2(2)-(6)"
- ]
- },
- {
- "ReferenceId": "NIST CSF v1.1",
- "Identifiers": [
- "PR.MA-1",
- "PR.IP-1",
- "DE.DP-4",
- "PR.IP-3"
- ]
- },
- {
- "ReferenceId": "NIST CSF v2.0",
- "Identifiers": [
- "PR.PS-01",
- "DE.CM-09",
- "DE.AE-06"
- ]
- },
- {
- "ReferenceId": "PCI DSS v3.2.1",
- "Identifiers": [
- "6.4.5.3",
- "6.4.5.4",
- "11.5",
- "11.5.1"
- ]
- },
- {
- "ReferenceId": "PCI DSS v4.0",
- "Identifiers": [
- "11.5.2",
- "11.6.1"
- ]
- }
- ]
- }
- ],
- "Checks": [
- "securitycenter_advanced_or_enterprise_edition",
- "sls_security_group_changes_alert_enabled",
- "sls_vpc_changes_alert_enabled",
- "sls_vpc_network_route_changes_alert_enabled",
- "sls_customer_created_cmk_changes_alert_enabled",
- "sls_cloud_firewall_changes_alert_enabled",
- "sls_management_console_authentication_failures_alert_enabled",
- "sls_rds_instance_configuration_changes_alert_enabled"
- ]
- },
- {
- "Id": "CEK-03",
- "Description": "Provide cryptographic protection to data at-rest and in-transit, using cryptographic libraries certified to approved standards.",
- "Name": "Data Encryption",
- "Attributes": [
- {
- "Section": "Cryptography, Encryption & Key Management",
- "CCMLite": "Yes",
- "IaaS": "Shared",
- "PaaS": "Shared",
- "SaaS": "Shared",
- "ScopeApplicability": [
- {
- "ReferenceId": "AICPA TSC 2017",
- "Identifiers": [
- "CC6.1",
- "CC6.7"
- ]
- },
- {
- "ReferenceId": "CCM v3.0.1",
- "Identifiers": [
- "EKM-03",
- "EKM-04"
- ]
- },
- {
- "ReferenceId": "CIS v8.0",
- "Identifiers": [
- "3.6",
- "3.1",
- "3.11",
- "11.3",
- "16.11"
- ]
- },
- {
- "ReferenceId": "ENX ISA v6.0",
- "Identifiers": [
- "5.1.1",
- "5.1.2"
- ]
- },
- {
- "ReferenceId": "ISF SOGP 2022",
- "Identifiers": [
- "TS2.1"
- ]
- },
- {
- "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019",
- "Identifiers": [
- "27001: A.18.1.1",
- "27001: A.18.1.2",
- "27001: A.18.1.3",
- "27001: A.18.1.4",
- "27001: A.18.1.5",
- "27001: A.10.1",
- "27002: 10.1",
- "27001: A.13.2.1",
- "27002: 13.2.1",
- "27001: A.18",
- "27002: 18",
- "27001: A.14.1.2",
- "27002: 14.1.2",
- "27001: A.14.1.3",
- "27002 14.1.3 c)",
- "27001 - A.10.1.1",
- "27017 - 10.1.1",
- "27001 - A.10.1.2",
- "27017 - 10.1.2"
- ]
- },
- {
- "ReferenceId": "ISO/IEC 27001:2022, 27002:2022",
- "Identifiers": [
- "27001: A.5.14",
- "27001: A.8.24",
- "27002: 8.24 Other Information (a)"
- ]
- },
- {
- "ReferenceId": "NIST 800-53 rev 5",
- "Identifiers": [
- "AC-19",
- "AC-19(5)",
- "SC-8",
- "SC-8(1)",
- "SC-8(3)",
- "SC-8(4)",
- "SC-12",
- "SC-12(2)",
- "SC-12(3)",
- "SC-28",
- "SC-28(1)-(3)",
- "SI-4",
- "SI-4(10)",
- "SI-7",
- "SI-7(6)"
- ]
- },
- {
- "ReferenceId": "NIST CSF v1.1",
- "Identifiers": [
- "PR.DS-1",
- "PR.DS-2"
- ]
- },
- {
- "ReferenceId": "NIST CSF v2.0",
- "Identifiers": [
- "PR.DS-01",
- "PR.DS-02"
- ]
- },
- {
- "ReferenceId": "PCI DSS v3.2.1",
- "Identifiers": [
- "Requirement 3",
- "2.2.3",
- "2.3",
- "3.4",
- "3.5.3",
- "4.1",
- "8.2.1",
- "PCI Glossary - Strong Cryptography"
- ]
- },
- {
- "ReferenceId": "PCI DSS v4.0",
- "Identifiers": [
- "2.2.7",
- "3.5.1",
- "4.2.1",
- "4.2.1.2",
- "4.2.2"
- ]
- }
- ]
- }
- ],
- "Checks": [
- "ecs_attached_disk_encrypted",
- "ecs_unattached_disk_encrypted",
- "rds_instance_tde_enabled",
- "rds_instance_ssl_enabled",
- "oss_bucket_secure_transport_enabled"
- ]
- },
- {
- "Id": "CEK-04",
- "Description": "Use encryption algorithms that are appropriate for data protection, considering the classification of data, associated risks, and usability of the encryption technology.",
- "Name": "Encryption Algorithm",
- "Attributes": [
- {
- "Section": "Cryptography, Encryption & Key Management",
- "CCMLite": "Yes",
- "IaaS": "Shared",
- "PaaS": "Shared",
- "SaaS": "Shared",
- "ScopeApplicability": [
- {
- "ReferenceId": "AICPA TSC 2017",
- "Identifiers": [
- "CC6.1",
- "CC6.7"
- ]
- },
- {
- "ReferenceId": "CCM v3.0.1",
- "Identifiers": [
- "EKM-04"
- ]
- },
- {
- "ReferenceId": "CIS v8.0",
- "Identifiers": [
- "16.11"
- ]
- },
- {
- "ReferenceId": "ENX ISA v6.0",
- "Identifiers": [
- "5.1.1",
- "5.1.2"
- ]
- },
- {
- "ReferenceId": "ISF SOGP 2022",
- "Identifiers": [
- "TS2.1"
- ]
- },
- {
- "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019",
- "Identifiers": [
- "27001: 6.1.2",
- "27001: 6.1.3",
- "27001: A.8.2",
- "27002: 8.2",
- "27001: A.8.3",
- "27001: A.10.1.1",
- "27002: 10.1.1 (b)",
- "27001: A.10.1.2",
- "27002: 10.1.2"
- ]
- },
- {
- "ReferenceId": "ISO/IEC 27001:2022, 27002:2022",
- "Identifiers": [
- "27001: 6.1.2",
- "27001: 6.1.3",
- "27001: A.8.24",
- "27001: A.5.12",
- "27001: A.5.13",
- "27002: 8.24 General (b)"
- ]
- },
- {
- "ReferenceId": "NIST 800-53 rev 5",
- "Identifiers": [
- "SC-12",
- "SC-12(2)",
- "SC-12(3)",
- "SC-28",
- "SC-28(1)"
- ]
- },
- {
- "ReferenceId": "NIST CSF v1.1",
- "Identifiers": [
- "PR.DS-1",
- "PR.DS-2",
- "ID.AM-5"
- ]
- },
- {
- "ReferenceId": "NIST CSF v2.0",
- "Identifiers": [
- "PR.DS-01",
- "PR.DS-02",
- "ID.AM-05"
- ]
- },
- {
- "ReferenceId": "PCI DSS v3.2.1",
- "Identifiers": [
- "A2",
- "Requirement 3",
- "2.3",
- "2.2.3",
- "3.4",
- "3.5.3",
- "4.1",
- "8.2.1",
- "PCI Glossary - Strong Cryptography"
- ]
- },
- {
- "ReferenceId": "PCI DSS v4.0",
- "Identifiers": [
- "2.2.7",
- "3.5.1",
- "4.2.1",
- "4.2.1.2",
- "4.2.2"
- ]
- }
- ]
- }
- ],
- "Checks": []
- },
- {
- "Id": "CEK-08",
- "Description": "CSPs must provide the capability for CSCs to manage their own data encryption keys.",
- "Name": "CSC Key Management Capability",
- "Attributes": [
- {
- "Section": "Cryptography, Encryption & Key Management",
- "CCMLite": "No",
- "IaaS": "Shared",
- "PaaS": "Shared",
- "SaaS": "Shared",
- "ScopeApplicability": [
- {
- "ReferenceId": "ISF SOGP 2022",
- "Identifiers": [
- "TS2.2",
- "SC2.1"
- ]
- },
- {
- "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019",
- "Identifiers": [
- "27001: A.10.1",
- "27017: 10.1",
- "27001: A.10.1.1",
- "27017: 10.1.1",
- "27001: A.10.1.2",
- "27017: 10.1.2"
- ]
- },
- {
- "ReferenceId": "ISO/IEC 27001:2022, 27002:2022",
- "Identifiers": [
- "27001: A.5.23",
- "27001: A.8.24"
- ]
- },
- {
- "ReferenceId": "NIST 800-53 rev 5",
- "Identifiers": [
- "CP-9",
- "CP-9(8)",
- "SA-9",
- "SA-9(6)",
- "SC-12",
- "SC-12(6)"
- ]
- },
- {
- "ReferenceId": "NIST CSF v1.1",
- "Identifiers": [
- "ID.SC-3",
- "ID.AM-6",
- "PR.AC-1"
- ]
- },
- {
- "ReferenceId": "NIST CSF v2.0",
- "Identifiers": [
- "GV.SC-05"
- ]
- }
- ]
- }
- ],
- "Checks": [
- "rds_instance_tde_key_custom"
- ]
- },
- {
- "Id": "CEK-10",
- "Description": "Generate Cryptographic keys using industry accepted cryptographic libraries specifying the algorithm strength and the random number generator used.",
- "Name": "Key Generation",
- "Attributes": [
- {
- "Section": "Cryptography, Encryption & Key Management",
- "CCMLite": "Yes",
- "IaaS": "Shared",
- "PaaS": "Shared",
- "SaaS": "Shared",
- "ScopeApplicability": [
- {
- "ReferenceId": "CCM v3.0.1",
- "Identifiers": [
- "EKM-04"
- ]
- },
- {
- "ReferenceId": "CIS v8.0",
- "Identifiers": [
- "16.11"
- ]
- },
- {
- "ReferenceId": "ENX ISA v6.0",
- "Identifiers": [
- "5.1.1"
- ]
- },
- {
- "ReferenceId": "ISF SOGP 2022",
- "Identifiers": [
- "TS2.2",
- "TS2.3",
- "SY1.2"
- ]
- },
- {
- "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019",
- "Identifiers": [
- "27001: A.10.1.1",
- "27002: 10.1.1 (e)",
- "27017: 10.1.1",
- "27001: A.10.1.2",
- "27002: 10.1.2",
- "27002: 10.1.2 (a)",
- "27017: 10.1.2"
- ]
- },
- {
- "ReferenceId": "ISO/IEC 27001:2022, 27002:2022",
- "Identifiers": [
- "27001: A.8.24",
- "27002: 8.24 (d), Key management (a)"
- ]
- },
- {
- "ReferenceId": "NIST 800-53 rev 5",
- "Identifiers": [
- "SC-12",
- "SC-12(2)",
- "SC-12(3)",
- "SC-13"
- ]
- },
- {
- "ReferenceId": "NIST CSF v1.1",
- "Identifiers": [
- "PR.AC-1"
- ]
- },
- {
- "ReferenceId": "NIST CSF v2.0",
- "Identifiers": [
- "PR.AA-01",
- "PR.AA-05"
- ]
- },
- {
- "ReferenceId": "PCI DSS v3.2.1",
- "Identifiers": [
- "2.2.3",
- "3.6.1",
- "PCI Glossary - Cryptographic Key Generation"
- ]
- },
- {
- "ReferenceId": "PCI DSS v4.0",
- "Identifiers": [
- "3.6.1",
- "3.6.1.1",
- "3.7.1"
- ]
- }
- ]
- }
- ],
- "Checks": [
- "rds_instance_tde_key_custom"
- ]
- },
- {
- "Id": "CEK-12",
- "Description": "Rotate cryptographic keys in accordance with the calculated cryptoperiod, which includes provisions for considering the risk of information disclosure and legal and regulatory requirements.",
- "Name": "Key Rotation",
- "Attributes": [
- {
- "Section": "Cryptography, Encryption & Key Management",
- "CCMLite": "Yes",
- "IaaS": "Shared",
- "PaaS": "Shared",
- "SaaS": "Shared",
- "ScopeApplicability": [
- {
- "ReferenceId": "ENX ISA v6.0",
- "Identifiers": [
- "5.1.1"
- ]
- },
- {
- "ReferenceId": "ISF SOGP 2022",
- "Identifiers": [
- "TS2.2"
- ]
- },
- {
- "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019",
- "Identifiers": [
- "27001: A.10.1.1",
- "27017: 10.1.1",
- "27001: A.10.1.2",
- "27002: 10.1.2 e)",
- "27017: 10.1.2"
- ]
- },
- {
- "ReferenceId": "ISO/IEC 27001:2022, 27002:2022",
- "Identifiers": [
- "27001: A.5.31",
- "27001: A.8.24",
- "27002: 5.31 Cryptography",
- "27002: 8.24 Key management (e,m)"
- ]
- },
- {
- "ReferenceId": "NIST 800-53 rev 5",
- "Identifiers": [
- "SC-12",
- "SC-12(2)",
- "SC-12(3)",
- "SC-13"
- ]
- },
- {
- "ReferenceId": "NIST CSF v1.1",
- "Identifiers": [
- "PR.AC-1",
- "ID.GV-3"
- ]
- },
- {
- "ReferenceId": "NIST CSF v2.0",
- "Identifiers": [
- "PR.AA-01",
- "PR.AA-05",
- "GV.OC-03"
- ]
- },
- {
- "ReferenceId": "PCI DSS v4.0",
- "Identifiers": [
- "3.7.4",
- "3.7.5"
- ]
- }
- ]
- }
- ],
- "Checks": [
- "ram_rotate_access_key_90_days"
- ]
- },
- {
- "Id": "CEK-14",
- "Description": "Define, implement and evaluate processes, procedures and technical measures to destroy keys stored outside a secure environment and revoke keys stored in Hardware Security Modules (HSMs) when they are no longer needed, which include provisions for legal and regulatory requirements.",
- "Name": "Key Destruction",
- "Attributes": [
- {
- "Section": "Cryptography, Encryption & Key Management",
- "CCMLite": "Yes",
- "IaaS": "Shared",
- "PaaS": "Shared",
- "SaaS": "Shared",
- "ScopeApplicability": [
- {
- "ReferenceId": "ENX ISA v6.0",
- "Identifiers": [
- "5.1.1"
- ]
- },
- {
- "ReferenceId": "ISF SOGP 2022",
- "Identifiers": [
- "TS2.2"
- ]
- },
- {
- "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019",
- "Identifiers": [
- "27001: A.10.1.1",
- "27017: 10.1.1",
- "27017: 10.1.2",
- "27001: A.10.1.2",
- "27002: 10.1.2 (j)",
- "27001: A.18.1.3",
- "27002: 18.1.3"
- ]
- },
- {
- "ReferenceId": "ISO/IEC 27001:2022, 27002:2022",
- "Identifiers": [
- "27001: A.5.31",
- "27001: A.8.24",
- "27002: 5.31 Cryptography",
- "27002: 8.24 Key management (j,m)"
- ]
- },
- {
- "ReferenceId": "NIST 800-53 rev 5",
- "Identifiers": [
- "SC-12",
- "SC-12(2)",
- "SC-12(3)"
- ]
- },
- {
- "ReferenceId": "NIST CSF v1.1",
- "Identifiers": [
- "PR.AC-1",
- "PR.IP-6",
- "ID.GV-3",
- "PR.DS-3"
- ]
- },
- {
- "ReferenceId": "NIST CSF v2.0",
- "Identifiers": [
- "PR.AA-01",
- "PR.AA-05",
- "ID.AM-08",
- "GV.OC-03"
- ]
- },
- {
- "ReferenceId": "PCI DSS v3.2.1",
- "Identifiers": [
- "3.6.4",
- "3.6.5"
- ]
- },
- {
- "ReferenceId": "PCI DSS v4.0",
- "Identifiers": [
- "3.7.4",
- "3.7.5"
- ]
- }
- ]
- }
- ],
- "Checks": []
- },
- {
- "Id": "DCS-06",
- "Description": "Catalogue and track all relevant physical and logical assets located at all of the CSP's sites within a secured system.",
- "Name": "Assets Cataloguing and Tracking",
- "Attributes": [
- {
- "Section": "Datacenter Security",
- "CCMLite": "Yes",
- "IaaS": "Shared",
- "PaaS": "Shared",
- "SaaS": "Shared",
- "ScopeApplicability": [
- {
- "ReferenceId": "AICPA TSC 2017",
- "Identifiers": [
- "CC6.1"
- ]
- },
- {
- "ReferenceId": "CCM v3.0.1",
- "Identifiers": [
- "DCS - 01"
- ]
- },
- {
- "ReferenceId": "CIS v8.0",
- "Identifiers": [
- "1.1",
- "2.1"
- ]
- },
- {
- "ReferenceId": "ENX ISA v6.0",
- "Identifiers": [
- "1.3.1"
- ]
- },
- {
- "ReferenceId": "ISF SOGP 2022",
- "Identifiers": [
- "SM2.6"
- ]
- },
- {
- "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019",
- "Identifiers": [
- "27001: A.8.1.1",
- "27002: 8.1.1",
- "27017: 8.1.1"
- ]
- },
- {
- "ReferenceId": "ISO/IEC 27001:2022, 27002:2022",
- "Identifiers": [
- "27001: A.5.9"
- ]
- },
- {
- "ReferenceId": "NIST 800-53 rev 5",
- "Identifiers": [
- "CM-8",
- "CM-8(1)",
- "CM-8(2)",
- "CM-8(4)",
- "CM-8(7)",
- "CM-8(8)"
- ]
- },
- {
- "ReferenceId": "NIST CSF v1.1",
- "Identifiers": [
- "ID.AM-1",
- "ID.AM-2",
- "ID.AM-4",
- "PR.AC-4"
- ]
- },
- {
- "ReferenceId": "NIST CSF v2.0",
- "Identifiers": [
- "ID.AM-01",
- "ID.AM-02",
- "ID.AM-04"
- ]
- },
- {
- "ReferenceId": "PCI DSS v3.2.1",
- "Identifiers": [
- "2.4",
- "9.7.1",
- "9.9.1",
- "9.9.1.a",
- "9.9.1.b",
- "9.9.1.c",
- "12.3.3",
- "12.3.4"
- ]
- },
- {
- "ReferenceId": "PCI DSS v4.0",
- "Identifiers": [
- "3.6.1.1",
- "6.3.2",
- "9.4.2",
- "9.4.3",
- "12.5.1"
- ]
- }
- ]
- }
- ],
- "Checks": [
- "securitycenter_all_assets_agent_installed"
- ]
- },
- {
- "Id": "DSP-02",
- "Description": "Apply industry accepted methods for the secure disposal of data from storage media such that data is not recoverable by any forensic means.",
- "Name": "Secure Disposal",
- "Attributes": [
- {
- "Section": "Data Security and Privacy Lifecycle Management",
- "CCMLite": "No",
- "IaaS": "Shared",
- "PaaS": "Shared",
- "SaaS": "Shared",
- "ScopeApplicability": [
- {
- "ReferenceId": "AICPA TSC 2017",
- "Identifiers": [
- "CC6.1",
- "CC6.2",
- "CC6.3",
- "CC6.4",
- "CC6.5",
- "CC6.7",
- "P4.3"
- ]
- },
- {
- "ReferenceId": "CCM v3.0.1",
- "Identifiers": [
- "DSI-07"
- ]
- },
- {
- "ReferenceId": "CIS v8.0",
- "Identifiers": [
- "3.5"
- ]
- },
- {
- "ReferenceId": "ENX ISA v6.0",
- "Identifiers": [
- "5.1.1",
- "5.3.3",
- "7.1.1"
- ]
- },
- {
- "ReferenceId": "ISF SOGP 2022",
- "Identifiers": [
- "IM1.1",
- "IM1.3"
- ]
- },
- {
- "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019",
- "Identifiers": [
- "27001: A.8.3.2",
- "27002: 8.3.2",
- "27001: A.11.2.7",
- "27002: 11.2.7"
- ]
- },
- {
- "ReferenceId": "ISO/IEC 27001:2022, 27002:2022",
- "Identifiers": [
- "27001: A.7.10",
- "27001: A.7.14",
- "27001: A.8.10",
- "27002: 7.10 (Secure reuse or disposal)"
- ]
- },
- {
- "ReferenceId": "NIST 800-53 rev 5",
- "Identifiers": [
- "PM-22",
- "SI-12",
- "SI-12(3)",
- "SI-18",
- "SI-18(1)",
- "SI-18(4)",
- "SI-18(5)"
- ]
- },
- {
- "ReferenceId": "NIST CSF v1.1",
- "Identifiers": [
- "PR.IP-6"
- ]
- },
- {
- "ReferenceId": "NIST CSF v2.0",
- "Identifiers": [
- "GV.SC-10",
- "PR.PS-02",
- "PR.PS-03",
- "ID.AM-08"
- ]
- },
- {
- "ReferenceId": "PCI DSS v3.2.1",
- "Identifiers": [
- "3.1",
- "9.8",
- "9.8.1",
- "9.8.2"
- ]
- },
- {
- "ReferenceId": "PCI DSS v4.0",
- "Identifiers": [
- "3.2.1",
- "3.7.5",
- "9.4.7"
- ]
- }
- ]
- }
- ],
- "Checks": []
- },
- {
- "Id": "DSP-03",
- "Description": "Create and maintain a data inventory, at least for any sensitive data and personal data.",
- "Name": "Data Inventory",
- "Attributes": [
- {
- "Section": "Data Security and Privacy Lifecycle Management",
- "CCMLite": "Yes",
- "IaaS": "Shared",
- "PaaS": "Shared",
- "SaaS": "Shared",
- "ScopeApplicability": [
- {
- "ReferenceId": "AICPA TSC 2017",
- "Identifiers": [
- "CC6.1"
- ]
- },
- {
- "ReferenceId": "CIS v8.0",
- "Identifiers": [
- "3.2"
- ]
- },
- {
- "ReferenceId": "ENX ISA v6.0",
- "Identifiers": [
- "1.3.1",
- "1.3.2",
- "1.3.3"
- ]
- },
- {
- "ReferenceId": "ISF SOGP 2022",
- "Identifiers": [
- "IM1.1",
- "IM2.1"
- ]
- },
- {
- "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019",
- "Identifiers": [
- "27001: A.8.1.1",
- "27002: 8.1.1"
- ]
- },
- {
- "ReferenceId": "ISO/IEC 27001:2022, 27002:2022",
- "Identifiers": [
- "27001: A.5.9",
- "27001: A.8.12"
- ]
- },
- {
- "ReferenceId": "NIST 800-53 rev 5",
- "Identifiers": [
- "CM-12",
- "CM-12(1)",
- "PM-5",
- "PM-5(1)",
- "SI-12",
- "SI-12(1)",
- "SI-19",
- "SI-19(1)",
- "SI-19(2)"
- ]
- },
- {
- "ReferenceId": "NIST CSF v1.1",
- "Identifiers": [
- "ID.AM-5"
- ]
- },
- {
- "ReferenceId": "NIST CSF v2.0",
- "Identifiers": [
- "ID.AM-07"
- ]
- },
- {
- "ReferenceId": "PCI DSS v4.0",
- "Identifiers": [
- "3.2.1",
- "9.4.5"
- ]
- }
- ]
- }
- ],
- "Checks": [
- "securitycenter_all_assets_agent_installed"
- ]
- },
- {
- "Id": "DSP-04",
- "Description": "Classify data according to its type and sensitivity level.",
- "Name": "Data Classification",
- "Attributes": [
- {
- "Section": "Data Security and Privacy Lifecycle Management",
- "CCMLite": "Yes",
- "IaaS": "Shared",
- "PaaS": "Shared",
- "SaaS": "Shared",
- "ScopeApplicability": [
- {
- "ReferenceId": "AICPA TSC 2017",
- "Identifiers": [
- "CC6.1",
- "C1.1"
- ]
- },
- {
- "ReferenceId": "CCM v3.0.1",
- "Identifiers": [
- "DSI-01"
- ]
- },
- {
- "ReferenceId": "CIS v8.0",
- "Identifiers": [
- "3.7"
- ]
- },
- {
- "ReferenceId": "ENX ISA v6.0",
- "Identifiers": [
- "1.3.1",
- "1.3.2"
- ]
- },
- {
- "ReferenceId": "ISF SOGP 2022",
- "Identifiers": [
- "IM1.2"
- ]
- },
- {
- "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019",
- "Identifiers": [
- "27001: A.8.2.1",
- "27002: 8.2.1"
- ]
- },
- {
- "ReferenceId": "ISO/IEC 27001:2022, 27002:2022",
- "Identifiers": [
- "27001: A.5.12"
- ]
- },
- {
- "ReferenceId": "NIST 800-53 rev 5",
- "Identifiers": [
- "AC-16",
- "AC-16(9)",
- "PM-22",
- "PM-23",
- "PT-2",
- "PT-2(1)",
- "SI-18",
- "SI-18(2)",
- "SI-19",
- "SI-19(6)"
- ]
- },
- {
- "ReferenceId": "NIST CSF v1.1",
- "Identifiers": [
- "ID.AM-5"
- ]
- },
- {
- "ReferenceId": "NIST CSF v2.0",
- "Identifiers": [
- "ID.AM-05",
- "ID.AM-07"
- ]
- },
- {
- "ReferenceId": "PCI DSS v3.2.1",
- "Identifiers": [
- "9.6.1"
- ]
- },
- {
- "ReferenceId": "PCI DSS v4.0",
- "Identifiers": [
- "9.4.2",
- "9.4.3"
- ]
- }
- ]
- }
- ],
- "Checks": []
- },
- {
- "Id": "DSP-07",
- "Description": "Develop systems, products, and business practices based upon a principle of security by design and industry best practices.",
- "Name": "Data Protection by Design and Default",
- "Attributes": [
- {
- "Section": "Data Security and Privacy Lifecycle Management",
- "CCMLite": "Yes",
- "IaaS": "Shared",
- "PaaS": "Shared",
- "SaaS": "Shared",
- "ScopeApplicability": [
- {
- "ReferenceId": "AICPA TSC 2017",
- "Identifiers": [
- "PI1.2",
- "PI1.3"
- ]
- },
- {
- "ReferenceId": "CIS v8.0",
- "Identifiers": [
- "16.1"
- ]
- },
- {
- "ReferenceId": "ENX ISA v6.0",
- "Identifiers": [
- "5.3.1",
- "5.3.2",
- "5.3.3",
- "5.3.4"
- ]
- },
- {
- "ReferenceId": "ISF SOGP 2022",
- "Identifiers": [
- "SD2.2",
- "IM1.1"
- ]
- },
- {
- "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019",
- "Identifiers": [
- "27001: A.14.1.1",
- "27002:14.1.1",
- "27001: A.14.2.5",
- "27002:14.2.5"
- ]
- },
- {
- "ReferenceId": "ISO/IEC 27001:2022, 27002:2022",
- "Identifiers": [
- "27001: A.8.27",
- "27001: A.8.28",
- "27001: A.8.29",
- "27002: 5.8 (Information security requirements a-i)"
- ]
- },
- {
- "ReferenceId": "NIST 800-53 rev 5",
- "Identifiers": [
- "PM-17",
- "PM-24",
- "PM-25",
- "PT-2",
- "PT-2(2)",
- "SA-3",
- "SA-4",
- "SA-5",
- "SA-8",
- "SA-8(9)",
- "SA-8(13)",
- "SA-8(18)",
- "SA-8(20)",
- "SA-8(22)",
- "SA-8(23)",
- "SA-8(33)",
- "SA-15",
- "SA-15(12)",
- "SC-3",
- "SC-3(3)",
- "SC-7",
- "SC-7(24)",
- "SC-8",
- "SC-8(1)-(4)",
- "SC-28",
- "SC-28(1)",
- "SI-12",
- "SI-12(1)-(3)"
- ]
- },
- {
- "ReferenceId": "NIST CSF v1.1",
- "Identifiers": [
- "PR.IP-2",
- "PR.PT-3",
- "PR.AC-4"
- ]
- },
- {
- "ReferenceId": "NIST CSF v2.0",
- "Identifiers": [
- "ID.AM-08",
- "PR.PS-06"
- ]
- },
- {
- "ReferenceId": "PCI DSS v4.0",
- "Identifiers": [
- "6.2.1"
- ]
- }
- ]
- }
- ],
- "Checks": [
- "oss_bucket_not_publicly_accessible",
- "rds_instance_no_public_access_whitelist"
- ]
- },
- {
- "Id": "DSP-10",
- "Description": "Define, implement and evaluate processes, procedures and technical measures that ensure any transfer of personal or sensitive data is protected from unauthorized access and only processed within scope as permitted by the respective laws and regulations.",
- "Name": "Sensitive Data Transfer",
- "Attributes": [
- {
- "Section": "Data Security and Privacy Lifecycle Management",
- "CCMLite": "Yes",
- "IaaS": "Shared",
- "PaaS": "Shared",
- "SaaS": "Shared",
- "ScopeApplicability": [
- {
- "ReferenceId": "AICPA TSC 2017",
- "Identifiers": [
- "CC6.7"
- ]
- },
- {
- "ReferenceId": "CCM v3.0.1",
- "Identifiers": [
- "GRM-02",
- "EKM-03"
- ]
- },
- {
- "ReferenceId": "CIS v8.0",
- "Identifiers": [
- "3.1",
- "3.12",
- "3.13"
- ]
- },
- {
- "ReferenceId": "ENX ISA v6.0",
- "Identifiers": [
- "5.1.2",
- "9.5.1",
- "9.5.2",
- "9.5.3"
- ]
- },
- {
- "ReferenceId": "ISF SOGP 2022",
- "Identifiers": [
- "IM1.4",
- "IM2.2"
- ]
- },
- {
- "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019",
- "Identifiers": [
- "27001: A.13.2.1",
- "27002: 13.2.1",
- "27001: A.8.3.3",
- "27002: 8.3.3",
- "27001: A.13.2.3",
- "27002: 13.2.3"
- ]
- },
- {
- "ReferenceId": "ISO/IEC 27001:2022, 27002:2022",
- "Identifiers": [
- "27001: A.5.14",
- "27001: A.7.10"
- ]
- },
- {
- "ReferenceId": "NIST 800-53 rev 5",
- "Identifiers": [
- "AC-4",
- "AC-4(23)-(25)",
- "CA-3",
- "CA-3(6)",
- "CA-6",
- "CA-6(1)",
- "CA-6(2)",
- "SC-4",
- "SC-4(2)",
- "SC-7",
- "SC-7(10)",
- "SC-7(24)",
- "SC-8",
- "SC-8(1)-(5)",
- "SC-16",
- "SC-16(1)-(3)"
- ]
- },
- {
- "ReferenceId": "NIST CSF v1.1",
- "Identifiers": [
- "PR.DS-2",
- "PR.DS-5",
- "PR.PT-4"
- ]
- },
- {
- "ReferenceId": "NIST CSF v2.0",
- "Identifiers": [
- "PR.DS-02",
- "PR.IR-01",
- "ID.AM-03",
- "GV.OC-03",
- "ID.AM-07"
- ]
- },
- {
- "ReferenceId": "PCI DSS v3.2.1",
- "Identifiers": [
- "4.1"
- ]
- },
- {
- "ReferenceId": "PCI DSS v4.0",
- "Identifiers": [
- "4.1.1",
- "4.2.1",
- "4.2.2"
- ]
- }
- ]
- }
- ],
- "Checks": [
- "oss_bucket_secure_transport_enabled",
- "rds_instance_ssl_enabled"
- ]
- },
- {
- "Id": "DSP-16",
- "Description": "Data retention, archiving and deletion is managed in accordance with business requirements, applicable laws and regulations.",
- "Name": "Data Retention and Deletion",
- "Attributes": [
- {
- "Section": "Data Security and Privacy Lifecycle Management",
- "CCMLite": "Yes",
- "IaaS": "Shared",
- "PaaS": "Shared",
- "SaaS": "Shared",
- "ScopeApplicability": [
- {
- "ReferenceId": "AICPA TSC 2017",
- "Identifiers": [
- "C1.1",
- "C1.2",
- "CC3.1",
- "P4.2"
- ]
- },
- {
- "ReferenceId": "CCM v3.0.1",
- "Identifiers": [
- "GRM-02",
- "BCR-11"
- ]
- },
- {
- "ReferenceId": "CIS v8.0",
- "Identifiers": [
- "3.4",
- "3.5"
- ]
- },
- {
- "ReferenceId": "ENX ISA v6.0",
- "Identifiers": [
- "5.1.1",
- "5.3.1",
- "7.1.2"
- ]
- },
- {
- "ReferenceId": "ISF SOGP 2022",
- "Identifiers": [
- "IM1.1",
- "IM2.1"
- ]
- },
- {
- "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019",
- "Identifiers": [
- "27001: A.18.1.3"
- ]
- },
- {
- "ReferenceId": "ISO/IEC 27001:2022, 27002:2022",
- "Identifiers": [
- "27001: A.5.33",
- "27001: A.8.10",
- "27002: 5.33 (b)"
- ]
- },
- {
- "ReferenceId": "NIST 800-53 rev 5",
- "Identifiers": [
- "SI-12",
- "SI-12(1)-(3)",
- "SI-18",
- "SI-18(1)",
- "SI-18(4)",
- "SI-18(5)",
- "SI-19",
- "SI-19(2)"
- ]
- },
- {
- "ReferenceId": "NIST CSF v1.1",
- "Identifiers": [
- "PR.DS-3",
- "PR.IP-6",
- "ID.GV-3"
- ]
- },
- {
- "ReferenceId": "NIST CSF v2.0",
- "Identifiers": [
- "ID.AM-08",
- "GV.OC-03",
- "GV.SC-10",
- "PR.DS-11"
- ]
- },
- {
- "ReferenceId": "PCI DSS v3.2.1",
- "Identifiers": [
- "3.1"
- ]
- },
- {
- "ReferenceId": "PCI DSS v4.0",
- "Identifiers": [
- "3.2.1"
- ]
- }
- ]
- }
- ],
- "Checks": [
- "sls_logstore_retention_period",
- "rds_instance_sql_audit_retention"
- ]
- },
- {
- "Id": "DSP-17",
- "Description": "Define and implement, processes, procedures and technical measures to protect sensitive data throughout it's lifecycle.",
- "Name": "Sensitive Data Protection",
- "Attributes": [
- {
- "Section": "Data Security and Privacy Lifecycle Management",
- "CCMLite": "Yes",
- "IaaS": "CSP-Owned",
- "PaaS": "CSP-Owned",
- "SaaS": "CSC-Owned",
- "ScopeApplicability": [
- {
- "ReferenceId": "AICPA TSC 2017",
- "Identifiers": [
- "CC2.1",
- "CC6.1",
- "CC6.3",
- "CC6.7",
- "CC8.1",
- "C1.1",
- "P2.0",
- "P3.0",
- "P4.0",
- "P5.0",
- "P6.0"
- ]
- },
- {
- "ReferenceId": "CIS v8.0",
- "Identifiers": [
- "3.1",
- "3.1",
- "3.14"
- ]
- },
- {
- "ReferenceId": "ENX ISA v6.0",
- "Identifiers": [
- "5.3.3",
- "9.1.1",
- "9.2.2"
- ]
- },
- {
- "ReferenceId": "ISF SOGP 2022",
- "Identifiers": [
- "IM1.1",
- "IM2.2"
- ]
- },
- {
- "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019",
- "Identifiers": [
- "27001: A.18.1.3",
- "27002: 18.1.3",
- "27001:A.18.1.4",
- "27002:18.1.4"
- ]
- },
- {
- "ReferenceId": "ISO/IEC 27001:2022, 27002:2022",
- "Identifiers": [
- "27001: A.8.11",
- "27001: A.8.12"
- ]
- },
- {
- "ReferenceId": "NIST 800-53 rev 5",
- "Identifiers": [
- "PL-2",
- "PM-22",
- "PM-24",
- "PT-7",
- "PT-7(1)",
- "PT-7(2)",
- "PT-8",
- "SC-8",
- "SC-8(1)-(5)",
- "SC-28",
- "SC-28(1)"
- ]
- },
- {
- "ReferenceId": "NIST CSF v1.1",
- "Identifiers": [
- "PR.DS-1",
- "PR.DS-2",
- "PR.DS-5"
- ]
- },
- {
- "ReferenceId": "NIST CSF v2.0",
- "Identifiers": [
- "PR.DS-01",
- "PR.DS-02",
- "PR.DS-10"
- ]
- },
- {
- "ReferenceId": "PCI DSS v3.2.1",
- "Identifiers": [
- "3.0 (including all subsections)",
- "4.0 (including all subsections)"
- ]
- },
- {
- "ReferenceId": "PCI DSS v4.0",
- "Identifiers": [
- "3.1.1",
- "4.1.1"
- ]
- }
- ]
- }
- ],
- "Checks": [
- "oss_bucket_not_publicly_accessible",
- "rds_instance_no_public_access_whitelist",
- "ecs_attached_disk_encrypted",
- "ecs_unattached_disk_encrypted",
- "rds_instance_tde_enabled"
- ]
- },
- {
- "Id": "GRC-05",
- "Description": "Develop and implement an Information Security Program, which includes programs for all the relevant domains of the CCM.",
- "Name": "Information Security Program",
- "Attributes": [
- {
- "Section": "Governance, Risk and Compliance",
- "CCMLite": "No",
- "IaaS": "Shared",
- "PaaS": "Shared",
- "SaaS": "Shared",
- "ScopeApplicability": [
- {
- "ReferenceId": "CCM v3.0.1",
- "Identifiers": [
- "GRM-04"
- ]
- },
- {
- "ReferenceId": "CIS v8.0",
- "Identifiers": [
- "14.1"
- ]
- },
- {
- "ReferenceId": "ENX ISA v6.0",
- "Identifiers": [
- "1.2.1"
- ]
- },
- {
- "ReferenceId": "ISF SOGP 2022",
- "Identifiers": [
- "SG2.2"
- ]
- },
- {
- "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019",
- "Identifiers": [
- "27001: 4.3"
- ]
- },
- {
- "ReferenceId": "ISO/IEC 27001:2022, 27002:2022",
- "Identifiers": [
- "27001: 4.3"
- ]
- },
- {
- "ReferenceId": "NIST 800-53 rev 5",
- "Identifiers": [
- "PM-1",
- "PM-3",
- "PM-14",
- "PL-2",
- "PM-18",
- "PM-31"
- ]
- },
- {
- "ReferenceId": "PCI DSS v3.2.1",
- "Identifiers": [
- "12.4.1",
- "A.3.1"
- ]
- },
- {
- "ReferenceId": "PCI DSS v4.0",
- "Identifiers": [
- "12.4.1",
- "A3.1.1"
- ]
- }
- ]
- }
- ],
- "Checks": [
- "securitycenter_advanced_or_enterprise_edition"
- ]
- },
- {
- "Id": "IAM-02",
- "Description": "Establish, document, approve, communicate, implement, apply, evaluate and maintain strong password policies and procedures. Review and update the policies and procedures at least annually.",
- "Name": "Strong Password Policy and Procedures",
- "Attributes": [
- {
- "Section": "Identity & Access Management",
- "CCMLite": "No",
- "IaaS": "Shared",
- "PaaS": "Shared",
- "SaaS": "Shared",
- "ScopeApplicability": [
- {
- "ReferenceId": "CCM v3.0.1",
- "Identifiers": [
- "IAM-02",
- "IAM-12",
- "GRM-06",
- "GRM-09"
- ]
- },
- {
- "ReferenceId": "CIS v8.0",
- "Identifiers": [
- "5.2"
- ]
- },
- {
- "ReferenceId": "ENX ISA v6.0",
- "Identifiers": [
- "1.1.1",
- "1.5.1",
- "4.1.2",
- "4.1.3"
- ]
- },
- {
- "ReferenceId": "ISF SOGP 2022",
- "Identifiers": [
- "SA1.1",
- "SA1.5"
- ]
- },
- {
- "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019",
- "Identifiers": [
- "27001: 5.1",
- "27001: 5.2",
- "27001: 7.3",
- "27001: 7.4",
- "27001: 7.5",
- "27001: 9.1",
- "27001: 9.3",
- "27001: A.5",
- "27002: 5",
- "27001: A.9.4.3",
- "27002: 9.4.3",
- "27017: 9.4.3",
- "27018: 9.4.3",
- "27001: A.9.2.4",
- "27002: 9.2.4",
- "27017: 9.2.4",
- "27001: A.7.2.2",
- "27002: 7.2.2",
- "27001: A.9.2.6",
- "27002: 9.2.6",
- "27001: A.9.2.3",
- "27002: 9.2.3"
- ]
- },
- {
- "ReferenceId": "ISO/IEC 27001:2022, 27002:2022",
- "Identifiers": [
- "27001: 5.1",
- "27001: 5.2",
- "27001: 7.3",
- "27001: 7.4",
- "27001: 7.5",
- "27001: 9.1",
- "27001: 9.3",
- "27001: A.5.1",
- "27001: A.5.4",
- "27001: A.5.17",
- "27001: A.6.3",
- "27001: A.8.5",
- "27001: A.5.37"
- ]
- },
- {
- "ReferenceId": "NIST 800-53 rev 5",
- "Identifiers": [
- "AC-2",
- "AC-2(3)",
- "AC-2(11)",
- "AC-3",
- "AC-3(3)",
- "AC-12",
- "AC-12(1)",
- "IA-2",
- "IA-2(10)",
- "IA-5",
- "IA-5(1)",
- "IA-5(18)"
- ]
- },
- {
- "ReferenceId": "NIST CSF v1.1",
- "Identifiers": [
- "ID.GV-1",
- "PR.AC-1",
- "PR.AC-7"
- ]
- },
- {
- "ReferenceId": "NIST CSF v2.0",
- "Identifiers": [
- "GV.PO-01",
- "GV.PO-02",
- "ID.IM-03",
- "PR.AA-03"
- ]
- },
- {
- "ReferenceId": "PCI DSS v3.2.1",
- "Identifiers": [
- "8.4",
- "12.1",
- "12.1.1",
- "12.11"
- ]
- },
- {
- "ReferenceId": "PCI DSS v4.0",
- "Identifiers": [
- "8.1.1",
- "8.3.8"
- ]
- }
- ]
- }
- ],
- "Checks": [
- "ram_password_policy_minimum_length",
- "ram_password_policy_lowercase",
- "ram_password_policy_uppercase",
- "ram_password_policy_number",
- "ram_password_policy_symbol",
- "ram_password_policy_password_reuse_prevention",
- "ram_password_policy_max_password_age",
- "ram_password_policy_max_login_attempts"
- ]
- },
- {
- "Id": "IAM-03",
- "Description": "Manage, store, and review the information of system identities, and level of access.",
- "Name": "Identity Inventory",
- "Attributes": [
- {
- "Section": "Identity & Access Management",
- "CCMLite": "Yes",
- "IaaS": "Shared",
- "PaaS": "Shared",
- "SaaS": "Shared",
- "ScopeApplicability": [
- {
- "ReferenceId": "AICPA TSC 2017",
- "Identifiers": [
- "CC6.1",
- "CC6.3"
- ]
- },
- {
- "ReferenceId": "CCM v3.0.1",
- "Identifiers": [
- "IAM-04",
- "IAM-08",
- "IAM-10"
- ]
- },
- {
- "ReferenceId": "CIS v8.0",
- "Identifiers": [
- "5.1",
- "5.2"
- ]
- },
- {
- "ReferenceId": "ENX ISA v6.0",
- "Identifiers": [
- "4.1.3",
- "4.2.1"
- ]
- },
- {
- "ReferenceId": "ISF SOGP 2022",
- "Identifiers": [
- "SA1.3"
- ]
- },
- {
- "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019",
- "Identifiers": [
- "27001: 9.2 (c)",
- "27001: A.8.1.1",
- "27002: 8.1.1",
- "27001: A.9.4.1",
- "27002: 9.4.1"
- ]
- },
- {
- "ReferenceId": "ISO/IEC 27001:2022, 27002:2022",
- "Identifiers": [
- "27001: 9.2 (c)",
- "27001: A.5.15",
- "27001: A.5.16",
- "27001: A.5.18",
- "27001: A.7.4",
- "27001: A.8.15",
- "27001: A.8.2",
- "27001: A.8.3"
- ]
- },
- {
- "ReferenceId": "NIST 800-53 rev 5",
- "Identifiers": [
- "AU-10",
- "AU-10(1)",
- "AU-10(2)",
- "AU-16",
- "AU-16(1)",
- "IA-4",
- "IA-4(8)",
- "IA-4(9)",
- "IA-5",
- "IA-5(5)",
- "IA-8",
- "IA-8(4)",
- "PM-5(1)",
- "SA-8",
- "SA-8(22)"
- ]
- },
- {
- "ReferenceId": "NIST CSF v1.1",
- "Identifiers": [
- "PR.AC-1",
- "PR.AC-6",
- "PR.AC-4"
- ]
- },
- {
- "ReferenceId": "NIST CSF v2.0",
- "Identifiers": [
- "PR.AA-01",
- "PR.AA-02",
- "PR.AA-04",
- "PR.AA-05"
- ]
- },
- {
- "ReferenceId": "PCI DSS v3.2.1",
- "Identifiers": [
- "2.4.a"
- ]
- },
- {
- "ReferenceId": "PCI DSS v4.0",
- "Identifiers": [
- "7.2.5",
- "7.2.5.1"
- ]
- }
- ]
- }
- ],
- "Checks": [
- "ram_user_console_access_unused"
- ]
- },
- {
- "Id": "IAM-04",
- "Description": "Employ the separation of duties principle when implementing information system access.",
- "Name": "Separation of Duties",
- "Attributes": [
- {
- "Section": "Identity & Access Management",
- "CCMLite": "Yes",
- "IaaS": "Shared",
- "PaaS": "Shared",
- "SaaS": "Shared",
- "ScopeApplicability": [
- {
- "ReferenceId": "AICPA TSC 2017",
- "Identifiers": [
- "CC1.3",
- "CC5.1",
- "CC6.3"
- ]
- },
- {
- "ReferenceId": "CCM v3.0.1",
- "Identifiers": [
- "IAM-05"
- ]
- },
- {
- "ReferenceId": "CIS v8.0",
- "Identifiers": [
- "6.8"
- ]
- },
- {
- "ReferenceId": "ENX ISA v6.0",
- "Identifiers": [
- "1.2.2",
- "4.2.1"
- ]
- },
- {
- "ReferenceId": "ISF SOGP 2022",
- "Identifiers": [
- "SA1.1"
- ]
- },
- {
- "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019",
- "Identifiers": [
- "27001: A.6.1.2",
- "27002: 6.1.2"
- ]
- },
- {
- "ReferenceId": "ISO/IEC 27001:2022, 27002:2022",
- "Identifiers": [
- "27001: A.5.15",
- "27001: A.5.18",
- "27001: A.5.3",
- "27001: A.8.2"
- ]
- },
- {
- "ReferenceId": "NIST 800-53 rev 5",
- "Identifiers": [
- "AC-2",
- "AC-2(3)",
- "AC-2(11)",
- "AC-6",
- "AC-6(1)-(10)"
- ]
- },
- {
- "ReferenceId": "NIST CSF v1.1",
- "Identifiers": [
- "PR.AC-4"
- ]
- },
- {
- "ReferenceId": "NIST CSF v2.0",
- "Identifiers": [
- "PR.AA-05"
- ]
- },
- {
- "ReferenceId": "PCI DSS v3.2.1",
- "Identifiers": [
- "6.4",
- "6.4.2"
- ]
- },
- {
- "ReferenceId": "PCI DSS v4.0",
- "Identifiers": [
- "6.5.3",
- "6.5.4",
- "7.2.1",
- "7.2.2"
- ]
- }
- ]
- }
- ],
- "Checks": [
- "ram_policy_attached_only_to_group_or_roles"
- ]
- },
- {
- "Id": "IAM-05",
- "Description": "Employ the least privilege principle when implementing information system access.",
- "Name": "Least Privilege",
- "Attributes": [
- {
- "Section": "Identity & Access Management",
- "CCMLite": "Yes",
- "IaaS": "Shared",
- "PaaS": "Shared",
- "SaaS": "Shared",
- "ScopeApplicability": [
- {
- "ReferenceId": "AICPA TSC 2017",
- "Identifiers": [
- "CC6.3"
- ]
- },
- {
- "ReferenceId": "CCM v3.0.1",
- "Identifiers": [
- "IAM-02",
- "IAM-06",
- "IVS-11"
- ]
- },
- {
- "ReferenceId": "CIS v8.0",
- "Identifiers": [
- "6.8"
- ]
- },
- {
- "ReferenceId": "ENX ISA v6.0",
- "Identifiers": [
- "4.2.1"
- ]
- },
- {
- "ReferenceId": "ISF SOGP 2022",
- "Identifiers": [
- "SA1.1"
- ]
- },
- {
- "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019",
- "Identifiers": [
- "27001: A.9.1.1",
- "27002: 9.1.1",
- "27001: A.9.1.2",
- "27002: 9.1.2",
- "27001: A.9.2.3",
- "27002: 9.2.3"
- ]
- },
- {
- "ReferenceId": "ISO/IEC 27001:2022, 27002:2022",
- "Identifiers": [
- "27001: A.5.15",
- "27001: A.8.2",
- "27002: 5.15 (Other information 2nd (a))"
- ]
- },
- {
- "ReferenceId": "NIST 800-53 rev 5",
- "Identifiers": [
- "AC-6",
- "AC-6(4)",
- "IA-12",
- "IA-12(2)",
- "IA-12(3)"
- ]
- },
- {
- "ReferenceId": "NIST CSF v1.1",
- "Identifiers": [
- "PR.AC-4"
- ]
- },
- {
- "ReferenceId": "NIST CSF v2.0",
- "Identifiers": [
- "PR.AA-05"
- ]
- },
- {
- "ReferenceId": "PCI DSS v3.2.1",
- "Identifiers": [
- "7.1",
- "7.1.1",
- "7.1.2"
- ]
- },
- {
- "ReferenceId": "PCI DSS v4.0",
- "Identifiers": [
- "7.2.1",
- "7.2.2",
- "7.2.5",
- "7.2.6"
- ]
- }
- ]
- }
- ],
- "Checks": [
- "ram_policy_no_administrative_privileges"
- ]
- },
- {
- "Id": "IAM-07",
- "Description": "De-provision or respectively modify access of movers / leavers or system identity changes in a timely manner in order to effectively adopt and communicate identity and access management policies.",
- "Name": "User Access Changes and Revocation",
- "Attributes": [
- {
- "Section": "Identity & Access Management",
- "CCMLite": "Yes",
- "IaaS": "Shared",
- "PaaS": "Shared",
- "SaaS": "Shared",
- "ScopeApplicability": [
- {
- "ReferenceId": "AICPA TSC 2017",
- "Identifiers": [
- "CC5.3",
- "CC6.3"
- ]
- },
- {
- "ReferenceId": "CCM v3.0.1",
- "Identifiers": [
- "IAM-11"
- ]
- },
- {
- "ReferenceId": "CIS v8.0",
- "Identifiers": [
- "5.3",
- "6.2"
- ]
- },
- {
- "ReferenceId": "ENX ISA v6.0",
- "Identifiers": [
- "4.2.1"
- ]
- },
- {
- "ReferenceId": "ISF SOGP 2022",
- "Identifiers": [
- "SA1.3"
- ]
- },
- {
- "ReferenceId": "ISO/IEC 27001:2022, 27002:2022",
- "Identifiers": [
- "27001: A.5.15",
- "27001: A.5.18"
- ]
- },
- {
- "ReferenceId": "NIST 800-53 rev 5",
- "Identifiers": [
- "AC-2",
- "AC-2(1)",
- "AC-2(2)",
- "AC-2(6)",
- "AC-2(8)",
- "AC-3",
- "AC-3(8)",
- "AC-6",
- "AC-6(7)",
- "AU-10",
- "AU-10(4)",
- "AU-16",
- "AU-16(1)",
- "CM-7",
- "CM-7(1)"
- ]
- },
- {
- "ReferenceId": "NIST CSF v1.1",
- "Identifiers": [
- "PR.AC-1",
- "PR.AC-4",
- "PR.IP-11"
- ]
- },
- {
- "ReferenceId": "NIST CSF v2.0",
- "Identifiers": [
- "GV.RR-04",
- "GV.SC-10",
- "PR.AA-01",
- "PR.AA-05"
- ]
- },
- {
- "ReferenceId": "PCI DSS v3.2.1",
- "Identifiers": [
- "8.1.2",
- "8.1.3"
- ]
- },
- {
- "ReferenceId": "PCI DSS v4.0",
- "Identifiers": [
- "8.2.5",
- "8.2.6"
- ]
- }
- ]
- }
- ],
- "Checks": [
- "ram_user_console_access_unused"
- ]
- },
- {
- "Id": "IAM-08",
- "Description": "Review and revalidate user access for least privilege and separation of duties with a frequency that is commensurate with organizational risk tolerance.",
- "Name": "User Access Review",
- "Attributes": [
- {
- "Section": "Identity & Access Management",
- "CCMLite": "Yes",
- "IaaS": "Shared",
- "PaaS": "Shared",
- "SaaS": "Shared",
- "ScopeApplicability": [
- {
- "ReferenceId": "AICPA TSC 2017",
- "Identifiers": [
- "CC6.2",
- "CC6.3"
- ]
- },
- {
- "ReferenceId": "CCM v3.0.1",
- "Identifiers": [
- "IAM-10"
- ]
- },
- {
- "ReferenceId": "CIS v8.0",
- "Identifiers": [
- "5.1"
- ]
- },
- {
- "ReferenceId": "ENX ISA v6.0",
- "Identifiers": [
- "4.2.1"
- ]
- },
- {
- "ReferenceId": "ISF SOGP 2022",
- "Identifiers": [
- "SA1.3"
- ]
- },
- {
- "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019",
- "Identifiers": [
- "27001: A.9.2.5",
- "27001: A.9.2.6",
- "27001: A.9.4.1",
- "27017: 9.4.1",
- "27001: A.6.1.2",
- "27001: A 9.2.5"
- ]
- },
- {
- "ReferenceId": "ISO/IEC 27001:2022, 27002:2022",
- "Identifiers": [
- "27001: A.5.3",
- "27001: A.5.18",
- "27001: A.8.3"
- ]
- },
- {
- "ReferenceId": "NIST 800-53 rev 5",
- "Identifiers": [
- "AC-6",
- "AC-6(4)",
- "AC-6(8)",
- "IA-8",
- "IA-8(4)"
- ]
- },
- {
- "ReferenceId": "NIST CSF v1.1",
- "Identifiers": [
- "PR.AC-4"
- ]
- },
- {
- "ReferenceId": "NIST CSF v2.0",
- "Identifiers": [
- "PR.AA-05"
- ]
- },
- {
- "ReferenceId": "PCI DSS v3.2.1",
- "Identifiers": [
- "12.5.5"
- ]
- },
- {
- "ReferenceId": "PCI DSS v4.0",
- "Identifiers": [
- "7.2.5.1",
- "7.2.5",
- "7.2.4"
- ]
- }
- ]
- }
- ],
- "Checks": [
- "ram_user_console_access_unused",
- "ram_rotate_access_key_90_days"
- ]
- },
- {
- "Id": "IAM-09",
- "Description": "Define, implement and evaluate processes, procedures and technical measures for the segregation of privileged access roles such that administrative access to data, encryption and key management capabilities and logging capabilities are distinct and separated.",
- "Name": "Segregation of Privileged Access Roles",
- "Attributes": [
- {
- "Section": "Identity & Access Management",
- "CCMLite": "Yes",
- "IaaS": "Shared",
- "PaaS": "Shared",
- "SaaS": "Shared",
- "ScopeApplicability": [
- {
- "ReferenceId": "AICPA TSC 2017",
- "Identifiers": [
- "CC5.1",
- "CC6.1",
- "CC6.3"
- ]
- },
- {
- "ReferenceId": "CIS v8.0",
- "Identifiers": [
- "5.4"
- ]
- },
- {
- "ReferenceId": "ISF SOGP 2022",
- "Identifiers": [
- "SA1.3"
- ]
- },
- {
- "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019",
- "Identifiers": [
- "27001: A.9.2.3",
- "27002: 9.2.3",
- "27017: 9.2.3",
- "27018: 9.2.3"
- ]
- },
- {
- "ReferenceId": "ISO/IEC 27001:2022, 27002:2022",
- "Identifiers": [
- "27001: A.8.2",
- "27001: A.8.18",
- "27002: 8.2 (j)"
- ]
- },
- {
- "ReferenceId": "NIST 800-53 rev 5",
- "Identifiers": [
- "AC-6",
- "AC-3(7)",
- "AC-6(4)",
- "AC-6(8)",
- "IA-5",
- "IA-5(6)",
- "IA-8",
- "IA-8(4)"
- ]
- },
- {
- "ReferenceId": "NIST CSF v1.1",
- "Identifiers": [
- "PR.AC-1",
- "PR.AC-4"
- ]
- },
- {
- "ReferenceId": "NIST CSF v2.0",
- "Identifiers": [
- "PR.AA-01",
- "PR.AA-05"
- ]
- },
- {
- "ReferenceId": "PCI DSS v3.2.1",
- "Identifiers": [
- "2.3",
- "3.5.2",
- "7.1.2",
- "7.1.1"
- ]
- },
- {
- "ReferenceId": "PCI DSS v4.0",
- "Identifiers": [
- "3.6.1",
- "3.7.6",
- "6.5.3",
- "6.5.4",
- "7.2.1",
- "7.2.2",
- "10.3.1"
- ]
- }
- ]
- }
- ],
- "Checks": [
- "ram_policy_attached_only_to_group_or_roles",
- "ram_no_root_access_key"
- ]
- },
- {
- "Id": "IAM-10",
- "Description": "Define and implement an access process to ensure privileged access roles and rights are granted for a time limited period, and implement procedures to prevent the culmination of segregated privileged access.",
- "Name": "Management of Privileged Access Roles",
- "Attributes": [
- {
- "Section": "Identity & Access Management",
- "CCMLite": "Yes",
- "IaaS": "Shared",
- "PaaS": "Shared",
- "SaaS": "Shared",
- "ScopeApplicability": [
- {
- "ReferenceId": "AICPA TSC 2017",
- "Identifiers": [
- "CC6.1",
- "CC6.2",
- "CC6.3"
- ]
- },
- {
- "ReferenceId": "CIS v8.0",
- "Identifiers": [
- "5.1",
- "6.5"
- ]
- },
- {
- "ReferenceId": "ISF SOGP 2022",
- "Identifiers": [
- "SA1.3"
- ]
- },
- {
- "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019",
- "Identifiers": [
- "27001: A.9.2.3",
- "27002: 9.2.3",
- "27017: 9.2.3",
- "27018: 9.2.3",
- "27001: A.9.4.4",
- "27002: 9.4.4",
- "27017: 9.4.4"
- ]
- },
- {
- "ReferenceId": "ISO/IEC 27001:2022, 27002:2022",
- "Identifiers": [
- "27001: A.8.2",
- "27001: A.8.18",
- "27002: 8.2 (i)"
- ]
- },
- {
- "ReferenceId": "NIST 800-53 rev 5",
- "Identifiers": [
- "AC-2",
- "AC-2(7)",
- "AC-3",
- "AC-3(4)",
- "AC-3(11)",
- "AC-3(13)",
- "AC-3(14)",
- "AC-6",
- "AC-6(4)",
- "AC-6(5)",
- "AC-6(8)",
- "AC-12",
- "AC-12(3)",
- "AC-17",
- "AC-17(4)",
- "IA-8",
- "IA-8(4)"
- ]
- },
- {
- "ReferenceId": "NIST CSF v1.1",
- "Identifiers": [
- "PR.AC-4"
- ]
- },
- {
- "ReferenceId": "NIST CSF v2.0",
- "Identifiers": [
- "PR.AA-05"
- ]
- },
- {
- "ReferenceId": "PCI DSS v3.2.1",
- "Identifiers": [
- "7.1"
- ]
- },
- {
- "ReferenceId": "PCI DSS v4.0",
- "Identifiers": [
- "7.2.1",
- "7.2.2"
- ]
- }
- ]
- }
- ],
- "Checks": [
- "ram_no_root_access_key",
- "ram_policy_no_administrative_privileges"
- ]
- },
- {
- "Id": "IAM-12",
- "Description": "Define, implement and evaluate processes, procedures and technical measures to ensure the logging infrastructure is read-only for all with write access, including privileged access roles, and that the ability to disable it is controlled through a procedure that ensures the segregation of duties and break glass procedures.",
- "Name": "Safeguard Logs Integrity",
- "Attributes": [
- {
- "Section": "Identity & Access Management",
- "CCMLite": "No",
- "IaaS": "Shared",
- "PaaS": "Shared",
- "SaaS": "Shared",
- "ScopeApplicability": [
- {
- "ReferenceId": "CIS v8.0",
- "Identifiers": [
- "3.3"
- ]
- },
- {
- "ReferenceId": "ENX ISA v6.0",
- "Identifiers": [
- "4.2.1",
- "5.2.4"
- ]
- },
- {
- "ReferenceId": "ISF SOGP 2022",
- "Identifiers": [
- "TM1.2"
- ]
- },
- {
- "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019",
- "Identifiers": [
- "27001: A.12.4.1",
- "27002: 12.4.1",
- "27017: 12.4.1",
- "27018: 12.4.1",
- "27001: A.12.4.2",
- "27002: 12.4.2",
- "27017: 12.4.2",
- "27018: 12.4.2",
- "27001: A.12.4.3",
- "27002: 12.4.3",
- "27017: 12.4.3",
- "27018: 12.4.3"
- ]
- },
- {
- "ReferenceId": "ISO/IEC 27001:2022, 27002:2022",
- "Identifiers": [
- "27001: A.8.15",
- "27001: A.8.18",
- "27002: 8.15 Protection of Logs"
- ]
- },
- {
- "ReferenceId": "NIST 800-53 rev 5",
- "Identifiers": [
- "AC-2",
- "AC-2(11)",
- "AC-2(12)",
- "IA-8",
- "IA-8(4)",
- "SA-8",
- "SA-8(22)",
- "SC-34",
- "SC-34(1)",
- "SC-34(2)",
- "SC-36",
- "SI-4",
- "SI-4(5)"
- ]
- },
- {
- "ReferenceId": "NIST CSF v1.1",
- "Identifiers": [
- "PR.AC-4"
- ]
- },
- {
- "ReferenceId": "NIST CSF v2.0",
- "Identifiers": [
- "PR.AA-05"
- ]
- },
- {
- "ReferenceId": "PCI DSS v3.2.1",
- "Identifiers": [
- "10.5"
- ]
- },
- {
- "ReferenceId": "PCI DSS v4.0",
- "Identifiers": [
- "10.3.1",
- "10.3.2",
- "10.3.3",
- "10.3.4"
- ]
- }
- ]
- }
- ],
- "Checks": [
- "actiontrail_oss_bucket_not_publicly_accessible"
- ]
- },
- {
- "Id": "IAM-13",
- "Description": "Define, implement and evaluate processes, procedures and technical measures that ensure users are identifiable through unique IDs or which can associate individuals to the usage of user IDs.",
- "Name": "Uniquely Identifiable Users",
- "Attributes": [
- {
- "Section": "Identity & Access Management",
- "CCMLite": "No",
- "IaaS": "Shared",
- "PaaS": "Shared",
- "SaaS": "Shared",
- "ScopeApplicability": [
- {
- "ReferenceId": "AICPA TSC 2017",
- "Identifiers": [
- "CC6.1"
- ]
- },
- {
- "ReferenceId": "ENX ISA v6.0",
- "Identifiers": [
- "4.1.3"
- ]
- },
- {
- "ReferenceId": "ISF SOGP 2022",
- "Identifiers": [
- "SA1.3"
- ]
- },
- {
- "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019",
- "Identifiers": [
- "27001: A.9.2.1",
- "27002: 9.2.1"
- ]
- },
- {
- "ReferenceId": "ISO/IEC 27001:2022, 27002:2022",
- "Identifiers": [
- "27001: A.5.16"
- ]
- },
- {
- "ReferenceId": "NIST 800-53 rev 5",
- "Identifiers": [
- "AC-3",
- "AC-3(14)",
- "AC-24",
- "AC-24(2)",
- "AU-10",
- "AU-10(1)",
- "IA-2",
- "IA-2(1)",
- "IA-2(2)",
- "IA-2(12)",
- "IA-4",
- "IA-4(1)",
- "SA-8",
- "SA-8(22)",
- "SC-23",
- "SC-23(3)",
- "SC-40(4)"
- ]
- },
- {
- "ReferenceId": "NIST CSF v1.1",
- "Identifiers": [
- "PR.AC-1",
- "PR.AC-6"
- ]
- },
- {
- "ReferenceId": "NIST CSF v2.0",
- "Identifiers": [
- "PR.AA-01",
- "PR.AA-02"
- ]
- },
- {
- "ReferenceId": "PCI DSS v3.2.1",
- "Identifiers": [
- "8.1",
- "8.2",
- "8.6"
- ]
- },
- {
- "ReferenceId": "PCI DSS v4.0",
- "Identifiers": [
- "8.2.1",
- "8.2.2",
- "8.2.4"
- ]
- }
- ]
- }
- ],
- "Checks": [
- "ram_user_mfa_enabled_console_access"
- ]
- },
- {
- "Id": "IAM-14",
- "Description": "Define, implement and evaluate processes, procedures and technical measures for authenticating access to systems, application and data assets, including multifactor authentication for at least privileged user and sensitive data access. Adopt digital certificates or alternatives which achieve an equivalent level of security for system identities.",
- "Name": "Strong Authentication",
- "Attributes": [
- {
- "Section": "Identity & Access Management",
- "CCMLite": "Yes",
- "IaaS": "Shared",
- "PaaS": "Shared",
- "SaaS": "Shared",
- "ScopeApplicability": [
- {
- "ReferenceId": "AICPA TSC 2017",
- "Identifiers": [
- "CC6.1",
- "CC6.2"
- ]
- },
- {
- "ReferenceId": "CCM v3.0.1",
- "Identifiers": [
- "IAM-02",
- "IAM-05"
- ]
- },
- {
- "ReferenceId": "CIS v8.0",
- "Identifiers": [
- "6.3",
- "6.5",
- "12.5",
- "12.7"
- ]
- },
- {
- "ReferenceId": "ENX ISA v6.0",
- "Identifiers": [
- "4.1.2"
- ]
- },
- {
- "ReferenceId": "ISF SOGP 2022",
- "Identifiers": [
- "SA1.3",
- "SA1.4",
- "SA1.8"
- ]
- },
- {
- "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019",
- "Identifiers": [
- "27001: A.9.1.2",
- "27002: 9.1.2",
- "27017: 9.1.2",
- "27001: A.9.2.4",
- "27002: 9.2.4",
- "27017: 9.2.4",
- "27001: A.9.4.2",
- "27002: 9.4.2",
- "27017: 9.4.2",
- "27018: 9.4.2"
- ]
- },
- {
- "ReferenceId": "ISO/IEC 27001:2022, 27002:2022",
- "Identifiers": [
- "27001: A.5.15",
- "27001: A.5.17",
- "27001: A.8.5",
- "27001: A.8.24",
- "27002: 8.5",
- "27002: 8.24 other information (d)"
- ]
- },
- {
- "ReferenceId": "NIST 800-53 rev 5",
- "Identifiers": [
- "AC-6",
- "AC-6(5)",
- "AC-7",
- "AC-7(4)",
- "AU-10",
- "AU-10(2)",
- "IA-2",
- "IA-2(1)",
- "IA-2(2)",
- "IA-2(8)",
- "IA-2(12)",
- "IA-3",
- "IA-3(1)",
- "IA-5",
- "IA-5(2)",
- "IA-5(7)",
- "IA-5(9)",
- "IA-5(10)",
- "IA-5(12)",
- "IA-5(14)-(16)",
- "IA-8",
- "IA-8(1)",
- "IA-8(6)",
- "SC-23",
- "SC-23(3)"
- ]
- },
- {
- "ReferenceId": "NIST CSF v1.1",
- "Identifiers": [
- "PR.AC-1",
- "PR.AC-6",
- "PR.AC-7"
- ]
- },
- {
- "ReferenceId": "NIST CSF v2.0",
- "Identifiers": [
- "PR.AA-01",
- "PR.AA-02",
- "PR.AA-03"
- ]
- },
- {
- "ReferenceId": "PCI DSS v3.2.1",
- "Identifiers": [
- "8.1.2",
- "8.1.3",
- "8.1.6",
- "8.2",
- "8.3",
- "8.3.2",
- "12.3.2"
- ]
- },
- {
- "ReferenceId": "PCI DSS v4.0",
- "Identifiers": [
- "7.2.1",
- "8.3.1",
- "8.3.2",
- "8.4.1",
- "8.4.2",
- "8.4.3"
- ]
- }
- ]
- }
- ],
- "Checks": [
- "ram_user_mfa_enabled_console_access"
- ]
- },
- {
- "Id": "IAM-15",
- "Description": "Define, implement and evaluate processes, procedures and technical measures for the secure management of passwords.",
- "Name": "Passwords Management",
- "Attributes": [
- {
- "Section": "Identity & Access Management",
- "CCMLite": "No",
- "IaaS": "Shared",
- "PaaS": "Shared",
- "SaaS": "Shared",
- "ScopeApplicability": [
- {
- "ReferenceId": "AICPA TSC 2017",
- "Identifiers": [
- "CC6.1",
- "CC6.2"
- ]
- },
- {
- "ReferenceId": "ENX ISA v6.0",
- "Identifiers": [
- "4.1.3"
- ]
- },
- {
- "ReferenceId": "ISF SOGP 2022",
- "Identifiers": [
- "SA1.5"
- ]
- },
- {
- "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019",
- "Identifiers": [
- "27001: A.9.2.4",
- "27002: 9.2.4",
- "27017: 9.2.4",
- "27018: 9.2.4",
- "27001: A.9.3.1",
- "27002: 9.3.1",
- "27017: 9.3.1",
- "27018: 9.3.1",
- "27001: A.9.4.3",
- "27002: 9.4.3",
- "27017: 9.4.3",
- "27018: 9.4.3"
- ]
- },
- {
- "ReferenceId": "ISO/IEC 27001:2022, 27002:2022",
- "Identifiers": [
- "27001: A.5.17"
- ]
- },
- {
- "ReferenceId": "NIST 800-53 rev 5",
- "Identifiers": [
- "IA-4",
- "IA-4(8)",
- "IA-5",
- "IA-5(1)",
- "IA-5(8)",
- "IA-5(18)"
- ]
- },
- {
- "ReferenceId": "NIST CSF v1.1",
- "Identifiers": [
- "PR.AC-1"
- ]
- },
- {
- "ReferenceId": "NIST CSF v2.0",
- "Identifiers": [
- "PR.AA-01"
- ]
- },
- {
- "ReferenceId": "PCI DSS v3.2.1",
- "Identifiers": [
- "8.2",
- "8.2.1-6"
- ]
- },
- {
- "ReferenceId": "PCI DSS v4.0",
- "Identifiers": [
- "2.2.2",
- "2.3.1",
- "8.3.5",
- "8.3.6",
- "8.3.7",
- "8.3.8",
- "8.3.9",
- "8.3.10",
- "8.3.10.1",
- "8.6.2"
- ]
- }
- ]
- }
- ],
- "Checks": [
- "ram_password_policy_minimum_length",
- "ram_password_policy_password_reuse_prevention",
- "ram_password_policy_max_password_age"
- ]
- },
- {
- "Id": "IAM-16",
- "Description": "Define, implement and evaluate processes, procedures and technical measures to verify access to data and system functions is authorized.",
- "Name": "Authorization Mechanisms",
- "Attributes": [
- {
- "Section": "Identity & Access Management",
- "CCMLite": "No",
- "IaaS": "Shared",
- "PaaS": "Shared",
- "SaaS": "Shared",
- "ScopeApplicability": [
- {
- "ReferenceId": "AICPA TSC 2017",
- "Identifiers": [
- "CC6.1",
- "CC6.2",
- "CC6.3"
- ]
- },
- {
- "ReferenceId": "CCM v3.0.1",
- "Identifiers": [
- "IAM-02"
- ]
- },
- {
- "ReferenceId": "CIS v8.0",
- "Identifiers": [
- "5.1"
- ]
- },
- {
- "ReferenceId": "ENX ISA v6.0",
- "Identifiers": [
- "4.2.1"
- ]
- },
- {
- "ReferenceId": "ISF SOGP 2022",
- "Identifiers": [
- "SA1.3",
- "SA1.4"
- ]
- },
- {
- "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019",
- "Identifiers": [
- "27001: A.9.2.5",
- "27002: 9.2.5",
- "27017: 9.2.5",
- "27018: 9.2.5"
- ]
- },
- {
- "ReferenceId": "ISO/IEC 27001:2022, 27002:2022",
- "Identifiers": [
- "27001: A.5.18"
- ]
- },
- {
- "ReferenceId": "NIST 800-53 rev 5",
- "Identifiers": [
- "AC-3",
- "AC-3(5)",
- "AC-4",
- "AC-4(17)",
- "AC-4(21)",
- "AC-4(22)",
- "AC-6",
- "AC-6(8)",
- "AC-6(9)",
- "AC-12",
- "AC-12(1)",
- "AC-20",
- "AC-20(1)",
- "AU-10",
- "AU-10(1)",
- "AU-10(2)",
- "IA-2",
- "IA-2(1)",
- "IA-2(2)",
- "IA-2(12)",
- "IA-3",
- "IA-3(1)",
- "IA-5(1)",
- "IA-5(2)",
- "IA-5(5)",
- "IA-5(8)",
- "IA-5(10)",
- "IA-5(12)",
- "IA-8",
- "IA-8(1)",
- "IA-8(2)"
- ]
- },
- {
- "ReferenceId": "NIST CSF v1.1",
- "Identifiers": [
- "PR.AC-1",
- "PR.AC-4",
- "PR.AC-6",
- "PR.AC-7",
- "PR.PT-1"
- ]
- },
- {
- "ReferenceId": "NIST CSF v2.0",
- "Identifiers": [
- "PR.AA-01",
- "PR.AA-02",
- "PR.AA-03",
- "PR.AA-04",
- "PR.AA-05",
- "PR.PS-04"
- ]
- },
- {
- "ReferenceId": "PCI DSS v3.2.1",
- "Identifiers": [
- "5.3",
- "7.1.4"
- ]
- },
- {
- "ReferenceId": "PCI DSS v4.0",
- "Identifiers": [
- "7.2.4",
- "7.2.3",
- "7.2.5.1"
- ]
- }
- ]
- }
- ],
- "Checks": [
- "ram_policy_no_administrative_privileges",
- "cs_kubernetes_rbac_enabled"
- ]
- },
- {
- "Id": "IPY-03",
- "Description": "Implement cryptographically secure and standardized network protocols for the management, import and export of data.",
- "Name": "Secure Interoperability and Portability Management",
- "Attributes": [
- {
- "Section": "Interoperability & Portability",
- "CCMLite": "No",
- "IaaS": "Shared",
- "PaaS": "Shared",
- "SaaS": "Shared",
- "ScopeApplicability": [
- {
- "ReferenceId": "AICPA TSC 2017",
- "Identifiers": [
- "CC6.7"
- ]
- },
- {
- "ReferenceId": "CCM v3.0.1",
- "Identifiers": [
- "IPY-04"
- ]
- },
- {
- "ReferenceId": "ENX ISA v6.0",
- "Identifiers": [
- "5.1.1",
- "5.1.2"
- ]
- },
- {
- "ReferenceId": "ISF SOGP 2022",
- "Identifiers": [
- "SY1.1",
- "SY1.2",
- "NC1.4"
- ]
- },
- {
- "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019",
- "Identifiers": [
- "27001: A.18.1",
- "27001: A.15.1.1",
- "27002: 15.1.1",
- "27017: 15.1.1"
- ]
- },
- {
- "ReferenceId": "ISO/IEC 27001:2022, 27002:2022",
- "Identifiers": [
- "27001: A.5.19",
- "27001: A.5.23",
- "27001: A.5.31",
- "27001: A.5.32",
- "27001: A.5.33",
- "27001: A.5.34"
- ]
- },
- {
- "ReferenceId": "NIST 800-53 rev 5",
- "Identifiers": [
- "PT-2",
- "PT-2(2)",
- "SA-4",
- "SC-16",
- "SC-16(3)"
- ]
- },
- {
- "ReferenceId": "NIST CSF v1.1",
- "Identifiers": [
- "PR.DS-2"
- ]
- },
- {
- "ReferenceId": "NIST CSF v2.0",
- "Identifiers": [
- "PR.DS-02"
- ]
- },
- {
- "ReferenceId": "PCI DSS v4.0",
- "Identifiers": [
- "1.2.1",
- "1.2.5",
- "1.2.6",
- "2.2.4",
- "2.2.5",
- "2.2.7",
- "4.2.1"
- ]
- }
- ]
- }
- ],
- "Checks": [
- "oss_bucket_secure_transport_enabled"
- ]
- },
- {
- "Id": "IVS-02",
- "Description": "Plan and monitor the availability, quality, and adequate capacity of resources in order to deliver the required system performance as determined by the business.",
- "Name": "Capacity and Resource Planning",
- "Attributes": [
- {
- "Section": "Infrastructure & Virtualization Security",
- "CCMLite": "No",
- "IaaS": "CSP-Owned",
- "PaaS": "CSP-Owned",
- "SaaS": "CSP-Owned",
- "ScopeApplicability": [
- {
- "ReferenceId": "AICPA TSC 2017",
- "Identifiers": [
- "A1.1"
- ]
- },
- {
- "ReferenceId": "CCM v3.0.1",
- "Identifiers": [
- "IVS-04"
- ]
- },
- {
- "ReferenceId": "ISF SOGP 2022",
- "Identifiers": [
- "SY2.2"
- ]
- },
- {
- "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019",
- "Identifiers": [
- "27001: 5.3",
- "27001: 6.1",
- "27001: 9.1",
- "27001: A.12.1.3",
- "27002: 12.1.3"
- ]
- },
- {
- "ReferenceId": "ISO/IEC 27001:2022, 27002:2022",
- "Identifiers": [
- "27001: 5.3 (b)",
- "27001: 6.1",
- "27001: 9.1",
- "27001: A.8.6",
- "27001: A.8.14"
- ]
- },
- {
- "ReferenceId": "NIST 800-53 rev 5",
- "Identifiers": [
- "CP-2",
- "CP-2(2)",
- "SC-5",
- "SC-5(2)",
- "SC-4",
- "SI-4"
- ]
- },
- {
- "ReferenceId": "NIST CSF v1.1",
- "Identifiers": [
- "PR.DS-4",
- "ID.BE-5"
- ]
- },
- {
- "ReferenceId": "NIST CSF v2.0",
- "Identifiers": [
- "PR.IR-04",
- "GV.OC-04"
- ]
- }
- ]
- }
- ],
- "Checks": []
- },
- {
- "Id": "IVS-03",
- "Description": "Monitor, encrypt and restrict communications between environments to only authenticated and authorized connections, as justified by the business. Review these configurations at least annually, and support them by a documented justification of all allowed services, protocols, ports, and compensating controls.",
- "Name": "Network Security",
- "Attributes": [
- {
- "Section": "Infrastructure & Virtualization Security",
- "CCMLite": "Yes",
- "IaaS": "Shared",
- "PaaS": "Shared",
- "SaaS": "CSP-Owned",
- "ScopeApplicability": [
- {
- "ReferenceId": "AICPA TSC 2017",
- "Identifiers": [
- "CC6.1",
- "CC6.7"
- ]
- },
- {
- "ReferenceId": "CCM v3.0.1",
- "Identifiers": [
- "IVS-06"
- ]
- },
- {
- "ReferenceId": "CIS v8.0",
- "Identifiers": [
- "3.8",
- "3.1",
- "12.2",
- "13.6",
- "13.9"
- ]
- },
- {
- "ReferenceId": "ENX ISA v6.0",
- "Identifiers": [
- "5.1.2",
- "5.2.7"
- ]
- },
- {
- "ReferenceId": "ISF SOGP 2022",
- "Identifiers": [
- "NC1.1"
- ]
- },
- {
- "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019",
- "Identifiers": [
- "27001: 7.5",
- "27001: 9.1",
- "27001: A.13.1.1",
- "27002: 13.1.1",
- "27001: A.13.1.2",
- "27002: 13.1.2",
- "27001: A.13.1.3",
- "27002: 13.1.3"
- ]
- },
- {
- "ReferenceId": "ISO/IEC 27001:2022, 27002:2022",
- "Identifiers": [
- "27001: 7.5",
- "27001: 9.1",
- "27001: A.5.15",
- "27001: A.5.37",
- "27001: A.8.5",
- "27001: A.8.9",
- "27001: A.8.16",
- "27001: A.8.20",
- "27001: A.8.21",
- "27001: A.8.22",
- "27001: A.8.24",
- "27002: A.5.15 2nd c)",
- "27002: 8.20",
- "27002: 8.21",
- "27002: 8.22",
- "27002: 8.24"
- ]
- },
- {
- "ReferenceId": "NIST 800-53 rev 5",
- "Identifiers": [
- "SC-1",
- "SC-4",
- "SC-7",
- "SC-7(4)",
- "SC-7(5)",
- "SC-7(8)",
- "SC-7(9)",
- "SC-7(11)",
- "SC-8",
- "SC-8(1)",
- "SC-11",
- "SC-12",
- "SC-16",
- "SC-23",
- "SC-29",
- "SC-29(1)"
- ]
- },
- {
- "ReferenceId": "NIST CSF v1.1",
- "Identifiers": [
- "PR.AC-5",
- "PR.AC-7",
- "PR.PT-4",
- "DE.CM-1",
- "DE.CM-7",
- "PR.DS-2"
- ]
- },
- {
- "ReferenceId": "NIST CSF v2.0",
- "Identifiers": [
- "PR.IR-01",
- "PR.AA-03",
- "PR.AA-05",
- "DE.CM-01",
- "PR.DS-02",
- "ID.AM-03"
- ]
- },
- {
- "ReferenceId": "PCI DSS v3.2.1",
- "Identifiers": [
- "1.1.6",
- "1.2",
- "1.2.3",
- "2.2",
- "4.1.1",
- "10.2"
- ]
- },
- {
- "ReferenceId": "PCI DSS v4.0",
- "Identifiers": [
- "1.2.5",
- "1.2.6",
- "1.2.7",
- "1.4.2",
- "2.2.4",
- "2.2.5",
- "2.2.7",
- "4.2.1",
- "10.1.1"
- ]
- }
- ]
- }
- ],
- "Checks": [
- "vpc_flow_logs_enabled",
- "ecs_securitygroup_restrict_ssh_internet",
- "ecs_securitygroup_restrict_rdp_internet"
- ]
- },
- {
- "Id": "IVS-04",
- "Description": "Harden host and guest OS, hypervisor or infrastructure control plane according to their respective best practices, and supported by technical controls, as part of a security baseline.",
- "Name": "OS Hardening and Base Controls",
- "Attributes": [
- {
- "Section": "Infrastructure & Virtualization Security",
- "CCMLite": "Yes",
- "IaaS": "Shared",
- "PaaS": "CSP-Owned",
- "SaaS": "CSP-Owned",
- "ScopeApplicability": [
- {
- "ReferenceId": "AICPA TSC 2017",
- "Identifiers": [
- "CC6.1",
- "CC6.8",
- "CC7.1"
- ]
- },
- {
- "ReferenceId": "CCM v3.0.1",
- "Identifiers": [
- "IVS-07",
- "IVS-11"
- ]
- },
- {
- "ReferenceId": "CIS v8.0",
- "Identifiers": [
- "4.1",
- "4.2"
- ]
- },
- {
- "ReferenceId": "ENX ISA v6.0",
- "Identifiers": [
- "4.1.3",
- "5.2.5"
- ]
- },
- {
- "ReferenceId": "ISF SOGP 2022",
- "Identifiers": [
- "SY1.1",
- "SY1.3",
- "SY1.4"
- ]
- },
- {
- "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019",
- "Identifiers": [
- "27001: 7.5",
- "27001: 9.1",
- "27001: A.14.2.2",
- "27002: 14.2.2",
- "27001: A.14.2.3",
- "27001 A.14.2.4",
- "27018: 12.1.2"
- ]
- },
- {
- "ReferenceId": "ISO/IEC 27001:2022, 27002:2022",
- "Identifiers": [
- "27001: 7.5",
- "27001: 9.1",
- "27001: A.5.37",
- "27001: A.8.5",
- "27001: A.8.9",
- "27001: A.8.16",
- "27001: A.8.20",
- "27001: A.8.22",
- "27001: A.8.24",
- "27002: 8.20",
- "27002: 8.22",
- "27002: 8.24"
- ]
- },
- {
- "ReferenceId": "NIST 800-53 rev 5",
- "Identifiers": [
- "CM-6",
- "CM-6(1)",
- "SC-29",
- "SC-29(1)",
- "SC-2",
- "SC-7",
- "SC-7(12)",
- "SC-30",
- "SC-34",
- "SC-35",
- "SC-39",
- "SC-44"
- ]
- },
- {
- "ReferenceId": "NIST CSF v1.1",
- "Identifiers": [
- "PR.IP-1",
- "PR.PT-3"
- ]
- },
- {
- "ReferenceId": "NIST CSF v2.0",
- "Identifiers": [
- "PR.PS-01"
- ]
- },
- {
- "ReferenceId": "PCI DSS v3.2.1",
- "Identifiers": [
- "2.2"
- ]
- },
- {
- "ReferenceId": "PCI DSS v4.0",
- "Identifiers": [
- "2.2.1"
- ]
- }
- ]
- }
- ],
- "Checks": [
- "ecs_instance_latest_os_patches_applied",
- "ecs_instance_endpoint_protection_installed"
- ]
- },
- {
- "Id": "IVS-06",
- "Description": "Design, develop, deploy and configure applications and infrastructures such that CSP and CSC (tenant) user access and intra-tenant access is appropriately segmented and segregated, monitored and restricted from other tenants.",
- "Name": "Segmentation and Segregation",
- "Attributes": [
- {
- "Section": "Infrastructure & Virtualization Security",
- "CCMLite": "Yes",
- "IaaS": "Shared",
- "PaaS": "Shared",
- "SaaS": "CSP-Owned",
- "ScopeApplicability": [
- {
- "ReferenceId": "CCM v3.0.1",
- "Identifiers": [
- "IVS-09"
- ]
- },
- {
- "ReferenceId": "ENX ISA v6.0",
- "Identifiers": [
- "4.2.1",
- "5.3.4",
- "5.2.7"
- ]
- },
- {
- "ReferenceId": "ISF SOGP 2022",
- "Identifiers": [
- "SC2.2"
- ]
- },
- {
- "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019",
- "Identifiers": [
- "27001: 9.1",
- "27001: A.13.1.3",
- "27002: 13.1.3",
- "27017: 13.1.3"
- ]
- },
- {
- "ReferenceId": "ISO/IEC 27001:2022, 27002:2022",
- "Identifiers": [
- "27001: 9.1",
- "27001: A.5.15",
- "27001: A.5.20",
- "27001: A.8.3",
- "27001: A.8.9",
- "27001: A.8.16",
- "27001: A.8.22",
- "27002: 5.15 (b)",
- "27002: 8.3 (b)",
- "27002: 8.16 (b)"
- ]
- },
- {
- "ReferenceId": "NIST 800-53 rev 5",
- "Identifiers": [
- "SC-3",
- "SC-7",
- "SC-7(20)"
- ]
- },
- {
- "ReferenceId": "NIST CSF v1.1",
- "Identifiers": [
- "PR.AC-4",
- "PR.AC-5"
- ]
- },
- {
- "ReferenceId": "NIST CSF v2.0",
- "Identifiers": [
- "PR.AA-05",
- "PR.IR-01",
- "PR.PS-01",
- "PR.PS-06",
- "DE.CM-09"
- ]
- },
- {
- "ReferenceId": "PCI DSS v3.2.1",
- "Identifiers": [
- "2.6",
- "8.3.1",
- "10.8",
- "11.3",
- "A3.2.1",
- "A3.3.1"
- ]
- },
- {
- "ReferenceId": "PCI DSS v4.0",
- "Identifiers": [
- "A1.1.1",
- "A1.1.2",
- "A1.1.3"
- ]
- }
- ]
- }
- ],
- "Checks": [
- "cs_kubernetes_network_policy_enabled",
- "cs_kubernetes_private_cluster_enabled",
- "ecs_instance_no_legacy_network"
- ]
- },
- {
- "Id": "IVS-07",
- "Description": "Use secure and encrypted communication channels when migrating servers, services, applications, or data to cloud environments. Such channels must include only up-to-date and approved protocols.",
- "Name": "Migration to Cloud Environments",
- "Attributes": [
- {
- "Section": "Infrastructure & Virtualization Security",
- "CCMLite": "Yes",
- "IaaS": "Shared",
- "PaaS": "Shared",
- "SaaS": "Shared",
- "ScopeApplicability": [
- {
- "ReferenceId": "AICPA TSC 2017",
- "Identifiers": [
- "CC6.1",
- "CC6.7"
- ]
- },
- {
- "ReferenceId": "CCM v3.0.1",
- "Identifiers": [
- "IVS-10"
- ]
- },
- {
- "ReferenceId": "ENX ISA v6.0",
- "Identifiers": [
- "5.1.2"
- ]
- },
- {
- "ReferenceId": "ISF SOGP 2022",
- "Identifiers": [
- "IM1.4",
- "IM1.4",
- "NC1.4",
- "SC2.1"
- ]
- },
- {
- "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019",
- "Identifiers": [
- "27001: A.13.1.1",
- "27002: 13.1.1",
- "27017: 13.1.1",
- "27018: 13.1.1",
- "27001: A.13.1.2",
- "27002: 13.1.2",
- "27017: 13.1.2",
- "27018: 13.1.2",
- "27001: A.13.1.3",
- "27002: 13.1.3",
- "27017: 13.1.3",
- "27018: 13.1.3",
- "27001: A.13.2.1",
- "27002: 13.2.1",
- "27017: 13.2.1",
- "27018: 13.2.1",
- "27001: A.13.2.2",
- "27002: 13.2.2",
- "27017: 13.2.2",
- "27018: 13.2.2",
- "27001: A.13.2.3",
- "27002: 13.2.3",
- "27017: 13.2.3",
- "27018: 13.2.3",
- "27001: A.13.2.4",
- "27002: 13.2.4",
- "27017: 13.2.4",
- "27018: 13.2.4"
- ]
- },
- {
- "ReferenceId": "ISO/IEC 27001:2022, 27002:2022",
- "Identifiers": [
- "27001: A.5.14",
- "27001: A.8.20",
- "27001: A.8.24",
- "27002: 8.20 (e)",
- "27002: 8.24 Guidance (b,f), other information (a)"
- ]
- },
- {
- "ReferenceId": "NIST 800-53 rev 5",
- "Identifiers": [
- "AC-17",
- "AC-20",
- "SC-7",
- "SC-7(28)",
- "SC-8",
- "SC-8(1)",
- "SC-12",
- "SC-23",
- "SC-29",
- "SI-7",
- "SI-7(1)-(3)",
- "SI-7(5)-(10)",
- "SI-7(12)"
- ]
- },
- {
- "ReferenceId": "NIST CSF v1.1",
- "Identifiers": [
- "PR.DS-2",
- "PR.PT-4"
- ]
- },
- {
- "ReferenceId": "NIST CSF v2.0",
- "Identifiers": [
- "PR.DS-02"
- ]
- },
- {
- "ReferenceId": "PCI DSS v4.0",
- "Identifiers": [
- "4.2.1"
- ]
- }
- ]
- }
- ],
- "Checks": [
- "rds_instance_ssl_enabled"
- ]
- },
- {
- "Id": "IVS-09",
- "Description": "Define, implement and evaluate processes, procedures and defense-in-depth techniques for protection, detection, and timely response to network-based attacks.",
- "Name": "Network Defense",
- "Attributes": [
- {
- "Section": "Infrastructure & Virtualization Security",
- "CCMLite": "Yes",
- "IaaS": "Shared",
- "PaaS": "Shared",
- "SaaS": "CSP-Owned",
- "ScopeApplicability": [
- {
- "ReferenceId": "AICPA TSC 2017",
- "Identifiers": [
- "CC6.6",
- "CC6.8",
- "CC7.1",
- "CC7.2",
- "CC7.5"
- ]
- },
- {
- "ReferenceId": "CCM v3.0.1",
- "Identifiers": [
- "IVS-13"
- ]
- },
- {
- "ReferenceId": "CIS v8.0",
- "Identifiers": [
- "13.3",
- "13.8"
- ]
- },
- {
- "ReferenceId": "ENX ISA v6.0",
- "Identifiers": [
- "5.2.3",
- "5.2.4",
- "5.2.5",
- "5.2.7",
- "5.3.2"
- ]
- },
- {
- "ReferenceId": "ISF SOGP 2022",
- "Identifiers": [
- "NC1.1"
- ]
- },
- {
- "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019",
- "Identifiers": [
- "27001: 6.1",
- "27001: 6.2",
- "27001: A.14.1.2",
- "27002: 14.1.2",
- "27017: 14.1.2",
- "27001: A.11.1.4",
- "27002: 11.1.4",
- "27017: 11.1.4",
- "27018: 16.1.1"
- ]
- },
- {
- "ReferenceId": "ISO/IEC 27001:2022, 27002:2022",
- "Identifiers": [
- "27001: 6.1",
- "27001: 6.2",
- "27001: A.5.24",
- "27001: A.5.26",
- "27001: A.8.8",
- "27001: A.8.16",
- "27001: A.8.20",
- "27001: A.8.21",
- "27001: A.8.22",
- "27001: A.8.26",
- "27002: 8.8 (i)"
- ]
- },
- {
- "ReferenceId": "NIST 800-53 rev 5",
- "Identifiers": [
- "PL-8",
- "PL-8(1)",
- "SC-5",
- "SC-5(1)",
- "SC-5(3)",
- "SC-7",
- "SC-7(13)"
- ]
- },
- {
- "ReferenceId": "NIST CSF v1.1",
- "Identifiers": [
- "DE.AE-1",
- "DE.DP-1",
- "DE.CM-1",
- "DE.CM-7",
- "PR.AC-5",
- "RS.MI-2",
- "PR.DS-2",
- "RS.RP-1"
- ]
- },
- {
- "ReferenceId": "NIST CSF v2.0",
- "Identifiers": [
- "ID.AM-03",
- "DE.CM-01",
- "PR.IR-01",
- "RS.MA-01",
- "RS.MI-01",
- "RS.MI-02"
- ]
- },
- {
- "ReferenceId": "PCI DSS v3.2.1",
- "Identifiers": [
- "6.6",
- "1.1",
- "1.2",
- "1.3",
- "1.5",
- "12.10.5"
- ]
- },
- {
- "ReferenceId": "PCI DSS v4.0",
- "Identifiers": [
- "1.1.1",
- "1.3.1",
- "1.3.2",
- "1.3.3",
- "1.4.1",
- "1.4.2",
- "1.4.3",
- "1.4.4",
- "1.4.5",
- "1.5.1",
- "12.10.1"
- ]
- }
- ]
- }
- ],
- "Checks": [
- "securitycenter_advanced_or_enterprise_edition",
- "sls_cloud_firewall_changes_alert_enabled"
- ]
- },
- {
- "Id": "LOG-02",
- "Description": "Define, implement and evaluate processes, procedures and technical measures to ensure the security and retention of audit logs.",
- "Name": "Audit Logs Protection",
- "Attributes": [
- {
- "Section": "Logging and Monitoring",
- "CCMLite": "No",
- "IaaS": "Shared",
- "PaaS": "Shared",
- "SaaS": "Shared",
- "ScopeApplicability": [
- {
- "ReferenceId": "CCM v3.0.1",
- "Identifiers": [
- "IVS-01"
- ]
- },
- {
- "ReferenceId": "CIS v8.0",
- "Identifiers": [
- "8.1",
- "8.9",
- "8.1"
- ]
- },
- {
- "ReferenceId": "ENX ISA v6.0",
- "Identifiers": [
- "3.1.3",
- "5.1.2",
- "5.2.4"
- ]
- },
- {
- "ReferenceId": "ISF SOGP 2022",
- "Identifiers": [
- "TM1.2"
- ]
- },
- {
- "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019",
- "Identifiers": [
- "27001: A.18.1.3",
- "27002: 18.1.3"
- ]
- },
- {
- "ReferenceId": "ISO/IEC 27001:2022, 27002:2022",
- "Identifiers": [
- "27001: A.5.28",
- "27001: A.5.33",
- "27001: A.8.15"
- ]
- },
- {
- "ReferenceId": "NIST 800-53 rev 5",
- "Identifiers": [
- "AU-4",
- "AU-11"
- ]
- },
- {
- "ReferenceId": "NIST CSF v1.1",
- "Identifiers": [
- "PR.AC-4",
- "PR.IP-4",
- "PR.IP-6",
- "PR.PT-1",
- "PR.DS-1"
- ]
- },
- {
- "ReferenceId": "NIST CSF v2.0",
- "Identifiers": [
- "PR.AA-05",
- "PR.DS-01",
- "PR.DS-02",
- "ID.AM-08",
- "PR.DS-11",
- "PR.PS-04"
- ]
- },
- {
- "ReferenceId": "PCI DSS v3.2.1",
- "Identifiers": [
- "10.5",
- "10.7"
- ]
- },
- {
- "ReferenceId": "PCI DSS v4.0",
- "Identifiers": [
- "10.3.1",
- "10.3.2",
- "10.3.3",
- "10.3.4",
- "10.5.1"
- ]
- }
- ]
- }
- ],
- "Checks": [
- "actiontrail_oss_bucket_not_publicly_accessible",
- "sls_logstore_retention_period"
- ]
- },
- {
- "Id": "LOG-03",
- "Description": "Identify and monitor security-related events within applications and the underlying infrastructure. Define and implement a system to generate alerts to responsible stakeholders based on such events and corresponding metrics.",
- "Name": "Security Monitoring and Alerting",
- "Attributes": [
- {
- "Section": "Logging and Monitoring",
- "CCMLite": "No",
- "IaaS": "Shared",
- "PaaS": "Shared",
- "SaaS": "Shared",
- "ScopeApplicability": [
- {
- "ReferenceId": "AICPA TSC 2017",
- "Identifiers": [
- "CC6.8",
- "CC7.3"
- ]
- },
- {
- "ReferenceId": "CCM v3.0.1",
- "Identifiers": [
- "SEF-03",
- "SEF-05"
- ]
- },
- {
- "ReferenceId": "CIS v8.0",
- "Identifiers": [
- "8.5"
- ]
- },
- {
- "ReferenceId": "ENX ISA v6.0",
- "Identifiers": [
- "5.2.4",
- "5.2.7",
- "1.6.1"
- ]
- },
- {
- "ReferenceId": "ISF SOGP 2022",
- "Identifiers": [
- "TM1.2",
- "TM1.3"
- ]
- },
- {
- "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019",
- "Identifiers": [
- "27001: A.12.4.1",
- "27002: 12.4.1"
- ]
- },
- {
- "ReferenceId": "ISO/IEC 27001:2022, 27002:2022",
- "Identifiers": [
- "27001: A.5.28",
- "27001: A.8.15"
- ]
- },
- {
- "ReferenceId": "NIST 800-53 rev 5",
- "Identifiers": [
- "AU-5",
- "AU-5(2)",
- "AU-13"
- ]
- },
- {
- "ReferenceId": "NIST CSF v1.1",
- "Identifiers": [
- "DE.AE-1",
- "DE.AE-2",
- "DE.AE-3",
- "DE.AE-5",
- "DE.CM-1",
- "DE.CM-2",
- "DE.CM-3",
- "DE.CM-4",
- "DE.CM-5",
- "DE.CM-6",
- "DE.CM-7",
- "DE.DP-1",
- "DE.DP-4",
- "DE.AE-4"
- ]
- },
- {
- "ReferenceId": "NIST CSF v2.0",
- "Identifiers": [
- "PR.PS-04",
- "DE.AE-02",
- "DE.AE-03",
- "DE.AE-04",
- "DE.AE-06",
- "DE.AE-07",
- "DE.AE-08",
- "DE.CM-01",
- "DE.CM-02",
- "DE.CM-03",
- "DE.CM-06",
- "DE.CM-09"
- ]
- },
- {
- "ReferenceId": "PCI DSS v3.2.1",
- "Identifiers": [
- "10.2"
- ]
- },
- {
- "ReferenceId": "PCI DSS v4.0",
- "Identifiers": [
- "10.2.1",
- "10.2.2",
- "10.4.1.1",
- "10.4.2.1",
- "10.4.3"
- ]
- }
- ]
- }
- ],
- "Checks": [
- "securitycenter_advanced_or_enterprise_edition",
- "securitycenter_notification_enabled_high_risk",
- "sls_unauthorized_api_calls_alert_enabled",
- "sls_root_account_usage_alert_enabled",
- "sls_management_console_signin_without_mfa_alert_enabled"
- ]
- },
- {
- "Id": "LOG-04",
- "Description": "Restrict audit logs access to authorized personnel and maintain records that provide unique access accountability.",
- "Name": "Audit Logs Access and Accountability",
- "Attributes": [
- {
- "Section": "Logging and Monitoring",
- "CCMLite": "Yes",
- "IaaS": "Shared",
- "PaaS": "Shared",
- "SaaS": "Shared",
- "ScopeApplicability": [
- {
- "ReferenceId": "CCM v3.0.1",
- "Identifiers": [
- "IVS-01"
- ]
- },
- {
- "ReferenceId": "CIS v8.0",
- "Identifiers": [
- "3.14"
- ]
- },
- {
- "ReferenceId": "ENX ISA v6.0",
- "Identifiers": [
- "3.1.1",
- "4.1.2",
- "4.1.3",
- "4.2.1",
- "5.2.4"
- ]
- },
- {
- "ReferenceId": "ISF SOGP 2022",
- "Identifiers": [
- "TM1.2"
- ]
- },
- {
- "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019",
- "Identifiers": [
- "27001: A.12.4.2",
- "27001: A.12.4.1",
- "27002: 12.4.2"
- ]
- },
- {
- "ReferenceId": "ISO/IEC 27001:2022, 27002:2022",
- "Identifiers": [
- "27001: A.5.33",
- "27001: A.8.15"
- ]
- },
- {
- "ReferenceId": "NIST 800-53 rev 5",
- "Identifiers": [
- "AU-9",
- "AU-9(4)",
- "AU-9(6)",
- "AU-10"
- ]
- },
- {
- "ReferenceId": "NIST CSF v1.1",
- "Identifiers": [
- "PR.AC-1",
- "PR.AC-4"
- ]
- },
- {
- "ReferenceId": "NIST CSF v2.0",
- "Identifiers": [
- "PR.AA-05",
- "PR.PS-04"
- ]
- },
- {
- "ReferenceId": "PCI DSS v3.2.1",
- "Identifiers": [
- "10.1",
- "10.2.1",
- "10.2.3",
- "10.5.1",
- "10.5.2"
- ]
- },
- {
- "ReferenceId": "PCI DSS v4.0",
- "Identifiers": [
- "10.2.1.3",
- "10.3.1"
- ]
- }
- ]
- }
- ],
- "Checks": [
- "actiontrail_oss_bucket_not_publicly_accessible"
- ]
- },
- {
- "Id": "LOG-05",
- "Description": "Monitor security audit logs to detect activity outside of typical or expected patterns. Establish and follow a defined process to review and take appropriate and timely actions on detected anomalies.",
- "Name": "Audit Logs Monitoring and Response",
- "Attributes": [
- {
- "Section": "Logging and Monitoring",
- "CCMLite": "Yes",
- "IaaS": "Shared",
- "PaaS": "Shared",
- "SaaS": "Shared",
- "ScopeApplicability": [
- {
- "ReferenceId": "AICPA TSC 2017",
- "Identifiers": [
- "CC7.2"
- ]
- },
- {
- "ReferenceId": "CIS v8.0",
- "Identifiers": [
- "8.8",
- "8.11"
- ]
- },
- {
- "ReferenceId": "ENX ISA v6.0",
- "Identifiers": [
- "1.6.1",
- "1.6.2",
- "5.2.4"
- ]
- },
- {
- "ReferenceId": "ISF SOGP 2022",
- "Identifiers": [
- "TM1.3"
- ]
- },
- {
- "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019",
- "Identifiers": [
- "27001: A.12.4.3",
- "27002: 12.4.3"
- ]
- },
- {
- "ReferenceId": "ISO/IEC 27001:2022, 27002:2022",
- "Identifiers": [
- "27001: A.8.15",
- "27001: A.8.16"
- ]
- },
- {
- "ReferenceId": "NIST 800-53 rev 5",
- "Identifiers": [
- "AU-6",
- "AU-6(1)",
- "AU-6(5)"
- ]
- },
- {
- "ReferenceId": "NIST CSF v1.1",
- "Identifiers": [
- "DE.AE-3",
- "PR.PT-1",
- "RS.AN-1",
- "RS.CO-1.",
- "DE.AE-1",
- "DE.AE-5",
- "DE.DP-4"
- ]
- },
- {
- "ReferenceId": "NIST CSF v2.0",
- "Identifiers": [
- "ID.AM-03",
- "PR.PS-04",
- "DE.AE-02",
- "DE.AE-03",
- "DE.AE-06",
- "DE.AE-07",
- "DE.AE-08",
- "DE.CM-01",
- "DE.CM-02",
- "DE.CM-03",
- "DE.CM-06",
- "DE.CM-09"
- ]
- },
- {
- "ReferenceId": "PCI DSS v3.2.1",
- "Identifiers": [
- "10.6",
- "10.6.1"
- ]
- },
- {
- "ReferenceId": "PCI DSS v4.0",
- "Identifiers": [
- "10.4.1.1",
- "10.4.2.1"
- ]
- }
- ]
- }
- ],
- "Checks": [
- "sls_unauthorized_api_calls_alert_enabled",
- "sls_root_account_usage_alert_enabled",
- "sls_management_console_signin_without_mfa_alert_enabled",
- "sls_ram_role_changes_alert_enabled",
- "sls_security_group_changes_alert_enabled",
- "sls_vpc_changes_alert_enabled",
- "sls_vpc_network_route_changes_alert_enabled",
- "sls_management_console_authentication_failures_alert_enabled",
- "sls_customer_created_cmk_changes_alert_enabled",
- "sls_oss_bucket_policy_changes_alert_enabled",
- "sls_oss_permission_changes_alert_enabled",
- "sls_cloud_firewall_changes_alert_enabled",
- "sls_rds_instance_configuration_changes_alert_enabled"
- ]
- },
- {
- "Id": "LOG-07",
- "Description": "Establish, document and implement which information meta/data system events should be logged. Review and update the scope at least annually or whenever there is a change in the threat environment.",
- "Name": "Logging Scope",
- "Attributes": [
- {
- "Section": "Logging and Monitoring",
- "CCMLite": "No",
- "IaaS": "Shared",
- "PaaS": "Shared",
- "SaaS": "Shared",
- "ScopeApplicability": [
- {
- "ReferenceId": "AICPA TSC 2017",
- "Identifiers": [
- "CC7.2"
- ]
- },
- {
- "ReferenceId": "CIS v8.0",
- "Identifiers": [
- "8.1"
- ]
- },
- {
- "ReferenceId": "ENX ISA v6.0",
- "Identifiers": [
- "5.2.4"
- ]
- },
- {
- "ReferenceId": "ISF SOGP 2022",
- "Identifiers": [
- "TM1.2"
- ]
- },
- {
- "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019",
- "Identifiers": [
- "27001: 7.5.3",
- "27001: A.12.4.1",
- "27002: 12.4.1",
- "27017: 12.4.1"
- ]
- },
- {
- "ReferenceId": "ISO/IEC 27001:2022, 27002:2022",
- "Identifiers": [
- "27001: 7.5.3",
- "27001: A.8.15"
- ]
- },
- {
- "ReferenceId": "NIST 800-53 rev 5",
- "Identifiers": [
- "AU-1",
- "AU-14",
- "AU-16"
- ]
- },
- {
- "ReferenceId": "NIST CSF v1.1",
- "Identifiers": [
- "ID.SC-3",
- "ID.SC-4",
- "PR.PT-1",
- "ID.GV-1"
- ]
- },
- {
- "ReferenceId": "NIST CSF v2.0",
- "Identifiers": [
- "PR.PS-04"
- ]
- },
- {
- "ReferenceId": "PCI DSS v3.2.1",
- "Identifiers": [
- "10.3"
- ]
- },
- {
- "ReferenceId": "PCI DSS v4.0",
- "Identifiers": [
- "10.2.1",
- "10.2.2"
- ]
- }
- ]
- }
- ],
- "Checks": [
- "actiontrail_multi_region_enabled",
- "vpc_flow_logs_enabled"
- ]
- },
- {
- "Id": "LOG-08",
- "Description": "Generate audit records containing relevant security information.",
- "Name": "Log Records",
- "Attributes": [
- {
- "Section": "Logging and Monitoring",
- "CCMLite": "No",
- "IaaS": "Shared",
- "PaaS": "Shared",
- "SaaS": "Shared",
- "ScopeApplicability": [
- {
- "ReferenceId": "AICPA TSC 2017",
- "Identifiers": [
- "CC7.2"
- ]
- },
- {
- "ReferenceId": "CIS v8.0",
- "Identifiers": [
- "8.2"
- ]
- },
- {
- "ReferenceId": "ENX ISA v6.0",
- "Identifiers": [
- "5.2.4"
- ]
- },
- {
- "ReferenceId": "ISF SOGP 2022",
- "Identifiers": [
- "TM1.2"
- ]
- },
- {
- "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019",
- "Identifiers": [
- "27001: A.12.4.1",
- "27002: 12.4.1",
- "27017: 12.4.1"
- ]
- },
- {
- "ReferenceId": "ISO/IEC 27001:2022, 27002:2022",
- "Identifiers": [
- "27001: A.8.15"
- ]
- },
- {
- "ReferenceId": "NIST 800-53 rev 5",
- "Identifiers": [
- "AU-3",
- "AU-3(1)",
- "AU-3(3)",
- "AU-6",
- "AU-6(8)",
- "AU-12",
- "AU-12(1)",
- "AU-12(2)",
- "AU-12(3)"
- ]
- },
- {
- "ReferenceId": "NIST CSF v1.1",
- "Identifiers": [
- "PR.PT-1",
- "DE.AE-3",
- "DE.CM-1",
- "DE.CM-2",
- "DE.CM-3",
- "DE.CM-6",
- "DE.CM-7"
- ]
- },
- {
- "ReferenceId": "NIST CSF v2.0",
- "Identifiers": [
- "PR.PS-04",
- "DE.CM-01",
- "DE.CM-02",
- "DE.CM-03",
- "DE.CM-06",
- "DE.CM-09"
- ]
- },
- {
- "ReferenceId": "PCI DSS v3.2.1",
- "Identifiers": [
- "10.3"
- ]
- },
- {
- "ReferenceId": "PCI DSS v4.0",
- "Identifiers": [
- "10.2.2"
- ]
- }
- ]
- }
- ],
- "Checks": [
- "actiontrail_multi_region_enabled",
- "vpc_flow_logs_enabled",
- "oss_bucket_logging_enabled",
- "rds_instance_sql_audit_enabled",
- "cs_kubernetes_log_service_enabled",
- "rds_instance_postgresql_log_connections_enabled",
- "rds_instance_postgresql_log_disconnections_enabled",
- "rds_instance_postgresql_log_duration_enabled"
- ]
- },
- {
- "Id": "LOG-09",
- "Description": "The information system protects audit records from unauthorized access, modification, and deletion.",
- "Name": "Log Protection",
- "Attributes": [
- {
- "Section": "Logging and Monitoring",
- "CCMLite": "No",
- "IaaS": "Shared",
- "PaaS": "Shared",
- "SaaS": "Shared",
- "ScopeApplicability": [
- {
- "ReferenceId": "CCM v3.0.1",
- "Identifiers": [
- "GRM-04",
- "IVS-01"
- ]
- },
- {
- "ReferenceId": "ENX ISA v6.0",
- "Identifiers": [
- "5.2.4",
- "4.2.1"
- ]
- },
- {
- "ReferenceId": "ISF SOGP 2022",
- "Identifiers": [
- "TM1.2"
- ]
- },
- {
- "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019",
- "Identifiers": [
- "27001: A.12.4.2",
- "27002: 12.4.2"
- ]
- },
- {
- "ReferenceId": "ISO/IEC 27001:2022, 27002:2022",
- "Identifiers": [
- "27001: A.8.15"
- ]
- },
- {
- "ReferenceId": "NIST 800-53 rev 5",
- "Identifiers": [
- "AU-9",
- "AU-9(2)",
- "AU-9(3)",
- "AU-9(4)",
- "AU-12(3)",
- "AU-12(3)"
- ]
- },
- {
- "ReferenceId": "NIST CSF v1.1",
- "Identifiers": [
- "PR.AC-4",
- "PR.IP-4",
- "PR.IP-6",
- "PR.PT-1",
- "PR.DS-1",
- "PR.DS-6"
- ]
- },
- {
- "ReferenceId": "NIST CSF v2.0",
- "Identifiers": [
- "PR.AA-05",
- "PR.DS-01",
- "PR.DS-02",
- "PR.DS-11"
- ]
- },
- {
- "ReferenceId": "PCI DSS v3.2.1",
- "Identifiers": [
- "10.5",
- "10.5.1",
- "10.5.2"
- ]
- },
- {
- "ReferenceId": "PCI DSS v4.0",
- "Identifiers": [
- "10.3.1",
- "10.3.2",
- "10.3.3",
- "10.3.4"
- ]
- }
- ]
- }
- ],
- "Checks": [
- "actiontrail_oss_bucket_not_publicly_accessible"
- ]
- },
- {
- "Id": "LOG-10",
- "Description": "Establish and maintain a monitoring and internal reporting capability over the operations of cryptographic, encryption and key management policies, processes, procedures, and controls.",
- "Name": "Encryption Monitoring and Reporting",
- "Attributes": [
- {
- "Section": "Logging and Monitoring",
- "CCMLite": "No",
- "IaaS": "Shared",
- "PaaS": "Shared",
- "SaaS": "Shared",
- "ScopeApplicability": [
- {
- "ReferenceId": "AICPA TSC 2017",
- "Identifiers": [
- "CC6.1",
- "CC7.2"
- ]
- },
- {
- "ReferenceId": "CCM v3.0.1",
- "Identifiers": [
- "EKM-02",
- "EKM-03"
- ]
- },
- {
- "ReferenceId": "ENX ISA v6.0",
- "Identifiers": [
- "4.2.1",
- "5.1.1",
- "5.1.2"
- ]
- },
- {
- "ReferenceId": "ISF SOGP 2022",
- "Identifiers": [
- "TS2.2"
- ]
- },
- {
- "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019",
- "Identifiers": [
- "27001: A.10.1",
- "27002: 10.1",
- "27001: A.10.1.2",
- "27017: 10.1.2"
- ]
- },
- {
- "ReferenceId": "ISO/IEC 27001:2022, 27002:2022",
- "Identifiers": [
- "27001: A.8.24"
- ]
- },
- {
- "ReferenceId": "NIST 800-53 rev 5",
- "Identifiers": [
- "AU-1",
- "AU-9",
- "AU-9(3)"
- ]
- },
- {
- "ReferenceId": "NIST CSF v1.1",
- "Identifiers": [
- "ID.GV-1",
- "PR.PT-1"
- ]
- },
- {
- "ReferenceId": "NIST CSF v2.0",
- "Identifiers": [
- "PR.PS-04",
- "DE.CM-09"
- ]
- },
- {
- "ReferenceId": "PCI DSS v4.0",
- "Identifiers": [
- "10.1.1",
- "10.2.1",
- "10.4.1"
- ]
- }
- ]
- }
- ],
- "Checks": [
- "sls_customer_created_cmk_changes_alert_enabled"
- ]
- },
- {
- "Id": "LOG-11",
- "Description": "Log and monitor key lifecycle management events to enable auditing and reporting on usage of cryptographic keys.",
- "Name": "Transaction/Activity Logging",
- "Attributes": [
- {
- "Section": "Logging and Monitoring",
- "CCMLite": "No",
- "IaaS": "Shared",
- "PaaS": "Shared",
- "SaaS": "Shared",
- "ScopeApplicability": [
- {
- "ReferenceId": "AICPA TSC 2017",
- "Identifiers": [
- "CC6.1",
- "CC7.2"
- ]
- },
- {
- "ReferenceId": "CCM v3.0.1",
- "Identifiers": [
- "EKM-02"
- ]
- },
- {
- "ReferenceId": "ENX ISA v6.0",
- "Identifiers": [
- "5.1.1"
- ]
- },
- {
- "ReferenceId": "ISF SOGP 2022",
- "Identifiers": [
- "TS2.2"
- ]
- },
- {
- "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019",
- "Identifiers": [
- "27001: A.10.1.2",
- "27017: 10.1.2"
- ]
- },
- {
- "ReferenceId": "ISO/IEC 27001:2022, 27002:2022",
- "Identifiers": [
- "27001: A.8.24"
- ]
- },
- {
- "ReferenceId": "NIST 800-53 rev 5",
- "Identifiers": [
- "AU-9",
- "AU-9(3)"
- ]
- },
- {
- "ReferenceId": "NIST CSF v1.1",
- "Identifiers": [
- "PR.PT-1",
- "DE.AE-3"
- ]
- },
- {
- "ReferenceId": "NIST CSF v2.0",
- "Identifiers": [
- "PR.PS-04",
- "DE.CM-09"
- ]
- }
- ]
- }
- ],
- "Checks": [
- "actiontrail_multi_region_enabled"
- ]
- },
- {
- "Id": "LOG-13",
- "Description": "Define, implement and evaluate processes, procedures and technical measures for the reporting of anomalies and failures of the monitoring system and provide immediate notification to the accountable party.",
- "Name": "Failures and Anomalies Reporting",
- "Attributes": [
- {
- "Section": "Logging and Monitoring",
- "CCMLite": "No",
- "IaaS": "Shared",
- "PaaS": "Shared",
- "SaaS": "Shared",
- "ScopeApplicability": [
- {
- "ReferenceId": "AICPA TSC 2017",
- "Identifiers": [
- "CC2.3",
- "CC7.3"
- ]
- },
- {
- "ReferenceId": "CCM v3.0.1",
- "Identifiers": [
- "SEF-03"
- ]
- },
- {
- "ReferenceId": "ENX ISA v6.0",
- "Identifiers": [
- "1.6.1",
- "5.2.4"
- ]
- },
- {
- "ReferenceId": "ISF SOGP 2022",
- "Identifiers": [
- "TM1.3"
- ]
- },
- {
- "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019",
- "Identifiers": [
- "27001: A.16.1.1",
- "27002: 16.1.1",
- "27001: A.16.1.2",
- "27017: 16.1.2"
- ]
- },
- {
- "ReferenceId": "ISO/IEC 27001:2022, 27002:2022",
- "Identifiers": [
- "27001: A.5.24",
- "27001: A.6.8",
- "27002: 6.8 (g)"
- ]
- },
- {
- "ReferenceId": "NIST 800-53 rev 5",
- "Identifiers": [
- "AU-5",
- "AU-5(2)",
- "AU-6",
- "AU-6(3)",
- "AU-6(4)",
- "AU-6(5)",
- "AU-16"
- ]
- },
- {
- "ReferenceId": "NIST CSF v1.1",
- "Identifiers": [
- "DE.DP-3",
- "DE.DP-4"
- ]
- },
- {
- "ReferenceId": "NIST CSF v2.0",
- "Identifiers": [
- "PR.PS-04",
- "DE.AE-06"
- ]
- },
- {
- "ReferenceId": "PCI DSS v3.2.1",
- "Identifiers": [
- "10.6"
- ]
- },
- {
- "ReferenceId": "PCI DSS v4.0",
- "Identifiers": [
- "10.4.3",
- "10.7.1",
- "10.7.2",
- "10.7.3"
- ]
- }
- ]
- }
- ],
- "Checks": [
- "securitycenter_advanced_or_enterprise_edition",
- "securitycenter_notification_enabled_high_risk"
- ]
- },
- {
- "Id": "SEF-03",
- "Description": "'Establish, document, approve, communicate, apply, evaluate and maintain a security incident response plan, which includes but is not limited to: relevant internal departments, impacted CSCs, and other business critical relationships (such as supply-chain) that may be impacted.'",
- "Name": "Incident Response Plans",
- "Attributes": [
- {
- "Section": "Security Incident Management, E-Discovery, & Cloud Forensics",
- "CCMLite": "Yes",
- "IaaS": "Shared",
- "PaaS": "Shared",
- "SaaS": "Shared",
- "ScopeApplicability": [
- {
- "ReferenceId": "AICPA TSC 2017",
- "Identifiers": [
- "CC7.2",
- "CC7.3",
- "CC7.4"
- ]
- },
- {
- "ReferenceId": "CCM v3.0.1",
- "Identifiers": [
- "BCR-02"
- ]
- },
- {
- "ReferenceId": "CIS v8.0",
- "Identifiers": [
- "17.2",
- "17.4"
- ]
- },
- {
- "ReferenceId": "ENX ISA v6.0",
- "Identifiers": [
- "1.6.2",
- "1.6.3"
- ]
- },
- {
- "ReferenceId": "ISF SOGP 2022",
- "Identifiers": [
- "TM2.2"
- ]
- },
- {
- "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019",
- "Identifiers": [
- "27001: 5.2",
- "27001: 7.3",
- "27001: 7.4",
- "27001: 7.5",
- "27001: A.16.1.5",
- "27002: 16.1.5",
- "27017: 16.1.5",
- "27017: CLD.12.1.5",
- "27018: 16.1.5"
- ]
- },
- {
- "ReferenceId": "ISO/IEC 27001:2022, 27002:2022",
- "Identifiers": [
- "27001: 5.2",
- "27001: 7.3",
- "27001: 7.4",
- "27001: 7.5",
- "27001: A.5.26",
- "27002: 5.26 (e,f)"
- ]
- },
- {
- "ReferenceId": "NIST 800-53 rev 5",
- "Identifiers": [
- "IR-1",
- "IR-2",
- "IR-2(1)-(3)",
- "IR-3",
- "IR-3(1)-(3)",
- "IR-4",
- "IR-4(1)-(15)",
- "IR-5",
- "IR-5(1)",
- "IR-6",
- "IR-6(1)-(3)",
- "IR-7",
- "IR-7(1)",
- "IR-7(2)",
- "IR-8",
- "IR-8(1)",
- "IR-9",
- "IR-9(1)-(4)",
- "PM-12"
- ]
- },
- {
- "ReferenceId": "NIST CSF v1.1",
- "Identifiers": [
- "RS.CO-1",
- "RS.CO-4",
- "ID.AM-6",
- "ID.GV-2",
- "ID.SC-5",
- "PR.IP-9",
- "PR.IP10"
- ]
- },
- {
- "ReferenceId": "NIST CSF v2.0",
- "Identifiers": [
- "PR.AT-01",
- "PR.AT-02",
- "RS.MA-01",
- "GV.SC-08",
- "ID.IM-02",
- "ID.IM-04",
- "RC.RP-01"
- ]
- },
- {
- "ReferenceId": "PCI DSS v3.2.1",
- "Identifiers": [
- "12.1",
- "12.10.1"
- ]
- },
- {
- "ReferenceId": "PCI DSS v4.0",
- "Identifiers": [
- "12.10.1",
- "12.10.5"
- ]
- }
- ]
- }
- ],
- "Checks": []
- },
- {
- "Id": "SEF-06",
- "Description": "Define, implement and evaluate processes, procedures and technical measures supporting business processes to triage security-related events.",
- "Name": "Event Triage Processes",
- "Attributes": [
- {
- "Section": "Security Incident Management, E-Discovery, & Cloud Forensics",
- "CCMLite": "No",
- "IaaS": "Shared",
- "PaaS": "Shared",
- "SaaS": "Shared",
- "ScopeApplicability": [
- {
- "ReferenceId": "AICPA TSC 2017",
- "Identifiers": [
- "CC7.3"
- ]
- },
- {
- "ReferenceId": "CCM v3.0.1",
- "Identifiers": [
- "SEF-02"
- ]
- },
- {
- "ReferenceId": "ENX ISA v6.0",
- "Identifiers": [
- "1.6.2"
- ]
- },
- {
- "ReferenceId": "ISF SOGP 2022",
- "Identifiers": [
- "TM1.3"
- ]
- },
- {
- "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019",
- "Identifiers": [
- "27001: A.16.1.4",
- "27002: 16.1.4",
- "27017: 16.1.4",
- "27018: 16.1.4",
- "27001: A.16.1.5",
- "27002: 16.1.5",
- "27017: 16.1.5",
- "27018: 16.1.5"
- ]
- },
- {
- "ReferenceId": "ISO/IEC 27001:2022, 27002:2022",
- "Identifiers": [
- "27001: A.5.25"
- ]
- },
- {
- "ReferenceId": "NIST 800-53 rev 5",
- "Identifiers": [
- "CA-7",
- "CA-7(3)",
- "CA-7(4)",
- "CA-7(5)",
- "CA-7(6)",
- "IR-4",
- "IR-4(1)",
- "IR-4(3)",
- "IR-4(4)"
- ]
- },
- {
- "ReferenceId": "NIST CSF v1.1",
- "Identifiers": [
- "DE.AE-1",
- "DE.AE-2",
- "DE.AE-4",
- "RS.RP-1",
- "RS.AN-2"
- ]
- },
- {
- "ReferenceId": "NIST CSF v2.0",
- "Identifiers": [
- "RS.MA-02",
- "RS.MA-03",
- "RS.AN-03",
- "DE.AE-02",
- "DE.AE-04",
- "DE.AE-06",
- "DE.AE-07",
- "DE.AE-08",
- "RS.MI-02",
- "RC.RP-02"
- ]
- },
- {
- "ReferenceId": "PCI DSS v3.2.1",
- "Identifiers": [
- "12.5.2"
- ]
- },
- {
- "ReferenceId": "PCI DSS v4.0",
- "Identifiers": [
- "12.10.1"
- ]
- }
- ]
- }
- ],
- "Checks": [
- "securitycenter_advanced_or_enterprise_edition"
- ]
- },
- {
- "Id": "SEF-08",
- "Description": "Maintain points of contact for applicable regulation authorities, national and local law enforcement, and other legal jurisdictional authorities.",
- "Name": "Points of Contact Maintenance",
- "Attributes": [
- {
- "Section": "Security Incident Management, E-Discovery, & Cloud Forensics",
- "CCMLite": "No",
- "IaaS": "Shared",
- "PaaS": "Shared",
- "SaaS": "Shared",
- "ScopeApplicability": [
- {
- "ReferenceId": "AICPA TSC 2017",
- "Identifiers": [
- "CC2.3"
- ]
- },
- {
- "ReferenceId": "CCM v3.0.1",
- "Identifiers": [
- "SEF-01"
- ]
- },
- {
- "ReferenceId": "CIS v8.0",
- "Identifiers": [
- "17.2"
- ]
- },
- {
- "ReferenceId": "ENX ISA v6.0",
- "Identifiers": [
- "1.6.2",
- "1.6.3"
- ]
- },
- {
- "ReferenceId": "ISF SOGP 2022",
- "Identifiers": [
- "SM2.2"
- ]
- },
- {
- "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019",
- "Identifiers": [
- "27001: 4.2",
- "27001: A.6.1.3",
- "27002: 6.1.3",
- "27017: 6.1.3",
- "27018: 6.1.3",
- "27001: A.16.1.1",
- "27002: 16.1.1",
- "27001: A.18.1.1",
- "27002: 18.1.1",
- "27017: 18.1.1",
- "27018: 18.1.1"
- ]
- },
- {
- "ReferenceId": "ISO/IEC 27001:2022, 27002:2022",
- "Identifiers": [
- "27001: A.5.5",
- "27001: A.5.24",
- "27002: 5.24 Incident management procedure (d)"
- ]
- },
- {
- "ReferenceId": "NIST 800-53 rev 5",
- "Identifiers": [
- "IR-4",
- "IR-4(8)",
- "IR-6",
- "IR-6(3)",
- "IR-7",
- "IR-7(2)",
- "PM-21",
- "PM-23",
- "PM-26"
- ]
- },
- {
- "ReferenceId": "NIST CSF v1.1",
- "Identifiers": [
- "ID.GV-2",
- "RS.CO-3",
- "RS.CO-4"
- ]
- },
- {
- "ReferenceId": "NIST CSF v2.0",
- "Identifiers": [
- "GV.RR-02",
- "RS.CO-02",
- "RS.CO-03"
- ]
- },
- {
- "ReferenceId": "PCI DSS v4.0",
- "Identifiers": [
- "12.10.1"
- ]
- }
- ]
- }
- ],
- "Checks": []
- },
- {
- "Id": "TVM-02",
- "Description": "Establish, document, approve, communicate, apply, evaluate and maintain policies and procedures to protect against malware on managed assets. Review and update the policies and procedures at least annually.",
- "Name": "Malware Protection Policy and Procedures",
- "Attributes": [
- {
- "Section": "Threat & Vulnerability Management",
- "CCMLite": "Yes",
- "IaaS": "Shared",
- "PaaS": "Shared",
- "SaaS": "Shared",
- "ScopeApplicability": [
- {
- "ReferenceId": "AICPA TSC 2017",
- "Identifiers": [
- "CC5.3",
- "CC6.8"
- ]
- },
- {
- "ReferenceId": "CCM v3.0.1",
- "Identifiers": [
- "TVM-01",
- "GRM-06",
- "GRM-09"
- ]
- },
- {
- "ReferenceId": "CIS v8.0",
- "Identifiers": [
- "9.7",
- "10.1"
- ]
- },
- {
- "ReferenceId": "ENX ISA v6.0",
- "Identifiers": [
- "1.1.1",
- "1.5.1",
- "5.2.3"
- ]
- },
- {
- "ReferenceId": "ISF SOGP 2022",
- "Identifiers": [
- "TS1.2",
- "TS1.3"
- ]
- },
- {
- "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019",
- "Identifiers": [
- "27001: 5.1",
- "27001: 5.2",
- "27001: 7.3",
- "27001: 7.4",
- "27001: 7.5",
- "27001: 9.1",
- "27001: 9.3",
- "27001: A.5",
- "27002: 5",
- "27001: A.12.2.1",
- "27001: A.6.2.1",
- "27002: 6.2.1 (h)",
- "27001: A.6.2.2",
- "27002: 6.2.2 (j)",
- "27001: A.7.2.2",
- "27002: 7.2.2 (d)",
- "27001: A.10.1.1",
- "27002: 10.1.1 (g)",
- "27001: A.13.2.1",
- "27002: 13.2.1 (b)",
- "27001: A.15.1.2",
- "27017: 15.1.2",
- "27001: A.12.2.1",
- "27002: 12.2.1 (a),(d)",
- "27017: CLD.9.5.2"
- ]
- },
- {
- "ReferenceId": "ISO/IEC 27001:2022, 27002:2022",
- "Identifiers": [
- "27001: 5.1",
- "27001: 5.2",
- "27001: 7.3",
- "27001: 7.4",
- "27001: 7.5",
- "27001: 9.1",
- "27001: 9.3",
- "27001: A.5.1",
- "27001: A.5.4",
- "27001: A.5.7",
- "27001: A.5.37",
- "27001: A.8.7",
- "27002: 5.7 (b)"
- ]
- },
- {
- "ReferenceId": "NIST 800-53 rev 5",
- "Identifiers": [
- "RA-3",
- "RA-3(3)",
- "RA-5",
- "RA-5(3)",
- "RA-5(5)",
- "SI-3",
- "SI-3(4)",
- "SI-3(10)"
- ]
- },
- {
- "ReferenceId": "NIST CSF v1.1",
- "Identifiers": [
- "ID.GV-1",
- "DE.CM-4",
- "DE.CM-5"
- ]
- },
- {
- "ReferenceId": "NIST CSF v2.0",
- "Identifiers": [
- "GV.PO-01",
- "GV.PO-02",
- "ID.IM-03",
- "DE.CM-01",
- "DE.CM-09"
- ]
- },
- {
- "ReferenceId": "PCI DSS v3.2.1",
- "Identifiers": [
- "5.4",
- "12.1",
- "12.1.1",
- "12.3.1",
- "12.5.1",
- "12.11"
- ]
- },
- {
- "ReferenceId": "PCI DSS v4.0",
- "Identifiers": [
- "12.1.1",
- "12.1.2",
- "5.1.1",
- "5.3.2.1"
- ]
- }
- ]
- }
- ],
- "Checks": [
- "ecs_instance_endpoint_protection_installed"
- ]
- },
- {
- "Id": "TVM-03",
- "Description": "Define, implement and evaluate processes, procedures and technical measures to enable both scheduled and emergency responses to vulnerability identifications, based on the identified risk.",
- "Name": "Vulnerability Remediation Schedule",
- "Attributes": [
- {
- "Section": "Threat & Vulnerability Management",
- "CCMLite": "Yes",
- "IaaS": "Shared",
- "PaaS": "Shared",
- "SaaS": "Shared",
- "ScopeApplicability": [
- {
- "ReferenceId": "AICPA TSC 2017",
- "Identifiers": [
- "CC5.3",
- "CC7.1",
- "CC7.4"
- ]
- },
- {
- "ReferenceId": "CCM v3.0.1",
- "Identifiers": [
- "TVM-02"
- ]
- },
- {
- "ReferenceId": "CIS v8.0",
- "Identifiers": [
- "7.2",
- "7.7",
- "17.9"
- ]
- },
- {
- "ReferenceId": "ENX ISA v6.0",
- "Identifiers": [
- "5.2.5"
- ]
- },
- {
- "ReferenceId": "ISF SOGP 2022",
- "Identifiers": [
- "TM1.1",
- "TM2.3"
- ]
- },
- {
- "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019",
- "Identifiers": [
- "27001: 6.1.3",
- "27001: A.12.2.1",
- "27001: A.12.6.1",
- "27002: 12.6.1(c)(d)(j)",
- "27018: 12.6.1(k)(i)"
- ]
- },
- {
- "ReferenceId": "ISO/IEC 27001:2022, 27002:2022",
- "Identifiers": [
- "27001: 6.1.3",
- "27001: A.8.7",
- "27001: A.8.8",
- "27001: A.8.32",
- "27002: 8.7",
- "27002: 8.8",
- "27002: 8.32"
- ]
- },
- {
- "ReferenceId": "NIST 800-53 rev 5",
- "Identifiers": [
- "PM-31",
- "RA-3",
- "RA-3(1)",
- "RA-5",
- "RA-5(2)-(4)",
- "RA-5(6)",
- "SI-3",
- "SI-3(10)"
- ]
- },
- {
- "ReferenceId": "NIST CSF v1.1",
- "Identifiers": [
- "RS.AN-5",
- "PR.IP-12"
- ]
- },
- {
- "ReferenceId": "NIST CSF v2.0",
- "Identifiers": [
- "ID.RA-01",
- "ID.RA-06",
- "ID.RA-08",
- "PR.PS-02",
- "PR.PS-03"
- ]
- },
- {
- "ReferenceId": "PCI DSS v3.2.1",
- "Identifiers": [
- "6.1",
- "6.1.a",
- "6.1.b"
- ]
- },
- {
- "ReferenceId": "PCI DSS v4.0",
- "Identifiers": [
- "6.1.1",
- "6.3.1",
- "6.3.2",
- "6.3.3",
- "12.10.1"
- ]
- }
- ]
- }
- ],
- "Checks": [
- "ecs_instance_latest_os_patches_applied"
- ]
- },
- {
- "Id": "TVM-04",
- "Description": "Define, implement and evaluate processes, procedures and technical measures to update detection tools, threat signatures, and indicators of compromise on a weekly, or more frequent basis.",
- "Name": "Detection Updates",
- "Attributes": [
- {
- "Section": "Threat & Vulnerability Management",
- "CCMLite": "Yes",
- "IaaS": "Shared",
- "PaaS": "Shared",
- "SaaS": "Shared",
- "ScopeApplicability": [
- {
- "ReferenceId": "AICPA TSC 2017",
- "Identifiers": [
- "CC7.2"
- ]
- },
- {
- "ReferenceId": "CCM v3.0.1",
- "Identifiers": [
- "No mapping"
- ]
- },
- {
- "ReferenceId": "CIS v8.0",
- "Identifiers": [
- "10.2"
- ]
- },
- {
- "ReferenceId": "ENX ISA v6.0",
- "Identifiers": [
- "5.2.3"
- ]
- },
- {
- "ReferenceId": "ISF SOGP 2022",
- "Identifiers": [
- "TS1.3",
- "TS1.4",
- "TM1.3",
- "TM1.4",
- "IM1.5"
- ]
- },
- {
- "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019",
- "Identifiers": [
- "27001: 6.1.3",
- "27001: A.5.1.1",
- "27002: 5.1.1 (h)",
- "27001: A.12.6.1",
- "27002: 12.6.1 (b),(c)"
- ]
- },
- {
- "ReferenceId": "ISO/IEC 27001:2022, 27002:2022",
- "Identifiers": [
- "27001: 6.1.3",
- "27001: A.5.1",
- "27001: A.8.8",
- "27001: A.8.15",
- "27001: A.8.16",
- "27002: 5.1",
- "27002: 5.37",
- "27002: 8.8",
- "27002: 8.15 (d)",
- "27002: 8.16 (d,e)",
- "27002: 8.31 2nd (a)"
- ]
- },
- {
- "ReferenceId": "NIST 800-53 rev 5",
- "Identifiers": [
- "CM-7",
- "CM-7(4)",
- "RA-3",
- "RA-3(3)",
- "RA-5(2)",
- "SA-10",
- "SA-10(5)",
- "SA-11",
- "SA-11(2)",
- "SI-2",
- "SI-2(4)",
- "SI-3",
- "SI-3(4)",
- "SI-4",
- "SI-4(9)",
- "SI-4(24)",
- "SI-8",
- "SI-8(2)",
- "SI-8(3)"
- ]
- },
- {
- "ReferenceId": "NIST CSF v1.1",
- "Identifiers": [
- "DE.DP-5",
- "PR.IP-12"
- ]
- },
- {
- "ReferenceId": "NIST CSF v2.0",
- "Identifiers": [
- "PR.PS-02",
- "ID.RA-02"
- ]
- },
- {
- "ReferenceId": "PCI DSS v3.2.1",
- "Identifiers": [
- "5.2",
- "5.2a",
- "5.2b",
- "5.2c"
- ]
- },
- {
- "ReferenceId": "PCI DSS v4.0",
- "Identifiers": [
- "5.3.1"
- ]
- }
- ]
- }
- ],
- "Checks": [
- "securitycenter_advanced_or_enterprise_edition",
- "securitycenter_vulnerability_scan_enabled"
- ]
- },
- {
- "Id": "TVM-05",
- "Description": "Define, implement and evaluate processes, procedures and technical measures to identify updates for applications which use third party or open source libraries according to the organization's vulnerability management policy.",
- "Name": "External Library Vulnerabilities",
- "Attributes": [
- {
- "Section": "Threat & Vulnerability Management",
- "CCMLite": "No",
- "IaaS": "Shared",
- "PaaS": "Shared",
- "SaaS": "CSP-Owned",
- "ScopeApplicability": [
- {
- "ReferenceId": "AICPA TSC 2017",
- "Identifiers": [
- "CC3.2"
- ]
- },
- {
- "ReferenceId": "CCM v3.0.1",
- "Identifiers": [
- "No mapping"
- ]
- },
- {
- "ReferenceId": "CIS v8.0",
- "Identifiers": [
- "2.6"
- ]
- },
- {
- "ReferenceId": "ISF SOGP 2022",
- "Identifiers": [
- "TM1.1",
- "SD2.3"
- ]
- },
- {
- "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019",
- "Identifiers": [
- "27001: 6.1.3",
- "27001: A.12.6.2",
- "27002: 12.6.2"
- ]
- },
- {
- "ReferenceId": "ISO/IEC 27001:2022, 27002:2022",
- "Identifiers": [
- "27001: 6.1.3",
- "27001: A 5.6",
- "27001: A.8.19",
- "27001: A.8.8",
- "27001: A.8.28",
- "27001: A.8.31",
- "27002: 5.6 (c)",
- "27001: 8.19",
- "27001: 8.8",
- "27001: 8.28",
- "27001: 8.31"
- ]
- },
- {
- "ReferenceId": "NIST 800-53 rev 5",
- "Identifiers": [
- "RA-5",
- "RA-5(3)",
- "SA-11",
- "SA-11(2)",
- "SA-11(5)"
- ]
- },
- {
- "ReferenceId": "NIST CSF v1.1",
- "Identifiers": [
- "DE.DP-5",
- "PR.IP-12"
- ]
- },
- {
- "ReferenceId": "NIST CSF v2.0",
- "Identifiers": [
- "ID.RA-01",
- "ID.RA-03",
- "PR.PS-02"
- ]
- },
- {
- "ReferenceId": "PCI DSS v3.2.1",
- "Identifiers": [
- "6.1",
- "6.2",
- "6.3.2"
- ]
- },
- {
- "ReferenceId": "PCI DSS v4.0",
- "Identifiers": [
- "6.3.1",
- "6.3.2",
- "6.3.3"
- ]
- }
- ]
- }
- ],
- "Checks": [
- "securitycenter_vulnerability_scan_enabled"
- ]
- },
- {
- "Id": "TVM-07",
- "Description": "Define, implement and evaluate processes, procedures and technical measures for the detection of vulnerabilities on organizationally managed assets at least monthly.",
- "Name": "Vulnerability Identification",
- "Attributes": [
- {
- "Section": "Threat & Vulnerability Management",
- "CCMLite": "Yes",
- "IaaS": "Shared",
- "PaaS": "Shared",
- "SaaS": "Shared",
- "ScopeApplicability": [
- {
- "ReferenceId": "AICPA TSC 2017",
- "Identifiers": [
- "CC7.1"
- ]
- },
- {
- "ReferenceId": "CCM v3.0.1",
- "Identifiers": [
- "TVM-02"
- ]
- },
- {
- "ReferenceId": "CIS v8.0",
- "Identifiers": [
- "7.1",
- "7.5",
- "7.6"
- ]
- },
- {
- "ReferenceId": "ENX ISA v6.0",
- "Identifiers": [
- "5.2.5",
- "5.2.6"
- ]
- },
- {
- "ReferenceId": "ISF SOGP 2022",
- "Identifiers": [
- "TM1.1"
- ]
- },
- {
- "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019",
- "Identifiers": [
- "27001: A.12.6",
- "27001: A.12.6.1",
- "27002: 12.6.1"
- ]
- },
- {
- "ReferenceId": "ISO/IEC 27001:2022, 27002:2022",
- "Identifiers": [
- "27001: A.8.8",
- "27002: 8.8"
- ]
- },
- {
- "ReferenceId": "NIST 800-53 rev 5",
- "Identifiers": [
- "RA-5",
- "RA-5(4)",
- "RA-5(5)",
- "SA-11",
- "SA-11(5)",
- "SA-15(5)",
- "SC-7",
- "SC-7(10)",
- "SI-3(8)",
- "SI-3(10)",
- "SI-7",
- "SI-7(9)"
- ]
- },
- {
- "ReferenceId": "NIST CSF v1.1",
- "Identifiers": [
- "ID.RA-1",
- "DE.CM-8",
- "PR.IP-12"
- ]
- },
- {
- "ReferenceId": "NIST CSF v2.0",
- "Identifiers": [
- "ID.RA-01"
- ]
- },
- {
- "ReferenceId": "PCI DSS v3.2.1",
- "Identifiers": [
- "6.1",
- "11.2",
- "11.2.1"
- ]
- },
- {
- "ReferenceId": "PCI DSS v4.0",
- "Identifiers": [
- "6.3.1",
- "6.3.2",
- "6.3.3",
- "11.3.2",
- "11.3.2.1"
- ]
- }
- ]
- }
- ],
- "Checks": [
- "securitycenter_vulnerability_scan_enabled",
- "securitycenter_advanced_or_enterprise_edition"
- ]
- },
- {
- "Id": "UEM-08",
- "Description": "Protect information from unauthorized disclosure on managed endpoint devices with storage encryption.",
- "Name": "Storage Encryption",
- "Attributes": [
- {
- "Section": "Universal Endpoint Management",
- "CCMLite": "No",
- "IaaS": "Shared",
- "PaaS": "Shared",
- "SaaS": "Shared",
- "ScopeApplicability": [
- {
- "ReferenceId": "AICPA TSC 2017",
- "Identifiers": [
- "CC6.1",
- "CC6.7"
- ]
- },
- {
- "ReferenceId": "CCM v3.0.1",
- "Identifiers": [
- "MOS-11"
- ]
- },
- {
- "ReferenceId": "CIS v8.0",
- "Identifiers": [
- "3.6"
- ]
- },
- {
- "ReferenceId": "ENX ISA v6.0",
- "Identifiers": [
- "5.1.2",
- "3.1.4"
- ]
- },
- {
- "ReferenceId": "ISF SOGP 2022",
- "Identifiers": [
- "PA1.2",
- "PA1.3",
- "PA1.5",
- "PA2.2",
- "PM1.4"
- ]
- },
- {
- "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019",
- "Identifiers": [
- "27001: A.11.2.7",
- "27002: 11.2.7",
- "27001: A.18.1.1",
- "27017: 18.1.1",
- "27001: A.12.3.1",
- "27017: 12.3.1",
- "27018: A.11.4",
- "27018: A.11.5"
- ]
- },
- {
- "ReferenceId": "ISO/IEC 27001:2022, 27002:2022",
- "Identifiers": [
- "27001: A.8.1",
- "27002: 8.1 (h)"
- ]
- },
- {
- "ReferenceId": "NIST 800-53 rev 5",
- "Identifiers": [
- "AC-19(5)",
- "SC-28",
- "SC-28(1)"
- ]
- },
- {
- "ReferenceId": "NIST CSF v1.1",
- "Identifiers": [
- "PR.DS-1"
- ]
- },
- {
- "ReferenceId": "NIST CSF v2.0",
- "Identifiers": [
- "PR.DS-01"
- ]
- },
- {
- "ReferenceId": "PCI DSS v3.2.1",
- "Identifiers": [
- "3.4",
- "3.6"
- ]
- },
- {
- "ReferenceId": "PCI DSS v4.0",
- "Identifiers": [
- "3.5.1",
- "3.6"
- ]
- }
- ]
- }
- ],
- "Checks": [
- "ecs_attached_disk_encrypted",
- "ecs_unattached_disk_encrypted"
- ]
- },
- {
- "Id": "UEM-11",
- "Description": "Configure managed endpoints with Data Loss Prevention (DLP) technologies and rules in accordance with a risk assessment.",
- "Name": "Data Loss Prevention",
- "Attributes": [
- {
- "Section": "Universal Endpoint Management",
- "CCMLite": "No",
- "IaaS": "Shared",
- "PaaS": "Shared",
- "SaaS": "Shared",
- "ScopeApplicability": [
- {
- "ReferenceId": "AICPA TSC 2017",
- "Identifiers": [
- "CC6.7"
- ]
- },
- {
- "ReferenceId": "CIS v8.0",
- "Identifiers": [
- "3.13"
- ]
- },
- {
- "ReferenceId": "ENX ISA v6.0",
- "Identifiers": [
- "5.2.7"
- ]
- },
- {
- "ReferenceId": "ISF SOGP 2022",
- "Identifiers": [
- "IM1.5",
- "PA2.2"
- ]
- },
- {
- "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019",
- "Identifiers": [
- "27001: A.12.3",
- "27002: 12.3",
- "27001: A.8.3.1",
- "27002: 8.3.1",
- "27001: A.12.2",
- "27002: 12.2",
- "27001: A.18.1.3",
- "27002: 18.1.3",
- "27001: A.6.1.1",
- "27017: 6.1.1",
- "27018: 12.3.1",
- "27018: 10.1"
- ]
- },
- {
- "ReferenceId": "ISO/IEC 27001:2022, 27002:2022",
- "Identifiers": [
- "27001: A.5.12",
- "27001: A.8.3"
- ]
- },
- {
- "ReferenceId": "NIST 800-53 rev 5",
- "Identifiers": [
- "SC-7",
- "SC-7(10)"
- ]
- },
- {
- "ReferenceId": "NIST CSF v1.1",
- "Identifiers": [
- "PR.DS-5"
- ]
- },
- {
- "ReferenceId": "NIST CSF v2.0",
- "Identifiers": [
- "PR.DS-02",
- "PR.DS-10",
- "PR.PS-01",
- "ID.AM-08",
- "DE.CM-09"
- ]
- },
- {
- "ReferenceId": "PCI DSS v3.2.1",
- "Identifiers": [
- "A3.2.6"
- ]
- },
- {
- "ReferenceId": "PCI DSS v4.0",
- "Identifiers": [
- "A3.2.6"
- ]
- }
- ]
- }
- ],
- "Checks": []
- }
- ]
-}
diff --git a/prowler/compliance/alibabacloud/prowler_threatscore_alibabacloud.json b/prowler/compliance/alibabacloud/prowler_threatscore_alibabacloud.json
index ca7a030dc2..0bfd47df7e 100644
--- a/prowler/compliance/alibabacloud/prowler_threatscore_alibabacloud.json
+++ b/prowler/compliance/alibabacloud/prowler_threatscore_alibabacloud.json
@@ -47,6 +47,14 @@
"Checks": [
"ram_user_console_access_unused"
],
+ "ConfigRequirements": [
+ {
+ "Check": "ram_user_console_access_unused",
+ "ConfigKey": "max_console_access_days",
+ "Operator": "lte",
+ "Value": 90
+ }
+ ],
"Attributes": [
{
"Title": "Inactive users disabled for console access",
@@ -399,6 +407,14 @@
"LevelOfRisk": 3,
"Weight": 10
}
+ ],
+ "ConfigRequirements": [
+ {
+ "Check": "cs_kubernetes_cluster_check_weekly",
+ "ConfigKey": "max_cluster_check_days",
+ "Operator": "lte",
+ "Value": 7
+ }
]
},
{
@@ -695,6 +711,14 @@
"Checks": [
"rds_instance_sql_audit_retention"
],
+ "ConfigRequirements": [
+ {
+ "Check": "rds_instance_sql_audit_retention",
+ "ConfigKey": "min_rds_audit_retention_days",
+ "Operator": "gte",
+ "Value": 180
+ }
+ ],
"Attributes": [
{
"Title": "RDS SQL audit retention configured",
diff --git a/prowler/compliance/aws/asd_essential_eight_aws.json b/prowler/compliance/aws/asd_essential_eight_aws.json
index 00b39817e3..dd39c44268 100644
--- a/prowler/compliance/aws/asd_essential_eight_aws.json
+++ b/prowler/compliance/aws/asd_essential_eight_aws.json
@@ -13,6 +13,14 @@
"config_recorder_all_regions_enabled",
"inspector2_is_enabled"
],
+ "ConfigRequirements": [
+ {
+ "Check": "config_recorder_all_regions_enabled",
+ "ConfigKey": "mute_non_default_regions",
+ "Operator": "eq",
+ "Value": false
+ }
+ ],
"Attributes": [
{
"Section": "1 Patch applications",
@@ -260,6 +268,14 @@
"config_recorder_all_regions_enabled",
"inspector2_is_enabled"
],
+ "ConfigRequirements": [
+ {
+ "Check": "config_recorder_all_regions_enabled",
+ "ConfigKey": "mute_non_default_regions",
+ "Operator": "eq",
+ "Value": false
+ }
+ ],
"Attributes": [
{
"Section": "2 Patch operating systems",
@@ -742,6 +758,14 @@
"accessanalyzer_enabled",
"accessanalyzer_enabled_without_findings"
],
+ "ConfigRequirements": [
+ {
+ "Check": "accessanalyzer_enabled",
+ "ConfigKey": "mute_non_default_regions",
+ "Operator": "eq",
+ "Value": false
+ }
+ ],
"Attributes": [
{
"Section": "4 Restrict administrative privileges",
diff --git a/prowler/compliance/aws/aws_account_security_onboarding_aws.json b/prowler/compliance/aws/aws_account_security_onboarding_aws.json
index 1d038537f0..1910bac223 100644
--- a/prowler/compliance/aws/aws_account_security_onboarding_aws.json
+++ b/prowler/compliance/aws/aws_account_security_onboarding_aws.json
@@ -37,6 +37,26 @@
"guardduty_is_enabled",
"accessanalyzer_enabled",
"macie_is_enabled"
+ ],
+ "ConfigRequirements": [
+ {
+ "Check": "securityhub_enabled",
+ "ConfigKey": "mute_non_default_regions",
+ "Operator": "eq",
+ "Value": false
+ },
+ {
+ "Check": "guardduty_is_enabled",
+ "ConfigKey": "mute_non_default_regions",
+ "Operator": "eq",
+ "Value": false
+ },
+ {
+ "Check": "accessanalyzer_enabled",
+ "ConfigKey": "mute_non_default_regions",
+ "Operator": "eq",
+ "Value": false
+ }
]
},
{
@@ -259,6 +279,20 @@
"Checks": [
"guardduty_is_enabled",
"guardduty_is_enabled"
+ ],
+ "ConfigRequirements": [
+ {
+ "Check": "guardduty_is_enabled",
+ "ConfigKey": "mute_non_default_regions",
+ "Operator": "eq",
+ "Value": false
+ },
+ {
+ "Check": "guardduty_is_enabled",
+ "ConfigKey": "mute_non_default_regions",
+ "Operator": "eq",
+ "Value": false
+ }
]
},
{
@@ -514,6 +548,14 @@
"Checks": [
"accessanalyzer_enabled",
"accessanalyzer_enabled_without_findings"
+ ],
+ "ConfigRequirements": [
+ {
+ "Check": "accessanalyzer_enabled",
+ "ConfigKey": "mute_non_default_regions",
+ "Operator": "eq",
+ "Value": false
+ }
]
},
{
@@ -530,6 +572,20 @@
"securityhub_enabled",
"accessanalyzer_enabled",
"accessanalyzer_enabled_without_findings"
+ ],
+ "ConfigRequirements": [
+ {
+ "Check": "securityhub_enabled",
+ "ConfigKey": "mute_non_default_regions",
+ "Operator": "eq",
+ "Value": false
+ },
+ {
+ "Check": "accessanalyzer_enabled",
+ "ConfigKey": "mute_non_default_regions",
+ "Operator": "eq",
+ "Value": false
+ }
]
},
{
@@ -666,6 +722,14 @@
],
"Checks": [
"securityhub_enabled"
+ ],
+ "ConfigRequirements": [
+ {
+ "Check": "securityhub_enabled",
+ "ConfigKey": "mute_non_default_regions",
+ "Operator": "eq",
+ "Value": false
+ }
]
},
{
@@ -680,6 +744,14 @@
],
"Checks": [
"securityhub_enabled"
+ ],
+ "ConfigRequirements": [
+ {
+ "Check": "securityhub_enabled",
+ "ConfigKey": "mute_non_default_regions",
+ "Operator": "eq",
+ "Value": false
+ }
]
},
{
@@ -694,6 +766,14 @@
],
"Checks": [
"securityhub_enabled"
+ ],
+ "ConfigRequirements": [
+ {
+ "Check": "securityhub_enabled",
+ "ConfigKey": "mute_non_default_regions",
+ "Operator": "eq",
+ "Value": false
+ }
]
},
{
@@ -708,6 +788,14 @@
],
"Checks": [
"securityhub_enabled"
+ ],
+ "ConfigRequirements": [
+ {
+ "Check": "securityhub_enabled",
+ "ConfigKey": "mute_non_default_regions",
+ "Operator": "eq",
+ "Value": false
+ }
]
},
{
@@ -722,6 +810,14 @@
],
"Checks": [
"securityhub_enabled"
+ ],
+ "ConfigRequirements": [
+ {
+ "Check": "securityhub_enabled",
+ "ConfigKey": "mute_non_default_regions",
+ "Operator": "eq",
+ "Value": false
+ }
]
},
{
@@ -736,6 +832,14 @@
],
"Checks": [
"config_recorder_all_regions_enabled"
+ ],
+ "ConfigRequirements": [
+ {
+ "Check": "config_recorder_all_regions_enabled",
+ "ConfigKey": "mute_non_default_regions",
+ "Operator": "eq",
+ "Value": false
+ }
]
},
{
@@ -762,6 +866,14 @@
],
"Checks": [
"config_recorder_all_regions_enabled"
+ ],
+ "ConfigRequirements": [
+ {
+ "Check": "config_recorder_all_regions_enabled",
+ "ConfigKey": "mute_non_default_regions",
+ "Operator": "eq",
+ "Value": false
+ }
]
},
{
@@ -777,6 +889,14 @@
"Checks": [
"guardduty_is_enabled",
"guardduty_centrally_managed"
+ ],
+ "ConfigRequirements": [
+ {
+ "Check": "guardduty_is_enabled",
+ "ConfigKey": "mute_non_default_regions",
+ "Operator": "eq",
+ "Value": false
+ }
]
},
{
@@ -792,6 +912,14 @@
"Checks": [
"guardduty_is_enabled",
"guardduty_no_high_severity_findings"
+ ],
+ "ConfigRequirements": [
+ {
+ "Check": "guardduty_is_enabled",
+ "ConfigKey": "mute_non_default_regions",
+ "Operator": "eq",
+ "Value": false
+ }
]
},
{
@@ -807,6 +935,14 @@
"Checks": [
"guardduty_is_enabled",
"guardduty_no_high_severity_findings"
+ ],
+ "ConfigRequirements": [
+ {
+ "Check": "guardduty_is_enabled",
+ "ConfigKey": "mute_non_default_regions",
+ "Operator": "eq",
+ "Value": false
+ }
]
},
{
@@ -822,6 +958,14 @@
"Checks": [
"guardduty_is_enabled",
"guardduty_no_high_severity_findings"
+ ],
+ "ConfigRequirements": [
+ {
+ "Check": "guardduty_is_enabled",
+ "ConfigKey": "mute_non_default_regions",
+ "Operator": "eq",
+ "Value": false
+ }
]
},
{
@@ -837,6 +981,14 @@
"Checks": [
"guardduty_is_enabled",
"guardduty_no_high_severity_findings"
+ ],
+ "ConfigRequirements": [
+ {
+ "Check": "guardduty_is_enabled",
+ "ConfigKey": "mute_non_default_regions",
+ "Operator": "eq",
+ "Value": false
+ }
]
},
{
@@ -852,6 +1004,14 @@
"Checks": [
"guardduty_is_enabled",
"guardduty_no_high_severity_findings"
+ ],
+ "ConfigRequirements": [
+ {
+ "Check": "guardduty_is_enabled",
+ "ConfigKey": "mute_non_default_regions",
+ "Operator": "eq",
+ "Value": false
+ }
]
},
{
@@ -867,6 +1027,14 @@
"Checks": [
"guardduty_is_enabled",
"guardduty_no_high_severity_findings"
+ ],
+ "ConfigRequirements": [
+ {
+ "Check": "guardduty_is_enabled",
+ "ConfigKey": "mute_non_default_regions",
+ "Operator": "eq",
+ "Value": false
+ }
]
},
{
@@ -882,6 +1050,14 @@
"Checks": [
"guardduty_is_enabled",
"guardduty_no_high_severity_findings"
+ ],
+ "ConfigRequirements": [
+ {
+ "Check": "guardduty_is_enabled",
+ "ConfigKey": "mute_non_default_regions",
+ "Operator": "eq",
+ "Value": false
+ }
]
},
{
@@ -897,6 +1073,14 @@
"Checks": [
"guardduty_is_enabled",
"guardduty_no_high_severity_findings"
+ ],
+ "ConfigRequirements": [
+ {
+ "Check": "guardduty_is_enabled",
+ "ConfigKey": "mute_non_default_regions",
+ "Operator": "eq",
+ "Value": false
+ }
]
},
{
@@ -912,6 +1096,14 @@
"Checks": [
"guardduty_is_enabled",
"guardduty_no_high_severity_findings"
+ ],
+ "ConfigRequirements": [
+ {
+ "Check": "guardduty_is_enabled",
+ "ConfigKey": "mute_non_default_regions",
+ "Operator": "eq",
+ "Value": false
+ }
]
},
{
diff --git a/prowler/compliance/aws/aws_ai_security_framework_aws.json b/prowler/compliance/aws/aws_ai_security_framework_aws.json
new file mode 100644
index 0000000000..9b87f7464d
--- /dev/null
+++ b/prowler/compliance/aws/aws_ai_security_framework_aws.json
@@ -0,0 +1,1198 @@
+{
+ "Framework": "AWS-AI-Security-Framework",
+ "Name": "AWS AI Security Framework",
+ "Version": "1.0",
+ "Provider": "AWS",
+ "Description": "Security compliance framework based on the AWS AI Security Framework blog post (2025). Organizes controls across three security layers (Infrastructure, Identity & Data, AI Application), three deployment phases (Foundational, Enhanced, Advanced), and three AI use cases (AI that Answers, AI that Connects, AI that Acts). Maps existing Prowler checks to AI workload security requirements and identifies gaps requiring new checks.",
+ "Requirements": [
+ {
+ "Id": "AISF-INFRA-01",
+ "Description": "Ensure VPC endpoints provide private connectivity for Bedrock APIs, preventing AI traffic from traversing the public internet.",
+ "Name": "Bedrock VPC Private Connectivity",
+ "Attributes": [
+ {
+ "Section": "Infrastructure Security",
+ "SubSection": "Network Isolation",
+ "Service": "bedrock",
+ "Type": "Automated"
+ }
+ ],
+ "Checks": [
+ "bedrock_vpc_endpoints_configured"
+ ]
+ },
+ {
+ "Id": "AISF-INFRA-02",
+ "Description": "Ensure VPCs have Network Firewall enabled to inspect and filter AI workload traffic, with logging, multi-AZ deployment, and proper default actions for both full and fragmented packets.",
+ "Name": "Network Firewall for AI Workloads",
+ "Attributes": [
+ {
+ "Section": "Infrastructure Security",
+ "SubSection": "Network Firewall",
+ "Service": "networkfirewall",
+ "Type": "Automated"
+ }
+ ],
+ "Checks": [
+ "networkfirewall_in_all_vpc",
+ "networkfirewall_logging_enabled",
+ "networkfirewall_multi_az",
+ "networkfirewall_policy_default_action_full_packets",
+ "networkfirewall_policy_default_action_fragmented_packets",
+ "networkfirewall_policy_rule_group_associated",
+ "networkfirewall_deletion_protection"
+ ]
+ },
+ {
+ "Id": "AISF-INFRA-03",
+ "Description": "Ensure WAFv2 Web ACLs are configured with rules and logging to protect AI application endpoints from HTTP-based attacks including prompt injection patterns at the perimeter.",
+ "Name": "WAF Protection for AI Endpoints",
+ "Attributes": [
+ {
+ "Section": "Infrastructure Security",
+ "SubSection": "Web Application Firewall",
+ "Service": "wafv2",
+ "Type": "Automated"
+ }
+ ],
+ "Checks": [
+ "wafv2_webacl_with_rules",
+ "wafv2_webacl_logging_enabled",
+ "wafv2_webacl_rule_logging_enabled",
+ "apigateway_restapi_waf_acl_attached",
+ "cognito_user_pool_waf_acl_attached"
+ ]
+ },
+ {
+ "Id": "AISF-INFRA-04",
+ "Description": "Ensure AWS Shield Advanced is enabled to protect internet-facing AI application infrastructure from DDoS attacks.",
+ "Name": "DDoS Protection for AI Infrastructure",
+ "Attributes": [
+ {
+ "Section": "Infrastructure Security",
+ "SubSection": "DDoS Protection",
+ "Service": "shield",
+ "Type": "Automated"
+ }
+ ],
+ "Checks": [
+ "shield_advanced_protection_in_cloudfront_distributions",
+ "shield_advanced_protection_in_internet_facing_load_balancers",
+ "shield_advanced_protection_in_classic_load_balancers",
+ "shield_advanced_protection_in_route53_hosted_zones",
+ "shield_advanced_protection_in_associated_elastic_ips",
+ "shield_advanced_protection_in_global_accelerators"
+ ]
+ },
+ {
+ "Id": "AISF-INFRA-05",
+ "Description": "Ensure all data at rest is encrypted with AES-256 across AI workload storage including S3 buckets, EBS volumes, RDS instances, SageMaker notebooks, and Bedrock prompts using customer-managed KMS keys where possible.",
+ "Name": "Encryption at Rest for AI Data",
+ "Attributes": [
+ {
+ "Section": "Infrastructure Security",
+ "SubSection": "Encryption at Rest",
+ "Service": "kms",
+ "Type": "Automated"
+ }
+ ],
+ "Checks": [
+ "s3_bucket_default_encryption",
+ "s3_bucket_kms_encryption",
+ "ec2_ebs_default_encryption",
+ "ec2_ebs_volume_encryption",
+ "rds_instance_storage_encrypted",
+ "sagemaker_notebook_instance_encryption_enabled",
+ "sagemaker_training_jobs_volume_and_output_encryption_enabled",
+ "bedrock_model_invocation_logs_encryption_enabled",
+ "cloudtrail_kms_encryption_enabled",
+ "cloudwatch_log_group_kms_encryption_enabled",
+ "eks_cluster_kms_cmk_encryption_in_secrets_enabled",
+ "dynamodb_tables_kms_cmk_encryption_enabled",
+ "sns_topics_kms_encryption_at_rest_enabled",
+ "sqs_queues_server_side_encryption_enabled"
+ ]
+ },
+ {
+ "Id": "AISF-INFRA-06",
+ "Description": "Ensure all data in transit uses TLS 1.2 or higher, including API communications, inter-container traffic for ML training, and connections between AI application components.",
+ "Name": "Encryption in Transit for AI Workloads",
+ "Attributes": [
+ {
+ "Section": "Infrastructure Security",
+ "SubSection": "Encryption in Transit",
+ "Service": "multiple",
+ "Type": "Automated"
+ }
+ ],
+ "Checks": [
+ "s3_bucket_secure_transport_policy",
+ "sagemaker_training_jobs_intercontainer_encryption_enabled",
+ "elbv2_ssl_listeners",
+ "elbv2_insecure_ssl_ciphers",
+ "cloudfront_distributions_https_enabled",
+ "cloudfront_distributions_using_deprecated_ssl_protocols",
+ "opensearch_service_domains_https_communications_enforced",
+ "opensearch_service_domains_node_to_node_encryption_enabled",
+ "apigateway_restapi_client_certificate_enabled"
+ ]
+ },
+ {
+ "Id": "AISF-INFRA-07",
+ "Description": "Ensure customer-managed KMS keys are in use, rotation is enabled, and keys are not scheduled for unintentional deletion to maintain control over AI data encryption.",
+ "Name": "Customer-Managed Key Governance",
+ "Attributes": [
+ {
+ "Section": "Infrastructure Security",
+ "SubSection": "Key Management",
+ "Service": "kms",
+ "Type": "Automated"
+ }
+ ],
+ "Checks": [
+ "kms_cmk_are_used",
+ "kms_cmk_rotation_enabled",
+ "kms_cmk_not_deleted_unintentionally",
+ "kms_key_not_publicly_accessible"
+ ]
+ },
+ {
+ "Id": "AISF-INFRA-08",
+ "Description": "Ensure VPC endpoints enforce trust boundaries, subnets do not assign public IPs by default, and flow logs are enabled for all VPCs hosting AI workloads.",
+ "Name": "VPC Security for AI Workloads",
+ "Attributes": [
+ {
+ "Section": "Infrastructure Security",
+ "SubSection": "VPC Security",
+ "Service": "vpc",
+ "Type": "Automated"
+ }
+ ],
+ "Checks": [
+ "vpc_flow_logs_enabled",
+ "vpc_subnet_no_public_ip_by_default",
+ "vpc_subnet_separate_private_public",
+ "vpc_endpoint_connections_trust_boundaries",
+ "vpc_endpoint_services_allowed_principals_trust_boundaries",
+ "vpc_endpoint_for_ec2_enabled",
+ "vpc_peering_routing_tables_with_least_privilege"
+ ]
+ },
+ {
+ "Id": "AISF-INFRA-09",
+ "Description": "Ensure hardware-enforced compute isolation is in use for AI workloads. AWS Nitro System provides isolation with no operator access to customer data during model inference and training.",
+ "Name": "Hardware-Enforced Compute Isolation",
+ "Attributes": [
+ {
+ "Section": "Infrastructure Security",
+ "SubSection": "Compute Isolation",
+ "Service": "ec2",
+ "Type": "Manual"
+ }
+ ],
+ "Checks": []
+ },
+ {
+ "Id": "AISF-IAM-01",
+ "Description": "Ensure MFA is enforced for all users accessing AI services, including root account hardware MFA, IAM user MFA for console access, and Cognito user pool MFA for AI application end users.",
+ "Name": "Multi-Factor Authentication for AI Access",
+ "Attributes": [
+ {
+ "Section": "Identity and Data Security",
+ "SubSection": "Authentication",
+ "Service": "iam",
+ "Type": "Automated"
+ }
+ ],
+ "Checks": [
+ "iam_root_mfa_enabled",
+ "iam_root_hardware_mfa_enabled",
+ "iam_user_mfa_enabled_console_access",
+ "iam_user_hardware_mfa_enabled",
+ "iam_administrator_access_with_mfa",
+ "cognito_user_pool_mfa_enabled",
+ "cognito_user_pool_advanced_security_enabled",
+ "cognito_user_pool_blocks_compromised_credentials_sign_in_attempts",
+ "cognito_user_pool_blocks_potential_malicious_sign_in_attempts"
+ ]
+ },
+ {
+ "Id": "AISF-IAM-02",
+ "Description": "Ensure least-privilege access is enforced for all AI service identities. No administrative privileges should be attached to IAM entities that interact with Bedrock, SageMaker, or other AI services.",
+ "Name": "Least Privilege for AI Identities",
+ "Attributes": [
+ {
+ "Section": "Identity and Data Security",
+ "SubSection": "Authorization",
+ "Service": "iam",
+ "Type": "Automated"
+ }
+ ],
+ "Checks": [
+ "iam_aws_attached_policy_no_administrative_privileges",
+ "iam_customer_attached_policy_no_administrative_privileges",
+ "iam_inline_policy_no_administrative_privileges",
+ "iam_policy_allows_privilege_escalation",
+ "iam_inline_policy_allows_privilege_escalation",
+ "iam_role_administratoraccess_policy",
+ "iam_user_administrator_access_policy",
+ "iam_group_administrator_access_policy",
+ "iam_policy_attached_only_to_group_or_roles",
+ "iam_no_custom_policy_permissive_role_assumption",
+ "bedrock_full_access_policy_attached",
+ "bedrock_api_key_no_administrative_privileges"
+ ]
+ },
+ {
+ "Id": "AISF-IAM-03",
+ "Description": "Ensure temporary and scoped credentials are used for AI service access. Long-term access keys should be avoided, rotated within 90 days when necessary, and unused keys should be disabled.",
+ "Name": "Temporary Scoped Credentials",
+ "Attributes": [
+ {
+ "Section": "Identity and Data Security",
+ "SubSection": "Credential Management",
+ "Service": "iam",
+ "Type": "Automated"
+ }
+ ],
+ "Checks": [
+ "iam_user_with_temporary_credentials",
+ "iam_rotate_access_key_90_days",
+ "iam_user_accesskey_unused",
+ "iam_user_no_setup_initial_access_key",
+ "iam_user_two_active_access_key",
+ "iam_user_console_access_unused",
+ "bedrock_api_key_no_long_term_credentials"
+ ]
+ },
+ {
+ "Id": "AISF-IAM-04",
+ "Description": "Ensure root account is properly secured with no active access keys and minimal usage, as root credentials in AI environments could grant unrestricted access to all AI models, data, and agent configurations.",
+ "Name": "Root Account Security",
+ "Attributes": [
+ {
+ "Section": "Identity and Data Security",
+ "SubSection": "Root Account",
+ "Service": "iam",
+ "Type": "Automated"
+ }
+ ],
+ "Checks": [
+ "iam_no_root_access_key",
+ "iam_avoid_root_usage",
+ "iam_root_credentials_management_enabled",
+ "cloudwatch_log_metric_filter_root_usage"
+ ]
+ },
+ {
+ "Id": "AISF-IAM-05",
+ "Description": "Ensure IAM roles used for AI services prevent cross-service confused deputy attacks and stale access to Bedrock and SageMaker is reviewed regularly.",
+ "Name": "AI Service Role Security",
+ "Attributes": [
+ {
+ "Section": "Identity and Data Security",
+ "SubSection": "Service Roles",
+ "Service": "iam",
+ "Type": "Automated"
+ }
+ ],
+ "Checks": [
+ "iam_role_cross_service_confused_deputy_prevention",
+ "iam_role_access_not_stale_to_bedrock",
+ "iam_user_access_not_stale_to_bedrock",
+ "iam_role_cross_account_readonlyaccess_policy"
+ ]
+ },
+ {
+ "Id": "AISF-IAM-06",
+ "Description": "Ensure strong password policies are enforced with minimum length, complexity requirements, expiration, and reuse prevention for all human identities accessing AI services.",
+ "Name": "Password Policy for AI Service Access",
+ "Attributes": [
+ {
+ "Section": "Identity and Data Security",
+ "SubSection": "Password Policy",
+ "Service": "iam",
+ "Type": "Automated"
+ }
+ ],
+ "Checks": [
+ "iam_password_policy_minimum_length_14",
+ "iam_password_policy_uppercase",
+ "iam_password_policy_lowercase",
+ "iam_password_policy_number",
+ "iam_password_policy_symbol",
+ "iam_password_policy_reuse_24",
+ "iam_password_policy_expires_passwords_within_90_days_or_less",
+ "cognito_user_pool_password_policy_minimum_length_14",
+ "cognito_user_pool_password_policy_lowercase",
+ "cognito_user_pool_password_policy_uppercase",
+ "cognito_user_pool_password_policy_number",
+ "cognito_user_pool_password_policy_symbol"
+ ]
+ },
+ {
+ "Id": "AISF-IAM-07",
+ "Description": "Ensure Cognito user pools are properly configured for AI application user authentication with advanced security features, token revocation, self-registration controls, and WAF protection.",
+ "Name": "Cognito User Authentication for AI Apps",
+ "Attributes": [
+ {
+ "Section": "Identity and Data Security",
+ "SubSection": "User Authentication",
+ "Service": "cognito",
+ "Type": "Automated"
+ }
+ ],
+ "Checks": [
+ "cognito_user_pool_mfa_enabled",
+ "cognito_user_pool_advanced_security_enabled",
+ "cognito_user_pool_self_registration_disabled",
+ "cognito_user_pool_deletion_protection_enabled",
+ "cognito_user_pool_client_token_revocation_enabled",
+ "cognito_user_pool_client_prevent_user_existence_errors",
+ "cognito_user_pool_temporary_password_expiration",
+ "cognito_user_pool_waf_acl_attached",
+ "cognito_identity_pool_guest_access_disabled"
+ ]
+ },
+ {
+ "Id": "AISF-IAM-08",
+ "Description": "Ensure API Gateway endpoints serving AI applications have proper authorization configured to authenticate and authorize every request to the model layer.",
+ "Name": "API Authorization for AI Endpoints",
+ "Attributes": [
+ {
+ "Section": "Identity and Data Security",
+ "SubSection": "API Authorization",
+ "Service": "apigateway",
+ "Type": "Automated"
+ }
+ ],
+ "Checks": [
+ "apigateway_restapi_authorizers_enabled",
+ "apigateway_restapi_public_with_authorizer",
+ "apigatewayv2_api_authorizers_enabled"
+ ]
+ },
+ {
+ "Id": "AISF-DATA-01",
+ "Description": "Ensure Amazon Macie is enabled with automated sensitive data discovery to classify and protect enterprise data before it is made available to AI systems through RAG or other patterns.",
+ "Name": "Data Classification for AI",
+ "Attributes": [
+ {
+ "Section": "Identity and Data Security",
+ "SubSection": "Data Classification",
+ "Service": "macie",
+ "Type": "Automated"
+ }
+ ],
+ "Checks": [
+ "macie_is_enabled",
+ "macie_automated_sensitive_data_discovery_enabled"
+ ]
+ },
+ {
+ "Id": "AISF-DATA-02",
+ "Description": "Ensure IAM Access Analyzer is enabled to validate access policies and identify unintended access to resources used by AI workloads.",
+ "Name": "Access Analysis for AI Resources",
+ "Attributes": [
+ {
+ "Section": "Identity and Data Security",
+ "SubSection": "Access Analysis",
+ "Service": "accessanalyzer",
+ "Type": "Automated"
+ }
+ ],
+ "Checks": [
+ "accessanalyzer_enabled",
+ "accessanalyzer_enabled_without_findings"
+ ],
+ "ConfigRequirements": [
+ {
+ "Check": "accessanalyzer_enabled",
+ "ConfigKey": "mute_non_default_regions",
+ "Operator": "eq",
+ "Value": false
+ }
+ ]
+ },
+ {
+ "Id": "AISF-DATA-03",
+ "Description": "Ensure Secrets Manager is used for AI application credentials with automatic rotation enabled, restrictive resource policies, and no public access.",
+ "Name": "Secrets Management for AI Workloads",
+ "Attributes": [
+ {
+ "Section": "Identity and Data Security",
+ "SubSection": "Secrets Management",
+ "Service": "secretsmanager",
+ "Type": "Automated"
+ }
+ ],
+ "Checks": [
+ "secretsmanager_automatic_rotation_enabled",
+ "secretsmanager_secret_rotated_periodically",
+ "secretsmanager_has_restrictive_resource_policy",
+ "secretsmanager_not_publicly_accessible",
+ "secretsmanager_secret_unused"
+ ]
+ },
+ {
+ "Id": "AISF-DATA-04",
+ "Description": "Ensure S3 buckets used for AI training data, model artifacts, RAG knowledge bases, and inference logs have public access blocked, encryption enabled, secure transport enforced, and access logging configured.",
+ "Name": "S3 Data Protection for AI",
+ "Attributes": [
+ {
+ "Section": "Identity and Data Security",
+ "SubSection": "Storage Security",
+ "Service": "s3",
+ "Type": "Automated"
+ }
+ ],
+ "Checks": [
+ "s3_account_level_public_access_blocks",
+ "s3_bucket_level_public_access_block",
+ "s3_bucket_public_access",
+ "s3_bucket_policy_public_write_access",
+ "s3_bucket_default_encryption",
+ "s3_bucket_kms_encryption",
+ "s3_bucket_secure_transport_policy",
+ "s3_bucket_server_access_logging_enabled",
+ "s3_bucket_object_versioning",
+ "s3_bucket_acl_prohibited",
+ "s3_bucket_cross_account_access"
+ ]
+ },
+ {
+ "Id": "AISF-DATA-05",
+ "Description": "Ensure no secrets or credentials are hardcoded in Lambda functions, ECS task definitions, or EC2 instances used as part of AI application architectures.",
+ "Name": "No Hardcoded Secrets in AI Workloads",
+ "Attributes": [
+ {
+ "Section": "Identity and Data Security",
+ "SubSection": "Secret Hygiene",
+ "Service": "multiple",
+ "Type": "Automated"
+ }
+ ],
+ "Checks": [
+ "awslambda_function_no_secrets_in_code",
+ "awslambda_function_no_secrets_in_variables",
+ "ecs_task_definitions_no_environment_secrets",
+ "ec2_instance_secrets_user_data",
+ "cloudwatch_log_group_no_secrets_in_logs"
+ ]
+ },
+ {
+ "Id": "AISF-DATA-06",
+ "Description": "Ensure the AWS Organization has opted out of all AI services data usage and child accounts cannot override this policy, preventing AWS from using customer data for AI service improvement.",
+ "Name": "AI Services Data Opt-Out",
+ "Attributes": [
+ {
+ "Section": "Identity and Data Security",
+ "SubSection": "Data Governance",
+ "Service": "organizations",
+ "Type": "Automated"
+ }
+ ],
+ "Checks": [
+ "organizations_opt_out_ai_services_policy"
+ ]
+ },
+ {
+ "Id": "AISF-AI-01",
+ "Description": "Ensure Amazon Bedrock has at least one guardrail configured to provide content filtering, prompt injection defense, PII filtering, and topic restrictions for foundation model interactions.",
+ "Name": "Bedrock Guardrails Configuration",
+ "Attributes": [
+ {
+ "Section": "AI Application Security",
+ "SubSection": "Content Filtering",
+ "Service": "bedrock",
+ "Type": "Automated"
+ }
+ ],
+ "Checks": [
+ "bedrock_guardrails_configured"
+ ]
+ },
+ {
+ "Id": "AISF-AI-02",
+ "Description": "Ensure Bedrock guardrails have prompt attack filter strength set to HIGH to detect and block prompt injection attempts, the #1 risk in OWASP Top 10 for LLM Applications.",
+ "Name": "Prompt Injection Defense",
+ "Attributes": [
+ {
+ "Section": "AI Application Security",
+ "SubSection": "Prompt Security",
+ "Service": "bedrock",
+ "Type": "Automated"
+ }
+ ],
+ "Checks": [
+ "bedrock_guardrail_prompt_attack_filter_enabled"
+ ]
+ },
+ {
+ "Id": "AISF-AI-03",
+ "Description": "Ensure Bedrock guardrails block or mask sensitive information (PII) in both model inputs and outputs to prevent data leakage through AI responses.",
+ "Name": "PII and Sensitive Data Filtering",
+ "Attributes": [
+ {
+ "Section": "AI Application Security",
+ "SubSection": "Output Filtering",
+ "Service": "bedrock",
+ "Type": "Automated"
+ }
+ ],
+ "Checks": [
+ "bedrock_guardrail_sensitive_information_filter_enabled"
+ ]
+ },
+ {
+ "Id": "AISF-AI-04",
+ "Description": "Ensure all Bedrock agents have guardrails enabled to protect agent sessions from prompt injection, data exfiltration, and unauthorized actions during agentic workflows.",
+ "Name": "Agent Guardrail Protection",
+ "Attributes": [
+ {
+ "Section": "AI Application Security",
+ "SubSection": "Agent Security",
+ "Service": "bedrock",
+ "Type": "Automated"
+ }
+ ],
+ "Checks": [
+ "bedrock_agent_guardrail_enabled"
+ ]
+ },
+ {
+ "Id": "AISF-AI-05",
+ "Description": "Ensure Bedrock model invocation logging is enabled to maintain an immutable audit trail of all model interactions, enabling incident investigation and behavioral analysis.",
+ "Name": "Model Invocation Logging",
+ "Attributes": [
+ {
+ "Section": "AI Application Security",
+ "SubSection": "AI Audit Logging",
+ "Service": "bedrock",
+ "Type": "Automated"
+ }
+ ],
+ "Checks": [
+ "bedrock_model_invocation_logging_enabled",
+ "bedrock_model_invocation_logs_encryption_enabled"
+ ]
+ },
+ {
+ "Id": "AISF-AI-06",
+ "Description": "Ensure CloudTrail is configured to log all Bedrock API calls for security auditing, enabling detection of unauthorized model access, configuration changes, and potential LLM jacking.",
+ "Name": "Bedrock API Audit Trail",
+ "Attributes": [
+ {
+ "Section": "AI Application Security",
+ "SubSection": "AI Audit Logging",
+ "Service": "cloudtrail",
+ "Type": "Automated"
+ }
+ ],
+ "Checks": [
+ "cloudtrail_threat_detection_llm_jacking"
+ ]
+ },
+ {
+ "Id": "AISF-AI-07",
+ "Description": "Ensure Bedrock prompts are encrypted at rest with customer-managed KMS keys and Prompt Management is used for centralized prompt governance.",
+ "Name": "Prompt Encryption and Management",
+ "Attributes": [
+ {
+ "Section": "AI Application Security",
+ "SubSection": "Prompt Management",
+ "Service": "bedrock",
+ "Type": "Automated"
+ }
+ ],
+ "Checks": [
+ "bedrock_prompt_encrypted_with_cmk",
+ "bedrock_prompt_management_exists"
+ ]
+ },
+ {
+ "Id": "AISF-AI-08",
+ "Description": "Ensure Bedrock Automated Reasoning Checks are configured to provide formal verification of model responses against source documents, achieving up to 99% verification accuracy against hallucinations.",
+ "Name": "Automated Reasoning for Output Validation",
+ "Attributes": [
+ {
+ "Section": "AI Application Security",
+ "SubSection": "Output Validation",
+ "Service": "bedrock",
+ "Type": "Manual"
+ }
+ ],
+ "Checks": []
+ },
+ {
+ "Id": "AISF-AI-09",
+ "Description": "Ensure Bedrock Contextual Grounding is configured to validate semantic consistency of model responses against sanctioned source documents, preventing hallucinated or fabricated outputs.",
+ "Name": "Contextual Grounding for RAG Validation",
+ "Attributes": [
+ {
+ "Section": "AI Application Security",
+ "SubSection": "Output Validation",
+ "Service": "bedrock",
+ "Type": "Manual"
+ }
+ ],
+ "Checks": []
+ },
+ {
+ "Id": "AISF-AI-10",
+ "Description": "Ensure Bedrock Knowledge Bases used for RAG patterns have proper security controls including encryption with customer-managed keys and VPC configuration for private data access.",
+ "Name": "Knowledge Base Security for RAG",
+ "Attributes": [
+ {
+ "Section": "AI Application Security",
+ "SubSection": "RAG Security",
+ "Service": "bedrock",
+ "Type": "Manual"
+ }
+ ],
+ "Checks": []
+ },
+ {
+ "Id": "AISF-AI-11",
+ "Description": "Ensure WAF AI Activity Dashboard is configured to monitor and analyze AI-specific traffic patterns, providing visibility into potential attacks targeting AI endpoints.",
+ "Name": "WAF AI Activity Monitoring",
+ "Attributes": [
+ {
+ "Section": "AI Application Security",
+ "SubSection": "AI Traffic Monitoring",
+ "Service": "wafv2",
+ "Type": "Manual"
+ }
+ ],
+ "Checks": []
+ },
+ {
+ "Id": "AISF-AGENT-01",
+ "Description": "Ensure every AI agent has its own identity with scoped credentials and independent authorization per request, following zero-trust principles. Agent identities must be separate from human user identities.",
+ "Name": "Agent Identity and Authentication",
+ "Attributes": [
+ {
+ "Section": "AI Application Security",
+ "SubSection": "Agent Governance",
+ "Service": "bedrock",
+ "Type": "Manual"
+ }
+ ],
+ "Checks": []
+ },
+ {
+ "Id": "AISF-AGENT-02",
+ "Description": "Ensure Bedrock AgentCore Cedar Policies enforce provable least-privilege authorization on every tool call and data access made by AI agents.",
+ "Name": "Agent Least-Privilege Authorization",
+ "Attributes": [
+ {
+ "Section": "AI Application Security",
+ "SubSection": "Agent Governance",
+ "Service": "bedrock",
+ "Type": "Manual"
+ }
+ ],
+ "Checks": []
+ },
+ {
+ "Id": "AISF-AGENT-03",
+ "Description": "Ensure AI agents have behavioral monitoring and observability configured to detect scope violations, anomalous actions, and drift from expected behavior patterns.",
+ "Name": "Agent Behavioral Monitoring",
+ "Attributes": [
+ {
+ "Section": "AI Application Security",
+ "SubSection": "Agent Governance",
+ "Service": "bedrock",
+ "Type": "Manual"
+ }
+ ],
+ "Checks": []
+ },
+ {
+ "Id": "AISF-AGENT-04",
+ "Description": "Ensure a central agent registry exists to catalog all AI agents, their permissions, data access patterns, and operational boundaries for governance at scale.",
+ "Name": "Agent Registry and Catalog",
+ "Attributes": [
+ {
+ "Section": "AI Application Security",
+ "SubSection": "Agent Governance",
+ "Service": "bedrock",
+ "Type": "Manual"
+ }
+ ],
+ "Checks": []
+ },
+ {
+ "Id": "AISF-AGENT-05",
+ "Description": "Ensure human-in-the-loop approval is required for high-consequence agent actions such as financial transactions, data deletion, or privilege changes.",
+ "Name": "Human Approval for Critical Agent Actions",
+ "Attributes": [
+ {
+ "Section": "AI Application Security",
+ "SubSection": "Agent Governance",
+ "Service": "bedrock",
+ "Type": "Manual"
+ }
+ ],
+ "Checks": []
+ },
+ {
+ "Id": "AISF-ML-01",
+ "Description": "Ensure SageMaker models have network isolation enabled to prevent models from making unauthorized network calls during inference.",
+ "Name": "ML Model Network Isolation",
+ "Attributes": [
+ {
+ "Section": "AI Application Security",
+ "SubSection": "ML Platform Security",
+ "Service": "sagemaker",
+ "Type": "Automated"
+ }
+ ],
+ "Checks": [
+ "sagemaker_models_network_isolation_enabled",
+ "sagemaker_models_vpc_settings_configured"
+ ]
+ },
+ {
+ "Id": "AISF-ML-02",
+ "Description": "Ensure SageMaker notebook instances are secured with encryption, VPC settings, no direct internet access, and root access disabled.",
+ "Name": "SageMaker Notebook Security",
+ "Attributes": [
+ {
+ "Section": "AI Application Security",
+ "SubSection": "ML Platform Security",
+ "Service": "sagemaker",
+ "Type": "Automated"
+ }
+ ],
+ "Checks": [
+ "sagemaker_notebook_instance_encryption_enabled",
+ "sagemaker_notebook_instance_vpc_settings_configured",
+ "sagemaker_notebook_instance_without_direct_internet_access_configured",
+ "sagemaker_notebook_instance_root_access_disabled"
+ ]
+ },
+ {
+ "Id": "AISF-ML-03",
+ "Description": "Ensure SageMaker training jobs have network isolation, VPC configuration, inter-container traffic encryption, and volume encryption enabled to protect training data and model weights.",
+ "Name": "ML Training Job Security",
+ "Attributes": [
+ {
+ "Section": "AI Application Security",
+ "SubSection": "ML Platform Security",
+ "Service": "sagemaker",
+ "Type": "Automated"
+ }
+ ],
+ "Checks": [
+ "sagemaker_training_jobs_network_isolation_enabled",
+ "sagemaker_training_jobs_vpc_settings_configured",
+ "sagemaker_training_jobs_intercontainer_encryption_enabled",
+ "sagemaker_training_jobs_volume_and_output_encryption_enabled"
+ ]
+ },
+ {
+ "Id": "AISF-ML-04",
+ "Description": "Ensure SageMaker Model Registry is in use with approved model packages and SSO authentication is configured for SageMaker domains.",
+ "Name": "Model Governance and Registry",
+ "Attributes": [
+ {
+ "Section": "AI Application Security",
+ "SubSection": "ML Platform Security",
+ "Service": "sagemaker",
+ "Type": "Automated"
+ }
+ ],
+ "Checks": [
+ "sagemaker_models_registry_in_use",
+ "sagemaker_domain_sso_configured"
+ ]
+ },
+ {
+ "Id": "AISF-ML-05",
+ "Description": "Ensure SageMaker Model Monitor is configured for continuous model quality and bias monitoring, and SageMaker Clarify is used for bias detection in AI/ML workloads.",
+ "Name": "Model Monitoring and Bias Detection",
+ "Attributes": [
+ {
+ "Section": "AI Application Security",
+ "SubSection": "ML Platform Security",
+ "Service": "sagemaker",
+ "Type": "Manual"
+ }
+ ],
+ "Checks": []
+ },
+ {
+ "Id": "AISF-DETECT-01",
+ "Description": "Ensure CloudTrail is enabled in all regions with multi-region logging, management event recording, log file validation, and CloudWatch integration for comprehensive AI workload audit trails.",
+ "Name": "Comprehensive Audit Logging",
+ "Attributes": [
+ {
+ "Section": "Threat Detection and Monitoring",
+ "SubSection": "Audit Logging",
+ "Service": "cloudtrail",
+ "Type": "Automated"
+ }
+ ],
+ "Checks": [
+ "cloudtrail_multi_region_enabled",
+ "cloudtrail_multi_region_enabled_logging_management_events",
+ "cloudtrail_log_file_validation_enabled",
+ "cloudtrail_cloudwatch_logging_enabled",
+ "cloudtrail_kms_encryption_enabled",
+ "cloudtrail_logs_s3_bucket_is_not_publicly_accessible",
+ "cloudtrail_logs_s3_bucket_access_logging_enabled",
+ "cloudtrail_insights_exist"
+ ]
+ },
+ {
+ "Id": "AISF-DETECT-02",
+ "Description": "Ensure GuardDuty is enabled across all regions with delegated admin, S3 protection, EKS monitoring, Lambda protection, and malware protection to detect AI-specific threat patterns.",
+ "Name": "GuardDuty Threat Detection",
+ "Attributes": [
+ {
+ "Section": "Threat Detection and Monitoring",
+ "SubSection": "Threat Detection",
+ "Service": "guardduty",
+ "Type": "Automated"
+ }
+ ],
+ "Checks": [
+ "guardduty_is_enabled",
+ "guardduty_no_high_severity_findings",
+ "guardduty_centrally_managed",
+ "guardduty_delegated_admin_enabled_all_regions",
+ "guardduty_s3_protection_enabled",
+ "guardduty_eks_audit_log_enabled",
+ "guardduty_eks_runtime_monitoring_enabled",
+ "guardduty_lambda_protection_enabled",
+ "guardduty_rds_protection_enabled",
+ "guardduty_ec2_malware_protection_enabled"
+ ],
+ "ConfigRequirements": [
+ {
+ "Check": "guardduty_is_enabled",
+ "ConfigKey": "mute_non_default_regions",
+ "Operator": "eq",
+ "Value": false
+ },
+ {
+ "Check": "guardduty_delegated_admin_enabled_all_regions",
+ "ConfigKey": "mute_non_default_regions",
+ "Operator": "eq",
+ "Value": false
+ }
+ ]
+ },
+ {
+ "Id": "AISF-DETECT-03",
+ "Description": "Ensure CloudTrail-based threat detection is monitoring for LLM jacking, privilege escalation, and enumeration activity that could indicate attacks against AI infrastructure.",
+ "Name": "AI-Specific Threat Detection",
+ "Attributes": [
+ {
+ "Section": "Threat Detection and Monitoring",
+ "SubSection": "AI Threat Detection",
+ "Service": "cloudtrail",
+ "Type": "Automated"
+ }
+ ],
+ "Checks": [
+ "cloudtrail_threat_detection_llm_jacking",
+ "cloudtrail_threat_detection_privilege_escalation",
+ "cloudtrail_threat_detection_enumeration"
+ ]
+ },
+ {
+ "Id": "AISF-DETECT-04",
+ "Description": "Ensure Security Hub is enabled with standards and integrations configured to aggregate and prioritize security findings across all AI workload services.",
+ "Name": "Security Hub Centralized Findings",
+ "Attributes": [
+ {
+ "Section": "Threat Detection and Monitoring",
+ "SubSection": "Security Aggregation",
+ "Service": "securityhub",
+ "Type": "Automated"
+ }
+ ],
+ "Checks": [
+ "securityhub_enabled"
+ ],
+ "ConfigRequirements": [
+ {
+ "Check": "securityhub_enabled",
+ "ConfigKey": "mute_non_default_regions",
+ "Operator": "eq",
+ "Value": false
+ }
+ ]
+ },
+ {
+ "Id": "AISF-DETECT-05",
+ "Description": "Ensure CloudWatch metric filters and alarms are configured for critical security events including IAM policy changes, unauthorized API calls, console sign-in without MFA, KMS key deletion, and network changes.",
+ "Name": "Security Event Alerting",
+ "Attributes": [
+ {
+ "Section": "Threat Detection and Monitoring",
+ "SubSection": "Security Alerting",
+ "Service": "cloudwatch",
+ "Type": "Automated"
+ }
+ ],
+ "Checks": [
+ "cloudwatch_log_metric_filter_policy_changes",
+ "cloudwatch_log_metric_filter_unauthorized_api_calls",
+ "cloudwatch_log_metric_filter_sign_in_without_mfa",
+ "cloudwatch_log_metric_filter_root_usage",
+ "cloudwatch_log_metric_filter_disable_or_scheduled_deletion_of_kms_cmk",
+ "cloudwatch_log_metric_filter_security_group_changes",
+ "cloudwatch_changes_to_network_acls_alarm_configured",
+ "cloudwatch_changes_to_vpcs_alarm_configured",
+ "cloudwatch_log_metric_filter_authentication_failures",
+ "cloudwatch_log_metric_filter_aws_organizations_changes"
+ ]
+ },
+ {
+ "Id": "AISF-DETECT-06",
+ "Description": "Ensure Amazon Detective is enabled for AI security incident investigation, providing full decision chain reconstruction from prompt to data access to action.",
+ "Name": "AI Incident Investigation",
+ "Attributes": [
+ {
+ "Section": "Threat Detection and Monitoring",
+ "SubSection": "Incident Investigation",
+ "Service": "detective",
+ "Type": "Manual"
+ }
+ ],
+ "Checks": []
+ },
+ {
+ "Id": "AISF-DETECT-07",
+ "Description": "Ensure GuardDuty Extended Threat Detection is enabled for AI-specific patterns including anomalous Bedrock API usage, model access from unusual locations, and potential data exfiltration through AI channels.",
+ "Name": "GuardDuty AI Threat Patterns",
+ "Attributes": [
+ {
+ "Section": "Threat Detection and Monitoring",
+ "SubSection": "AI Threat Detection",
+ "Service": "guardduty",
+ "Type": "Manual"
+ }
+ ],
+ "Checks": []
+ },
+ {
+ "Id": "AISF-GOV-01",
+ "Description": "Ensure AWS Config recorder is enabled in all regions to continuously monitor and record AI resource configurations, detect drift, and enforce compliance rules.",
+ "Name": "Configuration Compliance Monitoring",
+ "Attributes": [
+ {
+ "Section": "Governance and Compliance",
+ "SubSection": "Configuration Management",
+ "Service": "config",
+ "Type": "Automated"
+ }
+ ],
+ "Checks": [
+ "config_recorder_all_regions_enabled",
+ "config_recorder_using_aws_service_role"
+ ],
+ "ConfigRequirements": [
+ {
+ "Check": "config_recorder_all_regions_enabled",
+ "ConfigKey": "mute_non_default_regions",
+ "Operator": "eq",
+ "Value": false
+ }
+ ]
+ },
+ {
+ "Id": "AISF-GOV-02",
+ "Description": "Ensure the AWS account is part of an AWS Organization with proper governance controls including SCPs to restrict operations to approved regions and delegated administrators are trusted.",
+ "Name": "Organization Governance",
+ "Attributes": [
+ {
+ "Section": "Governance and Compliance",
+ "SubSection": "Account Governance",
+ "Service": "organizations",
+ "Type": "Automated"
+ }
+ ],
+ "Checks": [
+ "organizations_account_part_of_organizations",
+ "organizations_scp_check_deny_regions",
+ "organizations_delegated_administrators",
+ "organizations_tags_policies_enabled_and_attached"
+ ]
+ },
+ {
+ "Id": "AISF-GOV-03",
+ "Description": "Ensure security contact information is registered and current for AI workload incident response communication.",
+ "Name": "Security Contact Information",
+ "Attributes": [
+ {
+ "Section": "Governance and Compliance",
+ "SubSection": "Incident Response",
+ "Service": "account",
+ "Type": "Automated"
+ }
+ ],
+ "Checks": [
+ "account_maintain_current_contact_details",
+ "account_maintain_different_contact_details_to_security_billing_and_operations",
+ "account_security_contact_information_is_registered"
+ ]
+ },
+ {
+ "Id": "AISF-GOV-04",
+ "Description": "Ensure AWS Control Tower is enabled with landing zone configured to automate account governance and enforce security baselines across all accounts hosting AI workloads.",
+ "Name": "Control Tower Automated Governance",
+ "Attributes": [
+ {
+ "Section": "Governance and Compliance",
+ "SubSection": "Automated Governance",
+ "Service": "controltower",
+ "Type": "Manual"
+ }
+ ],
+ "Checks": []
+ },
+ {
+ "Id": "AISF-GOV-05",
+ "Description": "Maintain an inventory of all AI workloads including approved and shadow AI usage. Document model selections, their governance requirements, and security evaluations.",
+ "Name": "AI Workload Inventory and Audit",
+ "Attributes": [
+ {
+ "Section": "Governance and Compliance",
+ "SubSection": "AI Governance",
+ "Service": "multiple",
+ "Type": "Manual"
+ }
+ ],
+ "Checks": []
+ },
+ {
+ "Id": "AISF-GOV-06",
+ "Description": "Ensure AI-specific threat models are developed before production deployment, covering prompt injection, jailbreaks, data exfiltration, model poisoning, and adversarial attacks.",
+ "Name": "AI Threat Modeling",
+ "Attributes": [
+ {
+ "Section": "Governance and Compliance",
+ "SubSection": "AI Governance",
+ "Service": "multiple",
+ "Type": "Manual"
+ }
+ ],
+ "Checks": []
+ },
+ {
+ "Id": "AISF-GOV-07",
+ "Description": "Ensure incident response plans include AI-specific scenarios covering prompt injection, model manipulation, data exfiltration through AI, LLM jacking, and agent scope violations.",
+ "Name": "AI Incident Response Planning",
+ "Attributes": [
+ {
+ "Section": "Governance and Compliance",
+ "SubSection": "Incident Response",
+ "Service": "multiple",
+ "Type": "Manual"
+ }
+ ],
+ "Checks": []
+ },
+ {
+ "Id": "AISF-RUNTIME-01",
+ "Description": "Ensure EKS clusters used for AI agent runtimes are properly secured with private endpoints, network policies, supported versions, control plane logging, and secrets encryption.",
+ "Name": "EKS Security for AI Runtimes",
+ "Attributes": [
+ {
+ "Section": "Infrastructure Security",
+ "SubSection": "Container Runtime Security",
+ "Service": "eks",
+ "Type": "Automated"
+ }
+ ],
+ "Checks": [
+ "eks_cluster_not_publicly_accessible",
+ "eks_cluster_private_nodes_enabled",
+ "eks_cluster_network_policy_enabled",
+ "eks_cluster_uses_a_supported_version",
+ "eks_control_plane_logging_all_types_enabled",
+ "eks_cluster_kms_cmk_encryption_in_secrets_enabled",
+ "eks_cluster_deletion_protection_enabled"
+ ]
+ },
+ {
+ "Id": "AISF-RUNTIME-02",
+ "Description": "Ensure ECS tasks used for AI workloads have no public IPs, no privileged containers, read-only root filesystems, logging enabled, and no secrets in environment variables.",
+ "Name": "ECS Security for AI Workloads",
+ "Attributes": [
+ {
+ "Section": "Infrastructure Security",
+ "SubSection": "Container Runtime Security",
+ "Service": "ecs",
+ "Type": "Automated"
+ }
+ ],
+ "Checks": [
+ "ecs_service_no_assign_public_ip",
+ "ecs_task_set_no_assign_public_ip",
+ "ecs_task_definitions_no_privileged_containers",
+ "ecs_task_definitions_containers_readonly_access",
+ "ecs_task_definitions_logging_enabled",
+ "ecs_task_definitions_no_environment_secrets",
+ "ecs_task_definitions_host_namespace_not_shared",
+ "ecs_cluster_container_insights_enabled"
+ ]
+ },
+ {
+ "Id": "AISF-RUNTIME-03",
+ "Description": "Ensure Lambda functions used in AI architectures are deployed in VPCs, not publicly accessible, use supported runtimes, have no secrets in code or variables, and use CMK-encrypted environment variables.",
+ "Name": "Lambda Security for AI Functions",
+ "Attributes": [
+ {
+ "Section": "Infrastructure Security",
+ "SubSection": "Serverless Runtime Security",
+ "Service": "awslambda",
+ "Type": "Automated"
+ }
+ ],
+ "Checks": [
+ "awslambda_function_inside_vpc",
+ "awslambda_function_not_publicly_accessible",
+ "awslambda_function_url_public",
+ "awslambda_function_no_secrets_in_code",
+ "awslambda_function_no_secrets_in_variables",
+ "awslambda_function_using_supported_runtimes",
+ "awslambda_function_env_vars_not_encrypted_with_cmk",
+ "awslambda_function_url_cors_policy",
+ "awslambda_function_invoke_api_operations_cloudtrail_logging_enabled"
+ ]
+ },
+ {
+ "Id": "AISF-MODEL-01",
+ "Description": "Perform security evaluation of foundation models before deployment, including assessment of input sanitization, access controls, bias audits, privacy disclosure, data poisoning resilience, adversarial resilience, and prompt injection defenses.",
+ "Name": "Model Security Evaluation",
+ "Attributes": [
+ {
+ "Section": "AI Application Security",
+ "SubSection": "Model Governance",
+ "Service": "bedrock",
+ "Type": "Manual"
+ }
+ ],
+ "Checks": []
+ },
+ {
+ "Id": "AISF-MODEL-02",
+ "Description": "Ensure model selection is appropriate for the use case with CISO involvement in evaluation. Customer-facing agents require different model security profiles than internal summarization tools.",
+ "Name": "Model Selection Governance",
+ "Attributes": [
+ {
+ "Section": "AI Application Security",
+ "SubSection": "Model Governance",
+ "Service": "bedrock",
+ "Type": "Manual"
+ }
+ ],
+ "Checks": []
+ }
+ ]
+}
diff --git a/prowler/compliance/aws/aws_foundational_security_best_practices_aws.json b/prowler/compliance/aws/aws_foundational_security_best_practices_aws.json
index a64a421c8a..b58a74bcaa 100644
--- a/prowler/compliance/aws/aws_foundational_security_best_practices_aws.json
+++ b/prowler/compliance/aws/aws_foundational_security_best_practices_aws.json
@@ -20,6 +20,14 @@
"SectionDescription": "This section contains recommendations for configuring ACM resources.",
"Service": "ACM"
}
+ ],
+ "ConfigRequirements": [
+ {
+ "Check": "acm_certificates_expiration_check",
+ "ConfigKey": "days_to_expire_threshold",
+ "Operator": "gte",
+ "Value": 30
+ }
]
},
{
@@ -29,6 +37,17 @@
"Checks": [
"acm_certificates_with_secure_key_algorithms"
],
+ "ConfigRequirements": [
+ {
+ "Check": "acm_certificates_with_secure_key_algorithms",
+ "ConfigKey": "insecure_key_algorithms",
+ "Operator": "superset",
+ "Value": [
+ "RSA-1024",
+ "P-192"
+ ]
+ }
+ ],
"Attributes": [
{
"ItemId": "ACM.2",
@@ -777,6 +796,14 @@
"Checks": [
"config_recorder_all_regions_enabled"
],
+ "ConfigRequirements": [
+ {
+ "Check": "config_recorder_all_regions_enabled",
+ "ConfigKey": "mute_non_default_regions",
+ "Operator": "eq",
+ "Value": false
+ }
+ ],
"Attributes": [
{
"ItemId": "Config.1",
@@ -892,6 +919,14 @@
"Checks": [
"documentdb_cluster_backup_enabled"
],
+ "ConfigRequirements": [
+ {
+ "Check": "documentdb_cluster_backup_enabled",
+ "ConfigKey": "minimum_backup_retention_period",
+ "Operator": "gte",
+ "Value": 7
+ }
+ ],
"Attributes": [
{
"ItemId": "DocumentDB.2",
@@ -1863,7 +1898,9 @@
"Id": "ELB.4",
"Name": "Application load balancers should be configured to drop HTTP headers",
"Description": "This control evaluates AWS Application Load Balancers (ALB) to ensure they are configured to drop invalid HTTP headers. The control fails if the value of routing.http.drop_invalid_header_fields.enabled is set to false. By default, ALBs are not configured to drop invalid HTTP header values. Removing these header values prevents HTTP desync attacks.",
- "Checks": [],
+ "Checks": [
+ "elbv2_alb_drop_invalid_header_fields_enabled"
+ ],
"Attributes": [
{
"ItemId": "ELB.4",
@@ -1957,6 +1994,14 @@
"SectionDescription": "This section contains recommendations for configuring ELB resources.",
"Service": "ELB"
}
+ ],
+ "ConfigRequirements": [
+ {
+ "Check": "elb_is_in_multiple_az",
+ "ConfigKey": "elb_min_azs",
+ "Operator": "gte",
+ "Value": 2
+ }
]
},
{
@@ -1991,6 +2036,14 @@
"SectionDescription": "This section contains recommendations for configuring ELB resources.",
"Service": "ELB"
}
+ ],
+ "ConfigRequirements": [
+ {
+ "Check": "elbv2_is_in_multiple_az",
+ "ConfigKey": "elbv2_min_azs",
+ "Operator": "gte",
+ "Value": 2
+ }
]
},
{
@@ -2368,6 +2421,14 @@
"Checks": [
"guardduty_is_enabled"
],
+ "ConfigRequirements": [
+ {
+ "Check": "guardduty_is_enabled",
+ "ConfigKey": "mute_non_default_regions",
+ "Operator": "eq",
+ "Value": false
+ }
+ ],
"Attributes": [
{
"ItemId": "GuardDuty.1",
@@ -2545,6 +2606,20 @@
"iam_user_accesskey_unused",
"iam_user_console_access_unused"
],
+ "ConfigRequirements": [
+ {
+ "Check": "iam_user_accesskey_unused",
+ "ConfigKey": "max_unused_access_keys_days",
+ "Operator": "lte",
+ "Value": 90
+ },
+ {
+ "Check": "iam_user_console_access_unused",
+ "ConfigKey": "max_console_access_days",
+ "Operator": "lte",
+ "Value": 90
+ }
+ ],
"Attributes": [
{
"ItemId": "IAM.8",
@@ -2633,6 +2708,20 @@
"iam_user_accesskey_unused",
"iam_user_console_access_unused"
],
+ "ConfigRequirements": [
+ {
+ "Check": "iam_user_accesskey_unused",
+ "ConfigKey": "max_unused_access_keys_days",
+ "Operator": "lte",
+ "Value": 45
+ },
+ {
+ "Check": "iam_user_console_access_unused",
+ "ConfigKey": "max_console_access_days",
+ "Operator": "lte",
+ "Value": 45
+ }
+ ],
"Attributes": [
{
"ItemId": "IAM.22",
@@ -2789,6 +2878,40 @@
"SectionDescription": "This section contains recommendations for configuring Lambda resources.",
"Service": "Lambda"
}
+ ],
+ "ConfigRequirements": [
+ {
+ "Check": "awslambda_function_using_supported_runtimes",
+ "ConfigKey": "obsolete_lambda_runtimes",
+ "Operator": "superset",
+ "Value": [
+ "java8",
+ "go1.x",
+ "provided",
+ "python3.6",
+ "python2.7",
+ "python3.7",
+ "python3.8",
+ "nodejs4.3",
+ "nodejs4.3-edge",
+ "nodejs6.10",
+ "nodejs",
+ "nodejs8.10",
+ "nodejs10.x",
+ "nodejs12.x",
+ "nodejs14.x",
+ "nodejs16.x",
+ "dotnet5.0",
+ "dotnet6",
+ "dotnet7",
+ "dotnetcore1.0",
+ "dotnetcore2.0",
+ "dotnetcore2.1",
+ "dotnetcore3.1",
+ "ruby2.5",
+ "ruby2.7"
+ ]
+ }
]
},
{
@@ -2949,6 +3072,14 @@
"Checks": [
"neptune_cluster_backup_enabled"
],
+ "ConfigRequirements": [
+ {
+ "Check": "neptune_cluster_backup_enabled",
+ "ConfigKey": "minimum_backup_retention_period",
+ "Operator": "gte",
+ "Value": 7
+ }
+ ],
"Attributes": [
{
"ItemId": "Neptune.5",
diff --git a/prowler/compliance/aws/aws_foundational_technical_review_aws.json b/prowler/compliance/aws/aws_foundational_technical_review_aws.json
index 691a8ba7f1..9d8e3cfdc8 100644
--- a/prowler/compliance/aws/aws_foundational_technical_review_aws.json
+++ b/prowler/compliance/aws/aws_foundational_technical_review_aws.json
@@ -176,6 +176,14 @@
"iam_user_with_temporary_credentials",
"guardduty_is_enabled",
"guardduty_no_high_severity_findings"
+ ],
+ "ConfigRequirements": [
+ {
+ "Check": "guardduty_is_enabled",
+ "ConfigKey": "mute_non_default_regions",
+ "Operator": "eq",
+ "Value": false
+ }
]
},
{
diff --git a/prowler/compliance/aws/aws_well_architected_framework_security_pillar_aws.json b/prowler/compliance/aws/aws_well_architected_framework_security_pillar_aws.json
index 94c50eb68a..9dd009afb3 100644
--- a/prowler/compliance/aws/aws_well_architected_framework_security_pillar_aws.json
+++ b/prowler/compliance/aws/aws_well_architected_framework_security_pillar_aws.json
@@ -585,6 +585,14 @@
"cloudtrail_multi_region_enabled",
"vpc_flow_logs_enabled",
"config_recorder_all_regions_enabled"
+ ],
+ "ConfigRequirements": [
+ {
+ "Check": "config_recorder_all_regions_enabled",
+ "ConfigKey": "mute_non_default_regions",
+ "Operator": "eq",
+ "Value": false
+ }
]
},
{
@@ -646,6 +654,20 @@
"guardduty_no_high_severity_findings",
"macie_is_enabled",
"guardduty_is_enabled"
+ ],
+ "ConfigRequirements": [
+ {
+ "Check": "securityhub_enabled",
+ "ConfigKey": "mute_non_default_regions",
+ "Operator": "eq",
+ "Value": false
+ },
+ {
+ "Check": "guardduty_is_enabled",
+ "ConfigKey": "mute_non_default_regions",
+ "Operator": "eq",
+ "Value": false
+ }
]
},
{
@@ -778,6 +800,14 @@
"guardduty_is_enabled",
"vpc_flow_logs_enabled",
"apigateway_restapi_authorizers_enabled"
+ ],
+ "ConfigRequirements": [
+ {
+ "Check": "guardduty_is_enabled",
+ "ConfigKey": "mute_non_default_regions",
+ "Operator": "eq",
+ "Value": false
+ }
]
},
{
@@ -1151,6 +1181,10 @@
"elb_insecure_ssl_ciphers",
"elb_ssl_listeners",
"elbv2_insecure_ssl_ciphers",
+ "elbv2_listener_pqc_tls_enabled",
+ "cloudfront_distributions_pqc_tls_enabled",
+ "apigateway_domain_name_pqc_tls_enabled",
+ "transfer_server_pqc_ssh_kex_enabled",
"elbv2_ssl_listeners",
"s3_bucket_secure_transport_policy"
]
diff --git a/prowler/compliance/aws/c5_aws.json b/prowler/compliance/aws/c5_aws.json
index 8847469154..269a5ce308 100644
--- a/prowler/compliance/aws/c5_aws.json
+++ b/prowler/compliance/aws/c5_aws.json
@@ -382,6 +382,14 @@
"cloudtrail_multi_region_enabled",
"config_recorder_all_regions_enabled",
"s3_multi_region_access_point_public_access_block"
+ ],
+ "ConfigRequirements": [
+ {
+ "Check": "config_recorder_all_regions_enabled",
+ "ConfigKey": "mute_non_default_regions",
+ "Operator": "eq",
+ "Value": false
+ }
]
},
{
@@ -2234,6 +2242,14 @@
"vpc_different_regions",
"autoscaling_group_multiple_az",
"storagegateway_gateway_fault_tolerant"
+ ],
+ "ConfigRequirements": [
+ {
+ "Check": "config_recorder_all_regions_enabled",
+ "ConfigKey": "mute_non_default_regions",
+ "Operator": "eq",
+ "Value": false
+ }
]
},
{
@@ -2261,6 +2277,14 @@
"organizations_scp_check_deny_regions",
"s3_multi_region_access_point_public_access_block",
"vpc_different_regions"
+ ],
+ "ConfigRequirements": [
+ {
+ "Check": "config_recorder_all_regions_enabled",
+ "ConfigKey": "mute_non_default_regions",
+ "Operator": "eq",
+ "Value": false
+ }
]
},
{
@@ -2308,6 +2332,14 @@
"organizations_scp_check_deny_regions",
"s3_multi_region_access_point_public_access_block",
"vpc_different_regions"
+ ],
+ "ConfigRequirements": [
+ {
+ "Check": "config_recorder_all_regions_enabled",
+ "ConfigKey": "mute_non_default_regions",
+ "Operator": "eq",
+ "Value": false
+ }
]
},
{
@@ -2978,6 +3010,14 @@
"guardduty_is_enabled",
"athena_workgroup_enforce_configuration",
"shield_advanced_protection_in_global_accelerators"
+ ],
+ "ConfigRequirements": [
+ {
+ "Check": "guardduty_is_enabled",
+ "ConfigKey": "mute_non_default_regions",
+ "Operator": "eq",
+ "Value": false
+ }
]
},
{
@@ -3481,6 +3521,14 @@
"cloudtrail_cloudwatch_logging_enabled",
"guardduty_is_enabled",
"guardduty_no_high_severity_findings"
+ ],
+ "ConfigRequirements": [
+ {
+ "Check": "guardduty_is_enabled",
+ "ConfigKey": "mute_non_default_regions",
+ "Operator": "eq",
+ "Value": false
+ }
]
},
{
@@ -4299,6 +4347,14 @@
"guardduty_no_high_severity_findings",
"guardduty_rds_protection_enabled",
"guardduty_s3_protection_enabled"
+ ],
+ "ConfigRequirements": [
+ {
+ "Check": "guardduty_is_enabled",
+ "ConfigKey": "mute_non_default_regions",
+ "Operator": "eq",
+ "Value": false
+ }
]
},
{
@@ -4920,6 +4976,17 @@
"elbv2_nlb_tls_termination_enabled",
"transfer_server_in_transit_encryption_enabled",
"kafka_cluster_mutual_tls_authentication_enabled"
+ ],
+ "ConfigRequirements": [
+ {
+ "Check": "acm_certificates_with_secure_key_algorithms",
+ "ConfigKey": "insecure_key_algorithms",
+ "Operator": "superset",
+ "Value": [
+ "RSA-1024",
+ "P-192"
+ ]
+ }
]
},
{
@@ -4946,6 +5013,17 @@
"elbv2_nlb_tls_termination_enabled",
"transfer_server_in_transit_encryption_enabled",
"kafka_cluster_mutual_tls_authentication_enabled"
+ ],
+ "ConfigRequirements": [
+ {
+ "Check": "acm_certificates_with_secure_key_algorithms",
+ "ConfigKey": "insecure_key_algorithms",
+ "Operator": "superset",
+ "Value": [
+ "RSA-1024",
+ "P-192"
+ ]
+ }
]
},
{
@@ -5220,6 +5298,14 @@
"rds_instance_default_admin",
"accessanalyzer_enabled",
"efs_access_point_enforce_user_identity"
+ ],
+ "ConfigRequirements": [
+ {
+ "Check": "accessanalyzer_enabled",
+ "ConfigKey": "mute_non_default_regions",
+ "Operator": "eq",
+ "Value": false
+ }
]
},
{
@@ -5737,6 +5823,14 @@
"Checks": [
"accessanalyzer_enabled",
"accessanalyzer_enabled_without_findings"
+ ],
+ "ConfigRequirements": [
+ {
+ "Check": "accessanalyzer_enabled",
+ "ConfigKey": "mute_non_default_regions",
+ "Operator": "eq",
+ "Value": false
+ }
]
},
{
@@ -6100,6 +6194,17 @@
"cloudfront_distributions_origin_traffic_encrypted",
"glue_development_endpoints_job_bookmark_encryption_enabled",
"cloudtrail_kms_encryption_enabled"
+ ],
+ "ConfigRequirements": [
+ {
+ "Check": "acm_certificates_with_secure_key_algorithms",
+ "ConfigKey": "insecure_key_algorithms",
+ "Operator": "superset",
+ "Value": [
+ "RSA-1024",
+ "P-192"
+ ]
+ }
]
},
{
@@ -6196,6 +6301,17 @@
"elb_ssl_listeners_use_acm_certificate",
"iam_no_expired_server_certificates_stored",
"rds_instance_certificate_expiration"
+ ],
+ "ConfigRequirements": [
+ {
+ "Check": "acm_certificates_with_secure_key_algorithms",
+ "ConfigKey": "insecure_key_algorithms",
+ "Operator": "superset",
+ "Value": [
+ "RSA-1024",
+ "P-192"
+ ]
+ }
]
},
{
@@ -6307,6 +6423,17 @@
"elb_ssl_listeners_use_acm_certificate",
"iam_no_expired_server_certificates_stored",
"rds_instance_certificate_expiration"
+ ],
+ "ConfigRequirements": [
+ {
+ "Check": "acm_certificates_with_secure_key_algorithms",
+ "ConfigKey": "insecure_key_algorithms",
+ "Operator": "superset",
+ "Value": [
+ "RSA-1024",
+ "P-192"
+ ]
+ }
]
},
{
@@ -6393,6 +6520,14 @@
"sns_topics_not_publicly_accessible",
"sqs_queues_not_publicly_accessible",
"vpc_peering_routing_tables_with_least_privilege"
+ ],
+ "ConfigRequirements": [
+ {
+ "Check": "accessanalyzer_enabled",
+ "ConfigKey": "mute_non_default_regions",
+ "Operator": "eq",
+ "Value": false
+ }
]
},
{
@@ -6412,6 +6547,14 @@
"ec2_instance_profile_attached",
"accessanalyzer_enabled",
"accessanalyzer_enabled_without_findings"
+ ],
+ "ConfigRequirements": [
+ {
+ "Check": "accessanalyzer_enabled",
+ "ConfigKey": "mute_non_default_regions",
+ "Operator": "eq",
+ "Value": false
+ }
]
},
{
@@ -6587,6 +6730,17 @@
"kms_cmk_not_multi_region",
"kms_key_not_publicly_accessible",
"ec2_ebs_volume_encryption"
+ ],
+ "ConfigRequirements": [
+ {
+ "Check": "acm_certificates_with_secure_key_algorithms",
+ "ConfigKey": "insecure_key_algorithms",
+ "Operator": "superset",
+ "Value": [
+ "RSA-1024",
+ "P-192"
+ ]
+ }
]
},
{
@@ -6809,6 +6963,17 @@
"secretsmanager_not_publicly_accessible",
"secretsmanager_secret_rotated_periodically",
"secretsmanager_secret_unused"
+ ],
+ "ConfigRequirements": [
+ {
+ "Check": "acm_certificates_with_secure_key_algorithms",
+ "ConfigKey": "insecure_key_algorithms",
+ "Operator": "superset",
+ "Value": [
+ "RSA-1024",
+ "P-192"
+ ]
+ }
]
},
{
@@ -6842,6 +7007,17 @@
],
"Checks": [
"acm_certificates_with_secure_key_algorithms"
+ ],
+ "ConfigRequirements": [
+ {
+ "Check": "acm_certificates_with_secure_key_algorithms",
+ "ConfigKey": "insecure_key_algorithms",
+ "Operator": "superset",
+ "Value": [
+ "RSA-1024",
+ "P-192"
+ ]
+ }
]
},
{
@@ -6915,6 +7091,17 @@
"secretsmanager_secret_rotated_periodically",
"secretsmanager_secret_unused",
"acm_certificates_with_secure_key_algorithms"
+ ],
+ "ConfigRequirements": [
+ {
+ "Check": "acm_certificates_with_secure_key_algorithms",
+ "ConfigKey": "insecure_key_algorithms",
+ "Operator": "superset",
+ "Value": [
+ "RSA-1024",
+ "P-192"
+ ]
+ }
]
},
{
@@ -6937,6 +7124,17 @@
"secretsmanager_secret_rotated_periodically",
"secretsmanager_secret_unused",
"acm_certificates_with_secure_key_algorithms"
+ ],
+ "ConfigRequirements": [
+ {
+ "Check": "acm_certificates_with_secure_key_algorithms",
+ "ConfigKey": "insecure_key_algorithms",
+ "Operator": "superset",
+ "Value": [
+ "RSA-1024",
+ "P-192"
+ ]
+ }
]
},
{
@@ -8042,6 +8240,14 @@
"cloudtrail_multi_region_enabled",
"cloudtrail_multi_region_enabled_logging_management_events",
"cloudtrail_log_file_validation_enabled"
+ ],
+ "ConfigRequirements": [
+ {
+ "Check": "config_recorder_all_regions_enabled",
+ "ConfigKey": "mute_non_default_regions",
+ "Operator": "eq",
+ "Value": false
+ }
]
},
{
@@ -8810,6 +9016,14 @@
"guardduty_is_enabled",
"cloudtrail_log_file_validation_enabled",
"ssmincidents_enabled_with_plans"
+ ],
+ "ConfigRequirements": [
+ {
+ "Check": "guardduty_is_enabled",
+ "ConfigKey": "mute_non_default_regions",
+ "Operator": "eq",
+ "Value": false
+ }
]
},
{
@@ -9732,6 +9946,14 @@
"accessanalyzer_enabled_without_findings",
"cloudfront_distributions_s3_origin_access_control",
"cloudtrail_logs_s3_bucket_access_logging_enabled"
+ ],
+ "ConfigRequirements": [
+ {
+ "Check": "accessanalyzer_enabled",
+ "ConfigKey": "mute_non_default_regions",
+ "Operator": "eq",
+ "Value": false
+ }
]
},
{
@@ -10367,6 +10589,14 @@
"Checks": [
"accessanalyzer_enabled",
"accessanalyzer_enabled_without_findings"
+ ],
+ "ConfigRequirements": [
+ {
+ "Check": "accessanalyzer_enabled",
+ "ConfigKey": "mute_non_default_regions",
+ "Operator": "eq",
+ "Value": false
+ }
]
},
{
@@ -10457,6 +10687,14 @@
"ec2_instance_profile_attached",
"iam_role_cross_account_readonlyaccess_policy",
"iam_securityaudit_role_created"
+ ],
+ "ConfigRequirements": [
+ {
+ "Check": "accessanalyzer_enabled",
+ "ConfigKey": "mute_non_default_regions",
+ "Operator": "eq",
+ "Value": false
+ }
]
},
{
diff --git a/prowler/compliance/aws/ccc_aws.json b/prowler/compliance/aws/ccc_aws.json
index d1a20ee3d0..ef31bfe1ea 100644
--- a/prowler/compliance/aws/ccc_aws.json
+++ b/prowler/compliance/aws/ccc_aws.json
@@ -49,6 +49,10 @@
"elb_insecure_ssl_ciphers",
"elb_ssl_listeners",
"elbv2_insecure_ssl_ciphers",
+ "elbv2_listener_pqc_tls_enabled",
+ "cloudfront_distributions_pqc_tls_enabled",
+ "apigateway_domain_name_pqc_tls_enabled",
+ "transfer_server_pqc_ssh_kex_enabled",
"elbv2_ssl_listeners",
"elbv2_nlb_tls_termination_enabled",
"s3_bucket_secure_transport_policy",
@@ -272,6 +276,17 @@
"acm_certificates_expiration_check",
"acm_certificates_with_secure_key_algorithms",
"acm_certificates_transparency_logs_enabled"
+ ],
+ "ConfigRequirements": [
+ {
+ "Check": "acm_certificates_with_secure_key_algorithms",
+ "ConfigKey": "insecure_key_algorithms",
+ "Operator": "superset",
+ "Value": [
+ "RSA-1024",
+ "P-192"
+ ]
+ }
]
},
{
@@ -791,6 +806,17 @@
],
"Checks": [
"acm_certificates_with_secure_key_algorithms"
+ ],
+ "ConfigRequirements": [
+ {
+ "Check": "acm_certificates_with_secure_key_algorithms",
+ "ConfigKey": "insecure_key_algorithms",
+ "Operator": "superset",
+ "Value": [
+ "RSA-1024",
+ "P-192"
+ ]
+ }
]
},
{
@@ -1501,6 +1527,14 @@
"iam_policy_no_full_access_to_kms",
"iam_policy_no_full_access_to_cloudtrail",
"iam_policy_attached_only_to_group_or_roles"
+ ],
+ "ConfigRequirements": [
+ {
+ "Check": "accessanalyzer_enabled",
+ "ConfigKey": "mute_non_default_regions",
+ "Operator": "eq",
+ "Value": false
+ }
]
},
{
@@ -1663,6 +1697,14 @@
"cloudwatch_changes_to_network_route_tables_alarm_configured",
"cloudwatch_changes_to_vpcs_alarm_configured",
"config_recorder_all_regions_enabled"
+ ],
+ "ConfigRequirements": [
+ {
+ "Check": "config_recorder_all_regions_enabled",
+ "ConfigKey": "mute_non_default_regions",
+ "Operator": "eq",
+ "Value": false
+ }
]
},
{
@@ -1788,6 +1830,14 @@
"cloudtrail_threat_detection_enumeration",
"guardduty_is_enabled",
"guardduty_no_high_severity_findings"
+ ],
+ "ConfigRequirements": [
+ {
+ "Check": "guardduty_is_enabled",
+ "ConfigKey": "mute_non_default_regions",
+ "Operator": "eq",
+ "Value": false
+ }
]
},
{
@@ -4308,6 +4358,14 @@
],
"Checks": [
"acm_certificates_expiration_check"
+ ],
+ "ConfigRequirements": [
+ {
+ "Check": "acm_certificates_expiration_check",
+ "ConfigKey": "days_to_expire_threshold",
+ "Operator": "gte",
+ "Value": 30
+ }
]
},
{
@@ -6173,6 +6231,20 @@
"Checks": [
"iam_user_accesskey_unused",
"iam_user_console_access_unused"
+ ],
+ "ConfigRequirements": [
+ {
+ "Check": "iam_user_accesskey_unused",
+ "ConfigKey": "max_unused_access_keys_days",
+ "Operator": "lte",
+ "Value": 90
+ },
+ {
+ "Check": "iam_user_console_access_unused",
+ "ConfigKey": "max_console_access_days",
+ "Operator": "lte",
+ "Value": 90
+ }
]
},
{
@@ -6269,6 +6341,14 @@
"cloudwatch_log_metric_filter_root_usage",
"cloudwatch_log_metric_filter_sign_in_without_mfa",
"guardduty_is_enabled"
+ ],
+ "ConfigRequirements": [
+ {
+ "Check": "guardduty_is_enabled",
+ "ConfigKey": "mute_non_default_regions",
+ "Operator": "eq",
+ "Value": false
+ }
]
},
{
@@ -6371,6 +6451,14 @@
"Checks": [
"accessanalyzer_enabled",
"accessanalyzer_enabled_without_findings"
+ ],
+ "ConfigRequirements": [
+ {
+ "Check": "accessanalyzer_enabled",
+ "ConfigKey": "mute_non_default_regions",
+ "Operator": "eq",
+ "Value": false
+ }
]
},
{
diff --git a/prowler/compliance/aws/cis_1.4_aws.json b/prowler/compliance/aws/cis_1.4_aws.json
index 3efc29fd5b..b373a04665 100644
--- a/prowler/compliance/aws/cis_1.4_aws.json
+++ b/prowler/compliance/aws/cis_1.4_aws.json
@@ -75,6 +75,20 @@
"iam_user_accesskey_unused",
"iam_user_console_access_unused"
],
+ "ConfigRequirements": [
+ {
+ "Check": "iam_user_accesskey_unused",
+ "ConfigKey": "max_unused_access_keys_days",
+ "Operator": "lte",
+ "Value": 45
+ },
+ {
+ "Check": "iam_user_console_access_unused",
+ "ConfigKey": "max_console_access_days",
+ "Operator": "lte",
+ "Value": 45
+ }
+ ],
"Attributes": [
{
"Section": "1 Identity and Access Management",
@@ -265,6 +279,14 @@
"Checks": [
"accessanalyzer_enabled"
],
+ "ConfigRequirements": [
+ {
+ "Check": "accessanalyzer_enabled",
+ "ConfigKey": "mute_non_default_regions",
+ "Operator": "eq",
+ "Value": false
+ }
+ ],
"Attributes": [
{
"Section": "1 Identity and Access Management",
@@ -736,6 +758,14 @@
"Checks": [
"config_recorder_all_regions_enabled"
],
+ "ConfigRequirements": [
+ {
+ "Check": "config_recorder_all_regions_enabled",
+ "ConfigKey": "mute_non_default_regions",
+ "Operator": "eq",
+ "Value": false
+ }
+ ],
"Attributes": [
{
"Section": "3 Logging",
diff --git a/prowler/compliance/aws/cis_1.5_aws.json b/prowler/compliance/aws/cis_1.5_aws.json
index f7307bb7f4..6a60d786a2 100644
--- a/prowler/compliance/aws/cis_1.5_aws.json
+++ b/prowler/compliance/aws/cis_1.5_aws.json
@@ -75,6 +75,20 @@
"iam_user_accesskey_unused",
"iam_user_console_access_unused"
],
+ "ConfigRequirements": [
+ {
+ "Check": "iam_user_accesskey_unused",
+ "ConfigKey": "max_unused_access_keys_days",
+ "Operator": "lte",
+ "Value": 45
+ },
+ {
+ "Check": "iam_user_console_access_unused",
+ "ConfigKey": "max_console_access_days",
+ "Operator": "lte",
+ "Value": 45
+ }
+ ],
"Attributes": [
{
"Section": "1 Identity and Access Management",
@@ -265,6 +279,14 @@
"Checks": [
"accessanalyzer_enabled"
],
+ "ConfigRequirements": [
+ {
+ "Check": "accessanalyzer_enabled",
+ "ConfigKey": "mute_non_default_regions",
+ "Operator": "eq",
+ "Value": false
+ }
+ ],
"Attributes": [
{
"Section": "1 Identity and Access Management",
@@ -802,6 +824,14 @@
"Checks": [
"config_recorder_all_regions_enabled"
],
+ "ConfigRequirements": [
+ {
+ "Check": "config_recorder_all_regions_enabled",
+ "ConfigKey": "mute_non_default_regions",
+ "Operator": "eq",
+ "Value": false
+ }
+ ],
"Attributes": [
{
"Section": "3 Logging",
@@ -1054,6 +1084,14 @@
"Checks": [
"securityhub_enabled"
],
+ "ConfigRequirements": [
+ {
+ "Check": "securityhub_enabled",
+ "ConfigKey": "mute_non_default_regions",
+ "Operator": "eq",
+ "Value": false
+ }
+ ],
"Attributes": [
{
"Section": "4 Monitoring",
diff --git a/prowler/compliance/aws/cis_2.0_aws.json b/prowler/compliance/aws/cis_2.0_aws.json
index 4f8c4b2c23..e255bd43e1 100644
--- a/prowler/compliance/aws/cis_2.0_aws.json
+++ b/prowler/compliance/aws/cis_2.0_aws.json
@@ -75,6 +75,20 @@
"iam_user_accesskey_unused",
"iam_user_console_access_unused"
],
+ "ConfigRequirements": [
+ {
+ "Check": "iam_user_accesskey_unused",
+ "ConfigKey": "max_unused_access_keys_days",
+ "Operator": "lte",
+ "Value": 45
+ },
+ {
+ "Check": "iam_user_console_access_unused",
+ "ConfigKey": "max_console_access_days",
+ "Operator": "lte",
+ "Value": 45
+ }
+ ],
"Attributes": [
{
"Section": "1 Identity and Access Management",
@@ -265,6 +279,14 @@
"Checks": [
"accessanalyzer_enabled"
],
+ "ConfigRequirements": [
+ {
+ "Check": "accessanalyzer_enabled",
+ "ConfigKey": "mute_non_default_regions",
+ "Operator": "eq",
+ "Value": false
+ }
+ ],
"Attributes": [
{
"Section": "1 Identity and Access Management",
@@ -802,6 +824,14 @@
"Checks": [
"config_recorder_all_regions_enabled"
],
+ "ConfigRequirements": [
+ {
+ "Check": "config_recorder_all_regions_enabled",
+ "ConfigKey": "mute_non_default_regions",
+ "Operator": "eq",
+ "Value": false
+ }
+ ],
"Attributes": [
{
"Section": "3 Logging",
@@ -1054,6 +1084,14 @@
"Checks": [
"securityhub_enabled"
],
+ "ConfigRequirements": [
+ {
+ "Check": "securityhub_enabled",
+ "ConfigKey": "mute_non_default_regions",
+ "Operator": "eq",
+ "Value": false
+ }
+ ],
"Attributes": [
{
"Section": "4 Monitoring",
diff --git a/prowler/compliance/aws/cis_3.0_aws.json b/prowler/compliance/aws/cis_3.0_aws.json
index dd4c75a2f7..5540bc40cf 100644
--- a/prowler/compliance/aws/cis_3.0_aws.json
+++ b/prowler/compliance/aws/cis_3.0_aws.json
@@ -75,6 +75,20 @@
"iam_user_accesskey_unused",
"iam_user_console_access_unused"
],
+ "ConfigRequirements": [
+ {
+ "Check": "iam_user_accesskey_unused",
+ "ConfigKey": "max_unused_access_keys_days",
+ "Operator": "lte",
+ "Value": 45
+ },
+ {
+ "Check": "iam_user_console_access_unused",
+ "ConfigKey": "max_console_access_days",
+ "Operator": "lte",
+ "Value": 45
+ }
+ ],
"Attributes": [
{
"Section": "1 Identity and Access Management",
@@ -265,6 +279,14 @@
"Checks": [
"accessanalyzer_enabled"
],
+ "ConfigRequirements": [
+ {
+ "Check": "accessanalyzer_enabled",
+ "ConfigKey": "mute_non_default_regions",
+ "Operator": "eq",
+ "Value": false
+ }
+ ],
"Attributes": [
{
"Section": "1 Identity and Access Management",
@@ -756,6 +778,14 @@
"Checks": [
"config_recorder_all_regions_enabled"
],
+ "ConfigRequirements": [
+ {
+ "Check": "config_recorder_all_regions_enabled",
+ "ConfigKey": "mute_non_default_regions",
+ "Operator": "eq",
+ "Value": false
+ }
+ ],
"Attributes": [
{
"Section": "3 Logging",
@@ -1008,6 +1038,14 @@
"Checks": [
"securityhub_enabled"
],
+ "ConfigRequirements": [
+ {
+ "Check": "securityhub_enabled",
+ "ConfigKey": "mute_non_default_regions",
+ "Operator": "eq",
+ "Value": false
+ }
+ ],
"Attributes": [
{
"Section": "4 Monitoring",
diff --git a/prowler/compliance/aws/cis_4.0_aws.json b/prowler/compliance/aws/cis_4.0_aws.json
index 0c40f8ac09..c8787ef409 100644
--- a/prowler/compliance/aws/cis_4.0_aws.json
+++ b/prowler/compliance/aws/cis_4.0_aws.json
@@ -254,6 +254,20 @@
"iam_user_accesskey_unused",
"iam_user_console_access_unused"
],
+ "ConfigRequirements": [
+ {
+ "Check": "iam_user_accesskey_unused",
+ "ConfigKey": "max_unused_access_keys_days",
+ "Operator": "lte",
+ "Value": 45
+ },
+ {
+ "Check": "iam_user_console_access_unused",
+ "ConfigKey": "max_console_access_days",
+ "Operator": "lte",
+ "Value": 45
+ }
+ ],
"Attributes": [
{
"Section": "1 Identity and Access Management",
@@ -431,6 +445,14 @@
"Checks": [
"accessanalyzer_enabled"
],
+ "ConfigRequirements": [
+ {
+ "Check": "accessanalyzer_enabled",
+ "ConfigKey": "mute_non_default_regions",
+ "Operator": "eq",
+ "Value": false
+ }
+ ],
"Attributes": [
{
"Section": "1 Identity and Access Management",
@@ -750,6 +772,14 @@
"Checks": [
"config_recorder_all_regions_enabled"
],
+ "ConfigRequirements": [
+ {
+ "Check": "config_recorder_all_regions_enabled",
+ "ConfigKey": "mute_non_default_regions",
+ "Operator": "eq",
+ "Value": false
+ }
+ ],
"Attributes": [
{
"Section": "3 Logging",
@@ -1234,6 +1264,14 @@
"Checks": [
"securityhub_enabled"
],
+ "ConfigRequirements": [
+ {
+ "Check": "securityhub_enabled",
+ "ConfigKey": "mute_non_default_regions",
+ "Operator": "eq",
+ "Value": false
+ }
+ ],
"Attributes": [
{
"Section": "4 Monitoring",
diff --git a/prowler/compliance/aws/cis_5.0_aws.json b/prowler/compliance/aws/cis_5.0_aws.json
index e870878c6b..0c8d46e170 100644
--- a/prowler/compliance/aws/cis_5.0_aws.json
+++ b/prowler/compliance/aws/cis_5.0_aws.json
@@ -232,6 +232,20 @@
"iam_user_accesskey_unused",
"iam_user_console_access_unused"
],
+ "ConfigRequirements": [
+ {
+ "Check": "iam_user_accesskey_unused",
+ "ConfigKey": "max_unused_access_keys_days",
+ "Operator": "lte",
+ "Value": 45
+ },
+ {
+ "Check": "iam_user_console_access_unused",
+ "ConfigKey": "max_console_access_days",
+ "Operator": "lte",
+ "Value": 45
+ }
+ ],
"Attributes": [
{
"Section": "1 Identity and Access Management",
@@ -409,6 +423,14 @@
"Checks": [
"accessanalyzer_enabled"
],
+ "ConfigRequirements": [
+ {
+ "Check": "accessanalyzer_enabled",
+ "ConfigKey": "mute_non_default_regions",
+ "Operator": "eq",
+ "Value": false
+ }
+ ],
"Attributes": [
{
"Section": "1 Identity and Access Management",
@@ -728,6 +750,14 @@
"Checks": [
"config_recorder_all_regions_enabled"
],
+ "ConfigRequirements": [
+ {
+ "Check": "config_recorder_all_regions_enabled",
+ "ConfigKey": "mute_non_default_regions",
+ "Operator": "eq",
+ "Value": false
+ }
+ ],
"Attributes": [
{
"Section": "3 Logging",
@@ -1212,6 +1242,14 @@
"Checks": [
"securityhub_enabled"
],
+ "ConfigRequirements": [
+ {
+ "Check": "securityhub_enabled",
+ "ConfigKey": "mute_non_default_regions",
+ "Operator": "eq",
+ "Value": false
+ }
+ ],
"Attributes": [
{
"Section": "4 Monitoring",
diff --git a/prowler/compliance/aws/cis_6.0_aws.json b/prowler/compliance/aws/cis_6.0_aws.json
index 643c192b7b..7ad62a4b65 100644
--- a/prowler/compliance/aws/cis_6.0_aws.json
+++ b/prowler/compliance/aws/cis_6.0_aws.json
@@ -232,6 +232,20 @@
"iam_user_accesskey_unused",
"iam_user_console_access_unused"
],
+ "ConfigRequirements": [
+ {
+ "Check": "iam_user_accesskey_unused",
+ "ConfigKey": "max_unused_access_keys_days",
+ "Operator": "lte",
+ "Value": 45
+ },
+ {
+ "Check": "iam_user_console_access_unused",
+ "ConfigKey": "max_console_access_days",
+ "Operator": "lte",
+ "Value": 45
+ }
+ ],
"Attributes": [
{
"Section": "2 Identity and Access Management",
@@ -409,6 +423,14 @@
"Checks": [
"accessanalyzer_enabled"
],
+ "ConfigRequirements": [
+ {
+ "Check": "accessanalyzer_enabled",
+ "ConfigKey": "mute_non_default_regions",
+ "Operator": "eq",
+ "Value": false
+ }
+ ],
"Attributes": [
{
"Section": "2 Identity and Access Management",
@@ -728,6 +750,14 @@
"Checks": [
"config_recorder_all_regions_enabled"
],
+ "ConfigRequirements": [
+ {
+ "Check": "config_recorder_all_regions_enabled",
+ "ConfigKey": "mute_non_default_regions",
+ "Operator": "eq",
+ "Value": false
+ }
+ ],
"Attributes": [
{
"Section": "4 Logging",
@@ -1212,6 +1242,14 @@
"Checks": [
"securityhub_enabled"
],
+ "ConfigRequirements": [
+ {
+ "Check": "securityhub_enabled",
+ "ConfigKey": "mute_non_default_regions",
+ "Operator": "eq",
+ "Value": false
+ }
+ ],
"Attributes": [
{
"Section": "5 Monitoring",
diff --git a/prowler/compliance/aws/cis_7.0_aws.json b/prowler/compliance/aws/cis_7.0_aws.json
new file mode 100644
index 0000000000..a927c375bb
--- /dev/null
+++ b/prowler/compliance/aws/cis_7.0_aws.json
@@ -0,0 +1,1610 @@
+{
+ "Framework": "CIS",
+ "Name": "CIS Amazon Web Services Foundations Benchmark v7.0.0",
+ "Version": "7.0",
+ "Provider": "AWS",
+ "Description": "The CIS Amazon Web Services Foundations Benchmark provides prescriptive guidance for configuring security options for a subset of Amazon Web Services with an emphasis on foundational, testable, and architecture agnostic settings.",
+ "Requirements": [
+ {
+ "Id": "2.1.1",
+ "Description": "Ensure centralized root access in AWS Organizations",
+ "Checks": [
+ "iam_root_credentials_management_enabled"
+ ],
+ "Attributes": [
+ {
+ "Section": "2 Identity and Access Management",
+ "Profile": "Level 2",
+ "AssessmentStatus": "Manual",
+ "Description": "Ensure centralized root access management is enabled to manage and secure root user credentials for member accounts in AWS Organizations. This allows the management account and an optional delegated administrator account to centrally delete, prevent recovery of, and if necessary, perform short-lived, scoped root-required actions in member accounts without maintaining long-term root user credentials in each account.",
+ "RationaleStatement": "The AWS account root user is a powerful, default administrative identity that is difficult to manage safely across many accounts. When each member account manages its own root credentials, organizations often end up with numerous long-lived root passwords, access keys, and MFA devices that are hard to inventory, rotate, and protect. Centralized root access management lets security teams remove or avoid creating root user credentials in member accounts, centrally review and manage any remaining root credentials, and perform necessary root-only tasks via short-term, task-scoped root sessions. This significantly reduces privileged credential sprawl, supports least privilege and dedicated administrator models, and improves visibility and auditability of root-level activity across the organization.",
+ "ImpactStatement": "Enabling centralized root access management changes how root user access is obtained and used in member accounts, but it does not automatically remove existing root credentials. Organizations must plan when and how to delete or disable any existing root passwords, access keys, signing certificates, and MFA devices in member accounts and update any workflows that still rely on direct root sign-in. Security and operations teams will need to use centrally initiated, short-lived root sessions for exceptional tasks that truly require root. This may require procedural changes and additional training, but it significantly reduces long-lived privileged credential sprawl across the organization.",
+ "RemediationProcedure": "1. Sign in to the AWS Management Console with the management account. 2. In the console search bar, type Organizations and open AWS Organizations. - On the Overview page, confirm that an Organization exists and that this account is listed as the Management account. 3. In AWS Organizations, choose Services. Locate AWS Identity and Access Management in the list and, if it is not already enabled, choose Enable trusted access and confirm. - This allows IAM to integrate with AWS Organizations to manage root access centrally. 4. In the console search bar, type IAM and open IAM. In the left navigation pane, choose Root access management. If you see Root access management is disabled, choose Enable. - In the enable dialog, confirm that you want to - \"Root credentials management\" and if desired - \"Privileged root actions in member accounts\" - In the Delegated administrator field, enter the account ID of the account that will manage root user access and take privileged actions on member accounts. AWS recommends using an account intended for security or management purposes, not a general workload account. - When you enable centralized root access in the console, IAM also enables trusted access for IAM in AWS Organizations if it isn't already enabled. - Choose Enable to save the configuration.",
+ "AuditProcedure": "1. Sign in to the AWS Management Console with the management account. 2. In the console search bar, type Organizations and open AWS Organizations. - On the Overview page, confirm that an Organization exists and that this account is listed as the Management account. 3. In AWS Organizations, choose Services. - Confirm that AWS Identity and Access Management appears in the list of services with trusted access enabled. 4. In the console search bar, type IAM and open IAM. In the left navigation pane, choose Root access management. Check the status banner. - If you see that Root access management is enabled and the feature card shows that root credentials management is turned on for member accounts, the organization has centralized root access management enabled. - If you see Root access management is disabled with an option to Enable, centralized root access is not yet enabled. 5. (Optional) On the same Root access management page, review the Delegated administrator information (if shown). - Confirm that the delegated account (if present) is a security or management-focused account, not a general workload account.",
+ "AdditionalInformation": "",
+ "References": "",
+ "DefaultValue": ""
+ }
+ ]
+ },
+ {
+ "Id": "2.1.2",
+ "Description": "Ensure authorization guardrails for all AWS Organization accounts",
+ "Checks": [],
+ "Attributes": [
+ {
+ "Section": "2 Identity and Access Management",
+ "Profile": "Level 2",
+ "AssessmentStatus": "Manual",
+ "Description": "Ensure that one or more baseline authorization policies such as Service Control Policies (SCPs) and/or Resource Control Policies (RCPs) are attached to all member accounts in AWS Organizations in accordance with organizational security requirements. Authorization policies act as preventive permission guardrails: SCPs define the maximum available permissions for IAM principals within accounts, while RCPs define the maximum available permissions for resources within accounts. These policies can enforce security invariants such as preventing disabling of key security services, restricting use of unapproved AWS Regions, or blocking external access to sensitive resources.",
+ "RationaleStatement": "Authorization policies do not grant permissions but instead set organization-wide limits on what actions principals can perform (SCPs) and what access can be granted to resources (RCPs), regardless of local IAM or resource-based policies. Without baseline guardrail authorization policies, each account can grant excessive or inconsistent permissions that disable logging, weaken security services, allow use of unapproved Regions and services, or permit unintended external access to resources. Attaching standard authorization policies to all member accounts enforces preventive, centralized control over high-risk actions and access patterns, supports least-privilege and role-based access control at scale, and helps ensure that all accounts and resources operate within the organization's defined security baseline.",
+ "ImpactStatement": "Enforcing baseline authorization policies for all member accounts can initially block some existing patterns, such as use of unapproved Regions, disabling security services, or granting broader permissions than the guardrails allow. Teams may need to adjust IAM policies, deployment pipelines, and exception processes so legitimate use cases remain possible within the new guardrails. This can introduce short-term operational overhead and require careful testing, especially when attaching new policies at the root or OU level.",
+ "RemediationProcedure": "Design or confirm baseline guardrail SCPs. 1. From the AWS Organizations console, go to Policies → Service control policies. - If you already have standard guardrail SCPs that implement your security baseline, note their names. - If you do not have such policies, choose Create policy and create at least one baseline guardrail SCP that encodes non-negotiable security requirements. 2. Do the same step as above but for RCPs if needed. From the AWS Organizations console, go to Policies → Resource control policies. 3. Attach guardrail authorization policies to the root and/or OUs. In AWS Organizations, choose AWS accounts, then select the Root of the organization. - Go to the Policies tab, then within section for Service control policies, choose Attach, and select the baseline guardrail SCP(s) you identified or created in step 1. - If using RCPs, then within section for Resource control policies, choose Attach, and select the baseline guardrail RCP(s) you identified or created in step 2. - If your design uses different guardrails per OU (for example, stricter policies for production OU), select each OU in turn and attach the appropriate guardrail SCPs and RCPs to those OUs. - AWS recommends testing authorization policies in a staging OU before attaching them broadly to the root to avoid unintended service disruption.",
+ "AuditProcedure": "Pre-requisite: you must run these CLI commands in the management account for the AWS Organization. 1. Before auditing, document or confirm your organization's baseline guardrail requirements. Common examples include: - Prevent disabling CloudTrail, AWS Config, GuardDuty, or Security Hub - Restrict usage to approved AWS Regions only - Protect central security or logging roles from modification - Deny external principal access to sensitive resources 2. List all SCPs and RCPs in the organization: ``` aws organizations list-policies --filter SERVICE_CONTROL_POLICY aws organizations list-policies --filter RESOURCE_CONTROL_POLICY ``` This returns a list of SCP/RCP policy IDs and names. 3. For each SCP/RCP, retrieve and review the policy document to determine if it implements your baseline guardrail requirements: ``` aws organizations describe-policy --policy-id ``` Review the `Content` field in the output to confirm the policy enforces organizational security requirements. If no SCPs/RCPs exist that implement your documented baseline guardrail requirements, note this as a gap and proceed to remediation. 4. List all accounts in the organization and note the account IDs: ``` aws organizations list-accounts --query 'Accounts[?Status==`ACTIVE`].[Id,Name]' --output table ``` 5. For each baseline guardrail Authorization policy identified in Step 2, list the accounts and OUs to which it is attached: ``` aws organizations list-targets-for-policy --policy-id ``` The output shows `TargetId` values representing accounts, OUs, or the root. 6. Compare the list of attached targets to your full account list. - If the policy is attached to the root, all accounts in the organization inherit it and this policy passes for coverage. - If the policy is attached to specific OUs, verify that all active member accounts belong to those OUs. - If the policy is attached to individual accounts, verify that all active member accounts are included. The environment passes this recommendation if: - All baseline guardrail authorization policies required by organizational security requirements exist. - Each baseline guardrail authorization policy is attached to all active member accounts either directly or via OU/root inheritance.",
+ "AdditionalInformation": "",
+ "References": "",
+ "DefaultValue": ""
+ }
+ ]
+ },
+ {
+ "Id": "2.1.3",
+ "Description": "Ensure Organizations management account is not used for workloads",
+ "Checks": [],
+ "Attributes": [
+ {
+ "Section": "2 Identity and Access Management",
+ "Profile": "Level 2",
+ "AssessmentStatus": "Manual",
+ "Description": "Ensure that the AWS Organizations management account is used only for organizational governance tasks and does not host production workloads, applications, or business data. The management account is the most privileged account in an AWS Organization and performs sensitive administrative functions such as creating and managing member accounts, applying service control policies (SCPs), and managing consolidated billing. Workloads, applications, and associated data should be deployed in dedicated member accounts, not in the management account.",
+ "RationaleStatement": "The management account has unique privileges that cannot be restricted by SCPs, making it the highest-risk account in an organization. Deploying workloads or storing business data in the management account increases the attack surface and blast radius of a compromise. If a workload vulnerability or misconfiguration occurs in the management account, it could grant attackers access to organization-wide administrative capabilities.",
+ "ImpactStatement": "Restricting the management account to governance-only use may require creating new member accounts, redesigning existing account boundaries, and migrating workloads and data out of the management account. This can introduce short-term complexity and operational overhead. However, it reduces the blast radius of a compromise, simplifies security controls in the most privileged account, and aligns the environment with AWS multi-account and workload-isolation best practices.",
+ "RemediationProcedure": "1. Inventory all workload resources currently in the management account (compute, storage, databases, application services). 2. For each class of workload resource (for example, production, non-production, shared services), create or confirm dedicated member accounts within the organization and place them into the appropriate OUs. 3. For each workload resource, design a migration plan to the appropriate member account. - Execute the migrations in phases, starting with lower-risk environments (for example, development/test) before production. 4. Review and adjust IAM roles and permissions in the management account so that only personnel responsible for organization governance and security have access 5. Update architecture diagrams, runbooks, and onboarding processes to state that new workloads must be deployed only into designated workload accounts, not the management account.",
+ "AuditProcedure": "1. Confirm which AWS account is the management account for the organization (for example, via AWS Organizations \"Overview\" page or organizational documentation). 2. Ensure you have read-only access to review resources in this account. 3. Use your organization's standard discovery methods (for example, AWS Config, CMDB/asset inventory, or CSPM) to obtain a list of services and resources running in the management account. - At a minimum, identify compute, storage, database, and application services (for example, EC2, Lambda, ECS, S3, RDS, DynamoDB, API Gateway, load balancers). 4. For each identified resource, determine whether it is: - Governance/security: resources that support centralized management, logging, audit, or security (for example, org-wide CloudTrail, Config aggregator, Security Hub or GuardDuty delegated admin, billing/cost tooling). - Workload/business: resources that support business applications, production or non-production workloads, or customer-facing systems. 5. If any workload/business resources are present in the management account, record this as a gap and document the affected services and resource types",
+ "AdditionalInformation": "",
+ "References": "",
+ "DefaultValue": ""
+ }
+ ]
+ },
+ {
+ "Id": "2.1.4",
+ "Description": "Ensure Organizational Units are structured by environment and sensitivity",
+ "Checks": [],
+ "Attributes": [
+ {
+ "Section": "2 Identity and Access Management",
+ "Profile": "Level 2",
+ "AssessmentStatus": "Manual",
+ "Description": "Ensure that AWS Organizations Organizational Units (OUs) are structured primarily by environment (for example, production, non-production, sandbox) and sensitivity (for example, security, logging, shared services, regulated workloads), rather than mirroring the corporate org chart. OUs should group accounts that share similar security requirements and controls so that appropriate authorization policies and other guardrails can be applied consistently at the OU level.",
+ "RationaleStatement": "A clear OU structure based on environment and sensitivity makes it easier to apply consistent guardrails and centralized security controls to accounts that have similar risk profiles and compliance needs. Poorly defined or ad-hoc OU structures complicate policy management, increase the chance of misapplied controls, and can lead to mixing workloads with different data sensitivities under the same set of controls.",
+ "ImpactStatement": "Restructuring OUs by environment and sensitivity can require moving accounts, changing inherited policies, and updating automation that assumes existing OU paths. This may introduce short-term operational overhead, including policy revalidation, testing of workloads under new guardrails, and coordination with application and platform teams to avoid unintended service disruption.",
+ "RemediationProcedure": "1. Work with security, platform, and application teams to agree on a small set of top-level OUs such as: - Security / Management - Shared Services / Infrastructure - Prod - Non-Prod (dev, test, staging) - You may also define dedicated OUs for highly regulated workloads. 2. In the AWS Organizations console (management account), navigate to AWS Accounts. Under the root, create the agreed top-level OUs. If needed, create child OUs under these. 3. Export or list all existing accounts and their current OUs. Create a simple mapping from each account to its target OU based on environment and sensitivity. 4. In the AWS Organizations console (management account), navigate to AWS Accounts. Move accounts into the new environment/sensitivity-based OUs according to your mapping. - Start with low-risk accounts (for example, sandbox and non-production) to validate effects of inherited policies and guardrails before moving production and high-sensitivity accounts. 5. After accounts have been moved, remove old OUs that no longer reflect the target structure. - Ensure no active accounts remain directly under the root unless explicitly justified and documented. 6. Update architecture docs, onboarding runbooks, and account request processes to require new accounts to be created in the correct OU based on environment and sensitivity.",
+ "AuditProcedure": "1. From the management account, use AWS Organizations console to obtain: - The full OU hierarchy (root, top-level and child OUs). - The list of accounts in each OU. 2. Review top-level and key OUs and determine whether they are clearly aligned to: - Environment (for example, production, non-production, sandbox). - Sensitivity/function (for example, security, logging, shared services, regulated). - Note any OUs whose purpose is unclear or that appear to be organized mainly by department or owner rather than environment/sensitivity. 3. For each environment/sensitivity OU, select a sample of accounts and verify that their primary workloads match the OU's stated purpose. - Note any accounts that mix production and non-production workloads in the same OU when separate OUs are defined. - Note any accounts that place highly sensitive or regulated workloads in OUs that are intended for lower-sensitivity use.",
+ "AdditionalInformation": "",
+ "References": "",
+ "DefaultValue": ""
+ }
+ ]
+ },
+ {
+ "Id": "2.1.5",
+ "Description": "Ensure delegated admin manages AWS Organizations policies",
+ "Checks": [
+ "organizations_delegated_administrators"
+ ],
+ "Attributes": [
+ {
+ "Section": "2 Identity and Access Management",
+ "Profile": "Level 2",
+ "AssessmentStatus": "Manual",
+ "Description": "Ensure that a dedicated member account is configured as a delegated administrator for AWS Organizations to manage organization policies (SCPs, RCPs, tag policies, backup policies, AI opt-out policies) and other Organizations features, instead of performing these tasks directly from the management account. The delegated administrator for AWS Organizations is configured via a resource-based delegation policy in the management account, which grants specific member accounts limited permissions to perform Organizations policy and account management actions across the organization. This allows policy management, OU operations, and other governance tasks to be handled from purpose-built accounts without requiring broad access to the management account.",
+ "RationaleStatement": "The management account has unique and high privileges to manage AWS Organizations (for example, creating/deleting accounts, managing org structures) and is not subject to guardrails like SCPs. Without a delegated administrator for Organizations, all policy management, OU changes, and account governance must be performed directly from the management account. This results in concentrating operational activity in the most powerful account. Configuring a dedicated member account as a delegated administrator for Organizations policy management distributes these tasks to a purpose-built AWS account that can be protected by SCPs and other controls, reduces the number of users and roles that need management-account access, and supports separation of duties while maintaining centralized control over organization-wide features.",
+ "ImpactStatement": "Configuring a delegated administrator for AWS Organizations requires creating or identifying a dedicated member account for policy management and granting it specific permissions via a resource-based delegation policy. Existing workflows, automation, and user access patterns that currently perform Organizations policy tasks directly from the management account must be updated to use the delegated account instead. This introduces short-term operational overhead and testing to ensure policy creation, attachment, and management continue to function correctly from the new account.",
+ "RemediationProcedure": "1. Identify a dedicated member account for governance/policy management (for example, create a new \"Policy Management\" account or use an existing Security account) 2. You must be in the management account with permissions to manage Organizations resource policies. Navigate to AWS Organizations console, then click on Settings and browse to \"Delegated administrator for AWS Organizations\" section. 3. If no policy exists, click on Delegate. If a policy exists, choose Edit policy. - In the policy editor, paste or construct a delegation policy statement mentioning the Principal as the AWS account Root which is being delegated access to, and the Actions with the list of least-privileged permissions that could be performed by the delegated AWS account. - Save and validate the delegation policy. 4. Sign in to the delegated administrator account and open the AWS Organizations console. - Confirm that policy management (Policies, Attach/Detach, etc.) is accessible and that users/roles in this account can perform Organizations tasks without management-account access. 5. Grant IAM roles/users in the delegated admin account only the permissions needed for Organizations policy management. 6. Update procedures so that routine Organizations policy tasks are performed from the delegated account, reserving the management account for tasks that only it can perform",
+ "AuditProcedure": "1. Sign in to the AWS Organizations console. From the AWS Accounts section, verify that this is the management account for the organization. 2. In the AWS Organizations console, navigate to Settings. Scroll to the Delegated administrator for AWS Organizations section 3. Review the delegation policy status: - If a delegation policy is configured and shows one or more member accounts registered to manage Organizations policies, proceed to step 4. - If no delegation policy is configured or the section shows No delegated administrator (or equivalent), the audit fails because Organizations management is performed directly from the management account. 4. In the Delegated administrator section, note the account IDs registered for Organizations policy management. Confirm that the delegated accounts are purpose-built governance, security, or policy management accounts, not general workload, sandbox, or development accounts. 5. View the delegation policy details to confirm it grants appropriate least-privilege permissions for policy types (for example, SCPs, tag policies, backup policies) and actions (CreatePolicy, AttachPolicy, UpdatePolicy, etc.).",
+ "AdditionalInformation": "",
+ "References": "",
+ "DefaultValue": ""
+ }
+ ]
+ },
+ {
+ "Id": "2.1.6",
+ "Description": "Ensure delegated admins manage AWS Organizations-integrated services",
+ "Checks": [
+ "organizations_delegated_administrators"
+ ],
+ "Attributes": [
+ {
+ "Section": "2 Identity and Access Management",
+ "Profile": "Level 2",
+ "AssessmentStatus": "Manual",
+ "Description": "Ensure that AWS services (such as AWS CloudTrail) which integrate with AWS Organizations and support delegated administration are managed through delegated administrator member accounts instead of directly from the Organizations management account. For each such service, the management account should enable trusted access and register a purpose-built member account as the delegated administrator, so that this account can perform service-level administration across all organization accounts.",
+ "RationaleStatement": "The management account has unique and high privileges to manage AWS Organizations (for example, creating/deleting accounts, managing org structures) and is not subject to guardrails like SCPs. Without delegated administrators, organization-wide security, logging, and management services must be operated directly from the management account, concentrating operational activity and credentials in the most privileged account in the organization. Registering member accounts as delegated administrators for AWS services distributes service-specific administration to dedicated security, logging, or operations accounts that can be restricted by SCPs, monitored like other workload accounts, and aligned with team responsibilities, while reducing day-to-day use of the management account.",
+ "ImpactStatement": "Configuring a delegated administrator for AWS Services that integrate with AWS Organizations requires creating or identifying a dedicated member account for policy management and granting it specific permissions. Existing workflows, automation, and user access patterns that currently perform tasks directly from the management account must be updated to use the delegated account instead. This introduces short-term operational overhead and testing to ensure policy creation, attachment, and management continue to function correctly from the new account.",
+ "RemediationProcedure": "Note: This remediation section uses AWS CloudTrail as a concrete example. You must perform similar procedure for all other AWS services that integrate with AWS Organizations and support delegated administration that are in use in your environment. 1. In the management account, verify that trusted access for CloudTrail is enabled in AWS Organizations (AWS Organizations → Services). 2. In the management account CloudTrail console, choose Settings in the left navigation pane. Scroll to Organization delegated administrators. 3. Click on \"Register administrator\" - Enter the account ID of the designated Logging or Security account. - Click on Register administrator. CloudTrail will automatically create the necessary service-linked roles and register the account. 4. In the delegated administrator account, open the CloudTrail console and confirm that the organization trail is visible and administrative actions are accessible. 5. Update operational runbooks so that routine CloudTrail administration is performed from the delegated admin account, not the management account.",
+ "AuditProcedure": "Note: This audit uses AWS CloudTrail as a concrete example. You must perform similar audits for all other AWS services that integrate with AWS Organizations and support delegated administration that are in use in your environment. 1. Sign in to the management account and open the CloudTrail console. 2. In the left navigation pane, choose Trails. - Verify that there is at least one organization trail (trail with Apply trail to all accounts in my organization or equivalent setting enabled) - If CloudTrail is only configured as single-account trails and no organization trail is in use, note that delegated admin for CloudTrail is not in scope and this recommendation is not applicable for CloudTrail in this environment. 3. In the same management account CloudTrail console, choose Settings in the left navigation pane, and scroll to the Organization delegated administrators section. 4. Verify the configuration for Organization delegated administrators: - Verify that at least one member account ID (not the management account) is listed as a delegated administrator for CloudTrail. - Verify that the account(s) are appropriate for security/logging operations (for example, a named Security or Logging account, not a sandbox or general workload account). - If the section shows \"No delegated administrators\" when an organization trail is in use, CloudTrail is effectively administered from the management account and this is a gap.",
+ "AdditionalInformation": "",
+ "References": "",
+ "DefaultValue": ""
+ }
+ ]
+ },
+ {
+ "Id": "2.2",
+ "Description": "Maintain current AWS account contact details",
+ "Checks": [
+ "account_maintain_current_contact_details"
+ ],
+ "Attributes": [
+ {
+ "Section": "2 Identity and Access Management",
+ "Profile": "Level 1",
+ "AssessmentStatus": "Manual",
+ "Description": "Ensure contact email and telephone details for AWS accounts are current and map to more than one individual in your organization. An AWS account supports a number of contact details, and AWS will use these to contact the account owner if activity judged to be in breach of the Acceptable Use Policy or indicative of a likely security compromise is observed by the AWS Abuse team. Contact details should not be for a single individual, as circumstances may arise where that individual is unavailable. Email contact details should point to a mail alias which forwards email to multiple individuals within the organization; where feasible, phone contact details should point to a PABX hunt group or other call-forwarding system.",
+ "RationaleStatement": "If an AWS account is observed to be behaving in a prohibited or suspicious manner, AWS will attempt to contact the account owner by email and phone using the contact details listed. If this is unsuccessful and the account behavior needs urgent mitigation, proactive measures may be taken, including throttling of traffic between the account exhibiting suspicious behavior and the AWS API endpoints and the Internet. This will result in impaired service to and from the account in question, so it is in both the customers' and AWS's best interests that prompt contact can be established. This is best achieved by setting AWS account contact details to point to resources which have multiple individuals as recipients, such as email aliases and PABX hunt groups.",
+ "ImpactStatement": "",
+ "RemediationProcedure": "This activity can only be performed via the AWS Console, with a user who has permission to read and write Billing information (aws-portal:\\*Billing). **From Console:** 1. Sign in to the AWS Management Console and open the `Billing and Cost Management` console at https://console.aws.amazon.com/billing/home#/. 2. On the navigation bar, choose your account name, and then choose `Account`. 3. On the `Account Settings` page, next to `Account Settings`, choose `Edit`. 4. Next to the field that you need to update, choose `Edit`. 5. After you have entered your changes, choose `Save changes`. 6. After you have made your changes, choose `Done`. 7. To edit your contact information, under `Contact Information`, choose `Edit`. 8. For the fields that you want to change, type your updated information, and then choose `Update`. **From Command Line:** 1. Run the following command: ``` aws account put-contact-information --contact-information '{\"AddressLine1\": \"\", \"AddressLine2\": \"\", \"City\": \"\", \"CompanyName\": \"\", \"CountryCode\": \"\", \"FullName\": \"\", \"PhoneNumber\": \"\", \"PostalCode\": \"\", \"StateOrRegion\": \"\"}' ```",
+ "AuditProcedure": "This activity can only be performed via the AWS Console, with a user who has permission to read and write Billing information (aws-portal:\\*Billing). 1. Sign in to the AWS Management Console and open the `Billing and Cost Management` console at https://console.aws.amazon.com/billing/home#/. 2. On the navigation bar, choose your account name, and then choose `Account`. 3. On the `Account Settings` page, review and verify the current details. 4. Under `Contact Information`, review and verify the current details.",
+ "AdditionalInformation": "",
+ "References": "https://docs.aws.amazon.com/awsaccountbilling/latest/aboutv2/manage-account-payment.html#contact-info",
+ "DefaultValue": "By default, AWS account contact information (email and telephone) is set to the values provided at account creation. These usually reference a single individual rather than a shared alias or group contact."
+ }
+ ]
+ },
+ {
+ "Id": "2.3",
+ "Description": "Ensure security contact information is registered",
+ "Checks": [
+ "account_security_contact_information_is_registered"
+ ],
+ "Attributes": [
+ {
+ "Section": "2 Identity and Access Management",
+ "Profile": "Level 1",
+ "AssessmentStatus": "Manual",
+ "Description": "AWS provides customers with the option of specifying the contact information for account's security team. It is recommended that this information be provided.",
+ "RationaleStatement": "Specifying security-specific contact information will help ensure that security advisories sent by AWS reach the team in your organization that is best equipped to respond to them.",
+ "ImpactStatement": "",
+ "RemediationProcedure": "Perform the following to establish security contact information: **From Console:** 1. Click on your account name at the top right corner of the console. 2. From the drop-down menu Click `My Account` 3. Scroll down to the `Alternate Contacts` section 4. Enter contact information in the `Security` section **From Command Line:** Run the following command with the following input parameters: --email-address, --name, and --phone-number. ``` aws account put-alternate-contact --alternate-contact-type SECURITY ``` **Note:** Consider specifying an internal email distribution list to ensure emails are regularly monitored by more than one individual.",
+ "AuditProcedure": "Perform the following to determine if security contact information is present: **From Console:** 1. Click on your account name at the top right corner of the console 2. From the drop-down menu Click `My Account` 3. Scroll down to the `Alternate Contacts` section 4. Ensure contact information is specified in the `Security` section **From Command Line:** 1. Run the following command: ``` aws account get-alternate-contact --alternate-contact-type SECURITY ``` 2. Ensure proper contact information is specified for the `Security` contact.",
+ "AdditionalInformation": "",
+ "References": "",
+ "DefaultValue": ""
+ }
+ ]
+ },
+ {
+ "Id": "2.4",
+ "Description": "Ensure no 'root' user account access key exists",
+ "Checks": [
+ "iam_no_root_access_key"
+ ],
+ "Attributes": [
+ {
+ "Section": "2 Identity and Access Management",
+ "Profile": "Level 1",
+ "AssessmentStatus": "Automated",
+ "Description": "The 'root' user account is the most privileged user in an AWS account. AWS Access Keys provide programmatic access to a given AWS account. It is recommended that all access keys associated with the 'root' user account be deleted.",
+ "RationaleStatement": "Deleting access keys associated with the 'root' user account limits vectors by which the account can be compromised. Additionally, deleting the 'root' access keys encourages the creation and use of role based accounts that are least privileged.",
+ "ImpactStatement": "",
+ "RemediationProcedure": "Perform the following to delete active 'root' user access keys. **From Console:** 1. Sign in to the AWS Management Console as 'root' and open the IAM console at [https://console.aws.amazon.com/iam/](https://console.aws.amazon.com/iam/). 2. Click on `` at the top right and select `My Security Credentials` from the drop down list. 3. On the pop out screen Click on `Continue to Security Credentials`. 4. Click on `Access Keys` (Access Key ID and Secret Access Key). 5. If there are active keys, under `Status`, click `Delete` (Note: Deleted keys cannot be recovered). Note: While a key can be made inactive, this inactive key will still show up in the CLI command from the audit procedure, and may lead to the root user being falsely flagged as being non-compliant.",
+ "AuditProcedure": "Perform the following to determine if the 'root' user account has access keys: **From Console:** 1. Login to the AWS Management Console. 2. Click `Services`. 3. Click `IAM`. 4. Click on `Credential Report`. 5. This will download a `.csv` file which contains credential usage for all IAM users within an AWS Account - open this file. 6. For the `` user, ensure the `access_key_1_active` and `access_key_2_active` fields are set to `FALSE`. **From Command Line:** Run the following command: ``` aws iam get-account-summary | grep AccountAccessKeysPresent ``` If no 'root' access keys exist the output will show `AccountAccessKeysPresent: 0,`. If the output shows a 1, then 'root' keys exist and should be deleted.",
+ "AdditionalInformation": "- IAM User account root for us-gov cloud regions is not enabled by default. However, on request to AWS support enables 'root' access only through access-keys (CLI, API methods) for us-gov cloud region. - Implement regular checks and alerts for any creation of new root access keys to promptly address any unauthorized or accidental creation.",
+ "References": "http://docs.aws.amazon.com/general/latest/gr/aws-access-keys-best-practices.html:http://docs.aws.amazon.com/general/latest/gr/managing-aws-access-keys.html:http://docs.aws.amazon.com/IAM/latest/APIReference/API_GetAccountSummary.html:https://aws.amazon.com/blogs/security/an-easier-way-to-determine-the-presence-of-aws-account-access-keys/",
+ "DefaultValue": ""
+ }
+ ]
+ },
+ {
+ "Id": "2.5",
+ "Description": "Ensure MFA is enabled for the 'root' user account",
+ "Checks": [
+ "iam_root_mfa_enabled"
+ ],
+ "Attributes": [
+ {
+ "Section": "2 Identity and Access Management",
+ "Profile": "Level 1",
+ "AssessmentStatus": "Automated",
+ "Description": "The 'root' user account is the most privileged user in an AWS account. Multi-factor Authentication (MFA) adds an extra layer of protection on top of a username and password. With MFA enabled, when a user signs in to an AWS website, they will be prompted for their username and password as well as for an authentication code from their AWS MFA device. **Note:** When virtual MFA is used for 'root' accounts, it is recommended that the device used is NOT a personal device, but rather a dedicated mobile device (tablet or phone) that is kept charged and secured, independent of any individual personal devices (non-personal virtual MFA). This lessens the risks of losing access to the MFA due to device loss, device trade-in, or if the individual owning the device is no longer employed at the company. Where an AWS Organization is using centralized root access, root credentials can be removed from member accounts. In that case it is neither possible nor necessary to configure root MFA in the member account.",
+ "RationaleStatement": "Enabling MFA provides increased security for console access as it requires the authenticating principal to possess a device that emits a time-sensitive key and have knowledge of a credential.",
+ "ImpactStatement": "",
+ "RemediationProcedure": "**Note:** To manage MFA devices for the 'root' AWS account, you must use your 'root' account credentials to sign in to AWS. You cannot manage MFA devices for the 'root' account using other credentials. Perform the following to establish MFA for the 'root' user account: 1. Sign in to the AWS Management Console and open the IAM console at [https://console.aws.amazon.com/iam/](https://console.aws.amazon.com/iam/). 2. Choose `Dashboard` , and under `Security Status` , expand `Activate MFA` on your root account. 3. Choose `Activate MFA` 4. In the wizard, choose `A virtual MFA` device and then choose `Next Step` . 5. IAM generates and displays configuration information for the virtual MFA device, including a QR code graphic. The graphic is a representation of the 'secret configuration key' that is available for manual entry on devices that do not support QR codes. 6. Open your virtual MFA application. (For a list of apps that you can use for hosting virtual MFA devices, see [Virtual MFA Applications](http://aws.amazon.com/iam/details/mfa/#Virtual_MFA_Applications).) If the virtual MFA application supports multiple accounts (multiple virtual MFA devices), choose the option to create a new account (a new virtual MFA device). 7. Determine whether the MFA app supports QR codes, and then do one of the following: - Use the app to scan the QR code. For example, you might choose the camera icon or choose an option similar to Scan code, and then use the device's camera to scan the code. - In the Manage MFA Device wizard, choose Show secret key for manual configuration, and then type the secret configuration key into your MFA application. When you are finished, the virtual MFA device starts generating one-time passwords. In the Manage MFA Device wizard, in the Authentication Code 1 box, type the one-time password that currently appears in the virtual MFA device. Wait up to 30 seconds for the device to generate a new one-time password. Then type the second one-time password into the Authentication Code 2 box. Choose Assign Virtual MFA.",
+ "AuditProcedure": "Perform the following to determine if the 'root' user account is enabled and has MFA setup: **From Console:** 1. Login to the AWS Management Console 2. Click `Services` 3. Click `IAM` 4. Click on `Credential Report` 5. This will download a `.csv` file which contains credential usage for all IAM users within an AWS Account - open this file 6. For the `` user, ensure the `mfa_active` field is set to `TRUE` or the `password_enabled` field is set to `FALSE` **From Command Line:** 1. Run the following command: ``` aws iam get-account-summary | grep AccountMFAEnabled aws iam get-account-summary | grep AccountPasswordPresent ``` 2. Ensure the AccountMFAEnabled property is set to 1 or the AccountPasswordPresent property is set to 0",
+ "AdditionalInformation": "IAM User account root for us-gov cloud regions does not have console access. This recommendation is not applicable for us-gov cloud regions.",
+ "References": "https://docs.aws.amazon.com/IAM/latest/UserGuide/id_root-user.html#id_root-user_manage_mfa:https://docs.aws.amazon.com/IAM/latest/UserGuide/id_credentials_mfa_enable_virtual.html#enable-virt-mfa-for-root:https://docs.aws.amazon.com/IAM/latest/UserGuide/id_root-enable-root-access.html",
+ "DefaultValue": ""
+ }
+ ]
+ },
+ {
+ "Id": "2.6",
+ "Description": "Ensure hardware MFA is enabled for the 'root' user account",
+ "Checks": [
+ "iam_root_hardware_mfa_enabled"
+ ],
+ "Attributes": [
+ {
+ "Section": "2 Identity and Access Management",
+ "Profile": "Level 2",
+ "AssessmentStatus": "Manual",
+ "Description": "The 'root' user account is the most privileged user in an AWS account. MFA adds an extra layer of protection on top of a user name and password. With MFA enabled, when a user signs in to an AWS website, they will be prompted for their user name and password as well as for an authentication code from their AWS MFA device. For Level 2, it is recommended that the 'root' user account be protected with a hardware MFA. Where an AWS Organization is using centralized root access, root credentials can be removed from member accounts. In that case it is neither possible nor necessary to configure root MFA in the member account.",
+ "RationaleStatement": "A hardware MFA has a smaller attack surface than a virtual MFA. For example, a hardware MFA does not suffer the attack surface introduced by the mobile smartphone on which a virtual MFA resides. **Note**: Using hardware MFA for numerous AWS accounts may create a logistical device management issue. If this is the case, consider implementing this Level 2 recommendation selectively for the highest security AWS accounts, while applying the Level 1 recommendation to the remaining accounts.",
+ "ImpactStatement": "",
+ "RemediationProcedure": "**Note:** To manage MFA devices for the AWS 'root' user account, you must use your 'root' account credentials to sign in to AWS. You cannot manage MFA devices for the 'root' account using other credentials. Perform the following to establish a hardware MFA for the 'root' user account: 1. Open the AWS Management Console and sign in using your root user credentials. 2. On the right side of the navigation bar, choose your account name, and choose Security credentials. 3. In the Multi-Factor Authentication (MFA) section, choose Assign MFA device. 4. In the wizard, type a Device name, choose Authenticator app, and then choose Next. IAM generates and displays configuration information for the virtual MFA device, including a QR code graphic. The graphic is a representation of the secret configuration key that is available for manual entry on devices that do not support QR codes. 5. Open the virtual MFA app on the device. If the virtual MFA app supports multiple virtual MFA devices or accounts, choose the option to create a new virtual MFA device or account. 6. The easiest way to configure the app is to use the app to scan the QR code. If you cannot scan the code, you can type the configuration information manually. The QR code and secret configuration key generated by IAM are tied to your AWS account. To use the QR code to configure the virtual MFA device, from the wizard, choose Show QR code. Then follow the app instructions for scanning the code. For example, you might need to choose the camera icon or choose a command like Scan account barcode, and then use the device's camera to scan the QR code. To manual entry secret key on devices, in the Set up device wizard, choose Show secret key, and then type the secret key into your MFA app. 7. In the wizard, in the MFA code 1 box, type the one-time password that currently appears in the virtual MFA device. Wait up to 30 seconds for the device to generate a new one-time password. Then type the second one-time password into the MFA code 2 box. Choose Add MFA. Remediation for this recommendation is not available through AWS CLI.",
+ "AuditProcedure": "Perform the following to determine if the 'root' user account has a hardware MFA setup: 1. Run the following command to determine if the 'root' account has MFA setup: ``` aws iam get-account-summary | grep \"AccountMFAEnabled\" aws iam get-account-summary | grep \"AccountPasswordPresent\" ``` The `AccountMFAEnabled` property is set to `1` will ensure that the 'root' user account has MFA (Virtual or Hardware) Enabled. `AccountPasswordPresent` set to `0` indicates that the `root` console credential has been removed. If `AccountMFAEnabled` property is set to `0` and `AccountPasswordPresent` is set to `1` the account is not compliant with this recommendation. 2. If `AccountMFAEnabled` property is set to `1`, determine 'root' account has Hardware MFA enabled. Run the following command to list all virtual MFA devices: ``` aws iam list-virtual-mfa-devices ``` If the output contains one MFA with the following Serial Number, it means the MFA is virtual, not hardware and the account is not compliant with this recommendation: `SerialNumber: arn:aws:iam::__:mfa/root-account-mfa-device`",
+ "AdditionalInformation": "IAM User account 'root' for us-gov cloud regions does not have console access. This control is not applicable for us-gov cloud regions.",
+ "References": "CCE-78911-5:https://docs.aws.amazon.com/IAM/latest/UserGuide/id_credentials_mfa_enable_virtual.html:https://docs.aws.amazon.com/IAM/latest/UserGuide/id_credentials_mfa_enable_physical.html#enable-hw-mfa-for-root:https://docs.aws.amazon.com/IAM/latest/UserGuide/id_root-enable-root-access.html:https://docs.aws.amazon.com/IAM/latest/UserGuide/enable-virt-mfa-for-root.html",
+ "DefaultValue": "By default, the AWS root user does not have a hardware MFA device assigned. MFA must be explicitly configured, and if enabled by default it will be virtual (software-based), not hardware."
+ }
+ ]
+ },
+ {
+ "Id": "2.7",
+ "Description": "Eliminate use of the 'root' user for administrative and daily tasks",
+ "Checks": [
+ "iam_avoid_root_usage"
+ ],
+ "Attributes": [
+ {
+ "Section": "2 Identity and Access Management",
+ "Profile": "Level 1",
+ "AssessmentStatus": "Manual",
+ "Description": "With the creation of an AWS account, a 'root user' is created that cannot be disabled or deleted. That user has unrestricted access to and control over all resources in the AWS account. It is highly recommended that the use of this account be avoided for everyday tasks.",
+ "RationaleStatement": "The 'root user' has unrestricted access to and control over all account resources. Use of it is inconsistent with the principles of least privilege and separation of duties, and can lead to unnecessary harm due to error or account compromise.",
+ "ImpactStatement": "",
+ "RemediationProcedure": "If you find that the 'root' user account is being used for daily activities, including administrative tasks that do not require the 'root' user: 1. Change the 'root' user password. 2. Deactivate or delete any access keys associated with the 'root' user. Remember, anyone who has 'root' user credentials for your AWS account has unrestricted access to and control of all the resources in your account, including billing information.",
+ "AuditProcedure": "**From Console:** 1. Login to the AWS Management Console at `https://console.aws.amazon.com/iam/`. 2. In the left pane, click `Credential Report`. 3. Click on `Download Report`. 4. Open or Save the file locally. 5. Locate the `` under the user column. 6. Review `password_last_used, access_key_1_last_used_date, access_key_2_last_used_date` to determine when the 'root user' was last used. **From Command Line:** Run the following CLI commands to provide a credential report for determining the last time the 'root user' was used: ``` aws iam generate-credential-report ``` ``` aws iam get-credential-report --query 'Content' --output text | base64 -d | cut -d, -f1,5,11,16 | grep -B1 '' ``` Review `password_last_used`, `access_key_1_last_used_date`, `access_key_2_last_used_date` to determine when the _root user_ was last used. **Note:** There are a few conditions under which the use of the 'root' user account is required. Please see the reference links for all of the tasks that require use of the 'root' user.",
+ "AdditionalInformation": "The 'root' user for us-gov cloud regions is not enabled by default. However, on request to AWS support, they can enable the 'root' user and grant access only through access-keys (CLI, API methods) for us-gov cloud region. If the 'root' user for us-gov cloud regions is enabled, this recommendation is applicable. Monitoring usage of the 'root' user can be accomplished by implementing recommendation 3.3 Ensure a log metric filter and alarm exist for usage of the 'root' user.",
+ "References": "https://docs.aws.amazon.com/IAM/latest/UserGuide/best-practices.html:https://docs.aws.amazon.com/IAM/latest/UserGuide/id_root-user.html:https://docs.aws.amazon.com/general/latest/gr/aws_tasks-that-require-root.html",
+ "DefaultValue": ""
+ }
+ ]
+ },
+ {
+ "Id": "2.8",
+ "Description": "Ensure IAM password policy requires minimum length of 14 or greater",
+ "Checks": [
+ "iam_password_policy_minimum_length_14"
+ ],
+ "Attributes": [
+ {
+ "Section": "2 Identity and Access Management",
+ "Profile": "Level 1",
+ "AssessmentStatus": "Automated",
+ "Description": "Password policies are, in part, used to enforce password complexity requirements. IAM password policies can be used to ensure passwords are at least a given length. It is recommended that the password policy require a minimum password length 14.",
+ "RationaleStatement": "Setting a password complexity policy increases account resiliency against brute force login attempts.",
+ "ImpactStatement": "Enforcing a minimum password length of 14 characters enhances security by making passwords more resistant to brute force attacks. However, it may require users to create longer and potentially more complex passwords, which could impact user convenience.",
+ "RemediationProcedure": "Perform the following to set the password policy as prescribed: **From Console:** 1. Login to AWS Console (with appropriate permissions to View Identity Access Management Account Settings) 2. Go to IAM Service on the AWS Console 3. Click on Account Settings on the Left Pane 4. Set Minimum password length to `14` or greater. 5. Click Apply password policy **From Command Line:** ``` aws iam update-account-password-policy --minimum-password-length 14 ``` Note: All commands starting with aws iam update-account-password-policy can be combined into a single command.",
+ "AuditProcedure": "Perform the following to ensure the password policy is configured as prescribed: **From Console:** 1. Login to AWS Console (with appropriate permissions to View Identity Access Management Account Settings) 2. Go to IAM Service on the AWS Console 3. Click on Account Settings on the Left Pane 4. Ensure Minimum password length is set to 14 or greater. **From Command Line:** ``` aws iam get-account-password-policy ``` Ensure the output of the above command includes MinimumPasswordLength: 14 (or higher)",
+ "AdditionalInformation": "Ensure the password policy also includes requirements for password complexity, such as the inclusion of uppercase letters, lowercase letters, numbers, and special characters: ``` aws iam update-account-password-policy --require-uppercase-characters --require-lowercase-characters --require-numbers --require-symbols ```",
+ "References": "https://docs.aws.amazon.com/IAM/latest/UserGuide/id_credentials_passwords_account-policy.html:https://docs.aws.amazon.com/IAM/latest/UserGuide/best-practices.html#configure-strong-password-policy",
+ "DefaultValue": ""
+ }
+ ]
+ },
+ {
+ "Id": "2.9",
+ "Description": "Ensure IAM password policy prevents password reuse",
+ "Checks": [
+ "iam_password_policy_reuse_24"
+ ],
+ "Attributes": [
+ {
+ "Section": "2 Identity and Access Management",
+ "Profile": "Level 1",
+ "AssessmentStatus": "Automated",
+ "Description": "IAM password policies can prevent the reuse of a given password by the same user. It is recommended that the password policy prevent the reuse of passwords.",
+ "RationaleStatement": "Preventing password reuse increases account resiliency against brute force login attempts.",
+ "ImpactStatement": "",
+ "RemediationProcedure": "Perform the following to set the password policy as prescribed: **From Console:** 1. Login to AWS Console (with appropriate permissions to View Identity Access Management Account Settings) 2. Go to IAM Service on the AWS Console 3. Click on Account Settings on the Left Pane 4. Check Prevent password reuse 5. Set Number of passwords to remember is set to `24` **From Command Line:** ``` aws iam update-account-password-policy --password-reuse-prevention 24 ``` Note: All commands starting with aws iam update-account-password-policy can be combined into a single command.",
+ "AuditProcedure": "Perform the following to ensure the password policy is configured as prescribed: **From Console:** 1. Login to AWS Console (with appropriate permissions to View Identity Access Management Account Settings) 2. Go to IAM Service on the AWS Console 3. Click on Account Settings on the Left Pane 4. Ensure Prevent password reuse is checked 5. Ensure Number of passwords to remember is set to 24 **From Command Line:** ``` aws iam get-account-password-policy ``` Ensure the output of the above command includes PasswordReusePrevention: 24",
+ "AdditionalInformation": "",
+ "References": "https://docs.aws.amazon.com/IAM/latest/UserGuide/id_credentials_passwords_account-policy.html:https://docs.aws.amazon.com/IAM/latest/UserGuide/best-practices.html#configure-strong-password-policy",
+ "DefaultValue": ""
+ }
+ ]
+ },
+ {
+ "Id": "2.10",
+ "Description": "Ensure multi-factor authentication (MFA) is enabled for all IAM users that have a console password",
+ "Checks": [
+ "iam_user_mfa_enabled_console_access"
+ ],
+ "Attributes": [
+ {
+ "Section": "2 Identity and Access Management",
+ "Profile": "Level 1",
+ "AssessmentStatus": "Automated",
+ "Description": "Multi-Factor Authentication (MFA) adds an extra layer of authentication assurance beyond traditional credentials. With MFA enabled, when a user signs in to the AWS Console, they will be prompted for their user name and password as well as for an authentication code from their physical or virtual MFA token. It is recommended that MFA be enabled for all accounts that have a console password.",
+ "RationaleStatement": "Enabling MFA provides increased security for console access as it requires the authenticating principal to possess a device that displays a time-sensitive key and have knowledge of a credential.",
+ "ImpactStatement": "AWS will soon end support for SMS multi-factor authentication (MFA). New customers are not allowed to use this feature. We recommend that existing customers switch to an alternative method of MFA.",
+ "RemediationProcedure": "Perform the following to enable MFA: **From Console:** 1. Sign in to the AWS Management Console and open the IAM console at 'https://console.aws.amazon.com/iam/' 2. In the left pane, select `Users`. 3. In the `User Name` list, choose the name of the intended MFA user. 4. Choose the `Security Credentials` tab, and then choose `Manage MFA Device`. 5. In the `Manage MFA Device wizard`, choose `Virtual MFA` device, and then choose `Continue`. IAM generates and displays configuration information for the virtual MFA device, including a QR code graphic. The graphic is a representation of the 'secret configuration key' that is available for manual entry on devices that do not support QR codes. 6. Open your virtual MFA application. (For a list of apps that you can use for hosting virtual MFA devices, see Virtual MFA Applications at https://aws.amazon.com/iam/details/mfa/#Virtual_MFA_Applications). If the virtual MFA application supports multiple accounts (multiple virtual MFA devices), choose the option to create a new account (a new virtual MFA device). 7. Determine whether the MFA app supports QR codes, and then do one of the following: - Use the app to scan the QR code. For example, you might choose the camera icon or choose an option similar to Scan code, and then use the device's camera to scan the code. - In the Manage MFA Device wizard, choose Show secret key for manual configuration, and then type the secret configuration key into your MFA application. When you are finished, the virtual MFA device starts generating one-time passwords. 8. In the `Manage MFA Device wizard`, in the `MFA Code 1 box`, type the `one-time password` that currently appears in the virtual MFA device. Wait up to 30 seconds for the device to generate a new one-time password. Then type the second `one-time password` into the `MFA Code 2 box`. 9. Click `Assign MFA`.",
+ "AuditProcedure": "Perform the following to determine if a MFA device is enabled for all IAM users having a console password: **From Console:** 1. Open the IAM console at [https://console.aws.amazon.com/iam/](https://console.aws.amazon.com/iam/). 2. In the left pane, select `Users` 3. If the `MFA` or `Password age` columns are not visible in the table, click the gear icon at the upper right corner of the table and ensure a checkmark is next to both, then click `Close`. 4. Ensure that for each user where the `Password age` column shows a password age, the `MFA` column shows `Virtual`, `U2F Security Key`, or `Hardware`. **From Command Line:** 1. Run the following command (OSX/Linux/UNIX) to generate a list of all IAM users along with their password and MFA status: ``` aws iam generate-credential-report ``` ``` aws iam get-credential-report --query 'Content' --output text | base64 -d | cut -d, -f1,4,8 ``` 2. The output of this command will produce a table similar to the following: ``` user,password_enabled,mfa_active elise,false,false brandon,true,true rakesh,false,false helene,false,false paras,true,true anitha,false,false ``` 3. For any column having `password_enabled` set to `true` , ensure `mfa_active` is also set to `true.`",
+ "AdditionalInformation": "**Forced IAM User Self-Service Remediation** Amazon has published a pattern that requires users to set up MFA through self-service before they gain access to their complete set of permissions. Until they complete this step, they cannot access their full permissions. This pattern can be used for new AWS accounts. It can also be applied to existing accounts; it is recommended that users receive instructions and a grace period to complete MFA enrollment before active enforcement on existing AWS accounts.",
+ "References": "https://tools.ietf.org/html/rfc6238:https://docs.aws.amazon.com/IAM/latest/UserGuide/id_credentials_mfa.html:https://docs.aws.amazon.com/IAM/latest/UserGuide/best-practices.html#enable-mfa-for-privileged-users:https://docs.aws.amazon.com/IAM/latest/UserGuide/id_credentials_mfa_enable_virtual.html:https://blogs.aws.amazon.com/security/post/Tx2SJJYE082KBUK/How-to-Delegate-Management-of-Multi-Factor-Authentication-to-AWS-IAM-Users",
+ "DefaultValue": ""
+ }
+ ]
+ },
+ {
+ "Id": "2.11",
+ "Description": "Ensure credentials unused for 45 days or more are disabled",
+ "Checks": [
+ "iam_user_accesskey_unused",
+ "iam_user_console_access_unused"
+ ],
+ "ConfigRequirements": [
+ {
+ "Check": "iam_user_accesskey_unused",
+ "ConfigKey": "max_unused_access_keys_days",
+ "Operator": "lte",
+ "Value": 45
+ },
+ {
+ "Check": "iam_user_console_access_unused",
+ "ConfigKey": "max_console_access_days",
+ "Operator": "lte",
+ "Value": 45
+ }
+ ],
+ "Attributes": [
+ {
+ "Section": "2 Identity and Access Management",
+ "Profile": "Level 1",
+ "AssessmentStatus": "Automated",
+ "Description": "AWS IAM users can access AWS resources using different types of credentials, such as passwords or access keys. It is recommended that all credentials that have been unused for 45 days or more be deactivated or removed.",
+ "RationaleStatement": "Disabling or removing unnecessary credentials will reduce the window of opportunity for credentials associated with a compromised or abandoned account to be used.",
+ "ImpactStatement": "",
+ "RemediationProcedure": "**From Console:** Perform the following to manage Unused Password (IAM user console access) 1. Login to the AWS Management Console: 2. Click `Services` 3. Click `IAM` 4. Click on `Users` 5. Click on `Security Credentials` 6. Select user whose `Console last sign-in` is greater than 45 days 7. Click `Security credentials` 8. In section `Sign-in credentials`, `Console password` click `Manage` 9. Under Console Access select `Disable` 10. Click `Apply` Perform the following to deactivate Access Keys: 1. Login to the AWS Management Console: 2. Click `Services` 3. Click `IAM` 4. Click on `Users` 5. Click on `Security Credentials` 6. Select any access keys that are over 45 days old and that have been used and - Click on `Make Inactive` 7. Select any access keys that are over 45 days old and that have not been used and - Click the X to `Delete`",
+ "AuditProcedure": "Perform the following to determine if unused credentials exist: **From Console:** 1. Login to the AWS Management Console 2. Click `Services` 3. Click `IAM` 4. Click on `Users` 5. Click the `Settings` (gear) icon. 6. Select `Console last sign-in`, `Access key last used`, and `Access Key Id` 7. Click on `Close` 8. Check and ensure that `Console last sign-in` is less than 45 days ago. **Note** - `Never` means the user has never logged in. 9. Check and ensure that `Access key age` is less than 45 days and that `Access key last used` does not say `None` If the user hasn't signed into the Console in the last 45 days or Access keys are over 45 days old refer to the remediation. **From Command Line:** **Download Credential Report:** 1. Run the following commands: ``` aws iam generate-credential-report aws iam get-credential-report --query 'Content' --output text | base64 -d | cut -d, -f1,4,5,6,9,10,11,14,15,16 | grep -v '^' ``` **Ensure unused credentials do not exist:** 2. For each user having `password_enabled` set to `TRUE` , ensure `password_last_used_date` is less than `45` days ago. - When `password_enabled` is set to `TRUE` and `password_last_used` is set to `No_Information` , ensure `password_last_changed` is less than 45 days ago. 3. For each user having an `access_key_1_active` or `access_key_2_active` to `TRUE` , ensure the corresponding `access_key_n_last_used_date` is less than `45` days ago. - When a user having an `access_key_x_active` (where x is 1 or 2) to `TRUE` and corresponding access_key_x_last_used_date is set to `N/A`, ensure `access_key_x_last_rotated` is less than 45 days ago.",
+ "AdditionalInformation": " is excluded in the audit since the root account should not be used for day-to-day business and would likely be unused for more than 45 days.",
+ "References": "https://docs.aws.amazon.com/IAM/latest/UserGuide/best-practices.html#remove-credentials:https://docs.aws.amazon.com/IAM/latest/UserGuide/id_credentials_finding-unused.html:https://docs.aws.amazon.com/IAM/latest/UserGuide/id_credentials_passwords_admin-change-user.html:https://docs.aws.amazon.com/IAM/latest/UserGuide/id_credentials_access-keys.html",
+ "DefaultValue": ""
+ }
+ ]
+ },
+ {
+ "Id": "2.12",
+ "Description": "Ensure access keys are rotated every 90 days or less",
+ "Checks": [
+ "iam_rotate_access_key_90_days"
+ ],
+ "Attributes": [
+ {
+ "Section": "2 Identity and Access Management",
+ "Profile": "Level 1",
+ "AssessmentStatus": "Automated",
+ "Description": "Access keys consist of an access key ID and secret access key, which are used to sign programmatic requests that you make to AWS. AWS users need their own access keys to make programmatic calls to AWS from the AWS Command Line Interface (AWS CLI), Tools for Windows PowerShell, the AWS SDKs, or direct HTTP calls using the APIs for individual AWS services. It is recommended that all access keys be rotated regularly.",
+ "RationaleStatement": "Rotating access keys will reduce the window of opportunity for an access key that is associated with a compromised or terminated account to be used. Access keys should be rotated to ensure that data cannot be accessed with an old key which might have been lost, cracked, or stolen.",
+ "ImpactStatement": "",
+ "RemediationProcedure": "Perform the following to rotate access keys: **From Console:** 1. Go to the Management Console (https://console.aws.amazon.com/iam) 2. Click on `Users` 3. Click on `Security Credentials` 4. As an Administrator - Click on `Make Inactive` for keys that have not been rotated in `90` Days 5. As an IAM User - Click on `Make Inactive` or `Delete` for keys which have not been rotated or used in `90` Days 6. Click on `Create Access Key` 7. Update programmatic calls with new Access Key credentials **From Command Line:** 1. While the first access key is still active, create a second access key, which is active by default. Run the following command: ``` aws iam create-access-key --user-name ``` At this point, the user has two active access keys. 2. Update all applications and tools to use the new access key. 3. Determine whether the first access key is still in use by using this command: ``` aws iam get-access-key-last-used --access-key-id ``` 4. One approach is to wait several days and then check the old access key for any use before proceeding. Even if step 3 indicates no use of the old key, it is recommended that you do not immediately delete the first access key. Instead, change the state of the first access key to Inactive using this command: ``` aws iam update-access-key --user-name --access-key-id --status Inactive ``` 5. Use only the new access key to confirm that your applications are working. Any applications and tools that still use the original access key will stop working at this point because they no longer have access to AWS resources. If you find such an application or tool, you can switch its state back to Active to reenable the first access key. Then return to step 2 and update this application to use the new key. 6. After you wait some period of time to ensure that all applications and tools have been updated, you can delete the first access key with this command: ``` aws iam delete-access-key --user-name --access-key-id ```",
+ "AuditProcedure": "Perform the following to determine if access keys are rotated as prescribed: **From Console:** 1. Go to the Management Console (https://console.aws.amazon.com/iam) 2. Click on `Users` 3. For each user, go to `Security Credentials` 4. Review each key under `Access Keys` 5. For each key that shows `Active` for status, ensure that `Created` is less than or equal to `90 days ago`. **From Command Line:** ``` aws iam generate-credential-report aws iam get-credential-report --query 'Content' --output text | base64 -d ``` The `access_key_1_last_rotated` and the `access_key_2_last_rotated` fields in this file notes the date and time, in ISO 8601 date-time format, when the user's access key was created or last changed. If the user does not have an active access key, the value in this field is N/A (not applicable).",
+ "AdditionalInformation": "",
+ "References": "CCE-78902-4:https://docs.aws.amazon.com/IAM/latest/UserGuide/best-practices.html#rotate-credentials:https://docs.aws.amazon.com/IAM/latest/UserGuide/id_credentials_finding-unused.html:https://docs.aws.amazon.com/general/latest/gr/managing-aws-access-keys.html:https://docs.aws.amazon.com/IAM/latest/UserGuide/id_credentials_access-keys.html",
+ "DefaultValue": "By default, AWS does not enforce access key rotation. Access keys remain valid until they are manually deactivated or deleted."
+ }
+ ]
+ },
+ {
+ "Id": "2.13",
+ "Description": "Ensure IAM users receive permissions only through groups",
+ "Checks": [
+ "iam_policy_attached_only_to_group_or_roles"
+ ],
+ "Attributes": [
+ {
+ "Section": "2 Identity and Access Management",
+ "Profile": "Level 1",
+ "AssessmentStatus": "Automated",
+ "Description": "IAM users are granted access to services, functions, and data through IAM policies. There are four ways to define policies for a user: 1) Edit the user policy directly, also known as an inline or user policy; 2) attach a policy directly to a user; 3) add the user to an IAM group that has an attached policy; 4) add the user to an IAM group that has an inline policy. Only the third implementation is recommended.",
+ "RationaleStatement": "Assigning IAM policies solely through groups unifies permissions management into a single, flexible layer that is consistent with organizational functional roles. By unifying permissions management, the likelihood of excessive permissions is reduced.",
+ "ImpactStatement": "",
+ "RemediationProcedure": "**From Console:** Perform the following to create an IAM group and assign a policy to it: 1. Sign in to the AWS Management Console and open the IAM console at https://console.aws.amazon.com/iam/. 2. In the navigation pane, click `Groups` and then click `Create New Group`. 3. In the `Group Name` box, type the name of the group and then click `Next Step`. 4. In the list of policies, select the check box for each policy that you want to apply to all members of the group. Then click `Next Step`. 5. Click `Create Group`. Perform the following to add a user to a given group: 1. Sign in to the AWS Management Console and open the IAM console at https://console.aws.amazon.com/iam/. 2. In the navigation pane, click `Groups`. 3. Select the group to add a user to. 4. Click `Add Users To Group`. 5. Select the users to be added to the group. 6. Click `Add Users`. Perform the following to remove a direct association between a user and policy: 1. Sign in to the AWS Management Console and open the IAM console at https://console.aws.amazon.com/iam/. 2. In the left navigation pane, click on Users. 3. For each user: - Select the user - Click on the `Permissions` tab - Expand `Permissions policies` - Click `X` for each policy; then click Detach or Remove (depending on policy type) **From Command Line:** 1. Create the IAM user group: ``` aws iam create-group --group-name ``` 2. Attach the policy to the IAM user group: ``` aws iam attach-group-policy --group-name --policy-arn ``` 3. Perform the following to add a user to a given group: ``` aws iam add-user-to-group --user-name --group-name ``` 4. Perform the following to remove a direct association between a user and policy: ``` aws iam detach-user-policy --user-name --policy-arn ``` 5. Delete an inline policy from an IAM user: ``` aws iam delete-user-policy --user-name --policy-name ```",
+ "AuditProcedure": "Perform the following to determine if an inline policy is set or a policy is directly attached to users: 1. Run the following to get a list of IAM users: ``` aws iam list-users --query 'Users[*].UserName' --output text ``` 2. For each user returned, run the following command to determine if any policies are attached to them: ``` aws iam list-attached-user-policies --user-name aws iam list-user-policies --user-name ``` 3. If any policies are returned, the user has an inline policy or direct policy attachment.",
+ "AdditionalInformation": "",
+ "References": "http://docs.aws.amazon.com/IAM/latest/UserGuide/best-practices.html:http://docs.aws.amazon.com/IAM/latest/UserGuide/access_policies_managed-vs-inline.html:CCE-78912-3",
+ "DefaultValue": "By default, AWS allows IAM policies to be attached directly to users, groups, or roles. There is no restriction preventing direct user policies unless explicitly enforced by organizational standards."
+ }
+ ]
+ },
+ {
+ "Id": "2.14",
+ "Description": "Ensure IAM policies that allow full \"*:*\" administrative privileges are not attached",
+ "Checks": [
+ "iam_aws_attached_policy_no_administrative_privileges",
+ "iam_customer_attached_policy_no_administrative_privileges"
+ ],
+ "Attributes": [
+ {
+ "Section": "2 Identity and Access Management",
+ "Profile": "Level 1",
+ "AssessmentStatus": "Automated",
+ "Description": "IAM policies are the means by which privileges are granted to users, groups, or roles. It is recommended and considered standard security advice to grant least privilege—that is, granting only the permissions required to perform a task. Determine what users need to do, and then craft policies for them that allow the users to perform only those tasks, instead of granting full administrative privileges.",
+ "RationaleStatement": "It's more secure to start with a minimum set of permissions and grant additional permissions as necessary, rather than starting with permissions that are too lenient and then attempting to tighten them later. Providing full administrative privileges instead of restricting access to the minimum set of permissions required for the user exposes resources to potentially unwanted actions. IAM policies that contain a statement with `Effect: Allow` and `Action: *` over `Resource: *` should be removed.",
+ "ImpactStatement": "",
+ "RemediationProcedure": "**From Console:** Perform the following to detach the policy that has full administrative privileges: 1. Sign in to the AWS Management Console and open the IAM console at [https://console.aws.amazon.com/iam/](https://console.aws.amazon.com/iam/). 2. In the navigation pane, click Policies and then search for the policy name found in the audit step. 3. Select the policy that needs to be deleted. 4. In the policy action menu, select `Detach`. 5. Select all Users, Groups, Roles that have this policy attached. 6. Click `Detach Policy`. 7. Select the newly detached policy and select `Delete`. **From Command Line:** Perform the following to detach the policy that has full administrative privileges as found in the audit step: 1. Lists all IAM users, groups, and roles that the specified managed policy is attached to. ``` aws iam list-entities-for-policy --policy-arn ``` 2. Detach the policy from all IAM Users: ``` aws iam detach-user-policy --user-name --policy-arn ``` 3. Detach the policy from all IAM Groups: ``` aws iam detach-group-policy --group-name --policy-arn ``` 4. Detach the policy from all IAM Roles: ``` aws iam detach-role-policy --role-name --policy-arn ```",
+ "AuditProcedure": "Perform the following to determine existing policies: **From Command Line:** 1. Run the following to get a list of IAM policies: ``` aws iam list-policies --only-attached --output text ``` 2. For each policy returned, run the following command to determine if any policy is allowing full administrative privileges on the account: ``` aws iam get-policy-version --policy-arn --version-id ``` 3. In the output, the policy should not contain any Statement block with `Effect: Allow` and `Action` set to `*` and `Resource` set to `*`.",
+ "AdditionalInformation": "",
+ "References": "https://docs.aws.amazon.com/IAM/latest/UserGuide/best-practices.html:https://docs.aws.amazon.com/IAM/latest/UserGuide/access_policies_managed-vs-inline.html:https://docs.aws.amazon.com/cli/latest/reference/iam/index.html#cli-aws-iam",
+ "DefaultValue": ""
+ }
+ ]
+ },
+ {
+ "Id": "2.15",
+ "Description": "Ensure a support role has been created to manage incidents with AWS Support",
+ "Checks": [
+ "iam_support_role_created"
+ ],
+ "Attributes": [
+ {
+ "Section": "2 Identity and Access Management",
+ "Profile": "Level 1",
+ "AssessmentStatus": "Automated",
+ "Description": "AWS provides a support center that can be used for incident notification and response, as well as technical support and customer services. Create an IAM Role, with the appropriate policy assigned, to allow authorized users to manage incidents with AWS Support.",
+ "RationaleStatement": "By implementing least privilege for access control, an IAM Role will require an appropriate IAM Policy to allow Support Center Access in order to manage Incidents with AWS Support.",
+ "ImpactStatement": "All AWS Support plans include an unlimited number of account and billing support cases, with no long-term contracts. Support billing calculations are performed on a per-account basis for all plans. Enterprise Support plan customers have the option to include multiple enabled accounts in an aggregated monthly billing calculation. Monthly charges for the Business and Enterprise support plans are based on each month's AWS usage charges, subject to a monthly minimum, billed in advance. When assigning rights, keep in mind that other policies may grant access to Support as well. This may include AdministratorAccess and other policies including customer managed policies. Utilizing the AWS managed 'AWSSupportAccess' role is one simple way of ensuring that this permission is properly granted. To better support the principle of separation of duties, it would be best to only attach this role where necessary.",
+ "RemediationProcedure": "**From Command Line:** 1. Create an IAM role for managing incidents with AWS: - Create a trust relationship policy document that allows to manage AWS incidents, and save it locally as /tmp/TrustPolicy.json: ``` { Version: 2012-10-17, Statement: [ { Effect: Allow, Principal: { AWS: }, Action: sts:AssumeRole } ] } ``` 2. Create the IAM role using the above trust policy: ``` aws iam create-role --role-name --assume-role-policy-document file:///tmp/TrustPolicy.json ``` 3. Attach 'AWSSupportAccess' managed policy to the created IAM role: ``` aws iam attach-role-policy --policy-arn arn:aws:iam::aws:policy/AWSSupportAccess --role-name ```",
+ "AuditProcedure": "**From Command Line:** 1. List IAM policies, filter for the 'AWSSupportAccess' managed policy, and note the Arn element value: ``` aws iam list-policies --query Policies[?PolicyName == 'AWSSupportAccess'] ``` 2. Check if the 'AWSSupportAccess' policy is attached to any role: ``` aws iam list-entities-for-policy --policy-arn arn:aws:iam::aws:policy/AWSSupportAccess ``` 3. In the output, ensure `PolicyRoles` does not return empty. 'Example: Example: PolicyRoles: [ ]' If it returns empty refer to the remediation below.",
+ "AdditionalInformation": "AWSSupportAccess policy is a global AWS resource. It has same ARN as `arn:aws:iam::aws:policy/AWSSupportAccess` for every account.",
+ "References": "https://docs.aws.amazon.com/IAM/latest/UserGuide/access_policies_managed-vs-inline.html:https://aws.amazon.com/premiumsupport/pricing/:https://awscli.amazonaws.com/v2/documentation/api/latest/reference/iam/list-policies.html:https://awscli.amazonaws.com/v2/documentation/api/latest/reference/iam/attach-role-policy.html:https://awscli.amazonaws.com/v2/documentation/api/latest/reference/iam/list-entities-for-policy.html",
+ "DefaultValue": ""
+ }
+ ]
+ },
+ {
+ "Id": "2.16",
+ "Description": "Ensure IAM instance roles are used for AWS resource access from instances",
+ "Checks": [
+ "ec2_instance_profile_attached"
+ ],
+ "Attributes": [
+ {
+ "Section": "2 Identity and Access Management",
+ "Profile": "Level 2",
+ "AssessmentStatus": "Automated",
+ "Description": "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 Access means accessing the APIs of AWS in order to access AWS resources or manage AWS account resources.",
+ "RationaleStatement": "AWS IAM roles reduce the risks associated with sharing and rotating credentials that can be used outside of AWS itself. Compromised credentials can be used from outside the AWS account to which they provide access. In contrast, to leverage role permissions, an attacker would need to gain and maintain access to a specific instance to use the privileges associated with it. Additionally, if credentials are encoded into compiled applications or other hard-to-change mechanisms, they are even less likely to be properly rotated due to the risks of service disruption. As time passes, credentials that cannot be rotated are more likely to be known by an increasing number of individuals who no longer work for the organization that owns the credentials.",
+ "ImpactStatement": "",
+ "RemediationProcedure": "**From Console:** 1. Sign in to the AWS Management Console and navigate to the EC2 dashboard at `https://console.aws.amazon.com/ec2/`. 2. In the left navigation panel, choose `Instances`. 3. Select the EC2 instance you want to modify. 4. Click `Actions`. 5. Click `Security`. 6. Click `Modify IAM role`. 7. Click `Create new IAM role` if a new IAM role is required. 8. Select the IAM role you want to attach to your instance in the `IAM role` dropdown. 9. Click `Update IAM role`. 10. Repeat steps 3 to 9 for each EC2 instance in your AWS account that requires an IAM role to be attached. **From Command Line:** 1. Run the `describe-instances` command to list all EC2 instance IDs in the selected AWS region: ``` aws ec2 describe-instances --region --query 'Reservations[*].Instances[*].InstanceId' ``` 2. Run the `associate-iam-instance-profile` command to attach an instance profile (which is attached to an IAM role) to the EC2 instance: ``` aws ec2 associate-iam-instance-profile --region --instance-id --iam-instance-profile Name=Instance-Profile-Name ``` 3. Run the `describe-instances` command again for the recently modified EC2 instance. The command output should return the instance profile ARN and ID: ``` aws ec2 describe-instances --region --instance-id --query 'Reservations[*].Instances[*].IamInstanceProfile' ``` 4. Repeat steps 2 and 3 for each EC2 instance in your AWS account that requires an IAM role to be attached.",
+ "AuditProcedure": "First, check if the instance has any API secrets stored using Secret Scanning. Currently, AWS does not have a solution for this. You can use open-source tools like TruffleHog to scan for secrets in the EC2 instance. If a secret is found, then assign the role to the instance. **From Console:** 1. Sign in to the AWS Management Console and navigate to the EC2 dashboard at `https://console.aws.amazon.com/ec2/`. 2. In the left navigation panel, choose `Instances`. 3. Select the EC2 instance you want to examine. 4. Select `Actions`. 5. Select `View details`. 6. Select `Security` in the lower panel. - If the value for **Instance profile arn** is an instance profile ARN, then an instance profile (that contains an IAM role) is attached. - If the value for **IAM Role** is blank, no role is attached. - If the value for **IAM Role** contains a role, a role is attached. - If the value for **IAM Role** is No roles attached to instance profile: , then an instance profile is attached to the instance, but it does not contain an IAM role. 7. Repeat steps 3 to 6 for each EC2 instance in your AWS account. **From Command Line:** 1. Run the `describe-instances` command to list all EC2 instance IDs in the selected AWS region: ``` aws ec2 describe-instances --region --query 'Reservations[*].Instances[*].InstanceId' ``` 2. Run the `describe-instances` command again for each EC2 instance using the `IamInstanceProfile` identifier in the query filter to check if an IAM role is attached: ``` aws ec2 describe-instances --region --instance-id --query 'Reservations[*].Instances[*].IamInstanceProfile' ``` 3. If an IAM role is attached, the command output will show the IAM instance profile ARN and ID. 4. Repeat steps 2 and 3 for each EC2 instance in your AWS account.",
+ "AdditionalInformation": "",
+ "References": "https://docs.aws.amazon.com/IAM/latest/UserGuide/id_roles_use_switch-role-ec2.html:https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/iam-roles-for-amazon-ec2.html",
+ "DefaultValue": ""
+ }
+ ]
+ },
+ {
+ "Id": "2.17",
+ "Description": "Ensure that all expired SSL/TLS certificates stored in AWS IAM are removed",
+ "Checks": [
+ "iam_no_expired_server_certificates_stored"
+ ],
+ "Attributes": [
+ {
+ "Section": "2 Identity and Access Management",
+ "Profile": "Level 1",
+ "AssessmentStatus": "Automated",
+ "Description": "To enable HTTPS connections to your website or application in AWS, you need an SSL/TLS server certificate. You can use AWS Certificate Manager (ACM) or IAM to store and deploy server certificates. Use IAM as a certificate manager only when you must support HTTPS connections in a region that is not supported by ACM. IAM securely encrypts your private keys and stores the encrypted version in IAM SSL certificate storage. IAM supports deploying server certificates in all regions, but you must obtain your certificate from an external provider for use with AWS. You cannot upload an ACM certificate to IAM. Additionally, you cannot manage your certificates from the IAM Console.",
+ "RationaleStatement": "Removing expired SSL/TLS certificates eliminates the risk that an invalid certificate will be deployed accidentally to a resource such as AWS Elastic Load Balancer (ELB), which can damage the credibility of the application/website behind the ELB. As a best practice, it is recommended to delete expired certificates.",
+ "ImpactStatement": "Deleting the certificate could have implications for your application if you are using an expired server certificate with Elastic Load Balancing, CloudFront, etc. You must make configurations in the respective services to ensure there is no interruption in application functionality.",
+ "RemediationProcedure": "**From Console:** Removing expired certificates via AWS Management Console is not currently supported. To delete SSL/TLS certificates stored in IAM through the AWS API, use the Command Line Interface (CLI). **From Command Line:** To delete an expired certificate, run the following command by replacing with the name of the certificate to delete: ``` aws iam delete-server-certificate --server-certificate-name ``` When the preceding command is successful, it does not return any output.",
+ "AuditProcedure": "**From Console:** Getting the certificate expiration information via the AWS Management Console is not currently supported. To request information about the SSL/TLS certificates stored in IAM through the AWS API, use the Command Line Interface (CLI). **From Command Line:** Run the `list-server-certificates` command to list all the IAM-stored server certificates: ``` aws iam list-server-certificates ``` The command output should return an array that contains all the SSL/TLS certificates currently stored in IAM and their metadata (name, ID, expiration date, etc): ``` { ServerCertificateMetadataList: [ { ServerCertificateId: EHDGFRW7EJFYTE88D, ServerCertificateName: MyServerCertificate, Expiration: 2018-07-10T23:59:59Z, Path: /, Arn: arn:aws:iam::012345678910:server-certificate/MySSLCertificate, UploadDate: 2018-06-10T11:56:08Z } ] } ``` Verify the `ServerCertificateName` and `Expiration` parameter value (expiration date) for each SSL/TLS certificate returned by the list-server-certificates command and determine if there are any expired server certificates currently stored in AWS IAM. If so, use the AWS API to remove them. If this command returns: ``` { { ServerCertificateMetadataList: [] } ``` This means that there are no expired certificates; it **does not** mean that no certificates exist.",
+ "AdditionalInformation": "",
+ "References": "https://docs.aws.amazon.com/IAM/latest/UserGuide/id_credentials_server-certs.html:https://awscli.amazonaws.com/v2/documentation/api/latest/reference/iam/delete-server-certificate.html",
+ "DefaultValue": "By default, expired certificates will not be deleted."
+ }
+ ]
+ },
+ {
+ "Id": "2.18",
+ "Description": "Ensure that IAM External Access Analyzer is enabled for all regions",
+ "Checks": [
+ "accessanalyzer_enabled"
+ ],
+ "ConfigRequirements": [
+ {
+ "Check": "accessanalyzer_enabled",
+ "ConfigKey": "mute_non_default_regions",
+ "Operator": "eq",
+ "Value": false
+ }
+ ],
+ "Attributes": [
+ {
+ "Section": "2 Identity and Access Management",
+ "Profile": "Level 1",
+ "AssessmentStatus": "Automated",
+ "Description": "Enable the IAM External Access Analyzer regarding all resources in each active AWS region. IAM Access Analyzer is a technology introduced at AWS reinvent 2019. After the Analyzer is enabled in IAM, scan results are displayed on the console showing the accessible resources. Scans show resources that other accounts and federated users can access, such as KMS keys and IAM roles. The results allow you to determine whether an unintended user is permitted, making it easier for administrators to monitor least privilege access. Access Analyzer analyzes only the policies that are applied to resources in the same AWS Region.",
+ "RationaleStatement": "AWS IAM External Access Analyzer helps you identify the resources in your organization and accounts, such as Amazon S3 buckets or IAM roles, that are shared with external entities. This allows you to identify unintended access to your resources and data. Access Analyzer identifies resources that are shared with external principals by using logic-based reasoning to analyze the resource-based policies in your AWS environment. IAM External Access Analyzer continuously monitors all policies for S3 buckets, IAM roles, KMS (Key Management Service) keys, AWS Lambda functions, Amazon SQS (Simple Queue Service) queues and more",
+ "ImpactStatement": "",
+ "RemediationProcedure": "**From Console:** Perform the following to enable IAM Access Analyzer for IAM policies: 1. Open the IAM console at `https://console.aws.amazon.com/iam/.` 2. Choose `Access analyzer`. 3. Choose `Create external access analyzer`. 4. On the `Create analyzer` page, confirm that the `Region` displayed is the Region where you want to enable Access Analyzer. 5. Optionally enter a name for the analyzer. 6. Optionally add any tags that you want to apply to the analyzer. 7. Choose `Create Analyzer`. 8. Repeat these step for each active region. **From Command Line:** Run the following command: ``` aws accessanalyzer list-analyzers --type ORGANIZATION ``` Repeat this command for each active region. **Note:** The IAM Access Analyzer is successfully configured only when the account you use has the necessary permissions.",
+ "AuditProcedure": "**From Console:** 1. Open the IAM console at `https://console.aws.amazon.com/iam/` 2. Under `Access analyzer` choose `Analyzer Settings` 3. On the `Analyzer Settings` page, there will be a list of analyzers. 4. Look for analyzers where the `Finding type` is `External Access`. **From Command Line:** 1. Run the following command: ``` aws accessanalyzer list-analyzers --type ORGANIZATION | grep status ``` 2. Ensure that at least one Analyzer's `status` is set to `ACTIVE`. 3. Repeat the steps above for each active region. If an Access Analyzer is not listed for each region or the status is not set to active refer to the remediation procedure below.",
+ "AdditionalInformation": "",
+ "References": "https://docs.aws.amazon.com/IAM/latest/UserGuide/what-is-access-analyzer.html:https://docs.aws.amazon.com/IAM/latest/UserGuide/access-analyzer-getting-started.html:https://awscli.amazonaws.com/v2/documentation/api/latest/reference/accessanalyzer/get-analyzer.html:https://awscli.amazonaws.com/v2/documentation/api/latest/reference/accessanalyzer/create-analyzer.html",
+ "DefaultValue": "By default, IAM External Access Analyzer is not enabled in any region. An analyzer must be explicitly created and activated for each region where monitoring is required."
+ }
+ ]
+ },
+ {
+ "Id": "2.19",
+ "Description": "Ensure IAM users are managed centrally via identity federation or AWS Organizations for multi-account environments",
+ "Checks": [
+ "iam_check_saml_providers_sts"
+ ],
+ "Attributes": [
+ {
+ "Section": "2 Identity and Access Management",
+ "Profile": "Level 2",
+ "AssessmentStatus": "Manual",
+ "Description": "In multi-account environments, IAM user centralization facilitates greater user control. User access beyond the initial account is then provided via role assumption. Centralization of users can be accomplished through federation with an external identity provider or through the use of AWS Organizations.",
+ "RationaleStatement": "Centralizing IAM user management to a single identity store reduces complexity and thus the likelihood of access management errors.",
+ "ImpactStatement": "",
+ "RemediationProcedure": "The remediation procedure will vary based on each individual organization's implementation of identity federation and/or AWS Organizations, with the acceptance criteria that no non-service IAM users and non-root accounts are present outside the account providing centralized IAM user management.",
+ "AuditProcedure": "For multi-account AWS environments with an external identity provider: 1. Determine the master account for identity federation or IAM user management 2. Login to that account through the AWS Management Console 3. Click `Services` 4. Click `IAM` 5. Click `Identity providers` 6. Verify the configuration For multi-account AWS environments with an external identity provider, as well as for those implementing AWS Organizations without an external identity provider: 1. Determine all accounts that should not have local users present 2. Log into the AWS Management Console 3. Switch role into each identified account 4. Click `Services` 5. Click `IAM` 6. Click `Users` 7. Confirm that no IAM users representing individuals are present",
+ "AdditionalInformation": "",
+ "References": "",
+ "DefaultValue": ""
+ }
+ ]
+ },
+ {
+ "Id": "2.20",
+ "Description": "Ensure access to AWSCloudShellFullAccess is restricted",
+ "Checks": [
+ "iam_policy_cloudshell_admin_not_attached"
+ ],
+ "Attributes": [
+ {
+ "Section": "2 Identity and Access Management",
+ "Profile": "Level 1",
+ "AssessmentStatus": "Manual",
+ "Description": "AWS CloudShell is a convenient way of running CLI commands against AWS services; a managed IAM policy ('AWSCloudShellFullAccess') provides full access to CloudShell, which allows file upload and download capability between a user's local system and the CloudShell environment. Within the CloudShell environment, a user has sudo permissions and can access the internet. Therefore, it is feasible to install file transfer software, for example, and move data from CloudShell to external internet servers.",
+ "RationaleStatement": "Access to this policy should be restricted, as it presents a potential channel for data exfiltration by malicious cloud admins who are given full permissions to the service. AWS documentation describes how to create a more restrictive IAM policy that denies file transfer permissions.",
+ "ImpactStatement": "",
+ "RemediationProcedure": "**From Console** 1. Open the IAM console at https://console.aws.amazon.com/iam/ 2. In the left pane, select Policies 3. Search for and select AWSCloudShellFullAccess 4. On the Entities attached tab, for each item, check the box and select Detach",
+ "AuditProcedure": "**From Console** 1. Open the IAM console at https://console.aws.amazon.com/iam/ 2. In the left pane, select Policies 3. Search for and select AWSCloudShellFullAccess 4. On the Entities attached tab, ensure that there are no entities using this policy **From Command Line** 1. List IAM policies, filter for the 'AWSCloudShellFullAccess' managed policy, and note the Arn element value: ``` aws iam list-policies --query Policies[?PolicyName == 'AWSCloudShellFullAccess'] ``` 2. Check if the 'AWSCloudShellFullAccess' policy is attached to any role: ``` aws iam list-entities-for-policy --policy-arn arn:aws:iam::aws:policy/AWSCloudShellFullAccess ``` 3. In the output, ensure PolicyRoles returns empty. 'Example: Example: PolicyRoles: [ ]' If it does not return empty, refer to the remediation below. **Note:** Keep in mind that other policies may grant access.",
+ "AdditionalInformation": "",
+ "References": "https://docs.aws.amazon.com/cloudshell/latest/userguide/sec-auth-with-identities.html",
+ "DefaultValue": ""
+ }
+ ]
+ },
+ {
+ "Id": "2.21",
+ "Description": "Ensure AWS resource policies do not allow unrestricted access using 'Principal': '*'",
+ "Checks": [
+ "s3_bucket_policy_public_write_access",
+ "sqs_queues_not_publicly_accessible",
+ "sns_topics_not_publicly_accessible",
+ "awslambda_function_not_publicly_accessible",
+ "kms_key_not_publicly_accessible",
+ "glacier_vaults_policy_public_access",
+ "secretsmanager_not_publicly_accessible",
+ "eventbridge_bus_exposed"
+ ],
+ "Attributes": [
+ {
+ "Section": "2 Identity and Access Management",
+ "Profile": "Level 1",
+ "AssessmentStatus": "Manual",
+ "Description": "Ensure AWS resource-based policies, such as Amazon S3 bucket policies, Amazon SQS queue policies, Amazon SNS topic policies, and AWS Lambda resource policies, do not grant unrestricted access using \"Principal\": \"*\" with \"Effect\": \"Allow\" unless the policy includes restrictive conditions that limit access to specific trusted identities, accounts, services, or network boundaries.",
+ "RationaleStatement": "Resource-based policies are evaluated alongside identity-based IAM policies during authorization decisions. When a policy statement specifies \"Principal\": \"*\" with \"Effect\": \"Allow\", it grants the specified permissions to any AWS principal unless additional conditions restrict the request. This may unintentionally allow access from users, roles, or services in any AWS account. Such broad access significantly increases the risk of unauthorized data access, resource abuse, or data exfiltration.",
+ "ImpactStatement": "Unrestricted resource-based policies may expose data or services to unauthorized access, potentially leading to data breaches, service misuse, or unintended public exposure.",
+ "RemediationProcedure": "If a resource policy contains `\"Principal\": \"*\"` with `\"Effect\": \"Allow\"` and lacks sufficient restrictions, modify the policy to limit access. **OPTION 1 - Restrict the Principal:** Replace the wildcard principal (`\"Principal\": \"*\"`) with a specific account, role, user, or service. Example Non-Compliant Policy: ``` {\"Version\": \"2012-10-17\", \"Statement\": [{\"Sid\": \"AllowPublicAccess\", \"Effect\": \"Allow\", \"Principal\": \"*\", \"Action\": \"sqs:SendMessage\", \"Resource\": \"arn:aws:sqs:us-east-1:123456789012:my-queue\"}]} ``` Steps: 1. Retrieve the current policy: ``` aws sqs get-queue-attributes --queue-url https://sqs.us-east-1.amazonaws.com/123456789012/my-queue --attribute-names Policy --query 'Attributes.Policy' ``` 2. Update the policy with a specific principal: ``` aws sqs set-queue-attributes --queue-url https://sqs.us-east-1.amazonaws.com/123456789012/my-queue --attributes '{\"Policy\": \"{\\\"Version\\\":\\\"2012-10-17\\\",\\\"Statement\\\":[{\\\"Sid\\\":\\\"AllowSpecificAccount\\\",\\\"Effect\\\":\\\"Allow\\\",\\\"Principal\\\":{\\\"AWS\\\":\\\"arn:aws:iam::345678901234:root\\\"},\\\"Action\\\":\\\"sqs:SendMessage\\\",\\\"Resource\\\":\\\"arn:aws:sqs:us-east-1:123456789012:my-queue\\\"}]}\"}' ``` Resulting Compliant Policy: ``` {\"Version\": \"2012-10-17\", \"Statement\": [{\"Sid\": \"AllowSpecificAccount\", \"Effect\": \"Allow\", \"Principal\": {\"AWS\": \"arn:aws:iam::345678901234:root\"}, \"Action\": \"sqs:SendMessage\", \"Resource\": \"arn:aws:sqs:us-east-1:123456789012:my-queue\"}]} ``` **OPTION 2 - Restrict Using Conditions:** If a wildcard principal is required, add restrictive conditions. Example compliant policy: ``` {\"Version\": \"2012-10-17\", \"Statement\": [{\"Sid\": \"AllowServiceIntegration\", \"Effect\": \"Allow\", \"Principal\": \"*\", \"Action\": \"sqs:SendMessage\", \"Resource\": \"arn:aws:sqs:us-east-1:123456789012:my-queue\", \"Condition\": {\"StringEquals\": {\"aws:SourceAccount\": \"345678901234\"}}}]} ```",
+ "AuditProcedure": "1. Identify resources that support resource-based policies within the AWS account, such as S3 buckets, SQS queues, SNS topics, and Lambda functions. 2. Retrieve the resource policies for each resource. Example CLI commands: SQS Queue Policies: ``` aws sqs get-queue-attributes --queue-url https://sqs.region.amazonaws.com/account/QUEUE --attribute-names Policy ``` S3 Bucket Policies: ``` aws s3api get-bucket-policy --bucket YOUR-BUCKET-NAME ``` SNS Topic Policies: ``` aws sns get-topic-attributes --topic-arn TOPIC-ARN --query \"Attributes.Policy\" --output text ``` 3. Inspect the retrieved policies and identify statements containing: - `\"Effect\": \"Allow\"` AND `\"Principal\": \"*\"` OR - `\"Principal\": {\"AWS\": \"*\"}` 4. Evaluate whether the statement includes restrictive conditions such as: - `aws:SourceArn` - `aws:SourceAccount` - `aws:PrincipalArn` - Other service-specific condition keys 5. Determine audit status: - Compliant: Wildcard principals are present only when restrictive conditions limit access to trusted principals or services - Non-Compliant: Wildcard principals are used without sufficient restrictions",
+ "AdditionalInformation": "",
+ "References": "",
+ "DefaultValue": "By default, AWS does not prevent the use of \"Principal\": \"*\" in resource-based policies. Policies may allow unrestricted access unless explicitly restricted through policy definitions or organizational controls. It is the responsibility of the customer to ensure that resource policies are properly scoped and do not grant unintended public or cross-account access."
+ }
+ ]
+ },
+ {
+ "Id": "3.1.1",
+ "Description": "Ensure S3 Bucket Policy is set to deny HTTP requests",
+ "Checks": [
+ "s3_bucket_secure_transport_policy"
+ ],
+ "Attributes": [
+ {
+ "Section": "3 Storage",
+ "SubSection": "3.1 Simple Storage Service (S3)",
+ "Profile": "Level 2",
+ "AssessmentStatus": "Automated",
+ "Description": "At the Amazon S3 bucket level, you can configure permissions through a bucket policy, making the objects accessible only through HTTPS.",
+ "RationaleStatement": "By default, Amazon S3 allows both HTTP and HTTPS requests. To ensure that access to Amazon S3 objects is only permitted through HTTPS, you must explicitly deny HTTP requests. Bucket policies that allow HTTPS requests without explicitly denying HTTP requests will not comply with this recommendation.",
+ "ImpactStatement": "",
+ "RemediationProcedure": "**From Console:** 1. Login to the AWS Management Console and open the Amazon S3 console using https://console.aws.amazon.com/s3/. 2. Select the check box next to the Bucket. 3. Click on 'Permissions'. 4. Click 'Bucket Policy'. 5. Add either of the following to the existing policy, filling in the required information: ``` { Sid: , Effect: Deny, Principal: *, Action: s3:*, Resource: arn:aws:s3:::/*, Condition: { Bool: { aws:SecureTransport: false } } } ``` or ``` { Sid: , Effect: Deny, Principal: *, Action: s3:*, Resource: [ arn:aws:s3:::, arn:aws:s3:::/* ], Condition: { NumericLessThan: { s3:TlsVersion: 1.2 } } } ``` 6. Save 7. Repeat for all the buckets in your AWS account that contain sensitive data. **From Console** Using AWS Policy Generator: 1. Repeat steps 1-4 above. 2. Click on `Policy Generator` at the bottom of the Bucket Policy Editor. 3. Select Policy Type `S3 Bucket Policy`. 4. Add Statements: - `Effect` = Deny - `Principal` = * - `AWS Service` = Amazon S3 - `Actions` = * - `Amazon Resource Name` = 5. Generate Policy. 6. Copy the text and add it to the Bucket Policy. **From Command Line:** 1. Export the bucket policy to a json file: ``` aws s3api get-bucket-policy --bucket --query Policy --output text > policy.json ``` 2. Modify the policy.json file by adding either of the following: ``` { Sid: , Effect: Deny, Principal: *, Action: s3:*, Resource: arn:aws:s3:::/*, Condition: { Bool: { aws:SecureTransport: false } } } ``` or ``` { Sid: , Effect: Deny, Principal: *, Action: s3:*, Resource: [ arn:aws:s3:::, arn:aws:s3:::/* ], Condition: { NumericLessThan: { s3:TlsVersion: 1.2 } } } ``` 3. Apply this modified policy back to the S3 bucket: ``` aws s3api put-bucket-policy --bucket --policy file://policy.json ```",
+ "AuditProcedure": "To allow access to HTTPS, you can use a bucket policy with the effect `allow` and a condition that checks for the key `aws:SecureTransport: true`. This means that HTTPS requests are allowed, but it does not deny HTTP requests. To explicitly deny HTTP access, ensure that there is also a bucket policy with the effect `deny` that contains the key `aws:SecureTransport: false`. You may also require TLS by setting a policy to deny any version lower than the one you wish to require, using the condition `NumericLessThan` and the key `s3:TlsVersion: 1.2`. **From Console:** 1. Login to the AWS Management Console and open the Amazon S3 console using https://console.aws.amazon.com/s3/. 2. Select the check box next to the Bucket. 3. Click on 'Permissions', then click on `Bucket Policy`. 4. Ensure that a policy is listed that matches either: ``` { Sid: , Effect: Deny, Principal: *, Action: s3:*, Resource: arn:aws:s3:::/*, Condition: { Bool: { aws:SecureTransport: false } } } ``` or ``` { Sid: , Effect: Deny, Principal: *, Action: s3:*, Resource: [ arn:aws:s3:::, arn:aws:s3:::/* ], Condition: { NumericLessThan: { s3:TlsVersion: 1.2 } } } ``` `` and `` will be specific to your account, and TLS version will be site/policy specific to your organisation. 5. Repeat for all the buckets in your AWS account. **From Command Line:** 1. List all of the S3 Buckets ``` aws s3 ls ``` 2. Using the list of buckets, run this command on each of them: ``` aws s3api get-bucket-policy --bucket | grep aws:SecureTransport ``` or ``` aws s3api get-bucket-policy --bucket | grep s3:TlsVersion ``` NOTE : If an error is thrown by the CLI, it means no policy has been configured for the specified S3 bucket, and that by default it is allowing both HTTP and HTTPS requests. 3. Confirm that `aws:SecureTransport` is set to false (such as `aws:SecureTransport:false`) or that `s3:TlsVersion` has a site-specific value. 4. Confirm that the policy line has Effect set to Deny 'Effect:Deny'",
+ "AdditionalInformation": "",
+ "References": "https://aws.amazon.com/premiumsupport/knowledge-center/s3-bucket-policy-for-config-rule/:https://aws.amazon.com/blogs/security/how-to-use-bucket-policies-and-apply-defense-in-depth-to-help-secure-your-amazon-s3-data/:https://awscli.amazonaws.com/v2/documentation/api/latest/reference/s3api/get-bucket-policy.html",
+ "DefaultValue": "Both HTTP and HTTPS requests are allowed."
+ }
+ ]
+ },
+ {
+ "Id": "3.1.2",
+ "Description": "Ensure MFA Delete is enabled on S3 buckets",
+ "Checks": [
+ "s3_bucket_no_mfa_delete"
+ ],
+ "Attributes": [
+ {
+ "Section": "3 Storage",
+ "SubSection": "3.1 Simple Storage Service (S3)",
+ "Profile": "Level 2",
+ "AssessmentStatus": "Manual",
+ "Description": "Once MFA Delete is enabled on your sensitive and classified S3 bucket, it requires the user to provide two forms of authentication.",
+ "RationaleStatement": "Adding MFA delete to an S3 bucket requires additional authentication when you change the version state of your bucket or delete an object version, adding another layer of security in the event your security credentials are compromised or unauthorized access is granted.",
+ "ImpactStatement": "Enabling MFA delete on an S3 bucket could require additional administrator oversight. Enabling MFA delete may impact other services that automate the creation and/or deletion of S3 buckets.",
+ "RemediationProcedure": "Perform the steps below to enable MFA delete on an S3 bucket: **Note:** - You cannot enable MFA Delete using the AWS Management Console; you must use the AWS CLI or API. - You must use your 'root' account to enable MFA Delete on S3 buckets. **From Command line:** 1. Run the s3api `put-bucket-versioning` command: ``` aws s3api put-bucket-versioning --profile my-root-profile --bucket Bucket_Name --versioning-configuration Status=Enabled,MFADelete=Enabled --mfa “arn:aws:iam::aws_account_id:mfa/root-account-mfa-device passcode” ```",
+ "AuditProcedure": "Perform the steps below to confirm that MFA delete is configured on an S3 bucket: **From Console:** 1. Login to the S3 console at `https://console.aws.amazon.com/s3/`. 2. Click the `check` box next to the name of the bucket you want to confirm. 3. In the window under `Properties`: - Confirm that Versioning is `Enabled` - Confirm that MFA Delete is `Enabled` **From Command Line:** 1. Run the `get-bucket-versioning` command: ``` aws s3api get-bucket-versioning --bucket my-bucket ``` Example output: ``` EnabledEnabled ``` If the console or CLI output does not show that Versioning and MFA Delete are `enabled`, please refer to the remediation below.",
+ "AdditionalInformation": "",
+ "References": "https://docs.aws.amazon.com/AmazonS3/latest/dev/Versioning.html#MultiFactorAuthenticationDelete:https://docs.aws.amazon.com/AmazonS3/latest/dev/UsingMFADelete.html:https://aws.amazon.com/blogs/security/securing-access-to-aws-using-mfa-part-3/:https://docs.aws.amazon.com/IAM/latest/UserGuide/id_credentials_mfa_lost-or-broken.html",
+ "DefaultValue": ""
+ }
+ ]
+ },
+ {
+ "Id": "3.1.3",
+ "Description": "Ensure all data in Amazon S3 has been discovered, classified, and secured when necessary",
+ "Checks": [
+ "macie_is_enabled"
+ ],
+ "Attributes": [
+ {
+ "Section": "3 Storage",
+ "SubSection": "3.1 Simple Storage Service (S3)",
+ "Profile": "Level 2",
+ "AssessmentStatus": "Manual",
+ "Description": "Amazon S3 buckets can contain sensitive data that, for security purposes, should be discovered, monitored, classified, and protected. Macie, along with other third-party tools, can automatically provide an inventory of Amazon S3 buckets.",
+ "RationaleStatement": "Using a cloud service or third-party software to continuously monitor and automate the process of data discovery and classification for S3 buckets through machine learning and pattern matching is a strong defense in protecting that information. Amazon Macie is a fully managed data security and privacy service that uses machine learning and pattern matching to discover and protect your sensitive data in AWS.",
+ "ImpactStatement": "There is a cost associated with using Amazon Macie, and there is typically a cost associated with third-party tools that perform similar processes and provide protection.",
+ "RemediationProcedure": "Perform the steps below to enable and configure Amazon Macie: **From Console:** 1. Log on to the Macie console at `https://console.aws.amazon.com/macie/`. 2. Click `Get started`. 3. Click `Enable Macie`. Set up a repository for sensitive data discovery results: 1. In the left pane, under Settings, click `Discovery results`. 2. Make sure `Create bucket` is selected. 3. Create a bucket and enter a name for it. The name must be unique across all S3 buckets, and it must start with a lowercase letter or a number. 4. Click `Advanced`. 5. For block all public access, make sure `Yes` is selected. 6. For KMS encryption, specify the AWS KMS key that you want to use to encrypt the results. The key must be a symmetric customer master key (CMK) that is in the same region as the S3 bucket. 7. Click `Save`. Create a job to discover sensitive data: 1. In the left pane, click `S3 buckets`. Macie displays a list of all the S3 buckets for your account. 2. Check the box for each bucket that you want Macie to analyze as part of the job. 3. Click `Create job`. 4. Click `Quick create`. 5. For the Name and Description step, enter a name and, optionally, a description of the job. 6. Click `Next`. 7. For the Review and create step, click `Submit`. Review your findings: 1. In the left pane, click `Findings`. 2. To view the details of a specific finding, choose any field other than the check box for the finding. If you are using a third-party tool to manage and protect your S3 data, follow the vendor documentation for implementing and configuring that tool.",
+ "AuditProcedure": "Perform the following steps to determine if Macie is running: **From Console:** 1. Login to the Macie console at https://console.aws.amazon.com/macie/. 2. In the left hand pane, click on `By job` under findings. 3. Confirm that you have a job set up for your S3 buckets. When you log into the Macie console, if you are not taken to the summary page and do not have a job set up and running, then refer to the remediation procedure below. If you are using a third-party tool to manage and protect your S3 data, you meet this recommendation.",
+ "AdditionalInformation": "",
+ "References": "https://aws.amazon.com/macie/getting-started/:https://docs.aws.amazon.com/workspaces/latest/adminguide/data-protection.html:https://docs.aws.amazon.com/macie/latest/user/data-classification.html",
+ "DefaultValue": ""
+ }
+ ]
+ },
+ {
+ "Id": "3.1.4",
+ "Description": "Ensure that S3 is configured with 'Block Public Access' enabled",
+ "Checks": [
+ "s3_bucket_level_public_access_block",
+ "s3_account_level_public_access_blocks"
+ ],
+ "Attributes": [
+ {
+ "Section": "3 Storage",
+ "SubSection": "3.1 Simple Storage Service (S3)",
+ "Profile": "Level 1",
+ "AssessmentStatus": "Automated",
+ "Description": "Amazon S3 provides `Block public access (bucket settings)` and `Block public access (account settings)` to help you manage public access to Amazon S3 resources. By default, S3 buckets and objects are created with public access disabled. However, an IAM principal with sufficient S3 permissions can enable public access at the bucket and/or object level. While enabled, `Block public access (bucket settings)` prevents an individual bucket and its contained objects from becoming publicly accessible. Similarly, `Block public access (account settings)` prevents all buckets and their contained objects from becoming publicly accessible across the entire account.",
+ "RationaleStatement": "Amazon S3 `Block public access (bucket settings)` prevents the accidental or malicious public exposure of data contained within the respective bucket(s). Amazon S3 `Block public access (account settings)` prevents the accidental or malicious public exposure of data contained within all buckets of the respective AWS account. Whether to block public access to all or some buckets is an organizational decision that should be based on data sensitivity, least privilege, and use case.",
+ "ImpactStatement": "When you apply Block Public Access settings to an account, the settings apply to all AWS regions globally. The settings may not take effect in all regions immediately or simultaneously, but they will eventually propagate to all regions.",
+ "RemediationProcedure": "**If utilizing Block Public Access (bucket settings)** **From Console:** 1. Login to the AWS Management Console and open the Amazon S3 console using https://console.aws.amazon.com/s3/. 2. Select the check box next to a bucket. 3. Click 'Edit public access settings'. 4. Click 'Block all public access' 5. Repeat for all the buckets in your AWS account that contain sensitive data. **From Command Line:** 1. List all of the S3 buckets: ``` aws s3 ls ``` 2. Enable Block Public Access on a specific bucket: ``` aws s3api put-public-access-block --bucket --public-access-block-configuration BlockPublicAcls=true,IgnorePublicAcls=true,BlockPublicPolicy=true,RestrictPublicBuckets=true ``` **If utilizing Block Public Access (account settings)** **From Console:** If the output reads `true` for the separate configuration settings, then Block Public Access is enabled on the account. 1. Login to the AWS Management Console and open the Amazon S3 console using https://console.aws.amazon.com/s3/. 2. Click `Block Public Access (account settings)`. 3. Click `Edit` to change the block public access settings for all the buckets in your AWS account. 4. Update the settings and click `Save`. For details about each setting, pause on the `i` icons. 5. When you're asked for confirmation, enter `confirm`. Then click `Confirm` to save your changes. **From Command Line:** To enable Block Public Access for this account, run the following command: ``` aws s3control put-public-access-block --public-access-block-configuration BlockPublicAcls=true, IgnorePublicAcls=true, BlockPublicPolicy=true, RestrictPublicBuckets=true --account-id ```",
+ "AuditProcedure": "**If utilizing Block Public Access (bucket settings)** **From Console:** 1. Login to the AWS Management Console and open the Amazon S3 console using https://console.aws.amazon.com/s3/. 2. Select the check box next to a bucket. 3. Click on 'Edit public access settings'. 4. Ensure that the block public access settings are configured appropriately for this bucket. 5. Repeat for all the buckets in your AWS account. **From Command Line:** 1. List all of the S3 buckets: ``` aws s3 ls ``` 2. Find the public access settings for a specific bucket: ``` aws s3api get-public-access-block --bucket ``` Output if Block Public Access is enabled: ``` { PublicAccessBlockConfiguration: { BlockPublicAcls: true, IgnorePublicAcls: true, BlockPublicPolicy: true, RestrictPublicBuckets: true } } ``` If the output reads `false` for the separate configuration settings, then proceed with the remediation. **If utilizing Block Public Access (account settings)** **From Console:** 1. Login to the AWS Management Console and open the Amazon S3 console using https://console.aws.amazon.com/s3/. 2. Choose `Block public access (account settings)`. 3. Ensure that the block public access settings are configured appropriately for your AWS account. **From Command Line:** To check the block public access settings for this account, run the following command: `aws s3control get-public-access-block --account-id --region ` Output if Block Public Access is enabled: ``` { PublicAccessBlockConfiguration: { IgnorePublicAcls: true, BlockPublicPolicy: true, BlockPublicAcls: true, RestrictPublicBuckets: true } } ``` If the output reads `false` for the separate configuration settings, then proceed with the remediation.",
+ "AdditionalInformation": "",
+ "References": "https://docs.aws.amazon.com/AmazonS3/latest/user-guide/block-public-access-account.html",
+ "DefaultValue": ""
+ }
+ ]
+ },
+ {
+ "Id": "3.2.1",
+ "Description": "Ensure that encryption-at-rest is enabled for RDS instances",
+ "Checks": [
+ "rds_instance_storage_encrypted"
+ ],
+ "Attributes": [
+ {
+ "Section": "3 Storage",
+ "SubSection": "3.2 Relational Database Service (RDS)",
+ "Profile": "Level 1",
+ "AssessmentStatus": "Automated",
+ "Description": "Amazon RDS encrypted DB instances use the industry-standard AES-256 encryption algorithm to encrypt your data on the server that hosts your Amazon RDS DB instances. After your data is encrypted, Amazon RDS handles the authentication of access and the decryption of your data transparently, with minimal impact on performance.",
+ "RationaleStatement": "Databases are likely to hold sensitive and critical data; therefore, it is highly recommended to implement encryption to protect your data from unauthorized access or disclosure. With RDS encryption enabled, the data stored on the instance's underlying storage, the automated backups, read replicas, and snapshots are all encrypted.",
+ "ImpactStatement": "",
+ "RemediationProcedure": "**From Console:** 1. Login to the AWS Management Console and open the RDS dashboard at https://console.aws.amazon.com/rds/. 2. In the left navigation panel, click on `Databases`. 3. Select the Database instance that needs to be encrypted. 4. Click the `Actions` button placed at the top right and select `Take Snapshot`. 5. On the Take Snapshot page, enter the name of the database for which you want to take a snapshot in the `Snapshot Name` field and click on `Take Snapshot`. 6. Select the newly created snapshot, click the `Action` button placed at the top right, and select `Copy snapshot` from the Action menu. 7. On the Make Copy of DB Snapshot page, perform the following: - In the `New DB Snapshot Identifier` field, enter a name for the new snapshot. - Check `Copy Tags`. The new snapshot must have the same tags as the source snapshot. - Select `Yes` from the `Enable Encryption` dropdown list to enable encryption. You can choose to use the AWS default encryption key or a custom key from the Master Key dropdown list. 8. Click `Copy Snapshot` to create an encrypted copy of the selected instance's snapshot. 9. Select the new Snapshot Encrypted Copy and click the `Action` button located at the top right. Then, select the `Restore Snapshot` option from the Action menu. This will restore the encrypted snapshot to a new database instance. 10. On the Restore DB Instance page, enter a unique name for the new database instance in the DB Instance Identifier field. 11. Review the instance configuration details and click `Restore DB Instance`. 12. As the new instance provisioning process is completed, you can update the application configuration to refer to the endpoint of the new encrypted database instance. Once the database endpoint is changed at the application level, you can remove the unencrypted instance. **From Command Line:** 1. Run the `describe-db-instances` command to list the names of all RDS database instances in the selected AWS region. The command output should return database instance identifiers: ``` aws rds describe-db-instances --query 'DBInstances[*].DBInstanceIdentifier' ``` 2. Check if the specified RDS instance is encrypted. If it shows false, it means it is not yet encrypted: ``` aws rds describe-db-instances --region --db-instance-identifier --query 'DBInstances[*].StorageEncrypted' ``` 3. Run the `create-db-snapshot` command to create a snapshot for a selected database instance. The command output will return the `new snapshot` with name DB Snapshot Name: ``` aws rds create-db-snapshot --region --db-snapshot-identifier --db-instance-identifier ``` 4. Now run the `list-aliases` command to list the KMS key aliases available in a specified region. The command output should return each `key alias currently available`. For our RDS encryption activation process, locate the ID of the AWS default KMS key: ``` aws kms list-aliases --region ``` 5. Run the `copy-db-snapshot` command using the default KMS key ID for the RDS instances returned earlier to create an encrypted copy of the database instance snapshot. The command output will return the `encrypted instance snapshot configuration`: ``` aws rds copy-db-snapshot --region