mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-06-17 13:03:14 +00:00
Compare commits
59 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 11286c0ab4 | |||
| ad06eed7c9 | |||
| edb8c307b8 | |||
| b9104facb3 | |||
| 06100e9cc8 | |||
| 8dce04ccb8 | |||
| d05c871713 | |||
| a335c1bdc2 | |||
| 9502529424 | |||
| eeb02453d1 | |||
| cb4b889b20 | |||
| f1e42d1681 | |||
| ca7ce5a8c3 | |||
| 810d8d7686 | |||
| dd1895d2c4 | |||
| b5bb85c956 | |||
| 36fe48dbc5 | |||
| e5bbffd47c | |||
| 397496e7d7 | |||
| ef74321e53 | |||
| 01dc7aa8b5 | |||
| 566167489b | |||
| 3cb360e9ae | |||
| 24e3182329 | |||
| 49309b43d3 | |||
| 6db8ce672c | |||
| 9465b82747 | |||
| 383d2b218f | |||
| dccd674cf9 | |||
| a679865cce | |||
| 15bfa39b23 | |||
| 6ade644f2d | |||
| 02aea972fa | |||
| d636841832 | |||
| 870b32ed5d | |||
| 5cda01a0df | |||
| f80a56b88c | |||
| 9a9f0989de | |||
| 689916132a | |||
| 37a700dd4c | |||
| e38f249cd4 | |||
| 51c7e4f0b8 | |||
| 67e105cfeb | |||
| 48d7e7aa06 | |||
| 91b8f9dcce | |||
| 35748cc6b0 | |||
| 1d80e2dc17 | |||
| 14551245c4 | |||
| 4bc7a32159 | |||
| 81b636a2e7 | |||
| 3c5239a870 | |||
| f74f5eba0f | |||
| 9cf5c30d3e | |||
| a8998ad091 | |||
| c68b226582 | |||
| e62ae1cf0a | |||
| 21f20fa332 | |||
| 47267f39d0 | |||
| 1559cdf9e8 |
@@ -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
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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}
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
@@ -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_:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
Generated
+3
-3
@@ -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]
|
||||
|
||||
@@ -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)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
+4
-3
@@ -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
|
||||
|
||||
+10
-8
@@ -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)
|
||||
|
||||
+38
@@ -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": ""
|
||||
}
|
||||
+30
@@ -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
@@ -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"]
|
||||
|
||||
@@ -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.
|
||||
@@ -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"),
|
||||
]
|
||||
+143
-3
@@ -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(
|
||||
|
||||
+157
@@ -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
@@ -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` |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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"));
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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
@@ -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.`,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./schedules";
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
+21
-2
@@ -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";
|
||||
|
||||
+81
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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
@@ -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,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>
|
||||
</>
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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
Reference in New Issue
Block a user