Compare commits

..

12 Commits

Author SHA1 Message Date
Prowler Bot a7f4f44e7b fix(docker): chown copied files to prowler pin uv sync --locked (#11242)
Co-authored-by: Pedro Martín <pedromarting3@gmail.com>
2026-05-19 18:13:19 +02:00
Prowler Bot 2a31bfc3e6 chore(stepsecurity): add missing endpoints (#11241)
Co-authored-by: Pedro Martín <pedromarting3@gmail.com>
2026-05-19 18:11:52 +02:00
Prowler Bot 1a4cfd81c5 fix(azure): skip system 'master' DB in sqlserver_tde_encrypted_with_cmk (#11235)
Co-authored-by: Pedro Martín <pedromarting3@gmail.com>
2026-05-19 17:05:35 +02:00
Prowler Bot c0559e7f10 fix(s3): only emit shadow-resource finding when bucket name matches a predictable pattern (#11237)
Co-authored-by: Hugo Pereira Brito <101209179+HugoPBrito@users.noreply.github.com>
2026-05-19 15:53:59 +01:00
Prowler Bot 706742e6dc chore(release): Bump versions to v5.27.1 (#11226)
Co-authored-by: prowler-bot <179230569+prowler-bot@users.noreply.github.com>
2026-05-19 15:11:49 +02:00
Prowler Bot baaf56ea5e chore(api): Update prowler dependency to v5.27 for release 5.27.0 (#11219)
Co-authored-by: prowler-bot <179230569+prowler-bot@users.noreply.github.com>
2026-05-19 12:17:44 +02:00
Pepe Fagoaga cb01769237 chore(changelog): prepare for v5.27.0 (#11218) 2026-05-19 11:42:10 +02:00
Pedro Martín 4c802620c4 chore(readme): update table Prowler at a Glance (#11216) 2026-05-19 11:19:49 +02:00
Adrián Peña 4fa8d5465e refactor(mcp): align /health with IETF health-check format (#11207) 2026-05-19 09:51:32 +02:00
Alan Buscaglia 31b9619627 fix(ui): improve attack paths node labels (#11197) 2026-05-19 09:32:16 +02:00
Alan Buscaglia d4a1bc10e9 docs(skills): require changelog confirmation (#11209) 2026-05-19 09:25:45 +02:00
Adrián Peña a1848747a3 refactor(ui): align /api/health with IETF health-check format (#11206) 2026-05-19 09:19:34 +02:00
52 changed files with 1001 additions and 362 deletions
+1 -1
View File
@@ -145,7 +145,7 @@ SENTRY_RELEASE=local
NEXT_PUBLIC_SENTRY_ENVIRONMENT=${SENTRY_ENVIRONMENT}
#### Prowler release version ####
NEXT_PUBLIC_PROWLER_RELEASE_VERSION=v5.27.0
NEXT_PUBLIC_PROWLER_RELEASE_VERSION=v5.27.1
# Social login credentials
SOCIAL_GOOGLE_OAUTH_CALLBACK_URL="${AUTH_URL}/api/auth/callback/google"
@@ -122,6 +122,7 @@ jobs:
github.com:443
powershellinfraartifacts-gkhedzdeaghdezhr.z01.azurefd.net:443
production.cloudflare.docker.com:443
production.cloudfront.docker.com:443
pypi.org:443
registry-1.docker.io:443
release-assets.githubusercontent.com:443
@@ -179,6 +180,7 @@ jobs:
registry-1.docker.io:443
auth.docker.io:443
production.cloudflare.docker.com:443
production.cloudfront.docker.com:443
- name: Login to DockerHub
uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.0
with:
@@ -83,6 +83,7 @@ jobs:
registry-1.docker.io:443
auth.docker.io:443
production.cloudflare.docker.com:443
production.cloudfront.docker.com:443
debian.map.fastlydns.net:80
release-assets.githubusercontent.com:443
objects.githubusercontent.com:443
+36 -3
View File
@@ -139,6 +139,17 @@ jobs:
sed -i "s|version = \"${CURRENT_API_VERSION}\"|version = \"${NEXT_API_VERSION}\"|" api/pyproject.toml
sed -i "s| version: ${CURRENT_API_VERSION}| version: ${NEXT_API_VERSION}|" api/src/backend/api/specs/v1.yaml
- name: Regenerate lockfiles after version bump
run: |
set -e
# The bumps above edit pyproject.toml / api/pyproject.toml but leave
# uv.lock / api/uv.lock stale, which makes `uv sync --locked` fail in
# the container builds. Refresh both with the uv version the images
# pin (plain `uv lock`, no --upgrade: only the version line changes).
pip install --no-cache-dir "uv==0.11.14"
uv lock
(cd api && uv lock)
- name: Bump UI version (.env)
run: |
set -e
@@ -155,7 +166,7 @@ jobs:
run: git --no-pager diff
- name: Create PR for next versions to master
uses: peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1 # v8.1.1
uses: peter-evans/create-pull-request@c0f553fe549906ede9cf27b5156039d195d2ece0 # v8.1.0
with:
author: prowler-bot <179230569+prowler-bot@users.noreply.github.com>
token: ${{ secrets.PROWLER_BOT_ACCESS_TOKEN }}
@@ -240,6 +251,17 @@ jobs:
sed -i "s|version = \"${CURRENT_API_VERSION}\"|version = \"${FIRST_API_PATCH_VERSION}\"|" api/pyproject.toml
sed -i "s| version: ${CURRENT_API_VERSION}| version: ${FIRST_API_PATCH_VERSION}|" api/src/backend/api/specs/v1.yaml
- name: Regenerate lockfiles after version bump
run: |
set -e
# The bumps above edit pyproject.toml / api/pyproject.toml but leave
# uv.lock / api/uv.lock stale, which makes `uv sync --locked` fail in
# the container builds. Refresh both with the uv version the images
# pin (plain `uv lock`, no --upgrade: only the version line changes).
pip install --no-cache-dir "uv==0.11.14"
uv lock
(cd api && uv lock)
- name: Bump UI version (.env)
run: |
set -e
@@ -249,7 +271,7 @@ jobs:
run: git --no-pager diff
- name: Create PR for first patch versions to version branch
uses: peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1 # v8.1.1
uses: peter-evans/create-pull-request@c0f553fe549906ede9cf27b5156039d195d2ece0 # v8.1.0
with:
author: prowler-bot <179230569+prowler-bot@users.noreply.github.com>
token: ${{ secrets.PROWLER_BOT_ACCESS_TOKEN }}
@@ -341,6 +363,17 @@ jobs:
sed -i "s|version = \"${CURRENT_API_VERSION}\"|version = \"${NEXT_API_PATCH_VERSION}\"|" api/pyproject.toml
sed -i "s| version: ${CURRENT_API_VERSION}| version: ${NEXT_API_PATCH_VERSION}|" api/src/backend/api/specs/v1.yaml
- name: Regenerate lockfiles after version bump
run: |
set -e
# The bumps above edit pyproject.toml / api/pyproject.toml but leave
# uv.lock / api/uv.lock stale, which makes `uv sync --locked` fail in
# the container builds. Refresh both with the uv version the images
# pin (plain `uv lock`, no --upgrade: only the version line changes).
pip install --no-cache-dir "uv==0.11.14"
uv lock
(cd api && uv lock)
- name: Bump UI version (.env)
run: |
set -e
@@ -350,7 +383,7 @@ jobs:
run: git --no-pager diff
- name: Create PR for next patch versions to version branch
uses: peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1 # v8.1.1
uses: peter-evans/create-pull-request@c0f553fe549906ede9cf27b5156039d195d2ece0 # v8.1.0
with:
author: prowler-bot <179230569+prowler-bot@users.noreply.github.com>
token: ${{ secrets.PROWLER_BOT_ACCESS_TOKEN }}
@@ -114,6 +114,7 @@ jobs:
registry-1.docker.io:443
auth.docker.io:443
production.cloudflare.docker.com:443
production.cloudfront.docker.com:443
ghcr.io:443
pkg-containers.githubusercontent.com:443
files.pythonhosted.org:443
@@ -171,6 +172,7 @@ jobs:
registry-1.docker.io:443
auth.docker.io:443
production.cloudflare.docker.com:443
production.cloudfront.docker.com:443
github.com:443
release-assets.githubusercontent.com:443
@@ -79,6 +79,7 @@ jobs:
registry-1.docker.io:443
auth.docker.io:443
production.cloudflare.docker.com:443
production.cloudfront.docker.com:443
ghcr.io:443
pkg-containers.githubusercontent.com:443
files.pythonhosted.org:443
+1 -1
View File
@@ -338,7 +338,7 @@ jobs:
- name: Create PR for API dependency update
if: ${{ env.PATCH_VERSION == '0' }}
uses: peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1 # v8.1.1
uses: peter-evans/create-pull-request@c0f553fe549906ede9cf27b5156039d195d2ece0 # v8.1.0
with:
token: ${{ secrets.PROWLER_BOT_ACCESS_TOKEN }}
commit-message: 'chore(api): update prowler dependency to ${{ env.BRANCH_NAME }} for release ${{ env.PROWLER_VERSION }}'
@@ -149,6 +149,7 @@ jobs:
public.ecr.aws:443
registry-1.docker.io:443
production.cloudflare.docker.com:443
production.cloudfront.docker.com:443
auth.docker.io:443
debian.map.fastlydns.net:80
github.com:443
@@ -216,6 +217,7 @@ jobs:
auth.docker.io:443
public.ecr.aws:443
production.cloudflare.docker.com:443
production.cloudfront.docker.com:443
github.com:443
release-assets.githubusercontent.com:443
api.ecr-public.us-east-1.amazonaws.com:443
@@ -85,6 +85,7 @@ jobs:
registry-1.docker.io:443
auth.docker.io:443
production.cloudflare.docker.com:443
production.cloudfront.docker.com:443
api.github.com:443
mirror.gcr.io:443
check.trivy.dev:443
@@ -58,7 +58,7 @@ jobs:
- name: Create pull request
id: create-pr
uses: peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1 # v8.1.1
uses: peter-evans/create-pull-request@c0f553fe549906ede9cf27b5156039d195d2ece0 # v8.1.0
with:
token: ${{ secrets.PROWLER_BOT_ACCESS_TOKEN }}
author: 'prowler-bot <179230569+prowler-bot@users.noreply.github.com>'
@@ -55,7 +55,7 @@ jobs:
- name: Create pull request
id: create-pr
uses: peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1 # v8.1.1
uses: peter-evans/create-pull-request@c0f553fe549906ede9cf27b5156039d195d2ece0 # v8.1.0
with:
token: ${{ secrets.PROWLER_BOT_ACCESS_TOKEN }}
author: 'prowler-bot <179230569+prowler-bot@users.noreply.github.com>'
+1
View File
@@ -46,6 +46,7 @@ jobs:
schema.ocsf.io:443
registry-1.docker.io:443
production.cloudflare.docker.com:443
production.cloudfront.docker.com:443
powershellinfraartifacts-gkhedzdeaghdezhr.z01.azurefd.net:443
o26192.ingest.us.sentry.io:443
management.azure.com:443
@@ -116,6 +116,7 @@ jobs:
allowed-endpoints: >
registry-1.docker.io:443
production.cloudflare.docker.com:443
production.cloudfront.docker.com:443
auth.docker.io:443
registry.npmjs.org:443
dl-cdn.alpinelinux.org:443
@@ -172,6 +173,7 @@ jobs:
registry-1.docker.io:443
auth.docker.io:443
production.cloudflare.docker.com:443
production.cloudfront.docker.com:443
- name: Login to DockerHub
uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.0
@@ -80,6 +80,7 @@ jobs:
registry-1.docker.io:443
auth.docker.io:443
production.cloudflare.docker.com:443
production.cloudfront.docker.com:443
registry.npmjs.org:443
dl-cdn.alpinelinux.org:443
fonts.googleapis.com:443
+6 -6
View File
@@ -76,11 +76,11 @@ USER prowler
WORKDIR /home/prowler
# Copy necessary files
COPY prowler/ /home/prowler/prowler/
COPY dashboard/ /home/prowler/dashboard/
COPY pyproject.toml uv.lock /home/prowler/
COPY README.md /home/prowler/
COPY prowler/providers/m365/lib/powershell/m365_powershell.py /home/prowler/prowler/providers/m365/lib/powershell/m365_powershell.py
COPY --chown=prowler:prowler prowler/ /home/prowler/prowler/
COPY --chown=prowler:prowler dashboard/ /home/prowler/dashboard/
COPY --chown=prowler:prowler pyproject.toml uv.lock /home/prowler/
COPY --chown=prowler:prowler README.md /home/prowler/
COPY --chown=prowler:prowler prowler/providers/m365/lib/powershell/m365_powershell.py /home/prowler/prowler/providers/m365/lib/powershell/m365_powershell.py
# Install Python dependencies
ENV HOME='/home/prowler'
@@ -89,7 +89,7 @@ ENV PATH="${HOME}/.local/bin:${PATH}"
RUN pip install --no-cache-dir --upgrade pip && \
pip install --no-cache-dir uv==0.11.14
RUN uv sync --compile-bytecode && \
RUN uv sync --locked --compile-bytecode && \
rm -rf ~/.cache/uv
# Install PowerShell modules
+5 -5
View File
@@ -104,22 +104,22 @@ Every AWS provider scan will enqueue an Attack Paths ingestion job automatically
| Provider | Checks | Services | [Compliance Frameworks](https://docs.prowler.com/projects/prowler-open-source/en/latest/tutorials/compliance/) | [Categories](https://docs.prowler.com/projects/prowler-open-source/en/latest/tutorials/misc/#categories) | Support | Interface |
|---|---|---|---|---|---|---|
| AWS | 595 | 84 | 43 | 17 | Official | UI, API, CLI |
| AWS | 600 | 84 | 44 | 18 | Official | UI, API, CLI |
| Azure | 167 | 22 | 19 | 16 | Official | UI, API, CLI |
| GCP | 102 | 18 | 17 | 12 | Official | UI, API, CLI |
| Kubernetes | 83 | 7 | 7 | 11 | Official | UI, API, CLI |
| GitHub | 24 | 3 | 1 | 5 | Official | UI, API, CLI |
| M365 | 101 | 10 | 4 | 10 | Official | UI, API, CLI |
| M365 | 102 | 10 | 4 | 10 | Official | UI, API, CLI |
| OCI | 51 | 14 | 4 | 10 | Official | UI, API, CLI |
| Alibaba Cloud | 61 | 9 | 4 | 9 | Official | UI, API, CLI |
| Alibaba Cloud | 63 | 9 | 4 | 9 | Official | UI, API, CLI |
| Cloudflare | 29 | 3 | 0 | 5 | Official | UI, API, CLI |
| IaC | [See `trivy` docs.](https://trivy.dev/latest/docs/coverage/iac/) | N/A | N/A | N/A | Official | UI, API, CLI |
| MongoDB Atlas | 10 | 3 | 0 | 8 | Official | UI, API, CLI |
| LLM | [See `promptfoo` docs.](https://www.promptfoo.dev/docs/red-team/plugins/) | N/A | N/A | N/A | Official | CLI |
| Image | N/A | N/A | N/A | N/A | Official | CLI, API |
| Google Workspace | 25 | 4 | 2 | 4 | Official | UI, API, CLI |
| Google Workspace | 39 | 5 | 2 | 5 | Official | UI, API, CLI |
| OpenStack | 34 | 5 | 0 | 9 | Official | UI, API, CLI |
| Vercel | 26 | 6 | 0 | 5 | Official | UI, API, CLI |
| Vercel | 26 | 6 | 0 | 8 | Official | UI, API, CLI |
| Okta | 1 | 1 | 0 | 1 | Official | CLI |
| Scaleway [Contact us](https://prowler.com/contact) | 1 | 1 | 0 | 1 | Unofficial | CLI |
| NHN | 6 | 2 | 1 | 0 | Unofficial | CLI |
+5 -11
View File
@@ -2,30 +2,24 @@
All notable changes to the **Prowler API** are documented in this file.
## [1.28.0] (Prowler UNRELEASED)
## [1.28.0] (Prowler v5.27.0)
### 🚀 Added
- GIN index on `findings(categories, resource_services, resource_regions, resource_types)` to speed up `/api/v1/finding-groups` array filters [(#11001)](https://github.com/prowler-cloud/prowler/pull/11001)
- `GET /health/live` and `GET /health/ready` Kubernetes-style probe endpoints following the IETF Health Check Response Format (`application/health+json`). Readiness verifies PostgreSQL, Valkey and Neo4j connectivity and returns 503 with per-dependency detail when any is unreachable; both endpoints centralize the API version on `config/version.py` (read from `pyproject.toml`) and are wired into the Helm charts and the Docker Compose healthcheck [(#11200)](https://github.com/prowler-cloud/prowler/pull/11200)
- `GET /health/live` and `GET /health/ready` Kubernetes-style probe endpoints following the IETF Health Check Response Format (`application/health+json`). Readiness verifies PostgreSQL, Valkey and Neo4j connectivity and returns 503 with per-dependency detail when any is unreachable [(#11200)](https://github.com/prowler-cloud/prowler/pull/11200)
### 🔄 Changed
- Replace `poetry` with `uv` (`0.11.14`) as the API package manager; migrate `pyproject.toml` to `[dependency-groups]` and regenerate as `uv.lock` [(#10775)](https://github.com/prowler-cloud/prowler/pull/10775)
- Replace `poetry` with `uv` as package manager [(#10775)](https://github.com/prowler-cloud/prowler/pull/10775)
- Remove orphaned `gin_resources_search_idx` declaration from `Resource.Meta.indexes` (DB index dropped in `0072_drop_unused_indexes`) [(#11001)](https://github.com/prowler-cloud/prowler/pull/11001)
- PDF compliance reports cap detail tables at 100 failed findings per check (configurable via `DJANGO_PDF_MAX_FINDINGS_PER_CHECK`) to bound worker memory on large scans [(#11160)](https://github.com/prowler-cloud/prowler/pull/11160)
### 🐞 Fixed
- `perform_scan_task` and `perform_scheduled_scan_task` now short-circuit with a warning and `return None` when the target provider no longer exists, instead of letting `handle_provider_deletion` raise `ProviderDeletedException`. `perform_scheduled_scan_task` also removes any orphan `PeriodicTask` it finds so beat stops re-firing scans for deleted providers. Prevents queued messages for deleted providers from being recorded as `FAILURE` and, in one-shot scan-worker deployments, from burning a fresh container per redelivery [(#11185)](https://github.com/prowler-cloud/prowler/pull/11185)
- `perform_scan_task` and `perform_scheduled_scan_task` now short-circuit with a warning and `return None` when the target provider no longer exists, instead of letting `handle_provider_deletion` raise `ProviderDeletedException`. `perform_scheduled_scan_task` also removes any orphan `PeriodicTask` it finds so beat stops re-firing scans for deleted providers. Prevents queued messages for deleted providers from being recorded as `FAILURE` [(#11185)](https://github.com/prowler-cloud/prowler/pull/11185)
- Attack Paths: `BEDROCK-001` and `BEDROCK-002` now target roles trusting `bedrock-agentcore.amazonaws.com` instead of `bedrock.amazonaws.com`, eliminating false positives against regular Bedrock service roles (Agents, Knowledge Bases, model invocation) [(#11141)](https://github.com/prowler-cloud/prowler/pull/11141)
---
## [1.27.2] (Prowler UNRELEASED)
### 🐞 Fixed
- Attack Paths: BEDROCK-001 and BEDROCK-002 now target roles trusting `bedrock-agentcore.amazonaws.com` instead of `bedrock.amazonaws.com`, eliminating false positives against regular Bedrock service roles (Agents, Knowledge Bases, model invocation) [(#11141)](https://github.com/prowler-cloud/prowler/pull/11141)
---
+2 -2
View File
@@ -43,7 +43,7 @@ dependencies = [
"defusedxml==0.7.1",
"gunicorn==23.0.0",
"lxml==6.1.0",
"prowler @ git+https://github.com/prowler-cloud/prowler.git@master",
"prowler @ git+https://github.com/prowler-cloud/prowler.git@v5.27",
"psycopg2-binary==2.9.9",
"pytest-celery[redis] (==1.3.0)",
"sentry-sdk[django] (==2.56.0)",
@@ -68,7 +68,7 @@ name = "prowler-api"
package-mode = false
# Needed for the SDK compatibility
requires-python = ">=3.11,<3.13"
version = "1.28.0"
version = "1.28.1"
[tool.uv]
# Transitive pins matching master to avoid silent drift; bump deliberately.
+1 -1
View File
@@ -1,7 +1,7 @@
openapi: 3.0.3
info:
title: Prowler API
version: 1.28.0
version: 1.28.1
description: |-
Prowler API specification.
Generated
+4 -3
View File
@@ -4411,7 +4411,7 @@ wheels = [
[[package]]
name = "prowler"
version = "5.27.0"
source = { git = "https://github.com/prowler-cloud/prowler.git?rev=master#0abbb7fc590eaf7de6ed354dd5a217bca261d2b0" }
source = { git = "https://github.com/prowler-cloud/prowler.git?rev=v5.27#cb01769237cb99a21f2f12cce470e239573e1a01" }
dependencies = [
{ name = "alibabacloud-actiontrail20200706" },
{ name = "alibabacloud-credentials" },
@@ -4484,6 +4484,7 @@ dependencies = [
{ name = "pygithub" },
{ name = "python-dateutil" },
{ name = "pytz" },
{ name = "scaleway" },
{ name = "schema" },
{ name = "shodan" },
{ name = "slack-sdk" },
@@ -4494,7 +4495,7 @@ dependencies = [
[[package]]
name = "prowler-api"
version = "1.28.0"
version = "1.29.0"
source = { virtual = "." }
dependencies = [
{ name = "cartography" },
@@ -4590,7 +4591,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.27" },
{ name = "psycopg2-binary", specifier = "==2.9.9" },
{ name = "pytest-celery", extras = ["redis"], specifier = "==1.3.0" },
{ name = "reportlab", specifier = "==4.4.10" },
+1
View File
@@ -211,6 +211,7 @@ services:
interval: 10s
timeout: 5s
retries: 3
start_period: 60s
volumes:
outputs:
+1
View File
@@ -176,6 +176,7 @@ services:
interval: 10s
timeout: 5s
retries: 3
start_period: 60s
volumes:
output:
+2 -2
View File
@@ -2,11 +2,11 @@
All notable changes to the **Prowler MCP Server** are documented in this file.
## [0.7.0] (Prowler UNRELEASED)
## [0.7.0] (Prowler v5.27.0)
### 🚀 Added
- MCP Server tools for Prowler Finding Groups Management [(#11140)](https://github.com/prowler-cloud/prowler/pull/11140)
- Finding Groups tools [(#11140)](https://github.com/prowler-cloud/prowler/pull/11140)
### 🔐 Security
+13 -3
View File
@@ -41,12 +41,22 @@ async def setup_main_server():
logger.error(f"Failed to import Prowler Documentation server: {e}")
# Add health check endpoint
# Response follows the IETF Health Check Response Format
# (draft-inadarei-api-health-check-06). `version` is the contract version of
# this endpoint; `releaseId` is the package build version.
@prowler_mcp_server.custom_route("/health", methods=["GET"])
async def health_check(request) -> JSONResponse:
async def health_check(_request) -> JSONResponse:
"""Health check endpoint."""
return JSONResponse(
{"status": "healthy", "service": "prowler-mcp-server", "version": __version__}
{
"status": "pass",
"version": "1",
"releaseId": __version__,
"serviceId": "prowler-mcp-server",
"description": "Prowler MCP Server",
},
media_type="application/health+json",
headers={"Cache-Control": "no-store"},
)
+8
View File
@@ -2,6 +2,11 @@
build-backend = "setuptools.build_meta"
requires = ["setuptools>=61.0", "wheel"]
[dependency-groups]
dev = [
"pytest==8.3.5"
]
[project]
dependencies = [
"fastmcp==2.14.0",
@@ -16,5 +21,8 @@ version = "0.5.0"
[project.scripts]
prowler-mcp = "prowler_mcp_server.main:main"
[tool.pytest.ini_options]
testpaths = ["tests"]
[tool.uv]
package = true
View File
+46
View File
@@ -0,0 +1,46 @@
"""Tests for the Prowler MCP Server health endpoint."""
from starlette.testclient import TestClient
from prowler_mcp_server import __version__
from prowler_mcp_server.server import app
def test_health_returns_ietf_pass_response():
"""GET /health returns 200 with the IETF health-check body and headers."""
client = TestClient(app)
response = client.get("/health")
assert response.status_code == 200
assert response.headers["content-type"] == "application/health+json"
assert response.headers["cache-control"] == "no-store"
assert response.json() == {
"status": "pass",
"version": "1",
"releaseId": __version__,
"serviceId": "prowler-mcp-server",
"description": "Prowler MCP Server",
}
def test_health_release_id_matches_package_version():
"""The endpoint must surface the current package __version__ as releaseId.
Drift between the response and the installed package would mislead any
monitoring tool that uses releaseId to identify the running build.
"""
client = TestClient(app)
response = client.get("/health")
assert response.json()["releaseId"] == __version__
def test_health_rejects_non_get_methods():
"""The endpoint only exposes GET; other verbs return 405."""
client = TestClient(app)
response = client.post("/health")
assert response.status_code == 405
+50
View File
@@ -443,6 +443,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/fa/5e/f8e9a1d23b9c20a551a8a02ea3637b4642e22c2626e3a13a9a29cdea99eb/importlib_metadata-8.7.1-py3-none-any.whl", hash = "sha256:5a1f80bf1daa489495071efbb095d75a634cf28a8bc299581244063b53176151", size = 27865, upload-time = "2025-12-21T10:00:18.329Z" },
]
[[package]]
name = "iniconfig"
version = "2.3.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
]
[[package]]
name = "jaraco-classes"
version = "3.4.0"
@@ -676,6 +685,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/cf/df/d3f1ddf4bb4cb50ed9b1139cc7b1c54c34a1e7ce8fd1b9a37c0d1551a6bd/opentelemetry_api-1.39.1-py3-none-any.whl", hash = "sha256:2edd8463432a7f8443edce90972169b195e7d6a05500cd29e6d13898187c9950", size = 66356, upload-time = "2025-12-11T13:32:17.304Z" },
]
[[package]]
name = "packaging"
version = "26.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134, upload-time = "2026-04-24T20:15:23.917Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" },
]
[[package]]
name = "pathable"
version = "0.4.4"
@@ -703,6 +721,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/73/cb/ac7874b3e5d58441674fb70742e6c374b28b0c7cb988d37d991cde47166c/platformdirs-4.5.0-py3-none-any.whl", hash = "sha256:e578a81bb873cbb89a41fcc904c7ef523cc18284b7e3b3ccf06aca1403b7ebd3", size = 18651, upload-time = "2025-10-08T17:44:47.223Z" },
]
[[package]]
name = "pluggy"
version = "1.6.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
]
[[package]]
name = "prometheus-client"
version = "0.24.1"
@@ -721,12 +748,20 @@ dependencies = [
{ name = "httpx" },
]
[package.dev-dependencies]
dev = [
{ name = "pytest" },
]
[package.metadata]
requires-dist = [
{ name = "fastmcp", specifier = "==2.14.0" },
{ name = "httpx", specifier = "==0.28.1" },
]
[package.metadata.requires-dev]
dev = [{ name = "pytest", specifier = "==8.3.5" }]
[[package]]
name = "py-key-value-aio"
version = "0.3.0"
@@ -906,6 +941,21 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/1e/bc/22540e73c5f5ae18f02924cd3954a6c9a4aa6b713c841a94c98335d333a1/pyperclip-1.10.0-py3-none-any.whl", hash = "sha256:596fbe55dc59263bff26e61d2afbe10223e2fccb5210c9c96a28d6887cfcc7ec", size = 11062, upload-time = "2025-09-18T00:53:59.252Z" },
]
[[package]]
name = "pytest"
version = "8.3.5"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "colorama", marker = "sys_platform == 'win32'" },
{ name = "iniconfig" },
{ name = "packaging" },
{ name = "pluggy" },
]
sdist = { url = "https://files.pythonhosted.org/packages/ae/3c/c9d525a414d506893f0cd8a8d0de7706446213181570cdbd766691164e40/pytest-8.3.5.tar.gz", hash = "sha256:f4efe70cc14e511565ac476b57c279e12a855b11f48f212af1080ef2263d3845", size = 1450891, upload-time = "2025-03-02T12:54:54.503Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/30/3d/64ad57c803f1fa1e963a7946b6e0fea4a70df53c1a7fed304586539c2bac/pytest-8.3.5-py3-none-any.whl", hash = "sha256:c69214aa47deac29fad6c2a4f590b9c4a9fdb16a403176fe154b79c0b4d4d820", size = 343634, upload-time = "2025-03-02T12:54:52.069Z" },
]
[[package]]
name = "python-dotenv"
version = "1.1.1"
+14 -10
View File
@@ -2,7 +2,16 @@
All notable changes to the **Prowler SDK** are documented in this file.
## [5.27.0] (Prowler UNRELEASED)
## [5.27.1] (Prowler UNRELEASED)
### 🐞 Fixed
- `s3_bucket_shadow_resource_vulnerability` no longer emits a tautological `PASS` finding for every bucket; a finding is now produced only when the bucket name matches one of the predictable service patterns (Glue, SageMaker, EMR, CodeStar) [(#11220)](https://github.com/prowler-cloud/prowler/pull/11220)
- `sqlserver_tde_encrypted_with_cmk` check for Azure provider no longer reports a false `FAIL` for SQL Servers whose user databases are correctly encrypted with a customer-managed key, by excluding the system `master` database (always reports TDE `Disabled` and is not customer-controllable) from the TDE evaluation [(#11233)](https://github.com/prowler-cloud/prowler/pull/11233)
---
## [5.27.0] (Prowler v5.27.0)
### 🚀 Added
@@ -18,6 +27,8 @@ All notable changes to the **Prowler SDK** are documented in this file.
- `entra_emergency_access_exclusion` check for M365 provider now scopes the exclusion requirement to enabled Conditional Access policies with a `Block` grant control instead of every enabled policy, focusing on the lockout-relevant policy set [(#10849)](https://github.com/prowler-cloud/prowler/pull/10849)
- AWS IAM customer-managed policy checks no longer emit `FAIL` on unattached policies unless `--scan-unused-services` is enabled [(#11150)](https://github.com/prowler-cloud/prowler/pull/11150)
- Replace `poetry` with `uv` as package manager [(#11162)](https://github.com/prowler-cloud/prowler/pull/11162)
- Replace `safety` with `osv-scanner` for dependency vulnerability scanning in SDK CI and pre-commit [(#11167)](https://github.com/prowler-cloud/prowler/pull/11167)
### 🐞 Fixed
@@ -26,16 +37,9 @@ All notable changes to the **Prowler SDK** are documented in this file.
- `zone_waf_enabled` check for Cloudflare provider now appends a plan-aware hint to the FAIL `status_extended`: a possible-false-positive note on paid plans (Pro, Business, Enterprise) where the legacy `waf` zone setting can read `off` even though WAF managed rulesets are deployed via the dashboard, and a "not available on the Cloudflare Free plan" note on Free zones [(#9896)](https://github.com/prowler-cloud/prowler/pull/9896)
- Google Workspace Gmail checks sharing a single resource row, causing the service field to be overwritten by the last check executed [(#11169)](https://github.com/prowler-cloud/prowler/pull/11169)
- Google Workspace Drive and Calendar services missing server-side policy filters [(#11195)](https://github.com/prowler-cloud/prowler/pull/11195)
- `VercelSession.token` is now excluded from serialization and representation to prevent the Vercel API token from leaking through `.dict()`, `.json()` or logs [(#11198)](https://github.com/prowler-cloud/prowler/pull/11198)
---
## [5.26.2] (Prowler UNRELEASED)
### 🐞 Fixed
- `entra_users_mfa_capable` and `entra_break_glass_account_fido2_security_key_registered` report a preventive FAIL per affected user (with the missing permission named) when the M365 service principal lacks `AuditLog.Read.All`, instead of mass false positives [(#10907)](https://github.com/prowler-cloud/prowler/pull/10907)
- Update duplicated GCP CIS requirements IDs [(#11180)](https://github.com/prowler-cloud/prowler/pull/11180)
- Duplicated GCP CIS requirements IDs [(#11180)](https://github.com/prowler-cloud/prowler/pull/11180)
- `VercelSession.token` is now excluded from serialization and representation to prevent the Vercel API token from leaking through `.dict()`, `.json()` or logs [(#11198)](https://github.com/prowler-cloud/prowler/pull/11198)
---
+1 -1
View File
@@ -48,7 +48,7 @@ class _MutableTimestamp:
timestamp = _MutableTimestamp(datetime.today())
timestamp_utc = _MutableTimestamp(datetime.now(timezone.utc))
prowler_version = "5.27.0"
prowler_version = "5.27.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"
@@ -24,30 +24,30 @@ class s3_bucket_shadow_resource_vulnerability(Check):
# First, check buckets in the current account
for bucket in s3_client.buckets.values():
report = Check_Report_AWS(self.metadata(), resource=bucket)
report.region = bucket.region
report.resource_id = bucket.name
report.resource_arn = bucket.arn
report.resource_tags = bucket.tags
report.status = "PASS"
report.status_extended = (
f"S3 bucket {bucket.name} is not a known shadow resource."
)
# Check if this bucket matches any predictable pattern
# Only emit a finding when the bucket name actually matches one of
# the predictable service patterns. A bucket whose name does not
# match any pattern is, by definition, not a shadow resource, so a
# PASS finding for it would be tautological and add no signal.
for service, pattern_format in predictable_patterns.items():
pattern = pattern_format.replace("<region>", bucket.region)
if re.match(pattern, bucket.name):
report = Check_Report_AWS(self.metadata(), resource=bucket)
report.region = bucket.region
report.resource_id = bucket.name
report.resource_arn = bucket.arn
report.resource_tags = bucket.tags
if bucket.owner_id != s3_client.audited_canonical_id:
report.status = "FAIL"
report.status_extended = f"S3 bucket {bucket.name} for service {service} is a known shadow resource and it is owned by another account ({bucket.owner_id})."
else:
report.status = "PASS"
report.status_extended = f"S3 bucket {bucket.name} for service {service} is a known shadow resource but it is correctly owned by the audited account."
findings.append(report)
reported_buckets.add(bucket.name)
break
findings.append(report)
reported_buckets.add(bucket.name)
# Now check for shadow resources in other accounts by testing predictable patterns
# We'll test different regions to see if shadow resources exist
@@ -10,9 +10,13 @@ class sqlserver_tde_encrypted_with_cmk(Check):
subscription, subscription
)
for sql_server in sql_servers:
databases = (
sql_server.databases if sql_server.databases is not None else []
)
databases = [
database
for database in (
sql_server.databases if sql_server.databases is not None else []
)
if database.name.lower() != "master"
]
if len(databases) > 0:
report = Check_Report_Azure(
metadata=self.metadata(), resource=sql_server
+1 -1
View File
@@ -120,7 +120,7 @@ maintainers = [{name = "Prowler Engineering", email = "engineering@prowler.com"}
name = "prowler"
readme = "README.md"
requires-python = ">=3.10,<3.13"
version = "5.27.0"
version = "5.27.1"
[project.scripts]
prowler = "prowler.__main__:prowler"
+13
View File
@@ -132,6 +132,19 @@ Before editing any `CHANGELOG.md`, always inspect the active release boundary:
**Do not trust the current topmost matching section name.** A released block can contain the same section heading (`### 🚀 Added`, `### 🔄 Changed`, etc.). Always anchor edits to the `Prowler UNRELEASED` version block first.
## Mandatory Human Confirmation Gate
Before creating or editing any changelog file (`CHANGELOG.md`), the agent MUST stop and get explicit user confirmation. This applies even when the changelog gate is failing, the required edit seems obvious, or the user asked to "fix the changelog".
Present the proposed changelog action before writing:
1. Target file path.
2. Target version block and section.
3. Exact entry to add, move, remove, or rewrite.
4. Reason the changelog is needed.
Only proceed after an explicit approval such as "confirm", "approved", "sí", or equivalent. If the user rejects or does not answer, do not edit or create the changelog. Offer alternatives such as adding `no-changelog` when appropriate.
## Adding a Changelog Entry
### Step 1: Determine Affected Component(s)
@@ -93,6 +93,8 @@ class Test_s3_bucket_shadow_resource_vulnerability:
@mock_aws
def test_bucket_not_predictable(self):
# A bucket whose name does not match any predictable service pattern
# is not a shadow resource and must not produce any finding.
aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1])
aws_provider.identity.identity_arn = f"arn:aws:iam::{AWS_ACCOUNT_NUMBER}:root"
@@ -113,6 +115,55 @@ class Test_s3_bucket_shadow_resource_vulnerability:
s3_client.provider = aws_provider
s3_client._head_bucket = mock.MagicMock(return_value=False)
with (
mock.patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=aws_provider,
),
mock.patch(
"prowler.providers.aws.services.s3.s3_bucket_shadow_resource_vulnerability.s3_bucket_shadow_resource_vulnerability.s3_client",
new=s3_client,
),
):
from prowler.providers.aws.services.s3.s3_bucket_shadow_resource_vulnerability.s3_bucket_shadow_resource_vulnerability import (
s3_bucket_shadow_resource_vulnerability,
)
check = s3_bucket_shadow_resource_vulnerability()
result = check.execute()
assert len(result) == 0
@mock_aws
def test_only_predictable_bucket_reported_among_many(self):
# With a mix of buckets, only the one matching a predictable pattern
# must produce a finding; the rest must be silent.
aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1])
aws_provider.identity.identity_arn = f"arn:aws:iam::{AWS_ACCOUNT_NUMBER}:root"
predictable_bucket = f"sagemaker-{AWS_REGION_US_EAST_1}-{AWS_ACCOUNT_NUMBER}"
plain_buckets = [
"config-bucket-data",
"my-app-data-bucket",
"guardduty-findings-store",
]
s3_client = mock.MagicMock()
s3_client.audited_canonical_id = AWS_ACCOUNT_NUMBER
s3_client.audited_partition = "aws"
s3_client.buckets = {
name: Bucket(
name=name,
arn=f"arn:aws:s3:::{name}",
region=AWS_REGION_US_EAST_1,
owner_id=AWS_ACCOUNT_NUMBER,
tags=[],
)
for name in [predictable_bucket, *plain_buckets]
}
s3_client.provider = aws_provider
s3_client._head_bucket = mock.MagicMock(return_value=False)
with (
mock.patch(
"prowler.providers.common.provider.Provider.get_global_provider",
@@ -132,14 +183,10 @@ class Test_s3_bucket_shadow_resource_vulnerability:
assert len(result) == 1
report = result[0]
# Test all report attributes
assert report.status == "PASS"
assert report.region == AWS_REGION_US_EAST_1
assert report.resource_id == bucket_name
assert report.resource_arn == f"arn:aws:s3:::{bucket_name}"
assert report.resource_tags == [{"Key": "Project", "Value": "test-project"}]
assert "is not a known shadow resource" in report.status_extended
assert report.resource_id == predictable_bucket
assert "SageMaker" in report.status_extended
assert "is correctly owned by the audited account" in report.status_extended
@mock_aws
def test_shadow_resource_in_other_account(self):
@@ -264,3 +264,200 @@ class Test_sqlserver_tde_encrypted_with_cmk:
assert result[0].resource_name == sql_server_name
assert result[0].resource_id == sql_server_id
assert result[0].location == "location"
def test_sql_servers_master_database_disabled_user_database_enabled(self):
# System "master" database always reports TDE Disabled in Azure SQL
# and is not customer-controllable. It must not fail a server whose
# user databases are correctly encrypted with CMK (PROWLER-1760).
sqlserver_client = mock.MagicMock
sqlserver_client.subscriptions = {
AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME
}
sql_server_name = "SQL Server Name"
sql_server_id = str(uuid4())
master_database = Database(
id="master_id",
name="master",
type="type",
location="location",
managed_by="managed_by",
tde_encryption=TransparentDataEncryption(status="Disabled"),
)
user_database = Database(
id="user_id",
name="DynamicBudgets_Intacct",
type="type",
location="location",
managed_by="managed_by",
tde_encryption=TransparentDataEncryption(status="Enabled"),
)
sqlserver_client.sql_servers = {
AZURE_SUBSCRIPTION_ID: [
Server(
id=sql_server_id,
name=sql_server_name,
location="location",
public_network_access="",
minimal_tls_version="",
administrators=None,
auditing_policies=None,
firewall_rules=None,
databases=[master_database, user_database],
encryption_protector=EncryptionProtector(
server_key_type="AzureKeyVault"
),
)
]
}
with (
mock.patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=set_mocked_azure_provider(),
),
mock.patch(
"prowler.providers.azure.services.sqlserver.sqlserver_tde_encrypted_with_cmk.sqlserver_tde_encrypted_with_cmk.sqlserver_client",
new=sqlserver_client,
),
):
from prowler.providers.azure.services.sqlserver.sqlserver_tde_encrypted_with_cmk.sqlserver_tde_encrypted_with_cmk import (
sqlserver_tde_encrypted_with_cmk,
)
check = sqlserver_tde_encrypted_with_cmk()
result = check.execute()
assert len(result) == 1
assert result[0].status == "PASS"
assert (
result[0].status_extended
== f"SQL Server {sql_server_name} from subscription {AZURE_SUBSCRIPTION_DISPLAY} has TDE enabled with CMK."
)
assert result[0].subscription == AZURE_SUBSCRIPTION_ID
assert result[0].resource_name == sql_server_name
assert result[0].resource_id == sql_server_id
assert result[0].location == "location"
def test_sql_servers_only_master_database(self):
# A server whose only database is the system "master" has no user
# databases to evaluate, so it must not produce a finding.
sqlserver_client = mock.MagicMock
sqlserver_client.subscriptions = {
AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME
}
sql_server_name = "SQL Server Name"
sql_server_id = str(uuid4())
master_database = Database(
id="master_id",
name="MASTER",
type="type",
location="location",
managed_by="managed_by",
tde_encryption=TransparentDataEncryption(status="Disabled"),
)
sqlserver_client.sql_servers = {
AZURE_SUBSCRIPTION_ID: [
Server(
id=sql_server_id,
name=sql_server_name,
location="location",
public_network_access="",
minimal_tls_version="",
administrators=None,
auditing_policies=None,
firewall_rules=None,
databases=[master_database],
encryption_protector=EncryptionProtector(
server_key_type="AzureKeyVault"
),
)
]
}
with (
mock.patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=set_mocked_azure_provider(),
),
mock.patch(
"prowler.providers.azure.services.sqlserver.sqlserver_tde_encrypted_with_cmk.sqlserver_tde_encrypted_with_cmk.sqlserver_client",
new=sqlserver_client,
),
):
from prowler.providers.azure.services.sqlserver.sqlserver_tde_encrypted_with_cmk.sqlserver_tde_encrypted_with_cmk import (
sqlserver_tde_encrypted_with_cmk,
)
check = sqlserver_tde_encrypted_with_cmk()
result = check.execute()
assert len(result) == 0
def test_sql_servers_master_disabled_user_database_disabled(self):
# Filtering out "master" must not mask a genuinely failing user
# database: a disabled user DB still fails even with CMK.
sqlserver_client = mock.MagicMock
sqlserver_client.subscriptions = {
AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME
}
sql_server_name = "SQL Server Name"
sql_server_id = str(uuid4())
master_database = Database(
id="master_id",
name="master",
type="type",
location="location",
managed_by="managed_by",
tde_encryption=TransparentDataEncryption(status="Disabled"),
)
user_database = Database(
id="user_id",
name="DynamicBudgets_Intacct",
type="type",
location="location",
managed_by="managed_by",
tde_encryption=TransparentDataEncryption(status="Disabled"),
)
sqlserver_client.sql_servers = {
AZURE_SUBSCRIPTION_ID: [
Server(
id=sql_server_id,
name=sql_server_name,
location="location",
public_network_access="",
minimal_tls_version="",
administrators=None,
auditing_policies=None,
firewall_rules=None,
databases=[master_database, user_database],
encryption_protector=EncryptionProtector(
server_key_type="AzureKeyVault"
),
)
]
}
with (
mock.patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=set_mocked_azure_provider(),
),
mock.patch(
"prowler.providers.azure.services.sqlserver.sqlserver_tde_encrypted_with_cmk.sqlserver_tde_encrypted_with_cmk.sqlserver_client",
new=sqlserver_client,
),
):
from prowler.providers.azure.services.sqlserver.sqlserver_tde_encrypted_with_cmk.sqlserver_tde_encrypted_with_cmk import (
sqlserver_tde_encrypted_with_cmk,
)
check = sqlserver_tde_encrypted_with_cmk()
result = check.execute()
assert len(result) == 1
assert result[0].status == "FAIL"
assert (
result[0].status_extended
== f"SQL Server {sql_server_name} from subscription {AZURE_SUBSCRIPTION_DISPLAY} has TDE disabled with CMK."
)
assert result[0].subscription == AZURE_SUBSCRIPTION_ID
assert result[0].resource_name == sql_server_name
assert result[0].resource_id == sql_server_id
assert result[0].location == "location"
+11 -19
View File
@@ -2,18 +2,18 @@
All notable changes to the **Prowler UI** are documented in this file.
## [1.27.0] (Prowler UNRELEASED)
## [1.27.0] (Prowler v5.27.0)
### 🚀 Added
- UI health endpoint at `GET /api/health` for Docker Compose liveness checks [(#11145)](https://github.com/prowler-cloud/prowler/pull/11145)
- AWS findings and resource details now expose a "View in AWS Console" link that opens the resource directly in the AWS Console via the universal `/go/view` ARN resolver. The per-provider external link is rendered by a new shared `ExternalResourceLink` component, which also covers the existing IaC repository link [(#9172)](https://github.com/prowler-cloud/prowler/pull/9172)
- Health endpoint at `GET /api/health` for Docker Compose liveness checks [(#11145)](https://github.com/prowler-cloud/prowler/pull/11145)
- AWS findings and resource details now expose a "View in AWS Console" link that opens the resource directly in the AWS Console via the universal `/go/view` ARN resolver [(#9172)](https://github.com/prowler-cloud/prowler/pull/9172)
- Lighthouse AI: Prowler App Finding Groups MCP tools [(#11140)](https://github.com/prowler-cloud/prowler/pull/11140)
### 🔄 Changed
- Trimmed unused npm dependencies [(#11115)](https://github.com/prowler-cloud/prowler/pull/11115)
- Faster, stricter pre-commit: prek lints and formats only staged UI files (husky removed), with Prettier and ESLint (`--max-warnings 40`, stale-disable detection) now covering the full UI workspace, including `public/` assets (only the auto-generated `public/mockServiceWorker.js` stays ignored) [(#11118)](https://github.com/prowler-cloud/prowler/pull/11118)
- Lighthouse now accepts Prowler App Finding Groups MCP tools [(#11140)](https://github.com/prowler-cloud/prowler/pull/11140)
- Trimmed unused `npm` dependencies [(#11115)](https://github.com/prowler-cloud/prowler/pull/11115)
- Faster, stricter pre-commit: prek lints and formats only staged UI files (husky removed), with Prettier and ESLint (`--max-warnings 40`, stale-disable detection) now covering the full UI workspace, including `public/` assets [(#11118)](https://github.com/prowler-cloud/prowler/pull/11118)
- Attack Paths graph now uses React Flow with improved layout, interactions, export, minimap, and browser test coverage [(#10686)](https://github.com/prowler-cloud/prowler/pull/10686)
- SAML ACS URL is only shown if the email domain is configured [(#11144)](https://github.com/prowler-cloud/prowler/pull/11144)
- "View Resource" action in the finding resource detail drawer is now an icon-only link rendered next to the resource name (instead of a text button in the UID row), keeping the "View in AWS Console" link unchanged [(#11193)](https://github.com/prowler-cloud/prowler/pull/11193)
@@ -21,22 +21,14 @@ All notable changes to the **Prowler UI** are documented in this file.
### 🐞 Fixed
- Mute Findings modal now enforces the 100-character limit on the rule name input with a live counter and inline error, matching the existing reason field behaviour [(#11158)](https://github.com/prowler-cloud/prowler/pull/11158)
- Finding drawer no longer renders literal backticks around inline code in Risk, Description and Remediation sections [(#11142)](https://github.com/prowler-cloud/prowler/pull/11142)
- Launch Scan first-provider wizard continues after provider creation instead of resetting the Scans page [(#11136)](https://github.com/prowler-cloud/prowler/pull/11136)
- Attack Paths graph nodes now wrap long resource and finding labels, indicate truncated values with `…`, and show the full value in an immediate tooltip [(#11197)](https://github.com/prowler-cloud/prowler/pull/11197)
### 🔐 Security
- UI npm dependencies updated to patched versions for Next.js, Vite, LangChain, XML parsing, lodash, and related transitive packages [(#11171)](https://github.com/prowler-cloud/prowler/pull/11171)
---
## [1.26.2] (Prowler 5.26.2)
### 🐞 Fixed
- Finding drawer no longer renders literal backticks around inline code in Risk, Description and Remediation sections [(#11142)](https://github.com/prowler-cloud/prowler/pull/11142)
### 🐞 Fixed
- Launch Scan first-provider wizard continues after provider creation instead of resetting the Scans page [(#11136)](https://github.com/prowler-cloud/prowler/pull/11136)
- `npm` dependencies updated to patched versions for Next.js, Vite, LangChain, XML parsing, lodash, and related transitive packages [(#11173)](https://github.com/prowler-cloud/prowler/pull/11173)
- Hardened `npm` supply chain controls [(#11157)](https://github.com/prowler-cloud/prowler/pull/11157)
---
@@ -1,4 +1,5 @@
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { type NodeProps, Position } from "@xyflow/react";
import { describe, expect, it, vi } from "vitest";
@@ -91,5 +92,26 @@ describe("FindingNode", () => {
expect(screen.getByText("logging")).toBeInTheDocument();
expect(screen.getByText("medium")).toBeInTheDocument();
});
it("should expose the full finding title as an immediate tooltip when truncated", async () => {
// Given
const title =
"Ensure administrator access policies are rotated regularly";
const props = buildNodeProps(buildFindingNode("high", title));
// When
render(<FindingNode {...props} />);
// Then
expect(screen.getByText("Ensure")).toBeInTheDocument();
expect(screen.getByText("administrator")).toBeInTheDocument();
expect(screen.getByText("access policies")).toBeInTheDocument();
expect(screen.getByText("are rotated…")).toBeInTheDocument();
expect(screen.getByText("high")).toBeInTheDocument();
await userEvent.hover(screen.getByTestId("attack-path-finding-node"));
expect(await screen.findAllByText(title)).not.toHaveLength(0);
});
});
});
@@ -2,21 +2,27 @@
import { type NodeProps, Position } from "@xyflow/react";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/shadcn/tooltip";
import type { GraphNode } from "@/types/attack-paths";
import { resolveNodeColors, resolveNodeVisual } from "../../../_lib";
import { FINDING_NODE_DIMENSIONS } from "../../../_lib/node-dimensions";
import { getNodeLabelDisplay } from "../../../_lib/node-label-lines";
import { HiddenHandles } from "./hidden-handles";
import { getNodeLabelLines } from "./node-label-lines";
interface FindingNodeData {
graphNode: GraphNode;
[key: string]: unknown;
}
const NODE_WIDTH = 150;
const NODE_HEIGHT = 112;
const TITLE_MAX_CHARS = 18;
const TITLE_MAX_LINES = 2;
const NODE_WIDTH = FINDING_NODE_DIMENSIONS.WIDTH;
const NODE_HEIGHT = FINDING_NODE_DIMENSIONS.HEIGHT;
const TITLE_MAX_CHARS = FINDING_NODE_DIMENSIONS.LABEL_MAX_CHARS;
const TITLE_MAX_LINES = FINDING_NODE_DIMENSIONS.LABEL_MAX_LINES;
const BADGE_SIZE = 44;
const BADGE_RADIUS = BADGE_SIZE / 2;
const BADGE_CENTER_X = NODE_WIDTH / 2;
@@ -29,7 +35,7 @@ const ICON_Y = BADGE_CENTER_Y - ICON_SIZE / 2;
const TEXT_X = BADGE_CENTER_X;
const TITLE_Y = 66;
const TITLE_LINE_HEIGHT = 13;
const SEVERITY_Y = 94;
const SEVERITY_Y = 118;
const severityLabel = (severity: unknown): string | undefined => {
if (!severity) return undefined;
@@ -59,7 +65,7 @@ export const FindingNode = ({ data, selected }: NodeProps) => {
graphNode.properties?.id ||
"Finding",
);
const displayTitleLines = getNodeLabelLines(
const displayTitle = getNodeLabelDisplay(
title,
TITLE_MAX_CHARS,
TITLE_MAX_LINES,
@@ -72,6 +78,85 @@ export const FindingNode = ({ data, selected }: NodeProps) => {
const badgeStrokeWidth = selected ? 4 : 2.5;
const glowRadius = selected ? 32 : 30;
const glowOpacity = selected ? 0.34 : 0.28;
const nodeSvg = (
<svg
width={NODE_WIDTH}
height={NODE_HEIGHT}
className="overflow-visible"
tabIndex={displayTitle.isTruncated ? 0 : undefined}
data-testid="attack-path-finding-node"
>
<circle
cx={BADGE_CENTER_X}
cy={BADGE_CENTER_Y}
r={glowRadius}
stroke={borderColor}
strokeOpacity={glowOpacity}
strokeWidth={8}
fill={borderColor}
fillOpacity={glowOpacity / 2}
pointerEvents="none"
/>
<circle
cx={BADGE_CENTER_X}
cy={BADGE_CENTER_Y}
r={BADGE_RADIUS}
fill={fillColor}
fillOpacity={0.95}
stroke={borderColor}
strokeWidth={badgeStrokeWidth}
className={selected ? "selected-node" : undefined}
/>
<g
aria-label={iconLabel}
data-testid={toFindingIconTestId(severity)}
role="img"
transform={`translate(${ICON_X}, ${ICON_Y})`}
>
<Icon
aria-hidden="true"
color="#ffffff"
focusable="false"
height={ICON_SIZE}
role="presentation"
size={ICON_SIZE}
strokeWidth={2.4}
width={ICON_SIZE}
/>
</g>
<text
x={TEXT_X}
y={TITLE_Y}
textAnchor="middle"
dominantBaseline="middle"
fill="#ffffff"
style={{ textShadow: "0 1px 2px rgba(0,0,0,0.5)" }}
pointerEvents="none"
>
{displayTitle.lines.map((line, index) => (
<tspan
key={`${line}-${index}`}
x={TEXT_X}
y={TITLE_Y + index * TITLE_LINE_HEIGHT}
fontSize="11px"
fontWeight="600"
>
{line}
</tspan>
))}
{severity && (
<tspan
x={TEXT_X}
y={SEVERITY_Y}
fontSize="9px"
fill="rgba(255,255,255,0.82)"
>
{severity}
</tspan>
)}
</text>
</svg>
);
return (
<>
@@ -81,77 +166,14 @@ export const FindingNode = ({ data, selected }: NodeProps) => {
targetPosition={Position.Left}
targetStyle={{ left: BADGE_LEFT_X, top: BADGE_CENTER_Y }}
/>
<svg width={NODE_WIDTH} height={NODE_HEIGHT} className="overflow-visible">
<circle
cx={BADGE_CENTER_X}
cy={BADGE_CENTER_Y}
r={glowRadius}
stroke={borderColor}
strokeOpacity={glowOpacity}
strokeWidth={8}
fill={borderColor}
fillOpacity={glowOpacity / 2}
pointerEvents="none"
/>
<circle
cx={BADGE_CENTER_X}
cy={BADGE_CENTER_Y}
r={BADGE_RADIUS}
fill={fillColor}
fillOpacity={0.95}
stroke={borderColor}
strokeWidth={badgeStrokeWidth}
className={selected ? "selected-node" : undefined}
/>
<g
aria-label={iconLabel}
data-testid={toFindingIconTestId(severity)}
role="img"
transform={`translate(${ICON_X}, ${ICON_Y})`}
>
<Icon
aria-hidden="true"
color="#ffffff"
focusable="false"
height={ICON_SIZE}
role="presentation"
size={ICON_SIZE}
strokeWidth={2.4}
width={ICON_SIZE}
/>
</g>
<text
x={TEXT_X}
y={TITLE_Y}
textAnchor="middle"
dominantBaseline="middle"
fill="#ffffff"
style={{ textShadow: "0 1px 2px rgba(0,0,0,0.5)" }}
pointerEvents="none"
>
{displayTitleLines.map((line, index) => (
<tspan
key={`${line}-${index}`}
x={TEXT_X}
y={TITLE_Y + index * TITLE_LINE_HEIGHT}
fontSize="11px"
fontWeight="600"
>
{line}
</tspan>
))}
{severity && (
<tspan
x={TEXT_X}
y={SEVERITY_Y}
fontSize="9px"
fill="rgba(255,255,255,0.82)"
>
{severity}
</tspan>
)}
</text>
</svg>
{displayTitle.isTruncated ? (
<Tooltip>
<TooltipTrigger asChild>{nodeSvg}</TooltipTrigger>
<TooltipContent>{title}</TooltipContent>
</Tooltip>
) : (
nodeSvg
)}
</>
);
};
@@ -1,48 +0,0 @@
const splitByMaxChars = (text: string, maxChars: number): string[] => {
const words = text.trim().split(/\s+/).filter(Boolean);
const lines: string[] = [];
let currentLine = "";
for (const word of words) {
if (!currentLine) {
currentLine = word;
continue;
}
const nextLine = `${currentLine} ${word}`;
if (nextLine.length <= maxChars) {
currentLine = nextLine;
continue;
}
lines.push(currentLine);
currentLine = word;
}
if (currentLine) lines.push(currentLine);
return lines;
};
const splitLongToken = (text: string, maxChars: number): string[] => {
const lines: string[] = [];
for (let index = 0; index < text.length; index += maxChars) {
lines.push(text.slice(index, index + maxChars));
}
return lines;
};
export const getNodeLabelLines = (
text: string,
maxChars: number,
maxLines: number,
): string[] => {
if (!text.trim()) return [];
const rawLines = text.includes(" ")
? splitByMaxChars(text, maxChars)
: splitLongToken(text, maxChars);
return rawLines.slice(0, maxLines);
};
@@ -1,4 +1,5 @@
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { type NodeProps, Position } from "@xyflow/react";
import { describe, expect, it, vi } from "vitest";
@@ -84,5 +85,41 @@ describe("ResourceNode", () => {
expect(screen.getByText("main-vpc")).toBeInTheDocument();
expect(screen.getByText("VPC")).toBeInTheDocument();
});
it("should show up to four readable lines for long resource names", () => {
// Given
const props = buildNodeProps(
buildGraphNode("AWSRole", "AWSReservedSSO_AdministratorAccessExtra"),
);
// When
const { container } = render(<ResourceNode {...props} />);
// Then
expect(screen.getByText("AWSReservedSSO_A")).toBeInTheDocument();
expect(screen.getByText("dministratorAcce")).toBeInTheDocument();
expect(screen.getByText("ssExtra")).toBeInTheDocument();
expect(screen.getByText("AWS Role")).toBeInTheDocument();
expect(container.querySelector("title")).toBeNull();
});
it("should expose the full resource name as an immediate tooltip when truncated", async () => {
// Given
const name =
"arn:aws:iam::998057895221:role/OrganizationAccountAccessRole/integration";
const props = buildNodeProps(buildGraphNode("AWSRole", name));
// When
render(<ResourceNode {...props} />);
// Then
expect(screen.getByText("arn:aws:iam::998")).toBeInTheDocument();
expect(screen.getByText("057895221:role/O")).toBeInTheDocument();
expect(screen.getByText("ntAccessRole/in…")).toBeInTheDocument();
await userEvent.hover(screen.getByTestId("attack-path-resource-node"));
expect(await screen.findAllByText(name)).not.toHaveLength(0);
});
});
});
@@ -2,11 +2,17 @@
import { type NodeProps, Position } from "@xyflow/react";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/shadcn/tooltip";
import type { GraphNode } from "@/types/attack-paths";
import { resolveNodeColors, resolveNodeVisual } from "../../../_lib";
import { RESOURCE_NODE_DIMENSIONS } from "../../../_lib/node-dimensions";
import { getNodeLabelDisplay } from "../../../_lib/node-label-lines";
import { HiddenHandles } from "./hidden-handles";
import { getNodeLabelLines } from "./node-label-lines";
interface ResourceNodeData {
graphNode: GraphNode;
@@ -14,10 +20,10 @@ interface ResourceNodeData {
[key: string]: unknown;
}
const NODE_WIDTH = 136;
const NODE_HEIGHT = 112;
const NAME_MAX_CHARS = 16;
const NAME_MAX_LINES = 2;
const NODE_WIDTH = RESOURCE_NODE_DIMENSIONS.WIDTH;
const NODE_HEIGHT = RESOURCE_NODE_DIMENSIONS.HEIGHT;
const NAME_MAX_CHARS = RESOURCE_NODE_DIMENSIONS.LABEL_MAX_CHARS;
const NAME_MAX_LINES = RESOURCE_NODE_DIMENSIONS.LABEL_MAX_LINES;
const BADGE_SIZE = 44;
const BADGE_RADIUS = BADGE_SIZE / 2;
const BADGE_CENTER_X = NODE_WIDTH / 2;
@@ -30,7 +36,7 @@ const ICON_Y = BADGE_CENTER_Y - ICON_SIZE / 2;
const TEXT_X = BADGE_CENTER_X;
const NAME_Y = 66;
const NAME_LINE_HEIGHT = 13;
const TYPE_Y = 94;
const TYPE_Y = 118;
const toIconTestId = (description: string): string =>
`attack-path-node-icon-${description
@@ -52,13 +58,90 @@ export const ResourceNode = ({ data, selected }: NodeProps) => {
const visual = resolveNodeVisual(graphNode);
const Icon = visual.Icon;
const displayNameLines = getNodeLabelLines(
const displayName = getNodeLabelDisplay(
visual.displayName,
NAME_MAX_CHARS,
NAME_MAX_LINES,
);
const typeLabel = visual.description;
const iconLabel = `${visual.description} icon`;
const nodeSvg = (
<svg
width={NODE_WIDTH}
height={NODE_HEIGHT}
className="overflow-visible"
tabIndex={displayName.isTruncated ? 0 : undefined}
data-testid="attack-path-resource-node"
>
{glowRadius > 0 && (
<circle
cx={BADGE_CENTER_X}
cy={BADGE_CENTER_Y}
r={glowRadius}
fill={borderColor}
fillOpacity={glowOpacity}
pointerEvents="none"
/>
)}
<circle
cx={BADGE_CENTER_X}
cy={BADGE_CENTER_Y}
r={BADGE_RADIUS}
fill={fillColor}
fillOpacity={0.92}
stroke={borderColor}
strokeWidth={badgeStrokeWidth}
className={selected ? "selected-node" : undefined}
/>
<g
aria-label={iconLabel}
data-testid={toIconTestId(visual.description)}
role="img"
transform={`translate(${ICON_X}, ${ICON_Y})`}
>
<Icon
aria-hidden="true"
className="rounded-md"
focusable="false"
height={ICON_SIZE}
role="presentation"
size={ICON_SIZE}
width={ICON_SIZE}
/>
</g>
<text
x={TEXT_X}
y={NAME_Y}
textAnchor="middle"
dominantBaseline="middle"
fill="#ffffff"
style={{ textShadow: "0 1px 2px rgba(0,0,0,0.5)" }}
pointerEvents="none"
>
{displayName.lines.map((line, index) => (
<tspan
key={`${line}-${index}`}
x={TEXT_X}
y={NAME_Y + index * NAME_LINE_HEIGHT}
fontSize="11px"
fontWeight="600"
>
{line}
</tspan>
))}
{typeLabel && (
<tspan
x={TEXT_X}
y={TYPE_Y}
fontSize="9px"
fill="rgba(255,255,255,0.8)"
>
{typeLabel}
</tspan>
)}
</text>
</svg>
);
return (
<>
@@ -68,75 +151,14 @@ export const ResourceNode = ({ data, selected }: NodeProps) => {
targetPosition={Position.Left}
targetStyle={{ left: BADGE_LEFT_X, top: BADGE_CENTER_Y }}
/>
<svg width={NODE_WIDTH} height={NODE_HEIGHT} className="overflow-visible">
{glowRadius > 0 && (
<circle
cx={BADGE_CENTER_X}
cy={BADGE_CENTER_Y}
r={glowRadius}
fill={borderColor}
fillOpacity={glowOpacity}
pointerEvents="none"
/>
)}
<circle
cx={BADGE_CENTER_X}
cy={BADGE_CENTER_Y}
r={BADGE_RADIUS}
fill={fillColor}
fillOpacity={0.92}
stroke={borderColor}
strokeWidth={badgeStrokeWidth}
className={selected ? "selected-node" : undefined}
/>
<g
aria-label={iconLabel}
data-testid={toIconTestId(visual.description)}
role="img"
transform={`translate(${ICON_X}, ${ICON_Y})`}
>
<Icon
aria-hidden="true"
className="rounded-md"
focusable="false"
height={ICON_SIZE}
role="presentation"
size={ICON_SIZE}
width={ICON_SIZE}
/>
</g>
<text
x={TEXT_X}
y={NAME_Y}
textAnchor="middle"
dominantBaseline="middle"
fill="#ffffff"
style={{ textShadow: "0 1px 2px rgba(0,0,0,0.5)" }}
pointerEvents="none"
>
{displayNameLines.map((line, index) => (
<tspan
key={`${line}-${index}`}
x={TEXT_X}
y={NAME_Y + index * NAME_LINE_HEIGHT}
fontSize="11px"
fontWeight="600"
>
{line}
</tspan>
))}
{typeLabel && (
<tspan
x={TEXT_X}
y={TYPE_Y}
fontSize="9px"
fill="rgba(255,255,255,0.8)"
>
{typeLabel}
</tspan>
)}
</text>
</svg>
{displayName.isTruncated ? (
<Tooltip>
<TooltipTrigger asChild>{nodeSvg}</TooltipTrigger>
<TooltipContent>{visual.displayName}</TooltipContent>
</Tooltip>
) : (
nodeSvg
)}
</>
);
};
@@ -126,6 +126,50 @@ describe("exportGraphAsPNG", () => {
expect(link?.href).toBe("data:image/png;base64,AAAA");
});
it("renders exported long resource labels with the same wrapping as graph nodes", async () => {
const container = buildContainerWithViewport();
const longLabelGraphData: AttackPathGraphData = {
nodes: [
{
id: "role-1",
labels: ["AWSRole"],
properties: { name: "AWSReservedSSO_AdministratorAccessExtra" },
},
],
};
await exportGraphAsPNG(container, bounds, "graph.png", longLabelGraphData);
const context = vi.mocked(HTMLCanvasElement.prototype.getContext).mock
.results[0]?.value as CanvasRenderingContext2D;
const fillText = vi.mocked(context.fillText);
expect(fillText).toHaveBeenCalledWith(
"AWSReservedSSO_A",
expect.any(Number),
expect.any(Number),
136,
);
expect(fillText).toHaveBeenCalledWith(
"dministratorAcce",
expect.any(Number),
expect.any(Number),
136,
);
expect(fillText).toHaveBeenCalledWith(
"ssExtra",
expect.any(Number),
expect.any(Number),
136,
);
expect(fillText).toHaveBeenCalledWith(
"AWS Role",
expect.any(Number),
expect.any(Number),
136,
);
});
it("re-throws a generic export error when canvas is unavailable", async () => {
const container = buildContainerWithViewport();
vi.spyOn(HTMLCanvasElement.prototype, "getContext").mockReturnValue(null);
@@ -17,6 +17,11 @@ import {
GRAPH_EDGE_COLOR_DARK,
} from "./graph-colors";
import { layoutWithDagre } from "./layout";
import {
FINDING_NODE_DIMENSIONS,
RESOURCE_NODE_DIMENSIONS,
} from "./node-dimensions";
import { getNodeLabelDisplay } from "./node-label-lines";
import { resolveNodeVisual } from "./node-visuals";
interface ExportGraphOptions {
@@ -40,9 +45,7 @@ const BADGE_CENTER_Y = 26;
const GLOW_RADIUS = 30;
const LABEL_Y = 66;
const LABEL_LINE_HEIGHT = 13;
const TYPE_Y = 94;
const RESOURCE_NAME_MAX_CHARS = 18;
const RESOURCE_NAME_MAX_LINES = 2;
const TYPE_Y = 118;
const downloadBlob = (blob: Blob, filename: string) => {
const url = URL.createObjectURL(blob);
@@ -165,33 +168,6 @@ const getResourcesWithFindings = (
return resourcesWithFindings;
};
const getLabelLines = (label: string, maxChars: number, maxLines: number) => {
const words = label.split(/\s+/).filter(Boolean);
const lines: string[] = [];
let current = "";
words.forEach((word) => {
const next = current ? `${current} ${word}` : word;
if (next.length <= maxChars) {
current = next;
return;
}
if (current) lines.push(current);
current = word;
});
if (current) lines.push(current);
if (lines.length === 0) lines.push(label);
const visibleLines = lines.slice(0, maxLines);
if (lines.length > maxLines && visibleLines.length > 0) {
const lastIndex = visibleLines.length - 1;
visibleLines[lastIndex] = truncateLabel(visibleLines[lastIndex], maxChars);
}
return visibleLines;
};
const getFittedLayout = (graphData: AttackPathGraphData) => {
const { rfNodes, rfEdges } = layoutWithDagre(
graphData.nodes,
@@ -464,16 +440,21 @@ const drawNode = (
context.textAlign = "center";
context.textBaseline = "middle";
context.font = "600 11px sans-serif";
getLabelLines(
const dimensions = isFinding
? FINDING_NODE_DIMENSIONS
: RESOURCE_NODE_DIMENSIONS;
const labelMaxWidth = dimensions.WIDTH;
getNodeLabelDisplay(
visual.displayName,
RESOURCE_NAME_MAX_CHARS,
RESOURCE_NAME_MAX_LINES,
).forEach((line, index) => {
dimensions.LABEL_MAX_CHARS,
dimensions.LABEL_MAX_LINES,
).lines.forEach((line, index) => {
context.fillText(
line,
center.x,
center.y + (LABEL_Y - BADGE_CENTER_Y) + index * LABEL_LINE_HEIGHT,
150,
labelMaxWidth,
);
});
@@ -483,7 +464,7 @@ const drawNode = (
typeLabel,
center.x,
center.y + (TYPE_Y - BADGE_CENTER_Y),
150,
labelMaxWidth,
);
};
@@ -40,12 +40,12 @@ describe("layoutWithDagre", () => {
expect(byId.get("finding-1")).toMatchObject({
type: "finding",
width: 150,
height: 112,
height: 124,
});
expect(byId.get("resource-1")).toMatchObject({
type: "resource",
width: 136,
height: 112,
height: 124,
});
expect(byId.get("internet-1")).toMatchObject({
type: "internet",
@@ -8,12 +8,11 @@ import { type Edge, type Node, Position } from "@xyflow/react";
import type { GraphEdge, GraphNode } from "@/types/attack-paths";
// Node dimensions matching the rendered React Flow custom nodes.
const RESOURCE_NODE_WIDTH = 136;
const RESOURCE_NODE_HEIGHT = 112;
const FINDING_NODE_WIDTH = 150;
const FINDING_NODE_HEIGHT = 112;
const INTERNET_DIAMETER = 80; // NODE_HEIGHT * 0.8 * 2
import {
FINDING_NODE_DIMENSIONS,
INTERNET_NODE_DIMENSIONS,
RESOURCE_NODE_DIMENSIONS,
} from "./node-dimensions";
// Container relationships that get reversed for proper hierarchy
const CONTAINER_RELATIONS = new Set([
@@ -49,10 +48,19 @@ const getNodeDimensions = (
type: NodeType,
): { width: number; height: number } => {
if (type === NODE_TYPE.FINDING)
return { width: FINDING_NODE_WIDTH, height: FINDING_NODE_HEIGHT };
return {
width: FINDING_NODE_DIMENSIONS.WIDTH,
height: FINDING_NODE_DIMENSIONS.HEIGHT,
};
if (type === NODE_TYPE.INTERNET)
return { width: INTERNET_DIAMETER, height: INTERNET_DIAMETER };
return { width: RESOURCE_NODE_WIDTH, height: RESOURCE_NODE_HEIGHT };
return {
width: INTERNET_NODE_DIMENSIONS.DIAMETER,
height: INTERNET_NODE_DIMENSIONS.DIAMETER,
};
return {
width: RESOURCE_NODE_DIMENSIONS.WIDTH,
height: RESOURCE_NODE_DIMENSIONS.HEIGHT,
};
};
/**
@@ -0,0 +1,17 @@
export const RESOURCE_NODE_DIMENSIONS = {
WIDTH: 136,
HEIGHT: 124,
LABEL_MAX_CHARS: 16,
LABEL_MAX_LINES: 4,
} as const;
export const FINDING_NODE_DIMENSIONS = {
WIDTH: 150,
HEIGHT: 124,
LABEL_MAX_CHARS: 18,
LABEL_MAX_LINES: 4,
} as const;
export const INTERNET_NODE_DIMENSIONS = {
DIAMETER: 80,
} as const;
@@ -0,0 +1,31 @@
import { describe, expect, it } from "vitest";
import { getNodeLabelDisplay } from "./node-label-lines";
describe("getNodeLabelDisplay", () => {
it("adds an ellipsis within the max width when wrapped label text exceeds the visible line budget", () => {
expect(
getNodeLabelDisplay("AWSReservedSSO_AdministratorAccess", 16, 2).lines,
).toEqual(["AWSReservedSSO_A", "dministratorAcc…"]);
});
it("splits long tokens so unbroken identifiers do not overflow node labels", () => {
expect(
getNodeLabelDisplay("OrganizationAccountAccessRole", 16, 4).lines,
).toEqual(["OrganizationAcco", "untAccessRole"]);
});
it("reports whether the visible label was truncated", () => {
expect(getNodeLabelDisplay("short-name", 16, 4)).toMatchObject({
isTruncated: false,
lines: ["short-name"],
});
expect(
getNodeLabelDisplay(
"arn:aws:iam::998057895221:role/OrganizationAccountAccessRole/integration",
16,
4,
),
).toMatchObject({ isTruncated: true });
});
});
@@ -0,0 +1,64 @@
const splitLongToken = (text: string, maxChars: number): string[] => {
const lines: string[] = [];
for (let index = 0; index < text.length; index += maxChars) {
lines.push(text.slice(index, index + maxChars));
}
return lines;
};
const splitByMaxChars = (text: string, maxChars: number): string[] => {
const words = text.trim().split(/\s+/).filter(Boolean);
const lines: string[] = [];
let currentLine = "";
for (const word of words) {
const wordLines = splitLongToken(word, maxChars);
for (const wordLine of wordLines) {
if (!currentLine) {
currentLine = wordLine;
continue;
}
const nextLine = `${currentLine} ${wordLine}`;
if (nextLine.length <= maxChars) {
currentLine = nextLine;
continue;
}
lines.push(currentLine);
currentLine = wordLine;
}
}
if (currentLine) lines.push(currentLine);
return lines;
};
const withEllipsis = (line: string, maxChars: number): string => {
if (maxChars <= 1) return "…";
return `${line.slice(0, maxChars - 1)}`;
};
export const getNodeLabelDisplay = (
text: string,
maxChars: number,
maxLines: number,
): { lines: string[]; isTruncated: boolean } => {
if (!text.trim()) return { lines: [], isTruncated: false };
const rawLines = splitByMaxChars(text, maxChars);
const isTruncated = rawLines.length > maxLines;
const visibleLines = rawLines.slice(0, maxLines);
if (isTruncated && visibleLines.length > 0) {
visibleLines[visibleLines.length - 1] = withEllipsis(
visibleLines[visibleLines.length - 1],
maxChars,
);
}
return { lines: visibleLines, isTruncated };
};
+32 -12
View File
@@ -3,8 +3,11 @@ import { afterEach, describe, expect, it, vi } from "vitest";
import { GET } from "./route";
interface HealthResponse {
status: "healthy";
service: "prowler-ui";
status: "pass";
version: string;
releaseId: string;
serviceId: "prowler-ui";
description: string;
}
const parseHealthResponse = async (response: Response) =>
@@ -16,12 +19,9 @@ describe("GET /api/health", () => {
vi.unstubAllGlobals();
});
it("should return a healthy response when the Next.js route handler responds", async () => {
it("should return an IETF-shaped healthy response when the Next.js route handler responds", async () => {
// Given
const expectedBody: HealthResponse = {
status: "healthy",
service: "prowler-ui",
};
vi.stubEnv("NEXT_PUBLIC_PROWLER_RELEASE_VERSION", "1.28.0");
// When
const response = await GET();
@@ -29,8 +29,30 @@ describe("GET /api/health", () => {
// Then
expect(response.status).toBe(200);
expect(response.headers.get("Content-Type")).toBe(
"application/health+json",
);
expect(response.headers.get("Cache-Control")).toBe("no-store");
expect(body).toEqual(expectedBody);
expect(body).toEqual({
status: "pass",
version: "1",
releaseId: "1.28.0",
serviceId: "prowler-ui",
description: "Prowler UI",
});
});
it("should fall back to 'unknown' when the release version env var is missing", async () => {
// Given
vi.stubEnv("NEXT_PUBLIC_PROWLER_RELEASE_VERSION", "");
// When
const response = await GET();
const body = await parseHealthResponse(response);
// Then
expect(response.status).toBe(200);
expect(body.releaseId).toBe("unknown");
});
it("should not call fetch while evaluating UI liveness", async () => {
@@ -60,10 +82,8 @@ describe("GET /api/health", () => {
// Then
expect(response.status).toBe(200);
expect(body).toEqual({
status: "healthy",
service: "prowler-ui",
});
expect(body.status).toBe("pass");
expect(body.serviceId).toBe("prowler-ui");
expect(fetchMock).not.toHaveBeenCalled();
});
});
+11 -6
View File
@@ -1,13 +1,18 @@
const healthResponse = {
status: "healthy",
service: "prowler-ui",
} as const;
export const dynamic = "force-dynamic";
export async function GET() {
return Response.json(healthResponse, {
const body = {
status: "pass",
version: "1",
releaseId: process.env.NEXT_PUBLIC_PROWLER_RELEASE_VERSION || "unknown",
serviceId: "prowler-ui",
description: "Prowler UI",
};
return new Response(JSON.stringify(body), {
status: 200,
headers: {
"Content-Type": "application/health+json",
"Cache-Control": "no-store",
},
});
Generated
+1 -1
View File
@@ -3241,7 +3241,7 @@ wheels = [
[[package]]
name = "prowler"
version = "5.27.0"
version = "5.28.0"
source = { editable = "." }
dependencies = [
{ name = "alibabacloud-actiontrail20200706" },