mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-05-20 03:02:43 +00:00
Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a7f4f44e7b | |||
| 2a31bfc3e6 | |||
| 1a4cfd81c5 | |||
| c0559e7f10 | |||
| 706742e6dc | |||
| baaf56ea5e | |||
| cb01769237 | |||
| 4c802620c4 | |||
| 4fa8d5465e | |||
| 31b9619627 | |||
| d4a1bc10e9 | |||
| a1848747a3 |
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>'
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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,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
@@ -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" },
|
||||
|
||||
@@ -211,6 +211,7 @@ services:
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
start_period: 60s
|
||||
|
||||
volumes:
|
||||
outputs:
|
||||
|
||||
@@ -176,6 +176,7 @@ services:
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
start_period: 60s
|
||||
|
||||
volumes:
|
||||
output:
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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"},
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
Generated
+50
@@ -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
@@ -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)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
+13
-13
@@ -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
|
||||
|
||||
+7
-3
@@ -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
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
|
||||
+54
-7
@@ -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):
|
||||
|
||||
+197
@@ -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
@@ -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)
|
||||
|
||||
---
|
||||
|
||||
|
||||
+22
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
+100
-78
@@ -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
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
-48
@@ -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);
|
||||
};
|
||||
+37
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
+98
-76
@@ -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 };
|
||||
};
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user