Merge remote-tracking branch 'origin/master' into feature/eslint-typescript-flat

# Conflicts:
#	ui/app/(prowler)/alerts/_components/seed-from-findings-button.tsx
#	ui/components/layout/sidebar/sidebar.tsx
#	ui/components/providers/wizard/steps/launch-step.tsx
This commit is contained in:
Pablo F.G
2026-07-15 15:08:29 +02:00
261 changed files with 6754 additions and 1366 deletions
@@ -92,6 +92,8 @@ jobs:
_http._tcp.deb.debian.org:443
powershellinfraartifacts-gkhedzdeaghdezhr.z01.azurefd.net:443
get.trivy.dev:443
raw.githubusercontent.com:443
releases.astral.sh:443
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
@@ -34,6 +34,7 @@ jobs:
allowed-endpoints: >
api.github.com:443
github.com:443
raw.githubusercontent.com:443
objects.githubusercontent.com:443
codeload.github.com:443
release-assets.githubusercontent.com:443
@@ -41,6 +42,7 @@ jobs:
files.pythonhosted.org:443
registry.npmjs.org:443
nodejs.org:443
releases.astral.sh:443
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
@@ -94,6 +94,8 @@ jobs:
_http._tcp.deb.debian.org:443
powershellinfraartifacts-gkhedzdeaghdezhr.z01.azurefd.net:443
get.trivy.dev:443
raw.githubusercontent.com:443
releases.astral.sh:443
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+33 -22
View File
@@ -15,21 +15,32 @@
# neither vulnerable code path (Archive::Tar parsing or regex compilation of
# attacker-controlled input) is reachable from Prowler. No Debian bookworm fix
# is available yet.
CVE-2026-42496 pkg:perl exp:2026-07-15
CVE-2026-42496 pkg:perl-base exp:2026-07-15
CVE-2026-42496 pkg:perl-modules-5.36 exp:2026-07-15
CVE-2026-42496 pkg:libperl5.36 exp:2026-07-15
CVE-2026-8376 pkg:perl exp:2026-07-15
CVE-2026-8376 pkg:perl-base exp:2026-07-15
CVE-2026-8376 pkg:perl-modules-5.36 exp:2026-07-15
CVE-2026-8376 pkg:libperl5.36 exp:2026-07-15
CVE-2026-42496 pkg:perl exp:2026-08-15
CVE-2026-42496 pkg:perl-base exp:2026-08-15
CVE-2026-42496 pkg:perl-modules-5.36 exp:2026-08-15
CVE-2026-42496 pkg:libperl5.36 exp:2026-08-15
CVE-2026-8376 pkg:perl exp:2026-08-15
CVE-2026-8376 pkg:perl-base exp:2026-08-15
CVE-2026-8376 pkg:perl-modules-5.36 exp:2026-08-15
CVE-2026-8376 pkg:libperl5.36 exp:2026-08-15
# CVE-2026-13221 - Perl regex trie overflow.
# Packages: perl, perl-base, perl-modules-5.36, libperl5.36.
# Why ignored: upstream confirms Perl 5.36.0 is not affected; the regression
# was introduced after this version. Debian currently marks bookworm as
# vulnerable, which causes Trivy to report a false positive.
# Ref: https://github.com/Perl/perl5/issues/23388
CVE-2026-13221 pkg:perl exp:2026-08-15
CVE-2026-13221 pkg:perl-base exp:2026-08-15
CVE-2026-13221 pkg:perl-modules-5.36 exp:2026-08-15
CVE-2026-13221 pkg:libperl5.36 exp:2026-08-15
# CVE-2025-7458 — SQLite integer overflow.
# Package: libsqlite3-0.
# Why ignored: transitive dependency of CPython's stdlib sqlite3 module. The
# Prowler SDK does not open user-supplied SQLite databases; SQLite usage is
# internal and bounded. No Debian bookworm fix is available.
CVE-2025-7458 pkg:libsqlite3-0 exp:2026-07-15
CVE-2025-7458 pkg:libsqlite3-0 exp:2026-08-15
# CVE-2026-43185 — Linux kernel ksmbd signedness bug.
# Package: linux-libc-dev.
@@ -37,7 +48,7 @@ CVE-2025-7458 pkg:libsqlite3-0 exp:2026-07-15
# not a running kernel. Containers execute against the host kernel, so these
# headers are inert at runtime. The upstream fix landed in kernel 7.0-rc2 and
# has not been backported to Debian's 6.1 LTS line.
CVE-2026-43185 pkg:linux-libc-dev exp:2026-07-15
CVE-2026-43185 pkg:linux-libc-dev exp:2026-08-15
# CVE-2023-45853 — zlib MiniZip integer overflow / heap overflow in
# zipOpenNewFileInZip4_64.
@@ -49,8 +60,8 @@ CVE-2026-43185 pkg:linux-libc-dev exp:2026-07-15
# zlib 1.3.1, available in Debian trixie (13); migrating the base image would
# clear it fully.
# Ref: https://security-tracker.debian.org/tracker/CVE-2023-45853
CVE-2023-45853 pkg:zlib1g exp:2026-07-15
CVE-2023-45853 pkg:zlib1g-dev exp:2026-07-15
CVE-2023-45853 pkg:zlib1g exp:2026-08-15
CVE-2023-45853 pkg:zlib1g-dev exp:2026-08-15
# CVE-2026-55200 — libssh2 out-of-bounds write in ssh2_transport_read() due to
# an unchecked packet_length field in transport.c (heap corruption, possible RCE).
@@ -63,7 +74,7 @@ CVE-2023-45853 pkg:zlib1g-dev exp:2026-07-15
# affected code is unreachable at runtime. Fixed upstream in libssh2 commit
# 97acf3df (PR #2052); no Debian bookworm fix is available yet.
# Ref: https://security-tracker.debian.org/tracker/CVE-2026-55200
CVE-2026-55200 pkg:libssh2-1 exp:2026-07-15
CVE-2026-55200 pkg:libssh2-1 exp:2026-08-15
# --- API container image (api/Dockerfile) ---
# The entries below are specific to the Prowler API image, which ships
@@ -78,13 +89,13 @@ CVE-2026-55200 pkg:libssh2-1 exp:2026-07-15
# at runtime. The vulnerable path requires parsing attacker-controlled XML with
# the affected interpreter, which Prowler does not do with the system Python.
# Full mitigation also needs libexpat >= 2.8.0; no Debian bookworm fix yet.
CVE-2026-7210 pkg:python3.11 exp:2026-07-15
CVE-2026-7210 pkg:python3.11-dev exp:2026-07-15
CVE-2026-7210 pkg:python3.11-minimal exp:2026-07-15
CVE-2026-7210 pkg:libpython3.11 exp:2026-07-15
CVE-2026-7210 pkg:libpython3.11-dev exp:2026-07-15
CVE-2026-7210 pkg:libpython3.11-minimal exp:2026-07-15
CVE-2026-7210 pkg:libpython3.11-stdlib exp:2026-07-15
CVE-2026-7210 pkg:python3.11 exp:2026-08-15
CVE-2026-7210 pkg:python3.11-dev exp:2026-08-15
CVE-2026-7210 pkg:python3.11-minimal exp:2026-08-15
CVE-2026-7210 pkg:libpython3.11 exp:2026-08-15
CVE-2026-7210 pkg:libpython3.11-dev exp:2026-08-15
CVE-2026-7210 pkg:libpython3.11-minimal exp:2026-08-15
CVE-2026-7210 pkg:libpython3.11-stdlib exp:2026-08-15
# CVE-2026-33278 — Unbound DNSSEC validator use-after-free (DoS, possible RCE).
# CVE-2026-42960 — Unbound DNS cache poisoning via promiscuous additional records.
@@ -94,5 +105,5 @@ CVE-2026-7210 pkg:libpython3.11-stdlib exp:2026-07-15
# vulnerabilities require operating a live Unbound recursive DNSSEC validator
# that processes attacker-influenced DNS responses. Prowler never starts an
# Unbound resolver, so neither code path is reachable. No Debian bookworm fix yet.
CVE-2026-33278 pkg:libunbound8 exp:2026-07-15
CVE-2026-42960 pkg:libunbound8 exp:2026-07-15
CVE-2026-33278 pkg:libunbound8 exp:2026-08-15
CVE-2026-42960 pkg:libunbound8 exp:2026-08-15
+59 -21
View File
@@ -3,7 +3,7 @@
<img align="center" alt="Prowler logo" src="https://github.com/prowler-cloud/prowler/blob/master/docs/img/prowler-logo-white.png#gh-dark-mode-only" width="50%" height="50%">
</p>
<p align="center">
<b><i>Prowler</b> 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.
<b><i>Prowler</b> 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.
</p>
<p align="center">
<b>Secure ANY cloud at AI Speed at <a href="https://prowler.com">prowler.com</i></b>
@@ -21,7 +21,7 @@
<a href="https://pypi.python.org/pypi/prowler/"><img alt="Python Version" src="https://img.shields.io/pypi/pyversions/prowler.svg"></a>
<a href="https://pypistats.org/packages/prowler"><img alt="PyPI Downloads" src="https://img.shields.io/pypi/dw/prowler.svg?label=downloads"></a>
<a href="https://hub.docker.com/r/toniblyx/prowler"><img alt="Docker Pulls" src="https://img.shields.io/docker/pulls/toniblyx/prowler"></a>
<a href="https://gallery.ecr.aws/prowler-cloud/prowler"><img width="120" height=19" alt="AWS ECR Gallery" src="https://user-images.githubusercontent.com/3985464/151531396-b6535a68-c907-44eb-95a1-a09508178616.png"></a>
<a href="https://gallery.ecr.aws/prowler-cloud/prowler"><img width="120" height="19" alt="AWS ECR Gallery" src="https://user-images.githubusercontent.com/3985464/151531396-b6535a68-c907-44eb-95a1-a09508178616.png"></a>
<a href="https://codecov.io/gh/prowler-cloud/prowler"><img alt="Codecov coverage" src="https://codecov.io/gh/prowler-cloud/prowler/graph/badge.svg?token=OflBGsdpDl"/></a>
<a href="https://insights.linuxfoundation.org/project/prowler-cloud-prowler"><img alt="Linux Foundation insights health score" src="https://insights.linuxfoundation.org/api/badge/health-score?project=prowler-cloud-prowler"/></a>
</p>
@@ -41,7 +41,7 @@
# Description
**Prowler** is the worlds 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 worlds 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.
![Prowler App](docs/images/products/overview.png)
![Prowler Cloud](docs/images/products/overview.png)
![Risk Pipeline](docs/images/products/risk-pipeline.png)
![Threat Map](docs/images/products/threat-map.png)
>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,12 +73,12 @@ prowler <provider>
![Prowler CLI Execution](docs/img/short-display.png)
## Prowler Dashboard
## Prowler Local Dashboard
```console
prowler dashboard
```
![Prowler Dashboard](docs/images/products/dashboard.png)
![Prowler Local Dashboard](docs/images/products/dashboard.png)
## Attack Paths
@@ -121,7 +121,7 @@ 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 | 615 | 86 | 47 | 19 | Official | UI, API, CLI |
| Azure | 190 | 22 | 21 | 16 | Official | UI, API, CLI |
@@ -160,11 +160,11 @@ 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
@@ -197,7 +197,7 @@ 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
@@ -269,7 +269,7 @@ 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
@@ -287,7 +287,7 @@ 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
@@ -307,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)
@@ -357,17 +357,55 @@ 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.
![Prowler App Architecture](docs/images/products/prowler-app-architecture.png)
```mermaid
flowchart TB
user([User / Security Team])
cli([Prowler CLI])
<!-- Diagram source: docs/images/products/prowler-app-architecture.mmd — edit there, re-render at https://mermaid.live, and replace the PNG. -->
subgraph APP["Prowler Local Server"]
ui["Prowler UI<br/>(Next.js)"]
api["Prowler API<br/>(Django REST Framework)"]
worker["API Worker<br/>(Celery)"]
beat["API Scheduler<br/>(Celery Beat)"]
mcp["Prowler MCP Server<br/>(Lighthouse AI tools)"]
end
sdk["Prowler SDK<br/>(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
```
<!-- Diagram source: docs/images/products/prowler-app-architecture.mmd — keep this inline block, the docs page getting-started/products/prowler-app.mdx, and the .mmd file in sync. -->
## Prowler CLI
+10
View File
@@ -4,6 +4,16 @@ All notable changes to the **Prowler API** are documented in this file.
<!-- changelog: release notes start -->
## [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
+1
View File
@@ -113,6 +113,7 @@ RUN apt-get purge -y --auto-remove \
make \
libxml2-dev \
libxmlsec1-dev \
libxmlsec1-openssl \
pkg-config \
libtool \
libxslt1-dev \
@@ -0,0 +1 @@
`attack-paths-cleanup-stale-scans` now retries worker pings and checks recent scan activity before failing scans and removing temporary databases
@@ -0,0 +1 @@
RBAC permission gates now combine permissions from every role assigned to a user in the active tenant
@@ -0,0 +1 @@
`api` container image removes the unused Debian `libxml2` runtime package and scopes the `CVE-2026-13221` Trivy exception to unaffected Perl 5.36 packages
@@ -1,4 +1,6 @@
import logging
import random
import time
from collections.abc import Callable
from typing import Any
@@ -9,17 +11,19 @@ logger = logging.getLogger(__name__)
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,
) -> 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._session = self._session_factory()
def close(self) -> None:
@@ -56,24 +60,47 @@ class RetryableSession:
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:
raise
delay = self._retry_delay(attempt)
logger.warning(
f"Neo4j session {method_name} failed with {type(exc).__name__} ({attempt}/{self._max_retries} attempts). Retrying..."
"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:
@@ -42,6 +42,10 @@ def delete_batches(
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:
@@ -56,8 +60,7 @@ def delete_batches(
deleted_total,
time.perf_counter() - drop_t0,
)
record = session.run(query, {"batch_size": batch_size}).single()
deleted = (record[count_key] if record else 0) or 0
deleted = session.execute_write(delete_batch)
if deleted == 0:
return deleted_total, batches
@@ -355,7 +355,7 @@ class Neo4jSink(SinkDatabase):
f"ON (n.`{PROVIDER_ELEMENT_ID_PROPERTY}`)"
)
with self.get_session(database) as session:
session.run(query).consume()
session.execute_write(lambda tx: tx.run(query).consume())
def write_nodes(
self,
@@ -377,7 +377,7 @@ class Neo4jSink(SinkDatabase):
SET n += row.props
"""
with self.get_session(database) as session:
session.run(query, {"rows": rows}).consume()
session.execute_write(lambda tx: tx.run(query, {"rows": rows}).consume())
def write_relationships(
self,
@@ -403,7 +403,7 @@ class Neo4jSink(SinkDatabase):
SET r += row.props
"""
with self.get_session(database) as session:
session.run(query, {"rows": rows}).consume()
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:
@@ -59,17 +59,28 @@ 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_PREFIXES = (
"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
return bool(exc.message and exc.message.startswith(RETRYABLE_WRITE_ERROR_PREFIXES))
class NeptuneSink(SinkDatabase):
"""Neptune-backed sink. Single database; isolation is label-based."""
@@ -205,11 +216,16 @@ class NeptuneSink(SinkDatabase):
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
),
)
yield session_wrapper
@@ -405,7 +421,7 @@ class NeptuneSink(SinkDatabase):
SET n.`{PROVIDER_ELEMENT_ID_PROPERTY}` = row.provider_element_id
"""
with self.get_session() as session:
session.run(query, {"rows": rows}).consume()
session.execute_write(lambda tx: tx.run(query, {"rows": rows}).consume())
def write_relationships(
self,
@@ -429,7 +445,7 @@ class NeptuneSink(SinkDatabase):
SET r += row.props
"""
with self.get_session() as session:
session.run(query, {"rows": rows}).consume()
session.execute_write(lambda tx: tx.run(query, {"rows": rows}).consume())
# Test helpers
@@ -152,10 +152,10 @@ def execute_custom_query(
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
# 4. `USING QUERY:TIMEOUTMILLISECONDS` on Neptune server-side runaway cutoff
# 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.
+5 -6
View File
@@ -34,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)
@@ -43,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:
+43
View File
@@ -11,6 +11,7 @@ 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
@@ -816,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(
@@ -0,0 +1,85 @@
from unittest.mock import MagicMock, patch
import pytest
from api.attack_paths.retryable_session import RetryableSession
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,
)
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,
)
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()
+121 -92
View File
@@ -6,18 +6,20 @@ builds dual writer/reader Bolt drivers.
"""
import json
from importlib import import_module
from unittest.mock import MagicMock, patch
import neo4j
import pytest
# Prime patch-target resolution. `api.attack_paths.sink/__init__.py` doesn't
# eagerly import these submodules (they're loaded on demand inside the
# factory), so `mock.patch("api.attack_paths.sink.<sub>.…")` would fail with
# AttributeError on first call. Importing here registers them as attributes
# of the package before any decorator runs.
import_module("api.attack_paths.sink.neo4j")
import_module("api.attack_paths.sink.neptune")
from api.attack_paths import sink as sink_module
from api.attack_paths.database import GraphDatabaseQueryException
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)
@@ -26,8 +28,6 @@ def reset_sink_state():
The cache lives in `api.attack_paths.sink.factory`, not on the package.
"""
from api.attack_paths.sink import factory
original_backend = factory._backend
original_secondary = dict(factory._secondary_backends)
factory._backend = None
@@ -40,29 +40,20 @@ def reset_sink_state():
class TestSinkFactory:
def test_default_resolves_to_neo4j(self, settings):
from api.attack_paths.sink import factory
settings.ATTACK_PATHS_SINK_DATABASE = "neo4j"
assert factory._resolve_setting() == "neo4j"
def test_neptune_resolves_correctly(self, settings):
from api.attack_paths.sink import factory
settings.ATTACK_PATHS_SINK_DATABASE = "neptune"
assert factory._resolve_setting() == "neptune"
def test_invalid_value_raises(self, settings):
from api.attack_paths.sink import factory
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):
from api.attack_paths import sink as sink_module
from api.attack_paths.sink.neo4j import Neo4jSink
settings.ATTACK_PATHS_SINK_DATABASE = "neo4j"
settings.DATABASES = {
**settings.DATABASES,
@@ -85,9 +76,6 @@ class TestSinkFactory:
def test_init_builds_neptune_backend(
self, mock_driver, mock_auth_provider, settings
):
from api.attack_paths import sink as sink_module
from api.attack_paths.sink.neptune import NeptuneSink
settings.ATTACK_PATHS_SINK_DATABASE = "neptune"
settings.DATABASES = {
**settings.DATABASES,
@@ -116,8 +104,6 @@ class TestSinkFactory:
def test_neptune_reader_falls_back_to_writer(
self, mock_driver, mock_auth_provider, settings
):
from api.attack_paths import sink as sink_module
settings.ATTACK_PATHS_SINK_DATABASE = "neptune"
settings.DATABASES = {
**settings.DATABASES,
@@ -144,8 +130,6 @@ class TestGetBackendForScan:
def test_legacy_scan_in_neo4j_process_uses_active_backend(
self, mock_driver, settings
):
from api.attack_paths import sink as sink_module
settings.ATTACK_PATHS_SINK_DATABASE = "neo4j"
settings.DATABASES = {
**settings.DATABASES,
@@ -164,8 +148,6 @@ class TestGetBackendForScan:
assert backend is sink_module.get_backend()
def test_neptune_scan_on_neo4j_process_uses_neptune_secondary(self, settings):
from api.attack_paths.sink import factory
settings.ATTACK_PATHS_SINK_DATABASE = "neo4j"
active_neo4j = MagicMock(name="neo4j-active")
factory._backend = active_neo4j
@@ -190,6 +172,29 @@ 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,
@@ -207,31 +212,26 @@ def _directed_drop_results(
class TestNeo4jSinkSyncWrites:
def test_ensure_sync_indexes_runs_create_index_idempotent(self):
from api.attack_paths.sink.neo4j import Neo4jSink
sink = Neo4jSink()
session = MagicMock()
session.run.return_value = MagicMock()
with patch.object(sink, "get_session", return_value=_session_ctx(session)):
sink.ensure_sync_indexes("db-tenant-x")
query = session.run.call_args.args[0]
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):
from api.attack_paths.sink.neo4j import Neo4jSink
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):
from api.attack_paths.sink.neo4j import Neo4jSink
sink = Neo4jSink()
session = MagicMock()
with patch.object(sink, "get_session", return_value=_session_ctx(session)):
@@ -241,15 +241,15 @@ class TestNeo4jSinkSyncWrites:
[{"provider_element_id": "p:e", "props": {"k": "v"}}],
)
query, params = session.run.call_args.args
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):
from api.attack_paths.sink.neo4j import Neo4jSink
sink = Neo4jSink()
session = MagicMock()
provider_id = "00000000-0000-0000-0000-000000000abc"
@@ -268,24 +268,22 @@ class TestNeo4jSinkSyncWrites:
],
)
query = session.run.call_args.args[0]
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):
from api.attack_paths.sink.neptune import NeptuneSink
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):
from api.attack_paths.sink.neptune import NeptuneSink
sink = NeptuneSink()
session = MagicMock()
with patch.object(sink, "get_session", return_value=_session_ctx(session)):
@@ -295,16 +293,16 @@ class TestNeptuneSinkSyncWrites:
[{"provider_element_id": "p:e", "props": {"k": "v"}}],
)
query = session.run.call_args.args[0]
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):
from api.attack_paths.sink.neptune import NeptuneSink
sink = NeptuneSink()
session = MagicMock()
with patch.object(sink, "get_session", return_value=_session_ctx(session)):
@@ -322,30 +320,86 @@ class TestNeptuneSinkSyncWrites:
],
)
query = session.run.call_args.args[0]
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",
[
"Operation failed due to conflicting concurrent operations "
+ "(please retry), 0 transactions are currently rolling back.",
"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 = "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
)
@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
class TestNeptuneSinkDropSubgraph:
def test_drop_subgraph_deletes_directed_rels_before_nodes_in_bounded_batches(self):
from api.attack_paths.sink.neptune import NeptuneSink
sink = NeptuneSink()
session = MagicMock()
session.run.side_effect = _directed_drop_results(
outgoing_rels=50,
incoming_rels=30,
nodes=10,
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.run.call_count == 6
queries = [call.args[0] for call in session.run.call_args_list]
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]
@@ -362,14 +416,13 @@ 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):
from api.attack_paths.sink.neo4j import Neo4jSink
sink = Neo4jSink()
session = MagicMock()
session.run.side_effect = _directed_drop_results(
outgoing_rels=50,
incoming_rels=30,
nodes=10,
session, transactions = _managed_write_session(
_directed_drop_results(
outgoing_rels=50,
incoming_rels=30,
nodes=10,
)
)
provider_id = "00000000-0000-0000-0000-000000000abc"
@@ -378,9 +431,9 @@ class TestNeo4jSinkDropSubgraph:
# Only phase-2 node counts contribute to the return value.
assert deleted == 10
assert session.run.call_count == 6
assert session.execute_write.call_count == 6
queries = [call.args[0] for call in session.run.call_args_list]
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)
@@ -399,12 +452,9 @@ class TestNeo4jSinkDropSubgraph:
assert last_rel < first_node
def test_drop_subgraph_returns_zero_when_database_does_not_exist(self):
from api.attack_paths.database import GraphDatabaseQueryException
from api.attack_paths.sink.neo4j import DATABASE_NOT_FOUND_CODE, Neo4jSink
sink = Neo4jSink()
session = MagicMock()
session.run.side_effect = GraphDatabaseQueryException(
session.execute_write.side_effect = GraphDatabaseQueryException(
message="db missing", code=DATABASE_NOT_FOUND_CODE
)
@@ -418,8 +468,6 @@ class TestSinkHasProviderData:
"""``has_provider_data`` is the read-path probe used by API views."""
def test_neo4j_returns_true_when_provider_node_exists(self):
from api.attack_paths.sink.neo4j import Neo4jSink
sink = Neo4jSink()
session = MagicMock()
session.run.return_value.single.return_value = MagicMock()
@@ -433,9 +481,6 @@ class TestSinkHasProviderData:
assert ":`_Provider_00000000000000000000000000000abc`" in query
def test_neo4j_returns_false_when_database_does_not_exist(self):
from api.attack_paths.database import GraphDatabaseQueryException
from api.attack_paths.sink.neo4j import DATABASE_NOT_FOUND_CODE, Neo4jSink
sink = Neo4jSink()
session = MagicMock()
session.run.side_effect = GraphDatabaseQueryException(
@@ -448,8 +493,6 @@ class TestSinkHasProviderData:
assert present is False
def test_neptune_returns_true_when_provider_node_exists(self):
from api.attack_paths.sink.neptune import NeptuneSink
sink = NeptuneSink()
session = MagicMock()
session.run.return_value.single.return_value = MagicMock()
@@ -463,8 +506,6 @@ 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):
from api.attack_paths.sink import factory
settings.ATTACK_PATHS_SINK_DATABASE = "neptune"
active_neptune = MagicMock(name="neptune-active")
factory._backend = active_neptune
@@ -487,8 +528,6 @@ class TestSinkVerifyConnectivity:
@patch("api.attack_paths.sink.neo4j.neo4j.GraphDatabase.driver")
def test_neo4j_verifies_its_driver(self, mock_driver, settings):
from api.attack_paths.sink.neo4j import Neo4jSink
settings.DATABASES = {
**settings.DATABASES,
"neo4j": {
@@ -513,8 +552,6 @@ class TestSinkVerifyConnectivity:
def test_neptune_verifies_reader_not_writer(
self, mock_driver, mock_auth_provider, settings
):
from api.attack_paths.sink.neptune import NeptuneSink
settings.DATABASES = {
**settings.DATABASES,
"neptune": {
@@ -548,8 +585,6 @@ class TestSinkInitToleratesUnreachableSink:
@patch("api.attack_paths.sink.neo4j.neo4j.GraphDatabase.driver")
def test_neo4j_init_continues_when_verify_fails(self, mock_driver, settings):
from api.attack_paths.sink.neo4j import Neo4jSink
settings.DATABASES = {
**settings.DATABASES,
"neo4j": {
@@ -573,8 +608,6 @@ class TestSinkInitToleratesUnreachableSink:
def test_neptune_init_continues_when_verify_fails(
self, mock_driver, mock_auth_provider, settings
):
from api.attack_paths.sink.neptune import NeptuneSink
settings.DATABASES = {
**settings.DATABASES,
"neptune": {
@@ -601,8 +634,6 @@ class TestNeptuneAdminNoOps:
@pytest.mark.parametrize("method", ["create_database", "drop_database"])
def test_admin_ops_return_none_without_touching_a_session(self, method):
from api.attack_paths.sink.neptune import NeptuneSink
sink = NeptuneSink()
with patch.object(sink, "get_session") as get_session:
assert getattr(sink, method)("ignored") is None
@@ -617,8 +648,6 @@ class TestNeptuneAuthToken:
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.
from api.attack_paths.sink.neptune import _NeptuneAuthToken
credentials = MagicMock()
credentials.get_frozen_credentials.return_value = MagicMock()
mock_boto.return_value.get_credentials.return_value = credentials
+12 -7
View File
@@ -9435,20 +9435,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": [
@@ -9456,7 +9458,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",
)
+3
View File
@@ -308,6 +308,9 @@ 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
@@ -1,40 +1,50 @@
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 states
from celery import current_app, states
from celery.utils.log import get_task_logger
from config.django.base import ATTACK_PATHS_SCAN_STALE_THRESHOLD_MINUTES
from 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 is_worker_alive as _is_worker_alive
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=UTC)
cutoff = now - threshold
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"
@@ -42,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(
@@ -57,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] = []
@@ -75,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:
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
@@ -112,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)
@@ -141,42 +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 _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)
# 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=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}")
@@ -187,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=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)):
@@ -225,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
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
@@ -126,14 +126,17 @@ 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=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
@@ -181,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(
@@ -19,6 +19,7 @@ import json
import time
from collections import defaultdict
from collections.abc import Iterator
from hashlib import sha256
from typing import Any
import neo4j
@@ -392,11 +393,11 @@ def _build_child_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.
Dedupes within (tenant, provider): multiple parents referencing the same
value share one child node via the existing MERGE-on-_provider_element_id
index in both sinks.
Hashing the value keeps the ID bounded while preserving deduplication within
each provider and child label.
"""
return f"{provider_id}::{child_label}::{value_key}"
value_digest = sha256(value_key.encode("utf-8")).hexdigest()
return f"{provider_id}::{child_label}::{value_digest}"
def _build_catalog_index(
@@ -172,11 +172,9 @@ def reconcile_orphans(
window_hours: int = 6,
dry_run: bool = False,
) -> dict:
"""Run the full orphan sweep under a single-flight advisory lock.
"""Run the orphan task sweep under a single-flight advisory lock.
Recovers any orphaned in-flight task and delegates attack-paths scans that
never reached a worker to their existing stale-cleanup. Returns a summary;
a no-op (lock not won) is reported too.
Returns a recovery summary. A no-op is reported when the lock is not acquired.
"""
with advisory_lock() as acquired:
if not acquired:
@@ -200,11 +198,6 @@ def reconcile_orphans(
logger.info("Orphan task recovery disabled by feature flag")
result = {"recovered": [], "failed": [], "skipped": [], "enabled": False}
if not dry_run:
from tasks.jobs.attack_paths.cleanup import cleanup_stale_attack_paths_scans
result["attack_paths"] = cleanup_stale_attack_paths_scans()
return {"acquired": True, **result}
+13 -4
View File
@@ -1464,7 +1464,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,
@@ -1489,9 +1489,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,
@@ -17,7 +17,7 @@ from api.models import (
StatusChoices,
Task,
)
from django.db import DEFAULT_DB_ALIAS
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
@@ -1828,6 +1828,55 @@ 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_iter_sink_batches_rejects_zero_batch_size(self):
with pytest.raises(
@@ -2637,10 +2686,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(
@@ -2667,18 +2900,66 @@ 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,
@@ -2686,19 +2967,39 @@ class TestCleanupStaleAttackPathsScans:
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 = 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()
@@ -2707,7 +3008,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()
@@ -2721,10 +3025,10 @@ 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,
@@ -2741,11 +3045,12 @@ class TestCleanupStaleAttackPathsScans:
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()
@@ -2757,10 +3062,10 @@ 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=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,
@@ -2772,8 +3077,8 @@ class TestCleanupStaleAttackPathsScans:
tenant = tenants_fixture[0]
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()
@@ -2781,6 +3086,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(
@@ -2819,16 +3248,20 @@ 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,
@@ -2840,12 +3273,75 @@ class TestCleanupStaleAttackPathsScans:
tenant = tenants_fixture[0]
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")
@@ -2853,10 +3349,12 @@ 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,
@@ -2875,16 +3373,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()
@@ -2898,10 +3408,12 @@ 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,
@@ -2914,11 +3426,16 @@ class TestCleanupStaleAttackPathsScans:
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
@@ -2964,10 +3481,57 @@ 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,
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,
@@ -2979,15 +3543,114 @@ class TestCleanupStaleAttackPathsScans:
tenant = tenants_fixture[0]
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(
@@ -3074,7 +3737,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
@@ -7,6 +7,7 @@ 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,
@@ -14,6 +15,7 @@ from tasks.jobs.orphan_recovery import (
is_worker_alive,
reconcile_orphans,
reenqueueable_tasks,
revoke_task,
)
@@ -180,10 +182,18 @@ class TestReconcileTaskResults:
assert tr.task_id in result["failed"]
mock_count.assert_not_called()
def test_scan_task_is_skipped_entirely(self, tenants_fixture):
@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="scan-perform",
name=task_name,
kwargs={
"tenant_id": str(tenants_fixture[0].id),
"scan_id": str(uuid4()),
@@ -339,6 +349,15 @@ class TestOrphanRecoveryHelpers:
):
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())})
@@ -350,6 +369,12 @@ class TestOrphanRecoveryHelpers:
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
@@ -374,33 +399,23 @@ class TestRecoveryFeatureFlags:
class TestRecoveryMasterFlag:
@override_settings(TASK_RECOVERY_ENABLED=False)
def test_master_flag_disables_task_recovery(self):
with (
patch(
"tasks.jobs.orphan_recovery._reconcile_task_results"
) as mock_reconcile,
patch(
"tasks.jobs.attack_paths.cleanup.cleanup_stale_attack_paths_scans",
return_value={},
),
):
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,
patch(
"tasks.jobs.attack_paths.cleanup.cleanup_stale_attack_paths_scans",
return_value={},
),
):
reconcile_orphans(grace_minutes=2, max_attempts=3, dry_run=False)
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
+89
View File
@@ -3652,6 +3652,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")
+153 -114
View File
@@ -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://<your-bucket>/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)
+389
View File
@@ -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;
}
}
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" stroke="#000" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" viewBox="0 0 24 24"><path d="M10.268 21a2 2 0 0 0 3.464 0"/><path d="M22 8c0-2.3-.8-4.3-2-6"/><path d="M3.262 15.326A1 1 0 0 0 4 17h16a1 1 0 0 0 .74-1.673C19.41 13.956 18 12.499 18 8A6 6 0 0 0 6 8c0 4.499-1.411 5.956-2.738 7.326"/><path d="M4 2C2.8 3.7 2 5.7 2 8"/></svg>

After

Width:  |  Height:  |  Size: 402 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" stroke="#000" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" viewBox="0 0 24 24"><line x1="6" x2="6" y1="3" y2="15"/><circle cx="18" cy="6" r="3"/><circle cx="6" cy="18" r="3"/><path d="M18 9a9 9 0 0 1-9 9"/></svg>

After

Width:  |  Height:  |  Size: 284 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" stroke="#000" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" viewBox="0 0 24 24"><path d="M4.5 12.75l6 6 9-13.5"/></svg>

After

Width:  |  Height:  |  Size: 192 B

@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="#FFF" aria-hidden="true" class="h-5 w-5" viewBox="0 0 24 24">
<path fill-rule="evenodd" d="M9 1.5H5.625c-1.036 0-1.875.84-1.875 1.875v17.25c0 1.035.84 1.875 1.875 1.875h12.75c1.035 0 1.875-.84 1.875-1.875V12.75A3.75 3.75 0 0 0 16.5 9h-1.875a1.875 1.875 0 0 1-1.875-1.875V5.25A3.75 3.75 0 0 0 9 1.5zm6.61 10.936a.75.75 0 1 0-1.22-.872l-3.236 4.53L9.53 14.47a.75.75 0 0 0-1.06 1.06l2.25 2.25a.75.75 0 0 0 1.14-.094l3.75-5.25z" clip-rule="evenodd"/>
<path d="M12.971 1.816A5.23 5.23 0 0 1 14.25 5.25v1.875c0 .207.168.375.375.375H16.5a5.23 5.23 0 0 1 3.434 1.279 9.768 9.768 0 0 0-6.963-6.963z"/>
</svg>

After

Width:  |  Height:  |  Size: 650 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" stroke="#000" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" viewBox="0 0 24 24"><path d="M13.5 6H5.25A2.25 2.25 0 0 0 3 8.25v10.5A2.25 2.25 0 0 0 5.25 21h10.5A2.25 2.25 0 0 0 18 18.75V10.5m-10.5 6L21 3m0 0h-5.25M21 3v5.25"/></svg>

After

Width:  |  Height:  |  Size: 303 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" stroke="#000" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" viewBox="0 0 24 24"><path d="M12.586 2.586A2 2 0 0 0 11.172 2H4a2 2 0 0 0-2 2v7.172a2 2 0 0 0 .586 1.414l8.704 8.704a2.426 2.426 0 0 0 3.42 0l6.58-6.58a2.426 2.426 0 0 0 0-3.42z"/><circle cx="7.5" cy="7.5" r=".5" fill="#000"/></svg>

After

Width:  |  Height:  |  Size: 363 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" stroke="#000" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" viewBox="0 0 24 24"><path d="M9.879 7.519c1.171-1.025 3.071-1.025 4.242 0 1.172 1.025 1.172 2.687 0 3.712-.203.179-.43.326-.67.442-.745.361-1.45.999-1.45 1.827v.75M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Zm-9 5.25h.008v.008H12v-.008Z"/></svg>

After

Width:  |  Height:  |  Size: 370 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" stroke="#000" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" viewBox="0 0 24 24"><path d="M15.39 4.39a1 1 0 0 0 1.68-.474 2.5 2.5 0 1 1 3.014 3.015 1 1 0 0 0-.474 1.68l1.683 1.682a2.414 2.414 0 0 1 0 3.414L19.61 15.39a1 1 0 0 1-1.68-.474 2.5 2.5 0 1 0-3.014 3.015 1 1 0 0 1 .474 1.68l-1.683 1.682a2.414 2.414 0 0 1-3.414 0L8.61 19.61a1 1 0 0 0-1.68.474 2.5 2.5 0 1 1-3.014-3.015 1 1 0 0 0 .474-1.68l-1.683-1.682a2.414 2.414 0 0 1 0-3.414L4.39 8.61a1 1 0 0 1 1.68.474 2.5 2.5 0 1 0 3.014-3.015 1 1 0 0 1-.474-1.68l1.683-1.682a2.414 2.414 0 0 1 3.414 0z"/></svg>

After

Width:  |  Height:  |  Size: 630 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" stroke="#000" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" viewBox="0 0 24 24"><path d="M11.017 2.814a1 1 0 0 1 1.966 0l1.051 5.558a2 2 0 0 0 1.594 1.594l5.558 1.051a1 1 0 0 1 0 1.966l-5.558 1.051a2 2 0 0 0-1.594 1.594l-1.051 5.558a1 1 0 0 1-1.966 0l-1.051-5.558a2 2 0 0 0-1.594-1.594l-5.558-1.051a1 1 0 0 1 0-1.966l5.558-1.051a2 2 0 0 0 1.594-1.594z"/><path d="M20 2v4"/><path d="M22 4h-4"/><circle cx="4" cy="20" r="2"/></svg>

After

Width:  |  Height:  |  Size: 500 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" stroke="#000" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" viewBox="0 0 24 24"><path d="M11 4.702a.705.705 0 0 0-1.203-.498L6.413 7.587A1.4 1.4 0 0 1 5.416 8H3a1 1 0 0 0-1 1v6a1 1 0 0 0 1 1h2.416a1.4 1.4 0 0 1 .997.413l3.383 3.384A.705.705 0 0 0 11 19.298z"/><line x1="22" x2="16" y1="9" y2="15"/><line x1="16" x2="22" y1="9" y2="15"/></svg>

After

Width:  |  Height:  |  Size: 413 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" stroke="#000" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" viewBox="0 0 24 24"><path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/><path d="M16 3.128a4 4 0 0 1 0 7.744"/><path d="M22 21v-2a4 4 0 0 0-3-3.87"/><circle cx="9" cy="7" r="4"/></svg>

After

Width:  |  Height:  |  Size: 316 B

@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="#FFF" aria-hidden="true" class="h-5 w-5" viewBox="0 0 24 24">
<path fill-rule="evenodd" d="M2.25 13.5a8.25 8.25 0 0 1 8.25-8.25.75.75 0 0 1 .75.75v6.75H18a.75.75 0 0 1 .75.75 8.25 8.25 0 0 1-16.5 0z" clip-rule="evenodd"/>
<path fill-rule="evenodd" d="M12.75 3a.75.75 0 0 1 .75-.75 8.25 8.25 0 0 1 8.25 8.25.75.75 0 0 1-.75.75h-7.5a.75.75 0 0 1-.75-.75V3z" clip-rule="evenodd"/>
</svg>

After

Width:  |  Height:  |  Size: 435 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="200" height="88.14" viewBox="0 0 200 88.14"><defs><linearGradient id="lh" x1="0" y1="0" x2="1" y2="0.14"><stop offset="0" stop-color="#2EE59B"></stop><stop offset="1" stop-color="#62DFF0"></stop></linearGradient></defs><g transform="scale(0.1621179084)"><path d="M1169.38,132.04c20.76-12.21,34.44-34.9,34.44-59.79,0-38.18-31.06-69.25-69.25-69.25l-216.9.23v145.17h-64.8V3h-79.95s-47.14,95.97-47.14,95.97V3h-52.09s-47.14,95.97-47.14,95.97V3h-53.48v69.6C560.37,30.64,521.34,0,475.28,0c-42.63,0-79.24,26.25-94.54,63.43-4.35-34.03-33.48-60.43-68.67-60.43h-100.01v47.43C202.9,22.91,176.91,3,146.35,3H0s46.34,46.33,46.34,46.33v151.64h53.47v-76.68l17.21,17.21h29.33c30.56,0,56.54-19.91,65.71-47.43v106.91h53.48v-81.51l76.01,81.51h69.62l-64.29-68.94c11.14-6.56,20.22-16.15,26.26-27.46,1.27,55.26,46.58,99.82,102.14,99.82,46.06,0,85.09-30.64,97.81-72.6v69.18h60.88l38.34-78.06v78.06h60.88l66.2-134.78v135.69h95.41l22.86-22.86v22.86h95.05l21.84-21.84v20.93h53.48v-81.5l76.01,81.5h69.62l-64.29-68.94ZM146.35,88.02h-46.54v-31.54h46.54c8.7,0,15.77,7.07,15.77,15.77s-7.07,15.77-15.77,15.77ZM312.07,88.02l-46.54-.18v-31.36h46.54c8.7,0,15.77,7.07,15.77,15.77s-7.08,15.77-15.77,15.77ZM475.28,150.92c-26.86,0-48.72-21.86-48.72-48.72s21.86-48.72,48.72-48.72,48.72,21.86,48.72,48.72-21.86,48.72-48.72,48.72ZM1034.56,148.41h-63.41v-20.35h42.91v-50.88h-42.91v-20.46h63.41v91.69ZM1134.57,88.02l-46.54-.18v-31.36h46.54c8.7,0,15.77,7.07,15.77,15.77s-7.07,15.77-15.77,15.77Z" fill="#ffffff"></path></g><text x="200" y="59.14" text-anchor="end" font-family="ProwlerInter, Inter, sans-serif" font-weight="600" font-size="19" letter-spacing="0.7" fill="url(#lh)">LOCAL</text><text x="200" y="84.14" text-anchor="end" font-family="ProwlerInter, Inter, sans-serif" font-weight="600" font-size="19" letter-spacing="0.7" fill="url(#lh)">DASHBOARD</text></svg>

After

Width:  |  Height:  |  Size: 1.8 KiB

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><path fill="#8CE046" fill-rule="evenodd" clip-rule="evenodd" d="M7 4h9.5a8 8 0 0 1 0 16H12.5v8H7V4Zm5.5 5v6H16a3 3 0 1 0 0-6h-3.5Z"/></svg>

After

Width:  |  Height:  |  Size: 200 B

+1 -1
View File
@@ -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://cloud.prowler.com/",
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",
),
+265
View File
@@ -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),
)
+1 -1
View File
@@ -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://cloud.prowler.com/",
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",
),
+57 -8
View File
@@ -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**
---
@@ -137,10 +149,10 @@ Explicit use of second-person pronouns (you) and possessives (your) should be mi
### 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:
---
@@ -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"
<AppliesTo />
```
The default covers Prowler Cloud, Prowler Private Cloud, and Prowler Local Server. Pass the `products` prop to narrow the scope:
```mdx
<AppliesTo products={["Prowler Cloud", "Prowler Private Cloud"]} />
```
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="<page path>"] 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="<group name>"]` with a button toggle, so use `li[data-title="<group name>"] > 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 Audiences Expertise
### Understand Your Audiences Expertise
+2 -2
View File
@@ -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):
+2 -2
View File
@@ -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.
+8 -8
View File
@@ -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.
@@ -225,7 +225,7 @@ Before running E2E tests:
- 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 `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)**
+10 -10
View File
@@ -2,7 +2,7 @@
title: 'Environment Variable Naming Convention'
---
Prowler is a monorepo composed of several runtime components — Prowler App (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.
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
@@ -10,7 +10,7 @@ Each component owns a dedicated prefix for the environment variables it reads:
| Component | Prefix | Status |
|-----------|--------|--------|
| Prowler App (web UI) | `UI_` | Adopted |
| Prowler Local Server (web UI) | `UI_` | Adopted |
| Prowler API (backend) | `API_` | Planned |
| Prowler SDK | `SDK_` | Planned |
| Prowler MCP Server | `MCP_` | Planned |
@@ -21,11 +21,11 @@ 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 App, scoping browser-facing configuration under one intentional prefix prevents server-only values from leaking into the client bundle.
- **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 App
## Prowler Local Server
Prowler App 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).
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:
@@ -37,11 +37,11 @@ The former build-time variables map to the new runtime variables as follows:
| `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 the App's runtime configuration.
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 App 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)).
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 |
|-------------|-------------|-------------------------------------|
@@ -49,9 +49,9 @@ Prowler App gates each optional third-party integration behind an explicit enabl
| 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 App 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).
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 App reads and validates the PostHog variables but does not yet load a PostHog client.
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.
<Warning>
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.
@@ -67,5 +67,5 @@ Prowler API, Prowler SDK, and Prowler MCP Server have not yet adopted the conven
## Deprecated Names
- **Prowler App:** 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 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).
+1 -1
View File
@@ -240,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
```
@@ -141,7 +141,7 @@ Key constants:
- `RECONNECT_INTERVAL_MS`: 5 minutes before retry after failure
```typescript
// Authentication injection for Prowler App tools
// Authentication injection for prowler_app tools
private handleBeforeToolCall = ({ name, args }) => {
// Only inject auth for prowler_app_* tools (user-specific data)
if (!name.startsWith("prowler_app_")) {
@@ -307,13 +307,13 @@ 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_app_*` | Yes (JWT) | Prowler 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 <token>` header for `prowler_app_*` calls
4. MCP Server validates token and applies RLS filtering
+1 -1
View File
@@ -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
+4 -4
View File
@@ -20,8 +20,8 @@ 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_app` | Yes | Full access to Prowler Cloud and Prowler Local Server features |
| Prowler Hub | No | Security checks catalog with **over 2,000 checks**, fixers, and **70+ compliance frameworks** |
| Prowler Documentation | No | Full-text search and retrieval of official documentation |
<Note>
@@ -53,9 +53,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
+1 -1
View File
@@ -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.
<Info>
**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.
</Info>
<Warning>
@@ -266,7 +266,7 @@ Every legacy compliance file is a JSON document with the following top-level key
| 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. |
| `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. |
@@ -535,7 +535,7 @@ Each entry in the list is a single constraint with the following fields:
### 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 App backend so the rule is defined exactly once.
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.
@@ -736,9 +736,9 @@ Open the generated CSV and confirm:
- 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.
### 5. 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
+199 -161
View File
@@ -38,61 +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/installation/prowler-mcp",
"getting-started/basic-usage/prowler-mcp",
"getting-started/basic-usage/prowler-mcp-tools"
]
},
{
"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-cloud-lighthouse"
]
},
{
"group": "Prowler for Claude Code",
"pages": [
"getting-started/products/prowler-claude-code-plugin"
]
},
{
"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"
]
},
{
@@ -111,36 +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-import-findings",
"user-guide/tutorials/prowler-scan-scheduling",
"user-guide/tutorials/prowler-alerts",
"user-guide/tutorials/prowler-app-scan-configuration",
"user-guide/tutorials/prowler-app-findings-triage",
"user-guide/compliance/tutorials/cross-provider-compliance",
{
"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",
@@ -148,40 +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 (Prowler Cloud)",
"group": "Providers",
"pages": [
"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"
]
},
{
"group": "Tutorials",
"pages": [
"user-guide/tutorials/aws-organizations-bulk-provisioning",
"user-guide/tutorials/bulk-provider-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": "Lighthouse AI (Open Source)",
"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"
]
},
"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/bulk-provider-provisioning",
"user-guide/tutorials/aws-organizations-bulk-provisioning"
]
}
]
},
{
"group": "Prowler for AI Agents",
"pages": [
"getting-started/products/prowler-claude-code-plugin"
]
},
{
"group": "CI/CD",
"pages": [
@@ -223,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": [
@@ -250,6 +295,27 @@
"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": [
@@ -261,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"
]
},
{
@@ -274,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": [
@@ -282,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": [
@@ -311,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"
]
},
{
@@ -344,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": [
@@ -364,38 +424,9 @@
"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",
"user-guide/providers/okta/retry-configuration"
]
},
{
"group": "Linode",
"pages": [
"user-guide/providers/linode/getting-started-linode",
"user-guide/providers/linode/authentication"
]
},
{
"group": "E2E Networks",
"pages": [
"user-guide/providers/e2enetworks/getting-started-e2enetworks",
"user-guide/providers/e2enetworks/authentication"
]
}
]
},
{
"group": "Compliance",
"pages": [
"user-guide/compliance/tutorials/compliance",
"user-guide/compliance/tutorials/threatscore"
]
},
{
"group": "Cookbooks",
"pages": [
@@ -503,10 +534,6 @@
"tab": "Changelog",
"icon": "github",
"href": "https://github.com/prowler-cloud/prowler/releases"
},
{
"tab": "Public Roadmap",
"href": "https://roadmap.prowler.com/"
}
],
"global": {
@@ -542,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"
@@ -622,6 +656,10 @@
{
"source": "/user-guide/tutorials/prowler-app-alerts",
"destination": "/user-guide/tutorials/prowler-alerts"
},
{
"source": "/user-guide/tutorials/prowler-cloud-public-ips",
"destination": "/security/networking"
}
]
}
@@ -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
</Note>
## Log In
Access Prowler App by logging in with **email and password**.
Access Prowler Local Server by logging in with **email and password**.
<img src="/images/log-in.png" alt="Log In" width="285" />
@@ -62,7 +62,7 @@ Review findings during scan execution in the following sections:
- **Compliance** Displays compliance insights based on security frameworks.
<img src="/images/compliance.png" alt="Compliance" width="700" />
> 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.
<Note>
Prowler will automatically scan all configured providers every **24 hours**, ensuring your cloud environment stays continuously monitored.
@@ -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 & Prowler Local Server | 32 tools | Yes |
## Tool Naming Convention
@@ -20,9 +20,9 @@ All tools follow a consistent naming pattern with prefixes:
- `prowler_docs_*` - Prowler documentation search and retrieval
- `prowler_app_*` - Prowler Cloud and App (Self-Managed) management tools
## Prowler Cloud/App Tools
## Prowler Cloud and Prowler Local Server Tools
Manage Prowler Cloud or Prowler App (Self-Managed) features. **Requires authentication.**
Manage Prowler Cloud or Prowler Local Server features. **Requires authentication.**
<Note>
These tools require a valid API key. See the [Configuration Guide](/getting-started/basic-usage/prowler-mcp) for authentication setup.
@@ -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 Cloud and Prowler Local Server tools
## Additional Resources
@@ -7,10 +7,10 @@ Configure your MCP client to connect to Prowler MCP Server.
## Step 1: Get Your API Key
<Note>
**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 Cloud and Prowler Local Server features.
</Note>
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.
To use Prowler Cloud or Prowler Local Server features. To get the API key, please refer to the [API Keys](/user-guide/tutorials/prowler-app-api-keys) guide.
<Warning>
Keep the API key secure. Never share it publicly or commit it to version control.
@@ -205,7 +205,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 Cloud or Prowler Local Server:
### API Key (Recommended)
@@ -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.
<Warning>
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.
@@ -109,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.
<Warning>
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).
</Warning>
</Tab>
</Tabs>
### 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
@@ -133,7 +133,7 @@ PROWLER_API_VERSION="5.33.0"
```
<Note>
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.
</Note>
@@ -172,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)
@@ -184,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)
+48
View File
@@ -0,0 +1,48 @@
---
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.
## 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. |
*/}
<Info>
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).
</Info>
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.
+42 -4
View File
@@ -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**
![Prowler App](/images/products/overview.png)
![Prowler Local Server](/images/products/overview.png)
## 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)
![Prowler App Architecture](/images/products/prowler-app-architecture.png)
```mermaid
flowchart TB
user([User / Security Team])
cli([Prowler CLI])
subgraph APP["Prowler Local Server"]
ui["Prowler UI<br/>(Next.js)"]
api["Prowler API<br/>(Django REST Framework)"]
worker["API Worker<br/>(Celery)"]
beat["API Scheduler<br/>(Celery Beat)"]
mcp["Prowler MCP Server<br/>(Lighthouse AI tools)"]
end
sdk["Prowler SDK<br/>(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
```
@@ -1,5 +1,6 @@
---
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.
@@ -9,12 +9,12 @@ prowler <provider>
```
![Prowler CLI Execution](/images/short-display.png)
## Prowler Dashboard
## Prowler Local Dashboard
```console
prowler dashboard
```
![Prowler Dashboard](/images/products/dashboard.png)
![Prowler Local Dashboard](/images/products/dashboard.png)
Prowler includes hundreds of security controls aligned with widely recognized industry frameworks and standards, including:
@@ -75,10 +75,10 @@ No. Lighthouse AI has read-only access to security data and no tools to modify r
## Looking for the Open Source Version?
Lighthouse AI is also available in the self-hosted, open-source Prowler App. For its capabilities, FAQs, and limitations, see the open-source documentation.
Lighthouse AI is also available in the open-source Prowler Local Server. For its capabilities, FAQs, and limitations, see the open-source documentation.
<Card title="Lighthouse AI (Open Source)" icon="github" href="/getting-started/products/prowler-lighthouse-ai">
Capabilities, FAQs, and limitations for Lighthouse AI in the open-source Prowler App
Capabilities, FAQs, and limitations for Lighthouse AI in the open-source Prowler Local Server
</Card>
## Getting Help
+18 -13
View File
@@ -8,6 +8,10 @@ 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.
</Warning>
<Note>
Prowler MCP Server can run as a local instance, or as the hosted **Prowler MCP** at `https://mcp.prowler.com/mcp`. The hosted server also 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 [Deployment Options](#deployment-options).
</Note>
## 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 +20,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 and Prowler Local Server
Full access to Prowler Cloud platform and self-managed Prowler App for:
Full access to Prowler Cloud and 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 +33,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**.
@@ -49,7 +53,7 @@ The following diagram illustrates the Prowler MCP Server architecture and its in
![Prowler MCP Server Schema](/images/prowler_mcp_schema.png)
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 and Prowler Local Server for security operations
- Prowler Hub for security knowledge
- Prowler Documentation for guidance and reference.
@@ -136,15 +140,16 @@ Prowler MCP Server can be used in three ways:
- No installation required.
- Managed and maintained by Prowler team.
- Authentication to Prowler Cloud or Prowler App (self-managed) via API key or JWT token.
- Authentication to Prowler Cloud or Prowler Local Server via API key or JWT token.
- Includes tools for Prowler Cloud-specific features such as Alerts, Scan Scheduling, and Findings Triage.
### 2. Local STDIO Mode
**Run the server locally on your machine**
- 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.
- Runs as a subprocess of the MCP client.
- Possibility to connect to Prowler Local Server.
- Authentication to Prowler Cloud or Prowler Local Server via environment variables.
- Requires Python 3.12+ or Docker.
### 3. Self-Hosted HTTP Mode
@@ -152,8 +157,8 @@ Prowler MCP Server can be used in three ways:
**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.
- Possibility to connect to Prowler Local Server.
- Authentication to Prowler Local Server via API key or JWT token.
- Requires Python 3.12+ or Docker.
## Requirements
@@ -161,17 +166,17 @@ Prowler MCP Server can be used in three ways:
Requirements vary based on deployment option:
**For Prowler Cloud MCP Server:**
- Prowler Cloud account and API key (only for Prowler Cloud/App features)
- Prowler Cloud account and API key (only for Prowler Cloud and Prowler Local Server 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 Cloud and Prowler Local Server features)
<Note>
**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 in both deployment options. A Prowler API key is only required to access Prowler Cloud or Prowler Local Server features.
</Note>
## Next Steps
@@ -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:
<Card title="Developer Guide" icon="code" href="/developer-guide/introduction">
Providers, services, checks, and testing: everything needed to work with Prowler SDK.
</Card>
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="#10B981" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M17.5 19H9a7 7 0 1 1 6.71-9h1.79a4.5 4.5 0 1 1 0 9Z"/></svg>

