Compare commits

..
Author SHA1 Message Date
Prowler Botandprowler-bot f1bb376e66 chore(release): Bump versions to v5.46.1 (#13164)
Co-authored-by: prowler-bot <179230569+prowler-bot@users.noreply.github.com>
2026-10-26 20:54:48 +01:00
Prowler Botandprowler-bot 91b86777ce chore(api): Update prowler dependency to v5.46 for release 5.46.0 (#13163)
Co-authored-by: prowler-bot <179230569+prowler-bot@users.noreply.github.com>
2026-10-26 18:56:37 +01:00
Prowler Botandprowler-bot fdb1bd6193 chore(changelog): v5.46.0 (#13162)
Co-authored-by: prowler-bot <179230569+prowler-bot@users.noreply.github.com>
2026-10-26 18:46:03 +01:00
Rubén De la Torre Vicoandalejandrobailo 41a2bf605a feat(ui): bring the issues UI closer to the original prototype (#13154)
Co-authored-by: alejandrobailo <alejandrobailo94@gmail.com>
2026-10-26 17:24:52 +01:00
Rubén De la Torre Vicoandalejandrobailo 63b8c8969c feat(ui): show the compliance an issue puts at risk (#13153)
Co-authored-by: alejandrobailo <alejandrobailo94@gmail.com>
2026-10-26 16:44:04 +01:00
Rubén De la Torre Vicoandalejandrobailo 1ae3856de0 feat(ui): show what each attack path buys and where it starts (#13152)
Co-authored-by: alejandrobailo <alejandrobailo94@gmail.com>
2026-10-26 15:59:38 +01:00
40758818c3 feat(aws): Update regions for AWS services (#13156)
Co-authored-by: prowler-bot <179230569+prowler-bot@users.noreply.github.com>
Co-authored-by: pedrooot <pedromarting3@gmail.com>
2026-10-26 15:10:49 +01:00
Rubén De la Torre Vicoandalejandrobailo 83152ae1a6 feat(ui): triage issues and send them to Jira (#13151)
Co-authored-by: alejandrobailo <alejandrobailo94@gmail.com>
2026-10-26 15:09:05 +01:00
Pedro Martín 3bbd551f0f fix(openstack): block SSRF via auth_url in connection test (#12962) 2026-10-26 13:37:46 +01:00
Pedro Martín be01a97789 fix(outputs): parse resource metadata once per batch (#13149) 2026-10-26 12:57:52 +01:00
Alejandro Bailo 7ecb6aaefd fix(ui): match the "Save and launch scan" footer in the provider E2E flows (#13133) 2026-10-26 12:53:36 +01:00
Pedro Martín 75f81cc751 ci(ui): pin Playwright jobs to ubuntu-24.04 (#13158) 2026-10-26 12:32:58 +01:00
Pedro Martín c724840e6a fix(api): compare serialized metadata on scan ingestion (#13148) 2026-10-26 12:13:02 +01:00
Pedro Martín b52b8d3678 fix(api): bump djangorestframework to 3.18.3 (#13157) 2026-10-26 11:56:25 +01:00
Alejandro Bailo c533b47aeb fix: patch critical UI advisories and SDK container CVEs (#13135) 2026-10-26 11:51:31 +01:00
136 changed files with 3851 additions and 975 deletions

No files matched your search

+1 -1
View File
@@ -174,7 +174,7 @@ SENTRY_RELEASE=local
# REO_DEV_CLIENT_ID=
#### Prowler release version ####
NEXT_PUBLIC_PROWLER_RELEASE_VERSION=v5.46.0
NEXT_PUBLIC_PROWLER_RELEASE_VERSION=v5.46.1
# Social login credentials
SOCIAL_GOOGLE_OAUTH_CALLBACK_URL="${AUTH_URL}/api/auth/callback/google"
@@ -81,6 +81,7 @@ jobs:
auth.docker.io:443
production.cloudflare.docker.com:443
production.cloudfront.docker.com:443
tuf-repo-cdn.sigstore.dev:443
raw.githubusercontent.com:443
objects.githubusercontent.com:443
grype.anchore.io:443
@@ -77,6 +77,7 @@ jobs:
auth.docker.io:443
production.cloudflare.docker.com:443
production.cloudfront.docker.com:443
tuf-repo-cdn.sigstore.dev:443
ghcr.io:443
pkg-containers.githubusercontent.com:443
files.pythonhosted.org:443
@@ -81,6 +81,7 @@ jobs:
auth.docker.io:443
production.cloudflare.docker.com:443
production.cloudfront.docker.com:443
tuf-repo-cdn.sigstore.dev:443
api.github.com:443
mirror.gcr.io:443
check.trivy.dev:443
@@ -78,6 +78,7 @@ jobs:
auth.docker.io:443
production.cloudflare.docker.com:443
production.cloudfront.docker.com:443
tuf-repo-cdn.sigstore.dev:443
registry.npmjs.org:443
dl-cdn.alpinelinux.org:443
fonts.googleapis.com:443
+10 -1
View File
@@ -39,7 +39,10 @@ jobs:
github.repository == 'prowler-cloud/prowler' &&
(github.event_name != 'pull_request' || github.event.pull_request.head.repo.fork == false) &&
(needs.impact-analysis.outputs.has-ui-e2e == 'true' || needs.impact-analysis.outputs.run-all == 'true')
runs-on: ubuntu-latest
# ubuntu-latest is moving to 26.04 (rollout began October 19, 2026; completion
# targeted for November 19, 2026), where `playwright install` refuses to install
# Chromium for the pinned Playwright. Stay on 24.04 until the suite upgrades.
runs-on: ubuntu-24.04
env:
AUTH_SECRET: "fallback-ci-secret-for-testing"
AUTH_TRUST_HOST: true
@@ -391,6 +394,12 @@ jobs:
working-directory: ./ui
run: pnpm run test:e2e:registry
# A worker that crashes on import or a connection test that never resolves
# only shows up here; Playwright just reports a missing footer button.
- name: Dump API service logs
if: failure()
run: docker compose logs --no-color --tail=300 api worker worker-beat || true
- name: Upload test reports
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
if: failure()
+4 -1
View File
@@ -21,7 +21,10 @@ permissions: {}
jobs:
ui-tests:
runs-on: ubuntu-latest
# ubuntu-latest is moving to 26.04 (rollout began October 19, 2026; completion
# targeted for November 19, 2026), where `playwright install` refuses to install
# Chromium for the pinned Playwright. Stay on 24.04 until the suite upgrades.
runs-on: ubuntu-24.04
timeout-minutes: 20
permissions:
contents: read
+6 -3
View File
@@ -30,6 +30,7 @@ ARG ZIZMOR_SHA256_ARM64=d66e37ef8a375fb07939c630ebf9709a6e0f20242bdc3faf672a7ed9
# perl-base 5.40.1-6+deb13u1 CVE-2026-42497, -48962, -57432
# libssh2-1t64 1.11.1-1+deb13u2 CVE-2026-58050
# libpcre2-8-0 10.46-1~deb13u2 CVE-2026-86145, -89161
# libbrotli1 1.1.0-2+deb13u1 CVE-2026-111497
# Taken as a targeted --only-upgrade rather than by moving the digest: the newest
# published python:3.12-slim-trixie carries the same vulnerable versions. The three
# openssl packages are flagged separately, so all are named.
@@ -40,7 +41,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
build-essential pkg-config libzstd-dev zlib1g-dev \
&& apt-get install -y --no-install-recommends --only-upgrade \
util-linux libssl3t64 openssl openssl-provider-legacy \
libsqlite3-0 gzip perl-base libssh2-1t64 libpcre2-8-0 \
libsqlite3-0 gzip perl-base libssh2-1t64 libpcre2-8-0 libbrotli1 \
&& rm -rf /var/lib/apt/lists/*
# Install PowerShell
@@ -126,8 +127,10 @@ RUN pip install --no-cache-dir --upgrade pip && \
RUN uv sync --locked --compile-bytecode && \
rm -rf ~/.cache/uv
# Install PowerShell modules
RUN .venv/bin/python prowler/providers/m365/lib/powershell/m365_powershell.py
# Install PowerShell modules. Drop the MicrosoftTeams build SBOM: Trivy reports the npm
# tooling it declares as installed, though none ships (see .trivyignore.yaml).
RUN .venv/bin/python prowler/providers/m365/lib/powershell/m365_powershell.py && \
rm -rf "${HOME}"/.local/share/powershell/Modules/MicrosoftTeams/*/_manifest
USER root
+19
View File
@@ -4,6 +4,25 @@ All notable changes to the **Prowler API** are documented in this file.
<!-- changelog: release notes start -->
## [1.47.0] (Prowler v5.46.0)
### 🐞 Fixed
- Compliance report PDF cover table wraps every value, so long account IDs such as OCI tenancy OCIDs no longer overflow their cell [(#13063)](https://github.com/prowler-cloud/prowler/pull/13063)
- Scan ingestion compares the serialized resource metadata with the stored value and serializes sets in a stable order, so unchanged resources no longer get their metadata, details and updated_at rewritten on every scan [(#13148)](https://github.com/prowler-cloud/prowler/pull/13148)
- Report generation shares the parsed resource metadata between the findings of a batch and releases each batch before building the next one [(#13149)](https://github.com/prowler-cloud/prowler/pull/13149)
### 🔐 Security
- `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 [(#12964)](https://github.com/prowler-cloud/prowler/pull/12964)
- Trivy from 0.75.0 to 0.76.0 in the API container image, built with Go 1.27.2 and x/net 0.60.0, patching the HTTP/2 and TLS CVEs bundled in Trivy 0.75.0 [(#13064)](https://github.com/prowler-cloud/prowler/pull/13064)
- 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 [(#13107)](https://github.com/prowler-cloud/prowler/pull/13107)
- 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 [(#13120)](https://github.com/prowler-cloud/prowler/pull/13120)
- 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 [(#13121)](https://github.com/prowler-cloud/prowler/pull/13121)
- djangorestframework from 3.15.2 to 3.18.3 in the API, patching the JSON parser charset and Accept header denial of service and the TokenProxy revocation bypass, and the API container image no longer ships the MicrosoftTeams build SBOM that Trivy read as installed zod and body-parser [(#13157)](https://github.com/prowler-cloud/prowler/pull/13157)
---
## [1.46.0] (Prowler v5.45.0)
### 🐞 Fixed
+4 -2
View File
@@ -134,8 +134,10 @@ RUN uv sync --locked --no-install-project && \
rm -rf ~/.cache/uv
# Invoked as a module so the base image's Python minor version is not baked
# into a site-packages path.
RUN .venv/bin/python -m prowler.providers.m365.lib.powershell.m365_powershell
# into a site-packages path. Drop the MicrosoftTeams build SBOM: Trivy reports the npm
# tooling it declares as installed, though none ships (see .trivyignore.yaml).
RUN .venv/bin/python -m prowler.providers.m365.lib.powershell.m365_powershell && \
rm -rf /home/prowler/.local/share/powershell/Modules/MicrosoftTeams/*/_manifest
USER root
@@ -1 +0,0 @@
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
@@ -1 +0,0 @@
Trivy from 0.75.0 to 0.76.0 in the API container image, built with Go 1.27.2 and x/net 0.60.0, patching the HTTP/2 and TLS CVEs bundled in Trivy 0.75.0
@@ -1 +0,0 @@
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
@@ -1 +0,0 @@
`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
@@ -1 +0,0 @@
Compliance report PDF cover table wraps every value, so long account IDs such as OCI tenancy OCIDs no longer overflow their cell
@@ -1 +0,0 @@
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
+4 -4
View File
@@ -34,7 +34,7 @@ dependencies = [
"django-filter==24.3",
"django-guid==3.5.0",
"django-postgres-extra (==2.0.9)",
"djangorestframework==3.15.2",
"djangorestframework==3.18.3",
"djangorestframework-jsonapi==7.0.2",
"djangorestframework-simplejwt (==5.5.1)",
"drf-nested-routers (==0.95.0)",
@@ -45,7 +45,7 @@ dependencies = [
"gunicorn==26.0.0",
"uvloop==0.22.1",
"lxml==6.1.0",
"prowler @ git+https://github.com/prowler-cloud/prowler.git@master",
"prowler @ git+https://github.com/prowler-cloud/prowler.git@v5.46",
"psycopg2-binary==2.9.9",
"pytest-celery[redis] (==1.3.0)",
"sentry-sdk[django] (==2.56.0)",
@@ -71,7 +71,7 @@ name = "prowler-api"
package-mode = false
# Needed for the SDK compatibility
requires-python = ">=3.11,<3.13"
version = "1.47.0"
version = "1.47.1"
# Shared ruff baseline (kept in sync with mcp_server/pyproject.toml).
# target-version tracks this project's lowest supported Python.
@@ -234,7 +234,7 @@ constraint-dependencies = [
"django-postgres-extra==2.0.9",
"django-silk==5.3.2",
"django-timezone-field==7.2.1",
"djangorestframework==3.15.2",
"djangorestframework==3.18.3",
"djangorestframework-jsonapi==7.0.2",
"djangorestframework-simplejwt==5.5.1",
"dnspython==2.8.0",
+1 -1
View File
@@ -1,7 +1,7 @@
openapi: 3.0.3
info:
title: Prowler API
version: 1.47.0
version: 1.47.1
description: |-
Prowler API specification.
+5 -4
View File
@@ -997,11 +997,12 @@ def _process_finding_micro_batch(
snapshot_resource_fields(resource_uid, resource_instance)
resource_instance.type = finding.resource_type
updated = True
if resource_instance.metadata != finding.resource_metadata:
serialized_metadata = json.dumps(
finding.resource_metadata, cls=CustomEncoder
)
if resource_instance.metadata != serialized_metadata:
snapshot_resource_fields(resource_uid, resource_instance)
resource_instance.metadata = json.dumps(
finding.resource_metadata, cls=CustomEncoder
)
resource_instance.metadata = serialized_metadata
updated = True
if resource_instance.details != finding.resource_details:
snapshot_resource_fields(resource_uid, resource_instance)
+7 -1
View File
@@ -897,8 +897,12 @@ def generate_outputs_task(scan_id: str, provider_id: str, tenant_id: str):
)
with rls_transaction(tenant_id, using=READ_REPLICA_ALIAS):
for batch, is_last in batched(qs, DJANGO_FINDINGS_BATCH_SIZE):
metadata_cache = {}
fos = [
FindingOutput.transform_api_finding(f, prowler_provider) for f in batch
FindingOutput.transform_api_finding(
f, prowler_provider, metadata_cache=metadata_cache
)
for f in batch
]
# Outputs
@@ -979,6 +983,8 @@ def generate_outputs_task(scan_id: str, provider_id: str, tenant_id: str):
writer.batch_write_data_to_file()
writer._data.clear()
del fos, metadata_cache
compressed = _compress_output_files(out_dir)
upload_uri = _upload_to_s3(
+72
View File
@@ -2349,6 +2349,78 @@ class TestProcessFindingMicroBatch:
assert resource_cache[finding.resource_uid].service == finding.service_name
assert tag_cache.keys() == {("team", "devsec")}
def test_process_finding_micro_batch_skips_unchanged_resource(
self, tenants_fixture, scans_fixture
):
tenant = tenants_fixture[0]
scan = scans_fixture[0]
provider = scan.provider
resource_metadata = {"owner": "platform", "ports": [22, 443]}
existing_resource = Resource.objects.create(
tenant_id=tenant.id,
provider=provider,
uid="arn:aws:ec2:us-east-1:123456789012:instance/i-001",
name="i-001",
region="us-east-1",
service="ec2",
type="instance",
metadata=json.dumps(resource_metadata, cls=CustomEncoder),
details="details",
partition="aws",
)
finding = FakeFinding(
uid="finding-unchanged",
status=StatusChoices.PASS,
status_extended="passing",
severity=Severity.low,
check_id="ec2_public_instance",
resource_uid=existing_resource.uid,
resource_name=existing_resource.name,
region=existing_resource.region,
service_name=existing_resource.service,
resource_type=existing_resource.type,
resource_tags={},
resource_metadata=resource_metadata,
resource_details=existing_resource.details,
partition=existing_resource.partition,
raw={"status": "PASS"},
compliance={},
metadata={"source": "prowler"},
muted=False,
)
with (
patch("tasks.jobs.scan.rls_transaction", new=noop_rls_transaction),
patch("api.db_utils.rls_transaction", new=noop_rls_transaction),
patch(
"tasks.jobs.scan.Resource.objects.bulk_update",
wraps=Resource.objects.bulk_update,
) as bulk_update,
):
_process_finding_micro_batch(
str(tenant.id),
[finding],
scan,
provider,
{existing_resource.uid: existing_resource},
{},
{},
{existing_resource.uid: 0},
set(),
set(),
{},
{},
{},
{},
)
bulk_update.assert_not_called()
assert Finding.objects.filter(
tenant_id=tenant.id, scan_id=scan.id, uid=finding.uid
).exists()
def test_process_finding_micro_batch_refreshes_empty_resource_name(
self, tenants_fixture, scans_fixture
):
+1 -1
View File
@@ -742,7 +742,7 @@ class TestGenerateOutputs:
patch("tasks.tasks.FindingOutput._transform_findings_stats"),
patch(
"tasks.tasks.FindingOutput.transform_api_finding",
side_effect=lambda f, _prov: f,
side_effect=lambda f, _prov, **_kw: f,
),
patch("tasks.tasks._compress_output_files", return_value="outdir.zip"),
patch("tasks.tasks._upload_to_s3", return_value="s3://bucket/outdir.zip"),
+17 -1
View File
@@ -1,10 +1,11 @@
import json
from datetime import UTC, datetime, timedelta
from unittest.mock import patch
import pytest
from django_celery_beat.models import IntervalSchedule, PeriodicTask
from django_celery_results.models import TaskResult
from tasks.utils import batched, get_next_execution_datetime
from tasks.utils import CustomEncoder, batched, get_next_execution_datetime
@pytest.mark.django_db
@@ -100,3 +101,18 @@ class TestBatchedFunction:
result = list(batched([1, 2, 3], 5))
expected = [([1, 2, 3], True)]
assert result == expected
class TestCustomEncoder:
def test_set_serialized_in_stable_order(self):
zones = {"us-east-1d", "us-east-1a", "us-east-1c", "us-east-1b"}
assert json.dumps({"zones": zones}, cls=CustomEncoder) == json.dumps(
{"zones": "{'us-east-1a', 'us-east-1b', 'us-east-1c', 'us-east-1d'}"}
)
def test_empty_set_keeps_str_representation(self):
assert json.dumps(set(), cls=CustomEncoder) == json.dumps("set()")
def test_frozenset_serialized_like_set(self):
assert json.dumps(frozenset({2, 1}), cls=CustomEncoder) == json.dumps("{1, 2}")
+5
View File
@@ -19,6 +19,11 @@ class CustomEncoder(json.JSONEncoder):
return o.isoformat(timespec="seconds")
if isinstance(o, timedelta):
return o.total_seconds()
# Same representation as str(set), with a stable order across processes
if isinstance(o, (set, frozenset)):
if not o:
return "set()"
return "{" + ", ".join(repr(item) for item in sorted(o, key=repr)) + "}"
# Custom object serialization
try:
Generated
+37 -10
View File
@@ -1,5 +1,5 @@
version = 1
revision = 2
revision = 3
requires-python = ">=3.11, <3.13"
resolution-markers = [
"python_full_version >= '3.12' and sys_platform == 'win32'",
@@ -150,7 +150,7 @@ constraints = [
{ name = "django-postgres-extra", specifier = "==2.0.9" },
{ name = "django-silk", specifier = "==5.3.2" },
{ name = "django-timezone-field", specifier = "==7.2.1" },
{ name = "djangorestframework", specifier = "==3.15.2" },
{ name = "djangorestframework", specifier = "==3.18.3" },
{ name = "djangorestframework-jsonapi", specifier = "==7.0.2" },
{ name = "djangorestframework-simplejwt", specifier = "==5.5.1" },
{ name = "dnspython", specifier = "==2.8.0" },
@@ -1229,6 +1229,20 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/32/b3/8ec1268082f4d20cc8bf723a1a8e6b9e330bcc338a4dbcee9c7737e9dc1c/azure_mgmt_authorization-4.0.0-py3-none-any.whl", hash = "sha256:d8feeb3842e6ddf1a370963ca4f61fb6edc124e8997b807dd025bc9b2379cd1a", size = 1072620, upload-time = "2023-07-25T04:47:49.26Z" },
]
[[package]]
name = "azure-mgmt-cognitiveservices"
version = "14.1.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "azure-mgmt-core" },
{ name = "msrest" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/fa/8e/9fcfdd507413a2536c5458b40942705ea8d74fe4e17f05fd1c32bb0225a9/azure_mgmt_cognitiveservices-14.1.0.tar.gz", hash = "sha256:915191374d0adb443863c20aaea2c9f3b9d558a849ac2b78f152249262fdcaf8", size = 184444, upload-time = "2025-10-24T07:28:14.229Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d3/25/ed6bbc01a03991255dd22aa41091360d225f1be20c88710af0f4a6c27459/azure_mgmt_cognitiveservices-14.1.0-py3-none-any.whl", hash = "sha256:3c0eecc00d183842a11d754f4f5c3328ff86f2d6c0a2b81b28dd7e073680258e", size = 290096, upload-time = "2025-10-24T07:28:15.766Z" },
]
[[package]]
name = "azure-mgmt-compute"
version = "34.0.0"
@@ -2510,14 +2524,14 @@ wheels = [
[[package]]
name = "djangorestframework"
version = "3.15.2"
version = "3.18.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "django" },
]
sdist = { url = "https://files.pythonhosted.org/packages/2c/ce/31482eb688bdb4e271027076199e1aa8d02507e530b6d272ab8b4481557c/djangorestframework-3.15.2.tar.gz", hash = "sha256:36fe88cd2d6c6bec23dca9804bab2ba5517a8bb9d8f47ebc68981b56840107ad", size = 1067420, upload-time = "2024-06-19T07:59:32.891Z" }
sdist = { url = "https://files.pythonhosted.org/packages/8d/b2/1cee76b188301f7d03169ca77fab2b3d3ec5d01b3469189f944af2d7f11a/djangorestframework-3.18.3.tar.gz", hash = "sha256:446a9b352e7eff630421ab3f2328bd2401b109a9470afa4a31189994911ed030", size = 923563, upload-time = "2026-10-06T21:33:51.521Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/7c/b6/fa99d8f05eff3a9310286ae84c4059b08c301ae4ab33ae32e46e8ef76491/djangorestframework-3.15.2-py3-none-any.whl", hash = "sha256:2b8871b062ba1aefc2de01f773875441a961fefbf79f5eed1e32b2f096944b20", size = 1071235, upload-time = "2024-06-19T07:59:26.106Z" },
{ url = "https://files.pythonhosted.org/packages/c4/dd/de5f4543765bec94e036a5e9593a42f8fa7efec8de78eb56b9ca834385f3/djangorestframework-3.18.3-py3-none-any.whl", hash = "sha256:8544bb674846731b1e3c9b309236ee1dc412905a0aa725be2ec193ca950a7d12", size = 903986, upload-time = "2026-10-06T21:33:49.917Z" },
]
[[package]]
@@ -3503,6 +3517,17 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/18/7d/721f162c46e3de604a73674223bf6c6bc6cf7ade25b3751a71288f4dd122/huaweicloudsdkrds-3.1.204-py3-none-any.whl", hash = "sha256:a790b5b3c457a608e5679c101f463b4d037dd9a8a66f6e46144a9e5a4b37780f", size = 1626906, upload-time = "2026-07-09T09:04:06.936Z" },
]
[[package]]
name = "huaweicloudsdksmn"
version = "3.1.204"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "huaweicloudsdkcore" },
]
wheels = [
{ url = "https://files.pythonhosted.org/packages/e9/5d/c0de47d011f1932c9c0ddf669c238d9fad01653d438d38203703526799d7/huaweicloudsdksmn-3.1.204-py3-none-any.whl", hash = "sha256:b0818ea9293e27458c8fa1d2da021c98006cbf9ce01cf17a0f1df598c4da3a6c", size = 323674, upload-time = "2026-07-09T09:04:24.804Z" },
]
[[package]]
name = "huaweicloudsdkvpc"
version = "3.1.204"
@@ -4842,8 +4867,8 @@ wheels = [
[[package]]
name = "prowler"
version = "5.41.0"
source = { git = "https://github.com/prowler-cloud/prowler.git?rev=master#f05a490cd74a2c0f11a5d66d8ce29d03fa5c64a2" }
version = "5.46.0"
source = { git = "https://github.com/prowler-cloud/prowler.git?rev=v5.46#fdb1bd619345b6f92bd6a7f8906fa5e6684a90a4" }
dependencies = [
{ name = "alibabacloud-actiontrail20200706" },
{ name = "alibabacloud-credentials" },
@@ -4864,6 +4889,7 @@ dependencies = [
{ name = "azure-mgmt-apimanagement" },
{ name = "azure-mgmt-applicationinsights" },
{ name = "azure-mgmt-authorization" },
{ name = "azure-mgmt-cognitiveservices" },
{ name = "azure-mgmt-compute" },
{ name = "azure-mgmt-containerregistry" },
{ name = "azure-mgmt-containerservice" },
@@ -4907,6 +4933,7 @@ dependencies = [
{ name = "huaweicloudsdkkms" },
{ name = "huaweicloudsdkobs" },
{ name = "huaweicloudsdkrds" },
{ name = "huaweicloudsdksmn" },
{ name = "huaweicloudsdkvpc" },
{ name = "huaweicloudsdkwaf" },
{ name = "jsonschema" },
@@ -4945,7 +4972,7 @@ dependencies = [
[[package]]
name = "prowler-api"
version = "1.47.0"
version = "1.47.1"
source = { virtual = "." }
dependencies = [
{ name = "cartography" },
@@ -5029,7 +5056,7 @@ requires-dist = [
{ name = "django-filter", specifier = "==24.3" },
{ name = "django-guid", specifier = "==3.5.0" },
{ name = "django-postgres-extra", specifier = "==2.0.9" },
{ name = "djangorestframework", specifier = "==3.15.2" },
{ name = "djangorestframework", specifier = "==3.18.3" },
{ name = "djangorestframework-jsonapi", specifier = "==7.0.2" },
{ name = "djangorestframework-simplejwt", specifier = "==5.5.1" },
{ name = "drf-nested-routers", specifier = "==0.95.0" },
@@ -5045,7 +5072,7 @@ requires-dist = [
{ name = "matplotlib", specifier = "==3.10.8" },
{ name = "neo4j", specifier = "==6.1.0" },
{ name = "openai", specifier = "==1.109.1" },
{ name = "prowler", git = "https://github.com/prowler-cloud/prowler.git?rev=master" },
{ name = "prowler", git = "https://github.com/prowler-cloud/prowler.git?rev=v5.46" },
{ name = "psycopg2-binary", specifier = "==2.9.9" },
{ name = "pytest-celery", extras = ["redis"], specifier = "==1.3.0" },
{ name = "reportlab", specifier = "==4.4.10" },
+1 -1
View File
@@ -3251,7 +3251,7 @@ If your provider has specific fields, add them to the finding transformation:
**File:** `prowler/lib/outputs/finding.py`
```python
def transform_api_finding(cls, finding, provider) -> "Finding":
def transform_api_finding(cls, finding, provider, metadata_cache=None) -> "Finding":
# ... existing code ...
# Your provider specific field
@@ -611,6 +611,40 @@ If specific checks fail due to insufficient permissions:
Using Public Cloud credentials can limit Keystone API access, so the command above may not work. Verify permissions in the provider's control panel instead.
</Warning>
### Private Keystone Endpoints
Most OpenStack deployments keep Keystone on an internal network, which is the normal case rather than the exception.
**Prowler CLI scans a private cloud with no extra configuration:**
```console
export OS_AUTH_URL="https://keystone.internal:5000/v3"
prowler openstack
```
On the CLI the operator supplies the `auth_url` themselves, so there is nothing to defend against and the check below does not apply.
**Prowler App rejects an `auth_url` that resolves to a non-public address**, as an SSRF defense, before the SDK connects and on both the connection test and the scan. There the URL arrives from a tenant and the worker must not be used to reach the internal network it runs in. To scan a private cloud from Prowler App, declare the trusted ranges:
```console
PROWLER_ALLOWED_PRIVATE_NETWORKS="10.20.0.0/16,192.168.65.254/32"
```
The value is a comma-separated list of IPs and CIDRs. A resolved address inside an allowlisted range is permitted; every other non-public address stays blocked, so link-local (`169.254.169.254`), loopback and the rest of the internal network remain protected. Malformed entries are rejected, and a non-empty allowlist is logged as a relaxed security control.
The scan runs in the worker, not the API, so the variable has to reach the worker:
- **Docker Compose**: set it in the root `.env`; it reaches the worker through the shared environment file.
- **Helm**: add it under `api.djangoConfig`, which is rendered into the API ConfigMap that the worker inherits through `envFrom`.
```yaml
api:
djangoConfig:
PROWLER_ALLOWED_PRIVATE_NETWORKS: "10.20.0.0/16"
```
Setting it only on the API container has no effect. The same variable applies to the Kubernetes and IaC providers. The Image provider has its own, `PROWLER_IMAGE_PROVIDER_ALLOWED_PRIVATE_NETWORKS`.
## Next Steps
- [Getting Started with OpenStack](/user-guide/providers/openstack/getting-started-openstack) - Run your first scan
+8
View File
@@ -4,6 +4,14 @@ All notable changes to the **Prowler MCP Server** are documented in this file.
<!-- changelog: release notes start -->
## [0.12.4] (Prowler v5.46.0)
### 🔐 Security
- 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 [(#13107)](https://github.com/prowler-cloud/prowler/pull/13107)
---
## [0.12.3] (Prowler v5.45.0)
### 🔐 Security
@@ -1 +0,0 @@
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
+40
View File
@@ -4,6 +4,46 @@ All notable changes to the **Prowler SDK** are documented in this file.
<!-- changelog: release notes start -->
## [5.46.0] (Prowler v5.46.0)
### 🚀 Added
- `cognito_user_pool_account_recovery_sms_not_preferred` check verifying Cognito user pools do not rank SMS first for account recovery [(#12251)](https://github.com/prowler-cloud/prowler/pull/12251)
- `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 [(#12663)](https://github.com/prowler-cloud/prowler/pull/12663)
- `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 [(#12663)](https://github.com/prowler-cloud/prowler/pull/12663)
- Vertex AI service for GCP provider, including the `vertexai_workbench_instance_public_ip_disabled` and `vertexai_workbench_instance_root_access_disabled` checks mapped to PCI DSS 4.0 [(#13062)](https://github.com/prowler-cloud/prowler/pull/13062)
- `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 [(#13077)](https://github.com/prowler-cloud/prowler/pull/13077)
- 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 [(#13113)](https://github.com/prowler-cloud/prowler/pull/13113)
- `wafv2_webacl_managed_rule_groups_enabled` check to verify WAFv2 Web ACLs reference at least one managed rule group [(#13127)](https://github.com/prowler-cloud/prowler/pull/13127)
- `appengine` GCP service and `appengine_version_php_runtime_supported` check to flag App Engine serving versions on PHP runtimes without security fixes [(#13127)](https://github.com/prowler-cloud/prowler/pull/13127)
- `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 [(#13127)](https://github.com/prowler-cloud/prowler/pull/13127)
- `lightsail_instance_php_blueprint_updated` check to flag Lightsail instances created from PHP blueprints for manual verification of the PHP release [(#13127)](https://github.com/prowler-cloud/prowler/pull/13127)
### 🔄 Changed
- 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 [(#13117)](https://github.com/prowler-cloud/prowler/pull/13117)
- 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 [(#13128)](https://github.com/prowler-cloud/prowler/pull/13128)
### 🐞 Fixed
- `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 [(#12663)](https://github.com/prowler-cloud/prowler/pull/12663)
- `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 [(#13113)](https://github.com/prowler-cloud/prowler/pull/13113)
- 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 [(#13114)](https://github.com/prowler-cloud/prowler/pull/13114)
- 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 [(#13115)](https://github.com/prowler-cloud/prowler/pull/13115)
- 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 [(#13117)](https://github.com/prowler-cloud/prowler/pull/13117)
- 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 [(#13127)](https://github.com/prowler-cloud/prowler/pull/13127)
- Scan report generation parses each resource's metadata once per batch and sanitizes it once per finding, so providers with very large resource metadata no longer exhaust the worker memory [(#13149)](https://github.com/prowler-cloud/prowler/pull/13149)
### 🔐 Security
- OpenStack auth_url resolving to loopback, private or otherwise non-public hosts rejected before the SDK connects, on both the connection test and the scan [(#12962)](https://github.com/prowler-cloud/prowler/pull/12962)
- Trivy from 0.75.0 to 0.76.0 in the SDK container image, built with Go 1.27.2 and x/net 0.60.0, patching the HTTP/2 and TLS CVEs bundled in Trivy 0.75.0 [(#13064)](https://github.com/prowler-cloud/prowler/pull/13064)
- 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 [(#13107)](https://github.com/prowler-cloud/prowler/pull/13107)
- 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 [(#13129)](https://github.com/prowler-cloud/prowler/pull/13129)
- black from 26.3.1 to 26.10.1 and graphql-core from 3.2.8 to 3.2.12 in the SDK, and libbrotli1 1.1.0-2+deb13u1 in its container image, which no longer ships the MicrosoftTeams build SBOM that Trivy read as installed zod and body-parser [(#13135)](https://github.com/prowler-cloud/prowler/pull/13135)
---
## [5.45.0] (Prowler v5.45.0)
### 🚀 Added
@@ -1 +0,0 @@
`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
@@ -1 +0,0 @@
`lightsail_instance_php_blueprint_updated` check to flag Lightsail instances created from PHP blueprints for manual verification of the PHP release
@@ -1 +0,0 @@
`wafv2_webacl_managed_rule_groups_enabled` check to verify WAFv2 Web ACLs reference at least one managed rule group
@@ -1 +0,0 @@
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
@@ -1 +0,0 @@
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
@@ -1 +0,0 @@
`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
@@ -1 +0,0 @@
`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
@@ -1 +0,0 @@
`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
@@ -1 +0,0 @@
`cognito_user_pool_account_recovery_sms_not_preferred` check verifying Cognito user pools do not rank SMS first for account recovery
@@ -1 +0,0 @@
`appengine` GCP service and `appengine_version_php_runtime_supported` check to flag App Engine serving versions on PHP runtimes without security fixes
@@ -1 +0,0 @@
Vertex AI service for GCP provider, including the `vertexai_workbench_instance_public_ip_disabled` and `vertexai_workbench_instance_root_access_disabled` checks mapped to PCI DSS 4.0
@@ -1 +0,0 @@
`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
@@ -1 +0,0 @@
`repository_secret_scanning_push_protection_enabled` check for GitHub to flag repositories that accept pushes containing secrets because secret scanning push protection is disabled
@@ -1 +0,0 @@
`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
@@ -1 +0,0 @@
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
@@ -1 +0,0 @@
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
@@ -1 +0,0 @@
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
@@ -1 +0,0 @@
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
@@ -1 +0,0 @@
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
@@ -1 +0,0 @@
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
@@ -1 +0,0 @@
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
@@ -1 +0,0 @@
Trivy from 0.75.0 to 0.76.0 in the SDK container image, built with Go 1.27.2 and x/net 0.60.0, patching the HTTP/2 and TLS CVEs bundled in Trivy 0.75.0
+1 -1
View File
@@ -52,7 +52,7 @@ class _MutableTimestamp:
timestamp = _MutableTimestamp(datetime.today())
timestamp_utc = _MutableTimestamp(datetime.now(timezone.utc))
prowler_version = "5.46.0"
prowler_version = "5.46.1"
html_logo_url = "https://github.com/prowler-cloud/prowler/"
square_logo_img = "https://raw.githubusercontent.com/prowler-cloud/prowler/dc7d2d5aeb92fdf12e8604f42ef6472cd3e8e889/docs/img/prowler-logo-black.png"
aws_logo = "https://user-images.githubusercontent.com/38561120/235953920-3e3fba08-0795-41dc-b480-9bea57db9f2e.png"
@@ -25,7 +25,7 @@ from prowler.lib.check.compliance_config_eval import (
)
from prowler.lib.check.compliance_models import ComplianceFramework
from prowler.lib.logger import logger
from prowler.lib.outputs.utils import unroll_dict_to_list
from prowler.lib.outputs.utils import is_plain_json, unroll_dict_to_list
from prowler.lib.utils.utils import open_file
if TYPE_CHECKING:
@@ -58,6 +58,12 @@ def _sanitize_resource_data(resource_details, resource_metadata) -> dict:
return [_make_serializable(v) for v in obj]
return obj
if is_plain_json(resource_metadata):
return {
"details": resource_details,
"metadata": resource_metadata,
}
try:
converted = _make_serializable(resource_metadata)
sanitized_metadata = json.loads(json.dumps(converted, default=str))
@@ -193,6 +199,15 @@ class OCSFComplianceOutput:
for finding in findings:
if finding.check_id in check_req_map:
try:
resource_data = _sanitize_resource_data(
finding.resource_details, finding.resource_metadata
)
except Exception as error:
logger.error(
f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
)
continue
for req in check_req_map[finding.check_id]:
cf = self._build_compliance_finding(
finding,
@@ -200,6 +215,7 @@ class OCSFComplianceOutput:
req,
compliance_name,
requirement_config_status.get(req.id, (True, "")),
resource_data=resource_data,
)
if cf:
self._data.append(cf)
@@ -255,8 +271,13 @@ class OCSFComplianceOutput:
requirement,
compliance_name: str,
config_status: tuple = (True, ""),
resource_data: dict | None = None,
) -> ComplianceFinding:
try:
if resource_data is None:
resource_data = _sanitize_resource_data(
finding.resource_details, finding.resource_metadata
)
effective_status, message = apply_config_status(
finding.status, finding.status_extended, config_status
)
@@ -348,10 +369,7 @@ class OCSFComplianceOutput:
if finding.provider == "kubernetes"
else None
),
data=_sanitize_resource_data(
finding.resource_details,
finding.resource_metadata,
),
data=resource_data,
)
],
severity_id=finding_severity.value,
+26 -2
View File
@@ -21,6 +21,8 @@ from prowler.lib.utils.utils import dict_to_lowercase, get_nested_attribute
from prowler.providers.common.provider import Provider
from prowler.providers.github.models import GithubAppIdentityInfo, GithubIdentityInfo
RESOURCE_METADATA_WARNING_SIZE = 10 * 1024 * 1024
class Finding(BaseModel):
"""
@@ -601,8 +603,28 @@ class Finding(BaseModel):
)
raise error
@staticmethod
def _load_resource_metadata(resource, metadata_cache: dict | None) -> dict:
"""Parse the stored metadata once per resource when a cache is given."""
if metadata_cache is None:
return json.loads(resource.metadata)
if resource.id in metadata_cache:
return metadata_cache[resource.id]
metadata_size = len(resource.metadata)
if metadata_size > RESOURCE_METADATA_WARNING_SIZE:
logger.warning(
f"Resource {resource.id} ({resource.uid}) has {metadata_size} characters of metadata"
)
metadata = json.loads(resource.metadata)
metadata_cache[resource.id] = metadata
return metadata
@classmethod
def transform_api_finding(cls, finding, provider) -> "Finding":
def transform_api_finding(
cls, finding, provider, metadata_cache: dict | None = None
) -> "Finding":
"""
Transform a FindingModel instance into an API-friendly Finding object.
@@ -616,6 +638,8 @@ class Finding(BaseModel):
Args:
finding (API Finding): An API Finding instance containing data from the database.
provider (Provider): the provider object.
metadata_cache (dict | None): parsed resource metadata keyed by resource id,
shared by the findings of a batch so each resource is parsed once.
Returns:
Finding: A new Finding instance populated with data from the provided model.
@@ -624,7 +648,7 @@ class Finding(BaseModel):
resource = finding.resources.first()
finding.resource_arn = resource.uid
finding.resource_name = resource.name
finding.resource = json.loads(resource.metadata)
finding.resource = cls._load_resource_metadata(resource, metadata_cache)
finding.resource_details = resource.details
finding.resource_id = resource.name if provider.type == "aws" else resource.uid
+7 -1
View File
@@ -27,7 +27,7 @@ from uuid6 import UUID
from prowler.lib.logger import logger
from prowler.lib.outputs.finding import Finding
from prowler.lib.outputs.output import Output
from prowler.lib.outputs.utils import unroll_dict_to_list
from prowler.lib.outputs.utils import is_plain_json, unroll_dict_to_list
class OCSF(Output):
@@ -279,6 +279,12 @@ class OCSF(Output):
return [_make_serializable(v) for v in obj]
return obj
if is_plain_json(resource_metadata):
return {
"details": resource_details,
"metadata": resource_metadata,
}
try:
converted = _make_serializable(resource_metadata)
sanitized_metadata = json.loads(json.dumps(converted, default=str))
+17
View File
@@ -220,3 +220,20 @@ def sanitize_csv_value(value):
if isfinite(number) and value == value.strip():
return value
return f"'{value}"
def is_plain_json(value) -> bool:
"""Return True when value is made only of JSON types with str keys."""
try:
if value is None or isinstance(value, (str, int, float, bool)):
return True
if isinstance(value, dict):
return all(
isinstance(key, str) and is_plain_json(item)
for key, item in value.items()
)
if isinstance(value, list):
return all(is_plain_json(item) for item in value)
return False
except RecursionError:
return False
@@ -126,6 +126,7 @@
"regions": {
"aws": [
"ap-southeast-1",
"us-east-1",
"us-west-1"
],
"aws-cn": [],
@@ -262,8 +263,12 @@
"aws": [
"ap-northeast-1",
"ap-southeast-2",
"ca-central-1",
"eu-central-1",
"eu-west-1",
"eu-west-2",
"us-east-1",
"us-east-2",
"us-west-2"
],
"aws-cn": [],
@@ -1517,8 +1522,10 @@
"ap-southeast-1",
"ap-southeast-2",
"eu-central-1",
"eu-north-1",
"eu-west-1",
"eu-west-2",
"eu-west-3",
"sa-east-1",
"us-east-1",
"us-west-2"
@@ -1924,7 +1931,10 @@
"us-west-1",
"us-west-2"
],
"aws-cn": [],
"aws-cn": [
"cn-north-1",
"cn-northwest-1"
],
"aws-eusc": [],
"aws-iso": [],
"aws-iso-b": [],
@@ -1938,6 +1948,7 @@
"aws": [
"ap-northeast-1",
"ap-south-1",
"ap-southeast-1",
"ap-southeast-2",
"ca-central-1",
"eu-central-1",
@@ -3107,7 +3118,8 @@
"aws-iso-e": [],
"aws-iso-f": [],
"aws-us-gov": [
"us-gov-east-1"
"us-gov-east-1",
"us-gov-west-1"
]
}
},
@@ -3303,7 +3315,8 @@
"aws-iso-e": [],
"aws-iso-f": [],
"aws-us-gov": [
"us-gov-east-1"
"us-gov-east-1",
"us-gov-west-1"
]
}
},
@@ -4262,7 +4275,10 @@
"aws-iso-b": [],
"aws-iso-e": [],
"aws-iso-f": [],
"aws-us-gov": []
"aws-us-gov": [
"us-gov-east-1",
"us-gov-west-1"
]
}
},
"deadline-cloud": {
@@ -5826,6 +5842,48 @@
]
}
},
"endusermessaging": {
"regions": {
"aws": [
"af-south-1",
"ap-east-2",
"ap-northeast-1",
"ap-northeast-2",
"ap-northeast-3",
"ap-south-1",
"ap-south-2",
"ap-southeast-1",
"ap-southeast-2",
"ap-southeast-3",
"ap-southeast-4",
"ap-southeast-6",
"ca-central-1",
"ca-west-1",
"eu-central-1",
"eu-central-2",
"eu-north-1",
"eu-south-1",
"eu-south-2",
"eu-west-1",
"eu-west-2",
"eu-west-3",
"il-central-1",
"mx-central-1",
"sa-east-1",
"us-east-1",
"us-east-2",
"us-west-1",
"us-west-2"
],
"aws-cn": [],
"aws-eusc": [],
"aws-iso": [],
"aws-iso-b": [],
"aws-iso-e": [],
"aws-iso-f": [],
"aws-us-gov": []
}
},
"entityresolution": {
"regions": {
"aws": [
@@ -5972,6 +6030,18 @@
]
}
},
"eventbridgev2": {
"regions": {
"aws": [],
"aws-cn": [],
"aws-eusc": [],
"aws-iso": [],
"aws-iso-b": [],
"aws-iso-e": [],
"aws-iso-f": [],
"aws-us-gov": []
}
},
"events": {
"regions": {
"aws": [
@@ -6745,18 +6815,23 @@
"ap-northeast-2",
"ap-northeast-3",
"ap-south-1",
"ap-south-2",
"ap-southeast-1",
"ap-southeast-2",
"ap-southeast-3",
"ap-southeast-5",
"ap-southeast-7",
"ca-central-1",
"eu-central-1",
"eu-north-1",
"eu-south-1",
"eu-south-2",
"eu-west-1",
"eu-west-2",
"eu-west-3",
"il-central-1",
"me-south-1",
"mx-central-1",
"sa-east-1",
"us-east-1",
"us-east-2",
@@ -7465,6 +7540,20 @@
]
}
},
"interconnect": {
"regions": {
"aws": [
"eu-south-1"
],
"aws-cn": [],
"aws-eusc": [],
"aws-iso": [],
"aws-iso-b": [],
"aws-iso-e": [],
"aws-iso-f": [],
"aws-us-gov": []
}
},
"internetmonitor": {
"regions": {
"aws": [
@@ -8058,7 +8147,9 @@
"cn-north-1",
"cn-northwest-1"
],
"aws-eusc": [],
"aws-eusc": [
"eusc-de-east-1"
],
"aws-iso": [],
"aws-iso-b": [],
"aws-iso-e": [],
@@ -9952,6 +10043,7 @@
"il-central-1",
"me-central-1",
"me-south-1",
"mx-central-1",
"sa-east-1",
"us-east-1",
"us-east-2",
@@ -10013,6 +10105,7 @@
"aws-iso-e": [],
"aws-iso-f": [],
"aws-us-gov": [
"us-gov-east-1",
"us-gov-west-1"
]
}
@@ -10175,6 +10268,35 @@
]
}
},
"network-security-manager": {
"regions": {
"aws": [
"ap-east-1",
"ap-northeast-1",
"ap-south-1",
"ap-southeast-1",
"ap-southeast-2",
"ap-southeast-5",
"ap-southeast-7",
"eu-central-1",
"eu-north-1",
"eu-south-2",
"eu-west-1",
"us-east-1",
"us-east-2",
"us-west-2"
],
"aws-cn": [],
"aws-eusc": [
"eusc-de-east-1"
],
"aws-iso": [],
"aws-iso-b": [],
"aws-iso-e": [],
"aws-iso-f": [],
"aws-us-gov": []
}
},
"networkflowmonitor": {
"regions": {
"aws": [
@@ -10280,7 +10402,6 @@
"eu-west-3",
"il-central-1",
"me-central-1",
"me-south-1",
"sa-east-1",
"us-east-1",
"us-east-2",
@@ -10497,18 +10618,22 @@
"ap-northeast-3",
"ap-south-1",
"ap-south-2",
"ap-southeast-1",
"ap-southeast-2",
"ap-southeast-4",
"ca-central-1",
"eu-central-1",
"eu-central-2",
"eu-north-1",
"eu-south-1",
"eu-south-2",
"eu-west-1",
"eu-west-2",
"eu-west-3",
"sa-east-1",
"us-east-1",
"us-east-2",
"us-west-1",
"us-west-2"
],
"aws-cn": [],
@@ -11015,10 +11140,12 @@
"ap-northeast-2",
"ap-northeast-3",
"ap-south-1",
"ap-south-2",
"ap-southeast-1",
"ap-southeast-2",
"ap-southeast-3",
"ap-southeast-4",
"ap-southeast-5",
"ap-southeast-6",
"eu-central-1",
"eu-north-1",
@@ -13473,6 +13600,10 @@
"securityagent": {
"regions": {
"aws": [
"ap-northeast-2",
"ca-central-1",
"eu-north-1",
"eu-west-2",
"us-east-1",
"us-west-2"
],
@@ -14913,7 +15044,39 @@
},
"supportauthz": {
"regions": {
"aws": [],
"aws": [
"ap-east-1",
"ap-east-2",
"ap-northeast-1",
"ap-northeast-2",
"ap-northeast-3",
"ap-south-1",
"ap-south-2",
"ap-southeast-1",
"ap-southeast-2",
"ap-southeast-3",
"ap-southeast-4",
"ap-southeast-5",
"ap-southeast-6",
"ap-southeast-7",
"ca-central-1",
"ca-west-1",
"eu-central-1",
"eu-central-2",
"eu-north-1",
"eu-south-1",
"eu-south-2",
"eu-west-1",
"eu-west-2",
"eu-west-3",
"il-central-1",
"mx-central-1",
"sa-east-1",
"us-east-1",
"us-east-2",
"us-west-1",
"us-west-2"
],
"aws-cn": [],
"aws-eusc": [],
"aws-iso": [],
@@ -15089,12 +15252,14 @@
"textract": {
"regions": {
"aws": [
"ap-northeast-1",
"ap-northeast-2",
"ap-south-1",
"ap-southeast-1",
"ap-southeast-2",
"ca-central-1",
"eu-central-1",
"eu-central-2",
"eu-south-2",
"eu-west-1",
"eu-west-2",
@@ -15146,10 +15311,8 @@
"aws": [
"ap-northeast-1",
"ap-south-1",
"ap-southeast-1",
"ap-southeast-2",
"eu-central-1",
"eu-north-1",
"eu-west-1",
"us-east-1",
"us-east-2",
@@ -16047,6 +16210,22 @@
]
}
},
"wellarchitectedagent": {
"regions": {
"aws": [
"us-east-1",
"us-east-2",
"us-west-2"
],
"aws-cn": [],
"aws-eusc": [],
"aws-iso": [],
"aws-iso-b": [],
"aws-iso-e": [],
"aws-iso-f": [],
"aws-us-gov": []
}
},
"wellarchitectedtool": {
"regions": {
"aws": [
@@ -1,39 +0,0 @@
{
"Provider": "github",
"CheckID": "repository_secret_scanning_push_protection_enabled",
"CheckTitle": "Repository has secret scanning push protection enabled to block commits containing secrets",
"CheckType": [],
"ServiceName": "repository",
"SubServiceName": "",
"ResourceIdTemplate": "",
"Severity": "high",
"ResourceType": "NotDefined",
"ResourceGroup": "devops",
"Description": "**GitHub repository** configuration for **secret scanning push protection**-which rejects pushes containing supported secret patterns (API keys, tokens, private keys) before they reach the Git history-is evaluated to determine if the block is active.",
"Risk": "Without **push protection**, secret scanning only alerts after a credential is already in the Git history and visible to anyone with read access, forks or clones. During that exposure window attackers can gain **unauthorized access** to cloud and third-party services or compromise the **supply chain** through leaked deployment tokens, degrading **confidentiality** and **integrity**.",
"RelatedUrl": "",
"AdditionalURLs": [
"https://docs.github.com/en/code-security/secret-scanning/introduction/about-push-protection",
"https://docs.github.com/en/code-security/secret-scanning/enabling-secret-scanning-features/enabling-push-protection-for-your-repository"
],
"Remediation": {
"Code": {
"CLI": "gh api -X PATCH repos/<OWNER>/<REPO> -H \"Accept: application/vnd.github+json\" -H \"Content-Type: application/json\" -d '{\"security_and_analysis\":{\"secret_scanning\":{\"status\":\"enabled\"},\"secret_scanning_push_protection\":{\"status\":\"enabled\"}}}'",
"NativeIaC": "",
"Other": "1. In GitHub, open the target repository and go to Settings\n2. In the left sidebar, click Code security and analysis\n3. Under Secret scanning, make sure Secret scanning is Enabled\n4. Under Push protection, click Enable\n5. Confirm if prompted",
"Terraform": "```hcl\nresource \"github_repository\" \"<example_resource_name>\" {\n name = \"<example_resource_name>\"\n\n security_and_analysis {\n secret_scanning {\n status = \"enabled\"\n }\n secret_scanning_push_protection { # Critical: block pushes containing secrets\n status = \"enabled\"\n }\n }\n}\n```"
},
"Recommendation": {
"Text": "Enable **secret scanning push protection** on every repository, or enable it organization-wide so new repositories inherit it.\n- Keep **secret scanning** enabled; push protection depends on it\n- Enable **delegated bypass** so exceptions are reviewed instead of silently allowed\n- Store secrets in a dedicated **secrets manager**, never in code\n- Rotate any credential that was pushed before protection was active",
"Url": "https://hub.prowler.com/check/repository_secret_scanning_push_protection_enabled"
}
},
"Categories": [
"secrets"
],
"DependsOn": [],
"RelatedTo": [
"repository_secret_scanning_enabled"
],
"Notes": "Push protection requires secret scanning to be enabled on the repository. Repositories whose security settings could not be read are not reported."
}
@@ -1,37 +0,0 @@
from typing import List
from prowler.lib.check.models import Check, CheckReportGithub
from prowler.providers.github.services.repository.repository_client import (
repository_client,
)
class repository_secret_scanning_push_protection_enabled(Check):
"""Check if secret scanning push protection is enabled for the repository
This class verifies whether each repository blocks pushes that contain
supported secrets before they reach the Git history.
"""
def execute(self) -> List[CheckReportGithub]:
"""Execute the Github Repository Secret Scanning Push Protection check
Iterates over all repositories and checks if push protection is enabled.
Returns:
List[CheckReportGithub]: A list of reports for each repository
"""
findings = []
for repo in repository_client.repositories.values():
if repo.secret_scanning_push_protection_enabled is not None:
report = CheckReportGithub(metadata=self.metadata(), resource=repo)
if repo.secret_scanning_push_protection_enabled:
report.status = "PASS"
report.status_extended = f"Repository {repo.name} has secret scanning push protection enabled to block commits containing secrets."
else:
report.status = "FAIL"
report.status_extended = f"Repository {repo.name} does not have secret scanning push protection enabled, so commits containing secrets are accepted."
findings.append(report)
return findings
@@ -762,7 +762,6 @@ class Repository(GithubService):
approval_count_source = "ruleset_not_active"
secret_scanning_enabled = False
secret_scanning_push_protection_enabled = False
dependabot_alerts_enabled = False
try:
if (
@@ -772,14 +771,6 @@ class Repository(GithubService):
secret_scanning_enabled = (
repo.security_and_analysis.secret_scanning.status == "enabled"
)
if (
repo.security_and_analysis
and repo.security_and_analysis.secret_scanning_push_protection
):
secret_scanning_push_protection_enabled = (
repo.security_and_analysis.secret_scanning_push_protection.status
== "enabled"
)
try:
# Use get_dependabot_alerts to check if Dependabot alerts are enabled
repo.get_dependabot_alerts().totalCount
@@ -803,7 +794,6 @@ class Repository(GithubService):
f"{repo.full_name}: {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
)
secret_scanning_enabled = None
secret_scanning_push_protection_enabled = None
dependabot_alerts_enabled = None
repos[repo.id] = Repo(
id=repo.id,
@@ -850,7 +840,6 @@ class Repository(GithubService):
securitymd=securitymd_exists,
codeowners_exists=codeowners_exists,
secret_scanning_enabled=secret_scanning_enabled,
secret_scanning_push_protection_enabled=secret_scanning_push_protection_enabled,
dependabot_alerts_enabled=dependabot_alerts_enabled,
delete_branch_on_merge=delete_branch_on_merge,
)
@@ -1017,6 +1006,5 @@ class Repo(BaseModel):
securitymd: Optional[bool]
codeowners_exists: Optional[bool]
secret_scanning_enabled: Optional[bool]
secret_scanning_push_protection_enabled: Optional[bool] = None
dependabot_alerts_enabled: Optional[bool]
delete_branch_on_merge: Optional[bool]
@@ -54,6 +54,10 @@ class OpenStackBaseException(ProwlerException):
"message": "Ambiguous region configuration in clouds.yaml",
"remediation": "Use either 'region_name' or 'regions' in your cloud configuration, not both.",
},
(17012, "OpenStackAuthUrlNotAllowedError"): {
"message": "OpenStack auth_url points at a destination Prowler must not reach",
"remediation": "Use an http or https auth_url that resolves to a public address, or allow the private network through PROWLER_ALLOWED_PRIVATE_NETWORKS.",
},
}
def __init__(self, code, file=None, original_exception=None, message=None):
@@ -212,3 +216,15 @@ class OpenStackAmbiguousRegionError(OpenStackBaseException):
original_exception=original_exception,
message=message,
)
class OpenStackAuthUrlNotAllowedError(OpenStackBaseException):
"""Exception for an auth_url rejected by the outbound URL guard"""
def __init__(self, file=None, original_exception=None, message=None):
super().__init__(
code=17012,
file=file,
original_exception=original_exception,
message=message,
)
@@ -14,12 +14,14 @@ from prowler.config.config import (
load_and_validate_config_file,
)
from prowler.lib.logger import logger
from prowler.lib.network.ssrf import OutboundURLNotAllowedError, validate_outbound_url
from prowler.lib.utils.utils import print_boxes
from prowler.providers.common.models import Audit_Metadata, Connection
from prowler.providers.common.provider import Provider
from prowler.providers.openstack.exceptions.exceptions import (
OpenStackAmbiguousRegionError,
OpenStackAuthenticationError,
OpenStackAuthUrlNotAllowedError,
OpenStackCloudNotFoundError,
OpenStackConfigFileNotFoundError,
OpenStackCredentialsError,
@@ -454,6 +456,15 @@ class OpenstackProvider(Provider):
message=f"Failed to load clouds.yaml configuration: {error}",
)
@staticmethod
def _validate_auth_url(auth_url: str) -> None:
"""Reject an auth_url the worker must not reach; the detail stays in the log."""
try:
validate_outbound_url(auth_url, allowed_schemes=("http", "https"))
except OutboundURLNotAllowedError as error:
logger.warning(f"OpenStack auth_url rejected: {error}")
raise OpenStackAuthUrlNotAllowedError()
@staticmethod
def _create_connection(
session: OpenStackSession,
@@ -471,6 +482,7 @@ class OpenstackProvider(Provider):
region: Optional region override — when given, the connection is
scoped to this specific region instead of the session default.
"""
OpenstackProvider._validate_auth_url(session.auth_url)
try:
# Don't load from clouds.yaml or environment variables, we configure this in setup_session()
conn = connect(
@@ -636,6 +648,7 @@ class OpenstackProvider(Provider):
except (
OpenStackCredentialsError,
OpenStackAuthenticationError,
OpenStackAuthUrlNotAllowedError,
OpenStackSessionError,
OpenStackConfigFileNotFoundError,
OpenStackCloudNotFoundError,
+4 -4
View File
@@ -5,7 +5,7 @@ requires = ["hatchling==1.32.0"]
[dependency-groups]
dev = [
"bandit==1.8.3",
"black==26.3.1",
"black==26.10.1",
"coverage==7.6.12",
"docker==7.1.0",
"filelock==3.20.3",
@@ -145,7 +145,7 @@ maintainers = [{name = "Prowler Engineering", email = "engineering@prowler.com"}
name = "prowler"
readme = "README.md"
requires-python = ">=3.10,<3.14"
version = "5.46.0"
version = "5.46.1"
[project.scripts]
prowler = "prowler.__main__:prowler"
@@ -226,7 +226,7 @@ constraint-dependencies = [
"azure-core==1.41.0",
"azure-mgmt-core==1.6.0",
"bandit==1.8.3",
"black==26.3.1",
"black==26.10.1",
"blinker==1.9.0",
"certifi==2026.10.19",
"cffi==2.0.0",
@@ -258,7 +258,7 @@ constraint-dependencies = [
"google-auth==2.52.0",
"googleapis-common-protos==1.75.0",
"graphemeu==0.7.2",
"graphql-core==3.2.8",
"graphql-core==3.2.12",
"h11==0.16.0",
"hpack==4.1.0",
"httpcore==1.0.9",
@@ -1,6 +1,7 @@
import json
from datetime import datetime, timezone
from types import SimpleNamespace
from unittest.mock import patch
from py_ocsf_models.events.base_event import StatusID as EventStatusID
from py_ocsf_models.events.findings.compliance_finding import ComplianceFinding
@@ -301,6 +302,56 @@ class TestOCSFComplianceOutput:
assert ComplianceStatusID.Pass in statuses
assert ComplianceStatusID.Fail in statuses
def test_resource_data_sanitized_once_per_finding(self):
fw = _make_framework(
[
_simple_requirement("REQ-1", ["check_a"]),
_simple_requirement("REQ-2", ["check_a"]),
_simple_requirement("REQ-3", ["check_a"]),
]
)
finding = _make_finding("check_a")
finding.resource_metadata = {"big": ["payload"]}
with patch(
"prowler.lib.outputs.compliance.universal.ocsf_compliance._sanitize_resource_data",
wraps=_sanitize_resource_data,
) as sanitize:
output = OCSFComplianceOutput(
findings=[finding], framework=fw, provider="aws"
)
assert len(output.data) == 3
sanitize.assert_called_once_with(
finding.resource_details, finding.resource_metadata
)
assert all(
cf.resources[0].data["metadata"] is finding.resource_metadata
for cf in output.data
)
def test_resource_data_error_skips_only_that_finding(self):
fw = _make_framework([_simple_requirement("REQ-1", ["check_a"])])
broken = _make_finding("check_a")
healthy = _make_finding("check_a")
def sanitize(details, metadata):
if metadata is broken.resource_metadata:
raise RuntimeError("boom")
return _sanitize_resource_data(details, metadata)
broken.resource_metadata = {"broken": True}
with patch(
"prowler.lib.outputs.compliance.universal.ocsf_compliance._sanitize_resource_data",
side_effect=sanitize,
):
output = OCSFComplianceOutput(
findings=[broken, healthy], framework=fw, provider="aws"
)
assert len(output.data) == 1
assert output.data[0].resources[0].data["metadata"] == {}
def test_requirement_attributes_in_unmapped(self):
req = UniversalComplianceRequirement(
id="REQ-1",
@@ -509,6 +560,11 @@ class TestSanitizeResourceData:
result = _sanitize_resource_data("details", {"a": 1, "b": "two"})
assert result == {"details": "details", "metadata": {"a": 1, "b": "two"}}
def test_plain_json_is_not_copied(self):
metadata = {"a": [1, {"b": "two"}]}
result = _sanitize_resource_data("details", metadata)
assert result["metadata"] is metadata
def test_none_metadata(self):
result = _sanitize_resource_data("details", None)
assert result == {"details": "details", "metadata": None}
+134
View File
@@ -93,7 +93,9 @@ class DummyResource:
details=None,
metadata=None,
partition=None,
id=None,
):
self.id = id or uid
self.uid = uid
self.name = name
self.resource_arn = resource_arn
@@ -138,6 +140,51 @@ class DummyAPIFinding:
"""
def _aws_api_finding_fixture():
provider = DummyProvider(uid="account123")
provider.type = "aws"
provider.organizations_metadata = SimpleNamespace(
account_name="test-account",
account_email="test@example.com",
organization_arn="arn:aws:organizations::123456789012:organization/o-abcdef123456",
organization_id="o-abcdef123456",
account_tags={},
)
provider.identity = SimpleNamespace(
account="123456789012", partition="aws", profile="default"
)
api_finding = DummyAPIFinding()
api_finding.inserted_at = 1234567890
api_finding.scan = DummyScan(provider=provider)
api_finding.uid = "finding-uid-1"
api_finding.status = "FAIL"
api_finding.status_extended = "extended"
api_finding.check_metadata = {
"provider": "aws",
"checkid": "service_check_001",
"checktitle": "Test Check",
"checktype": [],
"servicename": "service",
"subservicename": "",
"severity": "high",
"resourcetype": "TestResource",
"description": "A test check",
"risk": "High risk",
"relatedurl": "",
"remediation": {
"recommendation": {"text": "Fix it", "url": ""},
"code": {"nativeiac": "", "terraform": "", "cli": "", "other": ""},
},
"resourceidtemplate": "",
"categories": [],
"dependson": [],
"relatedto": [],
"notes": "",
}
api_finding.muted = False
return provider, api_finding
class TestFinding:
def test_generate_output_aws(self):
# Mock provider
@@ -1115,6 +1162,93 @@ class TestFinding:
"MITRE-ATTACK": ["T1098"],
}
@patch(
"prowler.lib.outputs.finding.get_check_compliance",
new=mock_get_check_compliance,
)
def test_transform_api_finding_shares_parsed_metadata_within_cache(self):
provider, api_finding = _aws_api_finding_fixture()
resource = DummyResource(
uid="res-uid-1",
name="ResourceName1",
resource_arn="arn",
region="us-east-1",
tags=[],
metadata='{"nested": {"items": [1, 2, 3]}}',
)
api_finding.resources = DummyResources(resource)
_, other_api_finding = _aws_api_finding_fixture()
other_api_finding.resources = DummyResources(resource)
metadata_cache = {}
first = Finding.transform_api_finding(
api_finding, provider, metadata_cache=metadata_cache
)
second = Finding.transform_api_finding(
other_api_finding, provider, metadata_cache=metadata_cache
)
assert first.resource_metadata == {"nested": {"items": [1, 2, 3]}}
assert second.resource_metadata is first.resource_metadata
assert metadata_cache == {resource.id: first.resource_metadata}
@patch(
"prowler.lib.outputs.finding.get_check_compliance",
new=mock_get_check_compliance,
)
def test_transform_api_finding_without_cache_parses_each_time(self):
provider, api_finding = _aws_api_finding_fixture()
resource = DummyResource(
uid="res-uid-1",
name="ResourceName1",
resource_arn="arn",
region="us-east-1",
tags=[],
metadata='{"a": 1}',
)
api_finding.resources = DummyResources(resource)
_, other_api_finding = _aws_api_finding_fixture()
other_api_finding.resources = DummyResources(resource)
first = Finding.transform_api_finding(api_finding, provider)
second = Finding.transform_api_finding(other_api_finding, provider)
assert first.resource_metadata == second.resource_metadata == {"a": 1}
assert first.resource_metadata is not second.resource_metadata
@patch(
"prowler.lib.outputs.finding.get_check_compliance",
new=mock_get_check_compliance,
)
@patch("prowler.lib.outputs.finding.RESOURCE_METADATA_WARNING_SIZE", 16)
@patch("prowler.lib.outputs.finding.logger")
def test_transform_api_finding_warns_once_on_large_metadata(self, logger):
provider, api_finding = _aws_api_finding_fixture()
resource = DummyResource(
uid="res-uid-1",
name="ResourceName1",
resource_arn="arn",
region="us-east-1",
tags=[],
metadata='{"payload": "' + "x" * 32 + '"}',
)
api_finding.resources = DummyResources(resource)
_, other_api_finding = _aws_api_finding_fixture()
other_api_finding.resources = DummyResources(resource)
metadata_cache = {}
Finding.transform_api_finding(
api_finding, provider, metadata_cache=metadata_cache
)
Finding.transform_api_finding(
other_api_finding, provider, metadata_cache=metadata_cache
)
logger.warning.assert_called_once()
message = logger.warning.call_args.args[0]
assert "res-uid-1" in message
assert str(len(resource.metadata)) in message
@patch(
"prowler.lib.outputs.finding.get_check_compliance",
new=mock_get_check_compliance,
+5
View File
@@ -771,6 +771,11 @@ class TestOCSF:
"metadata": {"key": "value"},
}
def test_sanitize_resource_data_plain_json_is_not_copied(self):
metadata = {"key": ["value", {"nested": 1}]}
result = OCSF._sanitize_resource_data("details", metadata)
assert result["metadata"] is metadata
def test_sanitize_resource_data_empty_dict(self):
result = OCSF._sanitize_resource_data("details", {})
assert result == {
+20
View File
@@ -1,3 +1,4 @@
from datetime import datetime
from unittest import mock
import pytest
@@ -11,6 +12,7 @@ from prowler.lib.outputs.outputs import (
set_report_color,
)
from prowler.lib.outputs.utils import (
is_plain_json,
parse_html_string,
parse_json_tags,
sanitize_csv_value,
@@ -1362,3 +1364,21 @@ class TestSanitizeCSVValue:
@pytest.mark.parametrize("value", [" ", "\n", " text"])
def test_whitespace_without_initiator_is_untouched(self, value):
assert sanitize_csv_value(value) == value
def test_is_plain_json_accepts_json_types(self):
assert is_plain_json({"a": [1, 2.5, "x", True, None, {"b": {}}]})
assert is_plain_json(None)
assert is_plain_json([])
def test_is_plain_json_rejects_tuples(self):
assert not is_plain_json({"t": (1, 2)})
def test_is_plain_json_rejects_non_str_keys(self):
assert not is_plain_json({1: "a"})
def test_is_plain_json_rejects_objects(self):
class Opaque:
pass
assert not is_plain_json({"o": Opaque()})
assert not is_plain_json({"d": datetime(2024, 1, 1)})
@@ -1,149 +0,0 @@
from datetime import datetime, timezone
from unittest import mock
from prowler.providers.github.services.repository.repository_service import Branch, Repo
from tests.providers.github.github_fixtures import set_mocked_github_provider
class Test_repository_secret_scanning_push_protection_enabled:
def test_no_repositories(self):
repository_client = mock.MagicMock
repository_client.repositories = {}
with (
mock.patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=set_mocked_github_provider(),
),
mock.patch(
"prowler.providers.github.services.repository.repository_secret_scanning_push_protection_enabled.repository_secret_scanning_push_protection_enabled.repository_client",
new=repository_client,
),
):
from prowler.providers.github.services.repository.repository_secret_scanning_push_protection_enabled.repository_secret_scanning_push_protection_enabled import (
repository_secret_scanning_push_protection_enabled,
)
check = repository_secret_scanning_push_protection_enabled()
result = check.execute()
assert len(result) == 0
def test_one_repository_no_push_protection(self):
repository_client = mock.MagicMock
repo_name = "repo1"
repository_client.repositories = {
1: Repo(
id=1,
name=repo_name,
owner="account-name",
full_name="account-name/repo1",
default_branch=Branch(
name="main",
protected=False,
default_branch=True,
require_pull_request=False,
approval_count=0,
required_linear_history=False,
allow_force_pushes=True,
branch_deletion=True,
status_checks=False,
enforce_admins=False,
require_code_owner_reviews=False,
require_signed_commits=False,
conversation_resolution=False,
),
private=False,
securitymd=True,
codeowners_exists=False,
secret_scanning_enabled=True,
secret_scanning_push_protection_enabled=False,
archived=False,
pushed_at=datetime.now(timezone.utc),
delete_branch_on_merge=False,
),
}
with (
mock.patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=set_mocked_github_provider(),
),
mock.patch(
"prowler.providers.github.services.repository.repository_secret_scanning_push_protection_enabled.repository_secret_scanning_push_protection_enabled.repository_client",
new=repository_client,
),
):
from prowler.providers.github.services.repository.repository_secret_scanning_push_protection_enabled.repository_secret_scanning_push_protection_enabled import (
repository_secret_scanning_push_protection_enabled,
)
check = repository_secret_scanning_push_protection_enabled()
result = check.execute()
assert len(result) == 1
assert result[0].resource_id == 1
assert result[0].resource_name == repo_name
assert result[0].status == "FAIL"
assert (
result[0].status_extended
== f"Repository {repo_name} does not have secret scanning push protection enabled, so commits containing secrets are accepted."
)
def test_one_repository_with_push_protection(self):
repository_client = mock.MagicMock
repo_name = "repo2"
repository_client.repositories = {
2: Repo(
id=2,
name=repo_name,
owner="account-name",
full_name="account-name/repo2",
default_branch=Branch(
name="main",
protected=False,
default_branch=True,
require_pull_request=False,
approval_count=0,
required_linear_history=False,
allow_force_pushes=True,
branch_deletion=True,
status_checks=False,
enforce_admins=False,
require_code_owner_reviews=False,
require_signed_commits=False,
conversation_resolution=False,
),
private=False,
securitymd=True,
codeowners_exists=False,
secret_scanning_enabled=True,
secret_scanning_push_protection_enabled=True,
archived=False,
pushed_at=datetime.now(timezone.utc),
delete_branch_on_merge=False,
),
}
with (
mock.patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=set_mocked_github_provider(),
),
mock.patch(
"prowler.providers.github.services.repository.repository_secret_scanning_push_protection_enabled.repository_secret_scanning_push_protection_enabled.repository_client",
new=repository_client,
),
):
from prowler.providers.github.services.repository.repository_secret_scanning_push_protection_enabled.repository_secret_scanning_push_protection_enabled import (
repository_secret_scanning_push_protection_enabled,
)
check = repository_secret_scanning_push_protection_enabled()
result = check.execute()
assert len(result) == 1
assert result[0].resource_id == 2
assert result[0].resource_name == repo_name
assert result[0].status == "PASS"
assert (
result[0].status_extended
== f"Repository {repo_name} has secret scanning push protection enabled to block commits containing secrets."
)
@@ -567,41 +567,6 @@ class Test_Repository_BranchProtectionRulesets:
"rules": rules,
}
def test_process_repository_secret_scanning_push_protection_enabled(self):
repo = self._build_repo(branch_protected=False)
repo.security_and_analysis = MagicMock(
secret_scanning=MagicMock(status="enabled"),
secret_scanning_push_protection=MagicMock(status="enabled"),
)
repos = {}
self.repository_service._process_repository(repo, repos)
assert repos[1].secret_scanning_enabled is True
assert repos[1].secret_scanning_push_protection_enabled is True
def test_process_repository_secret_scanning_push_protection_disabled(self):
repo = self._build_repo(branch_protected=False)
repo.security_and_analysis = MagicMock(
secret_scanning=MagicMock(status="enabled"),
secret_scanning_push_protection=MagicMock(status="disabled"),
)
repos = {}
self.repository_service._process_repository(repo, repos)
assert repos[1].secret_scanning_enabled is True
assert repos[1].secret_scanning_push_protection_enabled is False
def test_process_repository_without_security_settings(self):
repo = self._build_repo(branch_protected=False)
repos = {}
self.repository_service._process_repository(repo, repos)
assert repos[1].secret_scanning_enabled is False
assert repos[1].secret_scanning_push_protection_enabled is False
def test_process_repository_uses_classic_branch_protection(self):
repo = self._build_repo(branch_protected=True, dismiss_stale_reviews=True)
repos = {}
@@ -16,6 +16,7 @@ from prowler.providers.common.models import Connection
from prowler.providers.openstack.exceptions.exceptions import (
OpenStackAmbiguousRegionError,
OpenStackAuthenticationError,
OpenStackAuthUrlNotAllowedError,
OpenStackCloudNotFoundError,
OpenStackConfigFileNotFoundError,
OpenStackCredentialsError,
@@ -26,6 +27,23 @@ from prowler.providers.openstack.exceptions.exceptions import (
from prowler.providers.openstack.models import OpenStackIdentityInfo, OpenStackSession
from prowler.providers.openstack.openstack_provider import OpenstackProvider
PUBLIC_IP = "8.8.8.8"
def _fake_getaddrinfo(host_to_ip: dict):
def _getaddrinfo(host, *_args, **_kwargs):
return [(None, None, None, None, (host_to_ip.get(host, PUBLIC_IP), 0))]
return _getaddrinfo
@pytest.fixture(autouse=True)
def _dns_resolves_public(monkeypatch):
"""Keep the outbound URL guard offline: every hostname resolves to a public IP."""
monkeypatch.setattr(
"prowler.lib.network.ssrf.socket.getaddrinfo", _fake_getaddrinfo({})
)
class TestOpenstackProvider:
"""Test suite for OpenStack Provider initialization."""
@@ -1620,3 +1638,156 @@ clouds:
assert result.is_connected is False
assert isinstance(result.error, OpenStackInvalidProviderIdError)
class TestOpenstackProviderAuthUrlGuard:
"""Test suite for the outbound URL guard applied to auth_url in test_connection."""
CLOUDS_YAML = """
clouds:
test-cloud:
auth:
auth_url: {auth_url}
username: test-user
password: test-password
project_id: test-project-id
region_name: RegionOne
"""
@staticmethod
def _test_connection(**kwargs) -> tuple[Connection, MagicMock]:
with patch(
"prowler.providers.openstack.openstack_provider.connect"
) as mock_connect:
mock_connect.return_value = MagicMock()
result = OpenstackProvider.test_connection(
username="test-user",
password="test-password",
project_id="test-project-id",
region_name="RegionOne",
raise_on_exception=False,
**kwargs,
)
return result, mock_connect
@pytest.mark.parametrize(
"auth_url",
[
"https://127.0.0.1:5000/v3",
"https://[::1]:5000/v3",
"https://10.0.0.5:5000/v3",
"https://172.16.0.5:5000/v3",
"https://192.168.1.5:5000/v3",
"http://169.254.169.254/latest/meta-data",
"ftp://keystone.example.com:5000/v3",
],
)
def test_test_connection_rejects_non_public_auth_url(self, auth_url):
result, mock_connect = self._test_connection(auth_url=auth_url)
assert result.is_connected is False
assert isinstance(result.error, OpenStackAuthUrlNotAllowedError)
mock_connect.assert_not_called()
def test_test_connection_rejects_hostname_resolving_to_private_ip(
self, monkeypatch
):
monkeypatch.setattr(
"prowler.lib.network.ssrf.socket.getaddrinfo",
_fake_getaddrinfo({"keystone.example.com": "10.0.0.5"}),
)
result, mock_connect = self._test_connection(
auth_url="https://keystone.example.com:5000/v3"
)
assert result.is_connected is False
assert isinstance(result.error, OpenStackAuthUrlNotAllowedError)
mock_connect.assert_not_called()
def test_test_connection_rejection_does_not_echo_the_target(self):
result, _ = self._test_connection(
auth_url="https://placeholder-user:placeholder-token@10.0.0.5:5000/v3" # trufflehog:ignore
)
assert isinstance(result.error, OpenStackAuthUrlNotAllowedError)
assert result.error.original_exception is None
for fragment in ("10.0.0.5", "placeholder-token", "non-public", "resolves"):
assert fragment not in str(result.error)
def test_test_connection_rejection_raises_when_requested(self):
with patch("prowler.providers.openstack.openstack_provider.connect"):
with pytest.raises(OpenStackAuthUrlNotAllowedError):
OpenstackProvider.test_connection(
auth_url="https://127.0.0.1:5000/v3",
username="test-user",
password="test-password",
project_id="test-project-id",
region_name="RegionOne",
raise_on_exception=True,
)
def test_scan_initialisation_rejects_a_non_public_auth_url(self):
with patch(
"prowler.providers.openstack.openstack_provider.connect"
) as mock_connect:
with pytest.raises(OpenStackAuthUrlNotAllowedError):
OpenstackProvider(
auth_url="https://169.254.169.254:5000/v3",
username="test-user",
password="test-password",
project_id="test-project-id",
region_name="RegionOne",
)
mock_connect.assert_not_called()
def test_scan_initialisation_rejects_shared_address_space(self):
with patch(
"prowler.providers.openstack.openstack_provider.connect"
) as mock_connect:
with pytest.raises(OpenStackAuthUrlNotAllowedError):
OpenstackProvider(
auth_url="https://100.100.100.200:5000/v3",
username="test-user",
password="test-password",
project_id="test-project-id",
region_name="RegionOne",
)
mock_connect.assert_not_called()
def test_test_connection_allows_public_auth_url(self):
result, mock_connect = self._test_connection(
auth_url="https://openstack.example.com:5000/v3"
)
assert result.is_connected is True
assert result.error is None
mock_connect.assert_called_once()
def test_test_connection_rejects_private_auth_url_from_clouds_yaml_content(
self,
):
result, mock_connect = self._test_connection(
clouds_yaml_content=self.CLOUDS_YAML.format(
auth_url="https://127.0.0.1:5000/v3"
),
clouds_yaml_cloud="test-cloud",
)
assert result.is_connected is False
assert isinstance(result.error, OpenStackAuthUrlNotAllowedError)
mock_connect.assert_not_called()
def test_test_connection_allows_public_auth_url_from_clouds_yaml_content(self):
result, mock_connect = self._test_connection(
clouds_yaml_content=self.CLOUDS_YAML.format(
auth_url="https://openstack.example.com:5000/v3"
),
clouds_yaml_cloud="test-cloud",
)
assert result.is_connected is True
assert result.error is None
mock_connect.assert_called_once()
+20
View File
@@ -4,6 +4,26 @@ All notable changes to the **Prowler UI** are documented in this file.
<!-- changelog: release notes start -->
## [1.46.0] (Prowler v5.46.0)
### 🚀 Added
- Issues page and overview cards for Cloud deployments, with verdict filters, accessible issue details, and scoped finding evidence [(#13068)](https://github.com/prowler-cloud/prowler/pull/13068)
- Outlined error badge variant with a visible border on error-colored surfaces [(#13111)](https://github.com/prowler-cloud/prowler/pull/13111)
### 🔄 Changed
- "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 [(#13104)](https://github.com/prowler-cloud/prowler/pull/13104)
- SAML SSO integration card on the profile page is shown to roles with the Manage Account permission, matching the API requirement [(#13121)](https://github.com/prowler-cloud/prowler/pull/13121)
- Default `php_latest_version` in the scan configuration template raised from `8.2` to `8.5` [(#13128)](https://github.com/prowler-cloud/prowler/pull/13128)
### 🔐 Security
- 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 [(#13126)](https://github.com/prowler-cloud/prowler/pull/13126)
- Next.js from 16.3.8 to 16.4.1, `@sentry/nextjs` from 10.65.0 to 10.75.2 and the transitive DOMPurify pin to 3.4.17, closing four critical advisories: two unauthenticated remote code executions in Next.js (Image Optimization with SVG files, GHSA-2464-fhr4-w9xw; `use cache` functions returning typed arrays, GHSA-673q-mjm6-r5xr), the Sentry middleware bypass through the tunnel route (GHSA-mchm-gjx6-7h55) and the DOMPurify `SAFE_FOR_TEMPLATES` bypass (GHSA-cg2g-xh64-v4q7) [(#13135)](https://github.com/prowler-cloud/prowler/pull/13135)
---
## [1.45.0] (Prowler v5.45.0)
### 🚀 Added
+28 -16
View File
@@ -1,20 +1,7 @@
import { FindingProps } from "@/types/components";
import { IssueFinding } from "@/types/issues";
import { JsonApiDocument, JsonApiResource } from "@/types/jsonapi";
import { ResourceProps } from "@/types/resources";
import { FINDING_TRIAGE_STATUS } from "@/types/findings-triage";
import type { IssueFinding, IssueTriageNote } from "@/types/issues";
interface IssueFindingResource extends Pick<FindingProps, "id"> {
attributes: Pick<
FindingProps["attributes"],
"check_id" | "check_metadata" | "severity"
>;
relationships: Pick<FindingProps["relationships"], "resources">;
}
interface IssueFindingsDocument
extends JsonApiDocument<IssueFindingResource[]> {
included?: JsonApiResource<Pick<ResourceProps["attributes"], "uid">>[];
}
import type { IssueFindingsDocument, IssueTriageNotesDocument } from "./types";
export const toIssueFindings = (
payload: IssueFindingsDocument | undefined,
@@ -33,14 +20,39 @@ export const toIssueFindings = (
return [
{
id: finding.id,
uid: finding.attributes.uid,
checkId: finding.attributes.check_id,
checkTitle:
finding.attributes.check_metadata?.checktitle ||
finding.attributes.check_id,
severity: finding.attributes.severity,
// A finding nobody has triaged reads as open, as on the Findings page.
triageStatus:
finding.attributes.triage_status ?? FINDING_TRIAGE_STATUS.OPEN,
resourceUid,
},
];
}),
);
};
export const toIssueTriageNotes = (
payload: IssueTriageNotesDocument | undefined,
): IssueTriageNote[] | undefined => {
if (!Array.isArray(payload?.data)) return undefined;
const authors = new Map(
(payload.included ?? [])
.filter((resource) => resource.type === "users")
.map((user) => [user.id, user.attributes.name || user.attributes.email]),
);
return payload.data.map((note) => {
const authorId = note.relationships?.created_by?.data?.id;
return {
id: note.id,
body: note.attributes.body,
insertedAt: note.attributes.inserted_at,
author: authorId ? authors.get(authorId) : undefined,
};
});
};
+142 -7
View File
@@ -1,12 +1,16 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const { fetchMock, getAuthHeadersMock, handleApiResponseMock } = vi.hoisted(
() => ({
fetchMock: vi.fn(),
getAuthHeadersMock: vi.fn(),
handleApiResponseMock: vi.fn(),
}),
);
const {
fetchMock,
getAuthHeadersMock,
handleApiErrorMock,
handleApiResponseMock,
} = vi.hoisted(() => ({
fetchMock: vi.fn(),
getAuthHeadersMock: vi.fn(),
handleApiErrorMock: vi.fn(),
handleApiResponseMock: vi.fn(),
}));
vi.mock("@/lib", () => ({
apiBaseUrl: "https://api.example.com/api/v1",
@@ -14,14 +18,20 @@ vi.mock("@/lib", () => ({
}));
vi.mock("@/lib/server-actions-helper", () => ({
handleApiError: handleApiErrorMock,
handleApiResponse: handleApiResponseMock,
}));
vi.mock("next/cache", () => ({ revalidatePath: vi.fn() }));
import {
addIssueNote,
getIssue,
getIssueFindings,
getIssueNotes,
getIssues,
getIssuesOverview,
updateIssueTriage,
} from "./issues";
describe("issues actions", () => {
@@ -54,9 +64,12 @@ describe("issues actions", () => {
{
id: "finding-1",
attributes: {
uid: "finding-uid",
check_id: "ssh",
severity: "critical",
check_metadata: { checktitle: "SSH exposed" },
// Nobody has triaged it yet.
triage_status: null,
},
relationships: { resources: { data: [{ id: "r1" }, { id: "r2" }] } },
},
@@ -76,12 +89,15 @@ describe("issues actions", () => {
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(url.searchParams.get("fields[findings]")).toContain("triage_status");
expect(findings).toEqual(
["instance", "security-group"].map((resourceUid) => ({
id: "finding-1",
uid: "finding-uid",
checkId: "ssh",
checkTitle: "SSH exposed",
severity: "critical",
triageStatus: "open",
resourceUid,
})),
);
@@ -118,4 +134,123 @@ describe("issues actions", () => {
getIssueFindings("scan-1", ["uid-1"]),
).resolves.toBeUndefined();
});
it("triages through the issue's triage, as findings are triaged", async () => {
// Given
const response = new Response("{}", { status: 200 });
fetchMock.mockResolvedValue(response);
handleApiResponseMock.mockResolvedValue({ data: {} });
// When
const result = await updateIssueTriage("issue-1", {
status: "risk_accepted",
note: "Static site.",
});
// Then
expect(result).toEqual({ success: true });
// The list shows each issue's triage status.
expect(handleApiResponseMock).toHaveBeenCalledWith(response, "/issues");
const [url, init] = fetchMock.mock.calls[0];
expect(url).toBe("https://api.example.com/api/v1/issues/issue-1/triage");
expect(init.method).toBe("PATCH");
expect(JSON.parse(init.body)).toEqual({
data: {
type: "issue-triages",
attributes: { status: "risk_accepted", note: "Static site." },
},
});
});
it("returns the API's reason when a triage write is refused", async () => {
handleApiResponseMock.mockResolvedValue({
error: "You do not have permission.",
status: 403,
});
await expect(addIssueNote("issue-1", "note")).resolves.toEqual({
error: "You do not have permission.",
});
expect(fetchMock.mock.calls[0][0]).toBe(
"https://api.example.com/api/v1/issues/issue-1/triage/notes",
);
});
it("returns the shared, sanitized message when a triage write fails on the server", async () => {
// Given: handleApiResponse rejects on a 5xx (see beforeEach)
handleApiErrorMock.mockReturnValue({ error: "Something went wrong." });
// When
const result = await addIssueNote("issue-1", "note");
// Then
expect(result).toEqual({ error: "Something went wrong." });
expect(handleApiErrorMock).toHaveBeenCalledWith(new Error("Server error"));
});
it.each([
{
operation: "updating triage",
save: () => updateIssueTriage("issue-1", { status: "risk_accepted" }),
},
{
operation: "adding a note",
save: () => addIssueNote("issue-1", "Accepted."),
},
])(
"returns a save error when authentication fails while $operation",
async ({ save }) => {
// Given
getAuthHeadersMock.mockRejectedValue(new Error("Session lookup failed"));
handleApiErrorMock.mockReturnValue({ error: "Something went wrong." });
// When / Then
await expect(save()).resolves.toEqual({ error: "Something went wrong." });
expect(fetchMock).not.toHaveBeenCalled();
},
);
it("names each note's author from the included users", async () => {
// Given
handleApiResponseMock.mockResolvedValue({
data: [
{
type: "issue-triage-notes",
id: "note-1",
attributes: { body: "Accepted.", inserted_at: "2026-10-20" },
relationships: { created_by: { data: { type: "users", id: "u1" } } },
},
{
type: "issue-triage-notes",
id: "note-2",
attributes: { body: "Orphan.", inserted_at: "2026-10-19" },
relationships: { created_by: { data: null } },
},
],
included: [{ type: "users", id: "u1", attributes: { name: "Ana" } }],
});
// When
const notes = await getIssueNotes("issue-1");
// Then
const url = new URL(fetchMock.mock.calls[0][0]);
expect(url.pathname).toBe("/api/v1/issues/issue-1/triage/notes");
expect(url.searchParams.get("include")).toBe("created_by");
expect(url.searchParams.get("page[size]")).toBe("100");
expect(notes).toEqual([
{
id: "note-1",
body: "Accepted.",
insertedAt: "2026-10-20",
author: "Ana",
},
{
id: "note-2",
body: "Orphan.",
insertedAt: "2026-10-19",
author: undefined,
},
]);
});
});
+79 -3
View File
@@ -1,16 +1,23 @@
"use server";
import { apiBaseUrl, getAuthHeaders } from "@/lib";
import { handleApiResponse } from "@/lib/server-actions-helper";
import { handleApiError, handleApiResponse } from "@/lib/server-actions-helper";
import {
ISSUE_FINDINGS_LIMIT,
IssueDetailResponse,
IssueFinding,
IssueResponse,
IssuesOverviewResponse,
IssueTriageNote,
} from "@/types/issues";
import { toIssueFindings } from "./issues.adapter";
import { toIssueFindings, toIssueTriageNotes } from "./issues.adapter";
import {
ISSUE_TRIAGE_WRITE_METHOD,
type IssueTriageWriteMethod,
type IssueWriteResult,
type UpdateIssueTriageInput,
} from "./types";
const withFilters = (
url: URL,
@@ -107,7 +114,7 @@ export const getIssueFindings = async (
url.searchParams.set("include", "resources");
url.searchParams.set(
"fields[findings]",
"check_id,severity,check_metadata,resources",
"uid,check_id,severity,check_metadata,triage_status,resources",
);
url.searchParams.set("fields[resources]", "uid");
url.searchParams.set("page[size]", String(uids.length));
@@ -120,3 +127,72 @@ export const getIssueFindings = async (
return undefined;
}
};
/** Write one issue triage request; any non-2xx comes back as an error to show. */
const writeIssueTriage = async (
path: string,
method: IssueTriageWriteMethod,
data: Record<string, unknown>,
): Promise<IssueWriteResult> => {
try {
const headers = await getAuthHeaders({ contentType: true });
const response = await fetch(`${apiBaseUrl}${path}`, {
method,
headers,
body: JSON.stringify({ data }),
});
// The list shows each issue's triage status.
const result = await handleApiResponse(response, "/issues");
return result?.error ? { error: result.error } : { success: true };
} catch (error) {
return handleApiError(error);
}
};
/** Set the status of the path an issue describes, with an optional note. */
export const updateIssueTriage = async (
id: string,
{ status, note }: UpdateIssueTriageInput,
): Promise<IssueWriteResult> =>
writeIssueTriage(
`/issues/${encodeURIComponent(id)}/triage`,
ISSUE_TRIAGE_WRITE_METHOD.UPDATE,
{
type: "issue-triages",
attributes: { status, ...(note ? { note } : {}) },
},
);
/** Add a note to an issue's triage without changing its status. */
export const addIssueNote = async (
id: string,
body: string,
): Promise<IssueWriteResult> =>
writeIssueTriage(
`/issues/${encodeURIComponent(id)}/triage/notes`,
ISSUE_TRIAGE_WRITE_METHOD.ADD_NOTE,
{
type: "issue-triage-notes",
attributes: { body },
},
);
export const getIssueNotes = async (
id: string,
): Promise<IssueTriageNote[] | undefined> => {
const headers = await getAuthHeaders({ contentType: false });
const url = new URL(
`${apiBaseUrl}/issues/${encodeURIComponent(id)}/triage/notes`,
);
url.searchParams.set("include", "created_by");
// shortcut: newest 100 notes only (the API's max page size; its default is 10), page through links.next once an issue's history outgrows it
url.searchParams.set("page[size]", "100");
try {
const response = await fetch(url.toString(), { headers });
return toIssueTriageNotes(await handleApiResponse(response));
} catch (error) {
console.error("Error fetching issue notes:", error);
return undefined;
}
};
+65
View File
@@ -0,0 +1,65 @@
import type { FindingProps } from "@/types/components";
import type {
FindingTriageManualStatus,
FindingTriageStatus,
} from "@/types/findings-triage";
import type { IssueTriageNote } from "@/types/issues";
import type { JsonApiDocument, JsonApiResource } from "@/types/jsonapi";
import type { ResourceProps } from "@/types/resources";
import type { UserAttributes, UserData } from "@/types/users";
export interface IssueFindingAttributes
extends Pick<
FindingProps["attributes"],
"uid" | "check_id" | "check_metadata" | "severity"
> {
triage_status?: FindingTriageStatus | null;
}
export interface IssueFindingResource extends Pick<FindingProps, "id"> {
attributes: IssueFindingAttributes;
relationships: Pick<FindingProps["relationships"], "resources">;
}
export interface IssueFindingsDocument
extends JsonApiDocument<IssueFindingResource[]> {
included?: JsonApiResource<Pick<ResourceProps["attributes"], "uid">>[];
}
export interface IssueTriageNoteAttributes
extends Pick<IssueTriageNote, "body"> {
inserted_at: IssueTriageNote["insertedAt"];
}
export interface IssueTriageNoteAuthorRelationship {
data: Pick<UserData, "id"> | null;
}
export interface IssueTriageNoteRelationships {
created_by?: IssueTriageNoteAuthorRelationship;
}
export interface IssueTriageNoteResource
extends JsonApiResource<IssueTriageNoteAttributes> {
relationships?: IssueTriageNoteRelationships;
}
export interface IssueTriageNotesDocument
extends JsonApiDocument<IssueTriageNoteResource[]> {
included?: JsonApiResource<Partial<Pick<UserAttributes, "name" | "email">>>[];
}
export type IssueWriteResult = { success: true } | { error: string };
export interface UpdateIssueTriageInput {
status: FindingTriageManualStatus;
note?: string;
}
export const ISSUE_TRIAGE_WRITE_METHOD = {
UPDATE: "PATCH",
ADD_NOTE: "POST",
} as const;
export type IssueTriageWriteMethod =
(typeof ISSUE_TRIAGE_WRITE_METHOD)[keyof typeof ISSUE_TRIAGE_WRITE_METHOD];
@@ -17,6 +17,7 @@ describe("ExploitabilityChart", () => {
issues={12}
findings={37}
actionable={3}
coveredFindings={0}
scope={{ "filter[provider_type__in]": "aws" }}
/>,
);
@@ -45,4 +46,38 @@ describe("ExploitabilityChart", () => {
screen.getByText("37 failed findings folded into 12 issues"),
).toBeInTheDocument();
});
it("draws each verdict's share and names the findings a control covers", () => {
render(
<ExploitabilityChart
counts={{
exploitable: 1,
likely: 2,
conditional: 0,
not_exploitable: 4,
unknown: 1,
}}
issues={8}
findings={30}
actionable={3}
coveredFindings={9}
scope={{}}
/>,
);
// Sized by share, so the bar reads the same whatever the estate's size.
expect(screen.getByTitle("4 not exploitable")).toHaveStyle({
width: "50%",
});
expect(screen.getByTitle("1 exploitable")).toHaveAttribute(
"href",
"/issues?filter%5Bverdict%5D=exploitable",
);
// A verdict with no issues gets no segment.
expect(screen.queryByTitle("0 conditional")).not.toBeInTheDocument();
expect(screen.getByRole("link", { name: "9 findings" })).toHaveAttribute(
"href",
"/issues?filter%5Bverdict%5D=not_exploitable",
);
});
});
@@ -1,7 +1,13 @@
import { TriangleAlert } from "lucide-react";
import Link from "next/link";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/shadcn";
import {
Button,
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";
@@ -12,6 +18,8 @@ interface ExploitabilityChartProps {
issues: number;
findings: number;
actionable: number;
/** Findings folded into not-exploitable issues: a control already covers them. */
coveredFindings: number;
/** Overview scope, carried into every drill-down so the counts still match. */
scope: Record<string, string>;
}
@@ -27,6 +35,15 @@ const TONE_DOT: Record<VerdictTone, string> = {
[VERDICT_TONE.neutral]: "bg-bg-data-muted",
};
/** The share bar runs worst to best; likely is a lighter exploitable. */
const BAR_SEGMENT: Record<Verdict, string> = {
exploitable: "bg-bg-fail",
likely: "bg-bg-fail/60",
conditional: TONE_DOT[VERDICT_TONE.warning],
unknown: TONE_DOT[VERDICT_TONE.neutral],
not_exploitable: TONE_DOT[VERDICT_TONE.pass],
};
/**
* The fold, sized for the ThreatScore row.
*
@@ -40,8 +57,11 @@ export const ExploitabilityChart = ({
issues,
findings,
actionable,
coveredFindings,
scope,
}: ExploitabilityChartProps) => {
const barTotal = Object.values(counts).reduce((sum, count) => sum + count, 0);
return (
<Card
variant="base"
@@ -76,6 +96,32 @@ export const ExploitabilityChart = ({
</span>
</Link>
{/* The ladder's shape: whether this estate is mostly open or mostly
blocked, sized by share and never by severity. The rows around it
carry the same links for keyboard and screen-reader users. */}
{barTotal > 0 && (
<div
aria-hidden="true"
className="bg-bg-neutral-tertiary flex h-2 w-full overflow-hidden rounded-full"
>
{(Object.keys(BAR_SEGMENT) as Verdict[])
.filter((id) => counts[id] > 0)
.map((id) => (
<Link
key={id}
tabIndex={-1}
href={issuesHref(scope, { "filter[verdict]": id })}
title={`${counts[id]} ${(VERDICTS.find((item) => item.id === id)?.label ?? id).toLowerCase()}`}
style={{ width: `${(counts[id] / barTotal) * 100}%` }}
className={cn(
"h-full transition-opacity hover:opacity-80",
BAR_SEGMENT[id],
)}
/>
))}
</div>
)}
<Card variant="inner" padding="none" className="gap-0 px-4 py-0.5">
<ul className="divide-border-neutral-secondary flex flex-col divide-y">
{REST.map((id) => {
@@ -111,6 +157,22 @@ export const ExploitabilityChart = ({
<p className="border-border-neutral-secondary text-text-neutral-tertiary mt-auto border-t pt-3 text-xs">
{findings.toLocaleString()} failed findings folded into{" "}
{issues.toLocaleString()} {issues === 1 ? "issue" : "issues"}
{coveredFindings > 0 && (
<>
{" · "}
<Button asChild variant="link" size="link-xs">
<Link
href={issuesHref(scope, {
"filter[verdict]": "not_exploitable",
})}
>
{coveredFindings.toLocaleString()}{" "}
{coveredFindings === 1 ? "finding" : "findings"}
</Link>
</Button>{" "}
already covered by a control
</>
)}
</p>
</CardContent>
</Card>
@@ -4,10 +4,14 @@ 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 { riskFactors } from "@/lib/issues/risk-factors";
import { issuesHref } from "@/lib/issues/scope";
import { VERDICTS } from "@/lib/issues/verdicts";
import { IssueResource } from "@/types/issues";
/** Enough to show the combination; the rest is on the issue. */
const TOP_FACTORS = 3;
interface TopIssuesProps {
issues: IssueResource[] | null;
scope: Record<string, string>;
@@ -31,6 +35,10 @@ export const TopIssues = ({ issues, scope }: TopIssuesProps) => (
(item) => item.id === attributes.verdict,
);
const providerId = relationships?.provider?.data?.id;
const factors = riskFactors(attributes)
.slice(0, TOP_FACTORS)
.map((factor) => factor.label)
.join(" · ");
return (
<li key={id} className="min-w-0">
<Card variant="inner" padding="md" className="h-full gap-2">
@@ -65,6 +73,12 @@ export const TopIssues = ({ issues, scope }: TopIssuesProps) => (
<p className="text-text-neutral-tertiary truncate text-xs">
{attributes.account}
</p>
{/* The reason to look is the combination, not the rank. */}
{factors && (
<p className="text-text-neutral-secondary truncate text-xs">
{factors}
</p>
)}
</div>
</Card>
</li>
@@ -29,6 +29,7 @@ export const ExploitabilityChartSSR = async ({
issues={attributes.issues ?? 0}
findings={attributes.findings ?? 0}
actionable={attributes.actionable ?? 0}
coveredFindings={attributes.not_exploitable_findings ?? 0}
scope={filters}
/>
);
@@ -24,6 +24,11 @@ describe("TopIssuesSSR", () => {
title: "RDS snapshot is visible to every AWS account",
verdict: "likely",
findings_count: 1,
attacker_start: "authenticated_principal",
outcome: "data_exposure",
legs: {
target: { state: "yes", text: "A snapshot.", evidence: [] },
},
},
relationships: {
provider: { data: { type: "providers", id: "p1" } },
@@ -38,6 +43,9 @@ describe("TopIssuesSSR", () => {
expect(row.closest("li")).toHaveTextContent("Likely");
expect(row.closest("li")).toHaveTextContent("aws_testing_1");
expect(row.closest("li")).toHaveTextContent("1 finding");
expect(row.closest("li")).toHaveTextContent(
"Any signed-in identity · Data exposure",
);
expect(row).toHaveAttribute(
"href",
"/issues?filter%5Bpattern%5D=public_snapshot&filter%5Bprovider_id__in%5D=p1",
@@ -588,7 +588,8 @@ export class AttackPathPageHarness extends BrowserHarness<PageFixture> {
/**
* Click every resource that the fixture's relationships connect to a finding.
* Findings are hidden by default in the full graph view (Tier 1) — clicking
* their adjacent resources reveals them.
* their adjacent resources reveals them. Restore the full view after each
* expansion because its contextual fit can move the next resource off-screen.
*/
async expandAllFindings(): Promise<void> {
const findingIds = new Set(
@@ -604,8 +605,15 @@ export class AttackPathPageHarness extends BrowserHarness<PageFixture> {
for (const id of Array.from(resourceWithFindingIds)) {
const el = this.getNodeById(id);
if (el) {
const beforeExpansion = await this.waitForViewportSettled();
await this.user.click(el);
await this.waitForTransition(50);
// Measuring new findings can delay the automatic fit. A quiet viewport
// alone does not prove that this fit has started or finished.
await this.waitForViewportChange(beforeExpansion);
await this.waitForViewportSettled();
await this.fit();
await this.waitForNodesInViewport(this.renderedNodeIds);
await this.waitForViewportSettled();
}
}
}
@@ -616,11 +616,15 @@ describe("auto-fitting the viewport", () => {
// their positions can sit outside the framed viewport. Expanding the
// resources should re-fit so the user does not have to hunt for the
// newly visible findings off-screen.
const before = graph.viewportTransform;
const before = await graph.waitForViewportSettled();
expect(before).toBeTruthy();
await graph.expandAllFindings();
// Use a single resource click so only the automatic fit can recover the
// findings; expandAllFindings also uses the toolbar to frame the full graph.
await graph.clickNode("s3-1");
await graph.waitForViewportChange(before);
await graph.waitForViewportSettled();
await graph.waitForNodesInViewport(["s3-1", "f-1"]);
expect(graph.viewportTransform).not.toBe(before);
});
@@ -0,0 +1,75 @@
import { render, screen } from "@testing-library/react";
import { ReactNode } from "react";
import { beforeEach, describe, expect, it, vi } from "vitest";
const { getIssue, notFound } = vi.hoisted(() => ({
getIssue: vi.fn(),
notFound: vi.fn(() => {
throw new Error("NOT_FOUND");
}),
}));
vi.mock("next/navigation", () => ({ notFound }));
vi.mock("@/actions/issues", () => ({ getIssue }));
vi.mock("@/components/shadcn/content-layout", () => ({
ContentLayout: ({ children }: { children: ReactNode }) => children,
}));
vi.mock("@/components/shared/feature-flag-gate", () => ({
FeatureFlagGate: ({ children }: { children: ReactNode }) => children,
}));
vi.mock("@/components/issues/issue-detail", () => ({
IssueDetail: ({ scanId }: { scanId?: string }) => (
<div data-testid="issue-detail">{scanId}</div>
),
}));
import IssuePage from "./page";
const params = Promise.resolve({ id: "issue-1" });
describe("IssuePage", () => {
beforeEach(() => {
vi.stubEnv("UI_CLOUD_ENABLED", "true");
getIssue.mockReset();
});
it("returns not found in Local Server without asking the API", async () => {
vi.stubEnv("UI_CLOUD_ENABLED", "false");
await expect(IssuePage({ params })).rejects.toThrow("NOT_FOUND");
expect(getIssue).not.toHaveBeenCalled();
});
it("returns not found when the API has no such issue", async () => {
getIssue.mockResolvedValue({ error: "Not found", status: 404 });
await expect(IssuePage({ params })).rejects.toThrow("NOT_FOUND");
});
it("shows the issue with the detail the list opens in place", async () => {
getIssue.mockResolvedValue({
data: {
type: "issues",
id: "issue-1",
attributes: {
pattern: "aws_public_bucket",
cloud: "aws",
account: "prod",
title: "Public bucket",
verdict: "likely",
},
relationships: { scan: { data: { type: "scans", id: "scan-1" } } },
},
});
render(await IssuePage({ params }));
expect(getIssue).toHaveBeenCalledWith("issue-1");
expect(
screen.getByRole("heading", { name: "Public bucket" }),
).toBeInTheDocument();
expect(screen.getByText("Likely")).toBeInTheDocument();
expect(screen.getByTestId("issue-detail")).toHaveTextContent("scan-1");
expect(screen.getByRole("link", { name: "← All issues" })).toHaveAttribute(
"href",
"/issues",
);
});
});
+76
View File
@@ -0,0 +1,76 @@
import { ShieldAlert } from "lucide-react";
import Link from "next/link";
import { notFound } from "next/navigation";
import { getIssue } from "@/actions/issues";
import { IssueDetail } from "@/components/issues/issue-detail";
import { Badge } from "@/components/shadcn/badge/badge";
import { Button } from "@/components/shadcn/button/button";
import { Card } from "@/components/shadcn/card/card";
import { ContentLayout } from "@/components/shadcn/content-layout";
import { EntityInfo } from "@/components/shadcn/entities";
import { FeatureFlagGate } from "@/components/shared/feature-flag-gate";
import { FEATURE_FLAG } from "@/lib/feature-flags";
import { VERDICTS } from "@/lib/issues/verdicts";
import { isCloud } from "@/lib/shared/env";
/**
* One issue on its own URL, so a path can be shared or linked from a ticket.
* The body is the same detail the list opens in place.
*/
export default async function IssuePage({
params,
}: {
params: Promise<{ id: string }>;
}) {
if (!isCloud()) notFound();
const { id } = await params;
const issue = (await getIssue(id))?.data;
// handleApiResponse resolves {error, status} on a 4xx: no data is no issue.
if (!issue?.attributes) notFound();
const { attributes, relationships } = issue;
const verdict = VERDICTS.find((item) => item.id === attributes.verdict);
return (
<ContentLayout title="Issue" icon={<ShieldAlert />}>
<FeatureFlagGate
flag={FEATURE_FLAG.ISSUES}
fallback={
<p className="text-text-neutral-tertiary text-sm">
Issues is not available for this account yet.
</p>
}
>
<div className="flex min-w-0 flex-col gap-4">
<Button asChild variant="link" size="link-sm" className="self-start">
<Link href="/issues">← All issues</Link>
</Button>
<Card variant="base" className="min-w-0 gap-3">
<div className="flex flex-wrap items-center gap-3">
<Badge variant={verdict?.variant ?? "tag"}>
{verdict?.label ?? attributes.verdict}
</Badge>
<span className="text-text-neutral-tertiary text-xs">
{attributes.pattern}
</span>
</div>
<h2 className="text-text-neutral-primary text-lg font-semibold break-words">
{attributes.title}
</h2>
<EntityInfo
cloudProvider={attributes.cloud}
entityAlias={attributes.account}
/>
<IssueDetail
id={issue.id}
issue={attributes}
scanId={relationships?.scan?.data?.id}
providerId={relationships?.provider?.data?.id}
/>
</Card>
</div>
</FeatureFlagGate>
</ContentLayout>
);
}
@@ -2,6 +2,7 @@ import { ReactNode } from "react";
import { getIssues, getIssuesOverview } from "@/actions/issues";
import { IssuesLoadError } from "@/components/issues/issues-load-error";
import { IssuesSummary } from "@/components/issues/issues-summary";
import { IssuesTable } from "@/components/issues/issues-table";
import { VerdictFilter } from "@/components/issues/verdict-filter";
import { ISSUE_SCOPE_KEYS } from "@/lib/issues/scope";
@@ -17,6 +18,8 @@ const ISSUE_FILTER_KEYS = [
"filter[severity]",
"filter[provider_type]",
"filter[provider_alias]",
"filter[triage_status]",
"filter[triage_status__in]",
...ISSUE_SCOPE_KEYS,
];
@@ -34,17 +37,16 @@ export const IssuesList = async ({
if (typeof value === "string" && value) filters[key] = value;
});
// 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.
const scope = Object.fromEntries(
Object.entries(filters).filter(
([key]) => key !== "filter[verdict]" && key !== "filter[verdict__in]",
),
);
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]",
),
),
}),
getIssuesOverview({ filters: scope }),
getIssues({
filters,
page: Number(searchParams.page) || 1,
@@ -61,7 +63,11 @@ export const IssuesList = async ({
return (
<>
{attributes ? null : (
{attributes ? (
<div className="mb-6">
<IssuesSummary summary={attributes} scope={scope} />
</div>
) : (
<IssuesLoadError message="Failed to load issue summary." />
)}
@@ -73,7 +79,6 @@ export const IssuesList = async ({
? {
counts: verdictCounts(attributes),
total: attributes.issues,
findings: attributes.findings,
}
: undefined
}
@@ -1 +0,0 @@
Default `php_latest_version` in the scan configuration template raised from `8.2` to `8.5`
@@ -1 +0,0 @@
Outlined error badge variant with a visible border on error-colored surfaces
@@ -1 +0,0 @@
"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
@@ -1 +0,0 @@
Issues page and overview cards for Cloud deployments, with verdict filters, accessible issue details, and scoped finding evidence
@@ -1 +0,0 @@
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
@@ -1 +0,0 @@
SAML SSO integration card on the profile page is shown to roles with the Manage Account permission, matching the API requirement
@@ -76,6 +76,9 @@ interface AttackPathGraphProps {
onNodeClick?: (node: GraphNode) => void;
onNodeDoubleClick?: (node: GraphNode) => void;
onInitialFilter?: (filteredData: AttackPathGraphData) => void;
// Floor for the automatic fit: a graph that would shrink below it is shown
// at this zoom and panned instead, so its labels stay readable.
minFitZoom?: number;
ref?: Ref<GraphHandle>;
className?: string;
}
@@ -194,6 +197,13 @@ const AUTO_FIT_OPTIONS = {
duration: 300,
} as const;
// With a zoom floor the graph can be larger than the canvas and is centred on
// it, so the minimap's lopsided padding would only push the top out of view.
const fitOptionsFor = (minFitZoom?: number) =>
minFitZoom === undefined
? AUTO_FIT_OPTIONS
: { ...AUTO_FIT_OPTIONS, padding: "32px" as const, minZoom: minFitZoom };
const MEASURED_FIT_MAX_ATTEMPTS = 30;
const scheduleMeasuredFit = (
@@ -230,6 +240,7 @@ const GraphCanvas = ({
onNodeClick,
onNodeDoubleClick,
onInitialFilter,
minFitZoom,
ref,
}: GraphCanvasProps) => {
const { zoomIn, zoomOut, fitView, getZoom, getNodes, getNodesBounds } =
@@ -265,9 +276,9 @@ const GraphCanvas = ({
visibleNodes.every((n) => (n.measured?.width ?? 0) > 0)
);
},
() => fitView(AUTO_FIT_OPTIONS),
() => fitView(fitOptionsFor(minFitZoom)),
);
}, [expandedClasses, fitView, getNodes]);
}, [expandedClasses, fitView, getNodes, minFitZoom]);
// --- initialNodeId: synchronous filtered-view derivation on first render ---
// Compute the effective data: if initialNodeId is set and valid, derive filtered subgraph
@@ -352,15 +363,15 @@ const GraphCanvas = ({
);
if (contextualNodes.length > 0) {
fitView({ ...AUTO_FIT_OPTIONS, nodes: contextualNodes });
fitView({ ...fitOptionsFor(minFitZoom), nodes: contextualNodes });
return;
}
}
fitView(AUTO_FIT_OPTIONS);
fitView(fitOptionsFor(minFitZoom));
},
);
}, [expanded, isFilteredView, fitView, getNodes]);
}, [expanded, isFilteredView, fitView, getNodes, minFitZoom]);
useEffect(() => {
const previous = previousExpandedRef.current;
@@ -384,7 +395,7 @@ const GraphCanvas = ({
visibleNodes.every((n) => (n.measured?.width ?? 0) > 0)
);
},
() => fitView(AUTO_FIT_OPTIONS),
() => fitView(fitOptionsFor(minFitZoom)),
);
}
@@ -415,10 +426,10 @@ const GraphCanvas = ({
const contextualNodes = getNodes().filter((n) =>
contextualFitNodeIds.has(n.id),
);
fitView({ ...AUTO_FIT_OPTIONS, nodes: contextualNodes });
fitView({ ...fitOptionsFor(minFitZoom), nodes: contextualNodes });
},
);
}, [expanded, fitView, getNodes]);
}, [expanded, fitView, getNodes, minFitZoom]);
// Cloud reshapes into the attack-path view: drop the account hub, collapse
// each resource class into one expandable node, and inject the outcome node.
@@ -592,7 +603,10 @@ const GraphCanvas = ({
onNodeMouseEnter={handleNodeMouseEnter}
onNodeMouseLeave={handleNodeMouseLeave}
fitView
fitViewOptions={{ ...AUTO_FIT_OPTIONS, includeHiddenNodes: true }}
fitViewOptions={{
...fitOptionsFor(minFitZoom),
includeHiddenNodes: true,
}}
// Supported React Flow behavior: wheel over the graph zooms the
// viewport. The surrounding UX avoids using node details as inline
// content, so this no longer fights a below-graph details section.
@@ -646,6 +660,7 @@ export const AttackPathGraph = ({
onNodeClick,
onNodeDoubleClick,
onInitialFilter,
minFitZoom,
ref,
className,
}: AttackPathGraphProps) => {
@@ -674,6 +689,7 @@ export const AttackPathGraph = ({
onNodeClick={onNodeClick}
onNodeDoubleClick={onNodeDoubleClick}
onInitialFilter={onInitialFilter}
minFitZoom={minFitZoom}
/>
</ReactFlowProvider>
</div>
@@ -0,0 +1,101 @@
"use client";
import Image from "next/image";
import { getComplianceIcon } from "@/components/icons";
import { Badge, Button } from "@/components/shadcn";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/shadcn/tooltip";
import type { FindingComplianceFramework } from "@/types/compliance-watchlist";
/** Frameworks are not uniquely named — eight AWS ones are called "CIS" — so the
* version is part of the label, not decoration. */
export function complianceFrameworkLabel(
framework: FindingComplianceFramework,
): string {
const name = framework.framework || framework.name;
return framework.version ? `${name} ${framework.version}` : name;
}
interface ComplianceFrameworkChipProps {
framework: FindingComplianceFramework;
isNavigable: boolean;
onOpen: (framework: FindingComplianceFramework) => void;
}
export function ComplianceFrameworkChip({
framework,
isNavigable,
onOpen,
}: ComplianceFrameworkChipProps) {
const icon = getComplianceIcon(framework.complianceId);
const label = complianceFrameworkLabel(framework);
const content = icon ? (
<span className="border-border-neutral-tertiary flex size-7 shrink-0 items-center justify-center rounded-md border bg-slate-50">
<Image
src={icon}
alt={label}
width={20}
height={20}
className="size-5 object-contain"
/>
</span>
) : (
label
);
return (
<Tooltip>
<TooltipTrigger asChild>
{isNavigable ? (
<Button
type="button"
variant={icon ? "bare" : "outline"}
size={icon ? "icon-xs" : "sm"}
aria-label={`Open ${label} compliance details`}
onClick={() => onOpen(framework)}
>
{content}
</Button>
) : icon ? (
content
) : (
<Badge variant="tag" size="sm" aria-label={label}>
{content}
</Badge>
)}
</TooltipTrigger>
<TooltipContent>{label}</TooltipContent>
</Tooltip>
);
}
export function buildComplianceDetailHref({
complianceId,
framework,
version,
scanId,
regionFilter,
}: {
complianceId: string;
framework: string;
version: string;
scanId: string;
regionFilter: string | null;
}): string {
const params = new URLSearchParams();
params.set("complianceId", complianceId);
if (version) {
params.set("version", version);
}
params.set("scanId", scanId);
if (regionFilter) {
params.set("filter[region__in]", regionFilter);
}
return `/compliance/${encodeURIComponent(framework)}?${params.toString()}`;
}
@@ -31,6 +31,7 @@ import {
FINDING_TRIAGE_RESOLVED_LOCKED_COPY,
FINDING_TRIAGE_STATUS,
MANUAL_PASS_PROVENANCE,
MANUAL_PASS_NOTE_REQUIRED_COPY,
type FindingTriageContext,
type FindingTriageDetail,
type FindingTriageModalStatus,
@@ -41,10 +42,7 @@ import {
isTriageStatusLocked,
} from "@/types/findings-triage";
import {
FindingTriageStatusControl,
MANUAL_PASS_NOTE_REQUIRED_COPY,
} from "./finding-triage-status-control";
import { FindingTriageStatusControl } from "./finding-triage-status-control";
import { buildFindingTriageUpdateInput } from "./finding-triage-submit";
interface FindingNoteModalProps {
@@ -92,7 +92,6 @@ import {
import {
FindingNoteActionItem,
FindingTriageStatusBadge,
FindingTriageStatusCell,
} from "./finding-triage-cells";
@@ -803,22 +802,6 @@ describe("finding triage cells", () => {
).not.toBeInTheDocument();
});
it("renders a read-only triage status badge with the status color", () => {
// Given / When
render(
<FindingTriageStatusBadge
triage={makeTriageSummary({
status: FINDING_TRIAGE_STATUS.REMEDIATING,
label: "Remediating",
})}
/>,
);
// Then
expect(screen.getByText("Triage:")).toBeInTheDocument();
expect(screen.getByText("Remediating")).toHaveClass("text-bg-data-info");
});
it("should disable table status mutation when no update handler is wired", async () => {
// Given
const user = userEvent.setup();
@@ -12,7 +12,6 @@ import {
TooltipTrigger,
} from "@/components/shadcn/tooltip";
import { applyOptimisticTriageSummaryUpdate } from "@/lib/finding-triage";
import { cn } from "@/lib/utils";
import { useCloudUpgradeStore } from "@/store";
import { CLOUD_UPGRADE_FEATURE } from "@/types/cloud-upgrade";
import { FINDING_STATUS } from "@/types/components";
@@ -39,10 +38,7 @@ import {
FindingNoteModal,
type FindingNoteModalMode,
} from "./finding-note-modal";
import {
FindingTriageStatusControl,
TRIAGE_STATUS_TEXT_CLASS,
} from "./finding-triage-status-control";
import { FindingTriageStatusControl } from "./finding-triage-status-control";
export const CLOUD_ONLY_TOOLTIP_COPY = "Available in Prowler Cloud";
export const EDITING_UNAVAILABLE_COPY = "Editing is currently unavailable.";
@@ -340,28 +336,6 @@ export function FindingTriageStatusCell({
);
}
// Read-only triage status indicator, e.g. for the side drawer header where the
// editable picker would be out of place among the status/severity badges.
export function FindingTriageStatusBadge({
triage,
}: {
triage: FindingTriageSummary;
}) {
return (
<div className="flex items-center gap-1">
<span className="text-text-neutral-tertiary text-xs">Triage:</span>
<span
className={cn(
"text-xs font-medium",
TRIAGE_STATUS_TEXT_CLASS[triage.status],
)}
>
{triage.label}
</span>
</div>
);
}
export function FindingNoteActionItem({
triage,
findingContext = { title: "Finding" },
@@ -1,27 +1,19 @@
"use client";
import { type ComponentProps, useState } from "react";
import { useState } from "react";
import { Button } from "@/components/shadcn";
import { Modal } from "@/components/shadcn/modal";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
} from "@/components/shadcn/select/select";
import { useToast } from "@/components/shadcn/toast/use-toast";
import { cn } from "@/lib/utils";
import { TriageStatusPicker } from "@/components/shared/triage/triage-status-picker";
import { FINDING_STATUS } from "@/types/components";
import {
FINDING_TRIAGE_MANUAL_STATUS_VALUES,
FINDING_TRIAGE_MODAL_STATUS_VALUES,
FINDING_TRIAGE_ORIGIN,
FINDING_TRIAGE_STATUS,
FINDING_TRIAGE_STATUS_LABELS,
type FindingTriageManualStatus,
type FindingTriageModalStatus,
type FindingTriageStatus,
type FindingTriageSummary,
type FindingTriageUpdateHandler,
getFindingTriageMuteInfoCopy,
@@ -29,89 +21,7 @@ import {
isTriageStatusLocked,
} from "@/types/findings-triage";
type TriageStatusPickerSize = NonNullable<
ComponentProps<typeof SelectTrigger>["size"]
>;
export const TRIAGE_STATUS_TEXT_CLASS = {
open: "text-text-error-primary",
under_review: "text-text-warning-primary",
remediating: "text-bg-data-info",
resolved: "text-bg-pass",
risk_accepted: "text-bg-pass",
false_positive: "text-text-neutral-secondary",
reopened: "text-text-error-primary",
} as const satisfies Record<FindingTriageStatus, string>;
const MUTELIST_CONFIRMATION_TITLE = "Mute finding?";
export const MANUAL_PASS_NOTE_REQUIRED_COPY =
"Add a Triage Note explaining why this finding passes.";
function TriageStatusPicker({
disabled,
size = "sm",
value,
statusValues,
showManualPassTooltip = false,
onValueChange,
}: {
disabled: boolean;
size?: TriageStatusPickerSize;
value: FindingTriageStatus;
statusValues: readonly FindingTriageModalStatus[];
showManualPassTooltip?: boolean;
onValueChange: (status: FindingTriageModalStatus) => void;
}) {
return (
<Select
value={value}
disabled={disabled}
onValueChange={(nextStatus) => {
if (statusValues.includes(nextStatus as FindingTriageModalStatus)) {
onValueChange(nextStatus as FindingTriageModalStatus);
}
}}
>
<SelectTrigger
aria-label="Triage status"
disabled={disabled}
size={size}
iconSize="sm"
>
<span className={cn("truncate", TRIAGE_STATUS_TEXT_CLASS[value])}>
{FINDING_TRIAGE_STATUS_LABELS[value]}
</span>
</SelectTrigger>
<SelectContent>
{statusValues.map((status) => {
const shouldExplainManualPass =
showManualPassTooltip && status === FINDING_TRIAGE_STATUS.RESOLVED;
return (
<SelectItem
key={status}
value={status}
aria-label={FINDING_TRIAGE_STATUS_LABELS[status]}
>
<span
className={cn("truncate", TRIAGE_STATUS_TEXT_CLASS[status])}
>
{FINDING_TRIAGE_STATUS_LABELS[status]}
</span>
{shouldExplainManualPass && (
<span
className="text-text-neutral-secondary text-xs font-normal normal-case"
aria-hidden="true"
>
{MANUAL_PASS_NOTE_REQUIRED_COPY}
</span>
)}
</SelectItem>
);
})}
</SelectContent>
</Select>
);
}
type TableStatusControlProps = {
origin: typeof FINDING_TRIAGE_ORIGIN.TABLE;
Loaded 100 of 136 files, more files were not shown because too many files have changed in this diff. Show more