Compare commits

..

59 Commits

Author SHA1 Message Date
alejandrobailo 11286c0ab4 refactor(ui): simplify providers table status cells 2026-06-17 12:25:42 +02:00
alejandrobailo ad06eed7c9 feat(ui): make table actions column sticky 2026-06-17 12:14:56 +02:00
alejandrobailo edb8c307b8 fix(ui): address scan schedule review feedback 2026-06-16 17:38:43 +02:00
alejandrobailo b9104facb3 feat(ui): align provider scan launch modes 2026-06-16 16:36:25 +02:00
alejandrobailo 06100e9cc8 fix(ui): restore cloud scan scheduling default 2026-06-16 15:30:33 +02:00
alejandrobailo 8dce04ccb8 fix(ui): default cloud provider launches to manual scans 2026-06-16 13:57:19 +02:00
alejandrobailo d05c871713 fix(ui): block provider scan schedules for trial tenants 2026-06-16 10:42:37 +02:00
alejandrobailo a335c1bdc2 fix(ui): align provider wizard footer 2026-06-16 10:42:28 +02:00
alejandrobailo 9502529424 Merge branch 'master' of https://github.com/prowler-cloud/prowler into feat/scan-schedule-ui 2026-06-16 10:22:42 +02:00
dependabot[bot] eeb02453d1 chore(deps): bump pyjwt from 2.12.1 to 2.13.0 in /mcp_server (#11606)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-16 10:14:21 +02:00
Aline Almeida cb4b889b20 fix(gcp): credit audit-filtered aggregated sinks in metric-filter checks (#11575)
Co-authored-by: Hugo P.Brito <hugopbrit@gmail.com>
2026-06-16 10:11:16 +02:00
Pepe Fagoaga f1e42d1681 chore(api-beat): absolute entrypoint (#11604) 2026-06-16 09:44:18 +02:00
Pepe Fagoaga ca7ce5a8c3 feat(jira): request timeout (#11602) 2026-06-16 09:36:22 +02:00
Pepe Fagoaga 810d8d7686 chore(codepipeline): verify if repo is public with TLS (#11603) 2026-06-16 09:35:11 +02:00
Alejandro Bailo dd1895d2c4 test(ui): remove onboarding e2e suite (#11605) 2026-06-16 09:32:37 +02:00
s1ns3nz0 b5bb85c956 feat(azure): add cosmosdb_account_backup_policy_continuous check (#11032)
Co-authored-by: Daniel Barranquero <danielbo2001@gmail.com>
2026-06-15 19:20:38 +02:00
Davidm4r 36fe48dbc5 fix(api): patch dependency and container CVEs (#11596)
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 18:24:55 +02:00
Alejandro Bailo e5bbffd47c fix(ui): exclude onboarding e2e from oss (#11597) 2026-06-15 17:19:40 +02:00
alejandrobailo 397496e7d7 fix(ui): sanitize HTML server action errors 2026-06-15 17:18:45 +02:00
alejandrobailo ef74321e53 fix(ui): show manual scan for onboarding providers 2026-06-15 17:18:39 +02:00
alejandrobailo 01dc7aa8b5 fix(ui): update scan schedule helper copy 2026-06-15 17:18:36 +02:00
Daniel Barranquero 566167489b fix(sdk): patch container CVEs and suppress unfixable bookworm criticals (#11592) 2026-06-15 16:59:44 +02:00
renovate[bot] 3cb360e9ae chore(docker): pin dependencies (#11292)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Pepe Fagoaga <pepe@prowler.com>
2026-06-15 15:31:24 +02:00
Alejandro Bailo 24e3182329 fix(ui): remove onboarding changelog entry (#11593) 2026-06-15 15:22:47 +02:00
Alan Buscaglia 49309b43d3 feat(ui): UI onboarding system (#11430)
Co-authored-by: Pablo F.G <pablo.fernandez@prowler.com>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
Co-authored-by: alejandrobailo <alejandrobailo94@gmail.com>
Co-authored-by: Alejandro Bailo <59607668+alejandrobailo@users.noreply.github.com>
2026-06-15 13:53:48 +02:00
Alejandro Bailo 6db8ce672c fix(ui): patch vulnerable dependencies (#11581)
Co-authored-by: Pepe Fagoaga <pepe@prowler.com>
2026-06-15 11:50:08 +02:00
Pepe Fagoaga 9465b82747 docs(sdk): reflect Python 3.13 support (#11585) 2026-06-15 11:27:09 +02:00
César Arroba 383d2b218f chore: configure vulture to ignore known false positives (#11583) 2026-06-15 11:15:22 +02:00
Branch Vincent dccd674cf9 chore(sdk): support Python 3.13 (#9293)
Co-authored-by: Pepe Fagoaga <pepe@prowler.com>
2026-06-15 10:59:51 +02:00
César Arroba a679865cce ci: always run container and dependency vulnerability scans on PRs (#11582) 2026-06-15 10:38:28 +02:00
César Arroba 15bfa39b23 ci: fail PR checks on critical container image and dependency vulnerabilities (#11580) 2026-06-15 09:57:23 +02:00
alejandrobailo 6ade644f2d fix(ui): validate provider id before building schedule request urls 2026-06-11 10:48:29 +02:00
alejandrobailo 02aea972fa test(ui): adapt provider wizard e2e to the schedule save step 2026-06-11 10:23:06 +02:00
alejandrobailo d636841832 test(ui): align multiselect width test with content sizing 2026-06-10 19:01:44 +02:00
alejandrobailo 870b32ed5d fix(ui): show cadence in browser timezone with next_scan_at fallback 2026-06-10 19:01:44 +02:00
alejandrobailo 5cda01a0df refactor(ui): extract StackedCell primitive for two-line table cells 2026-06-10 19:01:44 +02:00
alejandrobailo f80a56b88c fix(ui): remove redundant date from the scan type cell 2026-06-10 17:49:01 +02:00
alejandrobailo 9a9f0989de feat(ui): add next and last scan columns to the scheduled tab 2026-06-10 17:49:01 +02:00
alejandrobailo 689916132a refactor(ui): extract local time formatting from DateWithTime 2026-06-10 17:49:00 +02:00
alejandrobailo 37a700dd4c style(ui): capitalize the scheduled scan alias 2026-06-10 14:57:45 +02:00
alejandrobailo e38f249cd4 feat(ui): mark pending schedule rows with a tooltip badge 2026-06-10 14:57:45 +02:00
alejandrobailo 51c7e4f0b8 fix(ui): align schedule removal with header and confirm before deleting 2026-06-10 14:57:44 +02:00
alejandrobailo 67e105cfeb fix(ui): hide fabricated id on pending schedule rows 2026-06-10 14:20:23 +02:00
alejandrobailo 48d7e7aa06 fix(ui): use useWatch so schedule fields react under React Compiler 2026-06-10 14:20:23 +02:00
alejandrobailo 91b8f9dcce chore(ui): remove temporary api-debug logging 2026-06-10 14:10:55 +02:00
alejandrobailo 35748cc6b0 feat(ui): schedule scans from the launch scan modal 2026-06-10 14:10:55 +02:00
alejandrobailo 1d80e2dc17 feat(ui): show pending schedules and cadence in scan jobs tabs 2026-06-10 13:55:32 +02:00
alejandrobailo 14551245c4 chore(ui): add temporary api-debug logging to scan actions 2026-06-10 13:55:32 +02:00
alejandrobailo 4bc7a32159 fix(ui): align scan schedule estimates and actions with backend contract
- Compute the INTERVAL next-run estimate from the backend anchor (next
  occurrence of scan_hour) instead of now + 48h
- Preserve a custom scan_interval_hours when editing instead of silently
  rewriting it to 48, with a dynamic frequency label
- Label the MANUAL_ONLY wizard action "Launch scan" instead of "Save"
2026-06-10 10:31:07 +02:00
alejandrobailo 81b636a2e7 feat(ui): add scan-jobs navigation and schedule row actions 2026-06-08 11:14:34 +02:00
alejandrobailo 3c5239a870 feat(ui): compute next scheduled run and lock advanced cadence in OSS 2026-06-08 11:14:30 +02:00
alejandrobailo f74f5eba0f fix(ui): keep filter dropdown on one line and size to content 2026-06-08 11:14:26 +02:00
alejandrobailo 9cf5c30d3e feat(ui): gate scan scheduling by capability for OSS and Cloud 2026-06-05 14:14:31 +02:00
alejandrobailo a8998ad091 chore(ui): add shared isCloud environment helper 2026-06-05 14:14:24 +02:00
alejandrobailo c68b226582 Merge remote-tracking branch 'origin/master' into feat/scan-schedule-ui 2026-06-05 09:48:03 +02:00
alejandrobailo e62ae1cf0a feat(ui): wire scan schedule UI into provider and scan tables 2026-06-04 11:22:03 +02:00
alejandrobailo 21f20fa332 feat(ui): add scan schedule modal and fields components 2026-06-04 11:22:03 +02:00
alejandrobailo 47267f39d0 feat(ui): add scan schedule server actions 2026-06-04 11:22:03 +02:00
alejandrobailo 1559cdf9e8 feat(ui): add scan schedule types and helpers
Schedule domain types and the schedules lib utilities with unit tests.
2026-06-04 11:22:03 +02:00
222 changed files with 16098 additions and 3705 deletions
+3 -3
View File
@@ -1,5 +1,5 @@
name: 'OSV-Scanner'
description: 'Install osv-scanner and scan a lockfile, failing on HIGH/CRITICAL/UNKNOWN severity findings. Posts/updates a PR comment with findings on pull_request events (requires pull-requests: write).'
description: 'Install osv-scanner and scan a lockfile, failing on CRITICAL severity findings. Posts/updates a PR comment with findings on pull_request events (requires pull-requests: write).'
author: 'Prowler'
inputs:
@@ -7,9 +7,9 @@ inputs:
description: 'Path to the lockfile to scan, relative to the repository root (e.g. uv.lock, api/uv.lock, ui/pnpm-lock.yaml).'
required: true
severity-levels:
description: 'Comma-separated severity levels that fail the scan. Default: HIGH,CRITICAL,UNKNOWN.'
description: 'Comma-separated severity levels that fail the scan. Default: CRITICAL.'
required: false
default: 'HIGH,CRITICAL,UNKNOWN'
default: 'CRITICAL'
version:
description: 'osv-scanner release tag to install. When overriding, you MUST also override binary-sha256.'
required: false
+2 -2
View File
@@ -63,7 +63,7 @@ runs:
exit-code: '0'
scanners: 'vuln'
timeout: '5m'
version: 'v0.69.2'
version: 'v0.71.0'
- name: Run Trivy vulnerability scan (SARIF)
if: inputs.upload-sarif == 'true' && github.event_name == 'push'
@@ -76,7 +76,7 @@ runs:
exit-code: '0'
scanners: 'vuln'
timeout: '5m'
version: 'v0.69.2'
version: 'v0.71.0'
- name: Upload Trivy results to GitHub Security tab
if: inputs.upload-sarif == 'true' && github.event_name == 'push'
+2 -3
View File
@@ -6,8 +6,7 @@
# - .github/workflows/api-security.yml, sdk-security.yml, ui-security.yml
#
# Severity levels (comma-separated) are read from OSV_SEVERITY_LEVELS.
# Default: HIGH,CRITICAL,UNKNOWN — preserves prior .safety-policy.yml policy
# (ignore-cvss-severity-below: 7 + ignore-cvss-unknown-severity: False).
# Default: CRITICAL — only CVSS >= 9.0 findings fail the scan.
# osv-scanner has no native CVSS threshold (google/osv-scanner#1400, closed
# not-planned). Severity is derived from $group.max_severity (numeric CVSS
# score string) which osv-scanner emits per group.
@@ -33,7 +32,7 @@ set -euo pipefail
ROOT="$(git rev-parse --show-toplevel)"
CONFIG="${ROOT}/osv-scanner.toml"
SEVERITY_LEVELS="${OSV_SEVERITY_LEVELS:-HIGH,CRITICAL,UNKNOWN}"
SEVERITY_LEVELS="${OSV_SEVERITY_LEVELS:-CRITICAL}"
for bin in osv-scanner jq; do
if ! command -v "${bin}" >/dev/null 2>&1; then
@@ -215,7 +215,7 @@ jobs:
- name: Install regctl
if: always()
uses: regclient/actions/regctl-installer@14f9d37db17b5dc41fefd1ffdd1af4b9e2490560 # main
uses: regclient/actions/regctl-installer@da9319db8e44e8b062b3a147e1dfb2f574d41a03 # main
- name: Cleanup intermediate architecture tags
if: always()
+1 -4
View File
@@ -12,9 +12,6 @@ on:
branches:
- 'master'
- 'v5.*'
paths:
- 'api/**'
- '.github/workflows/api-container-checks.yml'
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
@@ -134,5 +131,5 @@ jobs:
with:
image-name: ${{ env.IMAGE_NAME }}
image-tag: ${{ github.sha }}
fail-on-critical: 'false'
fail-on-critical: 'true'
severity: 'CRITICAL'
-7
View File
@@ -16,13 +16,6 @@ on:
branches:
- "master"
- "v5.*"
paths:
- 'api/**'
- '.github/workflows/api-tests.yml'
- '.github/workflows/api-security.yml'
- '.github/actions/setup-python-uv/**'
- '.github/actions/osv-scanner/**'
- '.github/scripts/osv-scan.sh'
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
+2 -2
View File
@@ -48,7 +48,7 @@ jobs:
services:
postgres:
image: postgres:17@sha256:2203e6282d9e7de7c24d7da234e2a744fb325df366a3fd8ed940e8abbee39527
image: postgres:17@sha256:2cd82735a36356842d5eb1ef80db3ae8f1154172f0f653db48fde079b2a0b7f7
env:
POSTGRES_HOST: ${{ env.POSTGRES_HOST }}
POSTGRES_PORT: ${{ env.POSTGRES_PORT }}
@@ -63,7 +63,7 @@ jobs:
--health-timeout 5s
--health-retries 5
valkey:
image: valkey/valkey:7-alpine3.19@sha256:4054fe7fc607b9326ac7c4691ed26e9670d2ff17a9fb28c2577adecf928acbcc
image: valkey/valkey:7-alpine3.19
env:
VALKEY_HOST: ${{ env.VALKEY_HOST }}
VALKEY_PORT: ${{ env.VALKEY_PORT }}
@@ -206,7 +206,7 @@ jobs:
- name: Install regctl
if: always()
uses: regclient/actions/regctl-installer@14f9d37db17b5dc41fefd1ffdd1af4b9e2490560 # main
uses: regclient/actions/regctl-installer@da9319db8e44e8b062b3a147e1dfb2f574d41a03 # main
- name: Cleanup intermediate architecture tags
if: always()
+1 -4
View File
@@ -12,9 +12,6 @@ on:
branches:
- 'master'
- 'v5.*'
paths:
- 'mcp_server/**'
- '.github/workflows/mcp-container-checks.yml'
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
@@ -127,5 +124,5 @@ jobs:
with:
image-name: ${{ env.IMAGE_NAME }}
image-tag: ${{ github.sha }}
fail-on-critical: 'false'
fail-on-critical: 'true'
severity: 'CRITICAL'
-6
View File
@@ -15,12 +15,6 @@ on:
branches:
- 'master'
- 'v5.*'
paths:
- 'mcp_server/pyproject.toml'
- 'mcp_server/uv.lock'
- '.github/workflows/mcp-security.yml'
- '.github/actions/osv-scanner/**'
- '.github/scripts/osv-scan.sh'
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
+1
View File
@@ -29,6 +29,7 @@ jobs:
- '3.10'
- '3.11'
- '3.12'
- '3.13'
steps:
- name: Harden Runner
@@ -299,7 +299,7 @@ jobs:
- name: Install regctl
if: always()
uses: regclient/actions/regctl-installer@14f9d37db17b5dc41fefd1ffdd1af4b9e2490560 # main
uses: regclient/actions/regctl-installer@da9319db8e44e8b062b3a147e1dfb2f574d41a03 # main
- name: Cleanup intermediate architecture tags
if: always()
+7 -24
View File
@@ -15,12 +15,6 @@ on:
branches:
- 'master'
- 'v5.*'
paths:
- 'prowler/**'
- 'Dockerfile*'
- 'pyproject.toml'
- 'uv.lock'
- '.github/workflows/sdk-container-checks.yml'
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
@@ -111,25 +105,14 @@ jobs:
id: check-changes
uses: tj-actions/changed-files@9426d40962ed5378910ee2e21d5f8c6fcbf2dd96 # v47.0.6
with:
files: ./**
files: |
prowler/**
Dockerfile*
pyproject.toml
uv.lock
.github/workflows/sdk-container-checks.yml
files_ignore: |
.github/**
prowler/CHANGELOG.md
docs/**
permissions/**
api/**
ui/**
dashboard/**
mcp_server/**
skills/**
README.md
mkdocs.yml
.backportrc.json
.env
docker-compose*
examples/**
.gitignore
contrib/**
**/AGENTS.md
- name: Set up Docker Buildx
@@ -153,5 +136,5 @@ jobs:
with:
image-name: ${{ env.IMAGE_NAME }}
image-tag: ${{ github.sha }}
fail-on-critical: 'false'
fail-on-critical: 'true'
severity: 'CRITICAL'
+9 -28
View File
@@ -19,16 +19,6 @@ on:
branches:
- 'master'
- 'v5.*'
paths:
- 'prowler/**'
- 'tests/**'
- 'pyproject.toml'
- 'uv.lock'
- '.github/workflows/sdk-tests.yml'
- '.github/workflows/sdk-security.yml'
- '.github/actions/setup-python-uv/**'
- '.github/actions/osv-scanner/**'
- '.github/scripts/osv-scan.sh'
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
@@ -71,27 +61,18 @@ jobs:
id: check-changes
uses: tj-actions/changed-files@9426d40962ed5378910ee2e21d5f8c6fcbf2dd96 # v47.0.6
with:
files:
./**
files: |
prowler/**
tests/**
pyproject.toml
uv.lock
.github/workflows/sdk-tests.yml
.github/workflows/sdk-security.yml
.github/actions/setup-python-uv/**
.github/actions/osv-scanner/**
.github/scripts/osv-scan.sh
files_ignore: |
.github/**
prowler/CHANGELOG.md
docs/**
permissions/**
api/**
ui/**
dashboard/**
mcp_server/**
skills/**
README.md
mkdocs.yml
.backportrc.json
.env
docker-compose*
examples/**
.gitignore
contrib/**
**/AGENTS.md
- name: Setup Python with uv
+5 -4
View File
@@ -29,6 +29,7 @@ jobs:
- '3.10'
- '3.11'
- '3.12'
- '3.13'
steps:
- name: Harden Runner
@@ -540,7 +541,7 @@ jobs:
with:
flags: prowler-py${{ matrix.python-version }}-vercel
files: ./vercel_coverage.xml
# Scaleway Provider
- name: Check if Scaleway files changed
if: steps.check-changes.outputs.any_changed == 'true'
@@ -588,7 +589,7 @@ jobs:
with:
flags: prowler-py${{ matrix.python-version }}-stackit
files: ./stackit_coverage.xml
# External Provider (dynamic loading)
- name: Check if External Provider files changed
if: steps.check-changes.outputs.any_changed == 'true'
@@ -608,14 +609,14 @@ jobs:
- name: Upload External Provider coverage to Codecov
if: steps.changed-external.outputs.any_changed == 'true'
uses: codecov/codecov-action@671740ac38dd9b0130fbe1cec585b89eea48d3de # v5.5.2
env:
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
with:
flags: prowler-py${{ matrix.python-version }}-external
files: ./external_coverage.xml
# Lib
- name: Check if Lib files changed
if: steps.check-changes.outputs.any_changed == 'true'
+1 -1
View File
@@ -73,7 +73,7 @@ jobs:
- name: Setup Python
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
with:
python-version: '3.12.13'
python-version: '3.12'
- name: Install PyYAML
run: pip install pyyaml
@@ -205,7 +205,7 @@ jobs:
- name: Install regctl
if: always()
uses: regclient/actions/regctl-installer@14f9d37db17b5dc41fefd1ffdd1af4b9e2490560 # main
uses: regclient/actions/regctl-installer@da9319db8e44e8b062b3a147e1dfb2f574d41a03 # main
- name: Cleanup intermediate architecture tags
if: always()
+1 -4
View File
@@ -12,9 +12,6 @@ on:
branches:
- 'master'
- 'v5.*'
paths:
- 'ui/**'
- '.github/workflows/ui-container-checks.yml'
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
@@ -132,5 +129,5 @@ jobs:
with:
image-name: ${{ env.IMAGE_NAME }}
image-tag: ${{ github.sha }}
fail-on-critical: 'false'
fail-on-critical: 'true'
severity: 'CRITICAL'
-6
View File
@@ -15,12 +15,6 @@ on:
branches:
- 'master'
- 'v5.*'
paths:
- 'ui/package.json'
- 'ui/pnpm-lock.yaml'
- '.github/workflows/ui-security.yml'
- '.github/actions/osv-scanner/**'
- '.github/scripts/osv-scan.sh'
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
+4
View File
@@ -131,6 +131,10 @@ jobs:
if: steps.check-changes.outputs.any_changed == 'true'
run: pnpm run healthcheck
- name: Check product-tour alignment
if: steps.check-changes.outputs.any_changed == 'true'
run: pnpm run tour:check
- name: Run pnpm audit
if: steps.check-changes.outputs.any_changed == 'true'
run: pnpm run audit
+85
View File
@@ -0,0 +1,85 @@
# Trivy ignore file for prowlercloud/prowler SDK container image.
# Each entry below documents (a) the affected package and why it ships in the
# image, (b) why the CVE is not exploitable in Prowler's runtime, and (c) the
# upstream fix status. Entries carry an expiry so they auto-force re-review.
# Entries are scoped per-package so suppressions cannot drift onto unrelated
# packages that may be assigned the same CVE in the future.
#
# Scanned by: .github/actions/trivy-scan via .github/workflows/sdk-container-checks.yml
# CVE-2026-42496 — perl-archive-tar path traversal via crafted symlinks.
# CVE-2026-8376 — perl heap buffer overflow when compiling regex.
# Packages: perl, perl-base, perl-modules-5.36, libperl5.36.
# Why ignored: perl-base is part of Debian's "Essential: yes" set; it cannot be
# removed without breaking dpkg. The Prowler SDK does not invoke perl at runtime;
# neither vulnerable code path (Archive::Tar parsing or regex compilation of
# attacker-controlled input) is reachable from Prowler. No Debian bookworm fix
# is available yet.
CVE-2026-42496 pkg:perl exp:2026-07-15
CVE-2026-42496 pkg:perl-base exp:2026-07-15
CVE-2026-42496 pkg:perl-modules-5.36 exp:2026-07-15
CVE-2026-42496 pkg:libperl5.36 exp:2026-07-15
CVE-2026-8376 pkg:perl exp:2026-07-15
CVE-2026-8376 pkg:perl-base exp:2026-07-15
CVE-2026-8376 pkg:perl-modules-5.36 exp:2026-07-15
CVE-2026-8376 pkg:libperl5.36 exp:2026-07-15
# CVE-2025-7458 — SQLite integer overflow.
# Package: libsqlite3-0.
# Why ignored: transitive dependency of CPython's stdlib sqlite3 module. The
# Prowler SDK does not open user-supplied SQLite databases; SQLite usage is
# internal and bounded. No Debian bookworm fix is available.
CVE-2025-7458 pkg:libsqlite3-0 exp:2026-07-15
# CVE-2026-43185 — Linux kernel ksmbd signedness bug.
# Package: linux-libc-dev.
# Why ignored: linux-libc-dev ships kernel headers for build-time compilation,
# not a running kernel. Containers execute against the host kernel, so these
# headers are inert at runtime. The upstream fix landed in kernel 7.0-rc2 and
# has not been backported to Debian's 6.1 LTS line.
CVE-2026-43185 pkg:linux-libc-dev exp:2026-07-15
# CVE-2023-45853 — zlib MiniZip integer overflow / heap overflow in
# zipOpenNewFileInZip4_64.
# Packages: zlib1g, zlib1g-dev.
# Why ignored: Debian Security Tracker status for bookworm is <ignored>, with
# the published rationale "contrib/minizip not built and src:zlib not producing
# binary packages" — i.e. the vulnerable symbol is not present in the libz.so
# shipped by Debian. Real-not-affected, not unpatched. Upstream fix is in
# zlib 1.3.1, available in Debian trixie (13); migrating the base image would
# clear it fully.
# Ref: https://security-tracker.debian.org/tracker/CVE-2023-45853
CVE-2023-45853 pkg:zlib1g exp:2026-07-15
CVE-2023-45853 pkg:zlib1g-dev exp:2026-07-15
# --- API container image (api/Dockerfile) ---
# The entries below are specific to the Prowler API image, which ships
# PowerShell and additional build tooling on top of the same bookworm base.
# CVE-2026-7210 — CPython/Expat hash-flooding denial of service in
# `xml.parsers.expat` and `xml.etree.ElementTree`.
# Packages: the Debian system Python 3.11 (python3.11*, libpython3.11*).
# Why ignored: the API runs under the Python 3.12 interpreter shipped in its
# `.venv`; the system `python3.11` is only present because `python3-dev` is
# pulled in to compile native extensions (xmlsec, lxml) and is never executed
# at runtime. The vulnerable path requires parsing attacker-controlled XML with
# the affected interpreter, which Prowler does not do with the system Python.
# Full mitigation also needs libexpat >= 2.8.0; no Debian bookworm fix yet.
CVE-2026-7210 pkg:python3.11 exp:2026-07-15
CVE-2026-7210 pkg:python3.11-dev exp:2026-07-15
CVE-2026-7210 pkg:python3.11-minimal exp:2026-07-15
CVE-2026-7210 pkg:libpython3.11 exp:2026-07-15
CVE-2026-7210 pkg:libpython3.11-dev exp:2026-07-15
CVE-2026-7210 pkg:libpython3.11-minimal exp:2026-07-15
CVE-2026-7210 pkg:libpython3.11-stdlib exp:2026-07-15
# CVE-2026-33278 — Unbound DNSSEC validator use-after-free (DoS, possible RCE).
# CVE-2026-42960 — Unbound DNS cache poisoning via promiscuous additional records.
# Package: libunbound8.
# Why ignored: libunbound8 is a transitive apt dependency of the TLS/networking
# stack (GnuTLS DANE support); only the shared library ships in the image. Both
# vulnerabilities require operating a live Unbound recursive DNSSEC validator
# that processes attacker-influenced DNS responses. Prowler never starts an
# Unbound resolver, so neither code path is reachable. No Debian bookworm fix yet.
CVE-2026-33278 pkg:libunbound8 exp:2026-07-15
CVE-2026-42960 pkg:libunbound8 exp:2026-07-15
+6
View File
@@ -51,6 +51,7 @@ Use these skills for detailed patterns on-demand:
| `django-migration-psql` | Django migration best practices for PostgreSQL | [SKILL.md](skills/django-migration-psql/SKILL.md) |
| `postgresql-indexing` | PostgreSQL indexing, EXPLAIN, monitoring, maintenance | [SKILL.md](skills/postgresql-indexing/SKILL.md) |
| `prowler-attack-paths-query` | Create Attack Paths openCypher queries | [SKILL.md](skills/prowler-attack-paths-query/SKILL.md) |
| `prowler-tour` | Keep product-tour definitions aligned with the UI | [SKILL.md](skills/prowler-tour/SKILL.md) |
| `gh-aw` | GitHub Agentic Workflows (gh-aw) | [SKILL.md](skills/gh-aw/SKILL.md) |
| `skill-creator` | Create new AI agent skills | [SKILL.md](skills/skill-creator/SKILL.md) |
@@ -67,10 +68,12 @@ When performing these actions, ALWAYS invoke the corresponding skill FIRST:
| Adding new providers | `prowler-provider` |
| Adding privilege escalation detection queries | `prowler-attack-paths-query` |
| Adding services to existing providers | `prowler-provider` |
| Adding, updating, or removing a tour definition (*.tour.ts) | `prowler-tour` |
| After creating/modifying a skill | `skill-sync` |
| App Router / Server Actions | `nextjs-16` |
| Auditing check-to-requirement mappings as a cloud auditor | `prowler-compliance` |
| Building AI chat features | `ai-sdk-5` |
| Changing button labels or section headings on a tour-covered page | `prowler-tour` |
| Committing changes | `prowler-commit` |
| Configuring MCP servers in agentic workflows | `gh-aw` |
| Create PR that requires changelog entry | `prowler-changelog` |
@@ -89,6 +92,7 @@ When performing these actions, ALWAYS invoke the corresponding skill FIRST:
| Creating/updating compliance frameworks | `prowler-compliance` |
| Debug why a GitHub Actions job is failing | `prowler-ci` |
| Debugging gh-aw compilation errors | `gh-aw` |
| Editing a UI file containing data-tour-id attributes | `prowler-tour` |
| Fill .github/pull_request_template.md (Context/Description/Steps to review/Checklist) | `prowler-pr` |
| Fixing bug | `tdd` |
| Fixing compliance JSON bugs (duplicate IDs, empty Section, stale refs) | `prowler-compliance` |
@@ -105,6 +109,8 @@ When performing these actions, ALWAYS invoke the corresponding skill FIRST:
| Modifying gh-aw workflow frontmatter or safe-outputs | `gh-aw` |
| Refactoring code | `tdd` |
| Regenerate AGENTS.md Auto-invoke tables (sync.sh) | `skill-sync` |
| Renaming or removing a data-tour-id attribute value | `prowler-tour` |
| Restructuring routes or layouts covered by a tour | `prowler-tour` |
| Review PR requirements: template, title conventions, changelog gate | `prowler-pr` |
| Review changelog format and conventions | `prowler-changelog` |
| Reviewing JSON:API compliance | `jsonapi` |
+2 -2
View File
@@ -1,4 +1,4 @@
FROM python:3.12.11-slim-bookworm@sha256:519591d6871b7bc437060736b9f7456b8731f1499a57e22e6c285135ae657bf7 AS build
FROM python:3.12.13-slim-bookworm@sha256:76d4b7b6305788c6b4c6a19d6a22a3921bf802e9af4d5e1e5bd771208dba74bf AS build
LABEL maintainer="https://github.com/prowler-cloud/prowler"
LABEL org.opencontainers.image.source="https://github.com/prowler-cloud/prowler"
@@ -6,7 +6,7 @@ LABEL org.opencontainers.image.source="https://github.com/prowler-cloud/prowler"
ARG POWERSHELL_VERSION=7.5.0
ENV POWERSHELL_VERSION=${POWERSHELL_VERSION}
ARG TRIVY_VERSION=0.70.0
ARG TRIVY_VERSION=0.71.0
ENV TRIVY_VERSION=${TRIVY_VERSION}
ARG ZIZMOR_VERSION=1.24.1
+10
View File
@@ -2,6 +2,16 @@
All notable changes to the **Prowler API** are documented in this file.
## [1.32.0] (Prowler UNRELEASED)
### 🔐 Security
- `aiohttp` to 3.14.0 and `idna` to 3.15, patching known CVEs [(#11596)](https://github.com/prowler-cloud/prowler/pull/11596)
- Container base image to `python:3.12.13-slim-bookworm` and `trivy` to 0.71.0, patching OS and Go module CVEs [(#11596)](https://github.com/prowler-cloud/prowler/pull/11596)
- `trivy` binary bumped to 0.71.0 patching embedded `golang.org/x/crypto`, `golang.org/x/net`, and Go `stdlib` CVEs [(#11592)](https://github.com/prowler-cloud/prowler/pull/11592)
---
## [1.31.1] (Prowler v5.30.1)
### 🐞 Fixed
+2 -2
View File
@@ -1,11 +1,11 @@
FROM python:3.12.10-slim-bookworm@sha256:fd95fa221297a88e1cf49c55ec1828edd7c5a428187e67b5d1805692d11588db AS build
FROM python:3.12.13-slim-bookworm@sha256:76d4b7b6305788c6b4c6a19d6a22a3921bf802e9af4d5e1e5bd771208dba74bf AS build
LABEL maintainer="https://github.com/prowler-cloud/api"
ARG POWERSHELL_VERSION=7.5.0
ENV POWERSHELL_VERSION=${POWERSHELL_VERSION}
ARG TRIVY_VERSION=0.70.0
ARG TRIVY_VERSION=0.71.0
ENV TRIVY_VERSION=${TRIVY_VERSION}
ARG ZIZMOR_VERSION=1.24.1
+5 -5
View File
@@ -79,7 +79,7 @@ constraint-dependencies = [
"aiobotocore==2.25.1",
"aiofiles==24.1.0",
"aiohappyeyeballs==2.6.1",
"aiohttp==3.13.5",
"aiohttp==3.14.0",
"aioitertools==0.13.0",
"aiosignal==1.4.0",
"alibabacloud-actiontrail20200706==2.4.1",
@@ -124,7 +124,7 @@ constraint-dependencies = [
"astroid==3.2.4",
"async-timeout==5.0.1",
"attrs==25.4.0",
"authlib==1.6.9",
"authlib==1.6.12",
"autopep8==2.3.2",
"awsipranges==0.3.3",
"azure-cli-core==2.83.0",
@@ -263,7 +263,7 @@ constraint-dependencies = [
"humanfriendly==10.0",
"hyperframe==6.1.0",
"iamdata==0.1.202602021",
"idna==3.11",
"idna==3.15",
"importlib-metadata==8.7.1",
"inflection==0.5.1",
"iniconfig==2.3.0",
@@ -315,7 +315,7 @@ constraint-dependencies = [
"neo4j==6.1.0",
"nest-asyncio==1.6.0",
"nltk==3.9.4",
"numpy==2.0.2",
"numpy==2.2.6",
"oauthlib==3.3.1",
"oci==2.169.0",
"openai==1.109.1",
@@ -344,7 +344,7 @@ constraint-dependencies = [
"psutil==7.2.2",
"psycopg2-binary==2.9.9",
"py-deviceid==0.1.1",
"py-iam-expand==0.1.0",
"py-iam-expand==0.3.0",
"py-ocsf-models==0.8.1",
"pyasn1==0.6.3",
"pyasn1-modules==0.4.2",
Generated
+74 -81
View File
@@ -16,7 +16,7 @@ constraints = [
{ name = "aiobotocore", specifier = "==2.25.1" },
{ name = "aiofiles", specifier = "==24.1.0" },
{ name = "aiohappyeyeballs", specifier = "==2.6.1" },
{ name = "aiohttp", specifier = "==3.13.5" },
{ name = "aiohttp", specifier = "==3.14.0" },
{ name = "aioitertools", specifier = "==0.13.0" },
{ name = "aiosignal", specifier = "==1.4.0" },
{ name = "alibabacloud-actiontrail20200706", specifier = "==2.4.1" },
@@ -61,7 +61,7 @@ constraints = [
{ name = "astroid", specifier = "==3.2.4" },
{ name = "async-timeout", specifier = "==5.0.1" },
{ name = "attrs", specifier = "==25.4.0" },
{ name = "authlib", specifier = "==1.6.9" },
{ name = "authlib", specifier = "==1.6.12" },
{ name = "autopep8", specifier = "==2.3.2" },
{ name = "awsipranges", specifier = "==0.3.3" },
{ name = "azure-cli-core", specifier = "==2.83.0" },
@@ -200,7 +200,7 @@ constraints = [
{ name = "humanfriendly", specifier = "==10.0" },
{ name = "hyperframe", specifier = "==6.1.0" },
{ name = "iamdata", specifier = "==0.1.202602021" },
{ name = "idna", specifier = "==3.11" },
{ name = "idna", specifier = "==3.15" },
{ name = "importlib-metadata", specifier = "==8.7.1" },
{ name = "inflection", specifier = "==0.5.1" },
{ name = "iniconfig", specifier = "==2.3.0" },
@@ -252,7 +252,7 @@ constraints = [
{ name = "neo4j", specifier = "==6.1.0" },
{ name = "nest-asyncio", specifier = "==1.6.0" },
{ name = "nltk", specifier = "==3.9.4" },
{ name = "numpy", specifier = "==2.0.2" },
{ name = "numpy", specifier = "==2.2.6" },
{ name = "oauthlib", specifier = "==3.3.1" },
{ name = "oci", specifier = "==2.169.0" },
{ name = "openai", specifier = "==1.109.1" },
@@ -281,7 +281,7 @@ constraints = [
{ name = "psutil", specifier = "==7.2.2" },
{ name = "psycopg2-binary", specifier = "==2.9.9" },
{ name = "py-deviceid", specifier = "==0.1.1" },
{ name = "py-iam-expand", specifier = "==0.1.0" },
{ name = "py-iam-expand", specifier = "==0.3.0" },
{ name = "py-ocsf-models", specifier = "==0.8.1" },
{ name = "pyasn1", specifier = "==0.6.3" },
{ name = "pyasn1-modules", specifier = "==0.4.2" },
@@ -469,7 +469,7 @@ wheels = [
[[package]]
name = "aiohttp"
version = "3.13.5"
version = "3.14.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "aiohappyeyeballs" },
@@ -478,44 +478,47 @@ dependencies = [
{ name = "frozenlist" },
{ name = "multidict" },
{ name = "propcache" },
{ name = "typing-extensions" },
{ name = "yarl" },
]
sdist = { url = "https://files.pythonhosted.org/packages/77/9a/152096d4808df8e4268befa55fba462f440f14beab85e8ad9bf990516918/aiohttp-3.13.5.tar.gz", hash = "sha256:9d98cc980ecc96be6eb4c1994ce35d28d8b1f5e5208a23b421187d1209dbb7d1", size = 7858271, upload-time = "2026-03-31T22:01:03.343Z" }
sdist = { url = "https://files.pythonhosted.org/packages/ee/ab/93ce242f899b68c51b0578c027aafa791ab3614cb9345fa5d37b5f5c8e3e/aiohttp-3.14.0.tar.gz", hash = "sha256:2882de819734c715fd1b9c11c97e09fa020d14438203d1d354d8ed1702791c9b", size = 7940674, upload-time = "2026-06-01T19:41:02.763Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d6/f5/a20c4ac64aeaef1679e25c9983573618ff765d7aa829fa2b84ae7573169e/aiohttp-3.13.5-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:7ab7229b6f9b5c1ba4910d6c41a9eb11f543eadb3f384df1b4c293f4e73d44d6", size = 757513, upload-time = "2026-03-31T21:57:02.146Z" },
{ url = "https://files.pythonhosted.org/packages/75/0a/39fa6c6b179b53fcb3e4b3d2b6d6cad0180854eda17060c7218540102bef/aiohttp-3.13.5-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:8f14c50708bb156b3a3ca7230b3d820199d56a48e3af76fa21c2d6087190fe3d", size = 506748, upload-time = "2026-03-31T21:57:04.275Z" },
{ url = "https://files.pythonhosted.org/packages/87/ec/e38ce072e724fd7add6243613f8d1810da084f54175353d25ccf9f9c7e5a/aiohttp-3.13.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e7d2f8616f0ff60bd332022279011776c3ac0faa0f1b463f7bb12326fbc97a1c", size = 501673, upload-time = "2026-03-31T21:57:06.208Z" },
{ url = "https://files.pythonhosted.org/packages/ba/ba/3bc7525d7e2beaa11b309a70d48b0d3cfc3c2089ec6a7d0820d59c657053/aiohttp-3.13.5-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a2567b72e1ffc3ab25510db43f355b29eeada56c0a622e58dcdb19530eb0a3cb", size = 1763757, upload-time = "2026-03-31T21:57:07.882Z" },
{ url = "https://files.pythonhosted.org/packages/5e/ab/e87744cf18f1bd78263aba24924d4953b41086bd3a31d22452378e9028a0/aiohttp-3.13.5-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:fb0540c854ac9c0c5ad495908fdfd3e332d553ec731698c0e29b1877ba0d2ec6", size = 1720152, upload-time = "2026-03-31T21:57:09.946Z" },
{ url = "https://files.pythonhosted.org/packages/6b/f3/ed17a6f2d742af17b50bae2d152315ed1b164b07a5fd5cc1754d99e4dfa5/aiohttp-3.13.5-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c9883051c6972f58bfc4ebb2116345ee2aa151178e99c3f2b2bbe2af712abd13", size = 1818010, upload-time = "2026-03-31T21:57:12.157Z" },
{ url = "https://files.pythonhosted.org/packages/53/06/ecbc63dc937192e2a5cb46df4d3edb21deb8225535818802f210a6ea5816/aiohttp-3.13.5-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2294172ce08a82fb7c7273485895de1fa1186cc8294cfeb6aef4af42ad261174", size = 1907251, upload-time = "2026-03-31T21:57:14.023Z" },
{ url = "https://files.pythonhosted.org/packages/7e/a5/0521aa32c1ddf3aa1e71dcc466be0b7db2771907a13f18cddaa45967d97b/aiohttp-3.13.5-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3a807cabd5115fb55af198b98178997a5e0e57dead43eb74a93d9c07d6d4a7dc", size = 1759969, upload-time = "2026-03-31T21:57:16.146Z" },
{ url = "https://files.pythonhosted.org/packages/f6/78/a38f8c9105199dd3b9706745865a8a59d0041b6be0ca0cc4b2ccf1bab374/aiohttp-3.13.5-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:aa6d0d932e0f39c02b80744273cd5c388a2d9bc07760a03164f229c8e02662f6", size = 1616871, upload-time = "2026-03-31T21:57:17.856Z" },
{ url = "https://files.pythonhosted.org/packages/6f/41/27392a61ead8ab38072105c71aa44ff891e71653fe53d576a7067da2b4e8/aiohttp-3.13.5-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:60869c7ac4aaabe7110f26499f3e6e5696eae98144735b12a9c3d9eae2b51a49", size = 1739844, upload-time = "2026-03-31T21:57:19.679Z" },
{ url = "https://files.pythonhosted.org/packages/6e/55/5564e7ae26d94f3214250009a0b1c65a0c6af4bf88924ccb6fdab901de28/aiohttp-3.13.5-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:26d2f8546f1dfa75efa50c3488215a903c0168d253b75fba4210f57ab77a0fb8", size = 1731969, upload-time = "2026-03-31T21:57:22.006Z" },
{ url = "https://files.pythonhosted.org/packages/6d/c5/705a3929149865fc941bcbdd1047b238e4a72bcb215a9b16b9d7a2e8d992/aiohttp-3.13.5-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:f1162a1492032c82f14271e831c8f4b49f2b6078f4f5fc74de2c912fa225d51d", size = 1795193, upload-time = "2026-03-31T21:57:24.256Z" },
{ url = "https://files.pythonhosted.org/packages/a6/19/edabed62f718d02cff7231ca0db4ef1c72504235bc467f7b67adb1679f48/aiohttp-3.13.5-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:8b14eb3262fad0dc2f89c1a43b13727e709504972186ff6a99a3ecaa77102b6c", size = 1606477, upload-time = "2026-03-31T21:57:26.364Z" },
{ url = "https://files.pythonhosted.org/packages/de/fc/76f80ef008675637d88d0b21584596dc27410a990b0918cb1e5776545b5b/aiohttp-3.13.5-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:ca9ac61ac6db4eb6c2a0cd1d0f7e1357647b638ccc92f7e9d8d133e71ed3c6ac", size = 1813198, upload-time = "2026-03-31T21:57:28.316Z" },
{ url = "https://files.pythonhosted.org/packages/e5/67/5b3ac26b80adb20ea541c487f73730dc8fa107d632c998f25bbbab98fcda/aiohttp-3.13.5-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:7996023b2ed59489ae4762256c8516df9820f751cf2c5da8ed2fb20ee50abab3", size = 1752321, upload-time = "2026-03-31T21:57:30.549Z" },
{ url = "https://files.pythonhosted.org/packages/88/06/e4a2e49255ea23fa4feeb5ab092d90240d927c15e47b5b5c48dff5a9ce29/aiohttp-3.13.5-cp311-cp311-win32.whl", hash = "sha256:77dfa48c9f8013271011e51c00f8ada19851f013cde2c48fca1ba5e0caf5bb06", size = 439069, upload-time = "2026-03-31T21:57:32.388Z" },
{ url = "https://files.pythonhosted.org/packages/c0/43/8c7163a596dab4f8be12c190cf467a1e07e4734cf90eebb39f7f5d53fc6a/aiohttp-3.13.5-cp311-cp311-win_amd64.whl", hash = "sha256:d3a4834f221061624b8887090637db9ad4f61752001eae37d56c52fddade2dc8", size = 462859, upload-time = "2026-03-31T21:57:34.455Z" },
{ url = "https://files.pythonhosted.org/packages/be/6f/353954c29e7dcce7cf00280a02c75f30e133c00793c7a2ed3776d7b2f426/aiohttp-3.13.5-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:023ecba036ddd840b0b19bf195bfae970083fd7024ce1ac22e9bba90464620e9", size = 748876, upload-time = "2026-03-31T21:57:36.319Z" },
{ url = "https://files.pythonhosted.org/packages/f5/1b/428a7c64687b3b2e9cd293186695affc0e1e54a445d0361743b231f11066/aiohttp-3.13.5-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:15c933ad7920b7d9a20de151efcd05a6e38302cbf0e10c9b2acb9a42210a2416", size = 499557, upload-time = "2026-03-31T21:57:38.236Z" },
{ url = "https://files.pythonhosted.org/packages/29/47/7be41556bfbb6917069d6a6634bb7dd5e163ba445b783a90d40f5ac7e3a7/aiohttp-3.13.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ab2899f9fa2f9f741896ebb6fa07c4c883bfa5c7f2ddd8cf2aafa86fa981b2d2", size = 500258, upload-time = "2026-03-31T21:57:39.923Z" },
{ url = "https://files.pythonhosted.org/packages/67/84/c9ecc5828cb0b3695856c07c0a6817a99d51e2473400f705275a2b3d9239/aiohttp-3.13.5-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a60eaa2d440cd4707696b52e40ed3e2b0f73f65be07fd0ef23b6b539c9c0b0b4", size = 1749199, upload-time = "2026-03-31T21:57:41.938Z" },
{ url = "https://files.pythonhosted.org/packages/f0/d3/3c6d610e66b495657622edb6ae7c7fd31b2e9086b4ec50b47897ad6042a9/aiohttp-3.13.5-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:55b3bdd3292283295774ab585160c4004f4f2f203946997f49aac032c84649e9", size = 1721013, upload-time = "2026-03-31T21:57:43.904Z" },
{ url = "https://files.pythonhosted.org/packages/49/a0/24409c12217456df0bae7babe3b014e460b0b38a8e60753d6cb339f6556d/aiohttp-3.13.5-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c2b2355dc094e5f7d45a7bb262fe7207aa0460b37a0d87027dcf21b5d890e7d5", size = 1781501, upload-time = "2026-03-31T21:57:46.285Z" },
{ url = "https://files.pythonhosted.org/packages/98/9d/b65ec649adc5bccc008b0957a9a9c691070aeac4e41cea18559fef49958b/aiohttp-3.13.5-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b38765950832f7d728297689ad78f5f2cf79ff82487131c4d26fe6ceecdc5f8e", size = 1878981, upload-time = "2026-03-31T21:57:48.734Z" },
{ url = "https://files.pythonhosted.org/packages/57/d8/8d44036d7eb7b6a8ec4c5494ea0c8c8b94fbc0ed3991c1a7adf230df03bf/aiohttp-3.13.5-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b18f31b80d5a33661e08c89e202edabf1986e9b49c42b4504371daeaa11b47c1", size = 1767934, upload-time = "2026-03-31T21:57:51.171Z" },
{ url = "https://files.pythonhosted.org/packages/31/04/d3f8211f273356f158e3464e9e45484d3fb8c4ce5eb2f6fe9405c3273983/aiohttp-3.13.5-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:33add2463dde55c4f2d9635c6ab33ce154e5ecf322bd26d09af95c5f81cfa286", size = 1566671, upload-time = "2026-03-31T21:57:53.326Z" },
{ url = "https://files.pythonhosted.org/packages/41/db/073e4ebe00b78e2dfcacff734291651729a62953b48933d765dc513bf798/aiohttp-3.13.5-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:327cc432fdf1356fb4fbc6fe833ad4e9f6aacb71a8acaa5f1855e4b25910e4a9", size = 1705219, upload-time = "2026-03-31T21:57:55.385Z" },
{ url = "https://files.pythonhosted.org/packages/48/45/7dfba71a2f9fd97b15c95c06819de7eb38113d2cdb6319669195a7d64270/aiohttp-3.13.5-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:7c35b0bf0b48a70b4cb4fc5d7bed9b932532728e124874355de1a0af8ec4bc88", size = 1743049, upload-time = "2026-03-31T21:57:57.341Z" },
{ url = "https://files.pythonhosted.org/packages/18/71/901db0061e0f717d226386a7f471bb59b19566f2cae5f0d93874b017271f/aiohttp-3.13.5-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:df23d57718f24badef8656c49743e11a89fd6f5358fa8a7b96e728fda2abf7d3", size = 1749557, upload-time = "2026-03-31T21:57:59.626Z" },
{ url = "https://files.pythonhosted.org/packages/08/d5/41eebd16066e59cd43728fe74bce953d7402f2b4ddfdfef2c0e9f17ca274/aiohttp-3.13.5-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:02e048037a6501a5ec1f6fc9736135aec6eb8a004ce48838cb951c515f32c80b", size = 1558931, upload-time = "2026-03-31T21:58:01.972Z" },
{ url = "https://files.pythonhosted.org/packages/30/e6/4a799798bf05740e66c3a1161079bda7a3dd8e22ca392481d7a7f9af82a6/aiohttp-3.13.5-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:31cebae8b26f8a615d2b546fee45d5ffb76852ae6450e2a03f42c9102260d6fe", size = 1774125, upload-time = "2026-03-31T21:58:04.007Z" },
{ url = "https://files.pythonhosted.org/packages/84/63/7749337c90f92bc2cb18f9560d67aa6258c7060d1397d21529b8004fcf6f/aiohttp-3.13.5-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:888e78eb5ca55a615d285c3c09a7a91b42e9dd6fc699b166ebd5dee87c9ccf14", size = 1732427, upload-time = "2026-03-31T21:58:06.337Z" },
{ url = "https://files.pythonhosted.org/packages/98/de/cf2f44ff98d307e72fb97d5f5bbae3bfcb442f0ea9790c0bf5c5c2331404/aiohttp-3.13.5-cp312-cp312-win32.whl", hash = "sha256:8bd3ec6376e68a41f9f95f5ed170e2fcf22d4eb27a1f8cb361d0508f6e0557f3", size = 433534, upload-time = "2026-03-31T21:58:08.712Z" },
{ url = "https://files.pythonhosted.org/packages/aa/ca/eadf6f9c8fa5e31d40993e3db153fb5ed0b11008ad5d9de98a95045bed84/aiohttp-3.13.5-cp312-cp312-win_amd64.whl", hash = "sha256:110e448e02c729bcebb18c60b9214a87ba33bac4a9fa5e9a5f139938b56c6cb1", size = 460446, upload-time = "2026-03-31T21:58:10.945Z" },
{ url = "https://files.pythonhosted.org/packages/67/47/7727bfe8db93f8835a001bd4359d8480cc68d1259b8bce334668f8be97bd/aiohttp-3.14.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:54bf3522d6f7351e55f89a62d5c2bf138ad557b031670266c5df604ae88e0b5a", size = 759147, upload-time = "2026-06-01T19:37:12.918Z" },
{ url = "https://files.pythonhosted.org/packages/eb/f2/cd3fedff6fade73d71df9ec908c210cec518ef90fd00289250684b90aecf/aiohttp-3.14.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:0746d9fb0ac4fdef643a84494efe3f06d50335dd8c7a530228b86448aae0a803", size = 513705, upload-time = "2026-06-01T19:37:14.633Z" },
{ url = "https://files.pythonhosted.org/packages/5a/fe/49746b6b610144a06323bebd8e1211a390310d8c69b98dd6d52df341bc3e/aiohttp-3.14.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:9f3a96b6d39a4872222beee72e1df41d2ff886ae96152cf3e757ef8c5673ef0e", size = 509627, upload-time = "2026-06-01T19:37:16.385Z" },
{ url = "https://files.pythonhosted.org/packages/4c/3f/28f2f6cf3d5c0e7b01b27140d0e7873fd11fb341169ad3ce78ad04aba628/aiohttp-3.14.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d336820adbb914debbc90a1d8c1bfc4bea55996aecf64866a989d35d1f9fd903", size = 1769293, upload-time = "2026-06-01T19:37:18.067Z" },
{ url = "https://files.pythonhosted.org/packages/97/6f/2e5f1b525d5474b12b3c60abf733a755845f3bceff21542081ada515f837/aiohttp-3.14.0-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:71b2604c9bfc1b115547d63a094d5244b3f02799833513a99a68aaa7b167c4cb", size = 1732363, upload-time = "2026-06-01T19:37:20.138Z" },
{ url = "https://files.pythonhosted.org/packages/a8/ce/596120faa85ca7b19cd061e3f2f3be23aa8f11a0aedf9191db9e0da1bd76/aiohttp-3.14.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:610d68800435903e303ca0542b9d3e4eb72a12ff33a6d471a070c1d81eebd3c2", size = 1840375, upload-time = "2026-06-01T19:37:22.104Z" },
{ url = "https://files.pythonhosted.org/packages/72/3c/a7ffe05a757a4a7867643da69357ec41f506879fbd1b231d2ed90af246b2/aiohttp-3.14.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:514db9a79337068981ee2137310283a07b4b885c584991097a91a4da419bcb81", size = 1921484, upload-time = "2026-06-01T19:37:24.068Z" },
{ url = "https://files.pythonhosted.org/packages/93/fa/2c861170bbd4a491de93a69e081db1d971092569e0d593a98ef62c384dc1/aiohttp-3.14.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c452d17eeb95d563fc8b936f3050301dbd1d268126c4632d8b70ede9696202ee", size = 1774153, upload-time = "2026-06-01T19:37:26.256Z" },
{ url = "https://files.pythonhosted.org/packages/9d/da/1d2f5a165f47ec9b1f69d37b8b977fdc4d501aa72ffb7930db27bb9e49ea/aiohttp-3.14.0-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ed94a81506e3d1bdbad5108f497a58f2a2354aedb4ca314d5326f07d1fd1ac2d", size = 1632569, upload-time = "2026-06-01T19:37:28.192Z" },
{ url = "https://files.pythonhosted.org/packages/46/1d/7a6e295c4257252f70f69e90864fdad74b6a1293054fb3f9e65a15de6d63/aiohttp-3.14.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1394dce36e0f0d260ac0b555a654de19cb989f3c1b8bdd24f505314dfea18a00", size = 1740325, upload-time = "2026-06-01T19:37:30.08Z" },
{ url = "https://files.pythonhosted.org/packages/f1/7e/e1899b1ca3ec62f1eab2a5cbde14039b97493f7f53eb88d9b668562ffa8d/aiohttp-3.14.0-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:d1467d1e7b48a73ca7237e0ee4335f3d02b923dbc27b82fd254bc301c97d4026", size = 1748691, upload-time = "2026-06-01T19:37:32.211Z" },
{ url = "https://files.pythonhosted.org/packages/ec/54/4e6b61c1fe7d3433f82bcc6bd7e4d7c683a742a10c9b12a025fd3695c047/aiohttp-3.14.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:6a5f3532125233c261cf61f32df4059cfcf482eb793c7d3db8452e3142028b86", size = 1814477, upload-time = "2026-06-01T19:37:34.173Z" },
{ url = "https://files.pythonhosted.org/packages/9c/38/86fd51be2e08d8e45c83d879d255f10391903cd9fe2a16512f7591a15873/aiohttp-3.14.0-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:3ea81eb518a2ecb319d8ec6d1424a37c773f6634bd87d6985eb606b2faac419f", size = 1623393, upload-time = "2026-06-01T19:37:36.281Z" },
{ url = "https://files.pythonhosted.org/packages/78/49/466e947a42a88ee23c486d036e7e5d1b097f1bafd8084ad9c9a0a92f0f43/aiohttp-3.14.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:32e735c3182de7b64f6941a4ede48b38c7f47d9437bd615dd30b5bda8fa1bc93", size = 1824097, upload-time = "2026-06-01T19:37:38.421Z" },
{ url = "https://files.pythonhosted.org/packages/f3/89/35f3410bc284682338a1be6b6ea0c5abfa05f063942cfaa9256608440434/aiohttp-3.14.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:c21ca9a1c63d4509158f478aeb9d02914dcc52adc68d1bc9dee2452284ee5996", size = 1764790, upload-time = "2026-06-01T19:37:40.755Z" },
{ url = "https://files.pythonhosted.org/packages/42/80/2d4291bd5724d3d17e5951aff5a3e02281483fb47295f0788276ee66cd73/aiohttp-3.14.0-cp311-cp311-win32.whl", hash = "sha256:19ca5fc84130675ba11c6ca5c7da5cb65f7bf8a32cdd2b616bf49cd334688aae", size = 454176, upload-time = "2026-06-01T19:37:42.837Z" },
{ url = "https://files.pythonhosted.org/packages/59/ed/41d0ad4f6ececffc32bdf1f7b494e5498f7ca5c849ea2e3cc9bbd1668251/aiohttp-3.14.0-cp311-cp311-win_amd64.whl", hash = "sha256:d488e6e9d3bb8ba5ae7066d5be885ae9670eba021b8c6ccb9a3a568e6b19d6e5", size = 479334, upload-time = "2026-06-01T19:37:44.776Z" },
{ url = "https://files.pythonhosted.org/packages/d1/86/c0b5e305c770053f8c3d069bb52b8196917ba91949d1962d52eb307fb0d2/aiohttp-3.14.0-cp311-cp311-win_arm64.whl", hash = "sha256:8b93618102caf12801638a01a2b478a55410ddd71bd41cfaf6f707953a49ac43", size = 450262, upload-time = "2026-06-01T19:37:46.461Z" },
{ url = "https://files.pythonhosted.org/packages/89/97/2b6889bfb6b6847520d50d95eb8c4307a45e28aaca39faf4a9454b3d1b2f/aiohttp-3.14.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:b29518c9c2ec7e373e68259206a137c7f4f5439c58baaec4b5ab3ab799850a4e", size = 750194, upload-time = "2026-06-01T19:37:48.164Z" },
{ url = "https://files.pythonhosted.org/packages/21/e2/62634b7fff918ed98c3c6b2f0e70d520f7f28846cb412d451b04354c6459/aiohttp-3.14.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:dbec68ce61b64cb73cab4d33df9433427b1713c8bcccb181dce695c1b6f8e87c", size = 506966, upload-time = "2026-06-01T19:37:50.014Z" },
{ url = "https://files.pythonhosted.org/packages/dd/fb/5ce075150828c797a5106f1c2fb26034e709d4289b9d2bf8b07f1e59fac6/aiohttp-3.14.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:3cdf534aa455593e589302990c5097aa5c92c06c4262a20da22934f9186a5fff", size = 507527, upload-time = "2026-06-01T19:37:51.96Z" },
{ url = "https://files.pythonhosted.org/packages/01/d5/405a0ae4e6b081754a3609c1c97c63a950e000a2def16046f1e736933a0e/aiohttp-3.14.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cb6c657104393b5fbff01a5f59b2023db74058a8077d94475d6c25d03882a108", size = 1762420, upload-time = "2026-06-01T19:37:53.839Z" },
{ url = "https://files.pythonhosted.org/packages/ae/1d/e05a7c896b15a6bc6fb8fc5319eb437861c2c49c34559ef928add6590315/aiohttp-3.14.0-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:46fbbec4e4fab7428d4396a3823f9320e4560aa3113b89eeebce712c27c9ed5a", size = 1733672, upload-time = "2026-06-01T19:37:55.791Z" },
{ url = "https://files.pythonhosted.org/packages/cc/22/a72f7c459e195fa41bf4f7abd1f925b91fe91f8097e51c654229ba144a33/aiohttp-3.14.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2c2c7e05dd5335b298085abf45ddf98673934c3ee1c083d0b9ea13d4186ad500", size = 1805064, upload-time = "2026-06-01T19:37:57.931Z" },
{ url = "https://files.pythonhosted.org/packages/80/50/e85bdaba0be59ca4838005ebfef4048fcdd5f35a02b07057a9a123394440/aiohttp-3.14.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3c7139100fbaae76515b73051d8f0aa3a3ff02e415eec8a8eee8e2223d9ba955", size = 1902125, upload-time = "2026-06-01T19:38:00.225Z" },
{ url = "https://files.pythonhosted.org/packages/19/d8/51de5c6b971c27bb1ef620293b8d1ca611ec78736b34b3f6ccf68e4c8785/aiohttp-3.14.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:78d6f9286a629ce52728430afe18f8ed2b6c39a1fddb3802d7244b9983910ad2", size = 1783112, upload-time = "2026-06-01T19:38:02.641Z" },
{ url = "https://files.pythonhosted.org/packages/73/ae/b4402bfde77e43dfb1b6ccff83c7b7ab63ed06b50c4754f0c5423fb374fe/aiohttp-3.14.0-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cc3c3e12cdaeb92d7dcf13db00e9f6b1956b910e47256e696df1cfa946d02159", size = 1586356, upload-time = "2026-06-01T19:38:04.637Z" },
{ url = "https://files.pythonhosted.org/packages/bc/05/750a3265ca4dc54a460bd0cb1121a8f2ce9171fce4a135fb47ea7fd594d2/aiohttp-3.14.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:4d6a998191f5ebe3b8c28463ff72bc030250008b3193c402464efadd08b5ca02", size = 1723119, upload-time = "2026-06-01T19:38:06.713Z" },
{ url = "https://files.pythonhosted.org/packages/37/01/8c0812c50b3b1b1c37b323bf170d6be8847a8f234060485b7d1e71953f60/aiohttp-3.14.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:0fc2b75ae8d169d853be2862d960be8550da6c5c65711d5476407eb3fdb006bd", size = 1757216, upload-time = "2026-06-01T19:38:08.736Z" },
{ url = "https://files.pythonhosted.org/packages/47/2a/50fb98028a26887cbe48dcc1df92a90825615bc73b5584301304090cded8/aiohttp-3.14.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:16eee56bcc72d04600bc56c1759982c2385ec0b41d3fd3521f836bf64a0957ef", size = 1770500, upload-time = "2026-06-01T19:38:11.111Z" },
{ url = "https://files.pythonhosted.org/packages/bd/32/0ffd598a2fa2b9a423daf242e700cfdabda35d6e602394ad9ae58972c1c7/aiohttp-3.14.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:5a2e7ca615c3ddc15b82687e05a624e5f5cba3f1d6c20cb81172d70ea498451e", size = 1576224, upload-time = "2026-06-01T19:38:13.391Z" },
{ url = "https://files.pythonhosted.org/packages/0b/f9/b9fc381dd9b66afb33f2634c40e229d106467be0afcabe79648631ab6712/aiohttp-3.14.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:f0b7b8bbbec3ce9467ee0ebe334622fd90624f593edd3136c567811453fc4fae", size = 1794252, upload-time = "2026-06-01T19:38:15.498Z" },
{ url = "https://files.pythonhosted.org/packages/a8/fb/05d9214c975f23225a8cd5c439325e338c7c377b315480ef3871db51f54e/aiohttp-3.14.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5ba10966d4f03dd96a14365be4b8e37c327c76f11c3ca867116966cdd9f98066", size = 1760193, upload-time = "2026-06-01T19:38:17.624Z" },
{ url = "https://files.pythonhosted.org/packages/d9/4b/02992fc4fb9e1b6673ee3f888a8e587a6447afda1f6f4aca776c148c2876/aiohttp-3.14.0-cp312-cp312-win32.whl", hash = "sha256:101df7779c80c0636014a6b2c6642acd3efb5b355d48347c9d7dfb720aee9430", size = 448650, upload-time = "2026-06-01T19:38:19.545Z" },
{ url = "https://files.pythonhosted.org/packages/39/e9/246532214c3abda518477cbaaf16d420295ad8effa5233844cbb38f299ab/aiohttp-3.14.0-cp312-cp312-win_amd64.whl", hash = "sha256:b0a5747586d4467efd1f932710b269131c9717a872dce082cd92a00c1c13123a", size = 476145, upload-time = "2026-06-01T19:38:21.505Z" },
{ url = "https://files.pythonhosted.org/packages/2b/c3/63f8c20090048915711598b0adf475b149216d736157961de06480a45b15/aiohttp-3.14.0-cp312-cp312-win_arm64.whl", hash = "sha256:5f1c5be60add78fabb4aacd13c5a348ae79d2fcbfc7fa78da8f1eb192273b370", size = 444250, upload-time = "2026-06-01T19:38:24.027Z" },
]
[[package]]
@@ -1045,15 +1048,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/9e/43/53afb8ba17218f19b77c7834128566c5bbb100a0ad9ba2e8e89d089d7079/autopep8-2.3.2-py2.py3-none-any.whl", hash = "sha256:ce8ad498672c845a0c3de2629c15b635ec2b05ef8177a6e7c91c74f3e9b51128", size = 45807, upload-time = "2025-01-14T14:46:15.466Z" },
]
[[package]]
name = "awsipranges"
version = "0.3.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/19/2e/6efa95f995369da828715f41705686cd214b9259ed758266942553d40441/awsipranges-0.3.3.tar.gz", hash = "sha256:4f0b3f22a9dc1163c85b513bed812b6c92bdacd674e6a7b68252a3c25b99e2c0", size = 16739, upload-time = "2022-02-10T21:08:32.825Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/83/ce/5c9a8bf91bdc9592a409c99e58fd99f2727ab8d634719c0ad796021b76d7/awsipranges-0.3.3-py3-none-any.whl", hash = "sha256:f3d7a54aeaf7fe310beb5d377a4034a63a51b72677ae6af3e0967bc4de7eedaf", size = 18106, upload-time = "2022-02-10T21:08:31.497Z" },
]
[[package]]
name = "azure-cli-core"
version = "2.83.0"
@@ -3164,11 +3158,11 @@ wheels = [
[[package]]
name = "idna"
version = "3.11"
version = "3.15"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" }
sdist = { url = "https://files.pythonhosted.org/packages/82/77/7b3966d0b9d1d31a36ddf1746926a11dface89a83409bf1483f0237aa758/idna-3.15.tar.gz", hash = "sha256:ca962446ea538f7092a95e057da437618e886f4d349216d2b1e294abfdb65fdc", size = 199245, upload-time = "2026-05-12T22:45:57.011Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" },
{ url = "https://files.pythonhosted.org/packages/d2/23/408243171aa9aaba178d3e2559159c24c1171a641aa83b67bdd3394ead8e/idna-3.15-py3-none-any.whl", hash = "sha256:048adeaf8c2d788c40fee287673ccaa74c24ffd8dcf09ffa555a2fbb59f10ac8", size = 72340, upload-time = "2026-05-12T22:45:55.733Z" },
]
[[package]]
@@ -3971,30 +3965,30 @@ wheels = [
[[package]]
name = "numpy"
version = "2.0.2"
version = "2.2.6"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/a9/75/10dd1f8116a8b796cb2c737b674e02d02e80454bda953fa7e65d8c12b016/numpy-2.0.2.tar.gz", hash = "sha256:883c987dee1880e2a864ab0dc9892292582510604156762362d9326444636e78", size = 18902015, upload-time = "2024-08-26T20:19:40.945Z" }
sdist = { url = "https://files.pythonhosted.org/packages/76/21/7d2a95e4bba9dc13d043ee156a356c0a8f0c6309dff6b21b4d71a073b8a8/numpy-2.2.6.tar.gz", hash = "sha256:e29554e2bef54a90aa5cc07da6ce955accb83f21ab5de01a62c8478897b264fd", size = 20276440, upload-time = "2025-05-17T22:38:04.611Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/8b/cf/034500fb83041aa0286e0fb16e7c76e5c8b67c0711bb6e9e9737a717d5fe/numpy-2.0.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:49ca4decb342d66018b01932139c0961a8f9ddc7589611158cb3c27cbcf76448", size = 21169137, upload-time = "2024-08-26T20:07:45.345Z" },
{ url = "https://files.pythonhosted.org/packages/4a/d9/32de45561811a4b87fbdee23b5797394e3d1504b4a7cf40c10199848893e/numpy-2.0.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:11a76c372d1d37437857280aa142086476136a8c0f373b2e648ab2c8f18fb195", size = 13703552, upload-time = "2024-08-26T20:08:06.666Z" },
{ url = "https://files.pythonhosted.org/packages/c1/ca/2f384720020c7b244d22508cb7ab23d95f179fcfff33c31a6eeba8d6c512/numpy-2.0.2-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:807ec44583fd708a21d4a11d94aedf2f4f3c3719035c76a2bbe1fe8e217bdc57", size = 5298957, upload-time = "2024-08-26T20:08:15.83Z" },
{ url = "https://files.pythonhosted.org/packages/0e/78/a3e4f9fb6aa4e6fdca0c5428e8ba039408514388cf62d89651aade838269/numpy-2.0.2-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:8cafab480740e22f8d833acefed5cc87ce276f4ece12fdaa2e8903db2f82897a", size = 6905573, upload-time = "2024-08-26T20:08:27.185Z" },
{ url = "https://files.pythonhosted.org/packages/a0/72/cfc3a1beb2caf4efc9d0b38a15fe34025230da27e1c08cc2eb9bfb1c7231/numpy-2.0.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a15f476a45e6e5a3a79d8a14e62161d27ad897381fecfa4a09ed5322f2085669", size = 13914330, upload-time = "2024-08-26T20:08:48.058Z" },
{ url = "https://files.pythonhosted.org/packages/ba/a8/c17acf65a931ce551fee11b72e8de63bf7e8a6f0e21add4c937c83563538/numpy-2.0.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:13e689d772146140a252c3a28501da66dfecd77490b498b168b501835041f951", size = 19534895, upload-time = "2024-08-26T20:09:16.536Z" },
{ url = "https://files.pythonhosted.org/packages/ba/86/8767f3d54f6ae0165749f84648da9dcc8cd78ab65d415494962c86fac80f/numpy-2.0.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:9ea91dfb7c3d1c56a0e55657c0afb38cf1eeae4544c208dc465c3c9f3a7c09f9", size = 19937253, upload-time = "2024-08-26T20:09:46.263Z" },
{ url = "https://files.pythonhosted.org/packages/df/87/f76450e6e1c14e5bb1eae6836478b1028e096fd02e85c1c37674606ab752/numpy-2.0.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:c1c9307701fec8f3f7a1e6711f9089c06e6284b3afbbcd259f7791282d660a15", size = 14414074, upload-time = "2024-08-26T20:10:08.483Z" },
{ url = "https://files.pythonhosted.org/packages/5c/ca/0f0f328e1e59f73754f06e1adfb909de43726d4f24c6a3f8805f34f2b0fa/numpy-2.0.2-cp311-cp311-win32.whl", hash = "sha256:a392a68bd329eafac5817e5aefeb39038c48b671afd242710b451e76090e81f4", size = 6470640, upload-time = "2024-08-26T20:10:19.732Z" },
{ url = "https://files.pythonhosted.org/packages/eb/57/3a3f14d3a759dcf9bf6e9eda905794726b758819df4663f217d658a58695/numpy-2.0.2-cp311-cp311-win_amd64.whl", hash = "sha256:286cd40ce2b7d652a6f22efdfc6d1edf879440e53e76a75955bc0c826c7e64dc", size = 15910230, upload-time = "2024-08-26T20:10:43.413Z" },
{ url = "https://files.pythonhosted.org/packages/45/40/2e117be60ec50d98fa08c2f8c48e09b3edea93cfcabd5a9ff6925d54b1c2/numpy-2.0.2-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:df55d490dea7934f330006d0f81e8551ba6010a5bf035a249ef61a94f21c500b", size = 20895803, upload-time = "2024-08-26T20:11:13.916Z" },
{ url = "https://files.pythonhosted.org/packages/46/92/1b8b8dee833f53cef3e0a3f69b2374467789e0bb7399689582314df02651/numpy-2.0.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8df823f570d9adf0978347d1f926b2a867d5608f434a7cff7f7908c6570dcf5e", size = 13471835, upload-time = "2024-08-26T20:11:34.779Z" },
{ url = "https://files.pythonhosted.org/packages/7f/19/e2793bde475f1edaea6945be141aef6c8b4c669b90c90a300a8954d08f0a/numpy-2.0.2-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:9a92ae5c14811e390f3767053ff54eaee3bf84576d99a2456391401323f4ec2c", size = 5038499, upload-time = "2024-08-26T20:11:43.902Z" },
{ url = "https://files.pythonhosted.org/packages/e3/ff/ddf6dac2ff0dd50a7327bcdba45cb0264d0e96bb44d33324853f781a8f3c/numpy-2.0.2-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:a842d573724391493a97a62ebbb8e731f8a5dcc5d285dfc99141ca15a3302d0c", size = 6633497, upload-time = "2024-08-26T20:11:55.09Z" },
{ url = "https://files.pythonhosted.org/packages/72/21/67f36eac8e2d2cd652a2e69595a54128297cdcb1ff3931cfc87838874bd4/numpy-2.0.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c05e238064fc0610c840d1cf6a13bf63d7e391717d247f1bf0318172e759e692", size = 13621158, upload-time = "2024-08-26T20:12:14.95Z" },
{ url = "https://files.pythonhosted.org/packages/39/68/e9f1126d757653496dbc096cb429014347a36b228f5a991dae2c6b6cfd40/numpy-2.0.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0123ffdaa88fa4ab64835dcbde75dcdf89c453c922f18dced6e27c90d1d0ec5a", size = 19236173, upload-time = "2024-08-26T20:12:44.049Z" },
{ url = "https://files.pythonhosted.org/packages/d1/e9/1f5333281e4ebf483ba1c888b1d61ba7e78d7e910fdd8e6499667041cc35/numpy-2.0.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:96a55f64139912d61de9137f11bf39a55ec8faec288c75a54f93dfd39f7eb40c", size = 19634174, upload-time = "2024-08-26T20:13:13.634Z" },
{ url = "https://files.pythonhosted.org/packages/71/af/a469674070c8d8408384e3012e064299f7a2de540738a8e414dcfd639996/numpy-2.0.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ec9852fb39354b5a45a80bdab5ac02dd02b15f44b3804e9f00c556bf24b4bded", size = 14099701, upload-time = "2024-08-26T20:13:34.851Z" },
{ url = "https://files.pythonhosted.org/packages/d0/3d/08ea9f239d0e0e939b6ca52ad403c84a2bce1bde301a8eb4888c1c1543f1/numpy-2.0.2-cp312-cp312-win32.whl", hash = "sha256:671bec6496f83202ed2d3c8fdc486a8fc86942f2e69ff0e986140339a63bcbe5", size = 6174313, upload-time = "2024-08-26T20:13:45.653Z" },
{ url = "https://files.pythonhosted.org/packages/b2/b5/4ac39baebf1fdb2e72585c8352c56d063b6126be9fc95bd2bb5ef5770c20/numpy-2.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:cfd41e13fdc257aa5778496b8caa5e856dc4896d4ccf01841daee1d96465467a", size = 15606179, upload-time = "2024-08-26T20:14:08.786Z" },
{ url = "https://files.pythonhosted.org/packages/da/a8/4f83e2aa666a9fbf56d6118faaaf5f1974d456b1823fda0a176eff722839/numpy-2.2.6-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f9f1adb22318e121c5c69a09142811a201ef17ab257a1e66ca3025065b7f53ae", size = 21176963, upload-time = "2025-05-17T21:31:19.36Z" },
{ url = "https://files.pythonhosted.org/packages/b3/2b/64e1affc7972decb74c9e29e5649fac940514910960ba25cd9af4488b66c/numpy-2.2.6-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:c820a93b0255bc360f53eca31a0e676fd1101f673dda8da93454a12e23fc5f7a", size = 14406743, upload-time = "2025-05-17T21:31:41.087Z" },
{ url = "https://files.pythonhosted.org/packages/4a/9f/0121e375000b5e50ffdd8b25bf78d8e1a5aa4cca3f185d41265198c7b834/numpy-2.2.6-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:3d70692235e759f260c3d837193090014aebdf026dfd167834bcba43e30c2a42", size = 5352616, upload-time = "2025-05-17T21:31:50.072Z" },
{ url = "https://files.pythonhosted.org/packages/31/0d/b48c405c91693635fbe2dcd7bc84a33a602add5f63286e024d3b6741411c/numpy-2.2.6-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:481b49095335f8eed42e39e8041327c05b0f6f4780488f61286ed3c01368d491", size = 6889579, upload-time = "2025-05-17T21:32:01.712Z" },
{ url = "https://files.pythonhosted.org/packages/52/b8/7f0554d49b565d0171eab6e99001846882000883998e7b7d9f0d98b1f934/numpy-2.2.6-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b64d8d4d17135e00c8e346e0a738deb17e754230d7e0810ac5012750bbd85a5a", size = 14312005, upload-time = "2025-05-17T21:32:23.332Z" },
{ url = "https://files.pythonhosted.org/packages/b3/dd/2238b898e51bd6d389b7389ffb20d7f4c10066d80351187ec8e303a5a475/numpy-2.2.6-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ba10f8411898fc418a521833e014a77d3ca01c15b0c6cdcce6a0d2897e6dbbdf", size = 16821570, upload-time = "2025-05-17T21:32:47.991Z" },
{ url = "https://files.pythonhosted.org/packages/83/6c/44d0325722cf644f191042bf47eedad61c1e6df2432ed65cbe28509d404e/numpy-2.2.6-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:bd48227a919f1bafbdda0583705e547892342c26fb127219d60a5c36882609d1", size = 15818548, upload-time = "2025-05-17T21:33:11.728Z" },
{ url = "https://files.pythonhosted.org/packages/ae/9d/81e8216030ce66be25279098789b665d49ff19eef08bfa8cb96d4957f422/numpy-2.2.6-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:9551a499bf125c1d4f9e250377c1ee2eddd02e01eac6644c080162c0c51778ab", size = 18620521, upload-time = "2025-05-17T21:33:39.139Z" },
{ url = "https://files.pythonhosted.org/packages/6a/fd/e19617b9530b031db51b0926eed5345ce8ddc669bb3bc0044b23e275ebe8/numpy-2.2.6-cp311-cp311-win32.whl", hash = "sha256:0678000bb9ac1475cd454c6b8c799206af8107e310843532b04d49649c717a47", size = 6525866, upload-time = "2025-05-17T21:33:50.273Z" },
{ url = "https://files.pythonhosted.org/packages/31/0a/f354fb7176b81747d870f7991dc763e157a934c717b67b58456bc63da3df/numpy-2.2.6-cp311-cp311-win_amd64.whl", hash = "sha256:e8213002e427c69c45a52bbd94163084025f533a55a59d6f9c5b820774ef3303", size = 12907455, upload-time = "2025-05-17T21:34:09.135Z" },
{ url = "https://files.pythonhosted.org/packages/82/5d/c00588b6cf18e1da539b45d3598d3557084990dcc4331960c15ee776ee41/numpy-2.2.6-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:41c5a21f4a04fa86436124d388f6ed60a9343a6f767fced1a8a71c3fbca038ff", size = 20875348, upload-time = "2025-05-17T21:34:39.648Z" },
{ url = "https://files.pythonhosted.org/packages/66/ee/560deadcdde6c2f90200450d5938f63a34b37e27ebff162810f716f6a230/numpy-2.2.6-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:de749064336d37e340f640b05f24e9e3dd678c57318c7289d222a8a2f543e90c", size = 14119362, upload-time = "2025-05-17T21:35:01.241Z" },
{ url = "https://files.pythonhosted.org/packages/3c/65/4baa99f1c53b30adf0acd9a5519078871ddde8d2339dc5a7fde80d9d87da/numpy-2.2.6-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:894b3a42502226a1cac872f840030665f33326fc3dac8e57c607905773cdcde3", size = 5084103, upload-time = "2025-05-17T21:35:10.622Z" },
{ url = "https://files.pythonhosted.org/packages/cc/89/e5a34c071a0570cc40c9a54eb472d113eea6d002e9ae12bb3a8407fb912e/numpy-2.2.6-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:71594f7c51a18e728451bb50cc60a3ce4e6538822731b2933209a1f3614e9282", size = 6625382, upload-time = "2025-05-17T21:35:21.414Z" },
{ url = "https://files.pythonhosted.org/packages/f8/35/8c80729f1ff76b3921d5c9487c7ac3de9b2a103b1cd05e905b3090513510/numpy-2.2.6-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f2618db89be1b4e05f7a1a847a9c1c0abd63e63a1607d892dd54668dd92faf87", size = 14018462, upload-time = "2025-05-17T21:35:42.174Z" },
{ url = "https://files.pythonhosted.org/packages/8c/3d/1e1db36cfd41f895d266b103df00ca5b3cbe965184df824dec5c08c6b803/numpy-2.2.6-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fd83c01228a688733f1ded5201c678f0c53ecc1006ffbc404db9f7a899ac6249", size = 16527618, upload-time = "2025-05-17T21:36:06.711Z" },
{ url = "https://files.pythonhosted.org/packages/61/c6/03ed30992602c85aa3cd95b9070a514f8b3c33e31124694438d88809ae36/numpy-2.2.6-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:37c0ca431f82cd5fa716eca9506aefcabc247fb27ba69c5062a6d3ade8cf8f49", size = 15505511, upload-time = "2025-05-17T21:36:29.965Z" },
{ url = "https://files.pythonhosted.org/packages/b7/25/5761d832a81df431e260719ec45de696414266613c9ee268394dd5ad8236/numpy-2.2.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fe27749d33bb772c80dcd84ae7e8df2adc920ae8297400dabec45f0dedb3f6de", size = 18313783, upload-time = "2025-05-17T21:36:56.883Z" },
{ url = "https://files.pythonhosted.org/packages/57/0a/72d5a3527c5ebffcd47bde9162c39fae1f90138c961e5296491ce778e682/numpy-2.2.6-cp312-cp312-win32.whl", hash = "sha256:4eeaae00d789f66c7a25ac5f34b71a7035bb474e679f410e5e1a94deb24cf2d4", size = 6246506, upload-time = "2025-05-17T21:37:07.368Z" },
{ url = "https://files.pythonhosted.org/packages/36/fa/8c9210162ca1b88529ab76b41ba02d433fd54fecaf6feb70ef9f124683f1/numpy-2.2.6-cp312-cp312-win_amd64.whl", hash = "sha256:c1f9540be57940698ed329904db803cf7a402f3fc200bfe599334c9bd84a40b2", size = 12614190, upload-time = "2025-05-17T21:37:26.213Z" },
]
[[package]]
@@ -4415,8 +4409,8 @@ wheels = [
[[package]]
name = "prowler"
version = "5.30.0"
source = { git = "https://github.com/prowler-cloud/prowler.git?rev=master#f1d741214a60df17158c3fdc97804fd1fde64f3a" }
version = "5.31.0"
source = { git = "https://github.com/prowler-cloud/prowler.git?rev=master#8081d0fd0552131dfbd885f1074377eb653fc785" }
dependencies = [
{ name = "alibabacloud-actiontrail20200706" },
{ name = "alibabacloud-credentials" },
@@ -4432,7 +4426,6 @@ dependencies = [
{ name = "alibabacloud-tea-openapi" },
{ name = "alibabacloud-vpc20160428" },
{ name = "alive-progress" },
{ name = "awsipranges" },
{ name = "azure-identity" },
{ name = "azure-keyvault-keys" },
{ name = "azure-mgmt-apimanagement" },
@@ -4692,14 +4685,14 @@ wheels = [
[[package]]
name = "py-iam-expand"
version = "0.1.0"
version = "0.3.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "iamdata" },
]
sdist = { url = "https://files.pythonhosted.org/packages/22/99/8d31a30b37825577275bb3663885b55075fba80257fcd6813b85d3aaffa8/py_iam_expand-0.1.0.tar.gz", hash = "sha256:5a2884dc267ac59a02c3a80fefc0b34c309dac681baa0f87c436067c6cf53a96", size = 10228, upload-time = "2025-04-30T07:15:35.304Z" }
sdist = { url = "https://files.pythonhosted.org/packages/a3/08/f6e11a029b81f0bec4b7b1f18704aadf509a882cc386c90ef1ac043c18cc/py_iam_expand-0.3.0.tar.gz", hash = "sha256:4ccfe25f40ba0633a152c4f86b49cde8972ee3d4b6009b017a4310cc4b9e64c7", size = 10234, upload-time = "2026-02-24T09:47:47.772Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/79/19/482c2e0768cda7afaed07918e4fbd951e2418255fb5d1d9b35b284871716/py_iam_expand-0.1.0-py3-none-any.whl", hash = "sha256:b845ce7b50ac895b02b4f338e09c62a68ea51849794f76e189b02009bd388510", size = 12522, upload-time = "2025-04-30T07:15:33.799Z" },
{ url = "https://files.pythonhosted.org/packages/5a/dd/4056d0bc3d6317039d2dd2ee7cd6a5389575603e270399a8f9f20e11e721/py_iam_expand-0.3.0-py3-none-any.whl", hash = "sha256:94c0a1e9dd60316ce60ddc0cdc9a046119bde335b5bb9593ee29224857860d5a", size = 12527, upload-time = "2026-02-24T09:47:45.602Z" },
]
[[package]]
@@ -1,7 +1,7 @@
# Build command
# docker build --platform=linux/amd64 --no-cache -t prowler:latest .
ARG PROWLER_VERSION=latest
ARG PROWLER_VERSION=latest@sha256:4b796c6df40a3350c7947747b59bdda230d0da6222287500e13b0a8e1574aad4
FROM toniblyx/prowler:${PROWLER_VERSION}
+1 -1
View File
@@ -28,7 +28,7 @@ containers:
image:
repository: prowlercloud/prowler-api
pullPolicy: IfNotPresent
command: ["../docker-entrypoint.sh", "beat"]
command: ["/home/prowler/docker-entrypoint.sh", "beat"]
secrets:
POSTGRES_HOST:
+1 -1
View File
@@ -440,7 +440,7 @@ worker_beat:
tag: ""
command:
- ../docker-entrypoint.sh
- /home/prowler/docker-entrypoint.sh
args:
- beat
@@ -16,7 +16,7 @@
services:
nginx:
image: nginx:alpine
image: nginx:alpine@sha256:8b1e78743a03dbb2c95171cc58639fef29abc8816598e27fb910ed2e621e589a
container_name: prowler-nginx
restart: unless-stopped
ports:
+5 -5
View File
@@ -1,6 +1,6 @@
services:
api-dev-init:
image: busybox:1.37.0
image: busybox:1.37.0@sha256:9532d8c39891ca2ecde4d30d7710e01fb739c87a8b9299685c63704296b16028
volumes:
- ./_data/api:/data
command: ["sh", "-c", "chown -R 1000:1000 /data"]
@@ -64,7 +64,7 @@ services:
condition: service_healthy
postgres:
image: postgres:16.3-alpine3.20
image: postgres:16.3-alpine3.20@sha256:36ed71227ae36305d26382657c0b96cbaf298427b3f1eaeb10d77a6dea3eec41
hostname: "postgres-db"
volumes:
- ./_data/postgres:/var/lib/postgresql/data
@@ -88,7 +88,7 @@ services:
retries: 5
valkey:
image: valkey/valkey:7-alpine3.19
image: valkey/valkey:7-alpine3.19@sha256:4054fe7fc607b9326ac7c4691ed26e9670d2ff17a9fb28c2577adecf928acbcc
hostname: "valkey"
volumes:
- ./_data/valkey:/data
@@ -104,7 +104,7 @@ services:
retries: 3
neo4j:
image: graphstack/dozerdb:5.26.3.0
image: graphstack/dozerdb:5.26.3.0@sha256:a77526ea3918fdc46d1fff70c4aea7d71d3874a26ecec059179d6775845b1247
hostname: "neo4j"
volumes:
- ./_data/neo4j:/data
@@ -185,7 +185,7 @@ services:
soft: 65536
hard: 65536
entrypoint:
- "../docker-entrypoint.sh"
- "/home/prowler/docker-entrypoint.sh"
- "beat"
mcp-server:
+5 -5
View File
@@ -6,7 +6,7 @@
#
services:
api-init:
image: busybox:1.37.0
image: busybox:1.37.0@sha256:9532d8c39891ca2ecde4d30d7710e01fb739c87a8b9299685c63704296b16028
volumes:
- ./_data/api:/data
command: ["sh", "-c", "chown -R 1000:1000 /data"]
@@ -60,7 +60,7 @@ services:
start_period: 60s
postgres:
image: postgres:16.3-alpine3.20
image: postgres:16.3-alpine3.20@sha256:36ed71227ae36305d26382657c0b96cbaf298427b3f1eaeb10d77a6dea3eec41
hostname: "postgres-db"
volumes:
- ./_data/postgres:/var/lib/postgresql/data
@@ -80,7 +80,7 @@ services:
retries: 5
valkey:
image: valkey/valkey:7-alpine3.19
image: valkey/valkey:7-alpine3.19@sha256:4054fe7fc607b9326ac7c4691ed26e9670d2ff17a9fb28c2577adecf928acbcc
hostname: "valkey"
volumes:
- ./_data/valkey:/data
@@ -96,7 +96,7 @@ services:
retries: 3
neo4j:
image: graphstack/dozerdb:5.26.3.0
image: graphstack/dozerdb:5.26.3.0@sha256:a77526ea3918fdc46d1fff70c4aea7d71d3874a26ecec059179d6775845b1247
hostname: "neo4j"
volumes:
- ./_data/neo4j:/data
@@ -160,7 +160,7 @@ services:
soft: 65536
hard: 65536
entrypoint:
- "../docker-entrypoint.sh"
- "/home/prowler/docker-entrypoint.sh"
- "beat"
mcp-server:
@@ -4,7 +4,7 @@ title: 'Installation'
## Installation
To install Prowler as a Python package, use `Python >= 3.10, <= 3.12`. Prowler is available as a project in [PyPI](https://pypi.org/project/prowler/):
To install Prowler as a Python package, use `Python >= 3.10, <= 3.13`. Prowler is available as a project in [PyPI](https://pypi.org/project/prowler/):
<Tabs>
<Tab title="pipx">
@@ -12,7 +12,7 @@ To install Prowler as a Python package, use `Python >= 3.10, <= 3.12`. Prowler i
_Requirements_:
* `Python >= 3.10, <= 3.12`
* `Python >= 3.10, <= 3.13`
* `pipx` installed: [pipx installation](https://pipx.pypa.io/stable/installation/).
* AWS, GCP, Azure and/or Kubernetes credentials
@@ -30,7 +30,7 @@ To install Prowler as a Python package, use `Python >= 3.10, <= 3.12`. Prowler i
_Requirements_:
* `Python >= 3.10, <= 3.12`
* `Python >= 3.10, <= 3.13`
* `Python pip >= 21.0.0`
* AWS, GCP, Azure, M365 and/or Kubernetes credentials
@@ -81,7 +81,7 @@ To install Prowler as a Python package, use `Python >= 3.10, <= 3.12`. Prowler i
<Tab title="Amazon Linux 2">
_Requirements_:
* `Python >= 3.10, <= 3.12`
* `Python >= 3.10, <= 3.13`
* AWS, GCP, Azure and/or Kubernetes credentials
_Commands_:
@@ -96,8 +96,8 @@ To install Prowler as a Python package, use `Python >= 3.10, <= 3.12`. Prowler i
<Tab title="Ubuntu">
_Requirements_:
* `Ubuntu 23.04` or above. For older Ubuntu versions, check [pipx installation](https://docs.prowler.com/projects/prowler-open-source/en/latest/#__tabbed_1_1) and ensure `Python >= 3.10, <= 3.12` is installed.
* `Python >= 3.10, <= 3.12`
* `Ubuntu 23.04` or above. For older Ubuntu versions, check [pipx installation](https://docs.prowler.com/projects/prowler-open-source/en/latest/#__tabbed_1_1) and ensure `Python >= 3.10, <= 3.13` is installed.
* `Python >= 3.10, <= 3.13`
* AWS, GCP, Azure and/or Kubernetes credentials
_Commands_:
+2 -2
View File
@@ -127,7 +127,7 @@ Add the following to `.gitlab-ci.yml`:
```yaml
prowler-scan:
image: python:3.12-slim
image: python:3.13-slim
stage: test
script:
- pip install prowler
@@ -154,7 +154,7 @@ stages:
- security
.prowler-base:
image: python:3.12-slim
image: python:3.13-slim
stage: security
before_script:
- pip install prowler
+2 -2
View File
@@ -1,7 +1,7 @@
# =============================================================================
# Build stage - Install dependencies and build the application
# =============================================================================
FROM ghcr.io/astral-sh/uv:python3.13-alpine@sha256:8f53782bb232ab0b5558f3071e86e2bbfde884e18815f2b19cc57f2d336e9ee2 AS builder
FROM ghcr.io/astral-sh/uv:0.11.21-python3.13-alpine3.23@sha256:f09cc61ffc001f202701fdeae14dbdd50f6ca4cfcf248f41fd3234a302c8534f AS builder
WORKDIR /app
@@ -25,7 +25,7 @@ RUN --mount=type=cache,target=/root/.cache/uv \
# =============================================================================
# Final stage - Minimal runtime environment
# =============================================================================
FROM python:3.13-alpine@sha256:bb1f2fdb1065c85468775c9d680dcd344f6442a2d1181ef7916b60a623f11d40
FROM python:3.13.14-alpine3.23@sha256:b0513989fa9be54569cac73f48a60320b74bb0f9ffa886568eea7e48a2432c04
LABEL maintainer="https://github.com/prowler-cloud"
+3 -3
View File
@@ -857,11 +857,11 @@ wheels = [
[[package]]
name = "pyjwt"
version = "2.12.1"
version = "2.13.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/c2/27/a3b6e5bf6ff856d2509292e95c8f57f0df7017cf5394921fc4e4ef40308a/pyjwt-2.12.1.tar.gz", hash = "sha256:c74a7a2adf861c04d002db713dd85f84beb242228e671280bf709d765b03672b", size = 102564, upload-time = "2026-03-13T19:27:37.25Z" }
sdist = { url = "https://files.pythonhosted.org/packages/3b/81/58d0ac84e1ef3a3843791d6954d94c0b33d526c75eeb1efbce9d0a4c4077/pyjwt-2.13.0.tar.gz", hash = "sha256:41571c89ca91598c79e8ef18a2d07367d4810fbbd6f637794879baf1b7703423", size = 107515, upload-time = "2026-05-21T19:54:36.618Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e5/7a/8dd906bd22e79e47397a61742927f6747fe93242ef86645ee9092e610244/pyjwt-2.12.1-py3-none-any.whl", hash = "sha256:28ca37c070cad8ba8cd9790cd940535d40274d22f80ab87f3ac6a713e6e8454c", size = 29726, upload-time = "2026-03-13T19:27:35.677Z" },
{ url = "https://files.pythonhosted.org/packages/a3/5e/ecf12fdb62546d64385c158514e9b2b671f7832108ef2ecd2020ce0af2d1/pyjwt-2.13.0-py3-none-any.whl", hash = "sha256:66adcc2aff09b3f1bbd95fc1e1577df8ac8723c978552fd43304c8a290ac5728", size = 31274, upload-time = "2026-05-21T19:54:35.362Z" },
]
[package.optional-dependencies]
+21
View File
@@ -6,12 +6,33 @@ All notable changes to the **Prowler SDK** are documented in this file.
### 🚀 Added
- Support for Python 3.13 [(#9293)](https://github.com/prowler-cloud/prowler/pull/9293)
- `securityhub_delegated_admin_enabled_all_regions` check for AWS provider, verifying that Security Hub has a delegated administrator, is active in all opted-in regions, and has organization auto-enable on [(#11259)](https://github.com/prowler-cloud/prowler/pull/11259)
- `config_delegated_admin_and_org_aggregator_all_regions` check for AWS provider, verifying that AWS Config has a delegated administrator and an organization aggregator covering all AWS regions [(#11259)](https://github.com/prowler-cloud/prowler/pull/11259)
- `sagemaker_clarify_exists` check for AWS provider [(#11211)](https://github.com/prowler-cloud/prowler/pull/11211)
- `cloudsql_instance_high_availability_enabled` check for GCP provider, verifying Cloud SQL primary instances use `REGIONAL` availability for automatic zone failover [(#11024)](https://github.com/prowler-cloud/prowler/pull/11024)
- `identity_storage_service_level_admins_scoped` check for OCI provider CIS 3.1 control 1.15, ensuring storage service-level administrators exclude delete permissions [(#11523)](https://github.com/prowler-cloud/prowler/pull/11523)
- `cosmosdb_account_automatic_failover_enabled` check for Azure provider [(#11031)](https://github.com/prowler-cloud/prowler/pull/11031)
- `cosmosdb_account_backup_policy_continuous` check for Azure provider [(#11032)](https://github.com/prowler-cloud/prowler/pull/11032)
- Jira timeout preventing the calls from hanging indefinitely when the Jira endpoint is unreachable or slow [(#11602)](https://github.com/prowler-cloud/prowler/pull/11602)
- TLS certificate verification in the `codepipeline_project_repo_private` check, which previously used an unverified SSL context, leaving the repository-visibility probe open to MITM tampering [(#11603)](https://github.com/prowler-cloud/prowler/pull/11603)
### 🔄 Changed
- Replaced the unmaintained `awsipranges` dependency with a small standard-library helper for the `route53_dangling_ip_subdomain_takeover` check [(#9293)](https://github.com/prowler-cloud/prowler/pull/9293)
### 🔐 Security
- `microsoft-kiota-*` to 1.9.9 and `aiohttp` to 3.14.0, patching known CVEs [(#11596)](https://github.com/prowler-cloud/prowler/pull/11596)
- Container base image bumped to `python:3.12.13-slim-bookworm` (patches `libgnutls30` CVE-2026-33845 and CVE-2026-42010) and `trivy` bumped to 0.71.0 (patches embedded `golang.org/x/crypto` and Go stdlib CVEs); `.trivyignore` documents remaining bookworm criticals with no-fix or not-affected rationale [(#11592)](https://github.com/prowler-cloud/prowler/pull/11592)
---
## [5.30.2] (Prowler UNRELEASED)
### 🐞 Fixed
- GCP `logging_log_metric_filter_and_alert_*` checks now credit org-level aggregated sinks filtered to the Admin Activity audit stream [(#11575)](https://github.com/prowler-cloud/prowler/pull/11575)
---
+2 -1
View File
@@ -438,7 +438,8 @@
"storage_geo_redundant_enabled",
"keyvault_recoverable",
"sqlserver_auditing_retention_90_days",
"postgresql_flexible_server_log_retention_days_greater_3"
"postgresql_flexible_server_log_retention_days_greater_3",
"cosmosdb_account_backup_policy_continuous"
]
},
{
@@ -1266,7 +1266,9 @@
"Check_Summary": "Backup copies of information, software and systems should be maintained and regularly tested in accordance with the agreed topic-specific policy on backup."
}
],
"Checks": []
"Checks": [
"cosmosdb_account_backup_policy_continuous"
]
},
{
"Id": "A.8.14",
+2 -1
View File
@@ -1088,7 +1088,8 @@
"storage_geo_redundant_enabled",
"vm_scaleset_associated_with_load_balancer",
"vm_scaleset_not_empty",
"cosmosdb_account_automatic_failover_enabled"
"cosmosdb_account_automatic_failover_enabled",
"cosmosdb_account_backup_policy_continuous"
],
"gcp": [
"compute_instance_automatic_restart_enabled",
+25 -3
View File
@@ -341,6 +341,7 @@ class Jira:
}
TOKEN_URL = "https://auth.atlassian.com/oauth/token"
API_TOKEN_URL = "https://api.atlassian.com/oauth/token/accessible-resources"
REQUEST_TIMEOUT = 30
HEADER_TEMPLATE = {
"Content-Type": "application/json",
"X-Force-Accept-Language": "true",
@@ -578,7 +579,12 @@ class Jira:
}
headers = self.get_headers(content_type_json=True)
response = requests.post(self.TOKEN_URL, json=body, headers=headers)
response = requests.post(
self.TOKEN_URL,
json=body,
headers=headers,
timeout=self.REQUEST_TIMEOUT,
)
if response.status_code == 200:
tokens = response.json()
@@ -630,12 +636,17 @@ class Jira:
response = requests.get(
f"https://{domain}.atlassian.net/_edge/tenant_info",
headers=headers,
timeout=self.REQUEST_TIMEOUT,
)
response = response.json()
return response.get("cloudId")
else:
headers = self.get_headers(access_token)
response = requests.get(self.API_TOKEN_URL, headers=headers)
response = requests.get(
self.API_TOKEN_URL,
headers=headers,
timeout=self.REQUEST_TIMEOUT,
)
if response.status_code == 200:
resources = response.json()
@@ -717,7 +728,12 @@ class Jira:
}
headers = self.get_headers(content_type_json=True)
response = requests.post(url, json=body, headers=headers)
response = requests.post(
url,
json=body,
headers=headers,
timeout=self.REQUEST_TIMEOUT,
)
if response.status_code == 200:
tokens = response.json()
@@ -874,6 +890,7 @@ class Jira:
response = requests.get(
f"https://api.atlassian.com/ex/jira/{self.cloud_id}/rest/api/3/project",
headers=headers,
timeout=self.REQUEST_TIMEOUT,
)
if response.status_code == 200:
@@ -941,6 +958,7 @@ class Jira:
response = requests.get(
f"https://api.atlassian.com/ex/jira/{self.cloud_id}/rest/api/3/issue/createmeta?projectKeys={project_key}&expand=projects.issuetypes.fields",
headers=headers,
timeout=self.REQUEST_TIMEOUT,
)
if response.status_code == 200:
@@ -986,6 +1004,7 @@ class Jira:
response = requests.get(
f"https://api.atlassian.com/ex/jira/{self.cloud_id}/rest/api/3/project",
headers=headers,
timeout=self.REQUEST_TIMEOUT,
)
if response.status_code == 200:
projects_data = {}
@@ -1001,6 +1020,7 @@ class Jira:
project_response = requests.get(
f"https://api.atlassian.com/ex/jira/{self.cloud_id}/rest/api/3/issue/createmeta?projectKeys={project['key']}&expand=projects.issuetypes.fields",
headers=headers,
timeout=self.REQUEST_TIMEOUT,
)
if project_response.status_code == 200:
project_metadata = project_response.json()
@@ -1923,6 +1943,7 @@ class Jira:
f"https://api.atlassian.com/ex/jira/{self.cloud_id}/rest/api/3/issue",
json=payload,
headers=headers,
timeout=self.REQUEST_TIMEOUT,
)
if response.status_code != 201:
@@ -2127,6 +2148,7 @@ class Jira:
f"https://api.atlassian.com/ex/jira/{self.cloud_id}/rest/api/3/issue",
json=payload,
headers=headers,
timeout=self.REQUEST_TIMEOUT,
)
if response.status_code != 201:
@@ -0,0 +1,51 @@
import json
import urllib.error
import urllib.request
from ipaddress import ip_network
from prowler.lib.logger import logger
AWS_IP_RANGES_URL = "https://ip-ranges.amazonaws.com/ip-ranges.json"
AWS_IP_RANGES_TIMEOUT = 10
def get_public_ip_networks() -> list:
"""Fetch the AWS public IP prefixes as a list of ip_network objects.
The request verifies the server certificate against the system trust store,
matching urllib's default behaviour. This replaces the unmaintained
awsipranges package, whose latest release (0.3.3) calls
urllib.request.urlopen() with the cafile/capath arguments that Python 3.13
removed.
Returns an empty list when the feed cannot be fetched or parsed, and skips
individual malformed prefixes, so a transient or corrupt feed never aborts
the calling check.
"""
try:
with urllib.request.urlopen(
AWS_IP_RANGES_URL, timeout=AWS_IP_RANGES_TIMEOUT
) as response:
ranges = json.loads(response.read())
except (urllib.error.URLError, TimeoutError, json.JSONDecodeError) as error:
logger.error(
f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
)
return []
networks = []
for key, prefixes in (
("ip_prefix", ranges.get("prefixes", [])),
("ipv6_prefix", ranges.get("ipv6_prefixes", [])),
):
for prefix in prefixes:
cidr = prefix.get(key)
if not cidr:
continue
try:
networks.append(ip_network(cidr))
except ValueError as error:
logger.warning(
f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
)
return networks
@@ -7,6 +7,8 @@ from prowler.providers.aws.services.codepipeline.codepipeline_client import (
codepipeline_client,
)
HTTP_TIMEOUT = 30
class codepipeline_project_repo_private(Check):
"""Checks if AWS CodePipeline source repositories are configured as private.
@@ -87,9 +89,8 @@ class codepipeline_project_repo_private(Check):
repo_url = repo_url[:-4]
try:
context = ssl._create_unverified_context()
req = urllib.request.Request(repo_url, method="HEAD")
response = urllib.request.urlopen(req, context=context)
response = urllib.request.urlopen(req, timeout=HTTP_TIMEOUT)
return not response.geturl().endswith("sign_in")
except (urllib.error.HTTPError, urllib.error.URLError):
except (urllib.error.URLError, TimeoutError, ssl.SSLError):
return False
@@ -1,10 +1,9 @@
import re
from ipaddress import ip_address
import awsipranges
from prowler.lib.check.models import Check, Check_Report_AWS
from prowler.lib.utils.utils import validate_ip_address
from prowler.providers.aws.lib.ip_ranges.ip_ranges import get_public_ip_networks
from prowler.providers.aws.services.ec2.ec2_client import ec2_client
from prowler.providers.aws.services.route53.route53_client import route53_client
from prowler.providers.aws.services.s3.s3_client import s3_client
@@ -21,6 +20,10 @@ class route53_dangling_ip_subdomain_takeover(Check):
def execute(self) -> Check_Report_AWS:
findings = []
# AWS public IP prefixes are fetched lazily, at most once per run, only
# when a dangling-candidate public IP is found.
aws_ip_networks = None
# When --region is used, Route53 service gathers EIPs from all regions
# to avoid false positives. Otherwise, use ec2_client data directly.
if route53_client.all_account_elastic_ips:
@@ -42,6 +45,7 @@ class route53_dangling_ip_subdomain_takeover(Check):
if record_set.type == "A" and not record_set.is_alias:
for record in record_set.records:
if validate_ip_address(record):
record_ip = ip_address(record)
report = Check_Report_AWS(
metadata=self.metadata(), resource=record_set
)
@@ -53,14 +57,12 @@ class route53_dangling_ip_subdomain_takeover(Check):
report.status = "PASS"
report.status_extended = f"Route53 record {record} (name: {record_set.name}) in Hosted Zone {hosted_zone.name} is not a dangling IP."
# If Public IP check if it is in the AWS Account
if (
not ip_address(record).is_private
and record not in public_ips
):
if not record_ip.is_private and record not in public_ips:
report.status_extended = f"Route53 record {record} (name: {record_set.name}) in Hosted Zone {hosted_zone.name} does not belong to AWS and it is not a dangling IP."
# Check if potential dangling IP is within AWS Ranges
aws_ip_ranges = awsipranges.get_ranges()
if aws_ip_ranges.get(record):
if aws_ip_networks is None:
aws_ip_networks = get_public_ip_networks()
if any(record_ip in network for network in aws_ip_networks):
report.status = "FAIL"
report.status_extended = f"Route53 record {record} (name: {record_set.name}) in Hosted Zone {hosted_zone.name} is a dangling IP which can lead to a subdomain takeover attack."
findings.append(report)
@@ -0,0 +1,38 @@
{
"Provider": "azure",
"CheckID": "cosmosdb_account_backup_policy_continuous",
"CheckTitle": "Cosmos DB account uses continuous backup policy",
"CheckType": [],
"ServiceName": "cosmosdb",
"SubServiceName": "",
"ResourceIdTemplate": "",
"Severity": "medium",
"ResourceType": "microsoft.documentdb/databaseaccounts",
"ResourceGroup": "database",
"Description": "**Azure Cosmos DB accounts** are evaluated for **continuous backup** policy. Continuous backup provides **point-in-time restore (PITR)** enabling recovery to any point within the retention window, unlike periodic backup which only supports full restores at fixed intervals.",
"Risk": "**Periodic backup** limits recovery to the last backup snapshot. Data changes between snapshots are lost during restore. **Continuous backup** enables **granular recovery** from accidental deletes, corruption, or ransomware.",
"RelatedUrl": "",
"AdditionalURLs": [
"https://learn.microsoft.com/en-us/azure/cosmos-db/continuous-backup-restore-introduction",
"https://learn.microsoft.com/en-us/azure/cosmos-db/migrate-continuous-backup",
"https://learn.microsoft.com/en-us/azure/cosmos-db/restore-account-continuous-backup"
],
"Remediation": {
"Code": {
"CLI": "az cosmosdb update --name <COSMOS_ACCOUNT_NAME> --resource-group <RESOURCE_GROUP> --backup-policy-type Continuous --continuous-tier Continuous30Days",
"NativeIaC": "```bicep\n// Bicep: Switch a Cosmos DB account to Continuous backup (irreversible)\nresource account 'Microsoft.DocumentDB/databaseAccounts@2025-10-15' = {\n name: '<example_resource_name>'\n location: resourceGroup().location\n kind: 'GlobalDocumentDB'\n properties: {\n databaseAccountOfferType: 'Standard'\n locations: [{ locationName: resourceGroup().location }]\n backupPolicy: {\n type: 'Continuous' // Critical: Enables point-in-time restore. Migration from Periodic is one-way.\n continuousModeProperties: {\n tier: 'Continuous30Days' // or 'Continuous7Days' for the lower-cost tier\n }\n }\n }\n}\n```",
"Other": "1. Sign in to the Azure portal and open your Cosmos DB account\n2. In the left menu, select Backup & Restore\n3. Click Switch to Continuous backup\n4. Select the retention tier (Continuous30Days or Continuous7Days)\n5. Acknowledge that the migration from Periodic to Continuous is irreversible\n6. Click Save",
"Terraform": "```hcl\n# Terraform: Configure Cosmos DB account with Continuous backup (irreversible)\nresource \"azurerm_cosmosdb_account\" \"<example_resource_name>\" {\n name = \"<example_resource_name>\"\n resource_group_name = \"<example_resource_name>\"\n location = \"<example_resource_name>\"\n offer_type = \"Standard\"\n kind = \"GlobalDocumentDB\"\n\n geo_location {\n location = \"<example_resource_name>\"\n failover_priority = 0\n }\n\n backup {\n type = \"Continuous\" # Critical: Enables point-in-time restore. One-way migration from Periodic.\n tier = \"Continuous30Days\" # or \"Continuous7Days\" for the lower-cost tier\n }\n}\n```"
},
"Recommendation": {
"Text": "Use **Continuous backup** for Cosmos DB accounts that require **point-in-time restore (PITR)**. Pick the retention tier (`Continuous7Days` or `Continuous30Days`) based on recovery objectives, and validate restore procedures with periodic drills. Note that switching from **Periodic** to **Continuous** is a **one-way** migration; plan the change and review pricing before applying.",
"Url": "https://hub.prowler.com/check/cosmosdb_account_backup_policy_continuous"
}
},
"Categories": [
"forensics-ready"
],
"DependsOn": [],
"RelatedTo": [],
"Notes": ""
}
@@ -0,0 +1,30 @@
from prowler.lib.check.models import Check, Check_Report_Azure
from prowler.providers.azure.services.cosmosdb.cosmosdb_client import cosmosdb_client
class cosmosdb_account_backup_policy_continuous(Check):
"""Ensure that Cosmos DB accounts use the continuous backup policy."""
def execute(self) -> Check_Report_Azure:
"""Execute the Cosmos DB continuous-backup check.
Iterates over every Cosmos DB account fetched by the service and reports
PASS when `backupPolicy.type` is `Continuous`, FAIL otherwise (including
when the property is missing).
Returns:
A list of Check_Report_Azure with one report per Cosmos DB account.
"""
findings = []
for subscription, accounts in cosmosdb_client.accounts.items():
for account in accounts:
report = Check_Report_Azure(metadata=self.metadata(), resource=account)
report.subscription = subscription
report.status = "FAIL"
report.status_extended = f"CosmosDB account {account.name} from subscription {subscription} does not use continuous backup policy."
if account.backup_policy_type == "Continuous":
report.status = "PASS"
report.status_extended = f"CosmosDB account {account.name} from subscription {subscription} uses continuous backup policy."
findings.append(report)
return findings
@@ -1,9 +1,12 @@
import re
from pydantic.v1 import BaseModel
from prowler.lib.logger import logger
from prowler.providers.gcp.config import DEFAULT_RETRY_ATTEMPTS
from prowler.providers.gcp.gcp_provider import GcpProvider
from prowler.providers.gcp.lib.service.service import GCPService
from prowler.providers.gcp.services.monitoring.monitoring_service import Monitoring
class Logging(GCPService):
@@ -121,9 +124,86 @@ class Metric(BaseModel):
bucket_name: str = ""
# A positive selector of the Admin Activity stream: a ``logName`` predicate
# (``:`` has-substring or ``=`` equals) or a ``log_id()`` call. Written verbose
# so each fragment stays legible; ``(?![a-z_])`` keeps a longer stream name
# (``.../activity_v2``) from impersonating Admin Activity.
_ACTIVITY_SELECTOR = re.compile(
r"""
(?: logName \s* [:=] \s* | log_id \s* \( \s* ) # logName: / logName= / log_id(
["']? [^"'\s)]* # optional quote, then path prefix
cloudaudit\.googleapis\.com/activity (?![a-z_]) # the Admin Activity stream itself
""",
re.IGNORECASE | re.VERBOSE,
)
# The same selector for *any* Cloud Audit stream (activity, data_access,
# system_event, policy, access_transparency, …). Used to strip the OR-combined
# audit clauses so we can prove nothing restrictive is left over.
_CLOUDAUDIT_SELECTOR = re.compile(
r"""
(?: logName \s* [:=] \s* | log_id \s* \( \s* ) # logName: / logName= / log_id(
["']? [^"'\s)]* # optional quote, then path prefix
cloudaudit\.googleapis\.com/[a-z_]+ # any cloudaudit stream
["']? \s* \)? # optional closing quote / paren
""",
re.IGNORECASE | re.VERBOSE,
)
# Operators that exclude or narrow coverage. Any of these means we cannot prove
# the sink delivers the *whole* Admin Activity stream, so it is not credited.
_NEGATION_OR_RESTRICTION = re.compile(
r"""
\bNOT\b # NOT exclusion
| \bAND\b # AND conjunction (restriction)
| != | !: # "!=" / "!:" inequality
| (?:^|[\s(]) -\s* [A-Za-z_] # leading "-" exclusion operator
""",
re.IGNORECASE | re.VERBOSE,
)
def _sink_delivers_activity_logs(sink_filter: str) -> bool:
"""True only when a sink's filter *provably* exports the full Admin Activity
audit stream (or everything).
Crediting flips a child project to PASS on a CIS security control, so the
match is deliberately conservative: a false FAIL is safe, a false PASS is
not. A non-``"all"`` filter is credited only when
1. it positively selects the Admin Activity stream
(``logName:.../activity``, ``logName="...activity"`` or
``log_id("...activity")``);
2. it carries no operator that excludes or narrows the stream ``NOT`` /
``-`` / ``!=`` (negation) or ``AND`` (restriction); and
3. nothing but ``OR``-combined Cloud Audit selectors remains once those are
stripped an ``OR`` only widens coverage, but any leftover predicate
(``severity>=ERROR``, ``resource.type=...``) could narrow it.
Sink filters encode the stream URL-encoded (``...%2Factivity``) or as a path
normalize before matching.
"""
if not sink_filter or sink_filter.strip().lower() == "all":
return True
normalized = sink_filter.replace("%2F", "/").replace("%2f", "/")
# 1. The Admin Activity stream must be positively selected.
if not _ACTIVITY_SELECTOR.search(normalized):
return False
# 2. No operator may exclude or narrow that coverage.
if _NEGATION_OR_RESTRICTION.search(normalized):
return False
# 3. Only OR-combined audit selectors may remain — strip them and the OR
# glue; anything left is a predicate we cannot prove is full-coverage.
remainder = _CLOUDAUDIT_SELECTOR.sub(" ", normalized)
remainder = re.sub(r"\bOR\b|[()\s]", " ", remainder, flags=re.IGNORECASE)
return remainder.strip() == ""
def get_projects_covered_by_aggregated_metric(
logging_client, monitoring_client, metric_filter
):
logging_client: Logging,
monitoring_client: Monitoring,
metric_filter: str,
) -> dict[str, str]:
"""Return {project_id: metric_name} for scanned projects whose logs are routed,
via an organization-level sink with includeChildren=True, to a bucket that holds
a bucket-scoped log metric matching ``metric_filter`` that has an alert policy.
@@ -133,6 +213,10 @@ def get_projects_covered_by_aggregated_metric(
every child project's logs into one bucket, where a single bucket-scoped metric
+ alert covers them all. Without crediting that, those child projects are falsely
failed. Mirrors the org-sink handling already in ``logging_sink_created`` (#11355).
A sink is credited when it exports everything (``filter == "all"``) or when its
filter carries the Admin Activity audit stream the only stream the CIS metric
filters can match (see ``_sink_delivers_activity_logs``).
"""
# Buckets that hold a matching, alerted, bucket-scoped metric -> metric name.
bucket_to_metric = {}
@@ -155,7 +239,7 @@ def get_projects_covered_by_aggregated_metric(
for sink in logging_client.sinks:
if not getattr(sink, "include_children", False):
continue
if getattr(sink, "filter", "all") != "all":
if not _sink_delivers_activity_logs(getattr(sink, "filter", "all")):
continue
for bucket, metric_name in bucket_to_metric.items():
# sink.destination e.g. "logging.googleapis.com/projects/.../buckets/X";
+23 -13
View File
@@ -32,10 +32,10 @@ classifiers = [
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12"
"Programming Language :: Python :: 3.12",
"Programming Language :: Python :: 3.13"
]
dependencies = [
"awsipranges==0.3.3",
"alive-progress==3.3.0",
"azure-identity==1.21.0",
"azure-keyvault-keys==4.10.0",
@@ -79,9 +79,9 @@ dependencies = [
"jsonschema==4.23.0",
"kubernetes==32.0.1",
"markdown==3.10.2",
"microsoft-kiota-abstractions==1.9.2",
"microsoft-kiota-abstractions==1.9.9",
"numpy==2.2.6",
"msgraph-sdk==1.55.0",
"numpy==2.0.2",
"okta==3.4.2",
"openstacksdk==4.2.0",
"pandas==2.2.3",
@@ -100,7 +100,7 @@ dependencies = [
"tabulate==0.9.0",
"tzlocal==5.3.1",
"uuid6==2024.7.10",
"py-iam-expand==0.1.0",
"py-iam-expand==0.3.0",
"h2==4.3.0",
"oci==2.169.0",
"alibabacloud_credentials==1.0.3",
@@ -123,7 +123,7 @@ license = "Apache-2.0"
maintainers = [{name = "Prowler Engineering", email = "engineering@prowler.com"}]
name = "prowler"
readme = "README.md"
requires-python = ">=3.10,<3.13"
requires-python = ">=3.10,<3.14"
version = "5.31.0"
[project.scripts]
@@ -162,7 +162,7 @@ constraint-dependencies = [
"aenum==3.1.17",
"aiofiles==24.1.0",
"aiohappyeyeballs==2.6.1",
"aiohttp==3.13.5",
"aiohttp==3.14.0",
"aiosignal==1.4.0",
"alibabacloud-actiontrail20200706==2.4.1",
"alibabacloud-credentials==1.0.3",
@@ -267,12 +267,12 @@ constraint-dependencies = [
"markupsafe==3.0.3",
"mccabe==0.7.0",
"mdurl==0.1.2",
"microsoft-kiota-authentication-azure==1.9.2",
"microsoft-kiota-http==1.9.2",
"microsoft-kiota-serialization-form==1.9.2",
"microsoft-kiota-serialization-json==1.9.2",
"microsoft-kiota-serialization-multipart==1.9.2",
"microsoft-kiota-serialization-text==1.9.2",
"microsoft-kiota-authentication-azure==1.9.9",
"microsoft-kiota-http==1.9.9",
"microsoft-kiota-serialization-form==1.9.9",
"microsoft-kiota-serialization-json==1.9.9",
"microsoft-kiota-serialization-multipart==1.9.9",
"microsoft-kiota-serialization-text==1.9.9",
"mock==5.2.0",
"moto==5.1.11",
"mpmath==1.3.0",
@@ -364,3 +364,13 @@ constraint-dependencies = [
"zstd==1.5.7.3"
]
override-dependencies = ["okta==3.4.2"]
[tool.vulture]
# Suppress known false positives. The CI command only passes --exclude and
# --min-confidence on the CLI, so ignore_names from here still applies (vulture
# only overrides the keys set on the CLI).
# - mock_* : pytest fixtures injected as test params but unused in the body
# (e.g. mock_sensitive_args in tests/lib/cli/redact_test.py)
# - view : DRF BasePermission.has_object_permission(self, request, view, obj)
# framework-required signature param in skills/django-drf template assets
ignore_names = ["mock_*", "view"]
+99
View File
@@ -0,0 +1,99 @@
---
name: prowler-tour
description: >
Keeps product-tour definitions aligned with the UI features they describe.
Trigger: When modifying UI components that have associated tours, editing tour
definition files, or renaming data-tour-id attributes.
license: Apache-2.0
metadata:
author: prowler-cloud
version: "1.0"
scope: [root, ui]
auto_invoke:
- "Editing a UI file containing data-tour-id attributes"
- "Adding, updating, or removing a tour definition (*.tour.ts)"
- "Renaming or removing a data-tour-id attribute value"
- "Changing button labels or section headings on a tour-covered page"
- "Restructuring routes or layouts covered by a tour"
allowed-tools: Read, Glob, Grep
---
# prowler-tour
**Report-only.** This skill never edits tour files or UI files; it inspects
the change, reports drift it finds between tours and the covered UI, and
recommends actions for the developer to apply.
## Early-exit rule
Run this check first. Most UI edits are not tour-related — exit cheaply.
1. Glob `ui/lib/tours/*.tour.ts`.
2. For each tour, check whether any `coversFiles` glob pattern matches any
file in the current change.
3. If no tour matches, respond **exactly**:
> No tour affected — skipping alignment check
and exit. Do not proceed to the checklist.
4. If at least one tour matches, continue to "Drift checklist" for that tour.
## Drift checklist
For each affected tour, evaluate every item. Skip items that obviously do
not apply, but list explicitly which items were checked.
1. **Orphan selectors** — every step's `target` (which composes to
`data-tour-id="<tour-id>-<step.target>"`) must resolve to a real element
in the codebase. Grep `ui/` for the expected attribute value; report
any step whose target is missing.
2. **Renamed selectors** — a `data-tour-id` attribute was edited in this
change. Match it back to any tour step referencing the old value.
3. **Outdated copy** — a popover `title`/`description` references a button
label, heading, or term that no longer exists on the covered page.
4. **Obsolete steps** — a step describes a section, panel, or workflow
that was removed.
5. **Missing steps** — a new feature was added on the covered surface
without a corresponding step (e.g. a new panel, a new primary action,
a new wizard stage).
6. **Reordered flow** — the user's path through the feature changed (e.g.
query builder moved before scan selection) and the step order no
longer reflects it.
## Version-bump decision tree
Apply per tour after listing drift:
- **NO bump** when the change is cosmetic. Examples: fix a typo, soften
copy, rename a `data-tour-id` selector while keeping the same step,
swap one screenshot for another, tighten wording.
- **BUMP `version`** when the user-visible flow changes materially.
Examples: a new step was added or removed; the order changed; an
anchored target was retargeted to a different panel; the tour now
covers a new feature on the surface.
When in doubt, ask: "Would a user who already saw the previous version
miss something useful by not seeing this one?" If yes, bump.
## Output format
When emitting a report, follow the exact structure in
`references/output-format.md`. The structure is mandatory because the
report is consumed downstream and tolerates no field reordering.
## What this skill MUST NOT do
- Do not edit `*.tour.ts` files. This skill is report-only.
- Do not edit UI files to add or rename `data-tour-id` attributes.
- Do not invent new tours. Authoring a new tour is a separate, deliberate
decision — the developer makes it, not the skill.
- Do not flag drift in tours whose `coversFiles` do not match any file
in the current change. Stick to the early-exit rule.
## See also
- `references/output-format.md` — exact report template (read when
emitting a report).
- `references/tours-architecture.md` — code map for the tour abstraction
under `ui/lib/tours/`.
- `assets/tour-template.ts` — boilerplate for authoring a new `*.tour.ts`.
@@ -0,0 +1,51 @@
// @ts-nocheck -- template only; resolves once copied into `ui/lib/tours/`
/**
* Tour template copy this file to `ui/lib/tours/<your-id>.tour.ts` and
* fill in the placeholders. See `references/tours-architecture.md` for the
* design context.
*
* Conventions:
* - Declare via `defineTour({...})` (NOT `: TourDefinition`) so TS
* preserves the literal union of `target` values. `useDriverTour` uses
* that union to validate `stepHandlers` keys and `waitForStep` args.
* - `id` is kebab-case and unique across all tours.
* - Anchored steps reference DOM via `data-tour-id="<id>-<step.target>"`;
* the hook composes the CSS selector automatically.
* - `coversFiles` lists the globs that describe the tour's surface; the
* `prowler-tour` skill consumes this to decide whether to evaluate
* drift on a given change.
* - Material flow changes bump `version`; cosmetic edits do not.
*/
import {
defineTour,
TOUR_STEP_ALIGNMENTS,
TOUR_STEP_SIDES,
} from "@/lib/tours/tour-types";
export const yourTour = defineTour({
id: "your-tour-id",
version: 1,
coversFiles: [
// List the UI files this tour describes, using globs under `ui/`.
// Example: "ui/app/(prowler)/your-feature/**"
],
steps: [
{
// Modal step — no anchor. Use for intros, outros, and any step
// that does not point at a specific DOM element.
title: "Welcome",
description: "Short, plain-English description.",
},
{
// Anchored step. The hook resolves
// `[data-tour-id="your-tour-id-step-name"]` lazily, so the element
// can be conditionally rendered as long as it exists when the step
// becomes active.
target: "step-name",
side: TOUR_STEP_SIDES.BOTTOM,
align: TOUR_STEP_ALIGNMENTS.START,
title: "Where the action is",
description: "Tell the user what to look at here and why.",
},
],
});
@@ -0,0 +1,31 @@
# Tour Alignment Report — output format
The report is consumed downstream. Field names, order, and headings are
load-bearing — do not rename, reorder, or omit them.
## Template
```text
## Tour Alignment Report
**Tour:** `<tour-id>@v<version>`
**Files touched:** <comma-separated list of files in the change>
### Drift detected
- <one bullet per drift item; include file:line where available>
### Recommended actions
1. <numbered, actionable steps the developer should take>
### Version bump verdict
- <BUMP | NO bump> — <one-line rationale>
```
## Rules
- One report per affected tour. If multiple tours are affected, separate
reports with a `---` line.
- If no drift is detected for an affected tour, still emit the report:
put "No drift detected." under "Drift detected" and "None required."
under "Recommended actions". The verdict line is still mandatory.
- The verdict is exactly one of `BUMP` or `NO bump` — see the
version-bump decision tree in `SKILL.md`.
@@ -0,0 +1,44 @@
# Tours Architecture
The product-tour abstraction lives under [`ui/lib/tours/`](../../../ui/lib/tours/).
This skill operates on tour definitions that follow this architecture.
## Code map
| File | Purpose |
|---|---|
| `ui/lib/tours/tour-types.ts` | Public type surface: `TourDefinition`, `TourStep`, `TourId`, `TourCompletionRecord`, completion-state const map. Also exports `defineTour(...)` — the required authoring helper that preserves literal step `target`s so `useDriverTour` can type-check `stepHandlers` keys and `waitForStep` arguments. |
| `ui/lib/tours/tour-config.ts` | `baseDriverConfig`, `getDriverConfig(theme, overrides?)`, overlay-color map. |
| `ui/lib/tours/store/tour-completion-store.ts` | Persistence interface — the swap point for future API adapters. |
| `ui/lib/tours/store/local-storage-adapter.ts` | The only adapter in the PoC. Key format: `prowler.tour.<id>.v<version>`. |
| `ui/lib/tours/use-driver-tour.ts` | React hook. Initializes driver.js, derives `overlayColor` from `useTheme()`, persists completion. |
| `ui/lib/tours/<id>.tour.ts` | One file per tour. Declared via `defineTour({...})` (not `: TourDefinition`) and imported by the page that opts the user in. |
| `ui/styles/tours.css` | `.driver-popover.prowler-theme` — every color resolved via `var(--...)` from `globals.css`. |
## Selector convention
Tour steps anchor via `data-tour-id="<tour-id>-<step.target>"`. The hook
composes the CSS selector at runtime; tour authors only provide the step
name in `step.target`. Class-based, ID-based, structural selectors are
forbidden — they couple tours to styling decisions that legitimately
change.
## Identity and versioning
A tour is `{ id, version }`. The localStorage key composes both. A
**material content change** bumps `version`; cosmetic edits do not. The
decision tree lives in the parent SKILL.md.
## Persistence scope
Per-user, cross-tenant. A user who completed `attack-paths@v1` in tenant
A does not see the tour again in tenant B, even if they can access the
feature there. The future `UserTourState` model (documented in
`design.md`, not built) is FK to `User`, not `Membership`.
## Drift = #1 risk
Without the maintenance skill + the optional CI gate
(`ui/scripts/check-tour-alignment.mjs`), tours decay silently as the
covered UI evolves. The parent SKILL.md enumerates the six drift
categories the skill checks for.
+82
View File
@@ -339,6 +339,88 @@ class TestJiraIntegration:
with pytest.raises(JiraRefreshTokenError):
self.jira_integration.refresh_access_token()
@patch("prowler.lib.outputs.jira.jira.requests.post")
@patch.object(Jira, "get_cloud_id", return_value="test_cloud_id")
def test_get_auth_sends_timeout(self, mock_get_cloud_id, mock_post):
"""get_auth must pass a request timeout to avoid hanging on an unresponsive Jira."""
# To disable vulture
mock_get_cloud_id = mock_get_cloud_id
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.json.return_value = {
"access_token": "test_access_token",
"refresh_token": "test_refresh_token",
"expires_in": 3600,
}
mock_post.return_value = mock_response
self.jira_integration.get_auth("test_auth_code")
assert mock_post.call_args.kwargs["timeout"] == Jira.REQUEST_TIMEOUT
@patch("prowler.lib.outputs.jira.jira.requests.get")
def test_get_cloud_id_sends_timeout(self, mock_get):
"""get_cloud_id (OAuth path) must pass a request timeout."""
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.json.return_value = [{"id": "test_cloud_id"}]
mock_get.return_value = mock_response
self.jira_integration.get_cloud_id("test_access_token")
assert mock_get.call_args.kwargs["timeout"] == Jira.REQUEST_TIMEOUT
@patch("prowler.lib.outputs.jira.jira.requests.get")
def test_get_cloud_id_basic_auth_sends_timeout(self, mock_get):
"""get_cloud_id (basic-auth tenant_info path) must pass a request timeout."""
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.json.return_value = {"cloudId": "test_cloud_id"}
mock_get.return_value = mock_response
self.jira_integration_basic_auth.get_cloud_id(domain=self.domain)
assert mock_get.call_args.kwargs["timeout"] == Jira.REQUEST_TIMEOUT
@patch("prowler.lib.outputs.jira.jira.requests.post")
def test_refresh_access_token_sends_timeout(self, mock_post):
"""refresh_access_token must pass a request timeout."""
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.json.return_value = {
"access_token": "new_access_token",
"refresh_token": "new_refresh_token",
"expires_in": 3600,
}
mock_post.return_value = mock_response
self.jira_integration.refresh_access_token()
assert mock_post.call_args.kwargs["timeout"] == Jira.REQUEST_TIMEOUT
@patch.object(Jira, "get_access_token", return_value="valid_access_token")
@patch.object(
Jira, "cloud_id", new_callable=PropertyMock, return_value="test_cloud_id"
)
@patch("prowler.lib.outputs.jira.jira.requests.get")
def test_get_projects_sends_timeout(
self, mock_get, mock_cloud_id, mock_get_access_token
):
"""get_projects must pass a request timeout."""
# To disable vulture
mock_cloud_id = mock_cloud_id
mock_get_access_token = mock_get_access_token
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.json.return_value = [{"key": "PROJ1", "name": "Project One"}]
mock_get.return_value = mock_response
self.jira_integration.get_projects()
assert mock_get.call_args.kwargs["timeout"] == Jira.REQUEST_TIMEOUT
@patch.object(Jira, "get_auth", return_value=None)
@patch.object(
Jira,
@@ -0,0 +1,112 @@
import json
import urllib.error
from ipaddress import IPv4Network, IPv6Network, ip_address
from unittest.mock import MagicMock, patch
from prowler.providers.aws.lib.ip_ranges.ip_ranges import get_public_ip_networks
URLOPEN_TARGET = "prowler.providers.aws.lib.ip_ranges.ip_ranges.urllib.request.urlopen"
SAMPLE_RANGES = {
"prefixes": [
{"ip_prefix": "54.152.0.0/16", "service": "AMAZON"},
{"ip_prefix": "3.5.140.0/22", "service": "S3"},
],
"ipv6_prefixes": [
{"ipv6_prefix": "2600:1f00::/24", "service": "AMAZON"},
],
}
def mock_urlopen(payload):
response = MagicMock()
response.read.return_value = json.dumps(payload).encode()
context_manager = MagicMock()
context_manager.__enter__.return_value = response
context_manager.__exit__.return_value = False
return context_manager
class TestGetPublicIPNetworks:
def test_parses_ipv4_and_ipv6_prefixes(self):
with patch(URLOPEN_TARGET, return_value=mock_urlopen(SAMPLE_RANGES)):
networks = get_public_ip_networks()
assert networks == [
IPv4Network("54.152.0.0/16"),
IPv4Network("3.5.140.0/22"),
IPv6Network("2600:1f00::/24"),
]
def test_known_aws_ip_is_contained(self):
with patch(URLOPEN_TARGET, return_value=mock_urlopen(SAMPLE_RANGES)):
networks = get_public_ip_networks()
assert any(ip_address("54.152.12.70") in network for network in networks)
def test_external_ip_is_not_contained(self):
with patch(URLOPEN_TARGET, return_value=mock_urlopen(SAMPLE_RANGES)):
networks = get_public_ip_networks()
assert not any(ip_address("17.5.7.3") in network for network in networks)
def test_empty_payload_returns_empty_list(self):
with patch(
"prowler.providers.aws.lib.ip_ranges.ip_ranges.urllib.request.urlopen",
return_value=mock_urlopen({}),
):
networks = get_public_ip_networks()
assert networks == []
def test_prefixes_missing_cidr_are_skipped(self):
payload = {
"prefixes": [{"ip_prefix": "10.0.0.0/8"}, {"service": "EC2"}],
"ipv6_prefixes": [{"service": "AMAZON"}],
}
with patch(URLOPEN_TARGET, return_value=mock_urlopen(payload)):
networks = get_public_ip_networks()
assert networks == [IPv4Network("10.0.0.0/8")]
def test_urlopen_failure_returns_empty_list(self):
with patch(URLOPEN_TARGET, side_effect=urllib.error.URLError("boom")):
networks = get_public_ip_networks()
assert networks == []
def test_timeout_returns_empty_list(self):
with patch(URLOPEN_TARGET, side_effect=TimeoutError("timed out")):
networks = get_public_ip_networks()
assert networks == []
def test_invalid_json_returns_empty_list(self):
response = MagicMock()
response.read.return_value = b"not json"
context_manager = MagicMock()
context_manager.__enter__.return_value = response
context_manager.__exit__.return_value = False
with patch(URLOPEN_TARGET, return_value=context_manager):
networks = get_public_ip_networks()
assert networks == []
def test_malformed_cidr_is_skipped(self):
payload = {
"prefixes": [
{"ip_prefix": "300.0.0.0/8"},
{"ip_prefix": "10.0.0.0/8"},
],
"ipv6_prefixes": [
{"ipv6_prefix": "2600::/129"},
{"ipv6_prefix": "2600:1f00::/24"},
],
}
with patch(URLOPEN_TARGET, return_value=mock_urlopen(payload)):
networks = get_public_ip_networks()
assert networks == [
IPv4Network("10.0.0.0/8"),
IPv6Network("2600:1f00::/24"),
]
@@ -1,3 +1,4 @@
import ssl
import urllib.error
import urllib.request
from unittest import mock
@@ -67,7 +68,7 @@ class Test_codepipeline_project_repo_private:
"Connection": {"ProviderType": "GitHub"}
}
def mock_urlopen_side_effect(req, context=None):
def mock_urlopen_side_effect(req, **kwargs):
raise urllib.error.HTTPError(
url="", code=404, msg="", hdrs={}, fp=None
)
@@ -145,7 +146,7 @@ class Test_codepipeline_project_repo_private:
mock_response.getcode.return_value = 200
mock_response.geturl.return_value = f"https://github.com/{repo_id}"
def mock_urlopen_side_effect(req, context=None):
def mock_urlopen_side_effect(req, **kwargs):
if "github.com" in req.get_full_url():
return mock_response
raise urllib.error.HTTPError(
@@ -172,6 +173,145 @@ class Test_codepipeline_project_repo_private:
assert result[0].resource_tags == []
assert result[0].region == AWS_REGION
def test_pipeline_repo_ssl_verification_failure(self):
"""Test that a TLS certificate verification failure is treated as private.
When the probe cannot verify the server certificate (e.g. a MITM
presenting a forged certificate), the repository must not be reported
as public.
"""
with mock.patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=set_mocked_aws_provider([AWS_REGION]),
):
codepipeline_client = mock.MagicMock
pipeline_name = "test-pipeline"
pipeline_arn = f"arn:aws:codepipeline:{AWS_REGION}:{AWS_ACCOUNT_NUMBER}:pipeline/{pipeline_name}"
connection_arn = f"arn:aws:codestar-connections:{AWS_REGION}:{AWS_ACCOUNT_NUMBER}:connection/test-connection"
repo_id = "prowler-cloud/prowler-private"
codepipeline_client.pipelines = {
pipeline_arn: Pipeline(
name=pipeline_name,
arn=pipeline_arn,
region=AWS_REGION,
source=Source(
type="CodeStarSourceConnection",
repository_id=repo_id,
configuration={
"FullRepositoryId": repo_id,
"ConnectionArn": connection_arn,
},
),
tags=[],
)
}
with (
mock.patch(
"prowler.providers.aws.services.codepipeline.codepipeline_service.CodePipeline",
codepipeline_client,
),
mock.patch(
"prowler.providers.aws.services.codepipeline.codepipeline_project_repo_private.codepipeline_project_repo_private.codepipeline_client",
codepipeline_client,
),
mock.patch("boto3.client") as mock_client,
mock.patch("urllib.request.urlopen") as mock_urlopen,
):
mock_connection = mock_client.return_value
mock_connection.get_connection.return_value = {
"Connection": {"ProviderType": "GitHub"}
}
def mock_urlopen_side_effect(req, **kwargs):
raise ssl.SSLError("certificate verify failed")
mock_urlopen.side_effect = mock_urlopen_side_effect
from prowler.providers.aws.services.codepipeline.codepipeline_project_repo_private.codepipeline_project_repo_private import (
codepipeline_project_repo_private,
)
check = codepipeline_project_repo_private()
result = check.execute()
assert len(result) == 1
assert result[0].status == "PASS"
assert (
result[0].status_extended
== f"CodePipeline {pipeline_name} source repository {repo_id} is private."
)
def test_pipeline_repo_probe_verifies_certificate_and_sets_timeout(self):
"""Test the probe never disables certificate verification and sets a timeout.
Regression test for the SSL verification bypass: the check must not use
an unverified SSL context, and every request must carry a timeout.
"""
with mock.patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=set_mocked_aws_provider([AWS_REGION]),
):
codepipeline_client = mock.MagicMock
pipeline_name = "test-pipeline"
pipeline_arn = f"arn:aws:codepipeline:{AWS_REGION}:{AWS_ACCOUNT_NUMBER}:pipeline/{pipeline_name}"
connection_arn = f"arn:aws:codestar-connections:{AWS_REGION}:{AWS_ACCOUNT_NUMBER}:connection/test-connection"
repo_id = "prowler-cloud/prowler-private"
codepipeline_client.pipelines = {
pipeline_arn: Pipeline(
name=pipeline_name,
arn=pipeline_arn,
region=AWS_REGION,
source=Source(
type="CodeStarSourceConnection",
repository_id=repo_id,
configuration={
"FullRepositoryId": repo_id,
"ConnectionArn": connection_arn,
},
),
tags=[],
)
}
with (
mock.patch(
"prowler.providers.aws.services.codepipeline.codepipeline_service.CodePipeline",
codepipeline_client,
),
mock.patch(
"prowler.providers.aws.services.codepipeline.codepipeline_project_repo_private.codepipeline_project_repo_private.codepipeline_client",
codepipeline_client,
),
mock.patch("boto3.client") as mock_client,
mock.patch("urllib.request.urlopen") as mock_urlopen,
mock.patch("ssl._create_unverified_context") as mock_unverified,
):
mock_connection = mock_client.return_value
mock_connection.get_connection.return_value = {
"Connection": {"ProviderType": "GitHub"}
}
def mock_urlopen_side_effect(req, **kwargs):
raise urllib.error.HTTPError(
url="", code=404, msg="", hdrs={}, fp=None
)
mock_urlopen.side_effect = mock_urlopen_side_effect
from prowler.providers.aws.services.codepipeline.codepipeline_project_repo_private.codepipeline_project_repo_private import (
HTTP_TIMEOUT,
codepipeline_project_repo_private,
)
check = codepipeline_project_repo_private()
check.execute()
mock_unverified.assert_not_called()
assert mock_urlopen.call_count > 0
for call in mock_urlopen.call_args_list:
assert call.kwargs.get("timeout") == HTTP_TIMEOUT
def test_pipeline_public_gitlab_repo(self):
"""Test detection of public GitLab repository in CodePipeline.
Tests that the check correctly identifies a public GitLab repository
@@ -225,7 +365,7 @@ class Test_codepipeline_project_repo_private:
mock_response.getcode.return_value = 200
mock_response.geturl.return_value = f"https://gitlab.com/{repo_id}"
def mock_urlopen_side_effect(req, context=None):
def mock_urlopen_side_effect(req, **kwargs):
if "gitlab.com" in req.get_full_url():
return mock_response
raise urllib.error.HTTPError(
@@ -0,0 +1,157 @@
from unittest import mock
from tests.providers.azure.azure_fixtures import (
AZURE_SUBSCRIPTION_ID,
set_mocked_azure_provider,
)
class Test_cosmosdb_account_backup_policy_continuous:
def test_no_subscriptions(self):
cosmosdb_client = mock.MagicMock()
with (
mock.patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=set_mocked_azure_provider(),
),
mock.patch(
"prowler.providers.azure.services.cosmosdb.cosmosdb_account_backup_policy_continuous.cosmosdb_account_backup_policy_continuous.cosmosdb_client",
new=cosmosdb_client,
),
):
from prowler.providers.azure.services.cosmosdb.cosmosdb_account_backup_policy_continuous.cosmosdb_account_backup_policy_continuous import (
cosmosdb_account_backup_policy_continuous,
)
cosmosdb_client.accounts = {}
check = cosmosdb_account_backup_policy_continuous()
result = check.execute()
assert len(result) == 0
def test_pass(self):
cosmosdb_client = mock.MagicMock()
with (
mock.patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=set_mocked_azure_provider(),
),
mock.patch(
"prowler.providers.azure.services.cosmosdb.cosmosdb_account_backup_policy_continuous.cosmosdb_account_backup_policy_continuous.cosmosdb_client",
new=cosmosdb_client,
),
):
from prowler.providers.azure.services.cosmosdb.cosmosdb_account_backup_policy_continuous.cosmosdb_account_backup_policy_continuous import (
cosmosdb_account_backup_policy_continuous,
)
from prowler.providers.azure.services.cosmosdb.cosmosdb_service import (
Account,
)
cosmosdb_client.accounts = {
AZURE_SUBSCRIPTION_ID: [
Account(
id="/subscriptions/sub1/resourceGroups/rg1/providers/Microsoft.DocumentDB/databaseAccounts/test-account",
name="test-account",
kind="GlobalDocumentDB",
type="Microsoft.DocumentDB/databaseAccounts",
tags={},
is_virtual_network_filter_enabled=False,
location="eastus",
private_endpoint_connections=[],
disable_local_auth=False,
backup_policy_type="Continuous",
)
]
}
check = cosmosdb_account_backup_policy_continuous()
result = check.execute()
assert len(result) == 1
assert result[0].status == "PASS"
def test_fail_periodic(self):
cosmosdb_client = mock.MagicMock()
with (
mock.patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=set_mocked_azure_provider(),
),
mock.patch(
"prowler.providers.azure.services.cosmosdb.cosmosdb_account_backup_policy_continuous.cosmosdb_account_backup_policy_continuous.cosmosdb_client",
new=cosmosdb_client,
),
):
from prowler.providers.azure.services.cosmosdb.cosmosdb_account_backup_policy_continuous.cosmosdb_account_backup_policy_continuous import (
cosmosdb_account_backup_policy_continuous,
)
from prowler.providers.azure.services.cosmosdb.cosmosdb_service import (
Account,
)
cosmosdb_client.accounts = {
AZURE_SUBSCRIPTION_ID: [
Account(
id="/subscriptions/sub1/resourceGroups/rg1/providers/Microsoft.DocumentDB/databaseAccounts/test-account",
name="test-account",
kind="GlobalDocumentDB",
type="Microsoft.DocumentDB/databaseAccounts",
tags={},
is_virtual_network_filter_enabled=False,
location="eastus",
private_endpoint_connections=[],
disable_local_auth=False,
backup_policy_type="Periodic",
)
]
}
check = cosmosdb_account_backup_policy_continuous()
result = check.execute()
assert len(result) == 1
assert result[0].status == "FAIL"
def test_fail_no_backup_policy(self):
cosmosdb_client = mock.MagicMock()
with (
mock.patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=set_mocked_azure_provider(),
),
mock.patch(
"prowler.providers.azure.services.cosmosdb.cosmosdb_account_backup_policy_continuous.cosmosdb_account_backup_policy_continuous.cosmosdb_client",
new=cosmosdb_client,
),
):
from prowler.providers.azure.services.cosmosdb.cosmosdb_account_backup_policy_continuous.cosmosdb_account_backup_policy_continuous import (
cosmosdb_account_backup_policy_continuous,
)
from prowler.providers.azure.services.cosmosdb.cosmosdb_service import (
Account,
)
cosmosdb_client.accounts = {
AZURE_SUBSCRIPTION_ID: [
Account(
id="/subscriptions/sub1/resourceGroups/rg1/providers/Microsoft.DocumentDB/databaseAccounts/test-account",
name="test-account",
kind="GlobalDocumentDB",
type="Microsoft.DocumentDB/databaseAccounts",
tags={},
is_virtual_network_filter_enabled=False,
location="eastus",
private_endpoint_connections=[],
disable_local_auth=False,
backup_policy_type=None,
)
]
}
check = cosmosdb_account_backup_policy_continuous()
result = check.execute()
assert len(result) == 1
assert result[0].status == "FAIL"
@@ -1,5 +1,7 @@
from unittest.mock import MagicMock, patch
import pytest
from prowler.providers.gcp.services.logging.logging_service import Logging
from tests.providers.gcp.gcp_fixtures import (
GCP_PROJECT_ID,
@@ -291,6 +293,93 @@ class TestGetProjectsCoveredByAggregatedMetric:
)
assert self._run(logging_client, monitoring_client) == {}
def test_not_covered_when_sink_filter_omits_activity_stream(self):
"""A sink that routes cloudaudit streams but NOT Admin Activity (here,
data_access only) does not deliver the entries the CIS metric filters
match, so it must not be credited right service, wrong stream."""
logging_client, monitoring_client = self._clients(
sink_filter="logName: /logs/cloudaudit.googleapis.com%2Fdata_access"
)
assert self._run(logging_client, monitoring_client) == {}
def test_covered_when_sink_filter_carries_activity_stream_encoded(self):
"""A sink filtered to the cloudaudit streams (URL-encoded logName form,
as returned by the Logging API) delivers every Admin Activity entry the
CIS metric filters can match, so it must be credited."""
logging_client, monitoring_client = self._clients(
sink_filter=(
"logName: /logs/cloudaudit.googleapis.com%2Factivity OR "
"logName: /logs/cloudaudit.googleapis.com%2Fdata_access"
)
)
assert self._run(logging_client, monitoring_client) == {
GCP_PROJECT_ID: "central-metric"
}
def test_covered_when_sink_filter_carries_activity_stream_plain(self):
logging_client, monitoring_client = self._clients(
sink_filter='logName="projects/p/logs/cloudaudit.googleapis.com/activity"'
)
assert self._run(logging_client, monitoring_client) == {
GCP_PROJECT_ID: "central-metric"
}
@pytest.mark.parametrize(
"sink_filter",
[
# --- Negation: the stream is named but excluded. ---
'NOT logName:"projects/p/logs/cloudaudit.googleapis.com%2Factivity"',
'-logName:"projects/p/logs/cloudaudit.googleapis.com%2Factivity"',
'NOT log_id("cloudaudit.googleapis.com/activity")',
# "!=" inequality (and its spaced form) excludes the stream.
'logName!="projects/p/logs/cloudaudit.googleapis.com%2Factivity"',
'logName != "projects/p/logs/cloudaudit.googleapis.com/activity"',
# Activity negated inside a compound filter.
'resource.type="gce_instance" AND '
'NOT logName:"projects/p/logs/cloudaudit.googleapis.com%2Factivity"',
# --- Restriction: the stream is named but AND-narrowed, so only a
# subset of Admin Activity entries reaches the bucket. ---
'logName:"projects/p/logs/cloudaudit.googleapis.com%2Factivity" '
'AND resource.type="gce_instance"',
'log_id("cloudaudit.googleapis.com/activity") '
'AND resource.type="gce_instance"',
'logName="projects/p/logs/cloudaudit.googleapis.com/activity" '
"AND severity>=ERROR",
'logName:"projects/p/logs/cloudaudit.googleapis.com%2Factivity" '
'AND protoPayload.methodName="SetIamPolicy"',
# --- OR-ed with a non-audit predicate: fail closed, since we credit
# only unions of provable Cloud Audit stream selectors. ---
'logName:"projects/p/logs/cloudaudit.googleapis.com%2Factivity" '
"OR severity>=ERROR",
],
)
def test_not_covered_when_sink_filter_negated_or_restrictive(self, sink_filter):
"""A filter that names the Admin Activity stream but negates, narrows, or
mixes in an unprovable predicate is not credited we credit only filters
we can prove deliver every Admin Activity entry the CIS metrics match."""
logging_client, monitoring_client = self._clients(sink_filter=sink_filter)
assert self._run(logging_client, monitoring_client) == {}
def test_covered_when_activity_logname_has_hyphenated_path(self):
"""A hyphen in the project path must not be mistaken for the ``-`` (NOT)
negation operator the activity stream is still delivered."""
logging_client, monitoring_client = self._clients(
sink_filter='logName="projects/my-project/logs/cloudaudit.googleapis.com/activity"'
)
assert self._run(logging_client, monitoring_client) == {
GCP_PROJECT_ID: "central-metric"
}
def test_covered_when_sink_filter_uses_log_id_selector(self):
"""The ``log_id()`` form is an equivalent positive full-coverage selector
of the Admin Activity stream and is credited like the ``logName`` form."""
logging_client, monitoring_client = self._clients(
sink_filter='log_id("cloudaudit.googleapis.com/activity")'
)
assert self._run(logging_client, monitoring_client) == {
GCP_PROJECT_ID: "central-metric"
}
def test_not_covered_when_sink_destination_bucket_differs(self):
logging_client, monitoring_client = self._clients(
sink_destination="logging.googleapis.com/projects/x/locations/eu/buckets/other"
+35 -29
View File
@@ -14,40 +14,46 @@
> - [`playwright`](../skills/playwright/SKILL.md) - Page Object Model, selectors
> - [`vitest`](../skills/vitest/SKILL.md) - Unit testing with React Testing Library
> - [`tdd`](../skills/tdd/SKILL.md) - TDD workflow (MANDATORY for UI tasks)
> - [`prowler-tour`](../skills/prowler-tour/SKILL.md) - Keep product-tour definitions aligned with the UI
## Auto-invoke Skills
When performing these actions, ALWAYS invoke the corresponding skill FIRST:
| Action | Skill |
| -------------------------------------------------------------- | ------------------- |
| Add changelog entry for a PR or feature | `prowler-changelog` |
| App Router / Server Actions | `nextjs-16` |
| Building AI chat features | `ai-sdk-5` |
| Committing changes | `prowler-commit` |
| Create PR that requires changelog entry | `prowler-changelog` |
| Creating Zod schemas | `zod-4` |
| Creating a git commit | `prowler-commit` |
| Creating/modifying Prowler UI components | `prowler-ui` |
| Fixing bug | `tdd` |
| Implementing feature | `tdd` |
| Modifying component | `tdd` |
| Refactoring code | `tdd` |
| Review changelog format and conventions | `prowler-changelog` |
| Testing hooks or utilities | `vitest` |
| Update CHANGELOG.md in any component | `prowler-changelog` |
| Using Zustand stores | `zustand-5` |
| Working on Prowler UI structure (actions/adapters/types/hooks) | `prowler-ui` |
| Working on task | `tdd` |
| Working with Prowler UI test helpers/pages | `prowler-test-ui` |
| Working with Tailwind classes | `tailwind-4` |
| Writing Playwright E2E tests | `playwright` |
| Writing Prowler UI E2E tests | `prowler-test-ui` |
| Writing React component tests | `vitest` |
| Writing React components | `react-19` |
| Writing TypeScript types/interfaces | `typescript` |
| Writing Vitest tests | `vitest` |
| Writing unit tests for UI | `vitest` |
| Action | Skill |
| ----------------------------------------------------------------- | ------------------- |
| Add changelog entry for a PR or feature | `prowler-changelog` |
| Adding, updating, or removing a tour definition (\*.tour.ts) | `prowler-tour` |
| App Router / Server Actions | `nextjs-16` |
| Building AI chat features | `ai-sdk-5` |
| Changing button labels or section headings on a tour-covered page | `prowler-tour` |
| Committing changes | `prowler-commit` |
| Create PR that requires changelog entry | `prowler-changelog` |
| Creating Zod schemas | `zod-4` |
| Creating a git commit | `prowler-commit` |
| Creating/modifying Prowler UI components | `prowler-ui` |
| Editing a UI file containing data-tour-id attributes | `prowler-tour` |
| Fixing bug | `tdd` |
| Implementing feature | `tdd` |
| Modifying component | `tdd` |
| Refactoring code | `tdd` |
| Renaming or removing a data-tour-id attribute value | `prowler-tour` |
| Restructuring routes or layouts covered by a tour | `prowler-tour` |
| Review changelog format and conventions | `prowler-changelog` |
| Testing hooks or utilities | `vitest` |
| Update CHANGELOG.md in any component | `prowler-changelog` |
| Using Zustand stores | `zustand-5` |
| Working on Prowler UI structure (actions/adapters/types/hooks) | `prowler-ui` |
| Working on task | `tdd` |
| Working with Prowler UI test helpers/pages | `prowler-test-ui` |
| Working with Tailwind classes | `tailwind-4` |
| Writing Playwright E2E tests | `playwright` |
| Writing Prowler UI E2E tests | `prowler-test-ui` |
| Writing React component tests | `vitest` |
| Writing React components | `react-19` |
| Writing TypeScript types/interfaces | `typescript` |
| Writing Vitest tests | `vitest` |
| Writing unit tests for UI | `vitest` |
---
+8
View File
@@ -2,6 +2,14 @@
All notable changes to the **Prowler UI** are documented in this file.
## [1.31.0] (Prowler UNRELEASED)
### 🔐 Security
- Bump vulnerable `Next.js`, React, AI SDK, `postcss`, `hono`, `qs`, `esbuild`, and Alpine OpenSSL packages (`libcrypto3` and `libssl3`) [(#11581)](https://github.com/prowler-cloud/prowler/pull/11581)
---
## [1.30.1] (Prowler v5.30.1)
### 🐞 Fixed
+2 -2
View File
@@ -3,8 +3,8 @@ FROM node:24.13.0-alpine@sha256:cd6fb7efa6490f039f3471a189214d5f548c11df1ff9e5b1
LABEL maintainer="https://github.com/prowler-cloud"
# Enable corepack for pnpm
RUN corepack enable
# Patch Alpine OpenSSL runtime packages before all stages inherit the base image.
RUN apk upgrade --no-cache libcrypto3 libssl3 && corepack enable
# Install dependencies only when needed
FROM base AS deps
+27
View File
@@ -28,6 +28,10 @@ import {
vi.mock("@/lib", () => ({
apiBaseUrl: "https://api.example.com/api/v1",
getAuthHeaders: getAuthHeadersMock,
GENERIC_SERVER_ERROR_MESSAGE:
"Server is temporarily unavailable. Please try again in a few minutes.",
sanitizeErrorMessage: (message: string, fallback: string) =>
/<html\b|<\/?body\b|<\/?h1\b/i.test(message) ? fallback : message.trim(),
composeSort,
FG_FAIL_FIRST,
FG_RECENT_LAST_SEEN,
@@ -167,6 +171,29 @@ describe("getResourceEvents", () => {
});
});
it("returns a generic error when a gateway returns HTML", async () => {
// Given
const mockResponse = new Response(
"<html><head><title>502 Bad Gateway</title></head><body><h1>502 Bad Gateway</h1></body></html>",
{
status: 502,
statusText: "Bad Gateway",
headers: { "content-type": "text/html" },
},
);
fetchMock.mockResolvedValue(mockResponse);
// When
const result = await getResourceEvents("resource-123");
// Then
expect(result).toEqual({
error:
"Server is temporarily unavailable. Please try again in a few minutes.",
status: 502,
});
});
it("returns generic error when fetch throws", async () => {
// Given
fetchMock.mockRejectedValue(new Error("Network failure"));
+20 -9
View File
@@ -4,7 +4,13 @@ import { redirect } from "next/navigation";
import { getLatestFindings } from "@/actions/findings";
import { listOrganizationsSafe } from "@/actions/organizations/organizations";
import { apiBaseUrl, FINDINGS_FILTERED_SORT, getAuthHeaders } from "@/lib";
import {
apiBaseUrl,
FINDINGS_FILTERED_SORT,
GENERIC_SERVER_ERROR_MESSAGE,
getAuthHeaders,
sanitizeErrorMessage,
} from "@/lib";
import { appendSanitizedProviderTypeFilters } from "@/lib/provider-filters";
import { handleApiResponse } from "@/lib/server-actions-helper";
import { OrganizationResource } from "@/types/organizations";
@@ -193,22 +199,27 @@ export const getResourceEvents = async (
if (!response.ok) {
const rawText = await response.text().catch(() => "");
const contentType =
response.headers.get("content-type")?.toLowerCase() || "";
const defaultError = "An error occurred while fetching events.";
const fallbackError = contentType.includes("text/html")
? GENERIC_SERVER_ERROR_MESSAGE
: response.statusText || defaultError;
try {
const errorData = rawText ? JSON.parse(rawText) : null;
const errorMessage =
errorData?.errors?.[0]?.detail ||
errorData?.error ||
errorData?.message ||
rawText ||
fallbackError;
return {
error:
errorData?.errors?.[0]?.detail ||
errorData?.error ||
errorData?.message ||
rawText ||
response.statusText ||
defaultError,
error: sanitizeErrorMessage(String(errorMessage), fallbackError),
status: response.status,
};
} catch {
return {
error: rawText || response.statusText || defaultError,
error: sanitizeErrorMessage(rawText || fallbackError, fallbackError),
status: response.status,
};
}
+34 -1
View File
@@ -14,6 +14,8 @@ const {
vi.mock("@/lib", () => ({
apiBaseUrl: "https://api.example.com/api/v1",
GENERIC_SERVER_ERROR_MESSAGE:
"Server is temporarily unavailable. Please try again in a few minutes.",
getAuthHeaders: getAuthHeadersMock,
getErrorMessage: (error: unknown) =>
error instanceof Error ? error.message : String(error),
@@ -28,7 +30,7 @@ vi.mock("@/lib/sentry-breadcrumbs", () => ({
addScanOperation: vi.fn(),
}));
import { launchOrganizationScans } from "./scans";
import { getExportsZip, launchOrganizationScans } from "./scans";
describe("launchOrganizationScans", () => {
beforeEach(() => {
@@ -69,3 +71,34 @@ describe("launchOrganizationScans", () => {
expect(result.failureCount).toBe(0);
});
});
describe("getExportsZip", () => {
beforeEach(() => {
vi.clearAllMocks();
vi.stubGlobal("fetch", fetchMock);
getAuthHeadersMock.mockResolvedValue({ Authorization: "Bearer token" });
});
it("returns a generic server error when the report endpoint returns HTML", async () => {
// Given
fetchMock.mockResolvedValue(
new Response(
"<html><head><title>502 Bad Gateway</title></head><body><h1>502 Bad Gateway</h1></body></html>",
{
status: 502,
statusText: "Bad Gateway",
headers: { "content-type": "text/html" },
},
),
);
// When
const result = await getExportsZip("scan-123");
// Then
expect(result).toEqual({
error:
"Server is temporarily unavailable. Please try again in a few minutes.",
});
});
});
+48 -14
View File
@@ -3,7 +3,12 @@
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import { apiBaseUrl, getAuthHeaders, getErrorMessage } from "@/lib";
import {
apiBaseUrl,
GENERIC_SERVER_ERROR_MESSAGE,
getAuthHeaders,
getErrorMessage,
} from "@/lib";
import {
COMPLIANCE_REPORT_DISPLAY_NAMES,
type ComplianceReportType,
@@ -15,6 +20,7 @@ import {
} from "@/lib/provider-filters";
import { addScanOperation } from "@/lib/sentry-breadcrumbs";
import { handleApiError, handleApiResponse } from "@/lib/server-actions-helper";
import { SCAN_STATES } from "@/types/attack-paths";
const ORGANIZATION_SCAN_CONCURRENCY_LIMIT = 5;
export const getScans = async ({
@@ -64,6 +70,10 @@ export const getScansByState = async () => {
"filter[provider_type__in]",
sanitizeProviderTypesCsv(),
);
// Only need to know whether at least one completed scan exists; filter server-side
// and cap to a single row so the answer is correct regardless of total scan count.
url.searchParams.append("filter[state]", SCAN_STATES.COMPLETED);
url.searchParams.append("page[size]", "1");
try {
const response = await fetch(url.toString(), {
@@ -157,18 +167,20 @@ export const scheduleDaily = async (formData: FormData) => {
const url = new URL(`${apiBaseUrl}/schedules/daily`);
const body = {
data: {
type: "daily-schedules",
attributes: {
provider_id: providerId,
},
},
};
try {
const response = await fetch(url.toString(), {
method: "POST",
headers,
body: JSON.stringify({
data: {
type: "daily-schedules",
attributes: {
provider_id: providerId,
},
},
}),
body: JSON.stringify(body),
});
return handleApiResponse(response, "/scans");
@@ -244,6 +256,27 @@ export const launchOrganizationScans = async (
return summary;
};
async function getScanReportErrorMessage(
response: Response,
fallbackMessage: string,
): Promise<string> {
const contentType = response.headers.get("content-type")?.toLowerCase() || "";
if (contentType.includes("text/html")) {
return GENERIC_SERVER_ERROR_MESSAGE;
}
const errorData = await response.json().catch(() => null);
return (
errorData?.errors?.[0]?.detail ||
errorData?.errors?.detail ||
errorData?.error ||
errorData?.message ||
(response.status >= 500 ? GENERIC_SERVER_ERROR_MESSAGE : fallbackMessage)
);
}
export const updateScan = async (formData: FormData) => {
const headers = await getAuthHeaders({ contentType: true });
@@ -295,11 +328,11 @@ export const getExportsZip = async (scanId: string) => {
}
if (!response.ok) {
const errorData = await response.json();
throw new Error(
errorData?.errors?.detail ||
await getScanReportErrorMessage(
response,
"Unable to fetch scan report. Contact support if the issue continues.",
),
);
}
@@ -370,10 +403,11 @@ const _fetchScanBinary = async (
}
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new Error(
errorData?.errors?.detail ||
await getScanReportErrorMessage(
response,
`Unable to retrieve ${errorLabel}. Contact support if the issue continues.`,
),
);
}
+1
View File
@@ -0,0 +1 @@
export * from "./schedules";
+103
View File
@@ -0,0 +1,103 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { SCHEDULE_FREQUENCY } from "@/types/schedules";
const {
fetchMock,
getAuthHeadersMock,
handleApiErrorMock,
handleApiResponseMock,
revalidatePathMock,
} = vi.hoisted(() => ({
fetchMock: vi.fn(),
getAuthHeadersMock: vi.fn(),
handleApiErrorMock: vi.fn(),
handleApiResponseMock: vi.fn(),
revalidatePathMock: vi.fn(),
}));
vi.mock("next/cache", () => ({
revalidatePath: revalidatePathMock,
}));
vi.mock("@/lib", () => ({
apiBaseUrl: "https://api.example.com/api/v1",
getAuthHeaders: getAuthHeadersMock,
}));
vi.mock("@/lib/server-actions-helper", () => ({
handleApiError: handleApiErrorMock,
handleApiResponse: handleApiResponseMock,
}));
import { getSchedule, removeSchedule, updateSchedule } from "./schedules";
const PROVIDER_ID = "1795f636-37e6-42f6-b158-d4faaa64e0fc";
const payload = {
scan_enabled: true,
scan_frequency: SCHEDULE_FREQUENCY.DAILY,
scan_hour: 12,
scan_timezone: "Europe/Madrid",
scan_interval_hours: null,
scan_day_of_week: null,
scan_day_of_month: null,
};
describe("schedule write actions revalidate only on success", () => {
beforeEach(() => {
vi.stubGlobal("fetch", fetchMock);
getAuthHeadersMock.mockResolvedValue({ Authorization: "Bearer token" });
fetchMock.mockResolvedValue(new Response(null, { status: 204 }));
handleApiErrorMock.mockReturnValue({ error: "Failed" });
});
it("revalidates /scans and /providers after a successful update", async () => {
handleApiResponseMock.mockResolvedValue({ success: true });
await updateSchedule(PROVIDER_ID, payload);
expect(revalidatePathMock).toHaveBeenCalledWith("/scans");
expect(revalidatePathMock).toHaveBeenCalledWith("/providers");
});
it("does not revalidate when the update returns an error result", async () => {
handleApiResponseMock.mockResolvedValue({ error: "Schedule rejected" });
await updateSchedule(PROVIDER_ID, payload);
expect(revalidatePathMock).not.toHaveBeenCalled();
});
it("revalidates /scans and /providers after a successful delete", async () => {
handleApiResponseMock.mockResolvedValue({ success: true });
await removeSchedule(PROVIDER_ID);
expect(revalidatePathMock).toHaveBeenCalledWith("/scans");
expect(revalidatePathMock).toHaveBeenCalledWith("/providers");
});
it("does not revalidate when the delete returns an error result", async () => {
handleApiResponseMock.mockResolvedValue({ error: "Not allowed" });
await removeSchedule(PROVIDER_ID);
expect(revalidatePathMock).not.toHaveBeenCalled();
});
it("rejects non-UUID provider ids without issuing a request", async () => {
const malicious = "../users/me";
expect(await getSchedule(malicious)).toEqual({
error: "Invalid provider id.",
});
expect(await updateSchedule(malicious, payload)).toEqual({
error: "Invalid provider id.",
});
expect(await removeSchedule(malicious)).toEqual({
error: "Invalid provider id.",
});
expect(fetchMock).not.toHaveBeenCalled();
});
});
+128
View File
@@ -0,0 +1,128 @@
"use server";
import { revalidatePath } from "next/cache";
import { z } from "zod";
import { apiBaseUrl, getAuthHeaders } from "@/lib";
import { handleApiError, handleApiResponse } from "@/lib/server-actions-helper";
import type { ScheduleProps, ScheduleUpdatePayload } from "@/types/schedules";
// SSRF guard: the id is interpolated into the request URL, so only UUIDs pass.
const providerIdSchema = z.uuid();
function parseProviderId(providerId: string): string | null {
const parsed = providerIdSchema.safeParse(providerId);
return parsed.success ? parsed.data : null;
}
function revalidateScheduleViews() {
revalidatePath("/scans");
revalidatePath("/providers");
}
export const getSchedule = async (providerId: string) => {
const id = parseProviderId(providerId);
if (!id) return { error: "Invalid provider id." };
const headers = await getAuthHeaders({ contentType: false });
const url = new URL(`${apiBaseUrl}/schedules/${id}`);
url.searchParams.set("include", "provider");
try {
const response = await fetch(url.toString(), { headers });
return handleApiResponse(response);
} catch (error) {
return handleApiError(error);
}
};
/**
* Lists every schedule (one per provider), following pagination the backend
* has no multi-provider filter.
*/
export const getSchedules = async () => {
const headers = await getAuthHeaders({ contentType: false });
const schedules: ScheduleProps[] = [];
const MAX_PAGES = 20;
try {
for (let page = 1; page <= MAX_PAGES; page++) {
const url = new URL(`${apiBaseUrl}/schedules`);
url.searchParams.set("page[number]", String(page));
url.searchParams.set("page[size]", "100");
const response = await fetch(url.toString(), { headers });
const result = await handleApiResponse(response);
if (result?.error) return result;
schedules.push(...(result?.data ?? []));
const totalPages = result?.meta?.pagination?.pages ?? 1;
if (page >= totalPages) break;
}
return { data: schedules };
} catch (error) {
return handleApiError(error);
}
};
export const updateSchedule = async (
providerId: string,
payload: ScheduleUpdatePayload,
) => {
const id = parseProviderId(providerId);
if (!id) return { error: "Invalid provider id." };
const headers = await getAuthHeaders({ contentType: true });
const url = new URL(`${apiBaseUrl}/schedules/${id}`);
const body = {
data: {
type: "schedules",
id,
attributes: payload,
},
};
try {
const response = await fetch(url.toString(), {
method: "PATCH",
headers,
body: JSON.stringify(body),
});
const result = await handleApiResponse(response);
if (!result?.error) {
revalidateScheduleViews();
}
return result;
} catch (error) {
return handleApiError(error);
}
};
export const removeSchedule = async (providerId: string) => {
const id = parseProviderId(providerId);
if (!id) return { error: "Invalid provider id." };
const headers = await getAuthHeaders({ contentType: true });
const url = new URL(`${apiBaseUrl}/schedules/${id}`);
try {
const response = await fetch(url.toString(), {
method: "DELETE",
headers,
});
const result = await handleApiResponse(response);
if (!result?.error) {
revalidateScheduleViews();
}
return result;
} catch (error) {
return handleApiError(error);
}
};
@@ -58,6 +58,7 @@ vi.mock("@/components/ui/table", () => ({
data,
metadata,
controlledPage,
getRowAttributes,
}: {
columns: Array<{
id?: string;
@@ -74,6 +75,10 @@ vi.mock("@/components/ui/table", () => ({
};
};
controlledPage: number;
getRowAttributes?: (row: {
index: number;
original: AttackPathScan;
}) => Record<string, string | undefined>;
}) => (
<div>
<span>{metadata.pagination.count} Total Entries</span>
@@ -95,8 +100,8 @@ vi.mock("@/components/ui/table", () => ({
</tr>
</thead>
<tbody>
{data.map((row) => (
<tr key={row.id}>
{data.map((row, index) => (
<tr key={row.id} {...getRowAttributes?.({ index, original: row })}>
{columns.map((column, index) => (
<td key={column.id ?? index}>
{column.cell
@@ -176,6 +181,20 @@ describe("ScanListTable", () => {
);
});
it("anchors the attack paths scan tour to the first visible scan row", () => {
render(
<ScanListTable scans={[createScan(1), createScan(2), createScan(3)]} />,
);
const firstRow = screen
.getAllByRole("radio", {
name: "Select scan",
})[0]
.closest("tr");
expect(firstRow).toHaveAttribute("data-tour-id", "attack-paths-scan-list");
});
it("enables the radio button for a failed scan when graph data is ready", async () => {
const user = userEvent.setup();
const failedScan: AttackPathScan = {
@@ -295,6 +295,9 @@ export const ScanListTable = ({ scans }: ScanListTableProps) => {
handleSelectScan(row.original.id);
}
}}
getRowAttributes={(row) =>
row.index === 0 ? { "data-tour-id": "attack-paths-scan-list" } : {}
}
enableRowSelection
rowSelection={getSelectedRowSelection(paginatedScans, selectedScanId)}
/>
@@ -1,3 +1,4 @@
export { useAttackPathScans } from "./use-attack-path-scans";
export { useGraphState } from "./use-graph-state";
export { useQueryBuilder } from "./use-query-builder";
export { useWizardState } from "./use-wizard-state";
@@ -0,0 +1,81 @@
"use client";
import { useState } from "react";
import { getAttackPathScans } from "@/actions/attack-paths";
import { useMountEffect } from "@/hooks/use-mount-effect";
import type { AttackPathScan } from "@/types/attack-paths";
export interface UseAttackPathScansOptions {
/**
* Invoked once the initial load resolves with no scan whose graph data is
* ready (including empty results or a fetch failure). The page passes a
* redirect only during onboarding replay; an established user gets `undefined`
* and stays on the page.
*/
onNoReadyScan?: () => void;
}
export interface UseAttackPathScansResult {
scans: AttackPathScan[];
scansLoading: boolean;
refreshScans: () => Promise<void>;
}
/**
* `useData`-style hook owning the Attack Paths scan list. The direct
* `useEffect` (via `useMountEffect`) lives here, not in the component: the
* project forbids `useEffect` in components, but a reusable data hook is the
* sanctioned place for a mount-time fetch when no fetching library is wired up.
*/
export function useAttackPathScans(
options: UseAttackPathScansOptions = {},
): UseAttackPathScansResult {
const { onNoReadyScan } = options;
const [scans, setScans] = useState<AttackPathScan[]>([]);
const [scansLoading, setScansLoading] = useState(true);
const refreshScans = async () => {
try {
const scansData = await getAttackPathScans();
if (scansData?.data) {
setScans(scansData.data);
}
} catch (error) {
console.error("Failed to refresh scans:", error);
}
};
useMountEffect(() => {
let active = true;
const loadScans = async () => {
setScansLoading(true);
try {
const scansData = await getAttackPathScans();
const nextScans = scansData?.data ?? [];
if (!active) return;
setScans(nextScans);
if (!nextScans.some((scan) => scan.attributes.graph_data_ready)) {
onNoReadyScan?.();
}
} catch (error) {
if (!active) return;
console.error("Failed to load scans:", error);
setScans([]);
onNoReadyScan?.();
} finally {
if (active) setScansLoading(false);
}
};
void loadScans();
return () => {
active = false;
};
});
return { scans, scansLoading, refreshScans };
}
@@ -1,19 +0,0 @@
import { readFileSync } from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { describe, expect, it } from "vitest";
describe("AttackPathsPage", () => {
const currentDir = path.dirname(fileURLToPath(import.meta.url));
const filePath = path.join(currentDir, "attack-paths-page.tsx");
const source = readFileSync(filePath, "utf8");
it("keeps the page description without rendering a duplicate Attack Paths heading", () => {
// Then
expect(source).not.toContain(">\n Attack Paths\n </h2>");
expect(source).toContain(
"Select a scan, build a query, and visualize Attack Paths in your",
);
});
});
@@ -2,7 +2,7 @@
import { ArrowLeft, Info, Maximize2 } from "lucide-react";
import Link from "next/link";
import { useSearchParams } from "next/navigation";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import { Suspense, useEffect, useRef, useState } from "react";
import { FormProvider } from "react-hook-form";
@@ -10,11 +10,11 @@ import {
buildAttackPathQueries,
executeCustomQuery,
executeQuery,
getAttackPathScans,
getAvailableQueries,
} from "@/actions/attack-paths";
import { adaptQueryResultToGraphData } from "@/actions/attack-paths/query-result.adapter";
import { FindingDetailDrawer } from "@/components/findings/table";
import { PageReady } from "@/components/onboarding";
import { useFindingDetails } from "@/components/resources/table/use-finding-details";
import { AutoRefresh } from "@/components/scans";
import {
@@ -32,10 +32,19 @@ import {
DialogTrigger,
} from "@/components/shadcn/dialog";
import { useToast } from "@/components/ui";
import { useMountEffect } from "@/hooks/use-mount-effect";
import { isCloud } from "@/lib/shared/env";
import {
attackPathsTour,
type AttackPathsTourTarget,
pickDemoQuery,
pickDemoScan,
} from "@/lib/tours/attack-paths.tour";
import { attackPathsEmptyTour } from "@/lib/tours/attack-paths-empty.tour";
import { advanceActiveTour, useDriverTour } from "@/lib/tours/use-driver-tour";
import type {
AttackPathQuery,
AttackPathQueryError,
AttackPathScan,
GraphNode,
} from "@/types/attack-paths";
import { ATTACK_PATH_QUERY_IDS, SCAN_STATES } from "@/types/attack-paths";
@@ -53,23 +62,30 @@ import {
ScanListTable,
} from "./_components";
import type { GraphHandle } from "./_components/graph/attack-path-graph";
import { useAttackPathScans } from "./_hooks/use-attack-path-scans";
import { useGraphState } from "./_hooks/use-graph-state";
import { useQueryBuilder } from "./_hooks/use-query-builder";
import { exportGraphAsPNG } from "./_lib";
/**
* Attack Paths
* Allows users to select a scan, build a query, and visualize the attack path graph
*/
export default function AttackPathsPage() {
const searchParams = useSearchParams();
const pathname = usePathname();
const router = useRouter();
const scanId = searchParams.get("scanId");
// Onboarding tours are Cloud-only.
const onboardingEnabled = isCloud();
const isAttackPathsReplay =
onboardingEnabled && searchParams.get("onboarding") === "attack-paths";
const graphState = useGraphState();
const finding = useFindingDetails();
const { toast } = useToast();
const [scansLoading, setScansLoading] = useState(true);
const [scans, setScans] = useState<AttackPathScan[]>([]);
const { scans, scansLoading, refreshScans } = useAttackPathScans({
onNoReadyScan: isAttackPathsReplay
? () => router.push("/scans?onboarding=view-first-scan")
: undefined,
});
const [queriesLoading, setQueriesLoading] = useState(true);
const [queriesError, setQueriesError] = useState<string | null>(null);
const [isFullscreenOpen, setIsFullscreenOpen] = useState(false);
@@ -81,10 +97,62 @@ export default function AttackPathsPage() {
const [queries, setQueries] = useState<AttackPathQuery[]>([]);
// Use custom hook for query builder form state and validation
const queryBuilder = useQueryBuilder(queries);
// Reset graph state when component mounts
const hasReadyScan = scans.some((scan) => scan.attributes.graph_data_ready);
const hasNoScans = scans.length === 0;
useDriverTour(attackPathsEmptyTour, {
enabled: onboardingEnabled && !scansLoading && hasNoScans,
});
const { start: startAttackPathsTour } = useDriverTour<AttackPathsTourTarget>(
attackPathsTour,
{
enabled: onboardingEnabled && !scansLoading && hasReadyScan,
autoOpen: !isAttackPathsReplay,
// Page owns tour auto-open; OnboardingSequenceBanner is the sole Continue/Skip control.
// pickDemoScan/pickDemoQuery policy lives in attack-paths.tour.ts.
stepHandlers: {
"scan-list": {
onNext: async ({ waitForStep }) => {
const selected = pickDemoScan(scans);
if (!selected) return;
const params = new URLSearchParams(searchParams.toString());
params.set("scanId", selected.id);
router.push(`${pathname}?${params.toString()}`);
await waitForStep("query-selector");
},
},
"query-selector": {
onNext: async ({ waitForStep }) => {
const selected = pickDemoQuery(queries);
if (!selected) return;
queryBuilder.handleQueryChange(selected.id);
await waitForStep("execute-button");
},
},
},
},
);
// Onboarding replay entry: start the tour once and strip the `onboarding`
// param. Invoked from <AttackPathsReplayTrigger>, which mounts only when the
// replay conditions hold — so `useMountEffect` fires it exactly once and the
// old `replayStartedRef` run-once guard is gone.
const startAttackPathsReplay = () => {
startAttackPathsTour();
const params = new URLSearchParams(searchParams.toString());
params.delete("onboarding");
const query = params.toString();
window.history.replaceState(
null,
"",
query ? `${pathname}?${query}` : pathname,
);
};
useEffect(() => {
if (!hasResetRef.current) {
hasResetRef.current = true;
@@ -92,60 +160,22 @@ export default function AttackPathsPage() {
}
}, [graphState]);
// Reset graph state when scan changes
useEffect(() => {
graphState.resetGraph();
}, [scanId]); // eslint-disable-line react-hooks/exhaustive-deps -- reset on scanId change only
// Load available scans on mount
useEffect(() => {
const loadScans = async () => {
setScansLoading(true);
try {
const scansData = await getAttackPathScans();
if (scansData?.data) {
setScans(scansData.data);
} else {
setScans([]);
}
} catch (error) {
console.error("Failed to load scans:", error);
setScans([]);
} finally {
setScansLoading(false);
}
};
loadScans();
}, []);
// Check if there's an executing scan for auto-refresh
const hasExecutingScan = scans.some(
(scan) =>
scan.attributes.state === SCAN_STATES.EXECUTING ||
scan.attributes.state === SCAN_STATES.SCHEDULED,
);
// Detect if the selected scan is showing data from a previous cycle
const selectedScan = scans.find((scan) => scan.id === scanId);
const isViewingPreviousCycleData =
selectedScan &&
selectedScan.attributes.graph_data_ready &&
selectedScan.attributes.state !== SCAN_STATES.COMPLETED;
// Callback to refresh scans (used by AutoRefresh component)
const refreshScans = async () => {
try {
const scansData = await getAttackPathScans();
if (scansData?.data) {
setScans(scansData.data);
}
} catch (error) {
console.error("Failed to refresh scans:", error);
}
};
// Load available queries on mount
useEffect(() => {
const loadQueries = async () => {
if (!scanId) {
@@ -205,7 +235,6 @@ export default function AttackPathsPage() {
return;
}
// Validate form before executing query
const isValid = await queryBuilder.form.trigger();
if (!isValid) {
showErrorToast(
@@ -215,6 +244,9 @@ export default function AttackPathsPage() {
return;
}
// The tour's execute step is autoAdvance: the real Execute click moves it forward.
advanceActiveTour();
graphState.startLoading();
graphState.setError(null);
@@ -257,7 +289,6 @@ export default function AttackPathsPage() {
variant: "default",
});
// Scroll to graph after successful query execution
setTimeout(() => {
graphContainerRef.current?.scrollIntoView({
behavior: "smooth",
@@ -297,13 +328,9 @@ export default function AttackPathsPage() {
}
findingNavigationInFlightRef.current = true;
// Findings skip the intermediate node-details modal. The finding drawer
// is the useful destination, so open it directly from the graph click.
// Open finding drawer directly, bypassing the node-details modal.
graphState.enterFilteredView(node.id);
// enterFilteredView stores the filtered node as selected so the graph can
// highlight it. Clear the selection right after for findings so the node
// details modal does not open before the finding drawer.
graphState.selectNode(null);
graphState.selectNode(null); // clear so node-details modal doesn't open first
void handleViewFinding(String(node.properties?.id || node.id));
return;
}
@@ -368,14 +395,19 @@ export default function AttackPathsPage() {
return (
<div className="flex flex-col gap-6">
{/* Auto-refresh scans when there's an executing scan */}
<AutoRefresh
hasExecutingScan={hasExecutingScan}
onRefresh={refreshScans}
/>
{/* Page introduction */}
<div>
{isAttackPathsReplay && !scansLoading && hasReadyScan && (
<AttackPathsReplayTrigger onReplay={startAttackPathsReplay} />
)}
{/* Enables the navbar replay icon once the initial scan load resolves. */}
{!scansLoading && <PageReady />}
<div data-tour-id="attack-paths-intro">
<p className="text-text-neutral-secondary text-sm">
Select a scan, build a query, and visualize Attack Paths in your
infrastructure.
@@ -390,27 +422,27 @@ export default function AttackPathsPage() {
<div className="minimal-scrollbar rounded-large shadow-small border-border-neutral-secondary bg-bg-neutral-secondary relative z-0 flex w-full flex-col gap-4 overflow-auto border p-4">
<p className="text-sm">Loading scans...</p>
</div>
) : scans.length === 0 ? (
<Alert variant="info">
<Info className="size-4" />
<AlertTitle>No scans available</AlertTitle>
<AlertDescription>
<span>
You need to run a scan before you can analyze attack paths.{" "}
<Link href="/scans" className="font-medium underline">
Go to Scan Jobs
</Link>
</span>
</AlertDescription>
</Alert>
) : hasNoScans ? (
<div data-tour-id="attack-paths-empty-scans-cta">
<Alert variant="info">
<Info className="size-4" />
<AlertTitle>No scans available</AlertTitle>
<AlertDescription>
<span>
You need to run a scan before you can analyze attack paths.{" "}
<Link href="/scans" className="font-medium underline">
Go to Scan Jobs
</Link>
</span>
</AlertDescription>
</Alert>
</div>
) : (
<>
{/* Scans Table */}
<Suspense fallback={<div>Loading scans...</div>}>
<ScanListTable scans={scans} />
</Suspense>
{/* Banner: viewing data from a previous scan cycle */}
{isViewingPreviousCycleData && (
<Alert variant="info">
<Info className="size-4" />
@@ -425,7 +457,6 @@ export default function AttackPathsPage() {
</Alert>
)}
{/* Query Builder Section - shown only after selecting a scan */}
{scanId && (
<div className="minimal-scrollbar rounded-large shadow-small border-border-neutral-secondary bg-bg-neutral-secondary relative z-0 flex w-full flex-col gap-4 overflow-auto border p-4">
{queriesLoading ? (
@@ -438,11 +469,13 @@ export default function AttackPathsPage() {
) : (
<>
<FormProvider {...queryBuilder.form}>
<QuerySelector
queries={queries}
selectedQueryId={queryBuilder.selectedQuery}
onQueryChange={queryBuilder.handleQueryChange}
/>
<div data-tour-id="attack-paths-query-selector">
<QuerySelector
queries={queries}
selectedQueryId={queryBuilder.selectedQuery}
onQueryChange={queryBuilder.handleQueryChange}
/>
</div>
{queryBuilder.selectedQueryData && (
<QueryDescription
@@ -457,7 +490,10 @@ export default function AttackPathsPage() {
)}
</FormProvider>
<div className="flex justify-end gap-3">
<div
data-tour-id="attack-paths-execute-button"
className="flex justify-end gap-3"
>
<ExecuteButton
isLoading={graphState.loading}
isDisabled={
@@ -476,7 +512,6 @@ export default function AttackPathsPage() {
</div>
)}
{/* Graph Visualization (Full Width) */}
{(graphState.loading ||
(graphState.data &&
graphState.data.nodes &&
@@ -488,7 +523,6 @@ export default function AttackPathsPage() {
graphState.data.nodes &&
graphState.data.nodes.length > 0 ? (
<>
{/* Info message and controls */}
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
{graphState.isFilteredView ? (
<div className="flex items-center gap-3">
@@ -537,7 +571,6 @@ export default function AttackPathsPage() {
</div>
)}
{/* Graph controls and fullscreen button together */}
<div className="flex items-center gap-2">
<GraphControls
onZoomIn={() => graphRef.current?.zoomIn()}
@@ -546,7 +579,6 @@ export default function AttackPathsPage() {
onExport={() => handleGraphExport("main")}
/>
{/* Fullscreen button */}
<div className="border-border-neutral-primary bg-bg-neutral-tertiary flex gap-1 rounded-lg border p-1">
<Dialog
open={isFullscreenOpen}
@@ -604,7 +636,6 @@ export default function AttackPathsPage() {
</div>
</div>
{/* Graph in the middle */}
<div
ref={graphContainerRef}
className="h-[calc(100vh-22rem)]"
@@ -619,7 +650,6 @@ export default function AttackPathsPage() {
/>
</div>
{/* Legend below */}
<div className="flex justify-center overflow-x-auto">
<GraphLegend
data={graphState.data}
@@ -647,3 +677,26 @@ export default function AttackPathsPage() {
</div>
);
}
interface AttackPathsReplayTriggerProps {
onReplay: () => void;
}
// Conditional-mount trigger: the parent renders this only when the replay
// should start. The microtask keeps driver.js/flushSync outside React's
// mount lifecycle while still running before the next browser task.
function AttackPathsReplayTrigger({ onReplay }: AttackPathsReplayTriggerProps) {
useMountEffect(() => {
let cancelled = false;
queueMicrotask(() => {
if (!cancelled) onReplay();
});
return () => {
cancelled = true;
};
});
return null;
}
+5 -1
View File
@@ -6,7 +6,11 @@ export default function AttackPathsLayout({
children: React.ReactNode;
}) {
return (
<ContentLayout title="Attack Paths" icon="lucide:git-branch">
<ContentLayout
title="Attack Paths"
icon="lucide:git-branch"
onboardingAction={{ flowId: "attack-paths" }}
>
{children}
</ContentLayout>
);
+26 -18
View File
@@ -46,16 +46,26 @@ export default async function Compliance({
});
if (!scansData?.data) {
return <NoScansAvailable />;
return (
<ContentLayout
title="Compliance"
icon="lucide:shield-check"
onboardingAction={{
flowId: "view-compliance",
fallbackFlowId: "view-first-scan",
useFallback: true,
}}
>
<NoScansAvailable />
</ContentLayout>
);
}
// Process scans with provider information from included data
const expandedScansData: ExpandedScanData[] = scansData.data
.filter((scan: ScanProps) => scan.relationships?.provider?.data?.id)
.map((scan: ScanProps) => {
const providerId = scan.relationships!.provider!.data!.id;
// Find the provider data in the included array
const providerData = scansData.included?.find(
(item: { type: string; id: string }) =>
item.type === "providers" && item.id === providerId,
@@ -76,15 +86,20 @@ export default async function Compliance({
})
.filter(Boolean) as ExpandedScanData[];
// Use scanId from URL, or select the first scan if not provided
const scanIdParam = resolvedSearchParams.scanId;
const scanIdFromUrl = Array.isArray(scanIdParam)
? scanIdParam[0]
: scanIdParam;
const selectedScanId: string | null =
scanIdFromUrl || expandedScansData[0]?.id || null;
const onboardingAction = selectedScanId
? { flowId: "view-compliance" }
: {
flowId: "view-compliance",
fallbackFlowId: "view-first-scan",
useFallback: true,
};
// Find the selected scan
const selectedScan = expandedScansData.find(
(scan) => scan.id === selectedScanId,
);
@@ -100,7 +115,6 @@ export default async function Compliance({
}
: undefined;
// Fetch metadata if we have a selected scan
const metadataInfoData = selectedScanId
? await getComplianceOverviewMetadataInfo({
filters: {
@@ -111,7 +125,6 @@ export default async function Compliance({
const uniqueRegions = metadataInfoData?.data?.attributes?.regions || [];
// Fetch ThreatScore data from API if we have a selected scan
let threatScoreData = null;
if (selectedScanId && typeof selectedScanId === "string") {
const threatScoreResponse = await getThreatScore({
@@ -128,10 +141,13 @@ export default async function Compliance({
}
return (
<ContentLayout title="Compliance" icon="lucide:shield-check">
<ContentLayout
title="Compliance"
icon="lucide:shield-check"
onboardingAction={onboardingAction}
>
{selectedScanId ? (
<>
{/* Row 1: Filters */}
<div className="mb-6">
<ComplianceFilters
scans={expandedScansData}
@@ -140,7 +156,6 @@ export default async function Compliance({
/>
</div>
{/* Row 2: ThreatScore card — full width, horizontal */}
{threatScoreData &&
typeof selectedScanId === "string" &&
selectedScan && (
@@ -155,7 +170,6 @@ export default async function Compliance({
</div>
)}
{/* Row 3: Compliance grid with client-side search */}
<Suspense
key={searchParamsKey}
fallback={
@@ -189,7 +203,6 @@ const SSRComplianceGrid = async ({
}) => {
const regionFilter = searchParams["filter[region__in]"]?.toString() || "";
// Only fetch compliance data if we have a valid scanId
const compliancesData =
scanId && scanId.trim() !== ""
? await getCompliancesOverview({
@@ -207,7 +220,6 @@ const SSRComplianceGrid = async ({
a.attributes.framework.localeCompare(b.attributes.framework),
);
// Check if the response contains no data
if (
!compliancesData ||
!compliancesData.data ||
@@ -225,7 +237,6 @@ const SSRComplianceGrid = async ({
);
}
// Handle errors returned by the API
if (compliancesData?.errors?.length > 0) {
return (
<Alert variant="info">
@@ -235,10 +246,7 @@ const SSRComplianceGrid = async ({
);
}
// Compute the set of latest CIS variants per provider once, so each card
// can gate its PDF button without re-parsing on every render. The backend
// only generates a CIS PDF for the latest version per provider, so any
// other CIS card must not expose the PDF download button.
// Backend only generates CIS PDFs for the latest version per provider.
const latestCisIds = pickLatestCisPerProvider(
compliancesData.data.map(
(compliance: ComplianceOverviewData) => compliance.id,
+14 -5
View File
@@ -59,7 +59,6 @@ export default async function Findings({
filters: resolvedFilters,
});
// Extract unique regions, services, categories, groups from the new endpoint
const uniqueRegions = metadataInfoData?.data?.attributes?.regions || [];
const uniqueServices = metadataInfoData?.data?.attributes?.services || [];
const uniqueResourceTypes =
@@ -67,7 +66,6 @@ export default async function Findings({
const uniqueCategories = metadataInfoData?.data?.attributes?.categories || [];
const uniqueGroups = metadataInfoData?.data?.attributes?.groups || [];
// Extract scan UUIDs with "completed" state and more than one resource
const completedScans = scansData?.data?.filter(
(scan: ScanProps) =>
scan.attributes.state === "completed" &&
@@ -76,6 +74,14 @@ export default async function Findings({
const completedScanIds =
completedScans?.map((scan: ScanProps) => scan.id) || [];
const onboardingAction =
completedScanIds.length > 0
? { flowId: "explore-findings" }
: {
flowId: "explore-findings",
fallbackFlowId: "view-first-scan",
useFallback: true,
};
const scanDetails = createScanDetailsMapping(
completedScans || [],
@@ -84,7 +90,11 @@ export default async function Findings({
const alertsEnabled = process.env.NEXT_PUBLIC_IS_CLOUD_ENV === "true";
return (
<ContentLayout title="Findings" icon="lucide:tag">
<ContentLayout
title="Findings"
icon="lucide:tag"
onboardingAction={onboardingAction}
>
<FilterTransitionWrapper>
<div className="mb-6">
<FindingsFilters
@@ -146,9 +156,8 @@ const SSRDataTable = async ({
pageSize,
});
// Transform API response to FindingGroupRow[]
const groups = adaptFindingGroupsResponse(findingGroupsData);
// Key resets all client state (selection, drill-down) when data changes
// Key resets client state (selection, drill-down) when data changes.
const groupKey = groups.map((g) => g.id).join(",");
return (
+49 -5
View File
@@ -2,16 +2,24 @@ import "@/styles/globals.css";
import * as Sentry from "@sentry/nextjs";
import { Metadata, Viewport } from "next";
import { ReactNode } from "react";
import { ReactNode, Suspense } from "react";
import { getProviders } from "@/actions/providers";
import { getScansByState } from "@/actions/scans/scans";
import {
OnboardingCheckpointWatcher,
OnboardingGate,
OnboardingSequenceBanner,
} from "@/components/onboarding";
import MainLayout from "@/components/ui/main-layout/main-layout";
import { NavigationProgress } from "@/components/ui/navigation-progress";
import { Toaster } from "@/components/ui/toast";
import { fontSans } from "@/config/fonts";
import { siteConfig } from "@/config/site";
import { isCloud } from "@/lib/shared/env";
import { cn } from "@/lib/utils";
import { StoreInitializer } from "@/store/ui/store-initializer";
import { SCAN_STATES } from "@/types/attack-paths";
import { Providers } from "../providers";
@@ -41,8 +49,30 @@ export default async function RootLayout({
}: {
children: ReactNode;
}) {
const providersData = await getProviders({ page: 1, pageSize: 1 });
const hasProviders = !!(providersData?.data && providersData.data.length > 0);
// Onboarding is Cloud-only; skip its fetches and orchestrators in OSS.
const onboardingEnabled = isCloud();
// Fail-open: unknown scan state is treated as "has data" so the banner never blocks
// progression on a fetch error.
let hasCompletedScan = true;
// Tri-state: true = has providers, false = zero providers, undefined = fetch failed (gate fails open).
let hasProviders: boolean | undefined = false;
if (onboardingEnabled) {
const [providersData, scansByState] = await Promise.all([
getProviders({ page: 1, pageSize: 1 }),
getScansByState(),
]);
hasCompletedScan = Array.isArray(scansByState?.data)
? scansByState.data.some(
(scan: { attributes?: { state?: string } }) =>
scan.attributes?.state === SCAN_STATES.COMPLETED,
)
: true;
hasProviders = Array.isArray(providersData?.data)
? providersData.data.length > 0
: undefined;
}
return (
<html suppressHydrationWarning lang="en">
@@ -55,8 +85,22 @@ export default async function RootLayout({
)}
>
<Providers themeProps={{ attribute: "class", defaultTheme: "dark" }}>
<NavigationProgress />
<StoreInitializer values={{ hasProviders }} />
{/* Suspense contains the useSearchParams() CSR bailout so statically
prerendered pages don't fail the build (matches the auth layout). */}
<Suspense>
<NavigationProgress />
</Suspense>
{/* Store uses boolean; gate receives tri-state to fail open on fetch errors. */}
<StoreInitializer values={{ hasProviders: hasProviders ?? false }} />
{onboardingEnabled && (
<>
<OnboardingGate hasProviders={hasProviders} />
{/* Single mount point so the watcher survives post-connect navigation. */}
<OnboardingCheckpointWatcher />
{/* Persistent banner shown only while a guided sequence is active. */}
<OnboardingSequenceBanner hasCompletedScan={hasCompletedScan} />
</>
)}
<MainLayout>{children}</MainLayout>
<Toaster />
</Providers>
+14 -9
View File
@@ -22,12 +22,22 @@ export default async function Providers({
const activeTab = getProviderTab(resolvedSearchParams.tab);
const isCloudEnvironment = process.env.NEXT_PUBLIC_IS_CLOUD_ENV === "true";
// Exclude `tab` from the Suspense key so switching tabs doesn't re-suspend
const { tab: _, ...paramsWithoutTab } = resolvedSearchParams || {};
const searchParamsKey = JSON.stringify(paramsWithoutTab);
// Exclude `tab` and `onboarding` from the key: tab switches must not re-suspend,
// and `onboarding` is ephemeral (stripped via history.replaceState) — keeping it
// would remount ProvidersAccountsView and reset the wizard mid-flow.
const {
tab: _tab,
onboarding: _onboarding,
...stableParams
} = resolvedSearchParams || {};
const searchParamsKey = JSON.stringify(stableParams);
return (
<ContentLayout title="Providers" icon="lucide:cloud-cog">
<ContentLayout
title="Providers"
icon="lucide:cloud-cog"
onboardingAction={{ flowId: "add-provider" }}
>
{isCloudEnvironment && <CliImportBanner className="mb-6" />}
<FilterTransitionWrapper>
<ProviderPageTabs
@@ -58,15 +68,10 @@ const ProvidersTableFallback = () => {
return (
<div className="flex flex-col gap-6">
<div className="flex flex-wrap items-center gap-4">
{/* ProviderTypeSelector */}
<Skeleton className="h-[52px] min-w-[200px] flex-1 rounded-lg md:max-w-[280px]" />
{/* Organizations filter */}
<Skeleton className="h-[52px] max-w-[240px] min-w-[180px] flex-1 rounded-lg" />
{/* Provider Groups filter */}
<Skeleton className="h-[52px] max-w-[240px] min-w-[180px] flex-1 rounded-lg" />
{/* Status filter */}
<Skeleton className="h-[52px] max-w-[240px] min-w-[180px] flex-1 rounded-lg" />
{/* Action buttons */}
<div className="ml-auto flex flex-wrap gap-4">
<Skeleton className="h-9 w-[160px] rounded-md" />
<Skeleton className="h-9 w-[120px] rounded-md" />
+23
View File
@@ -0,0 +1,23 @@
import { readFileSync } from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { describe, expect, it } from "vitest";
describe("scans page onboarding", () => {
const currentDir = path.dirname(fileURLToPath(import.meta.url));
const pagePath = path.join(currentDir, "page.tsx");
const source = readFileSync(pagePath, "utf8");
it("redirects the scan tour replay to add-provider when providers are missing or disconnected", () => {
expect(source).toContain('redirect("/providers?onboarding=add-provider")');
expect(source).toContain(
'resolvedSearchParams.onboarding === "view-first-scan"',
);
});
it("passes the scan onboarding action to the page header when the tour can run", () => {
expect(source).toContain('flowId: "view-first-scan"');
expect(source).toContain("onboardingAction={onboardingAction}");
});
});
+221 -11
View File
@@ -1,9 +1,14 @@
import { redirect } from "next/navigation";
import { Suspense } from "react";
import { getAllProviders } from "@/actions/providers";
import { getScans } from "@/actions/scans";
import { getSchedules } from "@/actions/schedules";
import { auth } from "@/auth.config";
import { PageReady } from "@/components/onboarding";
import {
appendPendingScheduleRowsToPage,
getProviderIdsFromScans,
getScanJobsTab,
getScanJobsTabFilters,
getScanJobsUserFilters,
@@ -13,14 +18,46 @@ import { ScansProvidersEmptyState } from "@/components/scans/scans-providers-emp
import { SkeletonTableScans } from "@/components/scans/table";
import { ScanJobsTable } from "@/components/scans/table/scan-jobs-table";
import { ContentLayout } from "@/components/ui";
import {
describeScheduleCadence,
getNextScheduledRunInTimezone,
getScheduleCadenceParts,
isScheduleConfigured,
} from "@/lib/schedules";
import {
ProviderProps,
SCAN_JOBS_TAB,
SCAN_TRIGGER,
ScanProps,
ScheduleAttributes,
ScheduleProps,
SearchParamsProps,
} from "@/types";
const ACTIVE_SCAN_COUNT_PAGE_SIZE = 1;
// Pending schedule rows are derived from provider schedules, but must honor the
// same provider filters as real scan rows. Keep these filter keys typed locally
// without narrowing the global SearchParamsProps shape used by Next pages.
const PENDING_ROW_PROVIDER_FILTER = {
PROVIDER_UID_IN: "provider_uid__in",
PROVIDER_UID: "provider_uid",
PROVIDER_TYPE_IN: "provider_type__in",
PROVIDER_TYPE: "provider_type",
} as const;
type PendingRowProviderFilter =
(typeof PENDING_ROW_PROVIDER_FILTER)[keyof typeof PENDING_ROW_PROVIDER_FILTER];
type PendingRowProviderFilterParam = `filter[${PendingRowProviderFilter}]`;
const PROVIDER_UID_FILTER_KEYS = [
`filter[${PENDING_ROW_PROVIDER_FILTER.PROVIDER_UID_IN}]`,
`filter[${PENDING_ROW_PROVIDER_FILTER.PROVIDER_UID}]`,
] as const satisfies ReadonlyArray<PendingRowProviderFilterParam>;
const PROVIDER_TYPE_FILTER_KEYS = [
`filter[${PENDING_ROW_PROVIDER_FILTER.PROVIDER_TYPE_IN}]`,
`filter[${PENDING_ROW_PROVIDER_FILTER.PROVIDER_TYPE}]`,
] as const satisfies ReadonlyArray<PendingRowProviderFilterParam>;
const getFilterSearchQuery = (
filters: Record<string, string | string[]>,
@@ -31,6 +68,47 @@ const getFilterSearchQuery = (
return value ?? "";
};
const parseCsvParam = (value?: string | string[]): string[] => {
const rawValue = Array.isArray(value) ? value.join(",") : value;
if (!rawValue) return [];
return rawValue
.split(",")
.map((item) => item.trim())
.filter(Boolean);
};
const getFirstSearchParam = (
searchParams: SearchParamsProps,
keys: ReadonlyArray<PendingRowProviderFilterParam>,
): string | string[] | undefined => {
for (const key of keys) {
const value = searchParams[key];
if (value !== undefined) return value;
}
return undefined;
};
/** Applies the table's provider filters to synthetic pending-schedule rows. */
const filterProvidersForPendingRows = (
providers: ProviderProps[],
searchParams: SearchParamsProps,
): ProviderProps[] => {
const uids = parseCsvParam(
getFirstSearchParam(searchParams, PROVIDER_UID_FILTER_KEYS),
);
const types = parseCsvParam(
getFirstSearchParam(searchParams, PROVIDER_TYPE_FILTER_KEYS),
);
return providers.filter(
(provider) =>
(uids.length === 0 || uids.includes(provider.attributes.uid)) &&
(types.length === 0 || types.includes(provider.attributes.provider)),
);
};
const getActiveScanCount = async (
searchParams: SearchParamsProps,
): Promise<number> => {
@@ -51,6 +129,40 @@ const getActiveScanCount = async (
return scansData && "meta" in scansData ? scansData.meta.pagination.count : 0;
};
/**
* A provider can already have a real scheduled scan on a different page.
* Current-page rows are not enough to decide whether a schedule needs a
* synthetic Pending row, so fetch all scheduled scan provider ids when the
* backend paginated result is larger than the current slice.
*/
const getCoveredScheduledProviderIds = async ({
currentScans,
realScanCount,
query,
filters,
}: {
currentScans: ScanProps[];
realScanCount: number;
query: string;
filters: Record<string, string | string[]>;
}): Promise<Set<string>> => {
if (realScanCount === 0 || currentScans.length === realScanCount) {
return getProviderIdsFromScans(currentScans);
}
const allScheduledScansData = await getScans({
query,
page: 1,
pageSize: realScanCount,
filters,
include: "provider",
});
return getProviderIdsFromScans(
(allScheduledScansData?.data ?? []) as ScanProps[],
);
};
export default async function Scans({
searchParams,
}: {
@@ -69,19 +181,45 @@ export default async function Scans({
const thereIsNoProviders = providers.length === 0;
const thereIsNoProvidersConnected =
!thereIsNoProviders && connectedProviders.length === 0;
const missingScanPrerequisite =
thereIsNoProviders || thereIsNoProvidersConnected;
if (
missingScanPrerequisite &&
resolvedSearchParams.onboarding === "view-first-scan"
) {
redirect("/providers?onboarding=add-provider");
}
const hasManageScansPermission = Boolean(
session?.user?.permissions?.manage_scans,
);
const activeScanCount =
thereIsNoProviders || thereIsNoProvidersConnected
? 0
: await getActiveScanCount(resolvedSearchParams);
const activeScanCount = missingScanPrerequisite
? 0
: await getActiveScanCount(resolvedSearchParams);
const onboardingAction = missingScanPrerequisite
? {
flowId: "view-first-scan",
fallbackFlowId: "add-provider",
useFallback: true,
}
: { flowId: "view-first-scan" };
return (
<ContentLayout title="Scan Jobs" icon="lucide:timer">
{thereIsNoProviders || thereIsNoProvidersConnected ? (
<ScansProvidersEmptyState thereIsNoProviders={thereIsNoProviders} />
<ContentLayout
title="Scan Jobs"
icon="lucide:timer"
onboardingAction={onboardingAction}
>
{missingScanPrerequisite ? (
<>
{/* The populated branch mounts <PageReady/> inside ScansPageShell to
enable the navbar tour icon. The empty branch must mark the route
ready too, otherwise the icon (which falls back to the add-provider
flow here) stays hidden for users with no connected provider. */}
<PageReady />
<ScansProvidersEmptyState thereIsNoProviders={thereIsNoProviders} />
</>
) : (
<ScansPageShell
providers={providers}
@@ -95,7 +233,10 @@ export default async function Scans({
/>
}
>
<SSRDataTableScans searchParams={resolvedSearchParams} />
<SSRDataTableScans
searchParams={resolvedSearchParams}
providers={providers}
/>
</Suspense>
</ScansPageShell>
)}
@@ -105,8 +246,10 @@ export default async function Scans({
const SSRDataTableScans = async ({
searchParams,
providers,
}: {
searchParams: SearchParamsProps;
providers: ProviderProps[];
}) => {
const tab = getScanJobsTab(searchParams.tab);
@@ -142,7 +285,7 @@ const SSRDataTableScans = async ({
const included = scansData?.included;
const meta = scansData && "meta" in scansData ? scansData.meta : undefined;
const expandedScansData =
const expandedScansData: ScanProps[] =
scans?.map((scan: ScanProps) => {
const providerId = scan.relationships?.provider?.data?.id;
@@ -163,10 +306,77 @@ const SSRDataTableScans = async ({
};
}) || [];
const needsSchedules =
tab === SCAN_JOBS_TAB.SCHEDULED ||
expandedScansData.some(
(scan) => scan.attributes.trigger === SCAN_TRIGGER.SCHEDULED,
);
const schedulesResult = needsSchedules ? await getSchedules() : null;
// Schedules are keyed by provider id so real scheduled scan rows can display
// cadence/next-run info, and schedule-only providers can become Pending rows.
const schedulesByProviderId: Record<string, ScheduleAttributes> = {};
if (schedulesResult && !schedulesResult.error) {
for (const schedule of (schedulesResult.data ?? []) as ScheduleProps[]) {
schedulesByProviderId[schedule.id] = schedule.attributes;
}
}
const scansWithSchedule = expandedScansData.map((scan) => {
if (scan.attributes.trigger !== SCAN_TRIGGER.SCHEDULED) return scan;
const providerId = scan.relationships?.provider?.data?.id;
const schedule = providerId ? schedulesByProviderId[providerId] : undefined;
if (!schedule || !isScheduleConfigured(schedule)) return scan;
// Absent field (older API) -> client estimate; explicit null (paused) -> no time.
const nextScanAt =
schedule.next_scan_at === undefined && schedule.scan_enabled
? (getNextScheduledRunInTimezone(schedule, new Date())?.toISOString() ??
null)
: (schedule.next_scan_at ?? null);
return {
...scan,
providerSchedule: {
summary: describeScheduleCadence(schedule),
cadence: getScheduleCadenceParts(schedule).cadence,
nextScanAt,
lastScanAt: schedule.last_scan_at ?? null,
},
};
});
let tableData = scansWithSchedule;
let tableMeta = meta;
if (tab === SCAN_JOBS_TAB.SCHEDULED) {
// The backend paginates real scans only. Pending schedule rows are generated
// client-side, so reconcile both sources before passing data/meta to the table.
const coveredProviderIds = await getCoveredScheduledProviderIds({
currentScans: scansWithSchedule,
realScanCount: meta?.pagination?.count ?? scansWithSchedule.length,
query,
filters,
});
const scheduledTable = appendPendingScheduleRowsToPage({
scans: scansWithSchedule,
meta,
page,
pageSize,
providers: filterProvidersForPendingRows(providers, searchParams),
schedulesByProviderId,
coveredProviderIds,
now: new Date(),
});
tableData = scheduledTable.data;
tableMeta = scheduledTable.meta;
}
return (
<ScanJobsTable
data={expandedScansData}
meta={meta}
data={tableData}
meta={tableMeta}
tab={tab}
hasFilters={hasUserFilters}
/>
+10 -14
View File
@@ -10,6 +10,7 @@ import {
TooltipContent,
TooltipTrigger,
} from "@/components/shadcn/tooltip";
import { buildComplianceDetailPath } from "@/lib/compliance/compliance-detail-url";
import { getReportTypeForCompliance } from "@/lib/compliance/compliance-report-types";
import {
getScoreIndicatorClass,
@@ -69,20 +70,15 @@ export const ComplianceCard: React.FC<ComplianceCardProps> = ({
};
const navigateToDetail = () => {
const formattedTitleForUrl = encodeURIComponent(title);
const path = `/compliance/${formattedTitleForUrl}`;
const params = new URLSearchParams();
params.set("complianceId", id);
params.set("version", version);
params.set("scanId", scanId);
const regionFilter = searchParams.get("filter[region__in]");
if (regionFilter) {
params.set("filter[region__in]", regionFilter);
}
router.push(`${path}?${params.toString()}`);
router.push(
buildComplianceDetailPath({
title,
complianceId: id,
version,
scanId,
regionFilter: searchParams.get("filter[region__in]"),
}),
);
};
return (
@@ -1,12 +1,26 @@
"use client";
import { useState } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import { Suspense, useState } from "react";
import { ComplianceCard } from "@/components/compliance/compliance-card";
import { OnboardingTrigger, PageReady } from "@/components/onboarding";
import { DataTableSearch } from "@/components/ui/table/data-table-search";
import { buildComplianceDetailPath } from "@/lib/compliance/compliance-detail-url";
import { getFlowById } from "@/lib/onboarding";
import { createViewComplianceTourStepHandlers } from "@/lib/tours/view-compliance.tour";
import type { ComplianceOverviewData } from "@/types/compliance";
import type { ScanEntity } from "@/types/scans";
const viewComplianceFlow = getFlowById("view-compliance")!;
// Module-level so the identity is stable: `configOverrides` is an effect dependency in
// `useDriverTour`, and a fresh object per keystroke would tear the tour down mid-typing.
const VIEW_COMPLIANCE_TOUR_CONFIG = {
// Last step opens the first card (see createViewComplianceTourStepHandlers).
doneBtnText: "Open Compliance",
};
interface ComplianceOverviewGridProps {
frameworks: ComplianceOverviewData[];
scanId: string;
@@ -25,6 +39,8 @@ export const ComplianceOverviewGrid = ({
selectedScan,
latestCisIds,
}: ComplianceOverviewGridProps) => {
const router = useRouter();
const searchParams = useSearchParams();
const [searchTerm, setSearchTerm] = useState("");
const filteredFrameworks = frameworks.filter((compliance) =>
@@ -33,20 +49,54 @@ export const ComplianceOverviewGrid = ({
.includes(searchTerm.toLowerCase()),
);
const resetSearch = () => {
setSearchTerm("");
return frameworks.length > 0;
};
const openFirstFramework = () => {
const first = frameworks[0];
if (!first) return;
router.push(
buildComplianceDetailPath({
title: first.attributes.framework,
complianceId: first.id,
version: first.attributes.version,
scanId,
regionFilter: searchParams.get("filter[region__in]"),
}),
);
};
return (
<>
<div className="flex items-center justify-between gap-4">
<DataTableSearch
controlledValue={searchTerm}
onSearchChange={setSearchTerm}
placeholder="Search frameworks..."
{/* Suspense required: OnboardingTrigger reads useSearchParams */}
<Suspense fallback={null}>
<OnboardingTrigger
flow={viewComplianceFlow}
stepHandlers={createViewComplianceTourStepHandlers({
resetSearch,
openFirstFramework,
})}
configOverrides={VIEW_COMPLIANCE_TOUR_CONFIG}
/>
</Suspense>
{/* Signals the navbar that this route's data has loaded (enables the replay icon). */}
<PageReady />
<div className="flex items-center justify-between gap-4">
<div data-tour-id="view-compliance-search">
<DataTableSearch
controlledValue={searchTerm}
onSearchChange={setSearchTerm}
placeholder="Search frameworks..."
/>
</div>
<span className="text-text-neutral-secondary shrink-0 text-sm">
{filteredFrameworks.length.toLocaleString()} Total Entries
</span>
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3 2xl:grid-cols-4">
{filteredFrameworks.map((compliance) => {
{filteredFrameworks.map((compliance, index) => {
const { attributes, id } = compliance;
const {
framework,
@@ -55,9 +105,8 @@ export const ComplianceOverviewGrid = ({
total_requirements,
} = attributes;
return (
const card = (
<ComplianceCard
key={id}
title={framework}
version={version}
passingRequirements={requirements_passed}
@@ -71,6 +120,22 @@ export const ComplianceOverviewGrid = ({
isLatestCisForProvider={latestCisIds?.has(id) ?? false}
/>
);
// Anchor the tour to a single card, not the whole grid: highlighting the
// grid lit up the entire viewport and scrolled the page to the bottom.
return index === 0 ? (
<div
key={id}
data-tour-id="view-compliance-frameworks"
className="h-full [&>*]:h-full"
>
{card}
</div>
) : (
<div key={id} className="h-full [&>*]:h-full">
{card}
</div>
);
})}
</div>
</>
+16 -18
View File
@@ -95,7 +95,6 @@ export const FindingsFilterBatchControls = ({
const [isExpanded, setIsExpanded] = useState(false);
const isAlertsEdit = variant === "alerts-edit";
// Custom filters for the expandable section.
const customFilters = [
...filterFindings
.filter((filter) => !isAlertsEdit || filter.key !== FilterType.STATUS)
@@ -182,8 +181,6 @@ export const FindingsFilterBatchControls = ({
const showAppliedRow = appliedFilterChips.length > 0;
const showPendingRow = hasChanges;
// Handler for removing a single chip: update the pending filter to remove that value.
// setPending handles both "filter[key]" and "key" formats internally.
const handleChipRemove = (filterKey: string, value?: string) => {
if (value === undefined) {
setPending(filterKey, []);
@@ -195,7 +192,6 @@ export const FindingsFilterBatchControls = ({
setPending(filterKey, nextValues);
};
// For the date picker, read from pendingFilters
const pendingDateValues = pendingFilters["filter[inserted_at]"];
const pendingDateValue =
pendingDateValues && pendingDateValues.length > 0
@@ -333,19 +329,21 @@ export const FindingsFilters = (props: FindingsFiltersProps) => {
});
return (
<FindingsFilterBatchControls
{...props}
appliedFilters={appliedFilters}
pendingFilters={pendingFilters}
changedFilters={changedFilters}
setPending={setPending}
applyAll={applyAll}
discardAll={discardAll}
clearAndApply={clearAndApply}
removeAppliedAndApply={removeAppliedAndApply}
hasChanges={hasChanges}
changeCount={changeCount}
getFilterValue={getFilterValue}
/>
<div data-tour-id="explore-findings-filters">
<FindingsFilterBatchControls
{...props}
appliedFilters={appliedFilters}
pendingFilters={pendingFilters}
changedFilters={changedFilters}
setPending={setPending}
applyAll={applyAll}
discardAll={discardAll}
clearAndApply={clearAndApply}
removeAppliedAndApply={removeAppliedAndApply}
hasChanges={hasChanges}
changeCount={changeCount}
getFilterValue={getFilterValue}
/>
</div>
);
};
@@ -9,17 +9,47 @@ vi.mock("next/navigation", () => ({
refresh: vi.fn(),
}),
useSearchParams: () => new URLSearchParams(),
usePathname: () => "/findings",
}));
vi.mock("@/components/ui/table", () => ({
DataTable: ({ toolbarRightContent }: { toolbarRightContent?: ReactNode }) => (
DataTable: ({
data,
toolbarRightContent,
getRowAttributes,
}: {
data?: Array<{ checkId?: string }>;
toolbarRightContent?: ReactNode;
getRowAttributes?: (row: {
index: number;
original: { checkId?: string };
}) => Record<string, string | undefined>;
}) => (
<div>
<div data-testid="table-toolbar-right">{toolbarRightContent}</div>
<span>10 Total Entries</span>
<table>
<tbody>
{(data ?? []).map((original, index) => (
<tr
key={original.checkId ?? index}
data-testid={`row-${index}`}
{...getRowAttributes?.({ index, original })}
>
<td>{original.checkId}</td>
</tr>
))}
</tbody>
</table>
</div>
),
}));
vi.mock("@/components/onboarding", () => ({
OnboardingTrigger: () => <div data-testid="onboarding-trigger" />,
PageReady: () => <div data-testid="page-ready" />,
}));
vi.mock("@/components/filters/custom-checkbox-muted-findings", () => ({
CustomCheckboxMutedFindings: () => (
<label>
@@ -75,4 +105,68 @@ describe("FindingsGroupTable", () => {
expect(toolbar).toHaveTextContent("Include muted findings");
});
});
describe("explore-findings tour gating", () => {
it("does not mount the tour trigger when there are no finding groups", () => {
// Given an empty table (e.g. a scan is still running)
render(
<FindingsGroupTable
data={[]}
resolvedFilters={{}}
hasHistoricalData={false}
/>,
);
// Then the tour never starts — there is no first-row anchor for the
// "Open a finding group" step to resolve, which would otherwise throw.
expect(
screen.queryByTestId("onboarding-trigger"),
).not.toBeInTheDocument();
// PageReady still signals the navbar that the route's data has loaded.
expect(screen.getByTestId("page-ready")).toBeInTheDocument();
});
it("mounts the tour trigger once at least one finding group exists", () => {
// Given a populated table
const data = [{ checkId: "check-a" }] as unknown as Parameters<
typeof FindingsGroupTable
>[0]["data"];
render(
<FindingsGroupTable
data={data}
resolvedFilters={{}}
hasHistoricalData={false}
/>,
);
// Then the explore-findings tour is allowed to start.
expect(screen.getByTestId("onboarding-trigger")).toBeInTheDocument();
});
});
describe("onboarding anchor", () => {
it("anchors the finding-group tour step to the first row only", () => {
// Given two finding groups (the tour must point at the first, even if there is one)
const data = [
{ checkId: "check-a" },
{ checkId: "check-b" },
] as unknown as Parameters<typeof FindingsGroupTable>[0]["data"];
render(
<FindingsGroupTable
data={data}
resolvedFilters={{}}
hasHistoricalData={false}
/>,
);
// Then driver.js resolves `[data-tour-id="explore-findings-group"]` to the first row.
expect(screen.getByTestId("row-0")).toHaveAttribute(
"data-tour-id",
"explore-findings-group",
);
expect(screen.getByTestId("row-1")).not.toHaveAttribute("data-tour-id");
});
});
});
@@ -2,12 +2,15 @@
import { Row, RowSelectionState } from "@tanstack/react-table";
import { useRouter, useSearchParams } from "next/navigation";
import { useRef, useState } from "react";
import { Suspense, useRef, useState } from "react";
import { resolveFindingIdsByVisibleGroupResources } from "@/actions/findings/findings-by-resource";
import { CustomCheckboxMutedFindings } from "@/components/filters/custom-checkbox-muted-findings";
import { OnboardingTrigger, PageReady } from "@/components/onboarding";
import { DataTable } from "@/components/ui/table";
import { canDrillDownFindingGroup } from "@/lib/findings-groups";
import { getFlowById } from "@/lib/onboarding";
import { createExploreFindingsTourStepHandlers } from "@/lib/tours/explore-findings.tour";
import { FindingGroupRow, MetaDataProps } from "@/types";
import { FloatingMuteButton } from "../floating-mute-button";
@@ -19,6 +22,8 @@ import {
InlineResourceContainerHandle,
} from "./inline-resource-container";
const exploreFindingsFlow = getFlowById("explore-findings")!;
function buildMuteLabel(groupCount: number, resourceCount: number): string {
const parts: string[] = [];
if (groupCount > 0) {
@@ -52,23 +57,17 @@ export function FindingsGroupTable({
const [expandedGroup, setExpandedGroup] = useState<FindingGroupRow | null>(
null,
);
// Separate display state (updates on keystroke) from committed search (updates on Enter only).
// This prevents InlineResourceContainer from remounting on every keystroke.
// Separate input (keystroke) from committed search (Enter) to avoid remounting InlineResourceContainer.
const [resourceSearchInput, setResourceSearchInput] = useState("");
const [resourceSearch, setResourceSearch] = useState("");
const [resourceSelection, setResourceSelection] = useState<string[]>([]);
const inlineRef = useRef<InlineResourceContainerHandle>(null);
// State resets (selection, drill-down) are handled by the parent via
// key={groupKey} — when data changes, the component remounts with fresh state.
const safeData = data ?? [];
const hasResourceSelection = resourceSelection.length > 0;
const filters = resolvedFilters;
// Get selected group check IDs. When the expanded group has individual resource
// selections, exclude it from group-level mute targets — the resource-level
// FloatingMuteButton handles those.
// Exclude expanded group from group-level mutes when it has resource selections.
const selectedCheckIds = Object.keys(rowSelection)
.filter((key) => rowSelection[key])
.map((idx) => safeData[parseInt(idx)]?.checkId)
@@ -82,7 +81,6 @@ export function FindingsGroupTable({
.map((idx) => safeData[parseInt(idx)])
.filter(Boolean);
// Count of selectable rows (groups where not ALL findings are muted)
const selectableRowCount = safeData.filter((g) =>
canMuteFindingGroup({
resourcesFail: g.resourcesFail,
@@ -128,7 +126,6 @@ export function FindingsGroupTable({
return Array.from(new Set(results.flat()));
};
/** Shared resolver for group row action dropdowns (via context). */
const resolveMuteIds = async (checkIds: string[]) =>
resolveGroupMuteIds(checkIds);
@@ -141,10 +138,9 @@ export function FindingsGroupTable({
};
const handleDrillDown = (checkId: string, group: FindingGroupRow) => {
// No resources in the group → nothing to show, skip drill-down
if (!canDrillDownFindingGroup(group)) return;
// Toggle: same group = collapse, different = switch
// Toggle: same group collapses, different group switches
if (expandedCheckId === checkId) {
handleCollapse();
return;
@@ -164,6 +160,19 @@ export function FindingsGroupTable({
setResourceSelection([]);
};
// Drives the onboarding "Open a finding group" step: opens the first row when
// drillable, otherwise the first drillable group. Returns false when none can
// open so the tour skips the resources step instead of hanging.
const openFirstFindingGroup = (): boolean => {
const target =
safeData[0] && canDrillDownFindingGroup(safeData[0])
? safeData[0]
: safeData.find((group) => canDrillDownFindingGroup(group));
if (!target) return false;
handleDrillDown(target.checkId, target);
return true;
};
const columns = getColumnFindingGroups({
rowSelection,
selectableRowCount,
@@ -201,29 +210,49 @@ export function FindingsGroupTable({
resolveMuteIds,
}}
>
<DataTable
columns={columns}
data={safeData}
metadata={metadata}
enableRowSelection
rowSelection={rowSelection}
onRowSelectionChange={setRowSelection}
getRowCanSelect={getRowCanSelect}
showSearch
searchPlaceholder={
expandedCheckId ? "Search resources..." : "Search by name"
}
controlledSearch={expandedCheckId ? resourceSearchInput : undefined}
onSearchChange={expandedCheckId ? setResourceSearchInput : undefined}
onSearchCommit={expandedCheckId ? setResourceSearch : undefined}
searchBadge={
expandedGroup
? { label: expandedGroup.checkTitle, onDismiss: handleCollapse }
: undefined
}
toolbarRightContent={<CustomCheckboxMutedFindings />}
renderAfterRow={renderAfterRow}
/>
{/* Gate the tour on having at least one finding group */}
<div>
<Suspense fallback={null}>
{safeData.length > 0 && (
<OnboardingTrigger
flow={exploreFindingsFlow}
stepHandlers={createExploreFindingsTourStepHandlers(
openFirstFindingGroup,
)}
/>
)}
</Suspense>
{/* Signals the navbar that this route's data has loaded (enables the replay icon). */}
<PageReady />
<DataTable
columns={columns}
data={safeData}
metadata={metadata}
enableRowSelection
rowSelection={rowSelection}
onRowSelectionChange={setRowSelection}
getRowCanSelect={getRowCanSelect}
showSearch
searchPlaceholder={
expandedCheckId ? "Search resources..." : "Search by name"
}
controlledSearch={expandedCheckId ? resourceSearchInput : undefined}
onSearchChange={expandedCheckId ? setResourceSearchInput : undefined}
onSearchCommit={expandedCheckId ? setResourceSearch : undefined}
searchBadge={
expandedGroup
? { label: expandedGroup.checkTitle, onDismiss: handleCollapse }
: undefined
}
toolbarRightContent={<CustomCheckboxMutedFindings />}
renderAfterRow={renderAfterRow}
// Anchor the "Open a finding group" tour step to the first group row
// (there may be only one); driver.js resolves to the first match.
getRowAttributes={(row) =>
row.index === 0 ? { "data-tour-id": "explore-findings-group" } : {}
}
/>
</div>
{(selectedCheckIds.length > 0 || hasResourceSelection) && (
<FloatingMuteButton
@@ -238,7 +267,6 @@ export function FindingsGroupTable({
selectedCheckIds.length > 0
? resolveGroupMuteIds(selectedCheckIds)
: Promise.resolve([]),
// resourceSelection already contains real finding UUIDs
Promise.resolve(hasResourceSelection ? resourceSelection : []),
]);
return [...groupIds, ...resourceIds];
@@ -217,6 +217,8 @@ export function InlineResourceContainer({
<td colSpan={columnCount} className="p-0">
<AnimatePresence initial>
<motion.div
// Onboarding anchor: the "Review the affected resources" tour step.
data-tour-id="explore-findings-resources"
initial={{ height: 0, opacity: 0 }}
animate={{ height: "auto", opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
-66
View File
@@ -1322,69 +1322,3 @@ export const BellIcon: React.FC<IconSvgProps> = ({
</svg>
);
};
export const SidebarExpandIcon: React.FC<IconSvgProps> = ({
size = 24,
width,
height,
...props
}) => {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width={size || width}
height={size || height}
viewBox="0 0 24 24"
fill="none"
{...props}
>
<path
d="M19 21H5C4.46957 21 3.96086 20.7893 3.58579 20.4142C3.21071 20.0391 3 19.5304 3 19V5C3 4.46957 3.21071 3.96086 3.58579 3.58579C3.96086 3.21071 4.46957 3 5 3H19C19.5304 3 20.0391 3.21071 20.4142 3.58579C20.7893 3.96086 21 4.46957 21 5V19C21 19.5304 20.7893 20.0391 20.4142 20.4142C20.0391 20.7893 19.5304 21 19 21Z"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M7.25 10L5.5 12L7.25 14M9.5 21V3"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
};
export const SidebarCollapseIcon: React.FC<IconSvgProps> = ({
size = 24,
width,
height,
...props
}) => {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width={size || width}
height={size || height}
viewBox="0 0 24 24"
fill="none"
{...props}
>
<path
d="M19 21H5C4.46957 21 3.96086 20.7893 3.58579 20.4142C3.21071 20.0391 3 19.5304 3 19V5C3 4.46957 3.21071 3.96086 3.58579 3.58579C3.96086 3.21071 4.46957 3 5 3H19C19.5304 3 20.0391 3.21071 20.4142 3.58579C20.7893 3.96086 21 4.46957 21 5V19C21 19.5304 20.7893 20.0391 20.4142 20.4142C20.0391 20.7893 19.5304 21 19 21Z"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M16.75 10L18.5 12L16.75 14M14.5 21V3"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
};
@@ -0,0 +1,109 @@
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, describe, expect, it, vi } from "vitest";
import { OnboardingCheckpointDialog } from "../onboarding-checkpoint-dialog";
describe("OnboardingCheckpointDialog", () => {
afterEach(() => {
vi.restoreAllMocks();
});
describe("when open", () => {
it("shows the checkpoint title and both choices", () => {
render(
<OnboardingCheckpointDialog
open
onContinue={vi.fn()}
onFinish={vi.fn()}
/>,
);
expect(
screen.getByText("Provider added — keep exploring?"),
).toBeInTheDocument();
expect(
screen.getByText(/Your first provider is added\./),
).toBeInTheDocument();
expect(
screen.getByRole("button", { name: /continue the tour/i }),
).toBeInTheDocument();
expect(
screen.getByRole("button", { name: /finish here/i }),
).toBeInTheDocument();
});
it("calls onContinue when the primary button is clicked", async () => {
const user = userEvent.setup();
const onContinue = vi.fn();
const onFinish = vi.fn();
render(
<OnboardingCheckpointDialog
open
onContinue={onContinue}
onFinish={onFinish}
/>,
);
await user.click(
screen.getByRole("button", { name: /continue the tour/i }),
);
expect(onContinue).toHaveBeenCalledTimes(1);
expect(onFinish).not.toHaveBeenCalled();
});
it("calls onFinish when the outline button is clicked", async () => {
const user = userEvent.setup();
const onContinue = vi.fn();
const onFinish = vi.fn();
render(
<OnboardingCheckpointDialog
open
onContinue={onContinue}
onFinish={onFinish}
/>,
);
await user.click(screen.getByRole("button", { name: /finish here/i }));
expect(onFinish).toHaveBeenCalledTimes(1);
expect(onContinue).not.toHaveBeenCalled();
});
it("treats Escape (overlay/X dismiss) as finish", async () => {
const user = userEvent.setup();
const onContinue = vi.fn();
const onFinish = vi.fn();
render(
<OnboardingCheckpointDialog
open
onContinue={onContinue}
onFinish={onFinish}
/>,
);
await user.keyboard("{Escape}");
// Escape/overlay dismiss must route to onFinish, not onContinue.
await waitFor(() => expect(onFinish).toHaveBeenCalledTimes(1));
expect(onContinue).not.toHaveBeenCalled();
});
});
describe("when closed", () => {
it("renders nothing", () => {
render(
<OnboardingCheckpointDialog
open={false}
onContinue={vi.fn()}
onFinish={vi.fn()}
/>,
);
expect(
screen.queryByText("Provider added — keep exploring?"),
).not.toBeInTheDocument();
});
});
});
@@ -0,0 +1,112 @@
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { getFlowById } from "@/lib/onboarding";
import { OnboardingCheckpointWatcher } from "../onboarding-checkpoint-watcher";
const pushMock = vi.fn();
const startSequenceMock = vi.fn();
const closeMock = vi.fn();
const CHECKPOINT_MARKER = "prowler.onboarding.checkpoint";
// Tests set this before render to control the store `open` flag the watcher subscribes to.
let checkpointOpenState = false;
vi.mock("next/navigation", () => ({
useRouter: () => ({ push: pushMock }),
}));
vi.mock("@/store/onboarding-sequence", () => ({
useOnboardingSequenceStore: {
getState: () => ({ startSequence: startSequenceMock }),
},
}));
vi.mock("@/store/onboarding-checkpoint", () => ({
CHECKPOINT_MARKER: "prowler.onboarding.checkpoint",
useOnboardingCheckpointStore: Object.assign(
(selector: (state: { open: boolean }) => unknown) =>
selector({ open: checkpointOpenState }),
{
getState: () => ({ close: closeMock }),
},
),
}));
describe("OnboardingCheckpointWatcher", () => {
beforeEach(() => {
pushMock.mockClear();
startSequenceMock.mockClear();
closeMock.mockClear();
checkpointOpenState = false;
window.localStorage.clear();
});
afterEach(() => {
vi.restoreAllMocks();
});
describe("rendering", () => {
it("renders the dialog when the store open flag is true", () => {
checkpointOpenState = true;
render(<OnboardingCheckpointWatcher />);
expect(
screen.getByText("Provider added — keep exploring?"),
).toBeInTheDocument();
});
it("does not render the dialog when the store open flag is false", () => {
checkpointOpenState = false;
render(<OnboardingCheckpointWatcher />);
expect(
screen.queryByText("Provider added — keep exploring?"),
).not.toBeInTheDocument();
});
});
describe("when the user continues the tour", () => {
it("marks handled, starts the sequence at the next flow, navigates, and closes the store", async () => {
const user = userEvent.setup();
checkpointOpenState = true;
render(<OnboardingCheckpointWatcher />);
await screen.findByText("Provider added — keep exploring?");
await user.click(
screen.getByRole("button", { name: /continue the tour/i }),
);
const nextFlow = getFlowById("view-first-scan");
expect(window.localStorage.getItem(CHECKPOINT_MARKER)).not.toBeNull();
expect(closeMock).toHaveBeenCalledTimes(1);
if (nextFlow) {
expect(startSequenceMock).toHaveBeenCalledWith(nextFlow.id);
expect(pushMock).toHaveBeenCalledWith(nextFlow.route);
} else {
// Guard: registry is still add-provider-only.
expect(startSequenceMock).not.toHaveBeenCalled();
expect(pushMock).not.toHaveBeenCalled();
}
});
});
describe("when the user finishes here", () => {
it("marks handled, starts no sequence, does not navigate, and closes the store", async () => {
const user = userEvent.setup();
checkpointOpenState = true;
render(<OnboardingCheckpointWatcher />);
await screen.findByText("Provider added — keep exploring?");
await user.click(screen.getByRole("button", { name: /finish here/i }));
expect(window.localStorage.getItem(CHECKPOINT_MARKER)).not.toBeNull();
expect(startSequenceMock).not.toHaveBeenCalled();
expect(pushMock).not.toHaveBeenCalled();
await waitFor(() => expect(closeMock).toHaveBeenCalledTimes(1));
});
});
});
@@ -0,0 +1,185 @@
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { addProviderTour } from "@/lib/tours/add-provider.tour";
import { localStorageAdapter } from "@/lib/tours/store/local-storage-adapter";
import { OnboardingGate } from "../onboarding-gate";
const pushMock = vi.fn();
const armMock = vi.fn();
vi.mock("next/navigation", () => ({
useRouter: () => ({ push: pushMock, replace: vi.fn() }),
}));
vi.mock("@/store/onboarding-checkpoint", () => ({
useOnboardingCheckpointStore: {
getState: () => ({ arm: armMock }),
},
}));
const addProviderTourId = {
id: addProviderTour.id,
version: addProviderTour.version,
};
describe("OnboardingGate", () => {
beforeEach(() => {
window.localStorage.clear();
pushMock.mockClear();
armMock.mockClear();
});
afterEach(() => {
vi.restoreAllMocks();
});
describe("when the user has no providers and no completion record", () => {
it("shows the Welcome modal", async () => {
render(<OnboardingGate hasProviders={false} />);
expect(
await screen.findByRole("button", { name: /get started/i }),
).toBeInTheDocument();
});
});
describe("when the user already has providers", () => {
it("does not show the Welcome modal", async () => {
render(<OnboardingGate hasProviders={true} />);
await waitFor(() => {
expect(
screen.queryByRole("button", { name: /get started/i }),
).not.toBeInTheDocument();
});
});
});
describe("when a completion record already exists in this browser", () => {
it("does not show the Welcome modal", async () => {
localStorageAdapter.set(addProviderTourId, {
tourId: addProviderTour.id,
version: addProviderTour.version,
state: "dismissed",
completedAt: new Date().toISOString(),
});
render(<OnboardingGate hasProviders={false} />);
await waitFor(() => {
expect(
screen.queryByRole("button", { name: /get started/i }),
).not.toBeInTheDocument();
});
});
});
describe("when the gate flow is dismissed but later sequence flows are incomplete", () => {
it("does not show the Welcome modal for a later flow", async () => {
// Later flows are only reachable via the checkpoint/sequence, never the gate.
localStorageAdapter.set(addProviderTourId, {
tourId: addProviderTour.id,
version: addProviderTour.version,
state: "dismissed",
completedAt: new Date().toISOString(),
});
render(<OnboardingGate hasProviders={false} />);
await waitFor(() => {
expect(
screen.queryByRole("button", { name: /get started/i }),
).not.toBeInTheDocument();
});
});
});
describe("when hasProviders is undefined (fail-open)", () => {
it("does not show the Welcome modal", async () => {
// `undefined` mirrors the tri-state layout forwards on a failed provider fetch.
render(<OnboardingGate hasProviders={undefined} />);
await waitFor(() => {
expect(
screen.queryByRole("button", { name: /get started/i }),
).not.toBeInTheDocument();
});
});
it("can be mounted with the prop omitted entirely (fail-open)", async () => {
render(<OnboardingGate />);
await waitFor(() => {
expect(
screen.queryByRole("button", { name: /get started/i }),
).not.toBeInTheDocument();
});
});
});
describe("when the user accepts the Welcome modal", () => {
it("navigates to the flow route with the onboarding query param and writes no record", async () => {
const user = userEvent.setup();
render(<OnboardingGate hasProviders={false} />);
const getStarted = await screen.findByRole("button", {
name: /get started/i,
});
await user.click(getStarted);
expect(pushMock).toHaveBeenCalledWith(
"/providers?onboarding=add-provider",
);
expect(localStorageAdapter.get(addProviderTourId)).toBeNull();
});
it("arms the onboarding checkpoint", async () => {
const user = userEvent.setup();
render(<OnboardingGate hasProviders={false} />);
const getStarted = await screen.findByRole("button", {
name: /get started/i,
});
await user.click(getStarted);
expect(armMock).toHaveBeenCalledTimes(1);
});
});
describe("when the user dismisses the Welcome modal", () => {
it("writes a dismissed record and stops showing the modal", async () => {
const user = userEvent.setup();
render(<OnboardingGate hasProviders={false} />);
const skip = await screen.findByRole("button", {
name: /skip for now/i,
});
await user.click(skip);
await waitFor(() => {
expect(
screen.queryByRole("button", { name: /skip for now/i }),
).not.toBeInTheDocument();
});
const record = localStorageAdapter.get(addProviderTourId);
expect(record).not.toBeNull();
expect(record?.state).toBe("dismissed");
});
it("does NOT arm the onboarding checkpoint", async () => {
const user = userEvent.setup();
render(<OnboardingGate hasProviders={false} />);
const skip = await screen.findByRole("button", {
name: /skip for now/i,
});
await user.click(skip);
// Skipping must never arm the checkpoint (user opted out).
expect(armMock).not.toHaveBeenCalled();
});
});
});
@@ -0,0 +1,63 @@
import { describe, expect, it } from "vitest";
import type { OnboardingFlow } from "@/lib/onboarding";
import { getSequenceProgress } from "../onboarding-sequence-banner.logic";
// Minimal fixtures — the progress helper only reads id/order/title/route and the optional dataRequirementHint.
const buildFlow = (overrides: Partial<OnboardingFlow>): OnboardingFlow =>
({
id: overrides.id ?? "flow",
order: overrides.order ?? 1,
title: overrides.title ?? "Title",
description: overrides.description ?? "Description",
route: overrides.route ?? "/route",
tour: overrides.tour ?? { id: "t", version: 1, coversFiles: [], steps: [] },
dataRequirementHint: overrides.dataRequirementHint,
}) as OnboardingFlow;
const flows: OnboardingFlow[] = [
buildFlow({ id: "a", order: 1, title: "First", route: "/a" }),
buildFlow({
id: "b",
order: 2,
title: "Second",
route: "/b",
dataRequirementHint: "needs a scan",
}),
buildFlow({ id: "c", order: 3, title: "Third", route: "/c" }),
];
describe("getSequenceProgress", () => {
it("computes the index, total, current flow, and next flow for a middle step", () => {
const progress = getSequenceProgress("b", flows);
// 0-based index 1 of 3, current is b, next is c
expect(progress).not.toBeNull();
expect(progress?.index).toBe(1);
expect(progress?.total).toBe(3);
expect(progress?.flow.id).toBe("b");
expect(progress?.nextFlow?.id).toBe("c");
});
it("returns a null nextFlow on the last step", () => {
const progress = getSequenceProgress("c", flows);
expect(progress?.index).toBe(2);
expect(progress?.total).toBe(3);
expect(progress?.nextFlow).toBeNull();
});
it("returns null when the currentFlowId is unknown or null", () => {
expect(getSequenceProgress("missing", flows)).toBeNull();
expect(getSequenceProgress(null, flows)).toBeNull();
});
it("exposes the data requirement hint of the current flow when present", () => {
const withHint = getSequenceProgress("b", flows);
const withoutHint = getSequenceProgress("a", flows);
expect(withHint?.flow.dataRequirementHint).toBe("needs a scan");
expect(withoutHint?.flow.dataRequirementHint).toBeUndefined();
});
});
@@ -0,0 +1,193 @@
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { getFlowById } from "@/lib/onboarding";
import { OnboardingSequenceBanner } from "../onboarding-sequence-banner";
const pushMock = vi.fn();
const advanceMock = vi.fn();
const stopMock = vi.fn();
// Mutable snapshot the mocked store hook returns; tests mutate via setSlice().
let sliceState = {
active: false,
currentFlowId: null as string | null,
advance: advanceMock,
stop: stopMock,
};
vi.mock("next/navigation", () => ({
useRouter: () => ({ push: pushMock }),
}));
vi.mock("@/store/onboarding-sequence", () => {
const hook = (selector: (state: typeof sliceState) => unknown) =>
selector(sliceState);
hook.getState = () => sliceState;
return { useOnboardingSequenceStore: hook };
});
function setSlice(next: Partial<typeof sliceState>) {
sliceState = { ...sliceState, ...next };
}
describe("OnboardingSequenceBanner", () => {
beforeEach(() => {
pushMock.mockClear();
advanceMock.mockClear();
stopMock.mockClear();
sliceState = {
active: false,
currentFlowId: null,
advance: advanceMock,
stop: stopMock,
};
});
afterEach(() => {
vi.restoreAllMocks();
});
it("renders nothing when the sequence is inactive", () => {
const { container } = render(<OnboardingSequenceBanner />);
expect(container).toBeEmptyDOMElement();
});
it("shows the step progress for the active flow", () => {
setSlice({ active: true, currentFlowId: "view-first-scan" });
const flow = getFlowById("view-first-scan")!;
render(<OnboardingSequenceBanner />);
expect(screen.getByText(`Step 2 of 5: ${flow.title}`)).toBeInTheDocument();
});
it("announces step progress to screen readers via a polite live region", () => {
setSlice({ active: true, currentFlowId: "view-first-scan" });
const flow = getFlowById("view-first-scan")!;
render(<OnboardingSequenceBanner />);
// Polite live region so screen readers announce step transitions on update.
const status = screen.getByRole("status");
expect(status).toHaveTextContent(`Step 2 of 5: ${flow.title}`);
expect(status).toHaveAttribute("aria-live", "polite");
});
it("does not show the data requirement hint on a scan-dependent step once a scan has finished", () => {
// The Continue gate already guarantees we only reach this step with data,
// so the "wait for findings" hint would be stale/misleading here.
setSlice({ active: true, currentFlowId: "explore-findings" });
render(<OnboardingSequenceBanner hasCompletedScan={true} />);
expect(
screen.queryByText(/wait for the scan to finish/i),
).not.toBeInTheDocument();
});
it("does not show a hint for a flow without one", () => {
setSlice({ active: true, currentFlowId: "view-first-scan" });
render(<OnboardingSequenceBanner />);
expect(
screen.queryByText(/wait for the scan to finish/i),
).not.toBeInTheDocument();
});
it("advances and navigates to the next flow when Continue is clicked", async () => {
setSlice({ active: true, currentFlowId: "view-first-scan" });
const nextFlow = getFlowById("explore-findings")!;
const user = userEvent.setup();
render(<OnboardingSequenceBanner />);
await user.click(screen.getByRole("button", { name: /continue/i }));
expect(advanceMock).toHaveBeenCalledTimes(1);
expect(pushMock).toHaveBeenCalledWith(nextFlow.route);
expect(stopMock).not.toHaveBeenCalled();
});
it("stops the sequence without navigating when Continue is clicked on the last step", async () => {
setSlice({ active: true, currentFlowId: "attack-paths" });
const user = userEvent.setup();
render(<OnboardingSequenceBanner />);
await user.click(screen.getByRole("button", { name: /continue/i }));
expect(stopMock).toHaveBeenCalledTimes(1);
expect(pushMock).not.toHaveBeenCalled();
});
it("stops the sequence when Skip is clicked", async () => {
setSlice({ active: true, currentFlowId: "explore-findings" });
const user = userEvent.setup();
render(<OnboardingSequenceBanner />);
await user.click(screen.getByRole("button", { name: /skip/i }));
expect(stopMock).toHaveBeenCalledTimes(1);
expect(advanceMock).not.toHaveBeenCalled();
expect(pushMock).not.toHaveBeenCalled();
});
describe("scan-gated Continue", () => {
it("disables Continue when the next step needs scan data and none has finished", () => {
// On the scan step, advancing would land on explore-findings (scan-dependent).
setSlice({ active: true, currentFlowId: "view-first-scan" });
render(<OnboardingSequenceBanner hasCompletedScan={false} />);
expect(screen.getByRole("button", { name: /continue/i })).toBeDisabled();
// The next step's hint explains why progression is blocked.
expect(
screen.getByText(/wait for the scan to finish/i),
).toBeInTheDocument();
});
it("does not advance even if a disabled Continue is force-clicked", async () => {
setSlice({ active: true, currentFlowId: "view-first-scan" });
const user = userEvent.setup();
render(<OnboardingSequenceBanner hasCompletedScan={false} />);
await user.click(screen.getByRole("button", { name: /continue/i }));
expect(advanceMock).not.toHaveBeenCalled();
expect(pushMock).not.toHaveBeenCalled();
});
it("enables Continue once a scan has finished", () => {
setSlice({ active: true, currentFlowId: "view-first-scan" });
render(<OnboardingSequenceBanner hasCompletedScan={true} />);
expect(screen.getByRole("button", { name: /continue/i })).toBeEnabled();
});
it("surfaces the hint on a scan-dependent step itself when no scan has finished", () => {
// Edge case: if we somehow sit on findings without a completed scan, the
// next step (compliance) is still gated, so the hint explains the block.
setSlice({ active: true, currentFlowId: "explore-findings" });
render(<OnboardingSequenceBanner hasCompletedScan={false} />);
expect(screen.getByRole("button", { name: /continue/i })).toBeDisabled();
expect(
screen.getByText(/wait for the scan to finish/i),
).toBeInTheDocument();
});
it("never gates Continue when the next step does not need scan data", () => {
// add-provider → view-first-scan: neither requires scan data.
setSlice({ active: true, currentFlowId: "add-provider" });
render(<OnboardingSequenceBanner hasCompletedScan={false} />);
expect(screen.getByRole("button", { name: /continue/i })).toBeEnabled();
});
});
});
@@ -0,0 +1,164 @@
import { describe, expect, it } from "vitest";
import {
mapCloseToSequenceAction,
resolveTriggerRequest,
} from "../onboarding-trigger.logic";
describe("resolveTriggerRequest", () => {
describe("replay (param) path", () => {
it("requests a replay start when the param matches this flow", () => {
const result = resolveTriggerRequest({
param: "add-provider",
replayRequestFlowId: null,
sliceActive: false,
currentFlowId: null,
flowId: "add-provider",
});
expect(result).toEqual({ start: true, mode: "replay" });
});
it("takes precedence over the sequence when the param matches", () => {
const result = resolveTriggerRequest({
param: "add-provider",
replayRequestFlowId: null,
sliceActive: true,
currentFlowId: "add-provider",
flowId: "add-provider",
});
// Param takes precedence over sequence when both match.
expect(result).toEqual({ start: true, mode: "replay" });
});
it("takes precedence over an in-memory replay request", () => {
const result = resolveTriggerRequest({
param: "add-provider",
replayRequestFlowId: "add-provider",
sliceActive: false,
currentFlowId: null,
flowId: "add-provider",
});
// Both resolve to replay; the param branch wins but the mode is identical.
expect(result).toEqual({ start: true, mode: "replay" });
});
});
describe("replay (in-memory request) path", () => {
it("requests a replay start when the store names this flow", () => {
const result = resolveTriggerRequest({
param: null,
replayRequestFlowId: "add-provider",
sliceActive: false,
currentFlowId: null,
flowId: "add-provider",
});
expect(result).toEqual({ start: true, mode: "replay" });
});
it("takes precedence over the sequence when the store names this flow", () => {
const result = resolveTriggerRequest({
param: null,
replayRequestFlowId: "add-provider",
sliceActive: true,
currentFlowId: "add-provider",
flowId: "add-provider",
});
expect(result).toEqual({ start: true, mode: "replay" });
});
it("does not start when the store names a different flow", () => {
const result = resolveTriggerRequest({
param: null,
replayRequestFlowId: "view-first-scan",
sliceActive: false,
currentFlowId: null,
flowId: "add-provider",
});
expect(result).toBeNull();
});
});
describe("sequence (slice) path", () => {
it("requests a sequence start when the active slice names this flow", () => {
const result = resolveTriggerRequest({
param: null,
replayRequestFlowId: null,
sliceActive: true,
currentFlowId: "add-provider",
flowId: "add-provider",
});
expect(result).toEqual({ start: true, mode: "sequence" });
});
it("does not start when the active slice names a different flow", () => {
const result = resolveTriggerRequest({
param: null,
replayRequestFlowId: null,
sliceActive: true,
currentFlowId: "view-first-scan",
flowId: "add-provider",
});
expect(result).toBeNull();
});
it("does not start when the slice is inactive even if the id matches", () => {
const result = resolveTriggerRequest({
param: null,
replayRequestFlowId: null,
sliceActive: false,
currentFlowId: "add-provider",
flowId: "add-provider",
});
expect(result).toBeNull();
});
});
describe("no-match path", () => {
it("returns null when the param targets a different flow and the slice is inactive", () => {
const result = resolveTriggerRequest({
param: "view-first-scan",
replayRequestFlowId: null,
sliceActive: false,
currentFlowId: null,
flowId: "add-provider",
});
expect(result).toBeNull();
});
it("returns null when neither the param, the store, nor the slice match", () => {
const result = resolveTriggerRequest({
param: null,
replayRequestFlowId: null,
sliceActive: false,
currentFlowId: null,
flowId: "add-provider",
});
expect(result).toBeNull();
});
});
});
describe("mapCloseToSequenceAction", () => {
it("advances the sequence when the tour completes", () => {
expect(mapCloseToSequenceAction("completed")).toBe("advance");
});
it("stops the sequence when the tour is skipped", () => {
expect(mapCloseToSequenceAction("skipped")).toBe("stop");
});
it("stops the sequence when the tour is dismissed", () => {
expect(mapCloseToSequenceAction("dismissed")).toBe("stop");
});
});
@@ -0,0 +1,300 @@
import { render, waitFor } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { getFlowById } from "@/lib/onboarding";
import type { OnboardingSequenceMode } from "@/store/onboarding-sequence";
import { OnboardingTrigger } from "../onboarding-trigger";
const startMock = vi.fn();
const pushMock = vi.fn();
const advanceMock = vi.fn();
const stopMock = vi.fn();
const consumeMock = vi.fn();
// Counts hook re-invocations; the regression test uses this to confirm the runner stays mounted.
const useDriverTourMock = vi.fn();
// Captures onClosed so close→action wiring can be exercised without driver.js.
let capturedOnClosed: ((state: string) => void) | undefined;
let searchParamsValue = new URLSearchParams();
let sliceState = {
active: false,
currentFlowId: null as string | null,
mode: null as OnboardingSequenceMode | null,
advance: advanceMock,
stop: stopMock,
};
let replayState = {
flowId: null as string | null,
token: 0,
consume: consumeMock,
};
vi.mock("next/navigation", () => ({
useSearchParams: () => searchParamsValue,
usePathname: () => "/providers",
useRouter: () => ({ replace: vi.fn(), push: pushMock }),
}));
vi.mock("@/lib/tours/use-driver-tour", () => ({
useDriverTour: (
_tour: unknown,
options: { onClosed?: (state: string) => void },
) => {
useDriverTourMock();
capturedOnClosed = options?.onClosed;
return { start: startMock, stop: vi.fn(), hasCompleted: false };
},
}));
vi.mock("@/store/onboarding-sequence", () => {
const hook = (selector: (state: typeof sliceState) => unknown) =>
selector(sliceState);
hook.getState = () => sliceState;
return { useOnboardingSequenceStore: hook };
});
vi.mock("@/store/onboarding-replay", () => {
const hook = (selector: (state: typeof replayState) => unknown) =>
selector(replayState);
hook.getState = () => replayState;
return { useOnboardingReplayStore: hook };
});
const addProviderFlow = getFlowById("add-provider")!;
function setSlice(next: Partial<typeof sliceState>) {
sliceState = { ...sliceState, ...next };
}
function setReplay(next: Partial<typeof replayState>) {
replayState = { ...replayState, ...next };
}
describe("OnboardingTrigger", () => {
beforeEach(() => {
startMock.mockClear();
pushMock.mockClear();
advanceMock.mockClear();
stopMock.mockClear();
consumeMock.mockClear();
useDriverTourMock.mockClear();
capturedOnClosed = undefined;
// Trigger only resolves in cloud.
vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "true");
searchParamsValue = new URLSearchParams();
sliceState = {
active: false,
currentFlowId: null,
mode: null,
advance: advanceMock,
stop: stopMock,
};
replayState = { flowId: null, token: 0, consume: consumeMock };
});
afterEach(() => {
vi.restoreAllMocks();
});
describe("when the onboarding param matches this flow (replay)", () => {
it("force-starts the tour and strips the param", async () => {
searchParamsValue = new URLSearchParams("onboarding=add-provider");
const replaceStateSpy = vi.spyOn(window.history, "replaceState");
render(<OnboardingTrigger flow={addProviderFlow} />);
// Param is stripped via history.replaceState (no router round-trip).
await waitFor(() => expect(startMock).toHaveBeenCalledTimes(1));
await waitFor(() =>
expect(replaceStateSpy).toHaveBeenCalledWith(
null,
"",
window.location.pathname,
),
);
});
it("strips only the onboarding param and preserves other query params", async () => {
searchParamsValue = new URLSearchParams(
"scanId=scan-1&onboarding=add-provider&tab=completed",
);
const replaceStateSpy = vi.spyOn(window.history, "replaceState");
render(<OnboardingTrigger flow={addProviderFlow} />);
await waitFor(() => expect(startMock).toHaveBeenCalledTimes(1));
await waitFor(() =>
expect(replaceStateSpy).toHaveBeenCalledWith(
null,
"",
`${window.location.pathname}?scanId=scan-1&tab=completed`,
),
);
});
});
describe("when the in-memory replay store names this flow", () => {
it("force-starts the tour, consumes the request, and never touches the URL", async () => {
// Same-route navbar replay: started via the store, no `?onboarding=` param.
setReplay({ flowId: "add-provider", token: 1 });
const replaceStateSpy = vi.spyOn(window.history, "replaceState");
render(<OnboardingTrigger flow={addProviderFlow} />);
await waitFor(() => expect(startMock).toHaveBeenCalledTimes(1));
await waitFor(() => expect(consumeMock).toHaveBeenCalledTimes(1));
// No URL param to strip, and no router round-trip → no RSC refetch.
expect(replaceStateSpy).not.toHaveBeenCalled();
expect(pushMock).not.toHaveBeenCalled();
});
});
describe("when the sequence names this flow", () => {
it("force-starts the tour without stripping any param", async () => {
setSlice({
active: true,
currentFlowId: "add-provider",
mode: "sequence",
});
const replaceStateSpy = vi.spyOn(window.history, "replaceState");
render(<OnboardingTrigger flow={addProviderFlow} />);
await waitFor(() => expect(startMock).toHaveBeenCalledTimes(1));
expect(replaceStateSpy).not.toHaveBeenCalled();
});
});
describe("when neither the param nor the sequence names this flow", () => {
it("renders null and does not start the tour", async () => {
searchParamsValue = new URLSearchParams();
const replaceStateSpy = vi.spyOn(window.history, "replaceState");
const { container } = render(
<OnboardingTrigger flow={addProviderFlow} />,
);
expect(container).toBeEmptyDOMElement();
await waitFor(() => expect(startMock).not.toHaveBeenCalled());
expect(replaceStateSpy).not.toHaveBeenCalled();
});
});
describe("when the onboarding param targets a different flow", () => {
it("does not start the tour", async () => {
searchParamsValue = new URLSearchParams("onboarding=explore-findings");
render(<OnboardingTrigger flow={addProviderFlow} />);
await waitFor(() => expect(startMock).not.toHaveBeenCalled());
});
});
describe("in self-hosted (OSS) deployments", () => {
it("renders null and never starts the tour, even with a matching param", async () => {
vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "false");
searchParamsValue = new URLSearchParams("onboarding=add-provider");
const { container } = render(
<OnboardingTrigger flow={addProviderFlow} />,
);
expect(container).toBeEmptyDOMElement();
await waitFor(() => expect(startMock).not.toHaveBeenCalled());
});
it("ignores an active sequence slice in OSS", async () => {
vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "false");
setSlice({
active: true,
currentFlowId: "add-provider",
mode: "sequence",
});
const { container } = render(
<OnboardingTrigger flow={addProviderFlow} />,
);
expect(container).toBeEmptyDOMElement();
await waitFor(() => expect(startMock).not.toHaveBeenCalled());
});
});
describe("when a sequence tour completes", () => {
it("leaves the sequence slice untouched (the banner owns advance now)", async () => {
setSlice({
active: true,
currentFlowId: "add-provider",
mode: "sequence",
});
render(<OnboardingTrigger flow={addProviderFlow} />);
await waitFor(() => expect(capturedOnClosed).toBeDefined());
capturedOnClosed?.("completed");
// Banner is the sole advance/exit control; closing the tour must not auto-advance.
expect(advanceMock).not.toHaveBeenCalled();
expect(stopMock).not.toHaveBeenCalled();
expect(pushMock).not.toHaveBeenCalled();
});
});
describe("when a sequence tour is dismissed", () => {
it("leaves the sequence slice untouched (no auto-stop on close)", async () => {
setSlice({
active: true,
currentFlowId: "add-provider",
mode: "sequence",
});
render(<OnboardingTrigger flow={addProviderFlow} />);
await waitFor(() => expect(capturedOnClosed).toBeDefined());
capturedOnClosed?.("skipped");
// Only the banner Exit button ends the sequence; closing the tour must not auto-stop.
expect(stopMock).not.toHaveBeenCalled();
expect(advanceMock).not.toHaveBeenCalled();
expect(pushMock).not.toHaveBeenCalled();
});
});
describe("when a replay tour closes", () => {
it("does not touch the sequence slice", async () => {
searchParamsValue = new URLSearchParams("onboarding=add-provider");
render(<OnboardingTrigger flow={addProviderFlow} />);
await waitFor(() => expect(capturedOnClosed).toBeDefined());
capturedOnClosed?.("completed");
// Single-flow replay never advances or stops the sequence.
expect(advanceMock).not.toHaveBeenCalled();
expect(stopMock).not.toHaveBeenCalled();
expect(pushMock).not.toHaveBeenCalled();
});
});
describe("when the param is cleared after the tour starts (regression)", () => {
it("keeps the runner mounted and does not restart the tour", async () => {
// StrictMode-safe latch: stripping the param must not unmount the runner or re-start the tour.
searchParamsValue = new URLSearchParams("onboarding=add-provider");
const { rerender } = render(<OnboardingTrigger flow={addProviderFlow} />);
await waitFor(() => expect(startMock).toHaveBeenCalledTimes(1));
const callsBeforeClear = useDriverTourMock.mock.calls.length;
searchParamsValue = new URLSearchParams();
rerender(<OnboardingTrigger flow={addProviderFlow} />);
await waitFor(() =>
expect(useDriverTourMock.mock.calls.length).toBeGreaterThan(
callsBeforeClear,
),
);
expect(startMock).toHaveBeenCalledTimes(1);
});
});
});
@@ -0,0 +1,83 @@
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it, vi } from "vitest";
import { OnboardingWelcomeModal } from "../onboarding-welcome-modal";
describe("OnboardingWelcomeModal", () => {
describe("when open is true", () => {
it("renders the flow title and description", () => {
render(
<OnboardingWelcomeModal
open
flowTitle="Add your first provider"
flowDescription="Connect a cloud account so Prowler has something to scan."
onAccept={vi.fn()}
onDismiss={vi.fn()}
/>,
);
expect(screen.getByText("Add your first provider")).toBeInTheDocument();
expect(
screen.getByText(
"Connect a cloud account so Prowler has something to scan.",
),
).toBeInTheDocument();
});
it("calls onAccept when the primary action is clicked", async () => {
const user = userEvent.setup();
const onAccept = vi.fn();
const onDismiss = vi.fn();
render(
<OnboardingWelcomeModal
open
flowTitle="Add your first provider"
onAccept={onAccept}
onDismiss={onDismiss}
/>,
);
await user.click(screen.getByRole("button", { name: /get started/i }));
expect(onAccept).toHaveBeenCalledTimes(1);
expect(onDismiss).not.toHaveBeenCalled();
});
it("calls onDismiss when the skip action is clicked", async () => {
const user = userEvent.setup();
const onAccept = vi.fn();
const onDismiss = vi.fn();
render(
<OnboardingWelcomeModal
open
flowTitle="Add your first provider"
onAccept={onAccept}
onDismiss={onDismiss}
/>,
);
await user.click(screen.getByRole("button", { name: /skip for now/i }));
expect(onDismiss).toHaveBeenCalledTimes(1);
expect(onAccept).not.toHaveBeenCalled();
});
});
describe("when open is false", () => {
it("does not render the modal content", () => {
render(
<OnboardingWelcomeModal
open={false}
flowTitle="Add your first provider"
onAccept={vi.fn()}
onDismiss={vi.fn()}
/>,
);
expect(
screen.queryByRole("button", { name: /get started/i }),
).not.toBeInTheDocument();
});
});
});
@@ -0,0 +1,30 @@
import { render } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { usePageReadyStore } from "@/store/page-ready";
import { PageReady } from "../page-ready";
vi.mock("next/navigation", () => ({
usePathname: () => "/compliance",
}));
describe("PageReady", () => {
beforeEach(() => usePageReadyStore.setState({ readyPath: null }));
it("marks the current route ready on mount", () => {
render(<PageReady />);
expect(usePageReadyStore.getState().readyPath).toBe("/compliance");
});
it("clears readiness on unmount", () => {
const { unmount } = render(<PageReady />);
unmount();
expect(usePageReadyStore.getState().readyPath).toBeNull();
});
it("renders nothing", () => {
const { container } = render(<PageReady />);
expect(container).toBeEmptyDOMElement();
});
});
+5
View File
@@ -0,0 +1,5 @@
export { OnboardingCheckpointWatcher } from "./onboarding-checkpoint-watcher";
export { OnboardingGate } from "./onboarding-gate";
export { OnboardingSequenceBanner } from "./onboarding-sequence-banner";
export { OnboardingTrigger } from "./onboarding-trigger";
export { PageReady } from "./page-ready";

Some files were not shown because too many files have changed in this diff Show More