diff --git a/.github/workflows/api-container-checks.yml b/.github/workflows/api-container-checks.yml index 4798fd9df7..f8d5df5417 100644 --- a/.github/workflows/api-container-checks.yml +++ b/.github/workflows/api-container-checks.yml @@ -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 diff --git a/.github/workflows/renovate-config-validate.yml b/.github/workflows/renovate-config-validate.yml index 8426efa6b4..daed2353c1 100644 --- a/.github/workflows/renovate-config-validate.yml +++ b/.github/workflows/renovate-config-validate.yml @@ -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 diff --git a/.github/workflows/sdk-container-checks.yml b/.github/workflows/sdk-container-checks.yml index 709a785974..1e2e092dbc 100644 --- a/.github/workflows/sdk-container-checks.yml +++ b/.github/workflows/sdk-container-checks.yml @@ -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 diff --git a/.trivyignore b/.trivyignore index 744c94a193..c0207c8374 100644 --- a/.trivyignore +++ b/.trivyignore @@ -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 diff --git a/README.md b/README.md index 50df95484f..c58d903c08 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@ Prowler logo

- Prowler is the Open Cloud Security Platform trusted by thousands to automate security and compliance in any cloud environment. With hundreds of ready-to-use checks and compliance frameworks, Prowler delivers real-time, customizable monitoring and seamless integrations, making cloud security simple, scalable, and cost-effective for organizations of any size. + Prowler is the Open Cloud Security Platform trusted by thousands to automate security and compliance in any cloud environment. With thousands of ready-to-use checks and compliance frameworks, Prowler delivers real-time, customizable monitoring and seamless integrations, making cloud security simple, scalable, and cost-effective for organizations of any size.

Secure ANY cloud at AI Speed at prowler.com @@ -21,7 +21,7 @@ Python Version PyPI Downloads Docker Pulls - AWS ECR Gallery + AWS ECR Gallery Codecov coverage Linux Foundation insights health score

@@ -41,7 +41,7 @@ # Description -**Prowler** is the world’s most widely used _Open-Source Cloud Security Platform_ that automates security and compliance across **any cloud environment**. With hundreds of ready-to-use security checks, remediation guidance, and compliance frameworks, Prowler is built to _“Secure ANY Cloud at AI Speed”_. Prowler delivers **AI-driven**, **customizable**, and **easy-to-use** assessments, dashboards, reports, and integrations, making cloud security **simple**, **scalable**, and **cost-effective** for organizations of any size. +**Prowler** is the world’s most widely used _Open-Source Cloud Security Platform_ that automates security and compliance across **any cloud environment**. With thousands of ready-to-use security checks, remediation guidance, and compliance frameworks, Prowler is built to _“Secure ANY Cloud at AI Speed”_. Prowler delivers **AI-driven**, **customizable**, and **easy-to-use** assessments, dashboards, reports, and integrations, making cloud security **simple**, **scalable**, and **cost-effective** for organizations of any size. Prowler includes hundreds of built-in controls to ensure compliance with standards and frameworks, including: @@ -54,16 +54,16 @@ Prowler includes hundreds of built-in controls to ensure compliance with standar - **National Security Standards:** ENS (Spanish National Security Scheme) and KISA ISMS-P (Korean) - **Custom Security Frameworks:** Tailored to your needs -## Prowler App / Prowler Cloud +## Prowler Cloud & Prowler Local Server -Prowler App / [Prowler Cloud](https://cloud.prowler.com/) is a web-based application that simplifies running Prowler across your cloud provider accounts. It provides a user-friendly interface to visualize the results and streamline your security assessments. +[Prowler Cloud](https://cloud.prowler.com/) and Prowler Local Server, its self-hosted open-source version, are web applications that simplify running Prowler across your cloud provider accounts. They provide a user-friendly interface to visualize the results and streamline your security assessments. -![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 ![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]) - + subgraph APP["Prowler Local Server"] + ui["Prowler UI
(Next.js)"] + api["Prowler API
(Django REST Framework)"] + worker["API Worker
(Celery)"] + beat["API Scheduler
(Celery Beat)"] + mcp["Prowler MCP Server
(Lighthouse AI tools)"] + end + + sdk["Prowler SDK
(Python)"] + + subgraph DATA["Data Layer"] + pg[("PostgreSQL")] + valkey[("Valkey / Redis")] + neo4j[("Neo4j")] + end + + providers["Providers"] + + user --> ui + user --> cli + ui -->|REST| api + ui -->|MCP HTTP| mcp + mcp -->|REST| api + api --> pg + api --> valkey + beat -->|enqueue jobs| valkey + valkey -->|dispatch| worker + worker --> pg + worker -->|Attack Paths| neo4j + worker -->|invokes| sdk + cli --> sdk + + sdk --> providers +``` + + ## Prowler CLI diff --git a/api/CHANGELOG.md b/api/CHANGELOG.md index 9e8b541130..ba5e0638e0 100644 --- a/api/CHANGELOG.md +++ b/api/CHANGELOG.md @@ -4,6 +4,16 @@ All notable changes to the **Prowler API** are documented in this file. +## [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 diff --git a/api/Dockerfile b/api/Dockerfile index 0b5b0d7a27..8d6923bbfc 100644 --- a/api/Dockerfile +++ b/api/Dockerfile @@ -113,6 +113,7 @@ RUN apt-get purge -y --auto-remove \ make \ libxml2-dev \ libxmlsec1-dev \ + libxmlsec1-openssl \ pkg-config \ libtool \ libxslt1-dev \ diff --git a/api/changelog.d/attack-paths-stale-cleanup.fixed.md b/api/changelog.d/attack-paths-stale-cleanup.fixed.md new file mode 100644 index 0000000000..758ef9d630 --- /dev/null +++ b/api/changelog.d/attack-paths-stale-cleanup.fixed.md @@ -0,0 +1 @@ +`attack-paths-cleanup-stale-scans` now retries worker pings and checks recent scan activity before failing scans and removing temporary databases diff --git a/api/changelog.d/multi-role-permission-gates.fixed.md b/api/changelog.d/multi-role-permission-gates.fixed.md new file mode 100644 index 0000000000..b9a6c1a8ee --- /dev/null +++ b/api/changelog.d/multi-role-permission-gates.fixed.md @@ -0,0 +1 @@ +RBAC permission gates now combine permissions from every role assigned to a user in the active tenant diff --git a/api/changelog.d/trivy-critical-cves.security.md b/api/changelog.d/trivy-critical-cves.security.md new file mode 100644 index 0000000000..f078e69a04 --- /dev/null +++ b/api/changelog.d/trivy-critical-cves.security.md @@ -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 diff --git a/api/src/backend/api/attack_paths/retryable_session.py b/api/src/backend/api/attack_paths/retryable_session.py index 16f0d9e31a..2cd32f54ee 100644 --- a/api/src/backend/api/attack_paths/retryable_session.py +++ b/api/src/backend/api/attack_paths/retryable_session.py @@ -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: diff --git a/api/src/backend/api/attack_paths/sink/drop.py b/api/src/backend/api/attack_paths/sink/drop.py index 9b4044a8f0..be13a394f5 100644 --- a/api/src/backend/api/attack_paths/sink/drop.py +++ b/api/src/backend/api/attack_paths/sink/drop.py @@ -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 diff --git a/api/src/backend/api/attack_paths/sink/neo4j.py b/api/src/backend/api/attack_paths/sink/neo4j.py index c248237f01..cb7d4889b0 100644 --- a/api/src/backend/api/attack_paths/sink/neo4j.py +++ b/api/src/backend/api/attack_paths/sink/neo4j.py @@ -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: diff --git a/api/src/backend/api/attack_paths/sink/neptune.py b/api/src/backend/api/attack_paths/sink/neptune.py index b0d12069a3..dbb687e89c 100644 --- a/api/src/backend/api/attack_paths/sink/neptune.py +++ b/api/src/backend/api/attack_paths/sink/neptune.py @@ -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 diff --git a/api/src/backend/api/attack_paths/views_helpers.py b/api/src/backend/api/attack_paths/views_helpers.py index d1b351f454..1c1d3e7888 100644 --- a/api/src/backend/api/attack_paths/views_helpers.py +++ b/api/src/backend/api/attack_paths/views_helpers.py @@ -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. diff --git a/api/src/backend/api/rbac/permissions.py b/api/src/backend/api/rbac/permissions.py index ef0475fefb..3458346a5f 100644 --- a/api/src/backend/api/rbac/permissions.py +++ b/api/src/backend/api/rbac/permissions.py @@ -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: diff --git a/api/src/backend/api/tests/test_rbac.py b/api/src/backend/api/tests/test_rbac.py index f2e2fc4bb5..ed177138b2 100644 --- a/api/src/backend/api/tests/test_rbac.py +++ b/api/src/backend/api/tests/test_rbac.py @@ -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( diff --git a/api/src/backend/api/tests/test_retryable_session.py b/api/src/backend/api/tests/test_retryable_session.py new file mode 100644 index 0000000000..8bb457b8b6 --- /dev/null +++ b/api/src/backend/api/tests/test_retryable_session.py @@ -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() diff --git a/api/src/backend/api/tests/test_sink.py b/api/src/backend/api/tests/test_sink.py index 4bb302d492..3636771ddc 100644 --- a/api/src/backend/api/tests/test_sink.py +++ b/api/src/backend/api/tests/test_sink.py @@ -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..…")` 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 diff --git a/api/src/backend/api/tests/test_views.py b/api/src/backend/api/tests/test_views.py index be52393dfa..a1e73b46ec 100644 --- a/api/src/backend/api/tests/test_views.py +++ b/api/src/backend/api/tests/test_views.py @@ -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", ) diff --git a/api/src/backend/config/django/base.py b/api/src/backend/config/django/base.py index 23af0cc6d3..04251b4479 100644 --- a/api/src/backend/config/django/base.py +++ b/api/src/backend/config/django/base.py @@ -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 diff --git a/api/src/backend/tasks/jobs/attack_paths/cleanup.py b/api/src/backend/tasks/jobs/attack_paths/cleanup.py index 83192f18d0..11868b3fe0 100644 --- a/api/src/backend/tasks/jobs/attack_paths/cleanup.py +++ b/api/src/backend/tasks/jobs/attack_paths/cleanup.py @@ -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 diff --git a/api/src/backend/tasks/jobs/attack_paths/db_utils.py b/api/src/backend/tasks/jobs/attack_paths/db_utils.py index c444a62602..327d4463e2 100644 --- a/api/src/backend/tasks/jobs/attack_paths/db_utils.py +++ b/api/src/backend/tasks/jobs/attack_paths/db_utils.py @@ -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( diff --git a/api/src/backend/tasks/jobs/attack_paths/sync.py b/api/src/backend/tasks/jobs/attack_paths/sync.py index 7b73fa21e2..00c2c585c7 100644 --- a/api/src/backend/tasks/jobs/attack_paths/sync.py +++ b/api/src/backend/tasks/jobs/attack_paths/sync.py @@ -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( diff --git a/api/src/backend/tasks/jobs/orphan_recovery.py b/api/src/backend/tasks/jobs/orphan_recovery.py index 7211f1a1d5..05a2083dbe 100644 --- a/api/src/backend/tasks/jobs/orphan_recovery.py +++ b/api/src/backend/tasks/jobs/orphan_recovery.py @@ -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} diff --git a/api/src/backend/tasks/jobs/scan.py b/api/src/backend/tasks/jobs/scan.py index 8290c45a7a..39db32abe4 100644 --- a/api/src/backend/tasks/jobs/scan.py +++ b/api/src/backend/tasks/jobs/scan.py @@ -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, diff --git a/api/src/backend/tasks/tests/test_attack_paths_scan.py b/api/src/backend/tasks/tests/test_attack_paths_scan.py index d29d0980b0..3be885a7fc 100644 --- a/api/src/backend/tasks/tests/test_attack_paths_scan.py +++ b/api/src/backend/tasks/tests/test_attack_paths_scan.py @@ -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 diff --git a/api/src/backend/tasks/tests/test_orphan_recovery.py b/api/src/backend/tasks/tests/test_orphan_recovery.py index b78aca4e63..074e311b70 100644 --- a/api/src/backend/tasks/tests/test_orphan_recovery.py +++ b/api/src/backend/tasks/tests/test_orphan_recovery.py @@ -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 diff --git a/api/src/backend/tasks/tests/test_scan.py b/api/src/backend/tasks/tests/test_scan.py index f49ca6655b..2a66985276 100644 --- a/api/src/backend/tasks/tests/test_scan.py +++ b/api/src/backend/tasks/tests/test_scan.py @@ -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") diff --git a/dashboard/__main__.py b/dashboard/__main__.py index 664ce3bfa5..6a36bda7ed 100644 --- a/dashboard/__main__.py +++ b/dashboard/__main__.py @@ -20,7 +20,7 @@ print_banner() print( f"{Fore.GREEN}Loading all CSV files from the folder {folder_path_overview} ...\n{Style.RESET_ALL}" ) -cli.show_server_banner = lambda *x: click.echo( +cli.show_server_banner = lambda *_: click.echo( f"{Fore.YELLOW}NOTE:{Style.RESET_ALL} If you are using {Fore.GREEN}{Style.BRIGHT}Prowler Cloud{Style.RESET_ALL} with the S3 integration or that integration \nfrom {Fore.CYAN}{Style.BRIGHT}Prowler CLI{Style.RESET_ALL} and you want to use your data from your S3 bucket,\nrun: `{orange_color}aws s3 cp s3:///output/csv ./output --recursive{Style.RESET_ALL}`\nand then run `prowler dashboard` again to load the new files." ) @@ -33,148 +33,187 @@ dashboard = dash.Dash( title="Prowler Dashboard", ) -# Logo -prowler_logo = html.Img( - src="https://cdn.prod.website-files.com/68c4ec3f9fb7b154fbcb6e36/68ffb46d40ed7faa37a592a5_prowler-logo.png", - alt="Prowler Logo", +# ``use_pages`` above already imported dashboard/pages/cloud.py and registered +# every /cloud/* route. Import its metadata now (after app instantiation) so +# the sidebar and the gated pages share a single source of truth. +from dashboard.pages.cloud import CLOUD_FEATURES_BY_SLUG # noqa: E402 + +ICON_DIR = "/assets/images/icons/cloud" + +# Official marketing "PROWLER / LOCAL DASHBOARD" lockup (white wordmark + teal +# gradient sublabel) shown in the expanded sidebar. Vector SVG so it stays crisp +# at any DPI. The sublabel is right-anchored (text-anchor="end"), so a font +# fallback widens it leftward rather than clipping at the edge. +prowler_lockup = html.Img( + src=f"{ICON_DIR}/prowler-lockup.svg", + alt="Prowler Local Dashboard", + className="pc-brand-lockup", ) -menu_icons = { - "overview": "/assets/images/icons/overview.svg", - "compliance": "/assets/images/icons/compliance.svg", -} +# Compact brand mark shown only when the sidebar collapses to its icon rail. +prowler_mark = html.Img( + src=f"{ICON_DIR}/prowler-mark.svg", + alt="Prowler", + className="pc-brand-mark", +) + +# Locally functional destinations (Overview + Compliance). +DASHBOARD_ITEMS = [ + {"label": "Overview", "route": "/", "icon": f"{ICON_DIR}/overview.svg"}, + { + "label": "Compliance", + "route": "/compliance", + "icon": f"{ICON_DIR}/compliance.svg", + }, +] + +# Gated navigation groups reference the shared feature metadata by slug so the +# sidebar and the informational pages never drift apart. +GATED_GROUPS = [ + ("Upgrade to Prowler Cloud", ["lighthouse-ai", "attack-paths", "findings"]), + ("Configuration", ["alerts", "mutelist", "integrations"]), + ("Workspace", ["organization"]), +] + +HELP_LINKS = [ + { + "title": "Help", + "url": "https://github.com/prowler-cloud/prowler/issues", + "icon": f"{ICON_DIR}/help.svg", + }, + { + "title": "Docs", + "url": "https://docs.prowler.com", + "icon": f"{ICON_DIR}/docs.svg", + }, +] -# Function to generate navigation links -def generate_nav_links(current_path): - nav_links = [] - for page in dash.page_registry.values(): - # Gets the icon URL based on the page name - icon_url = menu_icons.get(page["name"].lower()) - is_active = ( - " bg-prowler-stone-950 border-r-4 border-solid border-prowler-lime" - if current_path == page["relative_path"] - else "" - ) - link_class = f"block hover:bg-prowler-stone-950 hover:border-r-4 hover:border-solid hover:border-prowler-lime{is_active}" +def _mask_style(icon_url): + """Inline style rendering a recolorable mask icon from a local asset.""" + return { + "WebkitMaskImage": f"url({icon_url})", + "maskImage": f"url({icon_url})", + } - link_content = html.Span( + +def _nav_icon(icon_url): + return html.Span(className="pc-ico", style=_mask_style(icon_url)) + + +def _nav_item(label, route, icon_url, current_path, gated=False): + is_active = current_path == route + class_name = "pc-nav-item pc-active" if is_active else "pc-nav-item" + + content = [ + _nav_icon(icon_url), + html.Span(label, className="pc-nav-label"), + ] + if gated: + content.append(html.Span("Prowler Cloud", className="pc-pill")) + + return dcc.Link(content, href=route, className=class_name) + + +def _section_label(title): + return html.Div(title, className="pc-section") + + +def generate_sidebar(current_path): + children = [ + # Brand lockup: full wordmark when expanded, compact mark when collapsed. + html.Div( + [prowler_lockup, prowler_mark], + className="pc-brand", + ), + # Dashboards section — the only locally functional destinations. + _section_label("Dashboards"), + html.Nav( [ - html.Img(src=icon_url, className="w-5"), - html.Span( - page["name"], className="font-medium text-base leading-6 text-white" - ), + _nav_item(item["label"], item["route"], item["icon"], current_path) + for item in DASHBOARD_ITEMS ], - className="flex justify-center lg:justify-normal items-center gap-x-3 py-2 px-3", - ) - - nav_link = html.Li( - dcc.Link(link_content, href=page["relative_path"], className=link_class) - ) - nav_links.append(nav_link) - return nav_links - - -def generate_help_menu(): - help_links = [ - { - "title": "Help", - "url": "https://github.com/prowler-cloud/prowler/issues", - "icon": "/assets/images/icons/help.png", - }, - { - "title": "Docs", - "url": "https://docs.prowler.com", - "icon": "/assets/images/icons/docs.png", - }, + className="pc-nav", + ), ] - link_class = "block hover:bg-prowler-stone-950 hover:border-r-4 hover:border-solid hover:border-prowler-lime" - - menu_items = [] - for link in help_links: - menu_item = html.Li( - html.A( - html.Span( - [ - html.Img(src=link["icon"], className="w-5"), - html.Span( - link["title"], - className="font-medium text-base leading-6 text-white", - ), - ], - className="flex items-center gap-x-3 py-2 px-3", - ), - href=link["url"], - target="_blank", - className=link_class, + # Gated groups (Prowler Cloud only). + for section_title, slugs in GATED_GROUPS: + children.append(_section_label(section_title)) + children.append( + html.Nav( + [ + _nav_item( + CLOUD_FEATURES_BY_SLUG[slug]["nav_label"], + CLOUD_FEATURES_BY_SLUG[slug]["route"], + CLOUD_FEATURES_BY_SLUG[slug]["icon"], + current_path, + gated=True, + ) + for slug in slugs + ], + className="pc-nav", ) ) - menu_items.append(menu_item) - return menu_items + # Help and Docs pinned to the bottom, separated by a neutral top border. + children.append( + html.Nav( + [ + html.A( + [ + _nav_icon(link["icon"]), + html.Span(link["title"], className="pc-nav-label"), + ], + href=link["url"], + target="_blank", + rel="noopener noreferrer", + className="pc-nav-item", + ) + for link in HELP_LINKS + ], + className="pc-nav pc-footer", + ) + ) + + return html.Div(children, className="pc-sidebar pc-font") # Layout dashboard.layout = html.Div( [ - dcc.Location(id="url", refresh=False), html.Link(rel="icon", href="assets/favicon.ico"), - # Placeholder for dynamic navigation bar html.Div( [ + # Dynamic sidebar (rebuilt on navigation for active state). + html.Div(id="navigation-bar"), + # Main pane hosting the routed page content. html.Div( - id="navigation-bar", className="bg-prowler-stone-900 min-w-36 z-10" - ), - html.Div( - [ + html.Div( dash.page_container, - ], + className="pc-main-inner", + ), id="content_select", - className="bg-prowler-white w-full col-span-11 h-screen mx-auto overflow-y-scroll no-scrollbar px-10 py-7", + className="pc-main pc-font no-scrollbar", ), ], - className="grid custom-grid 2xl:custom-grid-large h-screen", + className="pc-shell", ), ], className="h-screen mx-auto", ) -# Callback to update navigation bar -@dashboard.callback(Output("navigation-bar", "children"), [Input("url", "pathname")]) +# Callback to update navigation bar. +# +# Triggered off Dash Pages' own location (``_pages_location``) rather than a +# separate ``dcc.Location``. A standalone ``dcc.Location(id="url")`` stops +# emitting ``pathname`` when navigating between two pages that render an +# identical component tree — every ``/cloud/*`` gated page shares the same +# ``build_cloud_layout`` structure — which left the active highlight stuck on +# the first gated page visited. ``_pages_location`` fires on every route change. +@dashboard.callback( + Output("navigation-bar", "children"), [Input("_pages_location", "pathname")] +) def update_nav_bar(pathname): - return html.Div( - [ - html.Div([prowler_logo], className="mb-8 px-3"), - html.H6( - "Dashboards", - className="px-3 text-prowler-stone-500 text-sm opacity-90 font-regular mb-2", - ), - html.Nav( - [html.Ul(generate_nav_links(pathname), className="")], - className="flex flex-col gap-y-6", - ), - html.Nav( - [ - html.A( - [ - html.Span( - [ - html.Img(src="assets/favicon.ico", className="w-5"), - "Subscribe to Prowler Cloud", - ], - className="flex items-center gap-x-3 text-white", - ), - ], - href="https://prowler.com/", - target="_blank", - className="block p-3 uppercase text-xs hover:bg-prowler-stone-950 hover:border-r-4 hover:border-solid hover:border-prowler-lime", - ), - html.Ul(generate_help_menu(), className=""), - ], - className="flex flex-col gap-y-6 mt-auto", - ), - ], - className="flex flex-col bg-prowler-stone-900 py-7 h-full", - ) + return generate_sidebar(pathname) diff --git a/dashboard/assets/cloud-pages.css b/dashboard/assets/cloud-pages.css new file mode 100644 index 0000000000..53052eabda --- /dev/null +++ b/dashboard/assets/cloud-pages.css @@ -0,0 +1,389 @@ +/* + * Prowler Local Dashboard — Cloud upsell chrome & gated pages. + * These styles are self-contained (not dependent on the precompiled Tailwind + * bundle) so pixel specs from the PRD render reliably without a rebuild. + */ + +@import url("https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap"); + +:root { + --pc-btn: #6ee7b7; + --pc-btn-hover: #99f6e4; + --pc-btn-press: #34d399; + --pc-grad-start: #2ee59b; + --pc-grad-end: #62dff0; + --pc-text: #020617; + --pc-text-2: #27272a; + --pc-sidebar-bg: #27272a; + --pc-pane-bg: #fdfdfd; + --pc-border: #e5e5e5; + --pc-teal-accent: #2ee59b; + --pc-sidebar-w: 264px; + --pc-sidebar-w-collapsed: 82px; +} + +.pc-font { + font-family: "Inter", system-ui, -apple-system, "Segoe UI", Roboto, + Helvetica, Arial, sans-serif; +} + +/* ----------------------------------------------------------------- Shell */ + +.pc-shell { + display: flex; + height: 100vh; + width: 100%; + overflow: hidden; +} + +.pc-main { + flex: 1 1 auto; + height: 100vh; + overflow-y: auto; + background: var(--pc-pane-bg); +} + +.pc-main-inner { + padding: 28px 40px 64px; +} + +/* --------------------------------------------------------------- Sidebar */ + +.pc-sidebar { + flex: 0 0 var(--pc-sidebar-w); + width: var(--pc-sidebar-w); + background: var(--pc-sidebar-bg); + height: 100vh; + display: flex; + flex-direction: column; + padding: 22px 0 16px; + overflow-y: auto; + overflow-x: hidden; +} + +.pc-sidebar::-webkit-scrollbar { + display: none; +} + +.pc-brand { + display: flex; + align-items: center; + gap: 10px; + padding: 0 18px; + margin-bottom: 22px; +} + +.pc-brand-lockup { + width: 190px; + height: auto; + display: block; +} + +/* Compact mark is only revealed on the collapsed icon rail (see media query). */ +.pc-brand-mark { + width: 30px; + height: 30px; + flex: 0 0 auto; + display: none; +} + +.pc-section { + color: #8a8a90; + font-size: 11px; + font-weight: 600; + letter-spacing: 0.07em; + text-transform: uppercase; + padding: 0 18px; + margin: 18px 0 8px; +} + +.pc-nav { + display: flex; + flex-direction: column; + gap: 2px; +} + +.pc-nav-item { + display: flex; + align-items: center; + gap: 10px; + padding: 9px 12px; + margin: 0 8px; + color: #ffffff; + font-size: 14px; + font-weight: 500; + text-decoration: none; + border-radius: 8px; + border-left: 3px solid transparent; + overflow: hidden; + transition: background 0.15s ease; +} + +.pc-nav-item:hover { + background: rgba(255, 255, 255, 0.07); +} + +.pc-nav-item.pc-active { + background: rgba(255, 255, 255, 0.09); + border-left-color: var(--pc-teal-accent); +} + +.pc-nav-label { + flex: 0 1 auto; + min-width: 0; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +/* Icon rendered as a recolorable mask so one asset serves any color. */ +.pc-ico { + width: 20px; + height: 20px; + flex: 0 0 auto; + display: inline-block; + background-color: currentColor; + -webkit-mask-repeat: no-repeat; + mask-repeat: no-repeat; + -webkit-mask-position: center; + mask-position: center; + -webkit-mask-size: contain; + mask-size: contain; +} + +.pc-pill { + flex: 0 0 auto; + margin-left: auto; + display: inline-flex; + align-items: center; + background: linear-gradient( + 112deg, + var(--pc-grad-start) 3.5%, + var(--pc-grad-end) 98.8% + ); + color: var(--pc-text); + font-size: 10px; + font-weight: 700; + letter-spacing: 0.02em; + line-height: 1; + padding: 3px 8px; + border-radius: 9999px; + white-space: nowrap; +} + +.pc-footer { + margin-top: auto; + padding-top: 12px; + border-top: 1px solid rgba(255, 255, 255, 0.1); +} + +/* -------------------------------------------------------- Gated page body */ + +.pc-page { + max-width: 1120px; + margin: 0 auto; +} + +.pc-page-header { + border-bottom: 1px solid var(--pc-border); + padding-bottom: 18px; + margin-bottom: 28px; +} + +.pc-page-title-row { + display: flex; + align-items: center; + gap: 12px; +} + +/* In the page header the badge sits right next to the title (not pushed to the + far right like the sidebar pills) and is uppercased for emphasis. */ +.pc-page-title-row .pc-pill { + margin-left: 0; + text-transform: uppercase; + letter-spacing: 0.04em; +} + +.pc-page-title { + font-size: 24px; + font-weight: 700; + color: var(--pc-text); + margin: 0; +} + +.pc-page-subtitle { + font-size: 15px; + line-height: 24px; + color: #52525b; + margin: 8px 0 0; +} + +/* ----------------------------------------------------------- Upgrade card */ + +.pc-card { + position: relative; + background: #ffffff; + border: 1px solid var(--pc-border); + border-radius: 18px; + padding: 56px 40px; + overflow: hidden; +} + +.pc-card-glow { + position: absolute; + top: 0; + left: 50%; + transform: translateX(-50%); + width: 560px; + height: 240px; + background: radial-gradient( + ellipse at top, + rgba(46, 229, 155, 0.2), + rgba(98, 223, 240, 0.06) 45%, + transparent 72% + ); + pointer-events: none; +} + +.pc-card-body { + position: relative; + z-index: 1; + display: flex; + flex-direction: column; + align-items: center; + text-align: center; +} + +.pc-feature-icon { + width: 64px; + height: 64px; + border-radius: 16px; + background: linear-gradient( + 135deg, + rgba(46, 229, 155, 0.16), + rgba(98, 223, 240, 0.14) + ); + border: 1px solid rgba(46, 229, 155, 0.3); + display: flex; + align-items: center; + justify-content: center; + color: var(--pc-text); + margin-bottom: 22px; +} + +.pc-feature-icon .pc-ico { + width: 30px; + height: 30px; +} + +.pc-avail { + color: #0e9f6e; + font-size: 12px; + font-weight: 700; + letter-spacing: 0.09em; + text-transform: uppercase; + margin-bottom: 8px; +} + +.pc-card-title { + font-size: 24px; + font-weight: 700; + color: var(--pc-text); + margin: 0 0 14px; +} + +.pc-card-desc { + font-size: 15px; + line-height: 24px; + color: var(--pc-text-2); + max-width: 560px; + margin: 0 0 26px; +} + +.pc-benefits { + list-style: none; + padding: 0; + margin: 0 0 30px; + text-align: left; +} + +.pc-benefit { + display: flex; + align-items: flex-start; + gap: 10px; + padding: 7px 0; + font-size: 15px; + line-height: 22px; + color: var(--pc-text-2); +} + +.pc-benefit-check { + flex: 0 0 auto; + width: 18px; + height: 18px; + margin-top: 2px; + color: var(--pc-btn-press); +} + +.pc-cta { + display: inline-flex; + align-items: center; + justify-content: center; + background: var(--pc-btn); + color: var(--pc-text); + font-size: 15px; + font-weight: 700; + padding: 12px 24px; + border-radius: 12px; + border: 1px solid var(--pc-btn-press); + text-decoration: none; + cursor: pointer; + transition: background 0.15s ease; +} + +.pc-cta:hover { + background: var(--pc-btn-hover); + color: var(--pc-text); +} + +.pc-cta:active { + background: var(--pc-btn-press); +} + +/* --------------------------------------------------- Responsive collapse */ + +@media (max-width: 900px) { + .pc-sidebar { + flex-basis: var(--pc-sidebar-w-collapsed); + width: var(--pc-sidebar-w-collapsed); + } + + .pc-brand { + justify-content: center; + padding: 0 8px; + } + + .pc-brand-lockup { + display: none; + } + + .pc-brand-mark { + display: block; + } + + .pc-section, + .pc-nav-label, + .pc-pill { + display: none; + } + + .pc-nav-item { + justify-content: center; + margin: 0 6px; + padding: 10px 0; + } + + .pc-main-inner { + padding: 20px 18px 48px; + } +} diff --git a/dashboard/assets/images/icons/cloud/alerts.svg b/dashboard/assets/images/icons/cloud/alerts.svg new file mode 100644 index 0000000000..6109e378ab --- /dev/null +++ b/dashboard/assets/images/icons/cloud/alerts.svg @@ -0,0 +1 @@ + diff --git a/dashboard/assets/images/icons/cloud/attack-paths.svg b/dashboard/assets/images/icons/cloud/attack-paths.svg new file mode 100644 index 0000000000..b856c6ea2f --- /dev/null +++ b/dashboard/assets/images/icons/cloud/attack-paths.svg @@ -0,0 +1 @@ + diff --git a/dashboard/assets/images/icons/cloud/check.svg b/dashboard/assets/images/icons/cloud/check.svg new file mode 100644 index 0000000000..a5f8ed0c8a --- /dev/null +++ b/dashboard/assets/images/icons/cloud/check.svg @@ -0,0 +1 @@ + diff --git a/dashboard/assets/images/icons/cloud/compliance.svg b/dashboard/assets/images/icons/cloud/compliance.svg new file mode 100644 index 0000000000..e70ebda28c --- /dev/null +++ b/dashboard/assets/images/icons/cloud/compliance.svg @@ -0,0 +1,4 @@ + diff --git a/dashboard/assets/images/icons/cloud/docs.svg b/dashboard/assets/images/icons/cloud/docs.svg new file mode 100644 index 0000000000..efc3dfc61b --- /dev/null +++ b/dashboard/assets/images/icons/cloud/docs.svg @@ -0,0 +1 @@ + diff --git a/dashboard/assets/images/icons/cloud/findings.svg b/dashboard/assets/images/icons/cloud/findings.svg new file mode 100644 index 0000000000..93fc42a516 --- /dev/null +++ b/dashboard/assets/images/icons/cloud/findings.svg @@ -0,0 +1 @@ + diff --git a/dashboard/assets/images/icons/cloud/help.svg b/dashboard/assets/images/icons/cloud/help.svg new file mode 100644 index 0000000000..2b3f1024e8 --- /dev/null +++ b/dashboard/assets/images/icons/cloud/help.svg @@ -0,0 +1 @@ + diff --git a/dashboard/assets/images/icons/cloud/integrations.svg b/dashboard/assets/images/icons/cloud/integrations.svg new file mode 100644 index 0000000000..e7a2d07603 --- /dev/null +++ b/dashboard/assets/images/icons/cloud/integrations.svg @@ -0,0 +1 @@ + diff --git a/dashboard/assets/images/icons/cloud/lighthouse-ai.svg b/dashboard/assets/images/icons/cloud/lighthouse-ai.svg new file mode 100644 index 0000000000..1c510ac1fc --- /dev/null +++ b/dashboard/assets/images/icons/cloud/lighthouse-ai.svg @@ -0,0 +1 @@ + diff --git a/dashboard/assets/images/icons/cloud/mutelist.svg b/dashboard/assets/images/icons/cloud/mutelist.svg new file mode 100644 index 0000000000..1d498fa9e3 --- /dev/null +++ b/dashboard/assets/images/icons/cloud/mutelist.svg @@ -0,0 +1 @@ + diff --git a/dashboard/assets/images/icons/cloud/organization.svg b/dashboard/assets/images/icons/cloud/organization.svg new file mode 100644 index 0000000000..1f3d1da9f5 --- /dev/null +++ b/dashboard/assets/images/icons/cloud/organization.svg @@ -0,0 +1 @@ + diff --git a/dashboard/assets/images/icons/cloud/overview.svg b/dashboard/assets/images/icons/cloud/overview.svg new file mode 100644 index 0000000000..809e63d945 --- /dev/null +++ b/dashboard/assets/images/icons/cloud/overview.svg @@ -0,0 +1,4 @@ + diff --git a/dashboard/assets/images/icons/cloud/prowler-lockup.svg b/dashboard/assets/images/icons/cloud/prowler-lockup.svg new file mode 100644 index 0000000000..9daee36463 --- /dev/null +++ b/dashboard/assets/images/icons/cloud/prowler-lockup.svg @@ -0,0 +1 @@ +LOCALDASHBOARD diff --git a/dashboard/assets/images/icons/cloud/prowler-mark.svg b/dashboard/assets/images/icons/cloud/prowler-mark.svg new file mode 100644 index 0000000000..eb30d44be8 --- /dev/null +++ b/dashboard/assets/images/icons/cloud/prowler-mark.svg @@ -0,0 +1 @@ + diff --git a/dashboard/lib/layouts.py b/dashboard/lib/layouts.py index 3fb230f314..6d1e7947fa 100644 --- a/dashboard/lib/layouts.py +++ b/dashboard/lib/layouts.py @@ -156,7 +156,7 @@ def create_layout_compliance( html.Img(src="assets/favicon.ico", className="w-5 mr-3"), html.Span("Subscribe to Prowler Cloud"), ], - href="https://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", ), diff --git a/dashboard/pages/cloud.py b/dashboard/pages/cloud.py new file mode 100644 index 0000000000..49778f8e26 --- /dev/null +++ b/dashboard/pages/cloud.py @@ -0,0 +1,265 @@ +"""Prowler Cloud upsell (gated) informational pages. + +These routes live inside the Local Dashboard but do NOT reproduce any Prowler +Cloud functionality. Each renders the same reusable upgrade template with a +feature-specific name, icon, description, benefit bullets and UTM-tagged CTA. +All copy in ``CLOUD_FEATURES`` is normative — do not change wording, +capitalization or punctuation without Product approval. +""" + +import dash +from dash import html + +# Shared subtitle used across every gated page header. +CLOUD_SUBTITLE = "Discover more ways to protect and operate your cloud." + +# Base Prowler Cloud URL; the UTM content value identifies the feature. +CLOUD_CTA_BASE = ( + "https://cloud.prowler.com/sign-up?utm_source=prowler-local-dashboard&utm_content=" +) + +# Path to the recolorable checkmark mask used for benefit bullets. +CHECK_ICON = "/assets/images/icons/cloud/check.svg" + + +# Normative feature definitions. ``icon`` points to a local mask asset so no +# external requests are needed and the glyph recolors per context. +CLOUD_FEATURES = [ + { + "slug": "lighthouse-ai", + "route": "/cloud/lighthouse-ai", + "nav_label": "Lighthouse AI", + "page_title": "Lighthouse AI", + "card_title": "Unlock Lighthouse AI", + "description": ( + "Work with an AI security analyst that understands your cloud " + "posture and helps turn risk into action." + ), + "benefits": [ + "Ask questions about your security posture in plain language", + "Investigate findings with context from your connected providers", + "Move from insight to remediation faster", + ], + "utm_content": "lighthouse-ai", + "icon": "/assets/images/icons/cloud/lighthouse-ai.svg", + }, + { + "slug": "attack-paths", + "route": "/cloud/attack-paths", + "nav_label": "Attack Paths", + "page_title": "Attack Paths", + "card_title": "Unlock Attack Paths", + "description": ( + "Visualize the paths an attacker could take through connected " + "resources before risk becomes compromise." + ), + "benefits": [ + "See exploitable relationships across your AWS environment", + "Focus remediation on the paths with the greatest impact", + "Explore each scan as a point-in-time security graph", + ], + "utm_content": "attack-paths", + "icon": "/assets/images/icons/cloud/attack-paths.svg", + }, + { + "slug": "findings", + "route": "/cloud/findings", + "nav_label": "Findings", + "page_title": "Findings", + "card_title": "Unlock Findings", + "description": ( + "Filter, investigate, and prioritize security findings across " + "providers and scans from one workspace." + ), + "benefits": [ + "Search and filter findings across all connected accounts", + "Track status, severity, ownership, and remediation context", + "Triage findings and share a consistent source of truth with your security team", + ], + "utm_content": "findings", + "icon": "/assets/images/icons/cloud/findings.svg", + }, + { + "slug": "alerts", + "route": "/cloud/alerts", + "nav_label": "Alerts", + "page_title": "Alerts", + "card_title": "Unlock Alerts", + "description": ( + "Create alert rules and stay informed when scan results reveal " + "the risks your team cares about." + ), + "benefits": [ + "Define alerts around the findings that matter most", + "Route security signals to the right responders", + "Reduce the time between detection and action", + ], + "utm_content": "alerts", + "icon": "/assets/images/icons/cloud/alerts.svg", + }, + { + "slug": "mutelist", + "route": "/cloud/mutelist", + "nav_label": "Mutelist", + "page_title": "Mutelist", + "card_title": "Unlock Mutelist", + "description": ( + "Quiet expected findings, document accepted risk, and keep your " + "team focused on actionable work." + ), + "benefits": [ + "Create reusable rules for known exceptions", + "Keep muted findings available for audit and review", + "Cut noise without losing security context", + ], + "utm_content": "mutelist", + "icon": "/assets/images/icons/cloud/mutelist.svg", + }, + { + "slug": "integrations", + "route": "/cloud/integrations", + "nav_label": "Integrations", + "page_title": "Integrations", + "card_title": "Unlock Integrations", + "description": ( + "Connect Prowler to your security workflow so findings and scan " + "data reach the tools your team already uses." + ), + "benefits": [ + "Connect ticketing, notification, and cloud security services", + "Automate the handoff from detection to response", + "Keep teams aligned without manual exports", + ], + "utm_content": "integrations", + "icon": "/assets/images/icons/cloud/integrations.svg", + }, + { + "slug": "organization", + "route": "/cloud/organization", + "nav_label": "Organization", + "page_title": "Organization", + "card_title": "Unlock Organization", + "description": ( + "Manage users, roles, and invitations while organizing cloud " + "security work across your team." + ), + "benefits": [ + "Invite teammates into a shared security workspace", + "Control access with role-based permissions", + "Coordinate security operations across accounts and teams", + ], + "utm_content": "organization", + "icon": "/assets/images/icons/cloud/organization.svg", + }, +] + +# Convenience lookup for the navigation builder in ``__main__``. +CLOUD_FEATURES_BY_SLUG = {feature["slug"]: feature for feature in CLOUD_FEATURES} + + +def _mask_style(icon_url): + """Return the inline style that renders a recolorable mask icon.""" + return { + "WebkitMaskImage": f"url({icon_url})", + "maskImage": f"url({icon_url})", + } + + +def _benefit_item(text): + return html.Li( + [ + html.Span( + className="pc-ico pc-benefit-check", + style=_mask_style(CHECK_ICON), + ), + html.Span(text), + ], + className="pc-benefit", + ) + + +def build_cloud_layout(feature): + """Build the reusable gated informational page for a single feature.""" + cta_url = f"{CLOUD_CTA_BASE}{feature['utm_content']}" + + return html.Div( + html.Div( + [ + # Page header: title + Prowler Cloud badge + shared subtitle. + html.Div( + [ + html.Div( + [ + html.H1( + feature["page_title"], + className="pc-page-title", + ), + html.Span("Prowler Cloud", className="pc-pill"), + ], + className="pc-page-title-row", + ), + html.P(CLOUD_SUBTITLE, className="pc-page-subtitle"), + ], + className="pc-page-header", + ), + # Centered upgrade card. + html.Div( + [ + html.Div(className="pc-card-glow"), + html.Div( + [ + html.Div( + html.Span( + className="pc-ico", + style=_mask_style(feature["icon"]), + ), + className="pc-feature-icon", + ), + html.Div( + "Available in Prowler Cloud", + className="pc-avail", + ), + html.H2( + feature["card_title"], + className="pc-card-title", + ), + html.P( + feature["description"], + className="pc-card-desc", + ), + html.Ul( + [ + _benefit_item(benefit) + for benefit in feature["benefits"] + ], + className="pc-benefits", + ), + html.A( + "Upgrade to Prowler Cloud", + href=cta_url, + target="_blank", + rel="noopener noreferrer", + className="pc-cta", + ), + ], + className="pc-card-body", + ), + ], + className="pc-card", + ), + ], + className="pc-page pc-font", + ), + ) + + +# Register one page per gated feature. A distinct module key keeps each entry +# unique in Dash's page registry while sharing the same template. +for _feature in CLOUD_FEATURES: + dash.register_page( + f"cloud_{_feature['slug'].replace('-', '_')}", + path=_feature["route"], + name=_feature["nav_label"], + title=f"Prowler Dashboard - {_feature['page_title']}", + layout=build_cloud_layout(_feature), + ) diff --git a/dashboard/pages/overview.py b/dashboard/pages/overview.py index e705f15e9f..8f786412d9 100644 --- a/dashboard/pages/overview.py +++ b/dashboard/pages/overview.py @@ -1538,7 +1538,7 @@ def filter_data( html.Img(src="assets/favicon.ico", className="w-5 mr-3"), html.Span("Subscribe to Prowler Cloud"), ], - href="https://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", ), diff --git a/docs/AGENTS.md b/docs/AGENTS.md index 8278a7f88a..45bd3f04fa 100644 --- a/docs/AGENTS.md +++ b/docs/AGENTS.md @@ -78,11 +78,25 @@ b. National security is of the utmost concern nowadays. Prowler Features are considered proper nouns. They are to be referenced without articles in all pieces of writing. -This is a list of Prowler Features: +Prowler ships two product families. Use these names exactly; the former names Prowler App (now Prowler Local Server) and Prowler Enterprise (now Prowler Private Cloud) must not appear in new writing. The only allowed former-name notes are on the Prowler Product Families page (`getting-started/products/index.mdx`) and in the site-wide banner, which document the mapping. + +Prowler Products: + +* **Prowler Cloud** +* **Prowler Private Cloud** (formerly Prowler Enterprise) +* **Prowler Hub** +* **Prowler Lighthouse AI** +* **Prowler MCP** + +Open Source projects: -* **Prowler App** * **Prowler CLI** +* **Prowler Local Server** (formerly Prowler App) +* **Prowler Local Dashboard** (the Prowler CLI dashboard) * **Prowler SDK** + +Other Prowler Features: + * **Built-in Compliance Checks** * **Multi-cloud Security Scanning** * **Autonomous Cloud Security Analyst (AI)** @@ -97,8 +111,6 @@ This is a list of Prowler Features: * **AI-Generated Detections & Remediations** * **Prowler Studio** * **Custom Security Policies** -* **Prowler Cloud** -* **Prowler Registry** * **Open Source & Full APIs** --- @@ -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" + + +``` + +The default covers Prowler Cloud, Prowler Private Cloud, and Prowler Local Server. Pass the `products` prop to narrow the scope: + +```mdx + +``` + +Place it on its own line below the Version Badge when one is present, otherwise directly after the imports. + +### Cloud Marker for Subscription Content + +The custom green cloud glyph (`docs/images/icons/cloud-bold.svg`) marks content that requires a Prowler Cloud or Prowler Private Cloud subscription. It renders as a trailing `::after` element through the "Cloud marker" rules in `docs/style.css`, so sidebar labels stay left-aligned. + +* Pages: add a `li[id=""] a span::after` selector to the Cloud marker rule in `docs/style.css` for every page whose content is subscription-gated. The `li` id equals the page URL path. Pages where only one section is gated (for example the Support page, where only the Support Desk carries the SubscriptionBanner) get no marker. +* Navigation groups: add a selector only when every page in the group is subscription-gated. One sanctioned exception: the Prowler MCP group is marked because the hosted server at `mcp.prowler.com` includes tools for Prowler Cloud-specific features, and its overview page explains the free local alternative. Nested groups render as `li[data-title=""]` with a button toggle, so use `li[data-title=""] > button span:first-child::after`. Top-level groups render as `h3` headings without `data-title`, which means name collisions with top-level groups resolve themselves; a gated top-level group (always expanded) is selected through its sibling list with `:has()`, like the Security tab group. Do not use `:has()` on child links of nested groups: collapsed groups do not render their children. + +Do not use the `icon` field for this marker (icons render before the label and misalign the sidebar), and do not use `"tag"` on navigation groups (group-level tags crash `mint broken-links` with a stack overflow, verified with mint 4.2.689). The SVG stroke uses the fixed brand green `#10B981` on purpose: it must be visible on both themes without `currentColor` support. + +The marker meaning is explained on the Prowler Product Families page at `getting-started/products/index.mdx`: keep that note in place. + +--- + ## Avoid Assumptions Regarding Audience’s Expertise ### Understand Your Audience’s Expertise diff --git a/docs/README.md b/docs/README.md index 595f79bc5a..7934972c9b 100644 --- a/docs/README.md +++ b/docs/README.md @@ -5,7 +5,7 @@ This repository contains the Prowler Open Source documentation powered by [Mintl ## Documentation Structure - **Getting Started**: Overview, installation, and basic usage guides -- **User Guide**: Comprehensive guides for Prowler App, CLI, providers, and compliance +- **User Guide**: Comprehensive guides for Prowler Cloud, Prowler Local Server, Prowler CLI, providers, and compliance - **Developer Guide**: Technical documentation for developers contributing to Prowler ## Local Development @@ -13,7 +13,7 @@ This repository contains the Prowler Open Source documentation powered by [Mintl Install a reviewed version of the [Mintlify CLI](https://www.npmjs.com/package/mint) to preview documentation changes locally: ```bash -npm install --global mint@4.2.560 +npm install --global mint@4.2.689 ``` Run the following command at the root of your documentation (where `mint.json` is located): diff --git a/docs/developer-guide/documentation.mdx b/docs/developer-guide/documentation.mdx index d0fb808af2..fd045a1751 100644 --- a/docs/developer-guide/documentation.mdx +++ b/docs/developer-guide/documentation.mdx @@ -8,9 +8,9 @@ Prowler documentation is built using [Mintlify](https://www.mintlify.com/docs), The Prowler documentation is organized into several sections. The main ones are: -- **Getting Started**: Provides an overview of the Prowler platform and its different solutions, including Prowler Cloud/App, Prowler CLI, Prowler MCP Server, Prowler Hub, and Prowler Lighthouse AI. This section helps new users understand which Prowler solution best fits their needs and includes product comparisons. +- **Getting Started**: Provides an overview of the Prowler platform and its two product families, Prowler Products (Prowler Cloud, Prowler Hub, Prowler MCP, Prowler Lighthouse AI) and Open Source (Prowler CLI, Prowler Local Server). This section helps new users understand which Prowler solution best fits their needs and includes product comparisons. -- **Guides**: Contains practical tutorials and how-to guides organized by product (Prowler Cloud/App, CLI) and provider (AWS, Azure, GCP, Kubernetes, Microsoft 365, GitHub, etc.). This section covers authentication, integrations, compliance, and advanced usage scenarios. +- **Guides**: Contains practical tutorials and how-to guides organized by product (Prowler Cloud, CLI) and provider (AWS, Azure, GCP, Kubernetes, Microsoft 365, GitHub, etc.). This section covers authentication, integrations, compliance, and advanced usage scenarios. - **Developer Guide**: Documentation for contributors looking to extend Prowler functionality. This includes guides on creating providers, services, checks, output formats, integrations, and compliance frameworks. Provider-specific implementation details and testing strategies are also covered here. diff --git a/docs/developer-guide/end2end-testing.mdx b/docs/developer-guide/end2end-testing.mdx index 9013a32245..df99de6fee 100644 --- a/docs/developer-guide/end2end-testing.mdx +++ b/docs/developer-guide/end2end-testing.mdx @@ -1,12 +1,12 @@ --- -title: 'End-2-End Tests for Prowler App' +title: 'End-2-End Tests for Prowler Local Server' --- -End-to-end (E2E) tests validate complete user flows in Prowler App (UI + API). These tests are implemented with [Playwright](https://playwright.dev/) under the `ui/tests` folder and are designed to run against a Prowler App environment. +End-to-end (E2E) tests validate complete user flows in Prowler Local Server (UI + API). These tests are implemented with [Playwright](https://playwright.dev/) under the `ui/tests` folder and are designed to run against a Prowler Local Server environment. ## General Recommendations -When adding or maintaining E2E tests for Prowler App, follow these guidelines: +When adding or maintaining E2E tests for Prowler Local Server, follow these guidelines: 1. **Test real user journeys** Focus on full workflows (for example, sign-up → login → add provider → launch scan) instead of low-level UI details already covered by unit or integration tests. @@ -19,8 +19,8 @@ When adding or maintaining E2E tests for Prowler App, follow these guidelines: 3. **Use a Page Model (Page Object Model)** - Encapsulate selectors and common actions in page classes instead of repeating them in each test. - Leverage and extend the existing Playwright page models in `ui/tests`—such as `ProvidersPage`, `ScansPage`, and others—which are all based on the shared `BasePage`. - - Page models for Prowler App pages should be placed in their respective entity folders (for example, `ui/tests/providers/providers-page.ts`). - - Page models for external pages (not part of Prowler App) should be grouped in the `external` folder (for example, `ui/tests/external/github-page.ts`). + - Page models for Prowler Local Server pages should be placed in their respective entity folders (for example, `ui/tests/providers/providers-page.ts`). + - Page models for external pages (not part of Prowler Local Server) should be grouped in the `external` folder (for example, `ui/tests/external/github-page.ts`). - This approach improves readability, reduces duplication, and makes refactors safer. 4. **Reuse authentication states (StorageState)** @@ -193,7 +193,7 @@ When adding or maintaining E2E tests for Prowler App, follow these guidelines: ## Running Prowler Tests -E2E tests for Prowler App run from the `ui` project using Playwright. The Playwright configuration lives in `ui/playwright.config.ts` and defines: +E2E tests for Prowler Local Server run from the `ui` project using Playwright. The Playwright configuration lives in `ui/playwright.config.ts` and defines: - `testDir: "./tests"` – location of E2E test files (relative to the `ui` project root, so `ui/tests`). - `webServer` – how to start the Next.js development server and connect to Prowler API. @@ -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)** diff --git a/docs/developer-guide/environment-variables.mdx b/docs/developer-guide/environment-variables.mdx index 33e3567251..913444d84b 100644 --- a/docs/developer-guide/environment-variables.mdx +++ b/docs/developer-guide/environment-variables.mdx @@ -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. 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). diff --git a/docs/developer-guide/introduction.mdx b/docs/developer-guide/introduction.mdx index 1d434912ef..08c38ee17c 100644 --- a/docs/developer-guide/introduction.mdx +++ b/docs/developer-guide/introduction.mdx @@ -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 ``` diff --git a/docs/developer-guide/lighthouse-architecture.mdx b/docs/developer-guide/lighthouse-architecture.mdx index e8acc6278c..631e0e7ae4 100644 --- a/docs/developer-guide/lighthouse-architecture.mdx +++ b/docs/developer-guide/lighthouse-architecture.mdx @@ -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 ` header for `prowler_app_*` calls 4. MCP Server validates token and applies RLS filtering diff --git a/docs/developer-guide/llm-details.mdx b/docs/developer-guide/llm-details.mdx index 3647552c0f..3dc0f2653d 100644 --- a/docs/developer-guide/llm-details.mdx +++ b/docs/developer-guide/llm-details.mdx @@ -101,4 +101,4 @@ The LLM provider seamlessly integrates with Prowler's existing infrastructure: - **Output Formats**: Supports all Prowler output formats (JSON, CSV, HTML, etc.) - **Compliance Frameworks**: Integrates with Prowler's compliance reporting - **Fixer Integration**: Supports automated remediation recommendations -- **Dashboard Integration**: Compatible with Prowler App for centralized management +- **Dashboard Integration**: Compatible with Prowler Cloud and Prowler Local Server for centralized management diff --git a/docs/developer-guide/mcp-server.mdx b/docs/developer-guide/mcp-server.mdx index 4174f0f730..1b9c49a18b 100644 --- a/docs/developer-guide/mcp-server.mdx +++ b/docs/developer-guide/mcp-server.mdx @@ -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 | @@ -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 diff --git a/docs/developer-guide/prowler-studio.mdx b/docs/developer-guide/prowler-studio.mdx index a4a2a609b5..b89eea7175 100644 --- a/docs/developer-guide/prowler-studio.mdx +++ b/docs/developer-guide/prowler-studio.mdx @@ -5,7 +5,7 @@ title: 'Prowler Studio' **Prowler Studio is an AI workflow that ensures Claude Code follows Prowler's skills, guardrails, and best practices when creating new security checks.** What lands in the resulting pull request is consistent, tested, and ready for human review — not half-correct boilerplate that needs to be rewritten. -**Contributor Tool**: Prowler Studio is a workflow for advanced contributors adding new Prowler security checks. It is not part of Prowler Cloud, Prowler App, or Prowler CLI. +**Contributor Tool**: Prowler Studio is a workflow for advanced contributors adding new Prowler security checks. It is not part of Prowler Cloud, Prowler Local Server, or Prowler CLI. diff --git a/docs/developer-guide/security-compliance-framework.mdx b/docs/developer-guide/security-compliance-framework.mdx index d80588dd24..2eeabebe2a 100644 --- a/docs/developer-guide/security-compliance-framework.mdx +++ b/docs/developer-guide/security-compliance-framework.mdx @@ -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 diff --git a/docs/docs.json b/docs/docs.json index 4284f53a2a..d9ac4c2bd8 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -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" } ] } diff --git a/docs/getting-started/basic-usage/prowler-app.mdx b/docs/getting-started/basic-usage/prowler-app.mdx index bc39353dcc..0f4cf80a91 100644 --- a/docs/getting-started/basic-usage/prowler-app.mdx +++ b/docs/getting-started/basic-usage/prowler-app.mdx @@ -2,7 +2,7 @@ title: 'Basic Usage' --- -## Access Prowler App +## Access Prowler Local Server After [installation](/getting-started/installation/prowler-app), navigate to [http://localhost:3000](http://localhost:3000) and sign up with email and password. @@ -28,7 +28,7 @@ This mechanism ensures that the first user in a newly created tenant has adminis ## Log In -Access Prowler App by logging in with **email and password**. +Access Prowler Local Server by logging in with **email and password**. Log In @@ -62,7 +62,7 @@ Review findings during scan execution in the following sections: - **Compliance** – Displays compliance insights based on security frameworks. Compliance -> For detailed usage instructions, refer to the [Prowler App Guide](/user-guide/tutorials/prowler-app). +> For detailed usage instructions, refer to the [Prowler Cloud guide](/user-guide/tutorials/prowler-app), which also applies to Prowler Local Server. Prowler will automatically scan all configured providers every **24 hours**, ensuring your cloud environment stays continuously monitored. diff --git a/docs/getting-started/basic-usage/prowler-mcp-tools.mdx b/docs/getting-started/basic-usage/prowler-mcp-tools.mdx index ec11680dd5..d225617f0e 100644 --- a/docs/getting-started/basic-usage/prowler-mcp-tools.mdx +++ b/docs/getting-started/basic-usage/prowler-mcp-tools.mdx @@ -10,7 +10,7 @@ Complete reference guide for all tools available in the Prowler MCP Server. Tool |----------|------------|------------------------| | Prowler Hub | 10 tools | No | | Prowler Documentation | 2 tools | No | -| Prowler Cloud/App | 32 tools | Yes | +| Prowler Cloud & 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.** 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 diff --git a/docs/getting-started/basic-usage/prowler-mcp.mdx b/docs/getting-started/basic-usage/prowler-mcp.mdx index 2c32dbdbfc..a06f54375e 100644 --- a/docs/getting-started/basic-usage/prowler-mcp.mdx +++ b/docs/getting-started/basic-usage/prowler-mcp.mdx @@ -7,10 +7,10 @@ Configure your MCP client to connect to Prowler MCP Server. ## Step 1: Get Your API Key -**Authentication is optional**: Prowler Hub and Prowler Documentation features work without authentication. An API key is only required for Prowler Cloud and Prowler App (Self-Managed) features. +**Authentication is optional**: Prowler Hub and Prowler Documentation features work without authentication. An API key is only required for Prowler Cloud and Prowler Local Server features. -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. 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) diff --git a/docs/getting-started/installation/prowler-app.mdx b/docs/getting-started/installation/prowler-app.mdx index fe5281c140..20a45a405a 100644 --- a/docs/getting-started/installation/prowler-app.mdx +++ b/docs/getting-started/installation/prowler-app.mdx @@ -4,9 +4,9 @@ title: "Installation" ### Installation -Prowler App offers flexible installation methods tailored to various environments. +Prowler Local Server offers flexible installation methods tailored to various environments. -Refer to the [Prowler App Tutorial](/user-guide/tutorials/prowler-app) for detailed usage instructions. +Refer to the [Prowler Cloud guide](/user-guide/tutorials/prowler-app) for detailed usage instructions. Prowler configuration is based on `.env` files. Every version of Prowler can have differences on that file, so, please, use the file that corresponds with that version or repository branch or tag. @@ -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. - Google and GitHub authentication is only available in [Prowler Cloud](https://prowler.com). + Google and GitHub authentication works out of the box in [Prowler Cloud](https://prowler.com). In Prowler Local Server it requires OAuth credentials: see [Social Login Configuration](/user-guide/tutorials/prowler-app-social-login). -### Updating Prowler App +### Updating Prowler Local Server -Upgrade Prowler App installation using one of two options: +Upgrade Prowler Local Server installation using one of two options: #### Option 1: Updating the Environment File @@ -133,7 +133,7 @@ PROWLER_API_VERSION="5.33.0" ``` - You can find the latest versions of Prowler App in the [Releases Github section](https://github.com/prowler-cloud/prowler/releases) or in the [Container Versions](#container-versions) section of this documentation. + You can find the latest versions of Prowler Local Server in the [Releases Github section](https://github.com/prowler-cloud/prowler/releases) or in the [Container Versions](#container-versions) section of this documentation. @@ -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) diff --git a/docs/getting-started/products/index.mdx b/docs/getting-started/products/index.mdx new file mode 100644 index 0000000000..54ee8f3a23 --- /dev/null +++ b/docs/getting-started/products/index.mdx @@ -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. | +*/} + + +Throughout this documentation, the green cloud icon in the sidebar marks sections and pages for capabilities that require a Prowler Cloud or Prowler Private Cloud [subscription](https://prowler.com/pricing). + + +Products without a documentation page here are available through the Prowler team. [Contact us](https://prowler.com/contact) for details. + +## Open Source Projects + +| Name | Description | +|------|-------------| +| [Prowler CLI](/getting-started/products/prowler-cli) | Command line tool to run security scans across all supported providers. | +| [Prowler Local Server](/getting-started/products/prowler-app) | Self-hosted web application and API to run scans, visualize findings, and manage cloud providers. Formerly Prowler App. | +| [Prowler Local Dashboard](/user-guide/cli/tutorials/dashboard) | Local web dashboard to visualize scan results from Prowler CLI CSV outputs. Shipped with Prowler CLI. | +| [Prowler SDK](/getting-started/products/prowler-sdk) | Python library that powers Prowler CLI and Prowler Local Server. Part of the [prowler repository](https://github.com/prowler-cloud/prowler). | + +## Former Names + +| Former name | Current name | +|-------------|--------------| +| Prowler App | [Prowler Local Server](/getting-started/products/prowler-app) | +| Prowler Enterprise | Prowler Private Cloud | + +## Prowler for MSPs and MSSPs + +Prowler partners with managed service providers (MSPs) and managed security service providers (MSSPs) that operate Prowler for their customers. Visit [partners.prowler.com](https://partners.prowler.com) to become a partner. diff --git a/docs/getting-started/products/prowler-app.mdx b/docs/getting-started/products/prowler-app.mdx index 2df6015d06..76af591ba0 100644 --- a/docs/getting-started/products/prowler-app.mdx +++ b/docs/getting-started/products/prowler-app.mdx @@ -2,16 +2,16 @@ title: 'Overview' --- -Prowler App is a web application that simplifies running Prowler. It provides: +Prowler Local Server is a self-hosted web application that simplifies running Prowler. It provides: - **User-friendly interface** for configuring and executing scans - Dashboard to **view results** and manage **security findings** -![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
(Next.js)"] + api["Prowler API
(Django REST Framework)"] + worker["API Worker
(Celery)"] + beat["API Scheduler
(Celery Beat)"] + mcp["Prowler MCP Server
(Lighthouse AI tools)"] + end + + sdk["Prowler SDK
(Python)"] + + subgraph DATA["Data Layer"] + pg[("PostgreSQL")] + valkey[("Valkey / Redis")] + neo4j[("Neo4j")] + end + + providers["Providers"] + + user --> ui + user --> cli + ui -->|REST| api + ui -->|MCP HTTP| mcp + mcp -->|REST| api + api --> pg + api --> valkey + beat -->|enqueue jobs| valkey + valkey -->|dispatch| worker + worker --> pg + worker -->|Attack Paths| neo4j + worker -->|invokes| sdk + cli --> sdk + + sdk --> providers +``` diff --git a/docs/getting-started/products/prowler-claude-code-plugin.mdx b/docs/getting-started/products/prowler-claude-code-plugin.mdx index e3c11ec810..99c92a1488 100644 --- a/docs/getting-started/products/prowler-claude-code-plugin.mdx +++ b/docs/getting-started/products/prowler-claude-code-plugin.mdx @@ -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. diff --git a/docs/getting-started/products/prowler-cli.mdx b/docs/getting-started/products/prowler-cli.mdx index ac4f37c291..fa75e0456e 100644 --- a/docs/getting-started/products/prowler-cli.mdx +++ b/docs/getting-started/products/prowler-cli.mdx @@ -9,12 +9,12 @@ prowler ``` ![Prowler 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: diff --git a/docs/getting-started/products/prowler-cloud-lighthouse.mdx b/docs/getting-started/products/prowler-cloud-lighthouse.mdx index 740ba969a9..04bfd58224 100644 --- a/docs/getting-started/products/prowler-cloud-lighthouse.mdx +++ b/docs/getting-started/products/prowler-cloud-lighthouse.mdx @@ -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. - 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 ## Getting Help diff --git a/docs/getting-started/products/prowler-mcp.mdx b/docs/getting-started/products/prowler-mcp.mdx index 762b088326..b35ac3d611 100644 --- a/docs/getting-started/products/prowler-mcp.mdx +++ b/docs/getting-started/products/prowler-mcp.mdx @@ -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.
+ +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). + + ## 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) -**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. ## Next Steps diff --git a/docs/getting-started/products/prowler-sdk.mdx b/docs/getting-started/products/prowler-sdk.mdx new file mode 100644 index 0000000000..b19f576e12 --- /dev/null +++ b/docs/getting-started/products/prowler-sdk.mdx @@ -0,0 +1,11 @@ +--- +title: 'Prowler SDK' +--- + +Prowler SDK is the Python library that powers Prowler CLI and Prowler Local Server. It implements the providers, services, and security checks that every Prowler product runs. + +To use or extend Prowler SDK, start with the Developer Guide: + + + Providers, services, checks, and testing: everything needed to work with Prowler SDK. + diff --git a/docs/images/icons/cloud-bold.svg b/docs/images/icons/cloud-bold.svg new file mode 100644 index 0000000000..623bb36c11 --- /dev/null +++ b/docs/images/icons/cloud-bold.svg @@ -0,0 +1 @@ + diff --git a/docs/images/products/prowler-app-architecture.mmd b/docs/images/products/prowler-app-architecture.mmd index 0c13d580c3..da99d0ab96 100644 --- a/docs/images/products/prowler-app-architecture.mmd +++ b/docs/images/products/prowler-app-architecture.mmd @@ -1,8 +1,10 @@ +%% Source of truth for the architecture diagram. +%% Inlined as native mermaid blocks in the root README.md and in docs/getting-started/products/prowler-app.mdx: keep all copies in sync. flowchart TB user([User / Security Team]) cli([Prowler CLI]) - subgraph APP["Prowler App"] + subgraph APP["Prowler Local Server"] ui["Prowler UI
(Next.js)"] api["Prowler API
(Django REST Framework)"] worker["API Worker
(Celery)"] diff --git a/docs/introduction.mdx b/docs/introduction.mdx index c31ec2c864..9307fa351a 100644 --- a/docs/introduction.mdx +++ b/docs/introduction.mdx @@ -1,21 +1,36 @@ # What is Prowler? -**Prowler** is the world’s most widely used open-source cloud security platform that **automates security and compliance** across any cloud environment. With hundreds of ready-to-use security checks, remediation guidance, and compliance frameworks, Prowler delivers AI-driven, customizable, and easy-to-use monitoring and integrations, making cloud security simple, scalable, and cost-effective for organizations of any size. +**Prowler** is the world’s most widely used open-source cloud security platform that **automates security and compliance** across any cloud environment. With thousands of ready-to-use security checks, remediation guidance, and compliance frameworks, Prowler delivers AI-driven, customizable, and easy-to-use monitoring and integrations, making cloud security simple, scalable, and cost-effective for organizations of any size. ![](/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 + - - Command Line Interface - - - Web Application - - - A managed service built on top of Prowler App. + + Managed cloud security platform operated by the Prowler team. - A public library of versioned checks, cloud service artifacts, and compliance frameworks. + Free public library of versioned checks, cloud service artifacts, and compliance frameworks. + + + MCP server that connects AI assistants and agents to Prowler. + + + Prowler Private Cloud, Prowler Lighthouse AI, and more. + + + +### Open Source + + + + Command line tool to run security scans across all supported providers. + + + Self-hosted web application and API. @@ -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 | | -------------------- | -------- | -------------------- | --------- | diff --git a/docs/scripts/generate_provider_cards.py b/docs/scripts/generate_provider_cards.py index 3b02574978..1b35755507 100644 --- a/docs/scripts/generate_provider_cards.py +++ b/docs/scripts/generate_provider_cards.py @@ -2,7 +2,7 @@ """Generate docs/snippets/provider-cards.mdx from provider getting-started pages. Scans docs/user-guide/providers//getting-started-*.mdx, keeps only the -providers that Prowler 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 diff --git a/docs/security/index.mdx b/docs/security/index.mdx index 30364d5b82..45b39d608b 100644 --- a/docs/security/index.mdx +++ b/docs/security/index.mdx @@ -27,7 +27,7 @@ All Prowler code goes through the same security pipeline, whether running on Pro | **Updates** | Automatic | Manual | -Self-Managed includes Prowler App and Prowler CLI. They can run anywhere — any cloud provider, any region, on-premises, or air-gapped environments. Full control over data residency and infrastructure decisions. See the [Prowler App Installation Guide](/getting-started/installation/prowler-app) to get started. +Self-Managed includes Prowler Local Server and Prowler CLI. They can run anywhere — any cloud provider, any region, on-premises, or air-gapped environments. Full control over data residency and infrastructure decisions. See the [Prowler Local Server Installation Guide](/getting-started/installation/prowler-app) to get started. --- diff --git a/docs/security/networking.mdx b/docs/security/networking.mdx index 767e6ac4bf..eb00c9a840 100644 --- a/docs/security/networking.mdx +++ b/docs/security/networking.mdx @@ -16,6 +16,18 @@ Resolve the egress IP via DNS: dig egress.prowler.com +short ``` + +The egress IP address is stable, but it is recommended to periodically verify it remains current by querying `egress.prowler.com`. + + +## Use Cases + +Allowlisting Prowler Cloud's egress IP address enables: + +- **Credential Usage Control**: Restrict where cloud provider credentials can be used from across AWS, Azure, GCP, and other providers +- **Kubernetes Security**: Limit inbound HTTPS traffic to clusters by allowing only Prowler Cloud's IP address +- **Compliance Requirements**: Meet security policies requiring allowlisting of external services + ## Contact For questions about networking, visit the [Support page](/support). diff --git a/docs/snippets/applies-to.mdx b/docs/snippets/applies-to.mdx new file mode 100644 index 0000000000..0ce22af78d --- /dev/null +++ b/docs/snippets/applies-to.mdx @@ -0,0 +1,14 @@ +export const AppliesTo = ({ products = ["Prowler Cloud", "Prowler Private Cloud", "Prowler Local Server"] }) => { + return ( + + This guide applies to{" "} + {products.map((name, index) => ( + + {index > 0 && (index === products.length - 1 ? (products.length > 2 ? ", and " : " and ") : ", ")} + {name} + + ))} + . See Prowler product families. + + ); +}; diff --git a/docs/snippets/subscription-banner.mdx b/docs/snippets/subscription-banner.mdx index 8313997c84..90d9b651b5 100644 --- a/docs/snippets/subscription-banner.mdx +++ b/docs/snippets/subscription-banner.mdx @@ -1,7 +1,7 @@ -export const SubscriptionBanner = ({ children }) => { +export const SubscriptionBanner = ({ children, label = "feature" }) => { return ( - This feature is available exclusively in Prowler Cloud and Prowler Enterprise with a subscription. + This {label} is available exclusively in Prowler Cloud and Prowler Private Cloud with a subscription. {children} ); diff --git a/docs/style.css b/docs/style.css index 5c626fb06b..9a1ed19a0f 100644 --- a/docs/style.css +++ b/docs/style.css @@ -66,3 +66,59 @@ color: #000000; border: none; } + +/* Cloud marker: subscription-gated sections and pages (Prowler Cloud / Prowler Private Cloud). + Rendered as a trailing ::after glyph so sidebar labels stay left-aligned. + Nested groups render as li[data-title] with a button toggle; top-level groups + render as h3 headings. Every ungated group that shares a name with a gated one + (Providers, Prowler Cloud, Prowler Lighthouse AI) is top-level, + so plain li[data-title] selectors match only the gated nested groups, folded + or unfolded. The Security tab group is top-level (h3) and always expanded, + so it is selected through its sibling list content with :has(). + Pages are selected by their li id, which equals the page URL. The strict + > div > div > span:first-child path targets the title span only, never the + nested spans of a tag pill such as Coming Soon. + Icon source: /images/icons/cloud-bold.svg. See AGENTS.md "Cloud Marker" convention. */ +li[data-title="Prowler Cloud"] > button span:first-child::after, +li[data-title="Prowler Lighthouse AI"] > button span:first-child::after, +li[data-title="Providers"] > button span:first-child::after, +li[data-title="Scans"] > button span:first-child::after, +li[data-title="Prowler MCP"] > button span:first-child::after, +div:has(+ ul a[href="/security/encryption"]) h3 span::after, +li[id="/user-guide/compliance/tutorials/cross-provider-compliance"] a > div > div > span:first-child::after, +li[id="/user-guide/tutorials/prowler-alerts"] a > div > div > span:first-child::after, +li[id="/user-guide/tutorials/prowler-app-findings-triage"] a > div > div > span:first-child::after, +li[id="/user-guide/tutorials/prowler-app-scan-configuration"] a > div > div > span:first-child::after, +li[id="/user-guide/tutorials/prowler-cloud-aws-organizations"] a > div > div > span:first-child::after, +li[id="/user-guide/tutorials/prowler-cloud-azure-management-groups"] a > div > div > span:first-child::after, +li[id="/user-guide/tutorials/prowler-cloud-gcp-organizations"] a > div > div > span:first-child::after, +li[id="/user-guide/tutorials/prowler-cloud-lighthouse-multi-llm"] a > div > div > span:first-child::after, +li[id="/user-guide/tutorials/prowler-import-findings"] a > div > div > span:first-child::after, +li[id="/user-guide/tutorials/prowler-scan-scheduling"] a > div > div > span:first-child::after { + content: ""; + display: inline-block; + width: 0.875rem; + height: 0.875rem; + margin-left: 0.375rem; + vertical-align: -0.125rem; + background: url("/images/icons/cloud-bold.svg") no-repeat center / contain; +} +/* Wider sidebar: +2rem over the theme default (18rem) so gated labels with the + cloud marker fit on one line. The content column offsets are coupled to the + sidebar width and must shift by the same amount, hence the two companion + overrides selected by their Tailwind arbitrary-value class substrings. */ +@media (min-width: 1024px) { + #sidebar { + width: 20rem !important; + } + + div[class*="pl-[23.7rem]"] { + padding-left: 25.7rem !important; + } +} + +@media (min-width: 1280px) { + div[class*="(100%-28rem)"] { + width: calc(100% - 30rem) !important; + } +} diff --git a/docs/support.mdx b/docs/support.mdx index 6999d5efbf..07c9843e12 100644 --- a/docs/support.mdx +++ b/docs/support.mdx @@ -3,23 +3,25 @@ title: 'Support' description: 'Get help with Prowler' --- -## Lighthouse AI +import { SubscriptionBanner } from "/snippets/subscription-banner.mdx" -Lighthouse AI is a Cloud Security Analyst chatbot powered by [Prowler MCP](/getting-started/products/prowler-mcp), your 24/7 virtual cloud security analyst. It can: +## Prowler Lighthouse AI + +Prowler Lighthouse AI is your 24/7 cloud security analyst chatbot, the agentic cloud defender. Powered by [Prowler MCP](/getting-started/products/prowler-mcp), it can: - **Query your security data**: Findings, compliance status, resources, and remediation guidance -- **Search Prowler Hub**: Over 1,000 security checks and 70+ compliance frameworks +- **Search Prowler Hub**: Over 2,000 security checks and 70+ compliance frameworks - **Access documentation**: Search and retrieve Prowler docs contextually -Available in Prowler Cloud and Prowler App. +Available in Prowler Cloud, Prowler Private Cloud, and Prowler Local Server. -[Learn more about Lighthouse AI](/getting-started/products/prowler-lighthouse-ai) +[Learn more about Prowler Lighthouse AI](/getting-started/products/prowler-cloud-lighthouse) ## Support Desk -> Available to **Prowler Cloud** customers. + -For Prowler Cloud customers, submit support requests through our support desk. We'll route your request to the right team and respond via email. +Submit support requests through our support desk. We'll route your request to the right team and respond via email. Contact our support team diff --git a/docs/troubleshooting.mdx b/docs/troubleshooting.mdx index 3125d74ef5..9e0c849796 100644 --- a/docs/troubleshooting.mdx +++ b/docs/troubleshooting.mdx @@ -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 -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:** diff --git a/docs/user-guide/cli/tutorials/configuration_file.mdx b/docs/user-guide/cli/tutorials/configuration_file.mdx index e2b05e39db..e637ea9c9a 100644 --- a/docs/user-guide/cli/tutorials/configuration_file.mdx +++ b/docs/user-guide/cli/tutorials/configuration_file.mdx @@ -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 | `[]` | diff --git a/docs/user-guide/cli/tutorials/dashboard.mdx b/docs/user-guide/cli/tutorials/dashboard.mdx index 0fe3c69fed..abb3872657 100644 --- a/docs/user-guide/cli/tutorials/dashboard.mdx +++ b/docs/user-guide/cli/tutorials/dashboard.mdx @@ -1,8 +1,8 @@ --- -title: "Dashboard" +title: "Prowler Local Dashboard" --- -Prowler allows you to run your own local dashboards using the csv outputs provided by Prowler +Prowler Local Dashboard is a local web dashboard built from the CSV outputs produced by Prowler CLI. Launch it with: ```sh prowler dashboard @@ -12,7 +12,7 @@ prowler dashboard You can expose the `dashboard` server in another address using the `HOST` environment variable.
-To run Prowler local dashboard with Docker, use: +To run Prowler Local Dashboard with Docker, use: ```sh docker run -v /your/local/dir/prowler-output:/home/prowler/output --env HOST=0.0.0.0 --publish 127.0.0.1:11666:11666 toniblyx/prowler:latest dashboard diff --git a/docs/user-guide/compliance/tutorials/compliance.mdx b/docs/user-guide/compliance/tutorials/compliance.mdx index e1ec152afb..6c13f60529 100644 --- a/docs/user-guide/compliance/tutorials/compliance.mdx +++ b/docs/user-guide/compliance/tutorials/compliance.mdx @@ -1,14 +1,15 @@ --- title: 'Compliance' -description: 'Run security checks against compliance frameworks, review posture across providers, and download CSV or PDF reports from Prowler Cloud, Prowler App, and Prowler CLI.' +sidebarTitle: 'Overview' +description: 'Run security checks against compliance frameworks, review posture across providers, and download CSV or PDF reports from Prowler Cloud and Prowler Local Server, or CSV reports from Prowler CLI.' --- -Prowler maps every security check to one or more industry-standard compliance frameworks, so a single scan produces both technical findings and framework-aligned evidence. The same evaluation runs identically whether scans are launched from Prowler Cloud, Prowler App, or Prowler CLI. +Prowler maps every security check to one or more industry-standard compliance frameworks, so a single scan produces both technical findings and framework-aligned evidence. The same evaluation runs identically whether scans are launched from Prowler Cloud, Prowler Local Server, or Prowler CLI. Out of the box, Prowler covers frameworks such as CIS Benchmarks, NIST 800-53, NIST CSF, NIS2, ENS RD2022, ISO 27001, PCI-DSS, SOC 2, GDPR, HIPAA, AWS Well-Architected, BSI C5, CSA CCM, MITRE ATT&CK, KISA ISMS-P, FedRAMP, and Prowler ThreatScore. The full catalog is available at [Prowler Hub](https://hub.prowler.com/compliance). -For the unified compliance score methodology used across frameworks, see [Prowler ThreatScore Documentation](/user-guide/compliance/tutorials/threatscore). +For the unified compliance score methodology used across frameworks, see [Prowler ThreatScore](/user-guide/compliance/tutorials/threatscore). @@ -20,15 +21,15 @@ For the unified compliance score methodology used across frameworks, see [Prowle -## Prowler Cloud +## Prowler Cloud and Prowler Local Server -The Compliance section in Prowler Cloud and Prowler App centralizes compliance posture across every connected provider. It aggregates scan results, surfaces Prowler ThreatScore, and exposes detailed requirement-level evidence for each supported framework. +The Compliance section in Prowler Cloud and Prowler Local Server centralizes compliance posture across every connected provider. It aggregates scan results, surfaces Prowler ThreatScore, and exposes detailed requirement-level evidence for each supported framework. ### Accessing the Compliance Section To open the compliance overview, follow these steps: -1. Sign in to Prowler Cloud at [cloud.prowler.com](https://cloud.prowler.com/sign-in) or to a self-hosted Prowler App instance. +1. Sign in to Prowler Cloud at [cloud.prowler.com](https://cloud.prowler.com/sign-in) or to a Prowler Local Server instance. 2. Select **Compliance** from the left navigation. The page lists every framework evaluated by the most recent completed scan of the selected provider. @@ -74,7 +75,7 @@ When the selected scan includes Prowler ThreatScore data, a dedicated card appea Selecting the card opens the ThreatScore framework detail page, covered in [Working With the Framework Detail Page](#working-with-the-framework-detail-page). -For a complete explanation of the methodology, formula, and weighting, see [Prowler ThreatScore Documentation](/user-guide/compliance/tutorials/threatscore). +For a complete explanation of the methodology, formula, and weighting, see [Prowler ThreatScore](/user-guide/compliance/tutorials/threatscore). ### Exploring the Framework Grid @@ -169,7 +170,7 @@ Region filters disable the per-card download dropdown to avoid generating partia #### Downloading the Full Scan Output -To export every framework, finding, and resource at once, use the **Scan Jobs** section instead. The ZIP archive contains the CSV, JSON-OCSF, and HTML reports plus a `compliance/` subfolder with one CSV per framework. See [Prowler App — Getting Started](/user-guide/tutorials/prowler-app) for details. +To export every framework, finding, and resource at once, use the **Scan Jobs** section instead. The ZIP archive contains the CSV, JSON-OCSF, and HTML reports plus a `compliance/` subfolder with one CSV per framework. See [Prowler Cloud — Getting Started](/user-guide/tutorials/prowler-app) for details. ### API Access @@ -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) diff --git a/docs/user-guide/compliance/tutorials/cross-provider-compliance.mdx b/docs/user-guide/compliance/tutorials/cross-provider-compliance.mdx index 617a3680ed..545f5fb834 100644 --- a/docs/user-guide/compliance/tutorials/cross-provider-compliance.mdx +++ b/docs/user-guide/compliance/tutorials/cross-provider-compliance.mdx @@ -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) diff --git a/docs/user-guide/compliance/tutorials/threatscore.mdx b/docs/user-guide/compliance/tutorials/threatscore.mdx index 492f84c5d2..8d790cf90d 100644 --- a/docs/user-guide/compliance/tutorials/threatscore.mdx +++ b/docs/user-guide/compliance/tutorials/threatscore.mdx @@ -1,5 +1,5 @@ --- -title: "Prowler ThreatScore Documentation" +title: "Prowler ThreatScore" --- ## Introduction diff --git a/docs/user-guide/cookbooks/powerbi-cis-benchmarks.mdx b/docs/user-guide/cookbooks/powerbi-cis-benchmarks.mdx index 9b9e41ed93..15985aab28 100644 --- a/docs/user-guide/cookbooks/powerbi-cis-benchmarks.mdx +++ b/docs/user-guide/cookbooks/powerbi-cis-benchmarks.mdx @@ -14,7 +14,7 @@ The template and its source files live in the Prowler repository under [`contrib The setup requires the following components: * **Microsoft Power BI Desktop:** free download from Microsoft. -* **Prowler compliance CSV exports:** produced by Prowler CLI or downloaded from Prowler Cloud or Prowler App. +* **Prowler compliance CSV exports:** produced by Prowler CLI or downloaded from Prowler Cloud or Prowler Local Server. * **Local directory:** holds the CSV exports that the template ingests at load time. ## Supported CIS Benchmarks @@ -40,7 +40,7 @@ Download and install Microsoft Power BI Desktop from the official Microsoft site ### Step 2: Generate Compliance CSV Exports -Compliance CSV exports can be generated through Prowler CLI or downloaded from Prowler Cloud and Prowler App. +Compliance CSV exports can be generated through Prowler CLI or downloaded from Prowler Cloud and Prowler Local Server. #### Option A: Prowler CLI @@ -55,7 +55,7 @@ prowler kubernetes --compliance cis_1.12_kubernetes The compliance CSV exports are written to `output/compliance/` by default. -#### Option B: Prowler Cloud or Prowler App +#### Option B: Prowler Cloud or Prowler Local Server Open the Compliance section, select the desired CIS Benchmark, and download the CSV export. @@ -160,7 +160,7 @@ A full walkthrough is available on YouTube: - Review the Compliance workflow across Prowler Cloud, Prowler App, and Prowler CLI. + Review the Compliance workflow across Prowler Cloud, Prowler Local Server, and Prowler CLI. Explore the built-in local dashboard for Prowler CSV exports. diff --git a/docs/user-guide/providers/alibabacloud/getting-started-alibabacloud.mdx b/docs/user-guide/providers/alibabacloud/getting-started-alibabacloud.mdx index 353515c605..ce9778a3b5 100644 --- a/docs/user-guide/providers/alibabacloud/getting-started-alibabacloud.mdx +++ b/docs/user-guide/providers/alibabacloud/getting-started-alibabacloud.mdx @@ -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) diff --git a/docs/user-guide/providers/aws/authentication.mdx b/docs/user-guide/providers/aws/authentication.mdx index 22deb6ab99..e3c20ca99d 100644 --- a/docs/user-guide/providers/aws/authentication.mdx +++ b/docs/user-guide/providers/aws/authentication.mdx @@ -7,9 +7,9 @@ Prowler requires AWS credentials to function properly. Authentication is availab - Static Credentials - Assumed Role -When using **Assumed Role**, the Prowler UI exposes two credential sources for calling `sts:AssumeRole`. The labels differ between Prowler Cloud and self-hosted Prowler App, but both map to the same underlying credential types: +When using **Assumed Role**, the Prowler UI exposes two credential sources for calling `sts:AssumeRole`. The labels differ between Prowler Cloud and Prowler Local Server, but both map to the same underlying credential types: -- **AWS SDK Default** (shown as *"Prowler Cloud will assume your IAM role"* in Prowler Cloud and *"AWS SDK Default"* in self-hosted Prowler App): Prowler uses the credentials already available to the API and worker containers through the [AWS SDK default credential chain](https://boto3.amazonaws.com/v1/documentation/api/latest/guide/credentials.html). This is the default in Prowler Cloud and requires extra configuration in self-hosted Prowler App (see [Configuring AWS SDK Default for Self-Hosted Prowler App](#configuring-aws-sdk-default-for-self-hosted-prowler-app)). +- **AWS SDK Default** (shown as *"Prowler Cloud will assume your IAM role"* in Prowler Cloud and *"AWS SDK Default"* in Prowler Local Server): Prowler uses the credentials already available to the API and worker containers through the [AWS SDK default credential chain](https://boto3.amazonaws.com/v1/documentation/api/latest/guide/credentials.html). This is the default in Prowler Cloud and requires extra configuration in Prowler Local Server (see [Configuring AWS SDK Default for Prowler Local Server](#configuring-aws-sdk-default-for-prowler-local-server)). - **Access & Secret Key**: You paste an IAM user's `AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY`, and optionally `AWS_SESSION_TOKEN` into the form. Prowler uses those keys to call `sts:AssumeRole`. ## Required Permissions @@ -81,9 +81,9 @@ This method grants permanent access and is the recommended setup for production --- -## Configuring AWS SDK Default for Self-Hosted Prowler App +## Configuring AWS SDK Default for Prowler Local Server -When self-hosting Prowler App with Docker Compose, the API and worker containers do not have AWS credentials by default. Selecting **AWS SDK Default** without configuring those credentials produces: +When running Prowler Local Server with Docker Compose, the API and worker containers do not have AWS credentials by default. Selecting **AWS SDK Default** without configuring those credentials produces: ``` AWSAssumeRoleError[1012]: AWS assume role error - An error occurred (InvalidClientTokenId) when calling the AssumeRole operation: The security token included in the request is invalid. @@ -116,7 +116,7 @@ docker compose up -d --force-recreate api worker worker-beat ### Option 2: IAM Role (Host with Instance Metadata) -If you run Prowler App on an EC2 instance, ECS task, or EKS pod with an attached IAM role that can assume the scan role, no extra configuration is needed — `boto3` resolves credentials through instance or task metadata automatically. +If you run Prowler Local Server on an EC2 instance, ECS task, or EKS pod with an attached IAM role that can assume the scan role, no extra configuration is needed — `boto3` resolves credentials through instance or task metadata automatically. ### Trust Policy: Align `IAMPrincipal` With Your Identity diff --git a/docs/user-guide/providers/aws/getting-started-aws.mdx b/docs/user-guide/providers/aws/getting-started-aws.mdx index 847e7b6167..11516e88b3 100644 --- a/docs/user-guide/providers/aws/getting-started-aws.mdx +++ b/docs/user-guide/providers/aws/getting-started-aws.mdx @@ -18,7 +18,7 @@ title: 'Getting Started With AWS on Prowler' ### Step 2: Access Prowler Cloud -1. Navigate to [Prowler Cloud](https://cloud.prowler.com/) or launch [Prowler App](/user-guide/tutorials/prowler-app) +1. Navigate to [Prowler Cloud](https://cloud.prowler.com/) or launch [Prowler Local Server](/user-guide/tutorials/prowler-app) 2. Go to "Configuration" > "Providers" ![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) diff --git a/docs/user-guide/providers/aws/organizations.mdx b/docs/user-guide/providers/aws/organizations.mdx index bbdedef50b..c0e9ae0e6b 100644 --- a/docs/user-guide/providers/aws/organizations.mdx +++ b/docs/user-guide/providers/aws/organizations.mdx @@ -5,7 +5,7 @@ title: 'AWS Organizations in Prowler' **Using Prowler Cloud?** You can onboard your entire AWS Organization through the UI with automatic account discovery, OU-aware tree selection, and bulk connection testing — no scripts or YAML files required. -See [AWS Organizations in Prowler Cloud](/user-guide/tutorials/prowler-cloud-aws-organizations) for the full walkthrough. +See [AWS Organizations](/user-guide/tutorials/prowler-cloud-aws-organizations) in Prowler Cloud for the full walkthrough. Prowler can integrate with AWS Organizations to manage the visibility and onboarding of accounts centrally. diff --git a/docs/user-guide/providers/aws/regions-and-partitions.mdx b/docs/user-guide/providers/aws/regions-and-partitions.mdx index 377612013e..8556ccc74e 100644 --- a/docs/user-guide/providers/aws/regions-and-partitions.mdx +++ b/docs/user-guide/providers/aws/regions-and-partitions.mdx @@ -64,7 +64,7 @@ When more than one source is set, precedence is: 3. `aws.disallowed_regions` in `config.yaml` -For self-hosted App or API-triggered scans, set `PROWLER_AWS_DISALLOWED_REGIONS` in the runtime environment of the backend scan containers such as `api` and `worker`. The `ui` container does not enforce AWS region selection. +For Prowler Local Server or API-triggered scans, set `PROWLER_AWS_DISALLOWED_REGIONS` in the runtime environment of the backend scan containers such as `api` and `worker`. The `ui` container does not enforce AWS region selection. diff --git a/docs/user-guide/providers/azure/authentication.mdx b/docs/user-guide/providers/azure/authentication.mdx index 08bc79d8ff..f41e9dd9b3 100644 --- a/docs/user-guide/providers/azure/authentication.mdx +++ b/docs/user-guide/providers/azure/authentication.mdx @@ -2,9 +2,9 @@ title: 'Azure Authentication in Prowler' --- -Prowler for Azure supports multiple authentication types. Authentication methods vary between Prowler App and Prowler CLI: +Prowler for Azure supports multiple authentication types. Authentication methods vary between Prowler Cloud and Prowler CLI: -**Prowler App:** +**Prowler Cloud:** - [**Service Principal Application**](#service-principal-application-authentication-recommended) @@ -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). diff --git a/docs/user-guide/providers/azure/getting-started-azure.mdx b/docs/user-guide/providers/azure/getting-started-azure.mdx index 96498c879f..034e8eacfe 100644 --- a/docs/user-guide/providers/azure/getting-started-azure.mdx +++ b/docs/user-guide/providers/azure/getting-started-azure.mdx @@ -16,7 +16,7 @@ Government cloud subscriptions (Azure Government) are not currently supported, b
### Prerequisites -Before setting up Azure in Prowler App, you need to create a Service Principal with proper permissions. +Before setting up Azure in Prowler Cloud, you need to create a Service Principal with proper permissions. For detailed instructions on how to create the Service Principal and configure permissions, see [Authentication > Service Principal](/user-guide/providers/azure/authentication#service-principal-application-authentication-recommended). @@ -34,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` diff --git a/docs/user-guide/providers/azure/subscriptions.mdx b/docs/user-guide/providers/azure/subscriptions.mdx index 8fa6c73740..efd37c13e5 100644 --- a/docs/user-guide/providers/azure/subscriptions.mdx +++ b/docs/user-guide/providers/azure/subscriptions.mdx @@ -17,7 +17,7 @@ prowler azure --az-cli-auth --subscription-ids -The multi-subscription feature is available only in the CLI. In Prowler App, each scan is limited to a single subscription. +The multi-subscription feature is available only in the CLI. In Prowler Cloud, each scan is limited to a single subscription.
## Assigning Permissions for Subscription Scans diff --git a/docs/user-guide/providers/cloudflare/getting-started-cloudflare.mdx b/docs/user-guide/providers/cloudflare/getting-started-cloudflare.mdx index c4303b4d50..9af3dbe589 100644 --- a/docs/user-guide/providers/cloudflare/getting-started-cloudflare.mdx +++ b/docs/user-guide/providers/cloudflare/getting-started-cloudflare.mdx @@ -50,7 +50,7 @@ The Account ID is a 32-character hexadecimal string (e.g., `372e67954025e0ba6aaa ### Step 2: Open Prowler Cloud -1. Go to [Prowler Cloud](https://cloud.prowler.com/) or launch [Prowler App](/user-guide/tutorials/prowler-app). +1. Go to [Prowler Cloud](https://cloud.prowler.com/) or launch [Prowler Local Server](/user-guide/tutorials/prowler-app). 2. Navigate to "Configuration" > "Providers". ![Providers Page](/images/prowler-app/cloud-providers-page.png) diff --git a/docs/user-guide/providers/gcp/authentication.mdx b/docs/user-guide/providers/gcp/authentication.mdx index 34faa145c0..ce01c1ba23 100644 --- a/docs/user-guide/providers/gcp/authentication.mdx +++ b/docs/user-guide/providers/gcp/authentication.mdx @@ -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` diff --git a/docs/user-guide/providers/gcp/getting-started-gcp.mdx b/docs/user-guide/providers/gcp/getting-started-gcp.mdx index e16114e19a..f40542a151 100644 --- a/docs/user-guide/providers/gcp/getting-started-gcp.mdx +++ b/docs/user-guide/providers/gcp/getting-started-gcp.mdx @@ -13,7 +13,7 @@ title: 'Getting Started With GCP on Prowler' ### Step 2: Access Prowler Cloud -1. Navigate to [Prowler Cloud](https://cloud.prowler.com/) or launch [Prowler App](/user-guide/tutorials/prowler-app) +1. Navigate to [Prowler Cloud](https://cloud.prowler.com/) or launch [Prowler Local Server](/user-guide/tutorials/prowler-app) 2. Go to "Configuration" > "Providers" ![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. GCP Credentials diff --git a/docs/user-guide/providers/github/authentication.mdx b/docs/user-guide/providers/github/authentication.mdx index 0b5ab6b720..1546bf284a 100644 --- a/docs/user-guide/providers/github/authentication.mdx +++ b/docs/user-guide/providers/github/authentication.mdx @@ -12,7 +12,7 @@ Prowler offers three authentication methods. Fine-Grained Personal Access Tokens | Method | Best For | Key Benefit | |--------|----------|-------------| -| [**Fine-Grained Personal Access Token**](#fine-grained-personal-access-token-recommended) | Individual users, quick setup | Simple, user-scoped access | +| [**Fine-Grained Personal Access Token**](#fine-grained-personal-access-token-recommended-for-individual-use) | Individual users, quick setup | Simple, user-scoped access | | [**GitHub App**](#github-app-credentials) | Organizations, automation, CI/CD | Organization-scoped, no personal account dependency | | [**OAuth App Token**](#oauth-app-token) | Delegated user authorization | User-consented access flows | @@ -271,7 +271,7 @@ Store the `.pem` private key securely. Anyone with this key can authenticate as ## Prowler Cloud Authentication -For step-by-step setup instructions for Prowler Cloud, see the [Getting Started Guide](/user-guide/providers/github/getting-started-github#prowler-cloudapp). +For step-by-step setup instructions for Prowler Cloud, see the [Getting Started Guide](/user-guide/providers/github/getting-started-github#prowler-cloud-and-prowler-local-server). ### Using Personal Access Token @@ -301,7 +301,7 @@ For step-by-step setup instructions for Prowler Cloud, see the [Getting Started 3. Enter your GitHub App ID and upload the private key (`.pem` file). -For complete step-by-step instructions, see the [Getting Started Guide](/user-guide/providers/github/getting-started-github#prowler-cloudapp). +For complete step-by-step instructions, see the [Getting Started Guide](/user-guide/providers/github/getting-started-github#prowler-cloud-and-prowler-local-server). --- diff --git a/docs/user-guide/providers/github/getting-started-github.mdx b/docs/user-guide/providers/github/getting-started-github.mdx index 079f9a1d7b..09acdfe0c9 100644 --- a/docs/user-guide/providers/github/getting-started-github.mdx +++ b/docs/user-guide/providers/github/getting-started-github.mdx @@ -16,7 +16,7 @@ Prowler can scan either: - + Web-based interface with centralized management @@ -26,7 +26,7 @@ Prowler can scan either: --- -## Prowler Cloud/App +## Prowler Cloud and Prowler Local Server @@ -34,7 +34,7 @@ Prowler can scan either: ### Prerequisites -Before adding GitHub to Prowler Cloud/App, ensure you have: +Before adding GitHub to Prowler Cloud or Prowler Local Server, ensure you have: 1. **GitHub Account Access** - Personal GitHub account, OR @@ -46,9 +46,9 @@ Before adding GitHub to Prowler Cloud/App, ensure you have: - OAuth App Token - GitHub App Credentials (Not Recommended - limited data access) -### Step 1: Access Prowler Cloud/App +### Step 1: Access Prowler Cloud or Prowler Local Server -1. Navigate to [Prowler Cloud](https://cloud.prowler.com/) or launch [Prowler App](/user-guide/tutorials/prowler-app) +1. Navigate to [Prowler Cloud](https://cloud.prowler.com/) or launch [Prowler Local Server](/user-guide/tutorials/prowler-app) 2. Go to **Configuration** → **Providers** ![Providers Page](/images/prowler-app/cloud-providers-page.png) diff --git a/docs/user-guide/providers/googleworkspace/getting-started-googleworkspace.mdx b/docs/user-guide/providers/googleworkspace/getting-started-googleworkspace.mdx index 8931c43ebd..01e6e89986 100644 --- a/docs/user-guide/providers/googleworkspace/getting-started-googleworkspace.mdx +++ b/docs/user-guide/providers/googleworkspace/getting-started-googleworkspace.mdx @@ -42,7 +42,7 @@ The Customer ID starts with the letter "C" followed by alphanumeric characters ( ### Step 2: Open Prowler Cloud -1. Go to [Prowler Cloud](https://cloud.prowler.com/) or launch [Prowler App](/user-guide/tutorials/prowler-app). +1. Go to [Prowler Cloud](https://cloud.prowler.com/) or launch [Prowler Local Server](/user-guide/tutorials/prowler-app). 2. Navigate to "Configuration" > "Providers". ![Providers Page](/images/prowler-app/cloud-providers-page.png) diff --git a/docs/user-guide/providers/iac/getting-started-iac.mdx b/docs/user-guide/providers/iac/getting-started-iac.mdx index d1e978dc75..9a941c633d 100644 --- a/docs/user-guide/providers/iac/getting-started-iac.mdx +++ b/docs/user-guide/providers/iac/getting-started-iac.mdx @@ -24,7 +24,7 @@ Prowler IaC provider scans the following Infrastructure as Code configurations f ## How It Works -- Prowler App leverages [Trivy](https://trivy.dev/docs/latest/guide/coverage/iac/#scanner) to scan local directories (or specified paths) for supported IaC files, or scans remote repositories. +- Prowler Cloud leverages [Trivy](https://trivy.dev/docs/latest/guide/coverage/iac/#scanner) to scan local directories (or specified paths) for supported IaC files, or scans remote repositories. - No cloud credentials or authentication are required for local scans. - For remote repository scans, authentication can be provided via [git URL](https://git-scm.com/docs/git-clone#_git_urls), CLI flags or environment variables. - Check the [IaC Authentication](/user-guide/providers/iac/authentication) page for more details. @@ -37,11 +37,11 @@ Prowler IaC provider scans the following Infrastructure as Code configurations f ### Supported Scanners -Scanner selection is not configurable in Prowler App. Default scanners, misconfig and secret, run automatically during each scan. +Scanner selection is not configurable in Prowler Cloud. Default scanners, misconfig and secret, run automatically during each scan. -### Step 1: Access Prowler Cloud/App +### Step 1: Access Prowler Cloud or Prowler Local Server -1. Navigate to [Prowler Cloud](https://cloud.prowler.com/) or launch [Prowler App](/user-guide/tutorials/prowler-app) +1. Navigate to [Prowler Cloud](https://cloud.prowler.com/) or launch [Prowler Local Server](/user-guide/tutorials/prowler-app) 2. Go to "Configuration" > "Providers" ![Providers Page](/images/prowler-app/cloud-providers-page.png) diff --git a/docs/user-guide/providers/image/getting-started-image.mdx b/docs/user-guide/providers/image/getting-started-image.mdx index b9c305d0ef..6382f10e5c 100644 --- a/docs/user-guide/providers/image/getting-started-image.mdx +++ b/docs/user-guide/providers/image/getting-started-image.mdx @@ -33,7 +33,7 @@ Prowler Cloud does not support scanner selection. The vulnerability, secret, and ### Step 1: Access Prowler Cloud -1. Navigate to [Prowler Cloud](https://cloud.prowler.com/) or launch [Prowler App](/user-guide/tutorials/prowler-app) +1. Navigate to [Prowler Cloud](https://cloud.prowler.com/) or launch [Prowler Local Server](/user-guide/tutorials/prowler-app) 2. Navigate to "Configuration" > "Providers" ![Providers Page](/images/prowler-app/cloud-providers-page.png) diff --git a/docs/user-guide/providers/kubernetes/getting-started-k8s.mdx b/docs/user-guide/providers/kubernetes/getting-started-k8s.mdx index 81d482146e..c62ec2b8a1 100644 --- a/docs/user-guide/providers/kubernetes/getting-started-k8s.mdx +++ b/docs/user-guide/providers/kubernetes/getting-started-k8s.mdx @@ -4,9 +4,9 @@ title: 'Getting Started with Kubernetes' ## Prowler Cloud -### Step 1: Access Prowler Cloud/App +### Step 1: Access Prowler Cloud or Prowler Local Server -1. Navigate to [Prowler Cloud](https://cloud.prowler.com/) or launch [Prowler App](/user-guide/tutorials/prowler-app) +1. Navigate to [Prowler Cloud](https://cloud.prowler.com/) or launch [Prowler Local Server](/user-guide/tutorials/prowler-app) 2. Go to "Configuration" > "Providers" ![Providers Page](/images/prowler-app/cloud-providers-page.png) @@ -21,10 +21,10 @@ title: 'Getting Started with Kubernetes' ### Step 2: Configure Kubernetes Authentication -For Kubernetes, Prowler App uses a `kubeconfig` file to authenticate. Paste the contents of your `kubeconfig` file into the `Kubeconfig content` field. +For Kubernetes, Prowler Cloud uses a `kubeconfig` file to authenticate. Paste the contents of your `kubeconfig` file into the `Kubeconfig content` field. -Kubeconfigs that use `users[].user.exec` authentication are not supported in Prowler Cloud/App. For security reasons, Prowler Cloud does not run commands declared by uploaded kubeconfigs. Use kubeconfig credentials that do not rely on `exec` authentication, such as the ServiceAccount token flow documented below. +Kubeconfigs that use `users[].user.exec` authentication are not supported in Prowler Cloud or Prowler Local Server. For security reasons, Prowler Cloud does not run commands declared by uploaded kubeconfigs. Use kubeconfig credentials that do not rely on `exec` authentication, such as the ServiceAccount token flow documented below. By default, the `kubeconfig` file is located at `~/.kube/config`. diff --git a/docs/user-guide/providers/microsoft365/getting-started-m365.mdx b/docs/user-guide/providers/microsoft365/getting-started-m365.mdx index a21b796b5c..fd791cbbca 100644 --- a/docs/user-guide/providers/microsoft365/getting-started-m365.mdx +++ b/docs/user-guide/providers/microsoft365/getting-started-m365.mdx @@ -41,7 +41,7 @@ Set up authentication for Microsoft 365 with the [Microsoft 365 Authentication]( ### Step 2: Open Prowler Cloud -1. Go to [Prowler Cloud](https://cloud.prowler.com/) or launch [Prowler App](/user-guide/tutorials/prowler-app). +1. Go to [Prowler Cloud](https://cloud.prowler.com/) or launch [Prowler Local Server](/user-guide/tutorials/prowler-app). 2. Navigate to "Configuration" > "Providers". ![Providers Page](/images/prowler-app/cloud-providers-page.png) diff --git a/docs/user-guide/providers/mongodbatlas/getting-started-mongodbatlas.mdx b/docs/user-guide/providers/mongodbatlas/getting-started-mongodbatlas.mdx index c68bfac9c2..2fb415a11b 100644 --- a/docs/user-guide/providers/mongodbatlas/getting-started-mongodbatlas.mdx +++ b/docs/user-guide/providers/mongodbatlas/getting-started-mongodbatlas.mdx @@ -15,12 +15,12 @@ Before you begin, make sure you have: 3. An **API Key pair** (public and private keys) with appropriate permissions: - **Organization Read Only**: Provides read-only access to everything in the organization, including all projects in the organization. This permission is sufficient for most security checks. - **Organization Owner**: Required to audit the [Auditing configuration](https://www.mongodb.com/docs/api/doc/atlas-admin-api-v2/group/endpoint-auditing) for projects. Database auditing tracks database operations and security events, including authentication attempts, data definition language (DDL) changes, user and role modifications, and privilege grants. This configuration is essential for security monitoring, forensics, and compliance. Without **Organization Owner** permission, the `projects_auditing_enabled` check cannot retrieve the audit configuration status. -4. Prowler App access (cloud or self-hosted) or the Prowler CLI (`pip install prowler`). +4. Access to Prowler Cloud or Prowler Local Server, or Prowler CLI (`pip install prowler`). For detailed instructions on creating API keys, see the [MongoDB Atlas authentication guide](./authentication.mdx). -If **Require IP Access List for the Atlas Administration API** is enabled in your organization settings, you **must** add the IP address of the host running Prowler (or the public IP of Prowler Cloud) to the organization IP Access List or Atlas will reject every API call. You can manage this under **Settings → Organization Settings → Security**. See step 7 of the [authentication guide](./authentication.mdx) for detailed instructions, and refer to the [Prowler Cloud public IP list](../../tutorials/prowler-cloud-public-ips) when using Prowler Cloud. +If **Require IP Access List for the Atlas Administration API** is enabled in the organization settings, add the IP address of the host running Prowler (or the public IP of Prowler Cloud) to the organization IP Access List or Atlas will reject every API call. Manage this under **Settings → Organization Settings → Security**. See step 7 of the [authentication guide](./authentication.mdx) for detailed instructions, and refer to the [Prowler Cloud egress IPs](/security/networking) when using Prowler Cloud. @@ -53,7 +53,7 @@ If **Require IP Access List for the Atlas Administration API** is enabled in you ### Step 3: Test the connection and start scanning -1. Click **Test connection** to ensure Prowler App can reach the Atlas API. +1. Click **Test connection** to ensure Prowler Cloud can reach the Atlas API. 2. Save the credentials. The provider will appear in the list with its current connection status. 3. Launch a scan from the provider row or from the **Scans** page. ![Launch scan](./img/launch-scan.png) @@ -108,6 +108,6 @@ prowler mongodbatlas --atlas-project-id - Combine flags (for example, `--checks` or `--services`) just like with other providers. - Use `--output-modes` to export findings in JSON, CSV, ASFF, etc. -- Rotate API keys regularly and update the stored credentials in Prowler App to maintain connectivity. +- Rotate API keys regularly and update the stored credentials in Prowler Cloud to maintain connectivity. For more examples (filters, outputs, scheduling), refer back to the [MongoDB Atlas documentation hub](./authentication.mdx) and the main Prowler CLI usage guide. diff --git a/docs/user-guide/providers/oci/getting-started-oci.mdx b/docs/user-guide/providers/oci/getting-started-oci.mdx index 1e4b63cdb7..2f610cd09c 100644 --- a/docs/user-guide/providers/oci/getting-started-oci.mdx +++ b/docs/user-guide/providers/oci/getting-started-oci.mdx @@ -6,7 +6,7 @@ Prowler supports security scanning of Oracle Cloud Infrastructure (OCI) environm ## Prowler Cloud -The following steps apply to Prowler Cloud and the self-hosted Prowler App. +The following steps apply to Prowler Cloud and Prowler Local Server. ### Step 1: Collect OCI Identifiers 1. Sign in to the [OCI Console](https://cloud.oracle.com/) and open **Tenancy Details** to copy the Tenancy OCID. @@ -15,14 +15,14 @@ The following steps apply to Prowler Cloud and the self-hosted Prowler App. 4. Note the **Region** identifier to scan (for example, `us-ashburn-1`). ### Step 2: Access Prowler Cloud -1. Navigate to [Prowler Cloud](https://cloud.prowler.com/) or launch [Prowler App](/user-guide/tutorials/prowler-app). +1. Navigate to [Prowler Cloud](https://cloud.prowler.com/) or launch [Prowler Local Server](/user-guide/tutorials/prowler-app). 2. Go to **Configuration** → **Providers** and click **Add Provider**. ![Add OCI Provider](./images/oci-add-cloud-provider.png) 3. Select **Oracle Cloud** and enter the **Tenancy OCID** and an optional alias, then choose **Next**. ![Add OCI Cloud Tenancy](./images/oci-add-tenancy.png) ### Step 3: Add OCI API Key Credentials -Prowler App connects to OCI with API key credentials. Provide: +Prowler Cloud connects to OCI with API key credentials. Provide: - **User OCID** for the API key owner - **Fingerprint** of the API key diff --git a/docs/user-guide/providers/okta/authentication.mdx b/docs/user-guide/providers/okta/authentication.mdx index 2e08cac8af..8f664160a2 100644 --- a/docs/user-guide/providers/okta/authentication.mdx +++ b/docs/user-guide/providers/okta/authentication.mdx @@ -20,7 +20,7 @@ Prowler authenticates to Okta as a **service application** using **OAuth 2.0 wit | Method | Status | Use Case | |---|---|---| -| **OAuth 2.0 (private-key JWT)** | Supported | Production scans, CI/CD, Prowler App. | +| **OAuth 2.0 (private-key JWT)** | Supported | Production scans, CI/CD, Prowler Cloud. | The private-key JWT flow is the only supported authentication method in the initial release. The service application proves possession of a private key on every token request; Okta returns a short-lived access token, refreshed automatically by the SDK. diff --git a/docs/user-guide/providers/okta/getting-started-okta.mdx b/docs/user-guide/providers/okta/getting-started-okta.mdx index e04e0d4a13..dbe63807d7 100644 --- a/docs/user-guide/providers/okta/getting-started-okta.mdx +++ b/docs/user-guide/providers/okta/getting-started-okta.mdx @@ -30,7 +30,7 @@ Set up authentication for Okta with the [Okta Authentication](/user-guide/provid ### Step 1: Add the Provider -1. Go to [Prowler Cloud](https://cloud.prowler.com/) or launch [Prowler App](/user-guide/tutorials/prowler-app). +1. Go to [Prowler Cloud](https://cloud.prowler.com/) or launch [Prowler Local Server](/user-guide/tutorials/prowler-app). 2. Navigate to "Configuration" > "Providers". ![Providers Page](/images/prowler-app/cloud-providers-page.png) diff --git a/docs/user-guide/providers/vercel/getting-started-vercel.mdx b/docs/user-guide/providers/vercel/getting-started-vercel.mdx index c39c5f1e6a..da02019b6f 100644 --- a/docs/user-guide/providers/vercel/getting-started-vercel.mdx +++ b/docs/user-guide/providers/vercel/getting-started-vercel.mdx @@ -28,7 +28,7 @@ Set up authentication for Vercel with the [Vercel Authentication](/user-guide/pr ### Step 1: Add the Provider -1. Go to [Prowler Cloud](https://cloud.prowler.com/) or launch [Prowler App](/user-guide/tutorials/prowler-app). +1. Go to [Prowler Cloud](https://cloud.prowler.com/) or launch [Prowler Local Server](/user-guide/tutorials/prowler-app). 2. Navigate to "Configuration" > "Providers". ![Providers Page](/images/prowler-app/cloud-providers-page.png) diff --git a/docs/user-guide/tutorials/aws-organizations-bulk-provisioning.mdx b/docs/user-guide/tutorials/aws-organizations-bulk-provisioning.mdx index 88a084dd17..2e8b5eb62e 100644 --- a/docs/user-guide/tutorials/aws-organizations-bulk-provisioning.mdx +++ b/docs/user-guide/tutorials/aws-organizations-bulk-provisioning.mdx @@ -9,9 +9,9 @@ The tool, `aws_org_generator.py`‎, complements the [Bulk Provider Provisioning **Native AWS Organizations support is now available in Prowler Cloud.** You can onboard all accounts via the UI wizard — with automatic discovery, hierarchical tree selection, connection testing, and bulk scan launch — without any scripts or YAML files. -See [AWS Organizations in Prowler Cloud](/user-guide/tutorials/prowler-cloud-aws-organizations). +See [AWS Organizations](/user-guide/tutorials/prowler-cloud-aws-organizations) in Prowler Cloud. -The CLI-based tool below remains useful for self-hosted Prowler App and advanced automation scenarios. +The CLI-based tool below remains useful for Prowler Local Server and advanced automation scenarios. {/* TODO: Add screenshot of the tool in action */} @@ -32,9 +32,9 @@ The AWS Organizations Bulk Provisioning tool simplifies multi-account onboarding * Python 3.7 or higher * AWS credentials with Organizations read access * ProwlerRole (or custom role) deployed across all target accounts -* Prowler API key (from Prowler Cloud or self-hosted Prowler App) - * For self-hosted Prowler App, remember to [point to your API base URL](./bulk-provider-provisioning#custom-api-endpoints) - * Learn how to create API keys: [Prowler App API Keys](../tutorials/prowler-app-api-keys) +* Prowler API key (from Prowler Cloud or Prowler Local Server) + * For Prowler Local Server, remember to [point to your API base URL](./bulk-provider-provisioning#custom-api-endpoints) + * Learn how to create API keys: [Prowler Cloud API Keys](../tutorials/prowler-app-api-keys) ### Deploying ProwlerRole Across AWS Organizations @@ -97,13 +97,13 @@ export PROWLER_API_KEY="pk_example-api-key" To create an API key: -1. Log in to Prowler Cloud or Prowler App +1. Log in to Prowler Cloud or Prowler Local Server 2. Click **Profile** → **Account** 3. Click **Create API Key** 4. Provide a descriptive name and optionally set an expiration date 5. Copy the generated API key (it will only be shown once) -For detailed instructions, see: [Prowler App API Keys](../tutorials/prowler-app-api-keys) +For detailed instructions, see: [Prowler Cloud API Keys](../tutorials/prowler-app-api-keys) ## Basic Usage @@ -324,7 +324,7 @@ python aws_org_generator.py \ - Provision all accounts to Prowler Cloud or Prowler App: + Provision all accounts to Prowler Cloud or Prowler Local Server: ```bash # Set Prowler API key @@ -487,7 +487,7 @@ grep "provider: aws" aws-org-accounts.yaml | wc -l Learn how to bulk provision providers in Prowler. - + Detailed instructions on how to use Prowler. diff --git a/docs/user-guide/tutorials/bulk-provider-provisioning.mdx b/docs/user-guide/tutorials/bulk-provider-provisioning.mdx index 3ad5d9c0d5..cc759b6f85 100644 --- a/docs/user-guide/tutorials/bulk-provider-provisioning.mdx +++ b/docs/user-guide/tutorials/bulk-provider-provisioning.mdx @@ -10,7 +10,7 @@ The tool is available in the Prowler repository at: [util/prowler-bulk-provision ## Overview -The Bulk Provider Provisioning tool automates the creation of cloud providers in Prowler App or Prowler Cloud by: +The Bulk Provider Provisioning tool automates the creation of cloud providers in Prowler Cloud or Prowler Local Server by: * Reading provider configurations from YAML files * Creating providers with appropriate authentication credentials @@ -26,9 +26,9 @@ The Bulk Provider Provisioning tool automates the creation of cloud providers in ### Requirements * Python 3.7 or higher -* Prowler API key (from Prowler Cloud or self-hosted Prowler App) - * For self-hosted Prowler App, remember to [point to your API base URL](#custom-api-endpoints) - * Learn how to create API keys: [Prowler App API Keys](../tutorials/prowler-app-api-keys) +* Prowler API key (from Prowler Cloud or Prowler Local Server) + * For Prowler Local Server, remember to [point to your API base URL](#custom-api-endpoints) + * Learn how to create API keys: [Prowler Cloud API Keys](../tutorials/prowler-app-api-keys) * Authentication credentials for target cloud providers ### Installation @@ -51,13 +51,13 @@ export PROWLER_API_KEY="pk_example-api-key" To create an API key: -1. Log in to Prowler Cloud or Prowler App +1. Log in to Prowler Cloud or Prowler Local Server 2. Click **Profile** → **Account** 3. Click **Create API Key** 4. Provide a descriptive name and optionally set an expiration date 5. Copy the generated API key (it will only be shown once) -For detailed instructions, see: [Prowler App API Keys](../tutorials/prowler-app-api-keys) +For detailed instructions, see: [Prowler Cloud API Keys](../tutorials/prowler-app-api-keys) ## Configuration File Structure @@ -261,7 +261,7 @@ python prowler_bulk_provisioning.py providers.yaml --concurrency 10 ### Custom API Endpoints -For self-hosted Prowler App installations: +For Prowler Local Server installations: ```bash python prowler_bulk_provisioning.py providers.yaml \ diff --git a/docs/user-guide/tutorials/prowler-alerts.mdx b/docs/user-guide/tutorials/prowler-alerts.mdx index 9f01a77910..fc205b72be 100644 --- a/docs/user-guide/tutorials/prowler-alerts.mdx +++ b/docs/user-guide/tutorials/prowler-alerts.mdx @@ -1,5 +1,6 @@ --- title: 'Alerts' +sidebarTitle: 'Alerts' description: 'Create email alerts from Prowler Cloud findings to monitor relevant security changes after scans or in daily digests.' --- diff --git a/docs/user-guide/tutorials/prowler-app-api-keys.mdx b/docs/user-guide/tutorials/prowler-app-api-keys.mdx index 4e51e62bac..c7d5f86ec4 100644 --- a/docs/user-guide/tutorials/prowler-app-api-keys.mdx +++ b/docs/user-guide/tutorials/prowler-app-api-keys.mdx @@ -3,14 +3,17 @@ title: 'API Keys' --- import { VersionBadge } from "/snippets/version-badge.mdx" +import { AppliesTo } from "/snippets/applies-to.mdx" -API key authentication in Prowler App provides an alternative to JWT tokens and empowers automation, CI/CD pipelines, and third-party integrations. This guide explains how to create, manage, and safeguard API keys when working with the Prowler API. + + +API key authentication in Prowler Cloud provides an alternative to JWT tokens and empowers automation, CI/CD pipelines, and third-party integrations. This guide explains how to create, manage, and safeguard API keys when working with the Prowler API. ## API Key Advantages -- **Programmatic access:** Enables automated workflows and scripts to interact with Prowler App. +- **Programmatic access:** Enables automated workflows and scripts to interact with Prowler Cloud. - **Long-lived authentication:** Allows optional expiration dates, with a default of 1 year. - **Granular control:** Supports multiple keys with distinct names and purposes. - **Secure automation:** Simplifies safe integration into CI/CD pipelines and infrastructure-as-code tooling. @@ -19,7 +22,7 @@ API key authentication in Prowler App provides an alternative to JWT tokens and API keys provide a secure authentication mechanism for accessing the Prowler API: -1. API keys are created through Prowler App with a user-defined name and optional expiration date. +1. API keys are created through Prowler Cloud with a user-defined name and optional expiration date. 2. The full API key appears only once upon creation and cannot be retrieved later. 3. Each API key consists of a prefix (visible in the interface) and an encrypted secret portion. 4. Requests include the API key in the header as `Authorization: Api-Key `. @@ -63,13 +66,13 @@ Creating, viewing, or managing API keys requires the **MANAGE_ACCOUNT** RBAC per Without this permission, the API Keys section remains hidden. Access requests should be routed through the tenant administrator. -For more information about RBAC permissions, refer to the [Prowler App RBAC documentation](/user-guide/tutorials/prowler-app-rbac). +For more information about RBAC permissions, refer to the [Prowler Cloud RBAC documentation](/user-guide/tutorials/prowler-app-rbac). ## Creating API Keys -Follow these steps to create an API key in Prowler App: +Follow these steps to create an API key in Prowler Cloud: -1. Navigate to **Profile** → **Account** in Prowler App. +1. Navigate to **Profile** → **Account** in Prowler Cloud. 2. Select the **Create API Key** button. ![API Keys list](/images/cli/api-keys/list.png) @@ -206,7 +209,7 @@ When using API keys in CI/CD pipelines: * Ensure the key has not been revoked by checking the Revoked column in the API Keys list. * Confirm that the key has not expired by reviewing the expiration date. * Confirm that the correct API key format is in use, including both prefix and secret portions. -* Verify that the key prefix matches what is displayed in Prowler App. +* Verify that the key prefix matches what is displayed in Prowler Cloud. ### API Key Not Working After Creation diff --git a/docs/user-guide/tutorials/prowler-app-attack-paths.mdx b/docs/user-guide/tutorials/prowler-app-attack-paths.mdx index b07aebcc4f..32df3c50ba 100644 --- a/docs/user-guide/tutorials/prowler-app-attack-paths.mdx +++ b/docs/user-guide/tutorials/prowler-app-attack-paths.mdx @@ -4,9 +4,12 @@ description: "Identify privilege escalation chains and security misconfiguration --- import { VersionBadge } from "/snippets/version-badge.mdx"; +import { AppliesTo } from "/snippets/applies-to.mdx"; + + Attack Paths analyzes relationships between cloud resources, permissions, and security findings to detect how privileges can be escalated and how misconfigurations can be exploited by threat actors. By mapping these relationships as a graph, Attack Paths reveals risks that individual security checks cannot detect on their own, such as an IAM role that can escalate its own permissions, or a chain of policies that grants unintended access to sensitive resources. @@ -20,7 +23,7 @@ By mapping these relationships as a graph, Attack Paths reveals risks that indiv The following prerequisites are required for Attack Paths: -- **An AWS provider is configured** with valid credentials in Prowler App. For setup instructions, see [Getting Started with AWS](/user-guide/providers/aws/getting-started-aws). +- **An AWS provider is configured** with valid credentials in Prowler Cloud. For setup instructions, see [Getting Started with AWS](/user-guide/providers/aws/getting-started-aws). - **At least one scan has completed** on the configured AWS provider and produced graph data. Attack Paths scans run automatically alongside regular security scans, no separate configuration is required. ## How Attack Paths Scans Work diff --git a/docs/user-guide/tutorials/prowler-app-finding-groups.mdx b/docs/user-guide/tutorials/prowler-app-finding-groups.mdx index e1bf83a50f..c5234bb41f 100644 --- a/docs/user-guide/tutorials/prowler-app-finding-groups.mdx +++ b/docs/user-guide/tutorials/prowler-app-finding-groups.mdx @@ -1,12 +1,16 @@ --- title: 'Finding Groups' +sidebarTitle: 'Groups' description: 'Organize and triage security findings by check to reduce noise and prioritize remediation effectively.' --- import { VersionBadge } from "/snippets/version-badge.mdx" +import { AppliesTo } from "/snippets/applies-to.mdx" + + Finding Groups transforms security findings triage by grouping them by check instead of displaying a flat list. This dramatically reduces noise and enables faster, more effective prioritization. ## Triage Challenges with Flat Finding Lists @@ -112,7 +116,7 @@ This provides full context without leaving the drawer. ## Getting Started -1. Navigate to the **Findings** section in Prowler Cloud/App. +1. Navigate to the **Findings** section in Prowler Cloud. 2. Toggle to the **Grouped View** to see findings organized by check. 3. Select any group row to expand and see affected resources. 4. Select a resource to open the detail drawer with full context. diff --git a/docs/user-guide/tutorials/prowler-app-findings-triage.mdx b/docs/user-guide/tutorials/prowler-app-findings-triage.mdx index 68b1af5a32..bbceaef992 100644 --- a/docs/user-guide/tutorials/prowler-app-findings-triage.mdx +++ b/docs/user-guide/tutorials/prowler-app-findings-triage.mdx @@ -1,5 +1,6 @@ --- title: "Findings Triage" +sidebarTitle: 'Triage' description: "Track finding review status and team notes in Prowler Cloud." --- @@ -113,7 +114,7 @@ Make sure the row is an individual finding row. Finding Groups rows do not show ### Changes cannot be saved -Confirm that the user role has **Manage Scans** permission. Self-hosted Prowler App does not support Findings Triage writes. +Confirm that the user role has **Manage Scans** permission. Prowler Local Server does not support Findings Triage writes. ### Resolved or Reopened is missing from the selector diff --git a/docs/user-guide/tutorials/prowler-app-jira-integration.mdx b/docs/user-guide/tutorials/prowler-app-jira-integration.mdx index 01779a6a96..83554ee8c0 100644 --- a/docs/user-guide/tutorials/prowler-app-jira-integration.mdx +++ b/docs/user-guide/tutorials/prowler-app-jira-integration.mdx @@ -1,13 +1,17 @@ --- title: "Jira Integration" +sidebarTitle: 'Jira' --- import { VersionBadge } from "/snippets/version-badge.mdx" +import { AppliesTo } from "/snippets/applies-to.mdx" -Prowler App enables automatic export of security findings to Jira, providing seamless integration with Atlassian's work item tracking and project management platform. This comprehensive guide demonstrates how to configure and manage Jira integrations to streamline security incident management and enhance team collaboration across security workflows. + -Integrating Prowler App with Jira provides: +Prowler Cloud enables automatic export of security findings to Jira, providing seamless integration with Atlassian's work item tracking and project management platform. This comprehensive guide demonstrates how to configure and manage Jira integrations to streamline security incident management and enhance team collaboration across security workflows. + +Integrating Prowler Cloud with Jira provides: * **Streamlined management:** Convert security findings directly into actionable Jira work items * **Enhanced team collaboration:** Leverage existing project management workflows for security remediation @@ -22,9 +26,9 @@ When enabled and configured: ## Configuration -To configure Jira integration in Prowler App: +To configure Jira integration in Prowler Cloud: -1. Navigate to **Integrations** in the Prowler App interface +1. Navigate to **Integrations** in Prowler Cloud 2. Locate the **Jira** card and click **Manage**, then select **Add integration** ![Integrations tab](/images/prowler-app/jira/integrations-tab.png) @@ -50,7 +54,7 @@ Once configured successfully, the integration is ready to send findings to Jira. To manually send individual findings to Jira: -1. Navigate to the **Findings** section in Prowler App +1. Navigate to the **Findings** section in Prowler Cloud 2. Select one finding you want to export 3. Click the action button on the table row and select **Send to Jira** 4. Select the Jira integration and project diff --git a/docs/user-guide/tutorials/prowler-app-lighthouse-multi-llm.mdx b/docs/user-guide/tutorials/prowler-app-lighthouse-multi-llm.mdx index b1cb918de7..eee7564bcb 100644 --- a/docs/user-guide/tutorials/prowler-app-lighthouse-multi-llm.mdx +++ b/docs/user-guide/tutorials/prowler-app-lighthouse-multi-llm.mdx @@ -1,5 +1,6 @@ --- title: 'Using Multiple LLM Providers with Lighthouse' +sidebarTitle: 'Multiple LLM Providers' --- import { VersionBadge } from "/snippets/version-badge.mdx" diff --git a/docs/user-guide/tutorials/prowler-app-lighthouse.mdx b/docs/user-guide/tutorials/prowler-app-lighthouse.mdx index 0930be8aa8..5b4a0f5847 100644 --- a/docs/user-guide/tutorials/prowler-app-lighthouse.mdx +++ b/docs/user-guide/tutorials/prowler-app-lighthouse.mdx @@ -19,7 +19,7 @@ Behind the scenes, Lighthouse AI works as follows: - The agent accesses Prowler data through [Prowler MCP](https://docs.prowler.com/getting-started/products/prowler-mcp), which exposes tools from multiple sources, including: - Prowler Hub - Prowler Docs - - Prowler App + - Prowler Local Server - Instead of calling every tool directly, the agent uses two meta-tools: - `describe_tool` to retrieve a tool schema and parameter requirements. - `execute_tool` to run the selected tool with the required input. diff --git a/docs/user-guide/tutorials/prowler-app-multi-tenant.mdx b/docs/user-guide/tutorials/prowler-app-multi-tenant.mdx index b353bc85c5..8de9365fe9 100644 --- a/docs/user-guide/tutorials/prowler-app-multi-tenant.mdx +++ b/docs/user-guide/tutorials/prowler-app-multi-tenant.mdx @@ -1,12 +1,16 @@ --- title: 'Managing Organizations (Multi-Tenant)' +sidebarTitle: 'Organizations' --- import { VersionBadge } from "/snippets/version-badge.mdx" +import { AppliesTo } from "/snippets/applies-to.mdx" -Prowler App supports multi-tenancy through **Organizations**, allowing users to belong to multiple isolated environments within a single account. Each organization maintains its own providers, scans, findings, and user memberships, ensuring complete data separation between teams or business units. + + +Prowler Cloud supports multi-tenancy through **Organizations**, allowing users to belong to multiple isolated environments within a single account. Each organization maintains its own providers, scans, findings, and user memberships, ensuring complete data separation between teams or business units. ## Key Concepts @@ -128,7 +132,7 @@ When invited to join an organization, the invited user receives a link to accept 1. Open the invitation link. -2. If already authenticated, the invitation is accepted automatically and the user is redirected to Prowler App. +2. If already authenticated, the invitation is accepted automatically and the user is redirected to Prowler Cloud. 3. If not authenticated, choose **I have an account -- Sign in**, authenticate with existing credentials, and the invitation is accepted upon sign-in. diff --git a/docs/user-guide/tutorials/prowler-app-mute-findings.mdx b/docs/user-guide/tutorials/prowler-app-mute-findings.mdx index 82623b54cf..c2ada47e9c 100644 --- a/docs/user-guide/tutorials/prowler-app-mute-findings.mdx +++ b/docs/user-guide/tutorials/prowler-app-mute-findings.mdx @@ -1,11 +1,14 @@ --- -title: 'Advanced Mutelist (YAML)' +title: 'Advanced Mutelist' --- import { VersionBadge } from "/snippets/version-badge.mdx" +import { AppliesTo } from "/snippets/applies-to.mdx" -Prowler App allows users to mute specific findings to focus on the most critical security issues. This guide demonstrates how to use the Advanced Mutelist feature with YAML configuration for complex, pattern-based muting rules. + + +Prowler Cloud allows users to mute specific findings to focus on the most critical security issues. This guide demonstrates how to use the Advanced Mutelist feature with YAML configuration for complex, pattern-based muting rules. For muting individual findings without YAML configuration, use [Simple Mutelist](/user-guide/tutorials/prowler-app-simple-mutelist) to mute findings directly from the Findings table. @@ -31,8 +34,8 @@ Advanced Mutelist requires the **Manage Account** permission. See [RBAC Administ Before muting findings, ensure: -- Valid access to Prowler App with appropriate permissions -- A provider added to the Prowler App +- Valid access to Prowler Cloud with appropriate permissions +- A provider added to Prowler Cloud - Understanding of the security implications of muting specific findings @@ -43,7 +46,7 @@ Muting findings does not resolve underlying security issues. Review each finding To configure Advanced Mutelist: -1. Log into Prowler App +1. Log into Prowler Cloud 2. Navigate to the Providers page ![Add provider](/images/mutelist-ui-1.png) 3. Connect a provider to enable Mutelist configuration @@ -423,7 +426,7 @@ Mutelist: ### Priority: Advanced vs. Simple Mutelist -When both Advanced Mutelist (YAML) and [Simple Mutelist](/user-guide/tutorials/prowler-app-simple-mutelist) rules match the same finding, the **Advanced Mutelist takes higher priority**. The finding will be muted with the reason "Muted by mutelist". If a finding is not matched by the Advanced Mutelist but matches a Simple Mutelist rule, the Simple rule's custom justification is used instead. +When both Advanced Mutelist and [Simple Mutelist](/user-guide/tutorials/prowler-app-simple-mutelist) rules match the same finding, the **Advanced Mutelist takes higher priority**. The finding will be muted with the reason "Muted by mutelist". If a finding is not matched by the Advanced Mutelist but matches a Simple Mutelist rule, the Simple rule's custom justification is used instead. ### Best Practices @@ -436,7 +439,7 @@ When both Advanced Mutelist (YAML) and [Simple Mutelist](/user-guide/tutorials/p ### Validation -Prowler App validates your mutelist configuration and will display errors for: +Prowler Cloud validates the mutelist configuration and will display errors for: - Invalid YAML syntax - Missing required fields diff --git a/docs/user-guide/tutorials/prowler-app-rbac.mdx b/docs/user-guide/tutorials/prowler-app-rbac.mdx index 0cd22f20e7..4533e0423c 100644 --- a/docs/user-guide/tutorials/prowler-app-rbac.mdx +++ b/docs/user-guide/tutorials/prowler-app-rbac.mdx @@ -1,12 +1,16 @@ --- title: 'Managing Users and Role-Based Access Control (RBAC)' +sidebarTitle: 'Users & RBAC' --- import { VersionBadge } from "/snippets/version-badge.mdx" +import { AppliesTo } from "/snippets/applies-to.mdx" -**Prowler App** supports multiple users within a single tenant, enabling seamless collaboration by allowing team members to easily share insights and manage security findings. + + +**Prowler Cloud** supports multiple users within a single tenant, enabling seamless collaboration by allowing team members to easily share insights and manage security findings. [Roles](#roles) help you control user permissions, determining what actions each user can perform and the data they can access within Prowler. By default, each account includes an immutable **admin** role, ensuring that your account always retains administrative access. diff --git a/docs/user-guide/tutorials/prowler-app-s3-integration.mdx b/docs/user-guide/tutorials/prowler-app-s3-integration.mdx index 97987f1f74..9b7d7a9a2e 100644 --- a/docs/user-guide/tutorials/prowler-app-s3-integration.mdx +++ b/docs/user-guide/tutorials/prowler-app-s3-integration.mdx @@ -1,12 +1,16 @@ --- title: 'Amazon S3 Integration' +sidebarTitle: 'Amazon S3' --- import { VersionBadge } from "/snippets/version-badge.mdx" +import { AppliesTo } from "/snippets/applies-to.mdx" -**Prowler App** allows automatic export of scan results to Amazon S3 buckets, providing seamless integration with existing data workflows and storage infrastructure. This comprehensive guide demonstrates configuration and management of Amazon S3 integrations to streamline security finding management and reporting. + + +**Prowler Cloud** allows automatic export of scan results to Amazon S3 buckets, providing seamless integration with existing data workflows and storage infrastructure. This comprehensive guide demonstrates configuration and management of Amazon S3 integrations to streamline security finding management and reporting. When enabled and configured, scan results are automatically stored in the configured bucket. Results are provided in `csv`, `html` and `json-ocsf` formats, offering flexibility for custom integrations: @@ -201,7 +205,7 @@ Replace `` with the AWS account ID that contains the IAM role ### Available Templates -**Prowler App** provides Infrastructure as Code (IaC) templates to automate IAM role setup with S3 integration permissions. +**Prowler Cloud** provides Infrastructure as Code (IaC) templates to automate IAM role setup with S3 integration permissions. Templates are optional. Custom IAM roles or static credentials can be used instead. @@ -278,7 +282,7 @@ If using Prowler's CloudFormation template, execute the following command to upd 3. Edit `terraform.tfvars` with your specific values: ```hcl - # Required: External ID from Prowler App + # Required: External ID from Prowler Cloud external_id = "your-unique-external-id-here" # S3 Integration Configuration @@ -310,7 +314,7 @@ For detailed information, refer to the [Terraform README](https://github.com/pro ## Configuration -Once the required permissions are set up, proceed to configure the S3 integration in **Prowler App**. +Once the required permissions are set up, proceed to configure the S3 integration in **Prowler Cloud**. 1. Navigate to "Integrations" ![Navigate to integrations](/images/prowler-app/s3/s3-integration-ui-1.png) diff --git a/docs/user-guide/tutorials/prowler-app-scan-configuration.mdx b/docs/user-guide/tutorials/prowler-app-scan-configuration.mdx index 7f87eb53bf..8d50f0072c 100644 --- a/docs/user-guide/tutorials/prowler-app-scan-configuration.mdx +++ b/docs/user-guide/tutorials/prowler-app-scan-configuration.mdx @@ -1,5 +1,6 @@ --- title: 'Scan Configuration' +sidebarTitle: 'Configuration' --- import { VersionBadge } from "/snippets/version-badge.mdx" diff --git a/docs/user-guide/tutorials/prowler-app-security-hub-integration.mdx b/docs/user-guide/tutorials/prowler-app-security-hub-integration.mdx index 592ff2b26d..977ad8e6f1 100644 --- a/docs/user-guide/tutorials/prowler-app-security-hub-integration.mdx +++ b/docs/user-guide/tutorials/prowler-app-security-hub-integration.mdx @@ -1,13 +1,17 @@ --- title: "AWS Security Hub Integration" +sidebarTitle: 'AWS Security Hub' --- import { VersionBadge } from "/snippets/version-badge.mdx" +import { AppliesTo } from "/snippets/applies-to.mdx" -Prowler App enables automatic export of security findings to AWS Security Hub, providing seamless integration with AWS's native security and compliance service. This comprehensive guide demonstrates how to configure and manage AWS Security Hub integrations to centralize security findings and enhance compliance tracking across AWS environments. + -Integrating Prowler App with AWS Security Hub provides: +Prowler Cloud enables automatic export of security findings to AWS Security Hub, providing seamless integration with AWS's native security and compliance service. This comprehensive guide demonstrates how to configure and manage AWS Security Hub integrations to centralize security findings and enhance compliance tracking across AWS environments. + +Integrating Prowler Cloud with AWS Security Hub provides: * **Centralized security visibility:** Consolidate findings from multiple AWS accounts and regions * **Native AWS integration:** Leverage existing AWS security workflows and compliance frameworks @@ -30,7 +34,7 @@ Refer to [AWS Security Hub pricing](https://aws.amazon.com/security-hub/pricing/ ## Prerequisites -Before configuring AWS Security Hub Integration in Prowler App, complete these steps: +Before configuring AWS Security Hub Integration in Prowler Cloud, complete these steps: ### AWS Security Hub Setup @@ -42,9 +46,9 @@ Configure AWS credentials by following the [AWS authentication setup guide](/use ## Configuration -To configure AWS Security Hub integration in Prowler App: +To configure AWS Security Hub integration in Prowler Cloud: -1. Navigate to **Integrations** in the Prowler App interface +1. Navigate to **Integrations** in Prowler Cloud 2. Locate the **AWS Security Hub** card and click **Manage**, then select **Add integration** ![Integrations tab](/images/prowler-app/security-hub/integrations-tab.png) diff --git a/docs/user-guide/tutorials/prowler-app-simple-mutelist.mdx b/docs/user-guide/tutorials/prowler-app-simple-mutelist.mdx index c0bd7db9b4..4b0d59e24a 100644 --- a/docs/user-guide/tutorials/prowler-app-simple-mutelist.mdx +++ b/docs/user-guide/tutorials/prowler-app-simple-mutelist.mdx @@ -3,10 +3,13 @@ title: "Simple Mutelist" --- import { VersionBadge } from "/snippets/version-badge.mdx"; +import { AppliesTo } from "/snippets/applies-to.mdx"; -Prowler App provides Simple Mutelist, an intuitive way to mute findings directly from the Findings page without writing YAML configuration. This feature streamlines the muting workflow by allowing individual or bulk muting with just a few clicks. + + +Prowler Cloud provides Simple Mutelist, an intuitive way to mute findings directly from the Findings page without writing YAML configuration. This feature streamlines the muting workflow by allowing individual or bulk muting with just a few clicks. ## What Is Simple Mutelist? diff --git a/docs/user-guide/tutorials/prowler-app-social-login.mdx b/docs/user-guide/tutorials/prowler-app-social-login.mdx index 45fe8ec62f..62de6fbc9c 100644 --- a/docs/user-guide/tutorials/prowler-app-social-login.mdx +++ b/docs/user-guide/tutorials/prowler-app-social-login.mdx @@ -1,12 +1,16 @@ --- title: 'Social Login Configuration' +sidebarTitle: 'Social Login' --- import { VersionBadge } from "/snippets/version-badge.mdx" +import { AppliesTo } from "/snippets/applies-to.mdx" -**Prowler App** supports social login using Google and GitHub OAuth providers. This document guides you through configuring the required environment variables to enable social authentication. + + +Prowler supports social login using Google and GitHub OAuth providers. In **Prowler Cloud** social login is available out of the box. In **Prowler Local Server**, enable it by configuring the environment variables described in this guide. Social login buttons ## Configuring Social Login Credentials diff --git a/docs/user-guide/tutorials/prowler-app-sso-entra.mdx b/docs/user-guide/tutorials/prowler-app-sso-entra.mdx index 37f3bbdda5..07cf4709be 100644 --- a/docs/user-guide/tutorials/prowler-app-sso-entra.mdx +++ b/docs/user-guide/tutorials/prowler-app-sso-entra.mdx @@ -2,7 +2,11 @@ title: 'Entra ID Configuration' --- -This page provides instructions for creating and configuring a Microsoft Entra ID (formerly Azure AD) application to use SAML SSO with Prowler App. +import { AppliesTo } from "/snippets/applies-to.mdx" + + + +This page provides instructions for creating and configuring a Microsoft Entra ID (formerly Azure AD) application to use SAML SSO with Prowler Cloud. You can find a walkthrough video [here](https://www.youtube.com/watch?v=zegqm55oJVk). @@ -28,7 +32,7 @@ You can find a walkthrough video [here](https://www.youtube.com/watch?v=zegqm55o ![Edit](/images/prowler-app/saml/saml-sso-azure-5.png) -6. Enter the "Identifier (Entity ID)" and "Reply URL (Assertion Consumer Service URL)". These values can be obtained from the SAML SSO integration setup in Prowler App. For detailed instructions, refer to the [SAML SSO Configuration](/user-guide/tutorials/prowler-app-sso) page. +6. Enter the "Identifier (Entity ID)" and "Reply URL (Assertion Consumer Service URL)". These values can be obtained from the SAML SSO integration setup in Prowler Cloud. For detailed instructions, refer to the [SAML SSO Configuration](/user-guide/tutorials/prowler-app-sso) page. ![Enter data](/images/prowler-app/saml/saml-sso-azure-6.png) @@ -44,4 +48,4 @@ You can find a walkthrough video [here](https://www.youtube.com/watch?v=zegqm55o ![Metadata XML](/images/prowler-app/saml/saml-sso-azure-9.png) -10. Save the downloaded Metadata XML to a file. To complete the setup, upload this file during the Prowler App integration. (See the [SAML SSO Configuration](/user-guide/tutorials/prowler-app-sso) page for details). +10. Save the downloaded Metadata XML to a file. To complete the setup, upload this file during the SAML SSO integration setup in Prowler Cloud. (See the [SAML SSO Configuration](/user-guide/tutorials/prowler-app-sso) page for details). diff --git a/docs/user-guide/tutorials/prowler-app-sso-google-workspace.mdx b/docs/user-guide/tutorials/prowler-app-sso-google-workspace.mdx index 2f10d443ae..ce0e5edc79 100644 --- a/docs/user-guide/tutorials/prowler-app-sso-google-workspace.mdx +++ b/docs/user-guide/tutorials/prowler-app-sso-google-workspace.mdx @@ -2,20 +2,24 @@ title: 'SAML SSO: Google Workspace' --- -This page explains how to configure SAML-based Single Sign-On (SSO) in Prowler App using **Google Workspace** as the Identity Provider (IdP). The setup is divided into two parts: create a custom SAML app in Google Admin Console, then complete the configuration in Prowler App. +import { AppliesTo } from "/snippets/applies-to.mdx" + + + +This page explains how to configure SAML-based Single Sign-On (SSO) in Prowler Cloud using **Google Workspace** as the Identity Provider (IdP). The setup is divided into two parts: create a custom SAML app in Google Admin Console, then complete the configuration in Prowler Cloud. **Parallel Setup Required** -Google Admin Console requires the ACS URL and Entity ID from Prowler App, while Prowler App displays these values only after opening the SAML configuration dialog. To work around this, open Prowler App in a separate browser tab, navigate to the profile page, open the "Configure SAML SSO" dialog, and copy the ACS URL and Entity ID before proceeding with the Google configuration. +Google Admin Console requires the ACS URL and Entity ID from Prowler Cloud, while Prowler Cloud displays these values only after opening the SAML configuration dialog. To work around this, open Prowler Cloud in a separate browser tab, navigate to the profile page, open the "Configure SAML SSO" dialog, and copy the ACS URL and Entity ID before proceeding with the Google configuration. ## Prerequisites - **Google Workspace**: Super Admin access (or delegated admin with app management permissions). -- **Prowler App**: Administrator access to the organization (role with "Manage Account" permission). -- Prowler App version **5.9.0** or later. +- **Prowler Cloud**: Administrator access to the organization (role with "Manage Account" permission). +- Prowler version **5.9.0** or later. --- @@ -44,7 +48,7 @@ On the **Google Identity Provider details** screen: 1. Google displays two options: - **Option 1**: Click "Download Metadata" to save the XML file directly. This is the recommended approach. - **Option 2**: Manually copy the **SSO URL**, **Entity ID**, and **Certificate**. -2. Download the metadata. This file is required to complete the Prowler App configuration in Part B. +2. Download the metadata. This file is required to complete the configuration in Prowler Cloud (Part B). 3. Click "Continue". ![Google Identity Provider details - Download metadata](/images/prowler-app/saml/saml-sso-gw-3.png) @@ -52,18 +56,18 @@ On the **Google Identity Provider details** screen: **Save the Metadata File** -Download and save the IdP metadata XML file before proceeding. This file cannot be easily retrieved later and is required to complete the SAML configuration in Prowler App. +Download and save the IdP metadata XML file before proceeding. This file cannot be easily retrieved later and is required to complete the SAML configuration in Prowler Cloud. ### Step 4: Configure the Service Provider Details -Enter the following values obtained from the SAML SSO configuration dialog in Prowler App (see [Part B, Step 1](#step-1-open-the-saml-configuration-dialog) for details on where to find them): +Enter the following values obtained from the SAML SSO configuration dialog in Prowler Cloud (see [Part B, Step 1](#step-1-open-the-saml-configuration-dialog) for details on where to find them): | Google Workspace Field | Value | |------------------------|-------| -| **ACS URL** | The Assertion Consumer Service (ACS) URL displayed in Prowler App (e.g., `https://api.prowler.com/api/v1/accounts/saml/your-domain.com/acs/`). Self-hosted deployments use a different base URL. | -| **Entity ID** | The Audience URI displayed in Prowler App (e.g., `urn:prowler.com:sp`). | +| **ACS URL** | The Assertion Consumer Service (ACS) URL displayed in Prowler Cloud (e.g., `https://api.prowler.com/api/v1/accounts/saml/your-domain.com/acs/`). Prowler Local Server deployments use a different base URL. | +| **Entity ID** | The Audience URI displayed in Prowler Cloud (e.g., `urn:prowler.com:sp`). | | **Name ID format** | Select `EMAIL` from the dropdown. | | **Name ID** | Select `Basic Information > Primary email` from the dropdown. | @@ -82,7 +86,7 @@ Click "Add mapping" for each entry: | `Basic Information > First name` | `firstName` | Yes | | | `Basic Information > Last name` | `lastName` | Yes | | | `Employee Details > Department` | `userType` | No | Determines the Prowler role. **Case-sensitive.** | -| `Employee Details > Organization` | `organization` | No | Company name displayed in Prowler App profile. | +| `Employee Details > Organization` | `organization` | No | Company name displayed in the user profile in Prowler Cloud. | **Remember the Mapped Fields** @@ -98,7 +102,7 @@ Click "Finish" to create the SAML app. **Dynamic Updates** -Prowler App updates user attributes each time a user logs in. Any changes made in Google Workspace are reflected on the next login. +Prowler Cloud updates user attributes each time a user logs in. Any changes made in Google Workspace are reflected on the next login. @@ -108,7 +112,7 @@ Prowler App updates user attributes each time a user logs in. Any changes made i The `userType` attribute controls which Prowler role is assigned to the user: - If `userType` matches an existing Prowler role name, the user receives that role automatically. -- If `userType` does not match any existing role, Prowler App creates a new role with that name **with read-only access** (visibility over all providers, no management permissions). A Prowler administrator can adjust its permissions afterward through the [RBAC Management](/user-guide/tutorials/prowler-app-rbac) tab. +- If `userType` does not match any existing role, Prowler Cloud creates a new role with that name **with read-only access** (visibility over all providers, no management permissions). A Prowler administrator can adjust its permissions afterward through the [RBAC Management](/user-guide/tutorials/prowler-app-rbac) tab. - If `userType` is not set, the user's existing roles are left unchanged. The `userType` value is **case-sensitive** - for example, `Backend` and `backend` are treated as different roles. @@ -148,13 +152,13 @@ If attempting to use the "Test SAML login" option in Google Admin Console and re --- -## Part B - Prowler App Configuration +## Part B - Prowler Cloud Configuration ### Step 1: Open the SAML Configuration Dialog 1. Navigate to the profile settings page: - **Prowler Cloud**: `https://cloud.prowler.com/profile` - - **Self-hosted**: `http://{your-domain}/profile` + - **Prowler Local Server**: `http://{your-domain}/profile` 2. Find the "SAML SSO Integration" card and click "Enable" (or "Update" if already configured). 3. The "Configure SAML SSO" dialog opens, displaying: - **ACS URL**: The Assertion Consumer Service URL (copy this value for Part A, Step 4). This URL updates dynamically when the email domain is entered. @@ -162,21 +166,21 @@ If attempting to use the "Test SAML login" option in Google Admin Console and re - **Name ID Format**: The expected format (`urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress`). - **Supported Assertion Attributes**: The list of accepted attributes (`firstName`, `lastName`, `userType`, `organization`). -![Prowler App - Configure SAML SSO dialog (initial state)](/images/prowler-app/saml/saml-sso-gw-prowler-1.png) +![Prowler Cloud - Configure SAML SSO dialog (initial state)](/images/prowler-app/saml/saml-sso-gw-prowler-1.png) ### Step 2: Enter the Email Domain and Upload Metadata -1. Enter the **email domain** for the organization (e.g., `prowler.cloud`). Prowler App uses this domain to identify users who should authenticate via SAML. The ACS URL updates automatically to reflect the configured domain. +1. Enter the **email domain** for the organization (e.g., `prowler.cloud`). Prowler Cloud uses this domain to identify users who should authenticate via SAML. The ACS URL updates automatically to reflect the configured domain. 2. Upload the **metadata XML file** downloaded in Part A, Step 3. 3. Click "Save". -![Prowler App - Configure SAML SSO dialog (domain entered and ready to save)](/images/prowler-app/saml/saml-sso-gw-prowler-2.png) +![Prowler Cloud - Configure SAML SSO dialog (domain entered and ready to save)](/images/prowler-app/saml/saml-sso-gw-prowler-2.png) ### Step 3: Verify the Enabled Status The "SAML SSO Integration" card should now display a **"Status: Enabled"** indicator with a checkmark, confirming that the configuration is complete. -![Prowler App - SAML SSO Integration status showing "Enabled"](/images/prowler-app/saml/saml-sso-gw-prowler-3.png) +![Prowler Cloud - SAML SSO Integration status showing "Enabled"](/images/prowler-app/saml/saml-sso-gw-prowler-3.png) --- @@ -214,13 +218,13 @@ To test the `userType` → role mapping, set the **Department** attribute in the 1. Navigate to the Prowler login page. 2. Click "Continue with SAML SSO". 3. Enter an email from the configured domain (e.g., `adrian@prowler.cloud`). -4. Click "Log in". The browser redirects to Google for authentication and returns to Prowler App upon success. +4. Click "Log in". The browser redirects to Google for authentication and returns to Prowler Cloud upon success. -![Prowler App - Sign in with SAML SSO](/images/prowler-app/saml/saml-sso-gw-prowler-4.png) +![Prowler Cloud - Sign in with SAML SSO](/images/prowler-app/saml/saml-sso-gw-prowler-4.png) ### Verify User Profile and Role Mapping -After a successful SSO login, the user profile in Prowler App reflects the attributes sent by Google Workspace: +After a successful SSO login, the user profile in Prowler Cloud reflects the attributes sent by Google Workspace: - **Name**: Populated from the `firstName` and `lastName` attributes. - **Role**: Created automatically from the `userType` attribute (e.g., `Backend`). If the role did not exist previously, it is created with read-only access by default. @@ -230,14 +234,14 @@ After a successful SSO login, the user profile in Prowler App reflects the attri For more details on role assignment behavior and attribute mapping, refer to the [SAML SSO Configuration](/user-guide/tutorials/prowler-app-sso#configure-attribute-mapping-in-the-idp) page. -![Prowler App - User profile showing role "Backend" created from userType mapping](/images/prowler-app/saml/saml-sso-gw-prowler-5.png) +![Prowler Cloud - User profile showing role "Backend" created from userType mapping](/images/prowler-app/saml/saml-sso-gw-prowler-5.png) ### IdP-Initiated SSO (from Google) 1. Sign in to Google Workspace with an account that has access to the Prowler SAML app. 2. Open the Google Workspace app launcher (the grid icon in the top-right corner of any Google page). -3. Click the Prowler app tile. -4. The browser redirects directly to Prowler App, authenticated. +3. Click the Prowler tile. +4. The browser redirects directly to Prowler Cloud, authenticated. For more information on the SSO login flows, refer to the [SAML SSO Configuration](/user-guide/tutorials/prowler-app-sso#idp-initiated-sso) page. @@ -270,7 +274,7 @@ Prowler does not allow two tenants to share the same email domain. If the domain **Just-in-Time Provisioning** -Users who authenticate via SAML for the first time are automatically created in Prowler App. No prior invitation is needed. User attributes (`firstName`, `lastName`, `userType`) are updated on every login from the Google directory. +Users who authenticate via SAML for the first time are automatically created in Prowler Cloud. No prior invitation is needed. User attributes (`firstName`, `lastName`, `userType`) are updated on every login from the Google directory. @@ -278,10 +282,10 @@ Users who authenticate via SAML for the first time are automatically created in ## Quick Summary -1. In **Google Admin Console**, create a custom SAML app using the ACS URL and Entity ID from Prowler App. +1. In **Google Admin Console**, create a custom SAML app using the ACS URL and Entity ID from Prowler Cloud. 2. Configure **attribute mapping**: `firstName`, `lastName`, and optionally `userType` and `organization`. 3. **Download the metadata XML** from Google. 4. **Enable the app** in Google Workspace for the relevant users or groups. -5. In **Prowler App**, enter the email domain, upload the metadata XML, and save. +5. In **Prowler Cloud**, enter the email domain, upload the metadata XML, and save. 6. Verify the SAML SSO Integration shows **"Status: Enabled"**. 7. Test login via "Continue with SAML SSO" on the Prowler login page. diff --git a/docs/user-guide/tutorials/prowler-app-sso.mdx b/docs/user-guide/tutorials/prowler-app-sso.mdx index 84cccd3d65..bb993942cd 100644 --- a/docs/user-guide/tutorials/prowler-app-sso.mdx +++ b/docs/user-guide/tutorials/prowler-app-sso.mdx @@ -1,18 +1,22 @@ --- title: 'SAML Single Sign-On (SSO)' +sidebarTitle: 'SAML SSO' --- import { VersionBadge } from "/snippets/version-badge.mdx" +import { AppliesTo } from "/snippets/applies-to.mdx" -This guide provides comprehensive instructions to configure SAML-based Single Sign-On (SSO) in Prowler App. This configuration allows users to authenticate using the organization's Identity Provider (IdP). + + +This guide provides comprehensive instructions to configure SAML-based Single Sign-On (SSO) in Prowler Cloud. This configuration allows users to authenticate using the organization's Identity Provider (IdP). This document is divided into two main sections: -- **[User Guide](#user-guide-configuration)**: For organization administrators to configure SAML SSO through Prowler App. +- **[User Guide](#user-guide-configuration)**: For organization administrators to configure SAML SSO through Prowler Cloud. -- **[Developer and Administrator Guide](#developer-and-administrator-guide)**: For developers and system administrators running self-hosted Prowler App instances, providing technical details on environment configuration, API usage, and testing. +- **[Developer and Administrator Guide](#developer-and-administrator-guide)**: For developers and system administrators running Prowler Local Server instances, providing technical details on environment configuration, API usage, and testing. --- @@ -43,7 +47,7 @@ If the SAML configuration is removed, users who previously authenticated via SAM #### Step 1: Access Profile Settings -To access the account settings, click the "Account" button in the top-right corner of Prowler App, or navigate directly to `https://cloud.prowler.com/profile` (or `http://localhost:3000/profile` for local setups). +To access the account settings, click the "Account" button in the top-right corner of Prowler Cloud, or navigate directly to `https://cloud.prowler.com/profile` (or `http://localhost:3000/profile` for local setups). ![Access Profile Settings](/images/prowler-app/saml/saml-step-1.png) @@ -63,12 +67,12 @@ Choose a Method: - Prowler App displays the SAML configuration information needed to configure the IdP. Use this information to create a new SAML application in the IdP. + Prowler Cloud displays the SAML configuration information needed to configure the IdP. Use this information to create a new SAML application in the IdP. 1. **Assertion Consumer Service (ACS) URL**: The endpoint in Prowler that will receive the SAML assertion from the IdP. 2. **Audience URI (Entity ID)**: A unique identifier for the Prowler application (Service Provider). - To configure the IdP, copy the **ACS URL** and **Audience URI** from Prowler App and use them to set up a new SAML application. + To configure the IdP, copy the **ACS URL** and **Audience URI** from Prowler Cloud and use them to set up a new SAML application. ![IdP configuration](/images/prowler-app/saml/idp_config.png) @@ -81,13 +85,13 @@ Choose a Method: **Configure Attribute Mapping in the IdP** - For Prowler App to correctly identify and provision users, configure the IdP to send the following attributes in the SAML assertion: + For Prowler Cloud to correctly identify and provision users, configure the IdP to send the following attributes in the SAML assertion: | Attribute Name | Description | Required | |----------------|---------------------------------------------------------------------------------------------------------|----------| | `firstName` | The user's first name. | Yes | | `lastName` | The user's last name. | Yes | - | `userType` | Determines which Prowler role the user receives (e.g., `admin`, `auditor`). If a role with that name already exists, the user receives it automatically; if it does not exist, Prowler App creates a new role with that name with read-only access (visibility over all providers, no management permissions). If `userType` is not defined, the user's existing roles are left unchanged. Role permissions can be edited in the [RBAC Management tab](/user-guide/tutorials/prowler-app-rbac). | No | + | `userType` | Determines which Prowler role the user receives (e.g., `admin`, `auditor`). If a role with that name already exists, the user receives it automatically; if it does not exist, Prowler Cloud creates a new role with that name with read-only access (visibility over all providers, no management permissions). If `userType` is not defined, the user's existing roles are left unchanged. Role permissions can be edited in the [RBAC Management tab](/user-guide/tutorials/prowler-app-rbac). | No | | `organization` | The user's company name. | No | @@ -100,13 +104,13 @@ Choose a Method: **Single-Value `userType` Required** - Map `userType` to an IdP attribute that always contains a single value. If the IdP sends multiple values, Prowler App uses only the first value and does not assign multiple roles or select the highest-privilege role. + Map `userType` to an IdP attribute that always contains a single value. If the IdP sends multiple values, Prowler Cloud uses only the first value and does not assign multiple roles or select the highest-privilege role. **Dynamic Updates** - Prowler App updates these attributes each time a user logs in. Any changes made in the Identity Provider (IdP) will be reflected when the user logs in again. + Prowler Cloud updates these attributes each time a user logs in. Any changes made in the Identity Provider (IdP) will be reflected when the user logs in again. @@ -138,31 +142,31 @@ Choose a Method: ![Okta App Assignments](/images/prowler-app/saml/okta-app-assignments.png) - 7. **Configure User Attributes in Okta**: Okta acts as the central source for user profile information. Prowler App maps the following Okta user profile attributes during each SAML login: + 7. **Configure User Attributes in Okta**: Okta acts as the central source for user profile information. Prowler Cloud maps the following Okta user profile attributes during each SAML login: - * **First name** (`firstName`): Maps to the user's first name in Prowler App. - * **Last name** (`lastName`): Maps to the user's last name in Prowler App. + * **First name** (`firstName`): Maps to the user's first name in Prowler Cloud. + * **Last name** (`lastName`): Maps to the user's last name in Prowler Cloud. ![Okta User Profile — First Name and Last Name](/images/prowler-app/saml/okta-user-profile-name.png) - * **Organization** (`organization`): Maps to the company name displayed in Prowler App. This attribute is optional. - * **User type** (`userType`): Determines the Prowler role assigned to the user. This attribute is **case-sensitive**: if it matches the exact name of an existing role in Prowler App the user receives that role; if no role with that name exists, a new one is created with read-only access. + * **Organization** (`organization`): Maps to the company name displayed in Prowler Cloud. This attribute is optional. + * **User type** (`userType`): Determines the Prowler role assigned to the user. This attribute is **case-sensitive**: if it matches the exact name of an existing role in Prowler Cloud the user receives that role; if no role with that name exists, a new one is created with read-only access. ![Okta User Profile — User Type and Organization](/images/prowler-app/saml/okta-user-profile-attributes.png) - To modify these values, edit the user's profile directly in the Okta admin console under the "Profile" tab. Changes are reflected in Prowler App the next time the user logs in via SAML. + To modify these values, edit the user's profile directly in the Okta admin console under the "Profile" tab. Changes are reflected in Prowler Cloud the next time the user logs in via SAML. **User Type and Role Assignment** The `userType` attribute controls which Prowler role is assigned to the user: - * If a role with the specified name already exists in Prowler App, the user automatically receives that role. - * If the role does not exist, Prowler App creates a new role with that exact name with read-only access: the user can see all providers and their findings but cannot manage anything. A Prowler administrator (a user whose role includes the "Manage Account" permission) can adjust its permissions afterward through the [RBAC Management tab](/user-guide/tutorials/prowler-app-rbac). - * If `userType` is not defined in the user's Okta profile, the user's existing roles in Prowler App are left unchanged. - * `userType` must contain a single value. If the IdP sends multiple values, Prowler App uses only the first value and does not assign multiple roles. + * If a role with the specified name already exists in Prowler Cloud, the user automatically receives that role. + * If the role does not exist, Prowler Cloud creates a new role with that exact name with read-only access: the user can see all providers and their findings but cannot manage anything. A Prowler administrator (a user whose role includes the "Manage Account" permission) can adjust its permissions afterward through the [RBAC Management tab](/user-guide/tutorials/prowler-app-rbac). + * If `userType` is not defined in the user's Okta profile, the user's existing roles in Prowler Cloud are left unchanged. + * `userType` must contain a single value. If the IdP sends multiple values, Prowler Cloud uses only the first value and does not assign multiple roles. - **Example:** To assign the `IT` role to a user, set the `userType` value to `IT` in Okta. If a role named `IT` already exists in Prowler App, the user receives it automatically upon login. If it does not exist, Prowler App creates a new role called `IT` with read-only access, and a Prowler administrator can adjust its permissions as needed. + **Example:** To assign the `IT` role to a user, set the `userType` value to `IT` in Okta. If a role named `IT` already exists in Prowler Cloud, the user receives it automatically upon login. If it does not exist, Prowler Cloud creates a new role called `IT` with read-only access, and a Prowler administrator can adjust its permissions as needed. @@ -178,11 +182,11 @@ Choose a Method: Once the IdP is configured, it provides a **metadata XML file**. This file contains the IdP's configuration information, such as its public key and login URL. -To complete the Prowler App configuration: +To complete the Prowler Cloud configuration: 1. Return to the Prowler SAML configuration page. -2. Enter the **email domain** for the organization (e.g., `mycompany.com`). Prowler App uses this to identify users who should authenticate via SAML. +2. Enter the **email domain** for the organization (e.g., `mycompany.com`). Prowler Cloud uses this to identify users who should authenticate via SAML. 3. Upload the **metadata XML file** downloaded from the IdP. @@ -225,7 +229,7 @@ Users can also initiate the login process directly from Prowler's login page: 3. Enter their email address from the configured domain ![](/images/prowler-app/saml/saml-signin-2.png) 4. The system redirects users to the IdP for authentication -5. After successful authentication, users are returned to Prowler App +5. After successful authentication, users are returned to Prowler Cloud This method is useful when users bookmark Prowler or navigate directly to the application. @@ -233,11 +237,11 @@ This method is useful when users bookmark Prowler or navigate directly to the ap ## Developer and Administrator Guide -This section provides technical details for developers and administrators of self-hosted Prowler instances. +This section provides technical details for developers and administrators of Prowler Local Server instances. ### Environment Configuration -For self-hosted deployments, several environment variables must be configured to ensure SAML SSO functions correctly. These variables are typically set in an `.env` file. +For Prowler Local Server deployments, several environment variables must be configured to ensure SAML SSO functions correctly. These variables are typically set in an `.env` file. | Variable | Description | Example | |---------------------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------|-----------------------------------------------------------| diff --git a/docs/user-guide/tutorials/prowler-app.mdx b/docs/user-guide/tutorials/prowler-app.mdx index 51ebdde861..6c3131d136 100644 --- a/docs/user-guide/tutorials/prowler-app.mdx +++ b/docs/user-guide/tutorials/prowler-app.mdx @@ -1,21 +1,22 @@ --- title: 'Prowler Cloud' +sidebarTitle: 'Getting Started' --- import { ProviderCards } from "/snippets/provider-cards.mdx" **Prowler Cloud** is a web application that simplifies running Prowler. This tutorial will guide you through setting up and using it. -We refer to **Prowler App** as the self-hosted version of **Prowler Cloud**. +**Prowler Local Server** is the self-hosted version of **Prowler Cloud**. See [Prowler product families](/getting-started/products) for every official product name. ## Accessing Prowler Cloud and API Documentation If you are a [Prowler Cloud](https://cloud.prowler.com/sign-in) user, you can access API docs at [https://api.prowler.com/api/v1/docs](https://api.prowler.com/api/v1/docs) -**For Prowler App users** +**For Prowler Local Server users** -After [installing](/getting-started/installation/prowler-app) **Prowler App**, access it at [http://localhost:3000](http://localhost:3000). +After [installing](/getting-started/installation/prowler-app) **Prowler Local Server**, access it at [http://localhost:3000](http://localhost:3000). To view the auto-generated **Prowler API** documentation, navigate to [http://localhost:8080/api/v1/docs](http://localhost:8080/api/v1/docs). This documentation provides details on available endpoints, parameters, and responses. @@ -44,7 +45,7 @@ See [how to configure Social Login for Prowler](/user-guide/tutorials/prowler-ap ## Step 2: Log In -Once registered, log in with your email and password to access Prowler App. +Once registered, log in with your email and password to access Prowler Cloud. Log In @@ -84,7 +85,7 @@ For detailed instructions on configuring credentials for each provider, refer to ## Step 5: Test Connection -After adding your credentials of your cloud account, click the `Launch` button to verify that Prowler App can successfully connect to your provider: +After adding your credentials of your cloud account, click the `Launch` button to verify that Prowler can successfully connect to your provider: Test Connection diff --git a/docs/user-guide/tutorials/prowler-cloud-aws-organizations.mdx b/docs/user-guide/tutorials/prowler-cloud-aws-organizations.mdx index 2a6128e92e..8bb6e320ae 100644 --- a/docs/user-guide/tutorials/prowler-cloud-aws-organizations.mdx +++ b/docs/user-guide/tutorials/prowler-cloud-aws-organizations.mdx @@ -1,5 +1,5 @@ --- -title: 'AWS Organizations in Prowler Cloud' +title: 'AWS Organizations' description: 'Onboard all AWS accounts in your Organization through a single guided wizard' --- @@ -464,7 +464,7 @@ Each AWS account you connect through the Organizations wizard counts as one **pr - **Large organizations**: connecting a 500-account organization will result in up to 500 providers on your subscription. Review your plan limits before proceeding. - **Deleted providers**: if you later remove an account, the deleted provider no longer counts toward your subscription. -For pricing details, see [Prowler Cloud Pricing](/getting-started/products/prowler-cloud-pricing). +For pricing details, see [Prowler Cloud Pricing](https://prowler.com/pricing). ## Troubleshooting @@ -505,7 +505,7 @@ No accounts pass the connection test. - Verify the CloudFormation StackSet was deployed — complete [Step 2](#step-2-deploy-the-cloudformation-stackset) and wait for stack instances to reach **CREATE_COMPLETE** - Check that the **ExternalId** parameter in the StackSet matches the External ID shown in the Prowler wizard -- If your accounts use IP-based IAM policies, allow [Prowler Cloud public IPs](/user-guide/tutorials/prowler-cloud-public-ips) +- If your accounts use IP-based IAM policies, allow [Prowler Cloud egress IPs](/security/networking) ### Connection Test Fails for Some Accounts diff --git a/docs/user-guide/tutorials/prowler-cloud-azure-management-groups.mdx b/docs/user-guide/tutorials/prowler-cloud-azure-management-groups.mdx new file mode 100644 index 0000000000..98cfe095ce --- /dev/null +++ b/docs/user-guide/tutorials/prowler-cloud-azure-management-groups.mdx @@ -0,0 +1,11 @@ +--- +title: 'Azure Management Groups' +description: 'Onboard all Azure subscriptions in your management groups through a single guided wizard' +tag: "Coming Soon" +--- + +Onboarding Azure management groups through a single guided wizard is coming soon to Prowler Cloud. + +Today, Azure subscriptions are onboarded individually. See [Getting Started with Azure](/user-guide/providers/azure/getting-started-azure) and [Bulk Provider Provisioning](/user-guide/tutorials/bulk-provider-provisioning) to automate onboarding multiple subscriptions. + +Keep an eye on the [changelog](https://github.com/prowler-cloud/prowler/releases) for updates. diff --git a/docs/user-guide/tutorials/prowler-cloud-gcp-organizations.mdx b/docs/user-guide/tutorials/prowler-cloud-gcp-organizations.mdx new file mode 100644 index 0000000000..c0c11747a9 --- /dev/null +++ b/docs/user-guide/tutorials/prowler-cloud-gcp-organizations.mdx @@ -0,0 +1,11 @@ +--- +title: 'GCP Organizations' +description: 'Onboard all GCP projects in your organization through a single guided wizard' +tag: "Coming Soon" +--- + +Onboarding a full GCP organization through a single guided wizard is coming soon to Prowler Cloud. + +Today, GCP projects are onboarded individually. See [Getting Started with GCP](/user-guide/providers/gcp/getting-started-gcp) and [Bulk Provider Provisioning](/user-guide/tutorials/bulk-provider-provisioning) to automate onboarding multiple projects. + +Keep an eye on the [changelog](https://github.com/prowler-cloud/prowler/releases) for updates. diff --git a/docs/user-guide/tutorials/prowler-cloud-lighthouse-multi-llm.mdx b/docs/user-guide/tutorials/prowler-cloud-lighthouse-multi-llm.mdx index ea19b1fe7f..0cb251df1f 100644 --- a/docs/user-guide/tutorials/prowler-cloud-lighthouse-multi-llm.mdx +++ b/docs/user-guide/tutorials/prowler-cloud-lighthouse-multi-llm.mdx @@ -1,5 +1,6 @@ --- title: 'Using Multiple LLM Providers' +sidebarTitle: 'Multiple LLM Providers' --- import { SubscriptionBanner } from "/snippets/subscription-banner.mdx" diff --git a/docs/user-guide/tutorials/prowler-cloud-public-ips.mdx b/docs/user-guide/tutorials/prowler-cloud-public-ips.mdx deleted file mode 100644 index f3b285056c..0000000000 --- a/docs/user-guide/tutorials/prowler-cloud-public-ips.mdx +++ /dev/null @@ -1,29 +0,0 @@ ---- -title: 'Prowler Cloud Public IPs' ---- - -## Overview - -Prowler Cloud uses a dedicated egress IPv4 address for all outbound connections to customer infrastructure. This enables organizations to implement network-level security controls by whitelisting Prowler's IP address. - -## Use Cases - -Whitelisting Prowler's egress IP address enables: - -- **Credential Usage Control**: Restrict where cloud provider credentials can be used from across AWS, Azure, GCP, and other providers -- **Kubernetes Security**: Limit inbound HTTPS traffic to clusters by allowing only Prowler's IP address -- **Compliance Requirements**: Meet security policies requiring allowlisting of external services - -## Query the Egress IP Address - -Retrieve Prowler Cloud's current egress IP address using the following command: - -```bash -dig egress.prowler.com +short -``` - -This command returns the IPv4 address that Prowler Cloud uses for all outbound connections to customer infrastructure. - - -The egress IP address is stable, but it is recommended to periodically verify it remains current by querying `egress.prowler.com`. - diff --git a/docs/user-guide/tutorials/prowler-import-findings.mdx b/docs/user-guide/tutorials/prowler-import-findings.mdx index e02f3ee7a5..530cd160ee 100644 --- a/docs/user-guide/tutorials/prowler-import-findings.mdx +++ b/docs/user-guide/tutorials/prowler-import-findings.mdx @@ -1,5 +1,6 @@ --- title: 'Import Findings' +sidebarTitle: 'Import Findings' description: 'Upload OCSF scan results to Prowler Cloud from external sources or the CLI' --- @@ -133,7 +134,7 @@ Only **Detection Finding** (`class_uid: 2004`) records are accepted. Other OCSF The **Manage Ingestions** RBAC permission controls access to the ingestion endpoints. Without this permission, findings cannot be submitted via the API or `--push-to-cloud`. -For more information about RBAC permissions, refer to the [Prowler App RBAC documentation](/user-guide/tutorials/prowler-app-rbac). +For more information about RBAC permissions, refer to the [Prowler Cloud RBAC documentation](/user-guide/tutorials/prowler-app-rbac). ## Using the CLI diff --git a/docs/user-guide/tutorials/prowler-scan-scheduling.mdx b/docs/user-guide/tutorials/prowler-scan-scheduling.mdx index 961d32ad14..f909ed2fa0 100644 --- a/docs/user-guide/tutorials/prowler-scan-scheduling.mdx +++ b/docs/user-guide/tutorials/prowler-scan-scheduling.mdx @@ -1,6 +1,7 @@ --- title: 'Scan Scheduling' -description: 'Create, edit, and monitor recurring scans in Prowler Cloud and Enterprise.' +sidebarTitle: 'Scheduling' +description: 'Create, edit, and monitor recurring scans in Prowler Cloud and Prowler Private Cloud.' --- import { VersionBadge } from "/snippets/version-badge.mdx" @@ -21,11 +22,11 @@ Before creating or editing scan schedules, ensure that: ## Schedule Options -A Prowler Cloud or Enterprise subscription supports the following custom recurring schedule options. Prowler self-hosted runs a daily scan automatically and does not expose custom cadence controls. +A Prowler Cloud or Prowler Private Cloud subscription supports the following custom recurring schedule options. Prowler Local Server runs a daily scan automatically and does not expose custom cadence controls. -| Schedule Option | Description | Cloud & Enterprise | Self-Hosted | -|-----------------|-------------|--------------------|-------------| -| Daily | Runs one scan every day at the selected time. | Yes | Yes | +| Schedule Option | Description | Prowler Cloud & Prowler Private Cloud | Prowler Local Server | +|-----------------|-------------|---------------------------------------|----------------------| +| Daily | Runs one scan every day at the selected time. | Yes | Automatic | | Every 48 hours | Runs one scan every 48 hours, anchored to the selected time. | Yes | — | | Weekly | Runs one scan every week on the selected day and time. | Yes | — | | Monthly | Runs one scan every month on the selected day, from day 1 to day 28. | Yes | — | diff --git a/permissions/prowler-additions-policy.json b/permissions/prowler-additions-policy.json index ba56513732..6c58f6e184 100644 --- a/permissions/prowler-additions-policy.json +++ b/permissions/prowler-additions-policy.json @@ -4,6 +4,8 @@ { "Action": [ "account:Get*", + "amplify:ListApps", + "amplify:ListBranches", "appstream:Describe*", "appstream:List*", "backup:List*", diff --git a/permissions/templates/cloudformation/prowler-scan-role.yml b/permissions/templates/cloudformation/prowler-scan-role.yml index 96e6f7b2a5..c9eee71950 100644 --- a/permissions/templates/cloudformation/prowler-scan-role.yml +++ b/permissions/templates/cloudformation/prowler-scan-role.yml @@ -176,6 +176,8 @@ Resources: Effect: Allow Action: - "account:Get*" + - "amplify:ListApps" + - "amplify:ListBranches" - "appstream:Describe*" - "appstream:List*" - "backup:List*" diff --git a/prowler/CHANGELOG.md b/prowler/CHANGELOG.md index 3a9ebff986..2a0d5899f5 100644 --- a/prowler/CHANGELOG.md +++ b/prowler/CHANGELOG.md @@ -4,6 +4,15 @@ All notable changes to the **Prowler SDK** are documented in this file. +## [5.33.2] (Prowler v5.33.2) + +### 🐞 Fixed + +- EC2 AMI loading now targets Amazon-owned AMIs used by audited instances, reducing AWS API calls during EC2 scans [(#11958)](https://github.com/prowler-cloud/prowler/pull/11958) +- `ec2_instance_account_imdsv2_enabled` findings now use regional resource ARNs, preventing findings from different AWS Regions from collapsing into one resource [(#11966)](https://github.com/prowler-cloud/prowler/pull/11966) + +--- + ## [5.33.1] (Prowler v5.33.1) ### 🐞 Fixed diff --git a/prowler/changelog.d/amplify-app-secrets-check.added.md b/prowler/changelog.d/amplify-app-secrets-check.added.md new file mode 100644 index 0000000000..fa77779872 --- /dev/null +++ b/prowler/changelog.d/amplify-app-secrets-check.added.md @@ -0,0 +1 @@ +`amplify_app_no_secrets_in_environment` check for AWS provider, scanning Amplify app and branch environment variables and build settings for hardcoded secrets diff --git a/prowler/changelog.d/core-minimize-hostpath-volume-mounts.added.md b/prowler/changelog.d/core-minimize-hostpath-volume-mounts.added.md new file mode 100644 index 0000000000..af1595a354 --- /dev/null +++ b/prowler/changelog.d/core-minimize-hostpath-volume-mounts.added.md @@ -0,0 +1 @@ +`core_minimize_hostpath_volume_mounts` check for Kubernetes provider, detecting Pods that use `hostPath` volumes diff --git a/prowler/changelog.d/core-readonly-root-filesystem-enabled.added.md b/prowler/changelog.d/core-readonly-root-filesystem-enabled.added.md new file mode 100644 index 0000000000..cfc398b111 --- /dev/null +++ b/prowler/changelog.d/core-readonly-root-filesystem-enabled.added.md @@ -0,0 +1 @@ +`core_readonly_root_filesystem_enabled` check for Kubernetes provider, verifying that every container in each Pod explicitly sets `readOnlyRootFilesystem: true` in its security context diff --git a/prowler/changelog.d/dashboard-local-redesign.changed.md b/prowler/changelog.d/dashboard-local-redesign.changed.md new file mode 100644 index 0000000000..59ba9ca7ea --- /dev/null +++ b/prowler/changelog.d/dashboard-local-redesign.changed.md @@ -0,0 +1 @@ +Redesign the local dashboard sidebar and informational pages diff --git a/prowler/changelog.d/ec2-imdsv2-regional-resource-identity.fixed.md b/prowler/changelog.d/ec2-imdsv2-regional-resource-identity.fixed.md deleted file mode 100644 index 97e854ce61..0000000000 --- a/prowler/changelog.d/ec2-imdsv2-regional-resource-identity.fixed.md +++ /dev/null @@ -1 +0,0 @@ -`ec2_instance_account_imdsv2_enabled` findings now use regional resource ARNs, preventing findings from different AWS Regions from collapsing into one resource diff --git a/prowler/changelog.d/ec2-targeted-ami-loading.fixed.md b/prowler/changelog.d/ec2-targeted-ami-loading.fixed.md deleted file mode 100644 index 6fed547536..0000000000 --- a/prowler/changelog.d/ec2-targeted-ami-loading.fixed.md +++ /dev/null @@ -1 +0,0 @@ -EC2 AMI loading now targets Amazon-owned AMIs used by audited instances, reducing AWS API calls during EC2 scans diff --git a/prowler/changelog.d/elbv2-listener-pqc-tls-enabled.added.md b/prowler/changelog.d/elbv2-listener-pqc-tls-enabled.added.md new file mode 100644 index 0000000000..044d0bb0f4 --- /dev/null +++ b/prowler/changelog.d/elbv2-listener-pqc-tls-enabled.added.md @@ -0,0 +1 @@ +`elbv2_listener_pqc_tls_enabled` check for AWS provider, verifying that ELBv2 listeners use post-quantum TLS policies diff --git a/prowler/compliance/aws/aws_well_architected_framework_security_pillar_aws.json b/prowler/compliance/aws/aws_well_architected_framework_security_pillar_aws.json index a025bb3a3c..9dd009afb3 100644 --- a/prowler/compliance/aws/aws_well_architected_framework_security_pillar_aws.json +++ b/prowler/compliance/aws/aws_well_architected_framework_security_pillar_aws.json @@ -1181,6 +1181,7 @@ "elb_insecure_ssl_ciphers", "elb_ssl_listeners", "elbv2_insecure_ssl_ciphers", + "elbv2_listener_pqc_tls_enabled", "cloudfront_distributions_pqc_tls_enabled", "apigateway_domain_name_pqc_tls_enabled", "transfer_server_pqc_ssh_kex_enabled", diff --git a/prowler/compliance/aws/ccc_aws.json b/prowler/compliance/aws/ccc_aws.json index 7935424193..ef31bfe1ea 100644 --- a/prowler/compliance/aws/ccc_aws.json +++ b/prowler/compliance/aws/ccc_aws.json @@ -49,6 +49,7 @@ "elb_insecure_ssl_ciphers", "elb_ssl_listeners", "elbv2_insecure_ssl_ciphers", + "elbv2_listener_pqc_tls_enabled", "cloudfront_distributions_pqc_tls_enabled", "apigateway_domain_name_pqc_tls_enabled", "transfer_server_pqc_ssh_kex_enabled", diff --git a/prowler/compliance/aws/ens_rd2022_aws.json b/prowler/compliance/aws/ens_rd2022_aws.json index 144437ce52..f81b8a3eea 100644 --- a/prowler/compliance/aws/ens_rd2022_aws.json +++ b/prowler/compliance/aws/ens_rd2022_aws.json @@ -2495,6 +2495,7 @@ ], "Checks": [ "elbv2_insecure_ssl_ciphers", + "elbv2_listener_pqc_tls_enabled", "cloudfront_distributions_pqc_tls_enabled", "apigateway_domain_name_pqc_tls_enabled", "transfer_server_pqc_ssh_kex_enabled" @@ -2521,6 +2522,7 @@ ], "Checks": [ "elbv2_insecure_ssl_ciphers", + "elbv2_listener_pqc_tls_enabled", "cloudfront_distributions_pqc_tls_enabled", "apigateway_domain_name_pqc_tls_enabled", "transfer_server_pqc_ssh_kex_enabled" diff --git a/prowler/compliance/aws/fedramp_moderate_revision_4_aws.json b/prowler/compliance/aws/fedramp_moderate_revision_4_aws.json index eaa3ea25dc..13c4624572 100644 --- a/prowler/compliance/aws/fedramp_moderate_revision_4_aws.json +++ b/prowler/compliance/aws/fedramp_moderate_revision_4_aws.json @@ -1377,6 +1377,7 @@ "Checks": [ "apigateway_restapi_client_certificate_enabled", "elbv2_insecure_ssl_ciphers", + "elbv2_listener_pqc_tls_enabled", "cloudfront_distributions_pqc_tls_enabled", "apigateway_domain_name_pqc_tls_enabled", "transfer_server_pqc_ssh_kex_enabled", @@ -1399,6 +1400,7 @@ "Checks": [ "apigateway_restapi_client_certificate_enabled", "elbv2_insecure_ssl_ciphers", + "elbv2_listener_pqc_tls_enabled", "cloudfront_distributions_pqc_tls_enabled", "apigateway_domain_name_pqc_tls_enabled", "transfer_server_pqc_ssh_kex_enabled", diff --git a/prowler/compliance/aws/ffiec_aws.json b/prowler/compliance/aws/ffiec_aws.json index 8a50b79925..4a2a1b943e 100644 --- a/prowler/compliance/aws/ffiec_aws.json +++ b/prowler/compliance/aws/ffiec_aws.json @@ -615,6 +615,7 @@ "Checks": [ "apigateway_restapi_client_certificate_enabled", "elbv2_insecure_ssl_ciphers", + "elbv2_listener_pqc_tls_enabled", "cloudfront_distributions_pqc_tls_enabled", "apigateway_domain_name_pqc_tls_enabled", "transfer_server_pqc_ssh_kex_enabled", diff --git a/prowler/compliance/aws/gxp_21_cfr_part_11_aws.json b/prowler/compliance/aws/gxp_21_cfr_part_11_aws.json index 871af9e726..76ad0c74a5 100644 --- a/prowler/compliance/aws/gxp_21_cfr_part_11_aws.json +++ b/prowler/compliance/aws/gxp_21_cfr_part_11_aws.json @@ -266,6 +266,7 @@ "ec2_ebs_default_encryption", "efs_encryption_at_rest_enabled", "elbv2_insecure_ssl_ciphers", + "elbv2_listener_pqc_tls_enabled", "cloudfront_distributions_pqc_tls_enabled", "apigateway_domain_name_pqc_tls_enabled", "transfer_server_pqc_ssh_kex_enabled", diff --git a/prowler/compliance/aws/iso27001_2013_aws.json b/prowler/compliance/aws/iso27001_2013_aws.json index 1de8c23db8..c7ce030024 100644 --- a/prowler/compliance/aws/iso27001_2013_aws.json +++ b/prowler/compliance/aws/iso27001_2013_aws.json @@ -36,6 +36,7 @@ "Checks": [ "elb_insecure_ssl_ciphers", "elbv2_insecure_ssl_ciphers", + "elbv2_listener_pqc_tls_enabled", "cloudfront_distributions_pqc_tls_enabled", "apigateway_domain_name_pqc_tls_enabled", "transfer_server_pqc_ssh_kex_enabled" diff --git a/prowler/compliance/aws/kisa_isms_p_2023_aws.json b/prowler/compliance/aws/kisa_isms_p_2023_aws.json index 7b0446ac3f..65a6e8c8b8 100644 --- a/prowler/compliance/aws/kisa_isms_p_2023_aws.json +++ b/prowler/compliance/aws/kisa_isms_p_2023_aws.json @@ -2064,6 +2064,7 @@ "elb_ssl_listeners", "elb_ssl_listeners_use_acm_certificate", "elbv2_insecure_ssl_ciphers", + "elbv2_listener_pqc_tls_enabled", "cloudfront_distributions_pqc_tls_enabled", "apigateway_domain_name_pqc_tls_enabled", "transfer_server_pqc_ssh_kex_enabled", @@ -3142,6 +3143,7 @@ "elb_ssl_listeners_use_acm_certificate", "elbv2_desync_mitigation_mode", "elbv2_insecure_ssl_ciphers", + "elbv2_listener_pqc_tls_enabled", "cloudfront_distributions_pqc_tls_enabled", "apigateway_domain_name_pqc_tls_enabled", "transfer_server_pqc_ssh_kex_enabled", diff --git a/prowler/compliance/aws/kisa_isms_p_2023_korean_aws.json b/prowler/compliance/aws/kisa_isms_p_2023_korean_aws.json index 40b338ce41..b99a36d38e 100644 --- a/prowler/compliance/aws/kisa_isms_p_2023_korean_aws.json +++ b/prowler/compliance/aws/kisa_isms_p_2023_korean_aws.json @@ -2066,6 +2066,7 @@ "elb_ssl_listeners", "elb_ssl_listeners_use_acm_certificate", "elbv2_insecure_ssl_ciphers", + "elbv2_listener_pqc_tls_enabled", "cloudfront_distributions_pqc_tls_enabled", "apigateway_domain_name_pqc_tls_enabled", "transfer_server_pqc_ssh_kex_enabled", @@ -3145,6 +3146,7 @@ "elb_ssl_listeners_use_acm_certificate", "elbv2_desync_mitigation_mode", "elbv2_insecure_ssl_ciphers", + "elbv2_listener_pqc_tls_enabled", "cloudfront_distributions_pqc_tls_enabled", "apigateway_domain_name_pqc_tls_enabled", "transfer_server_pqc_ssh_kex_enabled", diff --git a/prowler/compliance/aws/nist_800_171_revision_2_aws.json b/prowler/compliance/aws/nist_800_171_revision_2_aws.json index 921bd33a53..31c45c6ae3 100644 --- a/prowler/compliance/aws/nist_800_171_revision_2_aws.json +++ b/prowler/compliance/aws/nist_800_171_revision_2_aws.json @@ -717,6 +717,7 @@ "apigateway_restapi_client_certificate_enabled", "ec2_ebs_volume_encryption", "elbv2_insecure_ssl_ciphers", + "elbv2_listener_pqc_tls_enabled", "cloudfront_distributions_pqc_tls_enabled", "apigateway_domain_name_pqc_tls_enabled", "transfer_server_pqc_ssh_kex_enabled", diff --git a/prowler/compliance/aws/nist_800_53_revision_5_aws.json b/prowler/compliance/aws/nist_800_53_revision_5_aws.json index e0ef936229..70864860bf 100644 --- a/prowler/compliance/aws/nist_800_53_revision_5_aws.json +++ b/prowler/compliance/aws/nist_800_53_revision_5_aws.json @@ -5592,6 +5592,7 @@ "Checks": [ "apigateway_restapi_client_certificate_enabled", "elbv2_insecure_ssl_ciphers", + "elbv2_listener_pqc_tls_enabled", "cloudfront_distributions_pqc_tls_enabled", "apigateway_domain_name_pqc_tls_enabled", "transfer_server_pqc_ssh_kex_enabled", diff --git a/prowler/compliance/aws/rbi_cyber_security_framework_aws.json b/prowler/compliance/aws/rbi_cyber_security_framework_aws.json index 5de1f5ca8a..554b40cdad 100644 --- a/prowler/compliance/aws/rbi_cyber_security_framework_aws.json +++ b/prowler/compliance/aws/rbi_cyber_security_framework_aws.json @@ -40,6 +40,7 @@ "ec2_instance_public_ip", "efs_encryption_at_rest_enabled", "elbv2_insecure_ssl_ciphers", + "elbv2_listener_pqc_tls_enabled", "cloudfront_distributions_pqc_tls_enabled", "apigateway_domain_name_pqc_tls_enabled", "transfer_server_pqc_ssh_kex_enabled", diff --git a/prowler/compliance/aws/secnumcloud_3.2_aws.json b/prowler/compliance/aws/secnumcloud_3.2_aws.json index 701f931b05..8075dc3610 100644 --- a/prowler/compliance/aws/secnumcloud_3.2_aws.json +++ b/prowler/compliance/aws/secnumcloud_3.2_aws.json @@ -490,6 +490,7 @@ "elbv2_ssl_listeners", "elb_insecure_ssl_ciphers", "elbv2_insecure_ssl_ciphers", + "elbv2_listener_pqc_tls_enabled", "cloudfront_distributions_pqc_tls_enabled", "apigateway_domain_name_pqc_tls_enabled", "transfer_server_pqc_ssh_kex_enabled", diff --git a/prowler/config/config.yaml b/prowler/config/config.yaml index 6d2803be56..aba7fd6481 100644 --- a/prowler/config/config.yaml +++ b/prowler/config/config.yaml @@ -424,6 +424,21 @@ aws: - "SecurityPolicy_TLS13_1_2_PFS_PQ_2025_09" - "SecurityPolicy_TLS13_1_2_PQ_2025_09" + # aws.elbv2_listener_pqc_tls_enabled + # Allowed post-quantum TLS security policies for ELBv2 HTTPS/TLS listeners + elbv2_listener_pqc_tls_allowed_policies: + - "ELBSecurityPolicy-TLS13-1-2-PQ-2025-09" + - "ELBSecurityPolicy-TLS13-1-2-Ext1-PQ-2025-09" + - "ELBSecurityPolicy-TLS13-1-2-Ext2-PQ-2025-09" + - "ELBSecurityPolicy-TLS13-1-2-Res-PQ-2025-09" + - "ELBSecurityPolicy-TLS13-1-3-PQ-2025-09" + - "ELBSecurityPolicy-TLS13-1-2-FIPS-PQ-2025-09" + - "ELBSecurityPolicy-TLS13-1-2-Ext0-FIPS-PQ-2025-09" + - "ELBSecurityPolicy-TLS13-1-2-Ext1-FIPS-PQ-2025-09" + - "ELBSecurityPolicy-TLS13-1-2-Ext2-FIPS-PQ-2025-09" + - "ELBSecurityPolicy-TLS13-1-2-Res-FIPS-PQ-2025-09" + - "ELBSecurityPolicy-TLS13-1-3-FIPS-PQ-2025-09" + # aws.rolesanywhere_trust_anchor_pqc_pki # Allowed post-quantum key algorithms for AWS Private CAs backing IAM Roles Anywhere trust anchors rolesanywhere_pqc_pca_key_algorithms: diff --git a/prowler/config/schema/aws.py b/prowler/config/schema/aws.py index 4e52093029..7ac12d60de 100644 --- a/prowler/config/schema/aws.py +++ b/prowler/config/schema/aws.py @@ -415,6 +415,10 @@ class AWSProviderConfig(ProviderConfigBase): le=6, description="Min AZs an Application/Network LB must span. Range: 1..6.", ) + elbv2_listener_pqc_tls_allowed_policies: Optional[list[str]] = Field( + default=None, + description="ELBv2 SSL policies that satisfy the PQ TLS listener check.", + ) # --- ElastiCache ----------------------------------------------------- minimum_snapshot_retention_period: Optional[int] = Field( diff --git a/prowler/lib/banner.py b/prowler/lib/banner.py index 8115983bc6..e1c7fa5a35 100644 --- a/prowler/lib/banner.py +++ b/prowler/lib/banner.py @@ -2,6 +2,21 @@ from colorama import Fore, Style from prowler.config.config import banner_color, orange_color, prowler_version, timestamp +# Prowler Cloud landing URL used by the CLI banner. The visible text stays +# "cloud.prowler.com" while the clickable target carries the UTM source so +# terminals that support OSC 8 hyperlinks attribute the visit to the CLI. +CLOUD_DISPLAY_TEXT = "cloud.prowler.com" +CLOUD_BANNER_URL = "https://cloud.prowler.com/sign-up?utm_source=prowler-cli" + + +def _hyperlink(url: str, text: str) -> str: + """Wrap ``text`` in an OSC 8 terminal hyperlink pointing to ``url``. + + Terminals that support OSC 8 render ``text`` as a clickable link to ``url``; + those that do not simply display ``text`` unchanged. + """ + return f"\033]8;;{url}\033\\{text}\033]8;;\033\\" + def print_banner(legend: bool = False, provider: str = None): """ @@ -18,8 +33,8 @@ def print_banner(legend: bool = False, provider: str = None): _ __ _ __ _____ _| | ___ _ __ | '_ \| '__/ _ \ \ /\ / / |/ _ \ '__| | |_) | | | (_) \ V V /| | __/ | -| .__/|_| \___/ \_/\_/ |_|\___|_|v{prowler_version} -|_|{Fore.BLUE} Get the most at https://cloud.prowler.com {Style.RESET_ALL} +| .__/|_| \___/ \_/\_/ |_|\___|_| CLI - v{prowler_version} +|_| {Fore.YELLOW}Date: {timestamp.strftime("%Y-%m-%d %H:%M:%S")}{Style.RESET_ALL} """ @@ -43,8 +58,9 @@ def print_prowler_cloud_banner(provider: str = None): the open-source CLI. Shown at the start and end of a scan to let users know about the managed - platform capabilities they are missing (attack paths, AI, organizations, - continuous scanning, integrations and live compliance dashboards). + platform capabilities they are missing (CLI findings upload, attack paths, + AI, triage, organizations, continuous scanning with custom scheduling and + scan configuration, integrations and live compliance dashboards). Parameters: - provider (str): The provider that was scanned, used to tailor the message. @@ -57,7 +73,9 @@ def print_prowler_cloud_banner(provider: str = None): print(f""" {bar} {Style.BRIGHT}You're getting a snapshot 📸. Prowler Cloud gives you the full picture:{Style.RESET_ALL} {bar} -{bar} {check} {Style.BRIGHT}Continuous Security Monitoring{Style.RESET_ALL} - scheduled scans with history, trends and alerts. +{bar} {check} {Style.BRIGHT}Send your findings{Style.RESET_ALL} - directly from the Prowler CLI to Prowler Cloud. +{bar} {check} {Style.BRIGHT}Continuous Security Monitoring{Style.RESET_ALL} - custom scheduling and scan configuration with history, trends and alerts. +{bar} {check} {Style.BRIGHT}Triage{Style.RESET_ALL} - review findings, flag false positives and track accepted risk with your team. {bar} {check} {Style.BRIGHT}Lighthouse AI + MCP{Style.RESET_ALL} - autonomous triage, custom dashboards, prioritization with prevention and remediation. {bar} {check} {Style.BRIGHT}Alerts{Style.RESET_ALL} - get notified when anything you want is happening. {bar} {check} {Style.BRIGHT}Live Compliance{Style.RESET_ALL} - dashboards for 50+ frameworks, always up to date. @@ -66,5 +84,5 @@ def print_prowler_cloud_banner(provider: str = None): {bar} {check} {Style.BRIGHT}Bulk Provisioning{Style.RESET_ALL} - add your entire AWS Organization in seconds. {bar} {check} {Style.BRIGHT}Integrations{Style.RESET_ALL} - Anything with our MCP + Jira, Slack, AWS Security Hub, Amazon S3, SSO and RBAC. {bar} -{bar} {Fore.BLUE}Start free at 👉 cloud.prowler.com{Style.RESET_ALL} +{bar} {banner_color}Start free at 👉 {_hyperlink(CLOUD_BANNER_URL, CLOUD_DISPLAY_TEXT)}{Style.RESET_ALL} """) diff --git a/prowler/providers/aws/services/amplify/__init__.py b/prowler/providers/aws/services/amplify/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/aws/services/amplify/amplify_app_no_secrets_in_environment/__init__.py b/prowler/providers/aws/services/amplify/amplify_app_no_secrets_in_environment/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/aws/services/amplify/amplify_app_no_secrets_in_environment/amplify_app_no_secrets_in_environment.metadata.json b/prowler/providers/aws/services/amplify/amplify_app_no_secrets_in_environment/amplify_app_no_secrets_in_environment.metadata.json new file mode 100644 index 0000000000..1a5c209fcb --- /dev/null +++ b/prowler/providers/aws/services/amplify/amplify_app_no_secrets_in_environment/amplify_app_no_secrets_in_environment.metadata.json @@ -0,0 +1,42 @@ +{ + "Provider": "aws", + "CheckID": "amplify_app_no_secrets_in_environment", + "CheckTitle": "Amplify app has no sensitive credentials in environment variables or build settings", + "CheckType": [ + "Software and Configuration Checks/AWS Security Best Practices", + "TTPs/Credential Access", + "Effects/Data Exposure", + "Sensitive Data Identifications/Security" + ], + "ServiceName": "amplify", + "SubServiceName": "", + "ResourceIdTemplate": "arn:partition:amplify:region:account-id:apps/app-id", + "Severity": "high", + "ResourceType": "AwsAmplifyApp", + "ResourceGroup": "security", + "Description": "AWS Amplify apps and their branches are inspected for hardcoded secrets, such as API keys, tokens, or passwords embedded in environment variables or build settings (buildSpec).", + "Risk": "Plaintext secrets in Amplify app environment variables or build configurations can be viewed by anyone with read access to the Amplify console, or may leak during the build process, exposing downstream resources and integrations.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://docs.aws.amazon.com/amplify/latest/userguide/environment-variables.html", + "https://docs.prowler.com/developer-guide/secret-scanning-checks" + ], + "Remediation": { + "Code": { + "CLI": "aws amplify update-app --app-id --environment-variables ", + "NativeIaC": "", + "Other": "1. Access the AWS Amplify console.\n2. Navigate to your app settings, choose Environment variables, and check if any secrets are stored in plaintext.\n3. For actual secrets, migrate them to environment secrets or AWS Secrets Manager / Parameter Store and reference them securely during the build phase.\n4. Clean the variables or buildSpec configuration.", + "Terraform": "" + }, + "Recommendation": { + "Text": "Avoid storing secrets in plaintext environment variables or build specifications for AWS Amplify apps. Store sensitive settings securely in AWS Systems Manager Parameter Store or AWS Secrets Manager.", + "Url": "https://hub.prowler.com/check/amplify_app_no_secrets_in_environment" + } + }, + "Categories": [ + "secrets" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "" +} diff --git a/prowler/providers/aws/services/amplify/amplify_app_no_secrets_in_environment/amplify_app_no_secrets_in_environment.py b/prowler/providers/aws/services/amplify/amplify_app_no_secrets_in_environment/amplify_app_no_secrets_in_environment.py new file mode 100644 index 0000000000..33175c724f --- /dev/null +++ b/prowler/providers/aws/services/amplify/amplify_app_no_secrets_in_environment/amplify_app_no_secrets_in_environment.py @@ -0,0 +1,105 @@ +import json + +from prowler.lib.check.models import Check, Check_Report_AWS +from prowler.lib.utils.utils import ( + SecretsScanError, + annotate_verified_secrets, + detect_secrets_scan_batch, +) +from prowler.providers.aws.services.amplify.amplify_client import amplify_client + + +class amplify_app_no_secrets_in_environment(Check): + """Check that AWS Amplify apps contain no hardcoded secrets in their environment variables or build settings.""" + + def execute(self) -> list[Check_Report_AWS]: + findings = [] + secrets_ignore_patterns = amplify_client.audit_config.get( + "secrets_ignore_patterns", [] + ) + validate = amplify_client.audit_config.get("secrets_validate", False) + apps = list(amplify_client.apps.values()) + line_context_by_app = {} + + payloads_list = [] + for app_index, app in enumerate(apps): + payload, line_context = _build_app_payload(app) + line_context_by_app[app_index] = line_context + if payload: + payloads_list.append((app_index, payload)) + + scan_error = None + try: + batch_results = detect_secrets_scan_batch( + payloads_list, + excluded_secrets=secrets_ignore_patterns, + validate=validate, + ) + except SecretsScanError as error: + batch_results = {} + scan_error = error + + for app_index, app in enumerate(apps): + report = Check_Report_AWS(metadata=self.metadata(), resource=app) + report.resource_tags = app.tags + report.status = "PASS" + report.status_extended = f"No secrets found in Amplify app {app.name} environment variables or build settings." + + line_context = line_context_by_app.get(app_index, {}) + if line_context: + if scan_error: + report.status = "MANUAL" + report.status_extended = ( + f"Could not scan Amplify app {app.name} environment variables " + f"for secrets: {scan_error}; manual review is required." + ) + findings.append(report) + continue + + detect_secrets_output = batch_results.get(app_index) + if detect_secrets_output: + secrets_string = ", ".join( + [ + f"{secret['type']} in {line_context.get(secret['line_number'], 'environment variables/build settings')}" + for secret in detect_secrets_output + ] + ) + report.status = "FAIL" + report.status_extended = ( + f"Potential {'secrets' if len(detect_secrets_output) > 1 else 'secret'} " + f"found in Amplify app {app.name} environment variables or build settings -> {secrets_string}." + ) + annotate_verified_secrets(report, detect_secrets_output) + + findings.append(report) + return findings + + +def _build_app_payload(app) -> tuple[str, dict[int, str]]: + """Build a line-oriented scan payload and map each line to a field context.""" + lines = [] + line_context = {} + + def add_line(context: str, value: str) -> None: + if value is None: + return + lines.append(json.dumps({context: value})) + line_context[len(lines)] = context + + # App environment variables + for var_name, var_value in app.environment_variables.items(): + add_line(f"app environment variable '{var_name}'", var_value) + + # App buildSpec + if app.build_spec: + for idx, line in enumerate(app.build_spec.splitlines(), start=1): + add_line(f"app buildSpec line {idx}", line) + + # Branch environment variables + for branch in app.branches: + for var_name, var_value in branch.environment_variables.items(): + add_line( + f"branch '{branch.name}' environment variable '{var_name}'", var_value + ) + + return "\n".join(lines), line_context diff --git a/prowler/providers/aws/services/amplify/amplify_client.py b/prowler/providers/aws/services/amplify/amplify_client.py new file mode 100644 index 0000000000..9d69c68424 --- /dev/null +++ b/prowler/providers/aws/services/amplify/amplify_client.py @@ -0,0 +1,4 @@ +from prowler.providers.aws.services.amplify.amplify_service import Amplify +from prowler.providers.common.provider import Provider + +amplify_client = Amplify(Provider.get_global_provider()) diff --git a/prowler/providers/aws/services/amplify/amplify_service.py b/prowler/providers/aws/services/amplify/amplify_service.py new file mode 100644 index 0000000000..1a80f9ebd3 --- /dev/null +++ b/prowler/providers/aws/services/amplify/amplify_service.py @@ -0,0 +1,97 @@ +from botocore.exceptions import ClientError +from pydantic.v1 import BaseModel, Field + +from prowler.lib.logger import logger +from prowler.lib.scan_filters.scan_filters import is_resource_filtered +from prowler.providers.aws.lib.service.service import AWSService + + +class Branch(BaseModel): + """Represents an AWS Amplify App Branch.""" + + name: str + arn: str + environment_variables: dict = Field(default_factory=dict) + + +class App(BaseModel): + """Represents an AWS Amplify App.""" + + id: str + name: str + arn: str + region: str + environment_variables: dict = Field(default_factory=dict) + build_spec: str = "" + branches: list[Branch] = Field(default_factory=list) + tags: list[dict] = Field(default_factory=list) + + +class Amplify(AWSService): + """AWS Amplify service class.""" + + def __init__(self, provider): + super().__init__(__class__.__name__, provider) + self.apps = {} + self.__threading_call__(self._list_apps) + if self.apps: + self.__threading_call__(self._list_branches, self.apps.values()) + + def _list_apps(self, regional_client) -> None: + logger.info("Amplify - Listing apps...") + try: + list_apps_paginator = regional_client.get_paginator("list_apps") + for page in list_apps_paginator.paginate(): + for app in page.get("apps", []): + app_id = app.get("appId") + app_name = app.get("name") + app_arn = app.get("appArn") + if not self.audit_resources or is_resource_filtered( + app_arn, self.audit_resources + ): + tags = app.get("tags", {}) + tags_list = [tags] if tags else [] + self.apps[app_arn] = App( + id=app_id, + name=app_name, + arn=app_arn, + region=regional_client.region, + environment_variables=app.get("environmentVariables", {}), + build_spec=app.get("buildSpec", ""), + tags=tags_list, + ) + except ClientError as error: + logger.error( + f"{regional_client.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + except Exception as error: + logger.error( + f"{regional_client.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + + def _list_branches(self, app: App) -> None: + logger.info(f"Amplify - Listing branches for app {app.name}...") + try: + regional_client = self.regional_clients[app.region] + list_branches_paginator = regional_client.get_paginator("list_branches") + for page in list_branches_paginator.paginate(appId=app.id): + for branch in page.get("branches", []): + branch_name = branch.get("branchName") + branch_arn = branch.get("branchArn") + app.branches.append( + Branch( + name=branch_name, + arn=branch_arn, + environment_variables=branch.get( + "environmentVariables", {} + ), + ) + ) + except ClientError as error: + logger.error( + f"{app.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + except Exception as error: + logger.error( + f"{app.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) diff --git a/prowler/providers/aws/services/elbv2/elbv2_listener_pqc_tls_enabled/__init__.py b/prowler/providers/aws/services/elbv2/elbv2_listener_pqc_tls_enabled/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/aws/services/elbv2/elbv2_listener_pqc_tls_enabled/elbv2_listener_pqc_tls_enabled.metadata.json b/prowler/providers/aws/services/elbv2/elbv2_listener_pqc_tls_enabled/elbv2_listener_pqc_tls_enabled.metadata.json new file mode 100644 index 0000000000..d86778d5ae --- /dev/null +++ b/prowler/providers/aws/services/elbv2/elbv2_listener_pqc_tls_enabled/elbv2_listener_pqc_tls_enabled.metadata.json @@ -0,0 +1,42 @@ +{ + "Provider": "aws", + "CheckID": "elbv2_listener_pqc_tls_enabled", + "CheckTitle": "ELBv2 HTTPS/TLS listeners use a post-quantum TLS security policy", + "CheckType": [ + "Software and Configuration Checks/AWS Security Best Practices" + ], + "ServiceName": "elbv2", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "low", + "ResourceType": "AwsElbv2LoadBalancer", + "ResourceGroup": "network", + "Description": "**ELBv2 HTTPS and TLS listeners** are assessed for use of **post-quantum (PQ) TLS security policies**. Listeners whose `SslPolicy` is not in the approved PQ set lack hybrid key exchange (ML-KEM 768 + ECDHE), which can increase harvest-now-decrypt-later exposure for recorded traffic.", + "Risk": "Without PQ-ready TLS policies, encrypted traffic captured today may be stored for future cryptanalysis if a **cryptographically relevant quantum computer** becomes available (**harvest-now, decrypt-later** attack). PQ-ready TLS policies reduce this long-term confidentiality risk for sensitive data, credentials, and session tokens transmitted through the load balancer.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://docs.aws.amazon.com/elasticloadbalancing/latest/application/describe-ssl-policies.html", + "https://aws.amazon.com/security/post-quantum-cryptography/", + "https://csrc.nist.gov/projects/post-quantum-cryptography" + ], + "Remediation": { + "Code": { + "CLI": "aws elbv2 modify-listener --listener-arn --ssl-policy ELBSecurityPolicy-TLS13-1-2-PQ-2025-09", + "NativeIaC": "```yaml\nResources:\n :\n Type: AWS::ElasticLoadBalancingV2::Listener\n Properties:\n LoadBalancerArn: \n Protocol: HTTPS\n Port: 443\n DefaultActions:\n - Type: forward\n TargetGroupArn: \n Certificates:\n - CertificateArn: \n SslPolicy: ELBSecurityPolicy-TLS13-1-2-PQ-2025-09 # FIX: uses a post-quantum TLS policy\n```", + "Other": "1. In the AWS Console, go to EC2 > Load Balancers\n2. Select the load balancer and open the Listeners tab\n3. Select the HTTPS/TLS listener and choose Edit\n4. Set Security policy to ELBSecurityPolicy-TLS13-1-2-PQ-2025-09 (or any approved PQ policy)\n5. Save changes", + "Terraform": "```hcl\nresource \"aws_lb_listener\" \"\" {\n load_balancer_arn = \"\"\n port = 443\n protocol = \"HTTPS\"\n ssl_policy = \"ELBSecurityPolicy-TLS13-1-2-PQ-2025-09\" # FIX: post-quantum TLS policy\n certificate_arn = \"\"\n\n default_action {\n type = \"forward\"\n target_group_arn = \"\"\n }\n}\n```" + }, + "Recommendation": { + "Text": "Migrate all ELBv2 HTTPS and TLS listeners to a **post-quantum TLS policy** (`ELBSecurityPolicy-TLS13-*-PQ-2025-09` family) to enable hybrid key exchange (ML-KEM + ECDHE). Periodically review and update policies as AWS publishes new PQ-ready options.", + "Url": "https://hub.prowler.com/check/elbv2_listener_pqc_tls_enabled" + } + }, + "Categories": [ + "encryption" + ], + "DependsOn": [], + "RelatedTo": [ + "elbv2_insecure_ssl_ciphers" + ], + "Notes": "" +} diff --git a/prowler/providers/aws/services/elbv2/elbv2_listener_pqc_tls_enabled/elbv2_listener_pqc_tls_enabled.py b/prowler/providers/aws/services/elbv2/elbv2_listener_pqc_tls_enabled/elbv2_listener_pqc_tls_enabled.py new file mode 100644 index 0000000000..270a2cc31b --- /dev/null +++ b/prowler/providers/aws/services/elbv2/elbv2_listener_pqc_tls_enabled/elbv2_listener_pqc_tls_enabled.py @@ -0,0 +1,73 @@ +"""Check that ELBv2 HTTPS/TLS listeners use post-quantum TLS policies.""" + +from prowler.lib.check.models import Check, Check_Report_AWS +from prowler.providers.aws.services.elbv2.elbv2_client import elbv2_client + +PQ_TLS_POLICIES_DEFAULT = [ + "ELBSecurityPolicy-TLS13-1-2-PQ-2025-09", + "ELBSecurityPolicy-TLS13-1-2-Ext1-PQ-2025-09", + "ELBSecurityPolicy-TLS13-1-2-Ext2-PQ-2025-09", + "ELBSecurityPolicy-TLS13-1-2-Res-PQ-2025-09", + "ELBSecurityPolicy-TLS13-1-3-PQ-2025-09", + "ELBSecurityPolicy-TLS13-1-2-FIPS-PQ-2025-09", + "ELBSecurityPolicy-TLS13-1-2-Ext0-FIPS-PQ-2025-09", + "ELBSecurityPolicy-TLS13-1-2-Ext1-FIPS-PQ-2025-09", + "ELBSecurityPolicy-TLS13-1-2-Ext2-FIPS-PQ-2025-09", + "ELBSecurityPolicy-TLS13-1-2-Res-FIPS-PQ-2025-09", + "ELBSecurityPolicy-TLS13-1-3-FIPS-PQ-2025-09", +] + + +class elbv2_listener_pqc_tls_enabled(Check): + """Verify that every ELBv2 HTTPS or TLS listener uses a post-quantum TLS policy. + + This check evaluates whether each HTTPS (ALB) or TLS (NLB) listener on an + ELBv2 load balancer terminates TLS with a security policy that offers + post-quantum (PQ) hybrid key exchange (ML-KEM 768 combined with ECDHE). + - PASS: All HTTPS/TLS listeners on the load balancer use a PQ TLS policy. + - FAIL: At least one HTTPS/TLS listener uses a non-PQ TLS policy. + """ + + def execute(self) -> list[Check_Report_AWS]: + """Execute the PQ TLS policy check for every ELBv2 load balancer. + + Returns: + A list of reports for load balancers with discovered listeners. + """ + findings = [] + pq_tls_policies = elbv2_client.audit_config.get( + "elbv2_listener_pqc_tls_allowed_policies", PQ_TLS_POLICIES_DEFAULT + ) + for lb in elbv2_client.loadbalancersv2.values(): + report = Check_Report_AWS(metadata=self.metadata(), resource=lb) + + if lb.listener_discovery_failed: + continue + + has_tls_listeners = False + non_pq_listeners = [] + for listener_arn, listener in lb.listeners.items(): + if listener.protocol in ("HTTPS", "TLS"): + has_tls_listeners = True + if listener.ssl_policy not in pq_tls_policies: + ssl_policy = listener.ssl_policy or "" + non_pq_listeners.append( + f"{listener.protocol}:{listener.port} ({listener_arn}) uses {ssl_policy}" + ) + + if not has_tls_listeners: + report.status = "PASS" + report.status_extended = f"ELBv2 {lb.name} has no HTTPS/TLS listeners." + findings.append(report) + continue + + if non_pq_listeners: + report.status = "FAIL" + report.status_extended = f"ELBv2 {lb.name} has HTTPS/TLS listeners without post-quantum TLS policy: {', '.join(non_pq_listeners)}." + else: + report.status = "PASS" + report.status_extended = f"ELBv2 {lb.name} has all HTTPS/TLS listeners using a post-quantum TLS policy." + + findings.append(report) + + return findings diff --git a/prowler/providers/aws/services/elbv2/elbv2_service.py b/prowler/providers/aws/services/elbv2/elbv2_service.py index c52110869f..e2d9bd3682 100644 --- a/prowler/providers/aws/services/elbv2/elbv2_service.py +++ b/prowler/providers/aws/services/elbv2/elbv2_service.py @@ -86,10 +86,14 @@ class ELBv2(AWSService): f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" ) else: + load_balancer[1].listener_discovery_failed = True + load_balancer[1].listener_discovery_error = str(error) logger.error( f"{regional_client.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" ) except Exception as error: + load_balancer[1].listener_discovery_failed = True + load_balancer[1].listener_discovery_error = str(error) logger.error( f"{regional_client.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" ) @@ -209,6 +213,8 @@ class LoadBalancerv2(BaseModel): drop_invalid_header_fields: Optional[str] cross_zone_load_balancing: Optional[str] listeners: Dict[str, Listenerv2] = {} + listener_discovery_failed: bool = False + listener_discovery_error: Optional[str] = None scheme: Optional[str] security_groups: list[str] = [] # Key: ZoneName, Value: SubnetId diff --git a/prowler/providers/kubernetes/services/core/core_minimize_hostpath_volume_mounts/__init__.py b/prowler/providers/kubernetes/services/core/core_minimize_hostpath_volume_mounts/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/kubernetes/services/core/core_minimize_hostpath_volume_mounts/core_minimize_hostpath_volume_mounts.metadata.json b/prowler/providers/kubernetes/services/core/core_minimize_hostpath_volume_mounts/core_minimize_hostpath_volume_mounts.metadata.json new file mode 100644 index 0000000000..f25b22231f --- /dev/null +++ b/prowler/providers/kubernetes/services/core/core_minimize_hostpath_volume_mounts/core_minimize_hostpath_volume_mounts.metadata.json @@ -0,0 +1,38 @@ +{ + "Provider": "kubernetes", + "CheckID": "core_minimize_hostpath_volume_mounts", + "CheckTitle": "Pod does not use hostPath volumes", + "CheckType": [], + "ServiceName": "core", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "high", + "ResourceType": "Pod", + "ResourceGroup": "container", + "Description": "**Kubernetes Pods** are evaluated for volumes of type `hostPath`, which mount paths from the node filesystem into a pod.", + "Risk": "`hostPath` volumes weaken the container boundary by exposing node files to workloads. A compromised container can read sensitive host data, tamper with node files, or use writable mounts to escalate privileges and affect node availability.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://kubernetes.io/docs/concepts/storage/volumes/#hostpath", + "https://kubernetes.io/docs/concepts/security/pod-security-standards/" + ], + "Remediation": { + "Code": { + "CLI": "kubectl get pod -n -o jsonpath='{range .metadata.ownerReferences[*]}{.kind}/{.name}{\"\\n\"}{end}{range .spec.volumes[?(@.hostPath)]}{.name}{\"\\t\"}{.hostPath.path}{\"\\n\"}{end}'\n# If the Pod is managed by a controller, edit the owning workload and remove hostPath volumes from its Pod template:\nkubectl edit / -n \n# For a standalone Pod, export the manifest, remove hostPath volumes, then recreate it:\nkubectl get pod -n -o yaml > pod.yaml\nkubectl delete pod -n \nkubectl apply -f pod.yaml", + "NativeIaC": "", + "Other": "1. Identify the workload that owns the failing Pod (Deployment/DaemonSet/StatefulSet/Job) or confirm it is a standalone Pod\n2. Edit the Pod template and remove volumes that define `hostPath`\n3. Replace hostPath with a safer volume type such as ConfigMap, Secret, emptyDir, persistentVolumeClaim, or a CSI volume when appropriate\n4. Apply the updated manifest and allow the workload to recreate Pods without hostPath volumes", + "Terraform": "```hcl\nresource \"kubernetes_pod\" \"\" {\n metadata {\n name = \"\"\n }\n spec {\n container {\n name = \"app\"\n image = \"nginx\"\n }\n # Do not define host_path blocks under volume.\n }\n}\n```" + }, + "Recommendation": { + "Text": "Disallow `hostPath` volumes by default with Pod Security Admission or policy-as-code controls. Permit narrow, read-only exceptions only for trusted system workloads, and prefer Kubernetes-native volume types that do not expose the node filesystem.", + "Url": "https://hub.prowler.com/check/core_minimize_hostpath_volume_mounts" + } + }, + "Categories": [ + "container-security", + "trust-boundaries" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "Exceptions for hostPath volumes should be narrowly scoped, read-only where possible, and monitored." +} diff --git a/prowler/providers/kubernetes/services/core/core_minimize_hostpath_volume_mounts/core_minimize_hostpath_volume_mounts.py b/prowler/providers/kubernetes/services/core/core_minimize_hostpath_volume_mounts/core_minimize_hostpath_volume_mounts.py new file mode 100644 index 0000000000..a693f52f0e --- /dev/null +++ b/prowler/providers/kubernetes/services/core/core_minimize_hostpath_volume_mounts/core_minimize_hostpath_volume_mounts.py @@ -0,0 +1,23 @@ +from prowler.lib.check.models import Check, Check_Report_Kubernetes +from prowler.providers.kubernetes.services.core.core_client import core_client + + +class core_minimize_hostpath_volume_mounts(Check): + def execute(self) -> list[Check_Report_Kubernetes]: + findings = [] + for pod in core_client.pods.values(): + report = Check_Report_Kubernetes(metadata=self.metadata(), resource=pod) + report.status = "PASS" + report.status_extended = f"Pod {pod.name} does not use hostPath volumes." + + for volume in pod.volumes or []: + if volume.get("host_path"): + report.status = "FAIL" + report.status_extended = ( + f"Pod {pod.name} uses hostPath volume {volume['name']}." + ) + break + + findings.append(report) + + return findings diff --git a/prowler/providers/kubernetes/services/core/core_readonly_root_filesystem_enabled/__init__.py b/prowler/providers/kubernetes/services/core/core_readonly_root_filesystem_enabled/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/kubernetes/services/core/core_readonly_root_filesystem_enabled/core_readonly_root_filesystem_enabled.metadata.json b/prowler/providers/kubernetes/services/core/core_readonly_root_filesystem_enabled/core_readonly_root_filesystem_enabled.metadata.json new file mode 100644 index 0000000000..d28b7ab94f --- /dev/null +++ b/prowler/providers/kubernetes/services/core/core_readonly_root_filesystem_enabled/core_readonly_root_filesystem_enabled.metadata.json @@ -0,0 +1,37 @@ +{ + "Provider": "kubernetes", + "CheckID": "core_readonly_root_filesystem_enabled", + "CheckTitle": "Containers should run with a read-only root filesystem", + "CheckType": [], + "ServiceName": "core", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "medium", + "ResourceType": "Pod", + "ResourceGroup": "container", + "Description": "**Kubernetes Pods** are evaluated to ensure every container sets `readOnlyRootFilesystem: true` in its `securityContext`. A writable root filesystem lets an attacker who gains code execution modify binaries, drop tools, or persist malicious files inside the container.", + "Risk": "Without a read-only root filesystem an attacker with code execution inside a container can tamper with binaries or libraries (**integrity**), write credential files or exfiltration tools (**confidentiality**), and persist across process restarts (**availability**).", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://kubernetes.io/docs/tasks/configure-pod-container/security-context/", + "https://kubernetes.io/docs/concepts/security/pod-security-standards/" + ], + "Remediation": { + "Code": { + "CLI": "kubectl patch deployment/ -n -p '{\"spec\":{\"template\":{\"spec\":{\"containers\":[{\"name\":\"\",\"securityContext\":{\"readOnlyRootFilesystem\":true}}]}}}}'\n# Deployment template example for regular containers. For init containers, use initContainers instead of containers. Ephemeral containers are added through the ephemeralcontainers subresource; remove or recreate debug containers with a compliant securityContext.", + "NativeIaC": "", + "Other": "1. Open your Kubernetes Dashboard (or your cloud provider's Kubernetes console) and locate the workload managing the failing Pod (Deployment/StatefulSet/DaemonSet)\n2. Click Edit to modify the manifest (YAML)\n3. For each container missing `securityContext.readOnlyRootFilesystem: true`, add or set it to `true`\n4. If the container needs write access, mount a writable `emptyDir` or `persistentVolumeClaim` at the specific paths that require writes instead of making the entire root filesystem writable\n5. Save/Apply the changes to trigger a rollout\n6. Verify new Pods have `securityContext.readOnlyRootFilesystem` set to `true`", + "Terraform": "```hcl\nresource \"kubernetes_pod\" \"main\" {\n metadata {\n name = \"\"\n }\n spec {\n container {\n name = \"app\"\n image = \"nginx\"\n security_context {\n read_only_root_filesystem = true # Critical: enforce a read-only root filesystem\n }\n }\n }\n}\n```" + }, + "Recommendation": { + "Text": "Set `readOnlyRootFilesystem: true` for every regular, init, and ephemeral container that is part of a Pod spec. Mount writable `emptyDir` volumes only at the specific paths that genuinely need write access (e.g., `/tmp`, `/var/cache`). Enforce this at admission time with custom admission policies such as ValidatingAdmissionPolicy or OPA/Gatekeeper.", + "Url": "https://hub.prowler.com/check/core_readonly_root_filesystem_enabled" + } + }, + "Categories": [ + "container-security" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "Regular, init, and ephemeral containers are evaluated. Workloads that genuinely require root filesystem writes should mount writable volumes at specific paths and may be exempted via Prowler's mutelist." +} diff --git a/prowler/providers/kubernetes/services/core/core_readonly_root_filesystem_enabled/core_readonly_root_filesystem_enabled.py b/prowler/providers/kubernetes/services/core/core_readonly_root_filesystem_enabled/core_readonly_root_filesystem_enabled.py new file mode 100644 index 0000000000..cb7eebcdf7 --- /dev/null +++ b/prowler/providers/kubernetes/services/core/core_readonly_root_filesystem_enabled/core_readonly_root_filesystem_enabled.py @@ -0,0 +1,38 @@ +from prowler.lib.check.models import Check, Check_Report_Kubernetes +from prowler.providers.kubernetes.services.core.core_client import core_client + + +class core_readonly_root_filesystem_enabled(Check): + """Check whether every container in each Pod has readOnlyRootFilesystem set to true.""" + + def execute(self) -> list[Check_Report_Kubernetes]: + """Execute the Kubernetes read-only root filesystem check. + + Returns: + List of check reports for Kubernetes pods. + """ + findings = [] + for pod in core_client.pods.values(): + report = Check_Report_Kubernetes(metadata=self.metadata(), resource=pod) + report.status = "PASS" + report.status_extended = f"Pod {pod.name} has read-only root filesystem enabled for all containers." + + for containers in ( + pod.containers, + pod.init_containers, + pod.ephemeral_containers, + ): + for container in (containers or {}).values(): + if ( + container.security_context.get("read_only_root_filesystem") + is not True + ): + report.status = "FAIL" + report.status_extended = f"Pod {pod.name} container {container.name} does not have readOnlyRootFilesystem set to true." + break + if report.status == "FAIL": + break + + findings.append(report) + + return findings diff --git a/prowler/providers/kubernetes/services/core/core_service.py b/prowler/providers/kubernetes/services/core/core_service.py index b53a81779c..dc44d8f51f 100644 --- a/prowler/providers/kubernetes/services/core/core_service.py +++ b/prowler/providers/kubernetes/services/core/core_service.py @@ -32,6 +32,7 @@ class Core(KubernetesService): ephemeral_containers = self._build_containers( pod.spec.ephemeral_containers ) + volumes = self._build_volumes(pod.spec.volumes) self.pods[pod.metadata.uid] = Pod( name=pod.metadata.name, uid=pod.metadata.uid, @@ -54,6 +55,7 @@ class Core(KubernetesService): containers=containers, init_containers=init_containers, ephemeral_containers=ephemeral_containers, + volumes=volumes, ) except Exception as error: logger.error( @@ -101,6 +103,20 @@ class Core(KubernetesService): ) return pod_containers + @staticmethod + def _build_volumes(volumes) -> List[dict]: + pod_volumes = [] + for volume in volumes or []: + pod_volumes.append( + { + "name": volume.name, + "host_path": ( + volume.host_path.to_dict() if volume.host_path else None + ), + } + ) + return pod_volumes + def _list_config_maps(self): try: response = self.client.list_config_map_for_all_namespaces() @@ -188,6 +204,7 @@ class Pod(BaseModel): containers: Optional[dict] init_containers: Optional[dict] = None ephemeral_containers: Optional[dict] = None + volumes: Optional[List[dict]] = None class ConfigMap(BaseModel): diff --git a/tests/config/config_test.py b/tests/config/config_test.py index 365efbc0c9..d10c384b0d 100644 --- a/tests/config/config_test.py +++ b/tests/config/config_test.py @@ -320,6 +320,19 @@ config_aws = { "minimum_snapshot_retention_period": 7, "elb_min_azs": 2, "elbv2_min_azs": 2, + "elbv2_listener_pqc_tls_allowed_policies": [ + "ELBSecurityPolicy-TLS13-1-2-PQ-2025-09", + "ELBSecurityPolicy-TLS13-1-2-Ext1-PQ-2025-09", + "ELBSecurityPolicy-TLS13-1-2-Ext2-PQ-2025-09", + "ELBSecurityPolicy-TLS13-1-2-Res-PQ-2025-09", + "ELBSecurityPolicy-TLS13-1-3-PQ-2025-09", + "ELBSecurityPolicy-TLS13-1-2-FIPS-PQ-2025-09", + "ELBSecurityPolicy-TLS13-1-2-Ext0-FIPS-PQ-2025-09", + "ELBSecurityPolicy-TLS13-1-2-Ext1-FIPS-PQ-2025-09", + "ELBSecurityPolicy-TLS13-1-2-Ext2-FIPS-PQ-2025-09", + "ELBSecurityPolicy-TLS13-1-2-Res-FIPS-PQ-2025-09", + "ELBSecurityPolicy-TLS13-1-3-FIPS-PQ-2025-09", + ], "secrets_ignore_patterns": [], "max_days_secret_unused": 90, "max_days_secret_unrotated": 90, diff --git a/tests/config/fixtures/config.yaml b/tests/config/fixtures/config.yaml index 39cba5f27d..df663e923a 100644 --- a/tests/config/fixtures/config.yaml +++ b/tests/config/fixtures/config.yaml @@ -362,6 +362,21 @@ aws: # Minimum number of Availability Zones that an ELBv2 must be in elbv2_min_azs: 2 + # aws.elbv2_listener_pqc_tls_enabled + # Allowed post-quantum TLS security policies for ELBv2 HTTPS/TLS listeners + elbv2_listener_pqc_tls_allowed_policies: + - "ELBSecurityPolicy-TLS13-1-2-PQ-2025-09" + - "ELBSecurityPolicy-TLS13-1-2-Ext1-PQ-2025-09" + - "ELBSecurityPolicy-TLS13-1-2-Ext2-PQ-2025-09" + - "ELBSecurityPolicy-TLS13-1-2-Res-PQ-2025-09" + - "ELBSecurityPolicy-TLS13-1-3-PQ-2025-09" + - "ELBSecurityPolicy-TLS13-1-2-FIPS-PQ-2025-09" + - "ELBSecurityPolicy-TLS13-1-2-Ext0-FIPS-PQ-2025-09" + - "ELBSecurityPolicy-TLS13-1-2-Ext1-FIPS-PQ-2025-09" + - "ELBSecurityPolicy-TLS13-1-2-Ext2-FIPS-PQ-2025-09" + - "ELBSecurityPolicy-TLS13-1-2-Res-FIPS-PQ-2025-09" + - "ELBSecurityPolicy-TLS13-1-3-FIPS-PQ-2025-09" + # AWS Elasticache Configuration # aws.elasticache_redis_cluster_backup_enabled # Minimum number of days that a Redis cluster must have backups retention period diff --git a/tests/config/schema/aws_schema_test.py b/tests/config/schema/aws_schema_test.py index d37a2e0bf6..838853f52a 100644 --- a/tests/config/schema/aws_schema_test.py +++ b/tests/config/schema/aws_schema_test.py @@ -176,6 +176,33 @@ class Test_AWS_Enums: assert _validate({"ecr_repository_vulnerability_minimum_severity": level}) == {} +class TestAWSELBv2PQCTLSAllowedPolicies: + def test_valid_policy_list_round_trips(self): + policies = [ + "ELBSecurityPolicy-TLS13-1-2-Res-2021-06", + "ELBSecurityPolicy-TLS13-1-3-2021-06", + ] + + assert _validate({"elbv2_listener_pqc_tls_allowed_policies": policies}) == { + "elbv2_listener_pqc_tls_allowed_policies": policies + } + + def test_key_is_exposed_in_scan_config_schema(self): + aws_properties = SCAN_CONFIG_SCHEMA["properties"]["aws"]["properties"] + + assert "elbv2_listener_pqc_tls_allowed_policies" in aws_properties + + @pytest.mark.parametrize( + "value", + [ + "ELBSecurityPolicy-TLS13-1-2-Res-2021-06", + ["ELBSecurityPolicy-TLS13-1-2-Res-2021-06", 123], + ], + ) + def test_invalid_policy_values_are_dropped(self, value): + assert _validate({"elbv2_listener_pqc_tls_allowed_policies": value}) == {} + + class Test_AWS_Secrets_Ignore_Files: def test_valid_file_patterns_round_trip(self): files = ["*.deps.json", "vendor/*.js"] diff --git a/tests/providers/aws/services/amplify/amplify_app_no_secrets_in_environment/amplify_app_no_secrets_in_environment_test.py b/tests/providers/aws/services/amplify/amplify_app_no_secrets_in_environment/amplify_app_no_secrets_in_environment_test.py new file mode 100644 index 0000000000..e78bf364d3 --- /dev/null +++ b/tests/providers/aws/services/amplify/amplify_app_no_secrets_in_environment/amplify_app_no_secrets_in_environment_test.py @@ -0,0 +1,194 @@ +from unittest import mock + +from prowler.lib.utils.utils import SecretsScanError +from prowler.providers.aws.services.amplify.amplify_service import App, Branch +from tests.providers.aws.utils import ( + AWS_ACCOUNT_NUMBER, + AWS_REGION_US_EAST_1, + set_mocked_aws_provider, +) + + +class Test_amplify_app_no_secrets_in_environment: + def test_no_apps(self): + amplify_client = mock.MagicMock() + amplify_client.apps = {} + amplify_client.audit_config = {"secrets_ignore_patterns": []} + + result = _execute_check(amplify_client) + + assert len(result) == 0 + + def test_app_with_no_secrets(self): + app = _build_app( + environment_variables={"key1": "val1"}, + build_spec="version: 1\nfrontend:\n phases:\n build:\n commands:\n - echo hello", + branches=[ + Branch( + name="main", + arn=f"arn:aws:amplify:{AWS_REGION_US_EAST_1}:{AWS_ACCOUNT_NUMBER}:apps/app-12345/branches/main", + environment_variables={"branch_key": "branch_val"}, + ) + ], + ) + amplify_client = mock.MagicMock() + amplify_client.apps = {app.arn: app} + amplify_client.audit_config = {"secrets_ignore_patterns": []} + + result = _execute_check(amplify_client) + + assert len(result) == 1 + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == "No secrets found in Amplify app test-app environment variables or build settings." + ) + assert result[0].region == AWS_REGION_US_EAST_1 + assert result[0].resource_id == "app-12345" + assert result[0].resource_arn == app.arn + + def test_app_with_secrets_in_app_variables(self): + app = _build_app( + environment_variables={ + "db_pass": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.dozjgNryP4J3jVmNHl0w5N_XgL0n3I9PlFUP0THsR8U" + }, + build_spec="", + branches=[], + ) + amplify_client = mock.MagicMock() + amplify_client.apps = {app.arn: app} + amplify_client.audit_config = {"secrets_ignore_patterns": []} + + result = _execute_check(amplify_client) + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert "app environment variable 'db_pass'" in result[0].status_extended + + def test_app_with_secrets_in_branch_variables(self): + app = _build_app( + environment_variables={}, + build_spec="", + branches=[ + Branch( + name="dev", + arn=f"arn:aws:amplify:{AWS_REGION_US_EAST_1}:{AWS_ACCOUNT_NUMBER}:apps/app-12345/branches/dev", + environment_variables={ + "api_key": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.dozjgNryP4J3jVmNHl0w5N_XgL0n3I9PlFUP0THsR8U" + }, + ) + ], + ) + amplify_client = mock.MagicMock() + amplify_client.apps = {app.arn: app} + amplify_client.audit_config = {"secrets_ignore_patterns": []} + + result = _execute_check(amplify_client) + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + "branch 'dev' environment variable 'api_key'" in result[0].status_extended + ) + + def test_app_with_secrets_in_build_spec(self): + app = _build_app( + environment_variables={}, + build_spec="version: 1\nfrontend:\n phases:\n build:\n commands:\n - export JWT=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.dozjgNryP4J3jVmNHl0w5N_XgL0n3I9PlFUP0THsR8U", + branches=[], + ) + amplify_client = mock.MagicMock() + amplify_client.apps = {app.arn: app} + amplify_client.audit_config = {"secrets_ignore_patterns": []} + + result = _execute_check(amplify_client) + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert "app buildSpec line 6" in result[0].status_extended + + def test_app_scan_error_marks_manual(self): + app = _build_app( + environment_variables={"key1": "val1"}, + build_spec="version: 1", + branches=[], + ) + amplify_client = mock.MagicMock() + amplify_client.apps = {app.arn: app} + amplify_client.audit_config = {"secrets_ignore_patterns": []} + + result = _execute_check_with_mocked_scan( + amplify_client, + side_effect=SecretsScanError("Scanner failure"), + ) + + assert len(result) == 1 + assert result[0].status == "MANUAL" + assert ( + "Could not scan Amplify app test-app environment variables for secrets: Scanner failure" + in result[0].status_extended + ) + + +def _build_app(environment_variables: dict, build_spec: str, branches: list) -> App: + app_id = "app-12345" + app_name = "test-app" + app_arn = ( + f"arn:aws:amplify:{AWS_REGION_US_EAST_1}:" f"{AWS_ACCOUNT_NUMBER}:apps/{app_id}" + ) + return App( + id=app_id, + name=app_name, + arn=app_arn, + region=AWS_REGION_US_EAST_1, + environment_variables=environment_variables, + build_spec=build_spec, + branches=branches, + tags=[], + ) + + +def _execute_check(amplify_client): + aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1]) + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch( + "prowler.providers.aws.services.amplify.amplify_app_no_secrets_in_environment.amplify_app_no_secrets_in_environment.amplify_client", + amplify_client, + ), + ): + from prowler.providers.aws.services.amplify.amplify_app_no_secrets_in_environment.amplify_app_no_secrets_in_environment import ( + amplify_app_no_secrets_in_environment, + ) + + check = amplify_app_no_secrets_in_environment() + return check.execute() + + +def _execute_check_with_mocked_scan( + amplify_client, return_value=None, side_effect=None +): + aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1]) + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch( + "prowler.providers.aws.services.amplify.amplify_app_no_secrets_in_environment.amplify_app_no_secrets_in_environment.amplify_client", + amplify_client, + ), + ): + import prowler.providers.aws.services.amplify.amplify_app_no_secrets_in_environment.amplify_app_no_secrets_in_environment as check_module + + with mock.patch.object( + check_module, + "detect_secrets_scan_batch", + return_value=return_value, + side_effect=side_effect, + ): + check = check_module.amplify_app_no_secrets_in_environment() + return check.execute() diff --git a/tests/providers/aws/services/amplify/amplify_service_test.py b/tests/providers/aws/services/amplify/amplify_service_test.py new file mode 100644 index 0000000000..90ff5b67ab --- /dev/null +++ b/tests/providers/aws/services/amplify/amplify_service_test.py @@ -0,0 +1,91 @@ +from unittest.mock import patch + +import botocore +from moto import mock_aws + +from prowler.providers.aws.services.amplify.amplify_service import Amplify, App, Branch +from tests.providers.aws.utils import ( + AWS_ACCOUNT_NUMBER, + AWS_REGION_US_EAST_1, + set_mocked_aws_provider, +) + +app_id = "app-12345" +app_name = "test-app" +app_arn = f"arn:aws:amplify:{AWS_REGION_US_EAST_1}:{AWS_ACCOUNT_NUMBER}:apps/{app_id}" +branch_name = "main" +branch_arn = f"{app_arn}/branches/{branch_name}" + +app_environment_variables = {"app_key": "app_val"} +branch_environment_variables = {"branch_key": "branch_val"} +build_spec = "version: 1" +app_tags = {"tag_key": "tag_val"} + +make_api_call = botocore.client.BaseClient._make_api_call + + +def mock_make_api_call(self, operation_name, kwarg): + if operation_name == "ListApps": + return { + "apps": [ + { + "appId": app_id, + "name": app_name, + "appArn": app_arn, + "environmentVariables": app_environment_variables, + "buildSpec": build_spec, + "tags": app_tags, + } + ] + } + if operation_name == "ListBranches": + return { + "branches": [ + { + "branchArn": branch_arn, + "branchName": branch_name, + "environmentVariables": branch_environment_variables, + } + ] + } + return make_api_call(self, operation_name, kwarg) + + +def mock_generate_regional_clients(provider, service): + regional_client = provider._session.current_session.client( + service, region_name=AWS_REGION_US_EAST_1 + ) + regional_client.region = AWS_REGION_US_EAST_1 + return {AWS_REGION_US_EAST_1: regional_client} + + +@patch("botocore.client.BaseClient._make_api_call", new=mock_make_api_call) +@patch( + "prowler.providers.aws.aws_provider.AwsProvider.generate_regional_clients", + new=mock_generate_regional_clients, +) +class TestAmplifyService: + @mock_aws + def test_amplify_service(self): + amplify = Amplify(set_mocked_aws_provider([AWS_REGION_US_EAST_1])) + + assert amplify.session.__class__.__name__ == "Session" + assert amplify.service == "amplify" + assert len(amplify.apps) == 1 + assert isinstance(amplify.apps[app_arn], App) + + app = amplify.apps[app_arn] + assert app.id == app_id + assert app.name == app_name + assert app.arn == app_arn + assert app.region == AWS_REGION_US_EAST_1 + assert app.environment_variables == app_environment_variables + assert app.build_spec == build_spec + assert app.tags == [app_tags] + + assert len(app.branches) == 1 + branch = app.branches[0] + assert isinstance(branch, Branch) + assert branch.name == branch_name + assert branch.arn == branch_arn + assert branch.environment_variables == branch_environment_variables diff --git a/tests/providers/aws/services/elbv2/elbv2_listener_pqc_tls_enabled/elbv2_listener_pqc_tls_enabled_test.py b/tests/providers/aws/services/elbv2/elbv2_listener_pqc_tls_enabled/elbv2_listener_pqc_tls_enabled_test.py new file mode 100644 index 0000000000..12f4d5a4dd --- /dev/null +++ b/tests/providers/aws/services/elbv2/elbv2_listener_pqc_tls_enabled/elbv2_listener_pqc_tls_enabled_test.py @@ -0,0 +1,673 @@ +"""Tests for elbv2_listener_pqc_tls_enabled check.""" + +from unittest import mock + +import pytest +from boto3 import client, resource +from botocore.exceptions import ClientError +from moto import mock_aws + +from tests.providers.aws.utils import ( + AWS_REGION_EU_WEST_1, + AWS_REGION_EU_WEST_1_AZA, + AWS_REGION_EU_WEST_1_AZB, + AWS_REGION_US_EAST_1, + set_mocked_aws_provider, +) + + +class Test_elbv2_listener_pqc_tls_enabled: + """Test cases for the elbv2_listener_pqc_tls_enabled check.""" + + def _create_alb_infrastructure(self, region=AWS_REGION_EU_WEST_1): + """Helper to create VPC, subnets, security group, target group, and ALB. + + Returns a tuple of (elbv2_client, lb_response, target_group_arn). + """ + conn = client("elbv2", region_name=region) + ec2 = resource("ec2", region_name=region) + + security_group = ec2.create_security_group( + GroupName="a-security-group", Description="First One" + ) + vpc = ec2.create_vpc(CidrBlock="172.28.7.0/24", InstanceTenancy="default") + subnet1 = ec2.create_subnet( + VpcId=vpc.id, + CidrBlock="172.28.7.192/26", + AvailabilityZone=f"{region}a", + ) + subnet2 = ec2.create_subnet( + VpcId=vpc.id, + CidrBlock="172.28.7.0/26", + AvailabilityZone=f"{region}b", + ) + + lb = conn.create_load_balancer( + Name="my-lb", + Subnets=[subnet1.id, subnet2.id], + SecurityGroups=[security_group.id], + Scheme="internal", + Type="application", + )["LoadBalancers"][0] + + response = conn.create_target_group( + Name="a-target", + Protocol="HTTP", + Port=8080, + VpcId=vpc.id, + HealthCheckProtocol="HTTP", + HealthCheckPort="8080", + HealthCheckPath="/", + HealthCheckIntervalSeconds=5, + HealthCheckTimeoutSeconds=3, + HealthyThresholdCount=5, + UnhealthyThresholdCount=2, + Matcher={"HttpCode": "200"}, + ) + target_group_arn = response["TargetGroups"][0]["TargetGroupArn"] + + return conn, lb, target_group_arn + + def _create_nlb_infrastructure(self, region=AWS_REGION_EU_WEST_1): + """Helper to create VPC, subnets, target group, and NLB. + + Returns a tuple of (elbv2_client, lb_response, target_group_arn). + """ + conn = client("elbv2", region_name=region) + ec2 = resource("ec2", region_name=region) + + vpc = ec2.create_vpc(CidrBlock="172.28.7.0/24", InstanceTenancy="default") + subnet1 = ec2.create_subnet( + VpcId=vpc.id, + CidrBlock="172.28.7.192/26", + AvailabilityZone=AWS_REGION_EU_WEST_1_AZA, + ) + subnet2 = ec2.create_subnet( + VpcId=vpc.id, + CidrBlock="172.28.7.0/26", + AvailabilityZone=AWS_REGION_EU_WEST_1_AZB, + ) + + lb = conn.create_load_balancer( + Name="my-nlb", + Subnets=[subnet1.id, subnet2.id], + Scheme="internal", + Type="network", + )["LoadBalancers"][0] + + response = conn.create_target_group( + Name="a-target", + Protocol="TCP", + Port=8080, + VpcId=vpc.id, + ) + target_group_arn = response["TargetGroups"][0]["TargetGroupArn"] + + return conn, lb, target_group_arn + + def _mock_and_execute(self, audit_config=None): + """Helper to set up mocks and execute the check. + + Must be called inside a @mock_aws decorated method, after AWS + resources have been created with moto. + """ + from prowler.providers.aws.services.elbv2.elbv2_service import ELBv2 + + audit_config = audit_config or {} + aws_provider = set_mocked_aws_provider( + [AWS_REGION_EU_WEST_1, AWS_REGION_US_EAST_1], + create_default_organization=False, + audit_config=audit_config, + ) + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_aws_provider( + [AWS_REGION_EU_WEST_1, AWS_REGION_US_EAST_1], + audit_config=audit_config, + ), + ), + mock.patch( + "prowler.providers.aws.services.elbv2.elbv2_listener_pqc_tls_enabled.elbv2_listener_pqc_tls_enabled.elbv2_client", + new=ELBv2(aws_provider), + ), + ): + from prowler.providers.aws.services.elbv2.elbv2_listener_pqc_tls_enabled.elbv2_listener_pqc_tls_enabled import ( + elbv2_listener_pqc_tls_enabled, + ) + + check = elbv2_listener_pqc_tls_enabled() + return check.execute() + + def _assert_listener_arn_in_status(self, result, listener_arn): + """Assert that remediation details identify the affected listener ARN.""" + assert listener_arn in result[0].status_extended + + # ------------------------------------------------------------------ + # No-resource scenarios + # ------------------------------------------------------------------ + + @mock_aws + def test_no_load_balancers(self): + """Test that no findings are returned when there are no load balancers.""" + result = self._mock_and_execute() + assert len(result) == 0 + + @mock_aws + def test_lb_with_http_listener_only(self): + """Test PASS when a load balancer has no HTTPS/TLS listeners.""" + conn, lb, target_group_arn = self._create_alb_infrastructure() + conn.create_listener( + LoadBalancerArn=lb["LoadBalancerArn"], + Protocol="HTTP", + Port=80, + DefaultActions=[{"Type": "forward", "TargetGroupArn": target_group_arn}], + ) + + result = self._mock_and_execute() + + assert len(result) == 1 + assert result[0].status == "PASS" + assert result[0].status_extended == "ELBv2 my-lb has no HTTPS/TLS listeners." + assert result[0].resource_id == "my-lb" + + # ------------------------------------------------------------------ + # PASS scenarios + # ------------------------------------------------------------------ + + @pytest.mark.parametrize( + "ssl_policy", + [ + "ELBSecurityPolicy-TLS13-1-2-PQ-2025-09", + "ELBSecurityPolicy-TLS13-1-2-Ext1-PQ-2025-09", + "ELBSecurityPolicy-TLS13-1-2-Ext2-PQ-2025-09", + "ELBSecurityPolicy-TLS13-1-2-Res-PQ-2025-09", + "ELBSecurityPolicy-TLS13-1-3-PQ-2025-09", + "ELBSecurityPolicy-TLS13-1-2-FIPS-PQ-2025-09", + "ELBSecurityPolicy-TLS13-1-2-Ext0-FIPS-PQ-2025-09", + "ELBSecurityPolicy-TLS13-1-2-Ext1-FIPS-PQ-2025-09", + "ELBSecurityPolicy-TLS13-1-2-Ext2-FIPS-PQ-2025-09", + "ELBSecurityPolicy-TLS13-1-2-Res-FIPS-PQ-2025-09", + "ELBSecurityPolicy-TLS13-1-3-FIPS-PQ-2025-09", + ], + ) + @mock_aws + def test_listener_with_pq_policy_pass(self, ssl_policy): + """Test PASS when HTTPS listener uses an allowed PQ TLS policy.""" + conn, lb, target_group_arn = self._create_alb_infrastructure() + conn.create_listener( + LoadBalancerArn=lb["LoadBalancerArn"], + Protocol="HTTPS", + Port=443, + SslPolicy=ssl_policy, + DefaultActions=[{"Type": "forward", "TargetGroupArn": target_group_arn}], + ) + + result = self._mock_and_execute() + + assert len(result) == 1 + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == "ELBv2 my-lb has all HTTPS/TLS listeners using a post-quantum TLS policy." + ) + assert result[0].resource_id == "my-lb" + assert result[0].resource_arn == lb["LoadBalancerArn"] + assert result[0].region == AWS_REGION_EU_WEST_1 + + @mock_aws + def test_multiple_https_listeners_all_pq_pass(self): + """Test PASS when a LB has multiple HTTPS listeners all using PQ policies.""" + conn, lb, target_group_arn = self._create_alb_infrastructure() + conn.create_listener( + LoadBalancerArn=lb["LoadBalancerArn"], + Protocol="HTTPS", + Port=443, + SslPolicy="ELBSecurityPolicy-TLS13-1-2-PQ-2025-09", + DefaultActions=[{"Type": "forward", "TargetGroupArn": target_group_arn}], + ) + conn.create_listener( + LoadBalancerArn=lb["LoadBalancerArn"], + Protocol="HTTPS", + Port=8443, + SslPolicy="ELBSecurityPolicy-TLS13-1-3-FIPS-PQ-2025-09", + DefaultActions=[{"Type": "forward", "TargetGroupArn": target_group_arn}], + ) + + result = self._mock_and_execute() + + assert len(result) == 1 + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == "ELBv2 my-lb has all HTTPS/TLS listeners using a post-quantum TLS policy." + ) + assert result[0].resource_id == "my-lb" + + @mock_aws + def test_mixed_http_and_pq_https_listeners_pass(self): + """Test PASS when LB has both HTTP and HTTPS listeners, HTTPS using PQ policy.""" + conn, lb, target_group_arn = self._create_alb_infrastructure() + # HTTP listener (out of scope) + conn.create_listener( + LoadBalancerArn=lb["LoadBalancerArn"], + Protocol="HTTP", + Port=80, + DefaultActions=[{"Type": "forward", "TargetGroupArn": target_group_arn}], + ) + # HTTPS listener with PQ policy + conn.create_listener( + LoadBalancerArn=lb["LoadBalancerArn"], + Protocol="HTTPS", + Port=443, + SslPolicy="ELBSecurityPolicy-TLS13-1-2-PQ-2025-09", + DefaultActions=[{"Type": "forward", "TargetGroupArn": target_group_arn}], + ) + + result = self._mock_and_execute() + + assert len(result) == 1 + assert result[0].status == "PASS" + assert result[0].resource_id == "my-lb" + + # ------------------------------------------------------------------ + # FAIL scenarios + # ------------------------------------------------------------------ + + @mock_aws + def test_listener_with_classical_tls_policy_fail(self): + """Test FAIL when HTTPS listener uses a classical (non-PQ) TLS policy.""" + conn, lb, target_group_arn = self._create_alb_infrastructure() + listener = conn.create_listener( + LoadBalancerArn=lb["LoadBalancerArn"], + Protocol="HTTPS", + Port=443, + SslPolicy="ELBSecurityPolicy-TLS13-1-2-2021-06", + DefaultActions=[{"Type": "forward", "TargetGroupArn": target_group_arn}], + )["Listeners"][0] + + result = self._mock_and_execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == f"ELBv2 my-lb has HTTPS/TLS listeners without post-quantum TLS policy: HTTPS:443 ({listener['ListenerArn']}) uses ELBSecurityPolicy-TLS13-1-2-2021-06." + ) + self._assert_listener_arn_in_status(result, listener["ListenerArn"]) + assert result[0].resource_id == "my-lb" + assert result[0].resource_arn == lb["LoadBalancerArn"] + assert result[0].region == AWS_REGION_EU_WEST_1 + + @mock_aws + def test_listener_with_tls_1_0_pq_policy_fails_by_default(self): + """Test FAIL when HTTPS listener uses a TLS 1.0-minimum PQ policy by default.""" + conn, lb, target_group_arn = self._create_alb_infrastructure() + listener = conn.create_listener( + LoadBalancerArn=lb["LoadBalancerArn"], + Protocol="HTTPS", + Port=443, + SslPolicy="ELBSecurityPolicy-TLS13-1-0-PQ-2025-09", + DefaultActions=[{"Type": "forward", "TargetGroupArn": target_group_arn}], + )["Listeners"][0] + + result = self._mock_and_execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == f"ELBv2 my-lb has HTTPS/TLS listeners without post-quantum TLS policy: HTTPS:443 ({listener['ListenerArn']}) uses ELBSecurityPolicy-TLS13-1-0-PQ-2025-09." + ) + assert result[0].resource_id == "my-lb" + + @mock_aws + def test_listener_with_empty_ssl_policy_fail(self): + """Test FAIL when an HTTPS listener has no SSL policy value.""" + conn, lb, target_group_arn = self._create_alb_infrastructure() + listener = conn.create_listener( + LoadBalancerArn=lb["LoadBalancerArn"], + Protocol="HTTPS", + Port=443, + SslPolicy="ELBSecurityPolicy-TLS13-1-2-2021-06", + DefaultActions=[{"Type": "forward", "TargetGroupArn": target_group_arn}], + )["Listeners"][0] + + from prowler.providers.aws.services.elbv2.elbv2_service import ELBv2 + + aws_provider = set_mocked_aws_provider( + [AWS_REGION_EU_WEST_1, AWS_REGION_US_EAST_1], + create_default_organization=False, + ) + service = ELBv2(aws_provider) + service.loadbalancersv2[lb["LoadBalancerArn"]].listeners[ + listener["ListenerArn"] + ].ssl_policy = "" + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch( + "prowler.providers.aws.services.elbv2.elbv2_listener_pqc_tls_enabled.elbv2_listener_pqc_tls_enabled.elbv2_client", + new=service, + ), + ): + from prowler.providers.aws.services.elbv2.elbv2_listener_pqc_tls_enabled.elbv2_listener_pqc_tls_enabled import ( + elbv2_listener_pqc_tls_enabled, + ) + + result = elbv2_listener_pqc_tls_enabled().execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == f"ELBv2 my-lb has HTTPS/TLS listeners without post-quantum TLS policy: HTTPS:443 ({listener['ListenerArn']}) uses ." + ) + assert result[0].resource_id == "my-lb" + + @mock_aws + def test_listener_with_legacy_policy_fail(self): + """Test FAIL when HTTPS listener uses a legacy TLS policy.""" + conn, lb, target_group_arn = self._create_alb_infrastructure() + listener = conn.create_listener( + LoadBalancerArn=lb["LoadBalancerArn"], + Protocol="HTTPS", + Port=443, + SslPolicy="ELBSecurityPolicy-TLS-1-1-2017-01", + DefaultActions=[{"Type": "forward", "TargetGroupArn": target_group_arn}], + )["Listeners"][0] + + result = self._mock_and_execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == f"ELBv2 my-lb has HTTPS/TLS listeners without post-quantum TLS policy: HTTPS:443 ({listener['ListenerArn']}) uses ELBSecurityPolicy-TLS-1-1-2017-01." + ) + self._assert_listener_arn_in_status(result, listener["ListenerArn"]) + assert result[0].resource_id == "my-lb" + assert result[0].resource_arn == lb["LoadBalancerArn"] + + @mock_aws + def test_mixed_pq_and_non_pq_listeners_fail(self): + """Test FAIL when LB has one PQ listener and one non-PQ listener.""" + conn, lb, target_group_arn = self._create_alb_infrastructure() + # PQ listener + conn.create_listener( + LoadBalancerArn=lb["LoadBalancerArn"], + Protocol="HTTPS", + Port=443, + SslPolicy="ELBSecurityPolicy-TLS13-1-2-PQ-2025-09", + DefaultActions=[{"Type": "forward", "TargetGroupArn": target_group_arn}], + ) + # Non-PQ listener + conn.create_listener( + LoadBalancerArn=lb["LoadBalancerArn"], + Protocol="HTTPS", + Port=8443, + SslPolicy="ELBSecurityPolicy-TLS13-1-2-2021-06", + DefaultActions=[{"Type": "forward", "TargetGroupArn": target_group_arn}], + ) + + result = self._mock_and_execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert "ELBSecurityPolicy-TLS13-1-2-2021-06" in result[0].status_extended + assert result[0].resource_id == "my-lb" + + @mock_aws + def test_multiple_non_pq_listeners_lists_all_policies_fail(self): + """Test FAIL lists all non-PQ policies when multiple listeners are non-compliant.""" + conn, lb, target_group_arn = self._create_alb_infrastructure() + conn.create_listener( + LoadBalancerArn=lb["LoadBalancerArn"], + Protocol="HTTPS", + Port=443, + SslPolicy="ELBSecurityPolicy-TLS-1-1-2017-01", + DefaultActions=[{"Type": "forward", "TargetGroupArn": target_group_arn}], + ) + conn.create_listener( + LoadBalancerArn=lb["LoadBalancerArn"], + Protocol="HTTPS", + Port=8443, + SslPolicy="ELBSecurityPolicy-TLS13-1-2-2021-06", + DefaultActions=[{"Type": "forward", "TargetGroupArn": target_group_arn}], + ) + + result = self._mock_and_execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + # Both non-PQ policies should be mentioned + assert "ELBSecurityPolicy-TLS-1-1-2017-01" in result[0].status_extended + assert "ELBSecurityPolicy-TLS13-1-2-2021-06" in result[0].status_extended + assert result[0].resource_id == "my-lb" + + # ------------------------------------------------------------------ + # Custom audit_config scenario + # ------------------------------------------------------------------ + + @mock_aws + def test_custom_audit_config_narrows_allowlist(self): + """Test that a custom audit_config allowlist is honoured. + + When elbv2_listener_pqc_tls_allowed_policies is overridden to only + allow FIPS PQ policies, a non-FIPS PQ policy should FAIL. + """ + conn, lb, target_group_arn = self._create_alb_infrastructure() + # Use a PQ policy that is in the default list but NOT in our custom list + conn.create_listener( + LoadBalancerArn=lb["LoadBalancerArn"], + Protocol="HTTPS", + Port=443, + SslPolicy="ELBSecurityPolicy-TLS13-1-2-PQ-2025-09", + DefaultActions=[{"Type": "forward", "TargetGroupArn": target_group_arn}], + ) + + custom_config = { + "elbv2_listener_pqc_tls_allowed_policies": [ + "ELBSecurityPolicy-TLS13-1-2-FIPS-PQ-2025-09", + "ELBSecurityPolicy-TLS13-1-3-FIPS-PQ-2025-09", + ] + } + + result = self._mock_and_execute(audit_config=custom_config) + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert "ELBSecurityPolicy-TLS13-1-2-PQ-2025-09" in result[0].status_extended + + @mock_aws + def test_custom_audit_config_fips_policy_pass(self): + """Test PASS when listener uses a FIPS PQ policy and custom config allows it.""" + conn, lb, target_group_arn = self._create_alb_infrastructure() + conn.create_listener( + LoadBalancerArn=lb["LoadBalancerArn"], + Protocol="HTTPS", + Port=443, + SslPolicy="ELBSecurityPolicy-TLS13-1-2-FIPS-PQ-2025-09", + DefaultActions=[{"Type": "forward", "TargetGroupArn": target_group_arn}], + ) + + custom_config = { + "elbv2_listener_pqc_tls_allowed_policies": [ + "ELBSecurityPolicy-TLS13-1-2-FIPS-PQ-2025-09", + "ELBSecurityPolicy-TLS13-1-3-FIPS-PQ-2025-09", + ] + } + + result = self._mock_and_execute(audit_config=custom_config) + + assert len(result) == 1 + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == "ELBv2 my-lb has all HTTPS/TLS listeners using a post-quantum TLS policy." + ) + + @mock_aws + def test_custom_audit_config_allows_tls_1_0_pq_policy(self): + """Test PASS when custom config explicitly allows a TLS 1.0-minimum PQ policy.""" + conn, lb, target_group_arn = self._create_alb_infrastructure() + conn.create_listener( + LoadBalancerArn=lb["LoadBalancerArn"], + Protocol="HTTPS", + Port=443, + SslPolicy="ELBSecurityPolicy-TLS13-1-0-PQ-2025-09", + DefaultActions=[{"Type": "forward", "TargetGroupArn": target_group_arn}], + ) + + result = self._mock_and_execute( + audit_config={ + "elbv2_listener_pqc_tls_allowed_policies": [ + "ELBSecurityPolicy-TLS13-1-0-PQ-2025-09" + ] + } + ) + + assert len(result) == 1 + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == "ELBv2 my-lb has all HTTPS/TLS listeners using a post-quantum TLS policy." + ) + + @mock_aws + def test_empty_audit_config_allowlist_fails_tls_listener(self): + """Test an intentionally empty allowlist is honoured.""" + conn, lb, target_group_arn = self._create_alb_infrastructure() + conn.create_listener( + LoadBalancerArn=lb["LoadBalancerArn"], + Protocol="HTTPS", + Port=443, + SslPolicy="ELBSecurityPolicy-TLS13-1-2-PQ-2025-09", + DefaultActions=[{"Type": "forward", "TargetGroupArn": target_group_arn}], + ) + + result = self._mock_and_execute( + audit_config={"elbv2_listener_pqc_tls_allowed_policies": []} + ) + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert result[0].resource_id == "my-lb" + assert "ELBSecurityPolicy-TLS13-1-2-PQ-2025-09" in result[0].status_extended + + @mock_aws + def test_tls_listener_with_pq_policy_pass(self): + """Test PASS when a TLS listener uses an allowed PQ TLS policy.""" + conn, lb, target_group_arn = self._create_nlb_infrastructure() + conn.create_listener( + LoadBalancerArn=lb["LoadBalancerArn"], + Protocol="TLS", + Port=443, + SslPolicy="ELBSecurityPolicy-TLS13-1-2-PQ-2025-09", + DefaultActions=[{"Type": "forward", "TargetGroupArn": target_group_arn}], + ) + + result = self._mock_and_execute() + + assert len(result) == 1 + assert result[0].status == "PASS" + assert result[0].resource_id == "my-nlb" + assert result[0].resource_arn == lb["LoadBalancerArn"] + + @mock_aws + def test_nlb_with_tcp_listener_only(self): + """Test PASS when an NLB has no HTTPS/TLS listeners.""" + conn, lb, target_group_arn = self._create_nlb_infrastructure() + conn.create_listener( + LoadBalancerArn=lb["LoadBalancerArn"], + Protocol="TCP", + Port=80, + DefaultActions=[{"Type": "forward", "TargetGroupArn": target_group_arn}], + ) + + result = self._mock_and_execute() + + assert len(result) == 1 + assert result[0].status == "PASS" + assert result[0].status_extended == "ELBv2 my-nlb has no HTTPS/TLS listeners." + assert result[0].resource_id == "my-nlb" + assert result[0].resource_arn == lb["LoadBalancerArn"] + + @mock_aws + def test_tls_listener_with_non_pq_policy_fail(self): + """Test FAIL when a TLS listener uses a non-PQ TLS policy.""" + conn, lb, target_group_arn = self._create_nlb_infrastructure() + listener = conn.create_listener( + LoadBalancerArn=lb["LoadBalancerArn"], + Protocol="TLS", + Port=443, + SslPolicy="ELBSecurityPolicy-TLS13-1-2-2021-06", + DefaultActions=[{"Type": "forward", "TargetGroupArn": target_group_arn}], + )["Listeners"][0] + + result = self._mock_and_execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == f"ELBv2 my-nlb has HTTPS/TLS listeners without post-quantum TLS policy: TLS:443 ({listener['ListenerArn']}) uses ELBSecurityPolicy-TLS13-1-2-2021-06." + ) + self._assert_listener_arn_in_status(result, listener["ListenerArn"]) + assert result[0].resource_id == "my-nlb" + assert result[0].resource_arn == lb["LoadBalancerArn"] + + @mock_aws + def test_listener_discovery_failure_returns_no_findings(self): + """Test no findings when listeners cannot be retrieved for a load balancer.""" + conn, lb, _ = self._create_alb_infrastructure() + + from prowler.providers.aws.services.elbv2.elbv2_service import ELBv2 + + aws_provider = set_mocked_aws_provider( + [AWS_REGION_EU_WEST_1, AWS_REGION_US_EAST_1], + create_default_organization=False, + ) + + service = ELBv2(aws_provider) + error = ClientError( + { + "Error": { + "Code": "AccessDenied", + "Message": "User is not authorized to perform: elasticloadbalancing:DescribeListeners", + } + }, + "DescribeListeners", + ) + service.loadbalancersv2[lb["LoadBalancerArn"]].listeners = {} + service.regional_clients[AWS_REGION_EU_WEST_1] = mock.MagicMock( + region=AWS_REGION_EU_WEST_1, + get_paginator=mock.MagicMock(side_effect=error), + ) + service._describe_listeners( + (lb["LoadBalancerArn"], service.loadbalancersv2[lb["LoadBalancerArn"]]) + ) + + assert service.loadbalancersv2[lb["LoadBalancerArn"]].listener_discovery_failed + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch( + "prowler.providers.aws.services.elbv2.elbv2_listener_pqc_tls_enabled.elbv2_listener_pqc_tls_enabled.elbv2_client", + new=service, + ), + ): + from prowler.providers.aws.services.elbv2.elbv2_listener_pqc_tls_enabled.elbv2_listener_pqc_tls_enabled import ( + elbv2_listener_pqc_tls_enabled, + ) + + result = elbv2_listener_pqc_tls_enabled().execute() + + assert len(result) == 0 diff --git a/tests/providers/kubernetes/services/core/conftest.py b/tests/providers/kubernetes/services/core/conftest.py index 9b0becd33c..89add32aa9 100644 --- a/tests/providers/kubernetes/services/core/conftest.py +++ b/tests/providers/kubernetes/services/core/conftest.py @@ -13,6 +13,7 @@ def make_container( resources=None, liveness_probe=None, readiness_probe=None, + security_context=None, ): return Container( name=name, @@ -20,7 +21,7 @@ def make_container( command=None, ports=None, env=None, - security_context={}, + security_context=security_context if security_context is not None else {}, resources=resources, liveness_probe=liveness_probe, readiness_probe=readiness_probe, @@ -31,6 +32,7 @@ def make_pod( containers=None, init_containers=None, ephemeral_containers=None, + volumes=None, name="test-pod", uid="test-pod-uid", ): @@ -52,6 +54,7 @@ def make_pod( containers=containers or {}, init_containers=init_containers or {}, ephemeral_containers=ephemeral_containers or {}, + volumes=volumes, ) diff --git a/tests/providers/kubernetes/services/core/core_minimize_hostpath_volume_mounts/core_minimize_hostpath_volume_mounts_test.py b/tests/providers/kubernetes/services/core/core_minimize_hostpath_volume_mounts/core_minimize_hostpath_volume_mounts_test.py new file mode 100644 index 0000000000..586abac5ec --- /dev/null +++ b/tests/providers/kubernetes/services/core/core_minimize_hostpath_volume_mounts/core_minimize_hostpath_volume_mounts_test.py @@ -0,0 +1,91 @@ +from kubernetes import client +from prowler.providers.kubernetes.services.core.core_service import Core +from tests.providers.kubernetes.services.core.conftest import ( + make_core_client, + make_pod, + run_check, +) + +MODULE = ( + "prowler.providers.kubernetes.services.core." + "core_minimize_hostpath_volume_mounts.core_minimize_hostpath_volume_mounts" +) +CLASS = "core_minimize_hostpath_volume_mounts" + + +class TestCoreMinimizeHostpathVolumeMounts: + def test_build_volumes_maps_kubernetes_hostpath_volume(self): + volumes = Core._build_volumes( + [ + client.V1Volume( + name="host-logs", + host_path=client.V1HostPathVolumeSource( + path="/var/log", + type="Directory", + ), + ) + ] + ) + + assert volumes == [ + { + "name": "host-logs", + "host_path": {"path": "/var/log", "type": "Directory"}, + } + ] + + def test_no_resources(self): + result = run_check(MODULE, CLASS, make_core_client({})) + + assert len(result) == 0 + + def test_no_hostpath_volumes_pass(self): + pod = make_pod( + volumes=[ + {"name": "config", "host_path": None}, + {"name": "scratch", "host_path": None}, + ] + ) + + result = run_check(MODULE, CLASS, make_core_client({pod.uid: pod})) + + assert result[0].status == "PASS" + assert ( + result[0].status_extended == "Pod test-pod does not use hostPath volumes." + ) + + def test_hostpath_volume_fails(self): + pod = make_pod( + volumes=[ + { + "name": "host-logs", + "host_path": {"path": "/var/log", "type": "Directory"}, + } + ] + ) + + result = run_check(MODULE, CLASS, make_core_client({pod.uid: pod})) + + assert result[0].status == "FAIL" + assert ( + result[0].status_extended == "Pod test-pod uses hostPath volume host-logs." + ) + + def test_mixed_volumes_fail_on_hostpath(self): + pod = make_pod( + volumes=[ + {"name": "config", "host_path": None}, + { + "name": "host-socket", + "host_path": {"path": "/var/run/docker.sock", "type": "Socket"}, + }, + ] + ) + + result = run_check(MODULE, CLASS, make_core_client({pod.uid: pod})) + + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == "Pod test-pod uses hostPath volume host-socket." + ) diff --git a/tests/providers/kubernetes/services/core/core_readonly_root_filesystem_enabled/core_readonly_root_filesystem_enabled_test.py b/tests/providers/kubernetes/services/core/core_readonly_root_filesystem_enabled/core_readonly_root_filesystem_enabled_test.py new file mode 100644 index 0000000000..352c08dc85 --- /dev/null +++ b/tests/providers/kubernetes/services/core/core_readonly_root_filesystem_enabled/core_readonly_root_filesystem_enabled_test.py @@ -0,0 +1,126 @@ +from tests.providers.kubernetes.services.core.conftest import ( + make_container, + make_core_client, + make_pod, + run_check, +) + +MODULE = "prowler.providers.kubernetes.services.core.core_readonly_root_filesystem_enabled.core_readonly_root_filesystem_enabled" +CLASS = "core_readonly_root_filesystem_enabled" + + +class TestCoreReadonlyRootFilesystemEnabled: + def test_no_resources(self): + result = run_check(MODULE, CLASS, make_core_client({})) + + assert len(result) == 0 + + def test_readonly_root_filesystem_enabled_pass(self): + pod = make_pod( + containers={ + "app": make_container( + security_context={"read_only_root_filesystem": True} + ) + } + ) + + result = run_check(MODULE, CLASS, make_core_client({pod.uid: pod})) + + assert len(result) == 1 + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == "Pod test-pod has read-only root filesystem enabled for all containers." + ) + + def test_readonly_root_filesystem_false_fail(self): + pod = make_pod( + containers={ + "app": make_container( + security_context={"read_only_root_filesystem": False} + ) + } + ) + + result = run_check(MODULE, CLASS, make_core_client({pod.uid: pod})) + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == "Pod test-pod container app does not have readOnlyRootFilesystem set to true." + ) + + def test_readonly_root_filesystem_unset_fail(self): + pod = make_pod(containers={"app": make_container(security_context={})}) + + result = run_check(MODULE, CLASS, make_core_client({pod.uid: pod})) + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == "Pod test-pod container app does not have readOnlyRootFilesystem set to true." + ) + + def test_mixed_containers_fail(self): + pod = make_pod( + containers={ + "app": make_container( + name="app", + security_context={"read_only_root_filesystem": True}, + ), + "sidecar": make_container( + name="sidecar", + security_context={"read_only_root_filesystem": False}, + ), + } + ) + + result = run_check(MODULE, CLASS, make_core_client({pod.uid: pod})) + + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == "Pod test-pod container sidecar does not have readOnlyRootFilesystem set to true." + ) + + def test_init_container_missing_readonly_root_filesystem_fails(self): + pod = make_pod( + containers={ + "app": make_container( + security_context={"read_only_root_filesystem": True} + ) + }, + init_containers={ + "init": make_container(name="init", security_context=None) + }, + ) + + result = run_check(MODULE, CLASS, make_core_client({pod.uid: pod})) + + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == "Pod test-pod container init does not have readOnlyRootFilesystem set to true." + ) + + def test_ephemeral_container_missing_readonly_root_filesystem_fails(self): + pod = make_pod( + containers={ + "app": make_container( + security_context={"read_only_root_filesystem": True} + ) + }, + ephemeral_containers={ + "debug": make_container(name="debug", security_context={}) + }, + ) + + result = run_check(MODULE, CLASS, make_core_client({pod.uid: pod})) + + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == "Pod test-pod container debug does not have readOnlyRootFilesystem set to true." + ) diff --git a/tests/providers/kubernetes/services/core/core_service_test.py b/tests/providers/kubernetes/services/core/core_service_test.py new file mode 100644 index 0000000000..5fef91159c --- /dev/null +++ b/tests/providers/kubernetes/services/core/core_service_test.py @@ -0,0 +1,64 @@ +from kubernetes import client +from prowler.providers.kubernetes.services.core.core_service import Core, Pod + + +def test_pod_model_preserves_core_service_container_groups(): + containers = Core._build_containers( + [ + client.V1Container( + name="app", + image="nginx:1.25", + security_context=client.V1SecurityContext( + read_only_root_filesystem=True + ), + ) + ] + ) + init_containers = Core._build_containers( + [ + client.V1Container( + name="init", + image="busybox:1.36", + security_context=client.V1SecurityContext( + read_only_root_filesystem=True + ), + ) + ] + ) + ephemeral_containers = Core._build_containers( + [ + client.V1EphemeralContainer( + name="debug", + image="busybox:1.36", + security_context=client.V1SecurityContext( + read_only_root_filesystem=True + ), + ) + ] + ) + + pod = Pod( + name="test-pod", + uid="test-pod-uid", + namespace="default", + labels=None, + annotations=None, + node_name=None, + service_account=None, + status_phase="Running", + pod_ip="10.0.0.1", + host_ip="192.168.1.1", + host_pid=False, + host_ipc=False, + host_network=False, + security_context={}, + containers=containers, + init_containers=init_containers, + ephemeral_containers=ephemeral_containers, + ) + + assert list(pod.containers) == ["app"] + assert list(pod.init_containers) == ["init"] + assert list(pod.ephemeral_containers) == ["debug"] + assert "init" not in pod.containers + assert "debug" not in pod.containers diff --git a/ui/app/(auth)/layout.tsx b/ui/app/(auth)/layout.tsx index 27fff93fe5..b0a9bc2061 100644 --- a/ui/app/(auth)/layout.tsx +++ b/ui/app/(auth)/layout.tsx @@ -5,6 +5,7 @@ import { Metadata, Viewport } from "next"; import { connection } from "next/server"; import { ReactNode, Suspense } from "react"; +import { PublicAuthShell } from "@/components/auth/oss/public-auth-shell"; import { RuntimePublicConfig } from "@/components/runtime-config/runtime-public-config"; import { NavigationProgress, Toaster } from "@/components/shadcn"; import { fontMono, fontSans } from "@/config/fonts"; @@ -65,7 +66,7 @@ export default async function AuthLayout({ - {children} + {children} {gtmId && } diff --git a/ui/app/(prowler)/alerts/_components/__tests__/seed-from-findings-button.test.tsx b/ui/app/(prowler)/alerts/_components/__tests__/seed-from-findings-button.test.tsx index 074304e58c..4250a9c579 100644 --- a/ui/app/(prowler)/alerts/_components/__tests__/seed-from-findings-button.test.tsx +++ b/ui/app/(prowler)/alerts/_components/__tests__/seed-from-findings-button.test.tsx @@ -8,6 +8,8 @@ import type { AlertFormSubmitResult, AlertFormValues, } from "@/app/(prowler)/alerts/_types/alert-form"; +import { useCloudUpgradeStore } from "@/store"; +import { CLOUD_UPGRADE_FEATURE } from "@/types/cloud-upgrade"; const routerMocks = vi.hoisted(() => ({ push: vi.fn(), @@ -99,6 +101,7 @@ import { SeedFromFindingsButton } from "../seed-from-findings-button"; describe("SeedFromFindingsButton", () => { afterEach(() => { vi.clearAllMocks(); + useCloudUpgradeStore.getState().closeCloudUpgrade(); }); it("should explain why creating an alert is disabled when no real filters are applied", async () => { @@ -356,8 +359,9 @@ describe("SeedFromFindingsButton", () => { ).not.toBeInTheDocument(); }); - it("should render disabled as a Cloud-only feature in OSS", () => { + it("should open the Alerts upgrade in Local Server", async () => { // Given + const user = userEvent.setup(); render( { // When const button = screen.getByRole("button", { name: /Create Alert/i }); + await user.click(button); // Then - expect(button).toBeDisabled(); + expect(button).not.toBeDisabled(); expect(button.className).not.toContain("min-w"); expect(button).not.toHaveClass("justify-start"); - const pricingLink = screen.getByRole("link", { - name: /available in prowler cloud/i, - }); - expect(pricingLink).toHaveAttribute("href", "https://prowler.com/pricing"); - expect(pricingLink).toHaveClass("whitespace-nowrap"); - expect(pricingLink).toHaveTextContent("Available in Prowler Cloud"); - expect(pricingLink.closest("button")).toBeNull(); - expect(screen.queryByRole("tooltip")).not.toBeInTheDocument(); + expect(screen.getByText("Cloud")).toBeVisible(); + expect(screen.queryByRole("link")).not.toBeInTheDocument(); + expect(useCloudUpgradeStore.getState().activeFeature).toBe( + CLOUD_UPGRADE_FEATURE.ALERTS, + ); expect(actionMocks.seedAlertRule).not.toHaveBeenCalled(); }); + + it("should expose a single keyboard stop for the Local Server upgrade", async () => { + // Given + const user = userEvent.setup(); + render( + , + ); + + // When + await user.tab(); + + // Then + expect(screen.getByRole("button", { name: /Create Alert/i })).toHaveFocus(); + }); }); diff --git a/ui/app/(prowler)/alerts/_components/seed-from-findings-button.tsx b/ui/app/(prowler)/alerts/_components/seed-from-findings-button.tsx index 0e7a306ce9..6b94549a9e 100644 --- a/ui/app/(prowler)/alerts/_components/seed-from-findings-button.tsx +++ b/ui/app/(prowler)/alerts/_components/seed-from-findings-button.tsx @@ -23,15 +23,16 @@ import type { } from "@/app/(prowler)/alerts/_types/alert-form"; import { buildFindingsFilterChips } from "@/components/findings/findings-filters.utils"; import { + Badge, Button, Tooltip, TooltipContent, TooltipTrigger, - ToastAction, - useToast, } from "@/components/shadcn"; -import { CloudFeatureBadgeLink } from "@/components/shared/cloud-feature-badge"; +import { ToastAction, useToast } from "@/components/shadcn"; +import { useCloudUpgradeStore } from "@/store"; import type { ScanEntity } from "@/types"; +import { CLOUD_UPGRADE_FEATURE } from "@/types/cloud-upgrade"; import type { ProviderProps } from "@/types/providers"; const DISABLED_FILTER_TOOLTIP = @@ -139,6 +140,9 @@ export const SeedFromFindingsButton = ({ }: SeedFromFindingsButtonProps) => { const router = useRouter(); const { toast } = useToast(); + const openCloudUpgrade = useCloudUpgradeStore( + (state) => state.openCloudUpgrade, + ); const [modalOpen, setModalOpen] = useState(false); const [seeding, setSeeding] = useState(false); const [seededCondition, setSeededCondition] = useState( @@ -155,7 +159,11 @@ export const SeedFromFindingsButton = ({ const canSeedFromFilters = hasFindingFilterValue(filterBag); const handleClick = async () => { - if (!isCloudEnabled || !canSeedFromFilters) return; + if (!isCloudEnabled) { + openCloudUpgrade(CLOUD_UPGRADE_FEATURE.ALERTS); + return; + } + if (!canSeedFromFilters) return; setSeeding(true); const result = await seedAlertRule(withDefaultAlertSeedFilters(filterBag)); setSeeding(false); @@ -202,7 +210,7 @@ export const SeedFromFindingsButton = ({ size={size} variant="default" onClick={handleClick} - disabled={!isCloudEnabled || !canSeedFromFilters || seeding} + disabled={(isCloudEnabled && !canSeedFromFilters) || seeding} className={className} > @@ -237,10 +245,10 @@ export const SeedFromFindingsButton = ({ if (!isCloudEnabled) { return ( - + {button} - - + + Cloud ); diff --git a/ui/app/(prowler)/alerts/page.tsx b/ui/app/(prowler)/alerts/page.tsx index 438313a262..2b7e74336b 100644 --- a/ui/app/(prowler)/alerts/page.tsx +++ b/ui/app/(prowler)/alerts/page.tsx @@ -7,6 +7,7 @@ import { getAlert, listAlerts } from "@/app/(prowler)/alerts/_actions"; import { AlertsManager } from "@/app/(prowler)/alerts/_components/alerts-manager"; import { ContentLayout } from "@/components/shadcn/content-layout"; import { createScanDetailsMapping } from "@/lib"; +import { isCloud } from "@/lib/shared/env"; import type { MetaDataProps, ScanEntity, ScanProps } from "@/types"; interface AlertsPageProps { @@ -49,7 +50,7 @@ const toAlertsSearchParams = ( }; export default async function AlertsPage({ searchParams }: AlertsPageProps) { - if (process.env.NEXT_PUBLIC_IS_CLOUD_ENV !== "true") { + if (!isCloud()) { redirect("/"); } diff --git a/ui/app/(prowler)/compliance/_components/compliance-page-tabs.test.tsx b/ui/app/(prowler)/compliance/_components/compliance-page-tabs.test.tsx index 089fc7b5e6..531cfbe79d 100644 --- a/ui/app/(prowler)/compliance/_components/compliance-page-tabs.test.tsx +++ b/ui/app/(prowler)/compliance/_components/compliance-page-tabs.test.tsx @@ -1,6 +1,9 @@ import { render, screen } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; -import { beforeEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { useCloudUpgradeStore } from "@/store"; +import { CLOUD_UPGRADE_FEATURE } from "@/types/cloud-upgrade"; import { COMPLIANCE_TAB } from "../_types"; @@ -33,6 +36,10 @@ describe("CompliancePageTabs", () => { pushMock.mockClear(); }); + afterEach(() => { + useCloudUpgradeStore.getState().closeCloudUpgrade(); + }); + it("navigates with ?tab=cross-provider and back to the bare route", async () => { const user = userEvent.setup(); const { rerender } = render( @@ -60,7 +67,8 @@ describe("CompliancePageTabs", () => { expect(pushMock).toHaveBeenCalledWith("/compliance"); }); - it("disables the cross-provider tab with the cloud upsell badge in OSS", () => { + it("opens the cross-provider upgrade without changing tabs in Local Server", async () => { + const user = userEvent.setup(); render( { const crossProviderTab = screen.getByRole("tab", { name: /cross-provider/i, }); - const tabLabel = screen.getByText("Cross-Provider", { exact: true }); - const cloudBadge = screen.getByText("Available in Prowler Cloud"); + await user.click(crossProviderTab); - expect(crossProviderTab).toBeDisabled(); - expect(crossProviderTab).not.toHaveClass("disabled:opacity-50"); - expect(tabLabel).toHaveClass("opacity-50"); - expect(cloudBadge.parentElement).toHaveClass("gap-2"); + expect(crossProviderTab).not.toBeDisabled(); + expect(crossProviderTab).toHaveAttribute("aria-selected", "false"); + expect(screen.getByText("Cloud")).toBeVisible(); + expect(pushMock).not.toHaveBeenCalled(); + expect(useCloudUpgradeStore.getState().activeFeature).toBe( + CLOUD_UPGRADE_FEATURE.CROSS_PROVIDER_COMPLIANCE, + ); }); }); diff --git a/ui/app/(prowler)/compliance/_components/compliance-page-tabs.tsx b/ui/app/(prowler)/compliance/_components/compliance-page-tabs.tsx index fa6ab6ea70..79e5d8af00 100644 --- a/ui/app/(prowler)/compliance/_components/compliance-page-tabs.tsx +++ b/ui/app/(prowler)/compliance/_components/compliance-page-tabs.tsx @@ -3,8 +3,15 @@ import { useRouter } from "next/navigation"; import { ReactNode } from "react"; -import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/shadcn"; -import { CloudFeatureBadge } from "@/components/shared/cloud-feature-badge"; +import { + Badge, + Tabs, + TabsContent, + TabsList, + TabsTrigger, +} from "@/components/shadcn"; +import { useCloudUpgradeStore } from "@/store"; +import { CLOUD_UPGRADE_FEATURE } from "@/types/cloud-upgrade"; import { COMPLIANCE_TAB, type ComplianceTab } from "../_types"; @@ -24,10 +31,18 @@ export const CompliancePageTabs = ({ crossProviderContent, }: CompliancePageTabsProps) => { const router = useRouter(); + const openCloudUpgrade = useCloudUpgradeStore( + (state) => state.openCloudUpgrade, + ); const handleTabChange = (tab: string) => { const typedTab = tab as ComplianceTab; + if (typedTab === COMPLIANCE_TAB.CROSS_PROVIDER && !crossProviderEnabled) { + openCloudUpgrade(CLOUD_UPGRADE_FEATURE.CROSS_PROVIDER_COMPLIANCE); + return; + } + if (typedTab === activeTab) { return; } @@ -52,8 +67,11 @@ export const CompliancePageTabs = ({ Per Scan : undefined} + adornment={ + !crossProviderEnabled ? ( + Cloud + ) : undefined + } > Cross-Provider diff --git a/ui/app/(prowler)/findings/page.tsx b/ui/app/(prowler)/findings/page.tsx index a93f799f62..77cb1d8d0e 100644 --- a/ui/app/(prowler)/findings/page.tsx +++ b/ui/app/(prowler)/findings/page.tsx @@ -25,6 +25,7 @@ import { hasDateOrScanFilter, } from "@/lib"; import { resolveFindingScanDateFilters } from "@/lib/findings-scan-filters"; +import { isCloud } from "@/lib/shared/env"; import { ScanEntity, ScanProps } from "@/types"; import { SearchParamsProps } from "@/types/components"; @@ -89,7 +90,7 @@ export default async function Findings({ completedScans || [], providersData, ) as { [uid: string]: ScanEntity }[]; - const alertsEnabled = process.env.NEXT_PUBLIC_IS_CLOUD_ENV === "true"; + const alertsEnabled = isCloud(); return ( + + + {!isCloudEnv && ( +
+ + + + + {!isOpen && ( + + Explore Prowler Cloud + + )} + +
+ )} + {/* Footer */}
{isOpen ? ( <> {process.env.NEXT_PUBLIC_PROWLER_RELEASE_VERSION} - {process.env.NEXT_PUBLIC_IS_CLOUD_ENV === "true" && ( + {isCloudEnv && ( <> { target="_blank" rel="noopener noreferrer" className="flex items-center gap-1" + onClick={onSelect} > @@ -188,7 +239,7 @@ export const Menu = ({ isOpen }: { isOpen: boolean }) => { )} ) : ( - process.env.NEXT_PUBLIC_IS_CLOUD_ENV === "true" && ( + isCloudEnv && ( { target="_blank" rel="noopener noreferrer" className="flex items-center" + onClick={onSelect} > diff --git a/ui/components/layout/sidebar/sheet-menu.test.tsx b/ui/components/layout/sidebar/sheet-menu.test.tsx new file mode 100644 index 0000000000..c57e7ecb54 --- /dev/null +++ b/ui/components/layout/sidebar/sheet-menu.test.tsx @@ -0,0 +1,35 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, expect, it, vi } from "vitest"; + +vi.mock("@/components/icons", () => ({ + ProwlerBrand: () => Prowler, +})); + +vi.mock("@/components/layout/sidebar/menu", () => ({ + Menu: ({ onSelect }: { onSelect?: () => void }) => ( + + ), +})); + +import { SheetMenu } from "./sheet-menu"; + +describe("SheetMenu", () => { + it("should close after selecting a menu action", async () => { + // Given + const user = userEvent.setup(); + render(); + + // When + await user.click(screen.getByRole("button", { name: "Open menu" })); + expect(screen.getByRole("dialog", { name: "Sidebar" })).toBeVisible(); + await user.click(screen.getByRole("button", { name: "Alerts" })); + + // Then + expect( + screen.queryByRole("dialog", { name: "Sidebar" }), + ).not.toBeInTheDocument(); + }); +}); diff --git a/ui/components/layout/sidebar/sheet-menu.tsx b/ui/components/layout/sidebar/sheet-menu.tsx index 8fef80c3fa..842986f8eb 100644 --- a/ui/components/layout/sidebar/sheet-menu.tsx +++ b/ui/components/layout/sidebar/sheet-menu.tsx @@ -1,7 +1,10 @@ +"use client"; + import { MenuIcon } from "lucide-react"; import Link from "next/link"; +import { useRef, useState } from "react"; -import { ProwlerExtended } from "@/components/icons"; +import { ProwlerBrand } from "@/components/icons"; import { Menu } from "@/components/layout/sidebar/menu"; import { Button } from "@/components/shadcn/button/button"; import { @@ -14,10 +17,24 @@ import { } from "@/components/shadcn/sheet"; export function SheetMenu() { + const [open, setOpen] = useState(false); + const triggerRef = useRef(null); + + const handleSelect = () => { + setOpen(false); + return triggerRef.current; + }; + return ( - + - @@ -30,12 +47,16 @@ export function SheetMenu() { variant="link" asChild > - - + + - + ); diff --git a/ui/components/layout/sidebar/sidebar.tsx b/ui/components/layout/sidebar/sidebar.tsx index 3f6b41e4b3..f6f788563d 100644 --- a/ui/components/layout/sidebar/sidebar.tsx +++ b/ui/components/layout/sidebar/sidebar.tsx @@ -1,9 +1,9 @@ "use client"; -import { clsx } from "clsx"; +import clsx from "clsx"; import Link from "next/link"; -import { ProwlerShort, ProwlerExtended } from "@/components/icons"; +import { ProwlerBrand, ProwlerShort } from "@/components/icons"; import { useSidebar } from "@/hooks/use-sidebar"; import { useStore } from "@/hooks/use-store"; import { cn } from "@/lib/utils"; @@ -48,7 +48,7 @@ export function Sidebar() { "mt-0!": isOpen, })} > - +
diff --git a/ui/components/layout/sidebar/submenu-item.test.tsx b/ui/components/layout/sidebar/submenu-item.test.tsx index 02726b8e46..053a50f3fc 100644 --- a/ui/components/layout/sidebar/submenu-item.test.tsx +++ b/ui/components/layout/sidebar/submenu-item.test.tsx @@ -1,6 +1,10 @@ import { render, screen } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; -import { describe, expect, it, vi } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { useCloudUpgradeStore } from "@/store"; +import { CLOUD_UPGRADE_FEATURE } from "@/types/cloud-upgrade"; +import { SUBMENU_KIND } from "@/types/components"; import { SubmenuItem } from "./submenu-item"; @@ -13,64 +17,41 @@ const TestIcon = ({ size = 16 }: { size?: number }) => ( ); describe("SubmenuItem", () => { - it("should show the cloud-only tooltip for disabled cloud menu items", async () => { + afterEach(() => { + useCloudUpgradeStore.getState().closeCloudUpgrade(); + }); + + it("should open the Alerts upgrade modal from a Local Server menu item", async () => { // Given const user = userEvent.setup(); + const returnFocusElement = document.createElement("button"); + const onSelect = vi.fn(() => returnFocusElement); render( , ); // When const button = screen.getByRole("button", { name: /alerts/i }); - expect(button).toHaveAttribute("aria-disabled", "true"); - expect(button).toHaveClass( - "cursor-not-allowed", - "text-text-neutral-tertiary", - ); - await user.hover(button.parentElement as HTMLElement); + await user.click(button); // Then - expect(screen.getByText("New")).toHaveClass("h-5", "text-[10px]"); - expect(screen.queryByText("Cloud")).not.toBeInTheDocument(); + expect(button).not.toHaveAttribute("aria-disabled"); + expect(screen.getByText("Cloud")).toBeVisible(); expect( - await screen.findAllByText("Available in Prowler Cloud"), - ).not.toHaveLength(0); - }); - - it("should render disabled Scan config menu items like disabled Alerts", async () => { - // Given - const user = userEvent.setup(); - render( - , + screen.queryByRole("link", { name: /alerts/i }), + ).not.toBeInTheDocument(); + expect(useCloudUpgradeStore.getState().activeFeature).toBe( + CLOUD_UPGRADE_FEATURE.ALERTS, ); - - // When - const button = screen.getByRole("button", { name: /scan/i }); - await user.hover(button.parentElement as HTMLElement); - - // Then - expect(button).toHaveAttribute("aria-disabled", "true"); - expect(button).toHaveClass( - "cursor-not-allowed", - "text-text-neutral-tertiary", + expect(onSelect).toHaveBeenCalledOnce(); + expect(useCloudUpgradeStore.getState().returnFocusElement).toBe( + returnFocusElement, ); - expect(screen.getByText("New")).toHaveClass("h-5", "text-[10px]"); - expect( - await screen.findAllByText("Available in Prowler Cloud"), - ).not.toHaveLength(0); }); }); diff --git a/ui/components/layout/sidebar/submenu-item.tsx b/ui/components/layout/sidebar/submenu-item.tsx index 2767381ff0..e481de6f5d 100644 --- a/ui/components/layout/sidebar/submenu-item.tsx +++ b/ui/components/layout/sidebar/submenu-item.tsx @@ -2,41 +2,67 @@ import Link from "next/link"; import { usePathname } from "next/navigation"; -import { type MouseEvent } from "react"; +import { Badge } from "@/components/shadcn/badge/badge"; import { Button } from "@/components/shadcn/button/button"; import { Tooltip, TooltipContent, TooltipTrigger, } from "@/components/shadcn/tooltip"; -import { MenuFeatureBadge } from "@/components/shared/cloud-feature-badge"; -import { IconComponent } from "@/types"; +import { useCloudUpgradeStore } from "@/store"; +import { + type MenuSelectionHandler, + SUBMENU_KIND, + type SubmenuProps, +} from "@/types"; -interface SubmenuItemProps { - href: string; - label: string; - icon: IconComponent; - active?: boolean; - target?: string; - disabled?: boolean; - highlight?: boolean; - cloudOnly?: boolean; - onClick?: (event: MouseEvent) => void; -} +type SubmenuItemProps = SubmenuProps & { + onSelect?: MenuSelectionHandler; +}; -export const SubmenuItem = ({ - href, - label, - icon: Icon, - active, - target, - disabled, - highlight, - cloudOnly, - onClick, -}: SubmenuItemProps) => { +export const SubmenuItem = (props: SubmenuItemProps) => { const pathname = usePathname(); + const openCloudUpgrade = useCloudUpgradeStore( + (state) => state.openCloudUpgrade, + ); + + if (props.kind === SUBMENU_KIND.CLOUD_UPGRADE) { + const { cloudUpgradeFeature, icon: Icon, label, onSelect } = props; + + return ( + + ); + } + + const { + active, + cloudOnly, + disabled, + highlight, + href, + icon: Icon, + label, + onSelect, + target, + } = props; const isActive = active !== undefined ? active : pathname === href; // Special case: Mutelist with tooltip when disabled @@ -86,7 +112,9 @@ export const SubmenuItem = ({

{label} {highlight && ( - + + New + )}

@@ -108,7 +136,7 @@ export const SubmenuItem = ({ href={href} target={target} className="flex items-center" - onClick={onClick} + onClick={onSelect} > @@ -116,12 +144,9 @@ export const SubmenuItem = ({

{label} {highlight && ( - + + New + )}

diff --git a/ui/components/lighthouse-v1/index.ts b/ui/components/lighthouse-v1/index.ts index 5f1281f40e..9bdcd76a06 100644 --- a/ui/components/lighthouse-v1/index.ts +++ b/ui/components/lighthouse-v1/index.ts @@ -4,4 +4,5 @@ export * from "./lighthouse-settings"; export * from "./llm-provider-registry"; export * from "./llm-provider-utils"; export * from "./llm-providers-table"; +export * from "./managed-lighthouse-callout"; export * from "./select-model"; diff --git a/ui/components/lighthouse-v1/managed-lighthouse-callout.test.tsx b/ui/components/lighthouse-v1/managed-lighthouse-callout.test.tsx new file mode 100644 index 0000000000..43ce9ac993 --- /dev/null +++ b/ui/components/lighthouse-v1/managed-lighthouse-callout.test.tsx @@ -0,0 +1,33 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { afterEach, describe, expect, it } from "vitest"; + +import { useCloudUpgradeStore } from "@/store"; +import { CLOUD_UPGRADE_FEATURE } from "@/types/cloud-upgrade"; + +import { ManagedLighthouseCallout } from "./managed-lighthouse-callout"; + +describe("ManagedLighthouseCallout", () => { + afterEach(() => { + useCloudUpgradeStore.getState().closeCloudUpgrade(); + }); + + it("opens the managed Lighthouse Cloud upgrade", async () => { + // Given + const user = userEvent.setup(); + render(); + + const upgradeButton = screen.getByRole("button", { + name: "Explore The Agentic Cloud Defender", + }); + + // When + await user.click(upgradeButton); + + // Then + expect(upgradeButton).toHaveClass("bg-button-primary"); + expect(useCloudUpgradeStore.getState().activeFeature).toBe( + CLOUD_UPGRADE_FEATURE.LIGHTHOUSE_AI, + ); + }); +}); diff --git a/ui/components/lighthouse-v1/managed-lighthouse-callout.tsx b/ui/components/lighthouse-v1/managed-lighthouse-callout.tsx new file mode 100644 index 0000000000..80e38f5ccc --- /dev/null +++ b/ui/components/lighthouse-v1/managed-lighthouse-callout.tsx @@ -0,0 +1,51 @@ +"use client"; + +import { Sparkles } from "lucide-react"; + +import { Badge } from "@/components/shadcn/badge/badge"; +import { Button } from "@/components/shadcn/button/button"; +import { + Card, + CardContent, + CardHeader, + CardTitle, +} from "@/components/shadcn/card/card"; +import { useCloudUpgradeStore } from "@/store"; +import { CLOUD_UPGRADE_FEATURE } from "@/types/cloud-upgrade"; + +export const ManagedLighthouseCallout = () => { + const openCloudUpgrade = useCloudUpgradeStore( + (state) => state.openCloudUpgrade, + ); + + return ( + + +
+
+
+ +

+ Prowler Cloud includes managed OpenAI access with no API keys to + provision, plus a hosted remote MCP server to automate security + workflows. +

+ +
+
+ ); +}; diff --git a/ui/components/providers/organizations/aws-method-selector.test.tsx b/ui/components/providers/organizations/aws-method-selector.test.tsx index 62b74a67f4..9279e8dfb2 100644 --- a/ui/components/providers/organizations/aws-method-selector.test.tsx +++ b/ui/components/providers/organizations/aws-method-selector.test.tsx @@ -1,28 +1,43 @@ import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; import { afterEach, describe, expect, it, vi } from "vitest"; +import { useCloudUpgradeStore } from "@/store"; +import { CLOUD_UPGRADE_FEATURE } from "@/types/cloud-upgrade"; + import { AwsMethodSelector } from "./aws-method-selector"; describe("AwsMethodSelector", () => { afterEach(() => { vi.unstubAllEnvs(); + useCloudUpgradeStore.getState().closeCloudUpgrade(); }); - it("links the OSS AWS Organizations badge to pricing", () => { + it("opens the AWS Organizations upgrade in Local Server", async () => { // Given vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "false"); + const user = userEvent.setup(); + const onSelectOrganizations = vi.fn(); // When render( , ); // Then - expect( - screen.getByRole("link", { name: /available in prowler cloud/i }), - ).toHaveAttribute("href", "https://prowler.com/pricing"); + await user.click( + screen.getByRole("radio", { + name: /add multiple accounts with aws organizations/i, + }), + ); + + expect(onSelectOrganizations).not.toHaveBeenCalled(); + expect(screen.getByText("Cloud")).toBeVisible(); + expect(useCloudUpgradeStore.getState().activeFeature).toBe( + CLOUD_UPGRADE_FEATURE.AWS_ORGANIZATIONS, + ); }); }); diff --git a/ui/components/providers/organizations/aws-method-selector.tsx b/ui/components/providers/organizations/aws-method-selector.tsx index ca36407508..882adef1b2 100644 --- a/ui/components/providers/organizations/aws-method-selector.tsx +++ b/ui/components/providers/organizations/aws-method-selector.tsx @@ -1,9 +1,12 @@ "use client"; -import { Ban, Box, Boxes } from "lucide-react"; +import { Box, Boxes } from "lucide-react"; import { RadioCard } from "@/components/providers/radio-card"; -import { CloudFeatureBadgeLink } from "@/components/shared/cloud-feature-badge"; +import { Badge } from "@/components/shadcn/badge/badge"; +import { isCloud } from "@/lib/shared/env"; +import { useCloudUpgradeStore } from "@/store"; +import { CLOUD_UPGRADE_FEATURE } from "@/types/cloud-upgrade"; interface AwsMethodSelectorProps { onSelectSingle: () => void; @@ -14,7 +17,10 @@ export function AwsMethodSelector({ onSelectSingle, onSelectOrganizations, }: AwsMethodSelectorProps) { - const isCloudEnv = process.env.NEXT_PUBLIC_IS_CLOUD_ENV === "true"; + const isCloudEnv = isCloud(); + const openCloudUpgrade = useCloudUpgradeStore( + (state) => state.openCloudUpgrade, + ); return (
@@ -29,12 +35,15 @@ export function AwsMethodSelector({ /> + isCloudEnv + ? onSelectOrganizations() + : openCloudUpgrade(CLOUD_UPGRADE_FEATURE.AWS_ORGANIZATIONS) + } > - {!isCloudEnv && } + {!isCloudEnv && Cloud}
); diff --git a/ui/components/providers/wizard/steps/launch-step.test.tsx b/ui/components/providers/wizard/steps/launch-step.test.tsx index caeef23385..dbc31771d9 100644 --- a/ui/components/providers/wizard/steps/launch-step.test.tsx +++ b/ui/components/providers/wizard/steps/launch-step.test.tsx @@ -374,6 +374,31 @@ describe("LaunchStep", () => { scanOnDemandMock.mockResolvedValue({ data: { id: "scan-1" } }); }); + it("uses a warning badge for the subscription requirement", () => { + // Given + seedConnectedProvider(); + + // When + render( + , + ); + + // Then + expect(screen.getByText("Requires subscription")).toHaveClass( + "bg-bg-warning-secondary/20", + "text-text-warning-primary", + ); + expect(screen.getByText("Requires subscription")).toHaveAttribute( + "data-slot", + "badge", + ); + }); + it("defaults to run now, locks schedule mode, and only launches a manual scan", async () => { // Given const onClose = vi.fn(); diff --git a/ui/components/providers/wizard/steps/launch-step.tsx b/ui/components/providers/wizard/steps/launch-step.tsx index 517046495f..1c24f2b893 100644 --- a/ui/components/providers/wizard/steps/launch-step.tsx +++ b/ui/components/providers/wizard/steps/launch-step.tsx @@ -11,7 +11,9 @@ import { saveScheduleWithInitialScan, } from "@/components/scans/schedule/save-schedule"; import { ScanScheduleFields } from "@/components/scans/schedule/scan-schedule-fields"; -import { Field, FieldLabel, ToastAction, useToast } from "@/components/shadcn"; +import { Field, FieldLabel } from "@/components/shadcn"; +import { ToastAction, useToast } from "@/components/shadcn"; +import { Badge } from "@/components/shadcn/badge/badge"; import { EntityInfo } from "@/components/shadcn/entities"; import { RadioGroup, @@ -19,10 +21,6 @@ import { } from "@/components/shadcn/radio-group/radio-group"; import { Spinner } from "@/components/shadcn/spinner/spinner"; import { TreeStatusIcon } from "@/components/shadcn/tree-view/tree-status-icon"; -import { - CloudFeatureBadge, - CloudFeatureBadgeLink, -} from "@/components/shared/cloud-feature-badge"; import { UsageLimitMessage } from "@/components/shared/usage-limit-message"; import { type ActionErrorResult, @@ -332,13 +330,11 @@ export function LaunchStep({ disabled={!canUseScheduleMode} /> On a schedule - {!canUseScheduleMode && - !isBlocked && - (isManualOnly ? ( - - ) : ( - - ))} + {isManualOnly && !isBlocked && ( + + Requires subscription + + )} diff --git a/ui/components/scans/launch-scan-modal.test.tsx b/ui/components/scans/launch-scan-modal.test.tsx index 96272e09cd..706b704425 100644 --- a/ui/components/scans/launch-scan-modal.test.tsx +++ b/ui/components/scans/launch-scan-modal.test.tsx @@ -548,15 +548,26 @@ describe("LaunchScanModal", () => { expect(getScheduleMock).not.toHaveBeenCalled(); }); - it("locks schedule mode outside ADVANCED (OSS default)", () => { + it("preserves legacy daily scheduling outside Cloud", async () => { + const user = userEvent.setup(); + scheduleDailyMock.mockResolvedValue({ data: { id: provider.id } }); render( , ); expect( screen.getByRole("radio", { name: "On a schedule" }), - ).toBeDisabled(); + ).toBeEnabled(); + + await user.selectOptions(screen.getByLabelText("Providers"), provider.id); + await user.click(screen.getByRole("radio", { name: "On a schedule" })); + expect(screen.getByRole("combobox", { name: "Repeats" })).toBeDisabled(); + + await user.click(screen.getByRole("button", { name: /save schedule/i })); + + await waitFor(() => expect(scheduleDailyMock).toHaveBeenCalledTimes(1)); expect(getScheduleMock).not.toHaveBeenCalled(); + expect(updateScheduleMock).not.toHaveBeenCalled(); }); it("hides schedule mode but allows manual scans in MANUAL_ONLY", async () => { diff --git a/ui/components/scans/launch-scan-modal.tsx b/ui/components/scans/launch-scan-modal.tsx index b9c469ce6f..465321dc1a 100644 --- a/ui/components/scans/launch-scan-modal.tsx +++ b/ui/components/scans/launch-scan-modal.tsx @@ -11,7 +11,13 @@ import { z } from "zod"; import { scanOnDemand } from "@/actions/scans"; import { getSchedule } from "@/actions/schedules"; import { AccountsSelector } from "@/app/(prowler)/_overview/_components/accounts-selector"; -import { Field, FieldError, FieldLabel, Input } from "@/components/shadcn"; +import { + Badge, + Field, + FieldError, + FieldLabel, + Input, +} from "@/components/shadcn"; import { FormButtons } from "@/components/shadcn/form"; import { Modal } from "@/components/shadcn/modal"; import { @@ -19,7 +25,6 @@ import { RadioGroupItem, } from "@/components/shadcn/radio-group/radio-group"; import { toast, ToastAction } from "@/components/shadcn/toast"; -import { CloudFeatureBadgeLink } from "@/components/shared/cloud-feature-badge"; import { UsageLimitMessage } from "@/components/shared/usage-limit-message"; import { getActionErrorMessage, hasActionError } from "@/lib/action-errors"; import { @@ -111,11 +116,13 @@ function LaunchScanForm({ const requestedProviderRef = useRef(""); const isAdvanced = capability === SCAN_SCHEDULE_CAPABILITY.ADVANCED; + const isDailyLegacy = capability === SCAN_SCHEDULE_CAPABILITY.DAILY_LEGACY; + const canUseScheduleMode = isAdvanced || isDailyLegacy; const isManualOnly = capability === SCAN_SCHEDULE_CAPABILITY.MANUAL_ONLY; const isBlocked = capability === SCAN_SCHEDULE_CAPABILITY.BLOCKED || (isManualOnly && isScanLimitReached); - const isScheduleMode = isAdvanced && mode === LAUNCH_MODE.SCHEDULE; + const isScheduleMode = canUseScheduleMode && mode === LAUNCH_MODE.SCHEDULE; // useWatch, not form.watch: form.watch re-renders are dropped by React Compiler memoization. const providerId = useWatch({ control: form.control, name: "providerId" }); @@ -169,13 +176,15 @@ function LaunchScanForm({ const handleProviderChange = (id: string) => { form.setValue("providerId", id, { shouldValidate: true }); - if (isScheduleMode) void loadSchedule(id); + if (isScheduleMode && isAdvanced) void loadSchedule(id); }; const handleModeChange = (nextMode: string) => { - if (nextMode === LAUNCH_MODE.SCHEDULE && !isAdvanced) return; + if (nextMode === LAUNCH_MODE.SCHEDULE && !canUseScheduleMode) return; setMode(nextMode as LaunchMode); - if (nextMode === LAUNCH_MODE.SCHEDULE) void loadSchedule(providerId); + if (nextMode === LAUNCH_MODE.SCHEDULE && isAdvanced) { + void loadSchedule(providerId); + } }; const launchNow = form.handleSubmit(async ({ providerId, scanAlias }) => { @@ -216,7 +225,7 @@ function LaunchScanForm({ }); const saveSchedule = async () => { - if (isBlocked || !isAdvanced) return; + if (isBlocked || !canUseScheduleMode) return; const providerValid = await form.trigger("providerId"); if (!providerValid) return; @@ -225,6 +234,7 @@ function LaunchScanForm({ const result = await saveScheduleWithInitialScan({ providerId: form.getValues("providerId"), values, + useLegacyDaily: isDailyLegacy, }); if (result.status === SAVE_SCHEDULE_STATUS.ERROR) { @@ -320,10 +330,14 @@ function LaunchScanForm({ On a schedule - {!isAdvanced && } + {isDailyLegacy && ( + + Cloud + + )} @@ -362,6 +376,8 @@ function LaunchScanForm({ disabled={isSubmitting || !providerId} showLaunchInitialScan showNextScheduledCopy + canUseAdvancedSchedule={isAdvanced} + showCloudUpgradeBadge={isDailyLegacy} /> )} diff --git a/ui/components/scans/scans-filter-bar.tsx b/ui/components/scans/scans-filter-bar.tsx index df635514e6..de4539651f 100644 --- a/ui/components/scans/scans-filter-bar.tsx +++ b/ui/components/scans/scans-filter-bar.tsx @@ -9,6 +9,7 @@ import { SelectTrigger, SelectValue, } from "@/components/shadcn"; +import { isCloud } from "@/lib/shared/env"; import { SCAN_JOBS_TAB, type ScanJobsTab } from "@/types"; import type { ProviderGroup } from "@/types/components"; import { FILTER_FIELD } from "@/types/filters"; @@ -42,7 +43,7 @@ export function ScansFilterBar({ onScheduleTypeChange, onScanStatusChange, }: ScansFilterBarProps) { - const isCloudEnvironment = process.env.NEXT_PUBLIC_IS_CLOUD_ENV === "true"; + const isCloudEnvironment = isCloud(); const triggerFilterOptions = getScanTriggerFilterOptions(isCloudEnvironment); const statusFilterOptions = getScanStatusFilterOptions(activeTab); const showScheduleTypeFilter = activeTab !== SCAN_JOBS_TAB.SCHEDULED; diff --git a/ui/components/scans/scans-page-shell.tsx b/ui/components/scans/scans-page-shell.tsx index 0333570db3..b553d224b5 100644 --- a/ui/components/scans/scans-page-shell.tsx +++ b/ui/components/scans/scans-page-shell.tsx @@ -17,6 +17,7 @@ import { LAUNCH_SCAN_SEARCH_PARAM, LAUNCH_SCAN_SEARCH_VALUE, } from "@/lib/scans-navigation"; +import { isCloud } from "@/lib/shared/env"; import { buildViewFirstScanTour } from "@/lib/tours/view-first-scan.tour"; import { useScansStore } from "@/store"; import { SCAN_JOBS_TAB, SCAN_TAB_LABELS, type ScanJobsTab } from "@/types"; @@ -67,7 +68,7 @@ export function ScansPageShell({ const hasConnectedProviders = providers.some( (provider) => provider.attributes.connection.connected === true, ); - const isCloudEnvironment = process.env.NEXT_PUBLIC_IS_CLOUD_ENV === "true"; + const isCloudEnvironment = isCloud(); const launchDisabled = !hasManageScansPermission || !hasConnectedProviders; const launchOpen = isLaunchScanModalOpen || urlLaunchOpen; // When a scan is already running, the tour highlights its row (anchored in diff --git a/ui/components/scans/schedule/scan-schedule-fields.test.tsx b/ui/components/scans/schedule/scan-schedule-fields.test.tsx index dd311d880e..2beadf68ad 100644 --- a/ui/components/scans/schedule/scan-schedule-fields.test.tsx +++ b/ui/components/scans/schedule/scan-schedule-fields.test.tsx @@ -1,9 +1,11 @@ import { render, screen } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { useForm } from "react-hook-form"; -import { beforeAll, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeAll, describe, expect, it, vi } from "vitest"; import { getScheduleFormDefaults } from "@/lib/schedules"; +import { useCloudUpgradeStore } from "@/store"; +import { CLOUD_UPGRADE_FEATURE } from "@/types/cloud-upgrade"; import type { ScheduleFormValues } from "@/types/schedules"; import { ScanScheduleFields } from "./scan-schedule-fields"; @@ -58,6 +60,9 @@ function getHelperCopy(text: RegExp) { } describe("ScanScheduleFields", () => { + afterEach(() => { + useCloudUpgradeStore.getState().closeCloudUpgrade(); + }); it("updates the helper copy when the cadence changes to interval", async () => { // Given const user = userEvent.setup(); @@ -97,8 +102,9 @@ describe("ScanScheduleFields", () => { ); }); - it("shows a single cloud badge beside the Scan Schedule title when advanced controls are locked", () => { + it("opens advanced scheduling from the Cloud badge when controls are locked", async () => { // Given + const user = userEvent.setup(); render( { ); // Then - expect(screen.getAllByText("Available in Prowler Cloud")).toHaveLength(1); + expect(screen.getAllByText("Cloud")).toHaveLength(1); expect(screen.getByText("Scan Schedule").parentElement).toHaveTextContent( - "Available in Prowler Cloud", + "Cloud", ); expect(screen.getByText("Scan Time").parentElement).not.toHaveTextContent( - "Available in Prowler Cloud", + "Cloud", ); expect(screen.getByText("Repeats").parentElement).not.toHaveTextContent( - "Available in Prowler Cloud", + "Cloud", + ); + + // When + await user.click( + screen.getByRole("button", { + name: "Explore advanced scheduling in Prowler Cloud", + }), + ); + + // Then + expect(useCloudUpgradeStore.getState().activeFeature).toBe( + CLOUD_UPGRADE_FEATURE.ADVANCED_SCHEDULING, ); }); }); diff --git a/ui/components/scans/schedule/scan-schedule-fields.tsx b/ui/components/scans/schedule/scan-schedule-fields.tsx index 9351ca5336..a83d8d3c0b 100644 --- a/ui/components/scans/schedule/scan-schedule-fields.tsx +++ b/ui/components/scans/schedule/scan-schedule-fields.tsx @@ -6,6 +6,8 @@ import type { ReactNode } from "react"; import { Controller, type UseFormReturn, useWatch } from "react-hook-form"; import { + Badge, + Button, Checkbox, Field, FieldLabel, @@ -15,13 +17,14 @@ import { SelectTrigger, SelectValue, } from "@/components/shadcn"; -import { CloudFeatureBadgeLink } from "@/components/shared/cloud-feature-badge"; import { formatDayOfMonth, formatScheduleHour, getBrowserTimezone, getNextScheduledRun, } from "@/lib/schedules"; +import { useCloudUpgradeStore } from "@/store"; +import { CLOUD_UPGRADE_FEATURE } from "@/types/cloud-upgrade"; import { SCHEDULE_FREQUENCY, SCHEDULE_WEEKDAY_LABELS, @@ -130,6 +133,9 @@ export function ScanScheduleFields({ canUseAdvancedSchedule = true, showCloudUpgradeBadge = false, }: ScanScheduleFieldsProps) { + const openCloudUpgrade = useCloudUpgradeStore( + (state) => state.openCloudUpgrade, + ); // useWatch, not form.watch: form.watch re-renders are dropped by React Compiler memoization. const control = form.control; const [frequency, hour, dayOfWeek, dayOfMonth, intervalHours] = useWatch({ @@ -149,7 +155,19 @@ export function ScanScheduleFields({ // ignores them, so they are display-only with a Cloud upsell. const advancedDisabled = disabled || !canUseAdvancedSchedule; const cloudUpgradeBadge = showCloudUpgradeBadge ? ( - + ) : null; return ( diff --git a/ui/components/shadcn/badge/badge.test.tsx b/ui/components/shadcn/badge/badge.test.tsx index adae2f54b8..51f68d4610 100644 --- a/ui/components/shadcn/badge/badge.test.tsx +++ b/ui/components/shadcn/badge/badge.test.tsx @@ -18,6 +18,39 @@ describe("Badge", () => { expect(badge?.className).toContain("text-bg-data-info"); }); + it("applies the Cloud variant and compact size", () => { + // Given / When + render( + + Cloud + , + ); + + // Then + expect(screen.getByText("Cloud")).toHaveClass( + "bg-feature-cloud", + "h-5", + "rounded-md", + "text-[10px]", + ); + }); + + it("applies the New feature variant tokens", () => { + // Given / When + render( + + New + , + ); + + // Then + expect(screen.getByText("New")).toHaveClass( + "bg-bg-feature-new", + "text-text-feature-new", + "h-5", + ); + }); + it("merges a custom className", () => { const { container } = render( diff --git a/ui/components/shadcn/badge/badge.tsx b/ui/components/shadcn/badge/badge.tsx index 57674c4610..7e727e1a33 100644 --- a/ui/components/shadcn/badge/badge.tsx +++ b/ui/components/shadcn/badge/badge.tsx @@ -25,10 +25,18 @@ const badgeVariants = cva( error: "border-transparent bg-bg-fail-secondary text-text-error-primary", info: "border-transparent bg-bg-data-info/15 text-bg-data-info", + cloud: + "bg-feature-cloud h-6 rounded-lg border-0 px-2 py-0 text-xs leading-5 font-bold text-black", + new: "bg-bg-feature-new text-text-feature-new border-0 font-bold", + }, + size: { + default: "", + sm: "h-5 rounded-md px-1.5 py-0 text-[10px] leading-4", }, }, defaultVariants: { variant: "default", + size: "default", }, }, ); @@ -36,6 +44,7 @@ const badgeVariants = cva( function Badge({ className, variant, + size, asChild = false, ...props }: ComponentProps<"span"> & @@ -45,7 +54,7 @@ function Badge({ return ( ); diff --git a/ui/components/shadcn/dialog.tsx b/ui/components/shadcn/dialog.tsx index d4ae96d2ed..2b4669190c 100644 --- a/ui/components/shadcn/dialog.tsx +++ b/ui/components/shadcn/dialog.tsx @@ -70,7 +70,7 @@ function DialogContent({ {showCloseButton && ( Close diff --git a/ui/components/shadcn/modal/modal.tsx b/ui/components/shadcn/modal/modal.tsx index d47f0f3055..058cce3e7d 100644 --- a/ui/components/shadcn/modal/modal.tsx +++ b/ui/components/shadcn/modal/modal.tsx @@ -33,6 +33,7 @@ interface ModalProps { size?: ModalSize; className?: string; onOpenAutoFocus?: (event: Event) => void; + onCloseAutoFocus?: (event: Event) => void; /** * Cap the dialog at 90dvh and scroll overflowing content, instead of * letting it grow past the viewport. Opt-in per modal (e.g. for content @@ -51,12 +52,14 @@ export const Modal = ({ size = "xl", className, onOpenAutoFocus = preventInitialAutoFocus, + onCloseAutoFocus, scrollable = false, }: ModalProps) => { return ( , - CSSProperties | undefined -> = { - cloud: { - backgroundImage: - "linear-gradient(112deg, rgb(46, 229, 155) 3.5%, rgb(98, 223, 240) 98.8%)", - }, - new: undefined, -}; - -const FEATURE_BADGE_VARIANT_CLASS: Record< - NonNullable, - string -> = { - cloud: "text-black", - new: "bg-emerald-500 text-white", -}; - -const FEATURE_BADGE_SIZE_CLASS: Record< - NonNullable, - string -> = { - default: "h-6 rounded-lg px-2 text-xs leading-5", - sm: "h-5 rounded-md px-1.5 text-[10px] leading-4", -}; - -export const MenuFeatureBadge = ({ - label, - variant = "cloud", - size = "default", - className, -}: MenuFeatureBadgeProps) => ( - - {label} - -); - -export const CloudFeatureBadge = ({ - label = "Available in Prowler Cloud", - size, - className, -}: Omit) => ( - -); - -interface CloudFeatureBadgeLinkProps - extends Omit { - href?: string; -} - -export const CloudFeatureBadgeLink = ({ - href = "https://prowler.com/pricing", - label, - size, - className, -}: CloudFeatureBadgeLinkProps) => ( - - - -); diff --git a/ui/components/shared/cloud-upgrade-modal.test.tsx b/ui/components/shared/cloud-upgrade-modal.test.tsx new file mode 100644 index 0000000000..318c03740c --- /dev/null +++ b/ui/components/shared/cloud-upgrade-modal.test.tsx @@ -0,0 +1,136 @@ +import { cleanup, render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { useCloudUpgradeStore } from "@/store/cloud-upgrade/store"; +import { CLOUD_UPGRADE_FEATURE } from "@/types/cloud-upgrade"; + +import { CloudUpgradeModal } from "./cloud-upgrade-modal"; + +describe("CloudUpgradeModal", () => { + afterEach(() => { + cleanup(); + vi.unstubAllEnvs(); + useCloudUpgradeStore.getState().closeCloudUpgrade(); + }); + + it("renders the active contextual upgrade in Local Server", async () => { + // Given + vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "false"); + useCloudUpgradeStore + .getState() + .openCloudUpgrade(CLOUD_UPGRADE_FEATURE.ALERTS); + + // When + render(); + + // Then + expect( + await screen.findByRole("dialog", { name: "Turn Findings into Alerts" }), + ).toBeVisible(); + expect(screen.getByText("Available in Prowler Cloud")).toBeVisible(); + expect( + screen.getByRole("link", { name: "Create Alerts in Prowler Cloud" }), + ).toHaveAttribute( + "href", + "https://cloud.prowler.com/sign-up?utm_source=prowler-local-server&utm_content=alerts", + ); + expect( + screen.getByRole("link", { name: "View Plans & Pricing" }), + ).toHaveAttribute( + "href", + "https://prowler.com/pricing?utm_source=prowler-local-server&utm_content=alerts", + ); + }); + + it("uses the standard equal-width CTA layout", async () => { + // Given + vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "false"); + useCloudUpgradeStore + .getState() + .openCloudUpgrade(CLOUD_UPGRADE_FEATURE.AWS_ORGANIZATIONS); + + // When + render(); + + // Then + const dialog = await screen.findByRole("dialog", { + name: "Add Your Entire AWS Organization", + }); + const primaryCta = screen.getByRole("link", { + name: "Set Up AWS Organizations in Prowler Cloud", + }); + const secondaryCta = screen.getByRole("link", { + name: "View Plans & Pricing", + }); + + expect(dialog).toHaveClass("sm:max-w-2xl"); + expect(primaryCta.parentElement).toHaveClass("gap-3", "md:flex-row"); + expect(primaryCta).toHaveClass( + "h-auto", + "min-h-9", + "whitespace-normal", + "md:flex-1", + ); + expect(secondaryCta).toHaveClass( + "h-auto", + "min-h-9", + "whitespace-normal", + "md:flex-1", + ); + expect(primaryCta.querySelector(".truncate")).not.toBeInTheDocument(); + expect(primaryCta).toHaveAttribute( + "href", + "https://cloud.prowler.com/sign-up?utm_source=prowler-local-server&utm_content=organization", + ); + }); + + it("closes the active upgrade and returns focus to its trigger", async () => { + // Given + vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "false"); + const user = userEvent.setup(); + + render( + <> + + + , + ); + + const trigger = screen.getByRole("button", { + name: "Explore Prowler Cloud", + }); + await user.click(trigger); + + // When + await user.click(screen.getByRole("button", { name: "Close" })); + + // Then + expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); + expect(trigger).toHaveFocus(); + expect(useCloudUpgradeStore.getState().activeFeature).toBeNull(); + }); + + it("does not render upgrade UI in Prowler Cloud", () => { + // Given + vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "true"); + useCloudUpgradeStore + .getState() + .openCloudUpgrade(CLOUD_UPGRADE_FEATURE.ALERTS); + + // When + render(); + + // Then + expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); + }); +}); diff --git a/ui/components/shared/cloud-upgrade-modal.tsx b/ui/components/shared/cloud-upgrade-modal.tsx new file mode 100644 index 0000000000..9d7617d941 --- /dev/null +++ b/ui/components/shared/cloud-upgrade-modal.tsx @@ -0,0 +1,107 @@ +"use client"; + +import { Check, Cloud } from "lucide-react"; + +import { Badge } from "@/components/shadcn/badge/badge"; +import { Button } from "@/components/shadcn/button/button"; +import { Modal } from "@/components/shadcn/modal"; +import { + CLOUD_UPGRADE_CONTENT, + CLOUD_UPGRADE_FOOTER_NOTE, + CLOUD_UPGRADE_SECONDARY_CTA, + getCloudUpgradeCompareUrl, + getCloudUpgradePrimaryUrl, +} from "@/lib/cloud-upgrade"; +import { isCloud } from "@/lib/shared/env"; +import { useCloudUpgradeStore } from "@/store"; +import { CLOUD_UPGRADE_FEATURE } from "@/types/cloud-upgrade"; + +const allowInitialAutoFocus = () => {}; + +export const CloudUpgradeModal = () => { + const activeFeature = useCloudUpgradeStore((state) => state.activeFeature); + const closeCloudUpgrade = useCloudUpgradeStore( + (state) => state.closeCloudUpgrade, + ); + const returnFocusElement = useCloudUpgradeStore( + (state) => state.returnFocusElement, + ); + + if (isCloud()) return null; + + const feature = activeFeature ?? CLOUD_UPGRADE_FEATURE.GENERAL; + const content = CLOUD_UPGRADE_CONTENT[feature]; + + return ( + !open && closeCloudUpgrade()} + onOpenAutoFocus={allowInitialAutoFocus} + onCloseAutoFocus={(event) => { + event.preventDefault(); + returnFocusElement?.focus(); + }} + title={content.title} + description={content.description} + size="2xl" + > +
+
+
+
+ Available in Prowler Cloud +
+ +
    + {content.benefits.map((benefit) => ( +
  • +
  • + ))} +
+ + + +

+ {CLOUD_UPGRADE_FOOTER_NOTE} +

+
+
+ ); +}; diff --git a/ui/components/sidebar/navigation-mode-toggle.tsx b/ui/components/sidebar/navigation-mode-toggle.tsx index 548c55dbef..73954e48ab 100644 --- a/ui/components/sidebar/navigation-mode-toggle.tsx +++ b/ui/components/sidebar/navigation-mode-toggle.tsx @@ -4,6 +4,7 @@ import { Home } from "lucide-react"; import { useRouter } from "next/navigation"; import { LighthouseIcon } from "@/components/icons/Icons"; +import { Badge } from "@/components/shadcn/badge/badge"; import { Tooltip, TooltipContent, @@ -14,19 +15,27 @@ import { type SidebarNavigationMode, } from "@/hooks/use-sidebar"; import { cn } from "@/lib/utils"; +import { useCloudUpgradeStore } from "@/store"; +import { CLOUD_UPGRADE_FEATURE } from "@/types/cloud-upgrade"; +import type { MenuSelectionHandler } from "@/types/components"; export function SidebarNavigationModeToggle({ isOpen, value, onChange, chatEnabled = true, + onSelect, }: { isOpen: boolean; value: SidebarNavigationMode; onChange: (value: SidebarNavigationMode) => void; chatEnabled?: boolean; + onSelect?: MenuSelectionHandler; }) { const router = useRouter(); + const openCloudUpgrade = useCloudUpgradeStore( + (state) => state.openCloudUpgrade, + ); const modes = [ { value: SIDEBAR_NAVIGATION_MODE.BROWSE, @@ -41,8 +50,15 @@ export function SidebarNavigationModeToggle({ ] as const; const handleModeChange = (mode: SidebarNavigationMode, disabled: boolean) => { - if (disabled) return; + if (disabled) { + openCloudUpgrade( + CLOUD_UPGRADE_FEATURE.LIGHTHOUSE_AI, + onSelect?.() ?? undefined, + ); + return; + } onChange(mode); + onSelect?.(); if (mode === SIDEBAR_NAVIGATION_MODE.CHAT) { router.push("/lighthouse"); } @@ -66,25 +82,37 @@ export function SidebarNavigationModeToggle({ key={mode.value} type="button" aria-label={mode.label} - // aria-disabled (not disabled) keeps the button hoverable and - // focusable so the availability tooltip can fire. - aria-disabled={disabled || undefined} className={cn( - "flex h-8 items-center justify-center rounded-[6px] border px-2 text-sm transition-all duration-200 ease-out", - isOpen ? "min-w-0 gap-2" : "w-8", - // The active segment grows (~55%) and gains a bordered, shadowed - // "thumb"; the inactive one shrinks (~45%) and stays flat. + "flex h-8 items-center justify-center rounded-[6px] border text-sm transition-all duration-200 ease-out", + isOpen + ? disabled + ? "min-w-0 gap-1 px-1.5" + : "min-w-0 gap-2 px-2" + : "w-8 px-2", active ? "border-border-input-primary bg-bg-neutral-primary text-text-neutral-primary shadow-md" : "text-text-neutral-secondary hover:text-text-neutral-primary border-transparent", - isOpen && (active ? "flex-[11]" : "flex-[9]"), - disabled && - "hover:text-text-neutral-secondary cursor-not-allowed opacity-50", + // Local Server gives the Chat upsell enough room to keep both + // the feature name and its Cloud badge visible. + isOpen && + (!chatEnabled + ? mode.value === SIDEBAR_NAVIGATION_MODE.CHAT + ? "flex-[3]" + : "flex-[2]" + : active + ? "flex-[11]" + : "flex-[9]"), + disabled && "text-text-neutral-secondary", )} onClick={() => handleModeChange(mode.value, disabled)} > - - {isOpen && {mode.label}} +