After

Width:  |  Height:  |  Size: 225 B

@@ -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<br/>(Next.js)"]
api["Prowler API<br/>(Django REST Framework)"]
worker["API Worker<br/>(Celery)"]
+26 -11
View File
@@ -1,21 +1,36 @@
# What is Prowler?
**Prowler** is the worlds 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 worlds 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.
![](/images/products/overview.png)
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
<Columns cols={2}>
<Card title="Prowler CLI" icon="terminal" href="/getting-started/products/prowler-cli">
Command Line Interface
</Card>
<Card title="Prowler App" icon="pen-to-square" href="/getting-started/products/prowler-app">
Web Application
</Card>
<Card title="Prowler Cloud" icon="pen-to-square" href="/getting-started/products/prowler-cloud">
A managed service built on top of Prowler App.
<Card title="Prowler Cloud" icon="cloud" href="/getting-started/products/prowler-cloud">
Managed cloud security platform operated by the Prowler team.
</Card>
<Card title="Prowler Hub" icon="map" href="/getting-started/products/prowler-hub">
A public library of versioned checks, cloud service artifacts, and compliance frameworks.
Free public library of versioned checks, cloud service artifacts, and compliance frameworks.
</Card>
<Card title="Prowler MCP" icon="plug" href="/getting-started/products/prowler-mcp">
MCP server that connects AI assistants and agents to Prowler.
</Card>
<Card title="All Prowler Products" icon="list" href="/getting-started/products">
Prowler Private Cloud, Prowler Lighthouse AI, and more.
</Card>
</Columns>
### Open Source
<Columns cols={2}>
<Card title="Prowler CLI" icon="terminal" href="/getting-started/products/prowler-cli">
Command line tool to run security scans across all supported providers.
</Card>
<Card title="Prowler Local Server" icon="server" href="/getting-started/products/prowler-app">
Self-hosted web application and API.
</Card>
</Columns>
@@ -70,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 |
| -------------------- | -------- | -------------------- | --------- |
+1 -1
View File
@@ -2,7 +2,7 @@
"""Generate docs/snippets/provider-cards.mdx from provider getting-started pages.
Scans docs/user-guide/providers/<name>/getting-started-*.mdx, keeps only the
providers that Prowler App/Cloud actually supports (source of truth: 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
+1 -1
View File
@@ -27,7 +27,7 @@ All Prowler code goes through the same security pipeline, whether running on Pro
| **Updates** | Automatic | Manual |
<Note>
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.
</Note>
---
+12
View File
@@ -16,6 +16,18 @@ Resolve the egress IP via DNS:
dig egress.prowler.com +short
```
<Note>
The egress IP address is stable, but it is recommended to periodically verify it remains current by querying `egress.prowler.com`.
</Note>
## 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).
+14
View File
@@ -0,0 +1,14 @@
export const AppliesTo = ({ products = ["Prowler Cloud", "Prowler Private Cloud", "Prowler Local Server"] }) => {
return (
<Info>
This guide applies to{" "}
{products.map((name, index) => (
<span key={name}>
{index > 0 && (index === products.length - 1 ? (products.length > 2 ? ", and " : " and ") : ", ")}
<b>{name}</b>
</span>
))}
. See <a href="/getting-started/products">Prowler product families</a>.
</Info>
);
};
+2 -2
View File
@@ -1,7 +1,7 @@
export const SubscriptionBanner = ({ children }) => {
export const SubscriptionBanner = ({ children, label = "feature" }) => {
return (
<Note>
This feature is available exclusively in <b>Prowler Cloud</b> and <b>Prowler Enterprise</b> with a <a href="https://prowler.com/pricing">subscription</a>.
This {label} is available exclusively in <b>Prowler Cloud</b> and <b>Prowler Private Cloud</b> with a <a href="https://prowler.com/pricing">subscription</a>.
{children}
</Note>
);
+56
View File
@@ -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;
}
}
+9 -7
View File
@@ -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.
<SubscriptionBanner label="service" />
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.
<Card title="Submit a request" icon="ticket" href="https://support.prowler.com">
Contact our support team
+10 -6
View File
@@ -4,7 +4,9 @@ title: 'Troubleshooting'
import { VersionBadge } from "/snippets/version-badge.mdx"
## Running `prowler` I get `[File: utils.py:15] [Module: utils] CRITICAL: path/redacted: OSError[13]`
## 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.
@@ -16,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:**
@@ -53,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.
@@ -87,7 +91,7 @@ docker compose up -d
<VersionBadge version="5.31.0" />
When Prowler App runs self-hosted on a machine or Kubernetes node 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
@@ -196,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:**
@@ -78,6 +78,7 @@ The following list includes all the AWS checks with configurable variables that
| `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 | `[]` |
+3 -3
View File
@@ -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.
</Note>
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
@@ -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).
<Note>
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).
</Note>
<CardGroup cols={2}>
@@ -20,15 +21,15 @@ For the unified compliance score methodology used across frameworks, see [Prowle
</Card>
</CardGroup>
## 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
@@ -263,6 +264,6 @@ To request a new framework or contribute one, see [Creating a New Security Compl
## Related Documentation
* [Cross-Provider Compliance](/user-guide/compliance/tutorials/cross-provider-compliance)
* [Prowler ThreatScore Documentation](/user-guide/compliance/tutorials/threatscore)
* [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)
@@ -1,5 +1,6 @@
---
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.'
---
@@ -203,6 +204,6 @@ The PDF detail section renders only **failed** requirements by default so the re
## Related Documentation
* [Compliance](/user-guide/compliance/tutorials/compliance)
* [Prowler ThreatScore Documentation](/user-guide/compliance/tutorials/threatscore)
* [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)
@@ -1,5 +1,5 @@
---
title: "Prowler ThreatScore Documentation"
title: "Prowler ThreatScore"
---
## Introduction
@@ -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:
<CardGroup cols={2}>
<Card title="Compliance Frameworks" icon="shield-check" href="/user-guide/compliance/tutorials/compliance">
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.
</Card>
<Card title="Prowler Dashboard" icon="chart-line" href="/user-guide/cli/tutorials/dashboard">
Explore the built-in local dashboard for Prowler CSV exports.
@@ -39,7 +39,7 @@ Before you begin, make sure you have:
### 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"
![Providers Page](/images/prowler-app/cloud-providers-page.png)
@@ -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
@@ -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"
![Providers Page](/images/prowler-app/cloud-providers-page.png)
@@ -75,13 +75,13 @@ For detailed instructions on how to create the role, see [Authentication > Assum
![New Role Info](/images/providers/get-role-arn.png)
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
![Input the Role ARN](/images/providers/paste-role-arn-prowler.png)
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"
![Filled credentials page](/images/providers/prowler-cloud-credentials-next.png)
@@ -5,7 +5,7 @@ title: 'AWS Organizations in Prowler'
<Info>
**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.
</Info>
Prowler can integrate with AWS Organizations to manage the visibility and onboarding of accounts centrally.
@@ -64,7 +64,7 @@ When more than one source is set, precedence is:
3. `aws.disallowed_regions` in `config.yaml`
<Note>
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.
</Note>
@@ -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)
@@ -218,7 +218,7 @@ The following security checks require the `ProwlerRole` permissions for executio
## 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).
@@ -16,7 +16,7 @@ Government cloud subscriptions (Azure Government) are not currently supported, b
</Note>
### 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,7 +34,7 @@ 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`
![Providers Page](/images/prowler-app/cloud-providers-page.png)
@@ -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`
![App Overview](/images/providers/app-overview.png)
2. Go to Prowler App and paste:
2. Go to Prowler Cloud and paste:
- `Client ID`
- `Tenant ID`
@@ -17,7 +17,7 @@ prowler azure --az-cli-auth --subscription-ids <subscription ID 1> <subscription
Prowler allows you to specify one or more subscriptions for scanning (up to N), enabling flexible audit configurations.
<Warning>
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.
</Warning>
## Assigning Permissions for Subscription Scans
@@ -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".
![Providers Page](/images/prowler-app/cloud-providers-page.png)
@@ -89,7 +89,7 @@ This method uses the Google Cloud CLI to authenticate and is suitable for develo
![Get the FileName](/images/providers/get-temp-file-credentials.png)
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`
@@ -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"
![Providers Page](/images/prowler-app/cloud-providers-page.png)
@@ -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.
<img src="/images/gcp-credentials.png" alt="GCP Credentials" width="700" />
@@ -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).
---

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