mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-11-02 01:15:31 +00:00
Compare commits
15
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f1bb376e66 | ||
|
|
91b86777ce | ||
|
|
fdb1bd6193 | ||
|
|
41a2bf605a | ||
|
|
63b8c8969c | ||
|
|
1ae3856de0 | ||
|
|
40758818c3 | ||
|
|
83152ae1a6 | ||
|
|
3bbd551f0f | ||
|
|
be01a97789 | ||
|
|
7ecb6aaefd | ||
|
|
75f81cc751 | ||
|
|
c724840e6a | ||
|
|
b52b8d3678 | ||
|
|
c533b47aeb |
No files matched your search
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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,7 +1,7 @@
|
||||
openapi: 3.0.3
|
||||
info:
|
||||
title: Prowler API
|
||||
version: 1.47.0
|
||||
version: 1.47.1
|
||||
description: |-
|
||||
Prowler API specification.
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
):
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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}")
|
||||
@@ -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
@@ -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" },
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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": [
|
||||
|
||||
Whitespace-only changes.
-39
@@ -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."
|
||||
}
|
||||
-37
@@ -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
@@ -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}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 == {
|
||||
|
||||
@@ -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)})
|
||||
Whitespace-only changes.
-149
@@ -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()
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
});
|
||||
};
|
||||
@@ -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,
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
@@ -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",
|
||||
|
||||
+10
-2
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+6
-2
@@ -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",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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
Reference in new issue
Block a user