Compare commits

..

14 Commits

Author SHA1 Message Date
Prowler Bot 2de298fb7b fix(cli): prevent unrelated built-in provider failures from aborting the CLI (#11620)
Co-authored-by: lydiavilchez <114735608+lydiavilchez@users.noreply.github.com>
2026-06-16 14:35:15 +02:00
Prowler Bot 11f0845a91 fix(gcp): surface organization-scan failures instead of silently scanning the home project (#11619)
Co-authored-by: Rubén De la Torre Vico <ruben@prowler.com>
Co-authored-by: Daniel Barranquero <danielbo2001@gmail.com>
2026-06-16 14:12:57 +02:00
Prowler Bot 42d99a17a6 perf(api): optimize scan-compliance-overviews task (#11613)
Co-authored-by: Pedro Martín <pedromarting3@gmail.com>
2026-06-16 12:18:55 +02:00
César Arroba 832f10b7f6 ci: narrow osv-scanner gate to CRITICAL on v5.30 (backport #11580) (#11616) 2026-06-16 11:35:30 +02:00
César Arroba d133ad18a4 ci: always run container and dependency vulnerability scans on PRs (v5.30 backport) (#11614) 2026-06-16 11:16:38 +02:00
Prowler Bot 3539940a26 fix(gcp): credit audit-filtered aggregated sinks in metric-filter checks (#11607)
Co-authored-by: Aline Almeida <aline@tuplita.ai>
Co-authored-by: Hugo Pereira Brito <101209179+HugoPBrito@users.noreply.github.com>
Co-authored-by: Hugo P.Brito <hugopbrit@gmail.com>
2026-06-16 10:34:23 +02:00
Prowler Bot 1192d94648 chore(release): Bump versions to v5.30.2 (#11571)
Co-authored-by: prowler-bot <179230569+prowler-bot@users.noreply.github.com>
2026-06-12 13:48:44 +02:00
Prowler Bot a578f4af34 chore: prepare API and UI changelogs for 5.30.1 release (#11566)
Co-authored-by: Josema Camacho <josema@prowler.com>
2026-06-12 12:16:15 +02:00
Prowler Bot d6528b674e fix(ui): show threat map data for okta and google workspace accounts (#11563)
Co-authored-by: Alejandro Bailo <59607668+alejandrobailo@users.noreply.github.com>
2026-06-12 10:18:43 +02:00
Prowler Bot 75decbbedf fix(api): drop_subgraph deletes relationships then nodes to cut Neo4j memory (#11561)
Co-authored-by: Josema Camacho <josema@prowler.com>
2026-06-12 09:47:41 +02:00
Prowler Bot 4a14559a5f fix(compliance): resolve provider from scan in attributes endp (#11560)
Co-authored-by: Pedro Martín <pedromarting3@gmail.com>
2026-06-12 09:18:11 +02:00
Prowler Bot c6f8620a0d fix(api): normalize OCI scan region credentials (#11559)
Co-authored-by: Hugo Pereira Brito <101209179+HugoPBrito@users.noreply.github.com>
2026-06-11 17:55:26 +02:00
Prowler Bot ca4889b43e chore(release): Bump versions to v5.30.1 (#11547)
Co-authored-by: prowler-bot <179230569+prowler-bot@users.noreply.github.com>
2026-06-11 15:28:54 +02:00
Prowler Bot 057d061c7e chore(api): Update prowler dependency to v5.30 for release 5.30.0 (#11543)
Co-authored-by: prowler-bot <179230569+prowler-bot@users.noreply.github.com>
2026-06-11 11:15:18 +02:00
218 changed files with 4309 additions and 16579 deletions
+1 -1
View File
@@ -145,7 +145,7 @@ SENTRY_RELEASE=local
NEXT_PUBLIC_SENTRY_ENVIRONMENT=${SENTRY_ENVIRONMENT}
#### Prowler release version ####
NEXT_PUBLIC_PROWLER_RELEASE_VERSION=v5.31.0
NEXT_PUBLIC_PROWLER_RELEASE_VERSION=v5.30.2
# Social login credentials
SOCIAL_GOOGLE_OAUTH_CALLBACK_URL="${AUTH_URL}/api/auth/callback/google"
+2 -2
View File
@@ -63,7 +63,7 @@ runs:
exit-code: '0'
scanners: 'vuln'
timeout: '5m'
version: 'v0.71.0'
version: 'v0.69.2'
- 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.71.0'
version: 'v0.69.2'
- name: Upload Trivy results to GitHub Security tab
if: inputs.upload-sarif == 'true' && github.event_name == 'push'
+3 -2
View File
@@ -6,7 +6,8 @@
# - .github/workflows/api-security.yml, sdk-security.yml, ui-security.yml
#
# Severity levels (comma-separated) are read from OSV_SEVERITY_LEVELS.
# Default: CRITICAL — only CVSS >= 9.0 findings fail the scan.
# Default: HIGH,CRITICAL,UNKNOWN — preserves prior .safety-policy.yml policy
# (ignore-cvss-severity-below: 7 + ignore-cvss-unknown-severity: False).
# 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.
@@ -32,7 +33,7 @@ set -euo pipefail
ROOT="$(git rev-parse --show-toplevel)"
CONFIG="${ROOT}/osv-scanner.toml"
SEVERITY_LEVELS="${OSV_SEVERITY_LEVELS:-CRITICAL}"
SEVERITY_LEVELS="${OSV_SEVERITY_LEVELS:-HIGH,CRITICAL,UNKNOWN}"
for bin in osv-scanner jq; do
if ! command -v "${bin}" >/dev/null 2>&1; then
+1 -1
View File
@@ -131,5 +131,5 @@ jobs:
with:
image-name: ${{ env.IMAGE_NAME }}
image-tag: ${{ github.sha }}
fail-on-critical: 'true'
fail-on-critical: 'false'
severity: 'CRITICAL'
+1 -1
View File
@@ -124,5 +124,5 @@ jobs:
with:
image-name: ${{ env.IMAGE_NAME }}
image-tag: ${{ github.sha }}
fail-on-critical: 'true'
fail-on-critical: 'false'
severity: 'CRITICAL'
-1
View File
@@ -29,7 +29,6 @@ jobs:
- '3.10'
- '3.11'
- '3.12'
- '3.13'
steps:
- name: Harden Runner
+18 -7
View File
@@ -105,14 +105,25 @@ jobs:
id: check-changes
uses: tj-actions/changed-files@9426d40962ed5378910ee2e21d5f8c6fcbf2dd96 # v47.0.6
with:
files: |
prowler/**
Dockerfile*
pyproject.toml
uv.lock
.github/workflows/sdk-container-checks.yml
files: ./**
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
@@ -136,5 +147,5 @@ jobs:
with:
image-name: ${{ env.IMAGE_NAME }}
image-tag: ${{ github.sha }}
fail-on-critical: 'true'
fail-on-critical: 'false'
severity: 'CRITICAL'
+18 -9
View File
@@ -61,18 +61,27 @@ jobs:
id: check-changes
uses: tj-actions/changed-files@9426d40962ed5378910ee2e21d5f8c6fcbf2dd96 # v47.0.6
with:
files: |
prowler/**
tests/**
pyproject.toml
uv.lock
.github/workflows/sdk-tests.yml
files:
./**
.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
+4 -5
View File
@@ -29,7 +29,6 @@ jobs:
- '3.10'
- '3.11'
- '3.12'
- '3.13'
steps:
- name: Harden Runner
@@ -541,7 +540,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'
@@ -589,7 +588,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'
@@ -609,14 +608,14 @@ jobs:
- name: Upload External Provider coverage to Codecov
if: steps.changed-external.outputs.any_changed == 'true'
uses: codecov/codecov-action@671740ac38dd9b0130fbe1cec585b89eea48d3de # v5.5.2
env:
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
with:
flags: prowler-py${{ matrix.python-version }}-external
files: ./external_coverage.xml
# Lib
- name: Check if Lib files changed
if: steps.check-changes.outputs.any_changed == 'true'
+1 -1
View File
@@ -129,5 +129,5 @@ jobs:
with:
image-name: ${{ env.IMAGE_NAME }}
image-tag: ${{ github.sha }}
fail-on-critical: 'true'
fail-on-critical: 'false'
severity: 'CRITICAL'
-4
View File
@@ -131,10 +131,6 @@ jobs:
if: steps.check-changes.outputs.any_changed == 'true'
run: pnpm run healthcheck
- name: Check product-tour alignment
if: steps.check-changes.outputs.any_changed == 'true'
run: pnpm run tour:check
- name: Run pnpm audit
if: steps.check-changes.outputs.any_changed == 'true'
run: pnpm run audit
-85
View File
@@ -1,85 +0,0 @@
# Trivy ignore file for prowlercloud/prowler SDK container image.
# Each entry below documents (a) the affected package and why it ships in the
# image, (b) why the CVE is not exploitable in Prowler's runtime, and (c) the
# upstream fix status. Entries carry an expiry so they auto-force re-review.
# Entries are scoped per-package so suppressions cannot drift onto unrelated
# packages that may be assigned the same CVE in the future.
#
# Scanned by: .github/actions/trivy-scan via .github/workflows/sdk-container-checks.yml
# CVE-2026-42496 — perl-archive-tar path traversal via crafted symlinks.
# CVE-2026-8376 — perl heap buffer overflow when compiling regex.
# Packages: perl, perl-base, perl-modules-5.36, libperl5.36.
# Why ignored: perl-base is part of Debian's "Essential: yes" set; it cannot be
# removed without breaking dpkg. The Prowler SDK does not invoke perl at runtime;
# neither vulnerable code path (Archive::Tar parsing or regex compilation of
# attacker-controlled input) is reachable from Prowler. No Debian bookworm fix
# is available yet.
CVE-2026-42496 pkg:perl exp:2026-07-15
CVE-2026-42496 pkg:perl-base exp:2026-07-15
CVE-2026-42496 pkg:perl-modules-5.36 exp:2026-07-15
CVE-2026-42496 pkg:libperl5.36 exp:2026-07-15
CVE-2026-8376 pkg:perl exp:2026-07-15
CVE-2026-8376 pkg:perl-base exp:2026-07-15
CVE-2026-8376 pkg:perl-modules-5.36 exp:2026-07-15
CVE-2026-8376 pkg:libperl5.36 exp:2026-07-15
# CVE-2025-7458 — SQLite integer overflow.
# Package: libsqlite3-0.
# Why ignored: transitive dependency of CPython's stdlib sqlite3 module. The
# Prowler SDK does not open user-supplied SQLite databases; SQLite usage is
# internal and bounded. No Debian bookworm fix is available.
CVE-2025-7458 pkg:libsqlite3-0 exp:2026-07-15
# CVE-2026-43185 — Linux kernel ksmbd signedness bug.
# Package: linux-libc-dev.
# Why ignored: linux-libc-dev ships kernel headers for build-time compilation,
# not a running kernel. Containers execute against the host kernel, so these
# headers are inert at runtime. The upstream fix landed in kernel 7.0-rc2 and
# has not been backported to Debian's 6.1 LTS line.
CVE-2026-43185 pkg:linux-libc-dev exp:2026-07-15
# CVE-2023-45853 — zlib MiniZip integer overflow / heap overflow in
# zipOpenNewFileInZip4_64.
# Packages: zlib1g, zlib1g-dev.
# Why ignored: Debian Security Tracker status for bookworm is <ignored>, with
# the published rationale "contrib/minizip not built and src:zlib not producing
# binary packages" — i.e. the vulnerable symbol is not present in the libz.so
# shipped by Debian. Real-not-affected, not unpatched. Upstream fix is in
# zlib 1.3.1, available in Debian trixie (13); migrating the base image would
# clear it fully.
# Ref: https://security-tracker.debian.org/tracker/CVE-2023-45853
CVE-2023-45853 pkg:zlib1g exp:2026-07-15
CVE-2023-45853 pkg:zlib1g-dev exp:2026-07-15
# --- API container image (api/Dockerfile) ---
# The entries below are specific to the Prowler API image, which ships
# PowerShell and additional build tooling on top of the same bookworm base.
# CVE-2026-7210 — CPython/Expat hash-flooding denial of service in
# `xml.parsers.expat` and `xml.etree.ElementTree`.
# Packages: the Debian system Python 3.11 (python3.11*, libpython3.11*).
# Why ignored: the API runs under the Python 3.12 interpreter shipped in its
# `.venv`; the system `python3.11` is only present because `python3-dev` is
# pulled in to compile native extensions (xmlsec, lxml) and is never executed
# at runtime. The vulnerable path requires parsing attacker-controlled XML with
# the affected interpreter, which Prowler does not do with the system Python.
# Full mitigation also needs libexpat >= 2.8.0; no Debian bookworm fix yet.
CVE-2026-7210 pkg:python3.11 exp:2026-07-15
CVE-2026-7210 pkg:python3.11-dev exp:2026-07-15
CVE-2026-7210 pkg:python3.11-minimal exp:2026-07-15
CVE-2026-7210 pkg:libpython3.11 exp:2026-07-15
CVE-2026-7210 pkg:libpython3.11-dev exp:2026-07-15
CVE-2026-7210 pkg:libpython3.11-minimal exp:2026-07-15
CVE-2026-7210 pkg:libpython3.11-stdlib exp:2026-07-15
# CVE-2026-33278 — Unbound DNSSEC validator use-after-free (DoS, possible RCE).
# CVE-2026-42960 — Unbound DNS cache poisoning via promiscuous additional records.
# Package: libunbound8.
# Why ignored: libunbound8 is a transitive apt dependency of the TLS/networking
# stack (GnuTLS DANE support); only the shared library ships in the image. Both
# vulnerabilities require operating a live Unbound recursive DNSSEC validator
# that processes attacker-influenced DNS responses. Prowler never starts an
# Unbound resolver, so neither code path is reachable. No Debian bookworm fix yet.
CVE-2026-33278 pkg:libunbound8 exp:2026-07-15
CVE-2026-42960 pkg:libunbound8 exp:2026-07-15
-6
View File
@@ -51,7 +51,6 @@ 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) |
@@ -68,12 +67,10 @@ 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` |
@@ -92,7 +89,6 @@ 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` |
@@ -109,8 +105,6 @@ When performing these actions, ALWAYS invoke the corresponding skill FIRST:
| Modifying gh-aw workflow frontmatter or safe-outputs | `gh-aw` |
| Refactoring code | `tdd` |
| Regenerate AGENTS.md Auto-invoke tables (sync.sh) | `skill-sync` |
| Renaming or removing a data-tour-id attribute value | `prowler-tour` |
| Restructuring routes or layouts covered by a tour | `prowler-tour` |
| Review PR requirements: template, title conventions, changelog gate | `prowler-pr` |
| Review changelog format and conventions | `prowler-changelog` |
| Reviewing JSON:API compliance | `jsonapi` |
+2 -2
View File
@@ -1,4 +1,4 @@
FROM python:3.12.13-slim-bookworm@sha256:76d4b7b6305788c6b4c6a19d6a22a3921bf802e9af4d5e1e5bd771208dba74bf AS build
FROM python:3.12.11-slim-bookworm@sha256:519591d6871b7bc437060736b9f7456b8731f1499a57e22e6c285135ae657bf7 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.71.0
ARG TRIVY_VERSION=0.70.0
ENV TRIVY_VERSION=${TRIVY_VERSION}
ARG ZIZMOR_VERSION=1.24.1
+3 -5
View File
@@ -2,13 +2,11 @@
All notable changes to the **Prowler API** are documented in this file.
## [1.32.0] (Prowler UNRELEASED)
## [1.31.2] (Prowler UNRELEASED)
### 🔐 Security
### 🔄 Changed
- `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)
- `scan-compliance-overviews` task now streams the findings aggregation and the requirement-row writes (reading the denormalized `resource_regions` instead of prefetching resources, and batching rows into COPY instead of building the full list first), so it runs faster and its peak memory no longer grows with the number of regions and frameworks — a previous worker OOM risk on large scans — with no change to the compliance overview output [(#11591)](https://github.com/prowler-cloud/prowler/pull/11591)
---
+2 -2
View File
@@ -1,11 +1,11 @@
FROM python:3.12.13-slim-bookworm@sha256:76d4b7b6305788c6b4c6a19d6a22a3921bf802e9af4d5e1e5bd771208dba74bf AS build
FROM python:3.12.10-slim-bookworm@sha256:fd95fa221297a88e1cf49c55ec1828edd7c5a428187e67b5d1805692d11588db 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.71.0
ARG TRIVY_VERSION=0.70.0
ENV TRIVY_VERSION=${TRIVY_VERSION}
ARG ZIZMOR_VERSION=1.24.1
+7 -7
View File
@@ -43,7 +43,7 @@ dependencies = [
"defusedxml==0.7.1",
"gunicorn==23.0.0",
"lxml==6.1.0",
"prowler @ git+https://github.com/prowler-cloud/prowler.git@master",
"prowler @ git+https://github.com/prowler-cloud/prowler.git@v5.30",
"psycopg2-binary==2.9.9",
"pytest-celery[redis] (==1.3.0)",
"sentry-sdk[django] (==2.56.0)",
@@ -68,7 +68,7 @@ name = "prowler-api"
package-mode = false
# Needed for the SDK compatibility
requires-python = ">=3.11,<3.13"
version = "1.32.0"
version = "1.31.2"
[tool.uv]
# Transitive pins matching master to avoid silent drift; bump deliberately.
@@ -79,7 +79,7 @@ constraint-dependencies = [
"aiobotocore==2.25.1",
"aiofiles==24.1.0",
"aiohappyeyeballs==2.6.1",
"aiohttp==3.14.0",
"aiohttp==3.13.5",
"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.12",
"authlib==1.6.9",
"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.15",
"idna==3.11",
"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.2.6",
"numpy==2.0.2",
"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.3.0",
"py-iam-expand==0.1.0",
"py-ocsf-models==0.8.1",
"pyasn1==0.6.3",
"pyasn1-modules==0.4.2",
+1 -1
View File
@@ -1,7 +1,7 @@
openapi: 3.0.3
info:
title: Prowler API
version: 1.32.0
version: 1.31.2
description: |-
Prowler API specification.
+161 -137
View File
@@ -5,6 +5,7 @@ import re
import time
import uuid
from collections import defaultdict
from collections.abc import Iterable
from datetime import datetime, timezone
from typing import Any
@@ -22,7 +23,6 @@ from django.db.models import (
Max,
Min,
OuterRef,
Prefetch,
Q,
Sum,
When,
@@ -357,68 +357,71 @@ def _copy_compliance_requirement_rows(
def _persist_compliance_requirement_rows(
tenant_id: str, rows: list[dict[str, Any]], batch_size: int = 10000
) -> None:
tenant_id: str, rows: Iterable[dict[str, Any]], batch_size: int = 10000
) -> int:
"""Persist compliance requirement rows using batched COPY with ORM fallback.
Splits large row sets into batches to reduce lock duration and improve concurrency.
``rows`` is consumed lazily in batches, so peak memory stays at ~``batch_size``
rows instead of the full set. A batch that fails COPY falls back to an ORM
``bulk_create`` of just that batch.
Args:
tenant_id: Target tenant UUID.
rows: Precomputed row dictionaries that reflect the compliance
overview state for a scan.
rows: Iterable of row dictionaries reflecting the compliance overview
state for a scan.
batch_size: Number of rows per COPY batch (default: 10000).
Returns:
int: total number of rows persisted.
"""
if not rows:
return
total_rows = len(rows)
total_batches = (total_rows + batch_size - 1) // batch_size
try:
# Process rows in batches to reduce lock duration
for batch_num in range(total_batches):
start_idx = batch_num * batch_size
end_idx = min(start_idx + batch_size, total_rows)
batch = rows[start_idx:end_idx]
total_rows = 0
batch_num = 0
for batch, _is_last in batched(rows, batch_size):
if not batch:
continue
batch_num += 1
try:
_copy_compliance_requirement_rows(tenant_id, batch)
except Exception as error:
logger.exception(
f"COPY bulk insert for compliance requirements batch {batch_num} "
"failed; falling back to ORM bulk_create for this batch",
exc_info=error,
)
fallback_objects = [
ComplianceRequirementOverview(
id=row["id"],
tenant_id=row["tenant_id"],
inserted_at=row["inserted_at"],
compliance_id=row["compliance_id"],
framework=row["framework"],
version=row["version"],
description=row["description"],
region=row["region"],
requirement_id=row["requirement_id"],
requirement_status=row["requirement_status"],
passed_checks=row["passed_checks"],
failed_checks=row["failed_checks"],
total_checks=row["total_checks"],
passed_findings=row.get("passed_findings", 0),
total_findings=row.get("total_findings", 0),
scan_id=row["scan_id"],
)
for row in batch
]
with rls_transaction(tenant_id):
ComplianceRequirementOverview.objects.bulk_create(
fallback_objects, batch_size=500
)
logger.info(
f"Compliance COPY batch {batch_num + 1}/{total_batches}: "
f"inserted {len(batch)} rows ({start_idx + len(batch)}/{total_rows} total)"
)
except Exception as error:
logger.exception(
"COPY bulk insert for compliance requirements failed; falling back to ORM bulk_create",
exc_info=error,
total_rows += len(batch)
logger.info(
f"Compliance COPY batch {batch_num}: inserted {len(batch)} rows "
f"({total_rows} total)"
)
# Fallback: use ORM bulk_create for all remaining rows
fallback_objects = [
ComplianceRequirementOverview(
id=row["id"],
tenant_id=row["tenant_id"],
inserted_at=row["inserted_at"],
compliance_id=row["compliance_id"],
framework=row["framework"],
version=row["version"],
description=row["description"],
region=row["region"],
requirement_id=row["requirement_id"],
requirement_status=row["requirement_status"],
passed_checks=row["passed_checks"],
failed_checks=row["failed_checks"],
total_checks=row["total_checks"],
passed_findings=row.get("passed_findings", 0),
total_findings=row.get("total_findings", 0),
scan_id=row["scan_id"],
)
for row in rows
]
with rls_transaction(tenant_id):
ComplianceRequirementOverview.objects.bulk_create(
fallback_objects, batch_size=500
)
return total_rows
def _create_compliance_summaries(
@@ -1445,9 +1448,13 @@ def _aggregate_findings_by_region(
tenant_id: str, scan_id: str, modeled_threatscore_compliance_id: str
) -> tuple[dict, dict]:
"""
Aggregate findings by region using optimized ORM queries.
Aggregate findings by region using streaming, column-scoped ORM reads.
Replaces nested Python loops with efficient queries and aggregation.
Reads only the consumed columns as tuples via ``values_list`` and streams
them with ``.iterator()``, using the denormalized ``resource_regions`` array
instead of ``prefetch_related("resources")``. ``resource_regions`` mirrors the
regions of a finding's related resources, so it yields the same per-region
tally without joining the resource table.
Args:
tenant_id: Tenant UUID
@@ -1459,12 +1466,12 @@ def _aggregate_findings_by_region(
- check_status_by_region: {region: {check_id: status}}
- findings_count_by_compliance: {region: {normalized_id: {requirement_id: {total, pass}}}}
"""
check_status_by_region = {}
findings_count_by_compliance = {}
check_status_by_region: dict = {}
findings_count_by_compliance: dict = {}
normalized_id = re.sub(r"[^a-z0-9]", "", modeled_threatscore_compliance_id.lower())
with rls_transaction(tenant_id, using=READ_REPLICA_ALIAS):
# Fetch only PASS/FAIL findings (optimized query reduces data transfer)
# Other statuses are not needed for check_status or ThreatScore calculation
findings = (
Finding.all_objects.filter(
tenant_id=tenant_id,
@@ -1472,42 +1479,28 @@ def _aggregate_findings_by_region(
muted=False,
status__in=["PASS", "FAIL"],
)
.only("id", "check_id", "status", "compliance")
.prefetch_related(
Prefetch(
"resources",
queryset=Resource.objects.only("id", "region"),
to_attr="small_resources",
)
.values_list("check_id", "status", "resource_regions", "compliance")
.iterator(chunk_size=DJANGO_FINDINGS_BATCH_SIZE)
)
for check_id, status, resource_regions, compliance in findings:
threatscore_requirements = (compliance or {}).get(
modeled_threatscore_compliance_id
)
)
# Process findings in a single pass (more efficient than original nested loops)
normalized_id = re.sub(
r"[^a-z0-9]", "", modeled_threatscore_compliance_id.lower()
)
for finding in findings:
status = finding.status
for resource in finding.small_resources:
region = resource.region
# Aggregate check status by region
current_status = check_status_by_region.setdefault(region, {})
for region in resource_regions or ():
# Priority: FAIL > any other status
if current_status.get(finding.check_id) != "FAIL":
current_status[finding.check_id] = status
current_status = check_status_by_region.setdefault(region, {})
if current_status.get(check_id) != "FAIL":
current_status[check_id] = status
# Aggregate ThreatScore compliance counts
if modeled_threatscore_compliance_id in (finding.compliance or {}):
if threatscore_requirements:
compliance_key = findings_count_by_compliance.setdefault(
region, {}
).setdefault(normalized_id, {})
for requirement_id in finding.compliance[
modeled_threatscore_compliance_id
]:
for requirement_id in threatscore_requirements:
requirement_stats = compliance_key.setdefault(
requirement_id, {"total": 0, "pass": 0}
)
@@ -1554,8 +1547,8 @@ def create_compliance_requirements(tenant_id: str, scan_id: str):
(compliance_id, requirement_id)
)
compliance_requirement_rows: list[dict[str, Any]] = []
regions = []
requirements_created = 0
requirement_statuses = defaultdict(
lambda: {"fail_count": 0, "pass_count": 0, "total_count": 0}
)
@@ -1595,44 +1588,93 @@ def create_compliance_requirements(tenant_id: str, scan_id: str):
else:
requirement_stats["failed_checks"] += 1
# Prepare compliance requirement rows and compute summaries in single pass
utc_datetime_now = datetime.now(tz=timezone.utc)
# Pre-compute shared strings (optimization: reduces string conversions)
tenant_id_str = str(tenant_id)
scan_id_str = str(scan_instance.id)
for region in regions:
region_stats = region_requirement_stats.get(region, {})
for compliance_id, compliance in compliance_template.items():
modeled_compliance_id = _normalized_compliance_key(
compliance["framework"], compliance["version"]
# Per-framework constants that don't depend on the region.
compliance_plan = []
for compliance_id, compliance in compliance_template.items():
modeled_compliance_id = _normalized_compliance_key(
compliance["framework"], compliance["version"]
)
framework = compliance["framework"]
version = compliance["version"] or ""
requirements = [
(
requirement_id,
requirement.get("description") or "",
len(requirement["checks"]),
)
compliance_stats = region_stats.get(compliance_id, {})
# Create an overview record for each requirement within each compliance framework
for requirement_id, requirement in compliance[
"requirements"
].items():
stats = compliance_stats.get(requirement_id)
passed_checks = stats["passed_checks"] if stats else 0
failed_checks = stats["failed_checks"] if stats else 0
total_checks = len(requirement["checks"])
if total_checks == 0:
requirement_status = "MANUAL"
elif failed_checks > 0:
requirement_status = "FAIL"
else:
requirement_status = "PASS"
].items()
]
compliance_plan.append(
(
compliance_id,
framework,
version,
modeled_compliance_id,
requirements,
)
)
compliance_requirement_rows.append(
{
# Yield rows lazily (consumed batch-by-batch by COPY) so peak memory
# stays bounded; tally requirement_statuses in the same pass.
def _iter_compliance_requirement_rows():
for region in regions:
region_stats = region_requirement_stats.get(region, {})
region_findings = findings_count_by_compliance.get(region, {})
for (
compliance_id,
framework,
version,
modeled_compliance_id,
requirements,
) in compliance_plan:
compliance_stats = region_stats.get(compliance_id, {})
compliance_findings = region_findings.get(
modeled_compliance_id, {}
)
for requirement_id, description, total_checks in requirements:
stats = compliance_stats.get(requirement_id)
if stats:
passed_checks = stats["passed_checks"]
failed_checks = stats["failed_checks"]
else:
passed_checks = 0
failed_checks = 0
if total_checks == 0:
requirement_status = "MANUAL"
elif failed_checks > 0:
requirement_status = "FAIL"
else:
requirement_status = "PASS"
finding_counts = compliance_findings.get(requirement_id)
if finding_counts:
passed_findings = finding_counts.get("pass", 0)
total_findings = finding_counts.get("total", 0)
else:
passed_findings = 0
total_findings = 0
key = (compliance_id, requirement_id)
requirement_statuses[key]["total_count"] += 1
if requirement_status == "FAIL":
requirement_statuses[key]["fail_count"] += 1
elif requirement_status == "PASS":
requirement_statuses[key]["pass_count"] += 1
yield {
"id": uuid.uuid4(),
"tenant_id": tenant_id_str,
"inserted_at": utc_datetime_now,
"compliance_id": compliance_id,
"framework": compliance["framework"],
"version": compliance["version"] or "",
"description": requirement.get("description") or "",
"framework": framework,
"version": version,
"description": description,
"region": region,
"requirement_id": requirement_id,
"requirement_status": requirement_status,
@@ -1640,41 +1682,23 @@ def create_compliance_requirements(tenant_id: str, scan_id: str):
"failed_checks": failed_checks,
"total_checks": total_checks,
"scan_id": scan_id_str,
"passed_findings": findings_count_by_compliance.get(
region, {}
)
.get(modeled_compliance_id, {})
.get(requirement_id, {})
.get("pass", 0),
"total_findings": findings_count_by_compliance.get(
region, {}
)
.get(modeled_compliance_id, {})
.get(requirement_id, {})
.get("total", 0),
"passed_findings": passed_findings,
"total_findings": total_findings,
}
)
# Update summary tracking (single-pass optimization)
key = (compliance_id, requirement_id)
requirement_statuses[key]["total_count"] += 1
if requirement_status == "FAIL":
requirement_statuses[key]["fail_count"] += 1
elif requirement_status == "PASS":
requirement_statuses[key]["pass_count"] += 1
# Idempotent re-run: COPY can't ON CONFLICT, so clear this scan's rows first.
# Idempotent re-run: clear this scan's rows before re-inserting.
with rls_transaction(tenant_id):
ComplianceRequirementOverview.objects.filter(scan_id=scan_id).delete()
# Bulk create requirement records using PostgreSQL COPY
_persist_compliance_requirement_rows(tenant_id, compliance_requirement_rows)
requirements_created = _persist_compliance_requirement_rows(
tenant_id, _iter_compliance_requirement_rows()
)
# Create pre-aggregated summaries for fast compliance overview lookups
_create_compliance_summaries(tenant_id, scan_id, requirement_statuses)
return {
"requirements_created": len(compliance_requirement_rows),
"requirements_created": requirements_created,
"regions_processed": list(regions),
"compliance_frameworks": (
list(compliance_template.keys()) if regions else []
+173 -74
View File
@@ -3674,19 +3674,19 @@ class TestAggregateFindingsByRegion:
scan_id = str(uuid.uuid4())
modeled_threatscore_compliance_id = "ProwlerThreatScore-1.0"
# Mock findings with resources
mock_finding1 = MagicMock()
mock_finding1.check_id = "check1"
mock_finding1.status = "FAIL"
mock_finding1.compliance = {modeled_threatscore_compliance_id: ["req1", "req2"]}
mock_resource1 = MagicMock()
mock_resource1.region = "us-east-1"
mock_finding1.small_resources = [mock_resource1]
# (check_id, status, resource_regions, compliance) tuples
finding_rows = [
(
"check1",
"FAIL",
["us-east-1"],
{modeled_threatscore_compliance_id: ["req1", "req2"]},
)
]
mock_queryset = MagicMock()
mock_queryset.only.return_value = mock_queryset
mock_queryset.prefetch_related.return_value = [mock_finding1]
mock_queryset.values_list.return_value = mock_queryset
mock_queryset.iterator.return_value = finding_rows
ctx = MagicMock()
ctx.__enter__.return_value = None
@@ -3700,6 +3700,12 @@ class TestAggregateFindingsByRegion:
)
)
# Streaming query contract: column-scoped values_list + iterator
mock_queryset.values_list.assert_called_once_with(
"check_id", "status", "resource_regions", "compliance"
)
mock_queryset.iterator.assert_called_once()
# Verify structure of check_status_by_region
assert isinstance(check_status_by_region, dict)
assert "us-east-1" in check_status_by_region
@@ -3719,27 +3725,15 @@ class TestAggregateFindingsByRegion:
scan_id = str(uuid.uuid4())
modeled_threatscore_compliance_id = "ProwlerThreatScore-1.0"
# First finding with PASS status
mock_finding1 = MagicMock()
mock_finding1.check_id = "check1"
mock_finding1.status = "PASS"
mock_finding1.compliance = {}
mock_resource1 = MagicMock()
mock_resource1.region = "us-east-1"
mock_finding1.small_resources = [mock_resource1]
# Second finding with FAIL status for same check/region
mock_finding2 = MagicMock()
mock_finding2.check_id = "check1"
mock_finding2.status = "FAIL"
mock_finding2.compliance = {}
mock_resource2 = MagicMock()
mock_resource2.region = "us-east-1"
mock_finding2.small_resources = [mock_resource2]
# Same check/region: PASS first, then FAIL — FAIL must win
finding_rows = [
("check1", "PASS", ["us-east-1"], {}),
("check1", "FAIL", ["us-east-1"], {}),
]
mock_queryset = MagicMock()
mock_queryset.only.return_value = mock_queryset
mock_queryset.prefetch_related.return_value = [mock_finding1, mock_finding2]
mock_queryset.values_list.return_value = mock_queryset
mock_queryset.iterator.return_value = finding_rows
ctx = MagicMock()
ctx.__enter__.return_value = None
@@ -3751,6 +3745,12 @@ class TestAggregateFindingsByRegion:
tenant_id, scan_id, modeled_threatscore_compliance_id
)
# Streaming query contract: column-scoped values_list + iterator
mock_queryset.values_list.assert_called_once_with(
"check_id", "status", "resource_regions", "compliance"
)
mock_queryset.iterator.assert_called_once()
# FAIL should override PASS
assert check_status_by_region["us-east-1"]["check1"] == "FAIL"
@@ -3765,8 +3765,8 @@ class TestAggregateFindingsByRegion:
modeled_threatscore_compliance_id = "ProwlerThreatScore-1.0"
mock_queryset = MagicMock()
mock_queryset.only.return_value = mock_queryset
mock_queryset.prefetch_related.return_value = []
mock_queryset.values_list.return_value = mock_queryset
mock_queryset.iterator.return_value = []
ctx = MagicMock()
ctx.__enter__.return_value = None
@@ -3778,6 +3778,12 @@ class TestAggregateFindingsByRegion:
tenant_id, scan_id, modeled_threatscore_compliance_id
)
# Streaming query contract: column-scoped values_list + iterator
mock_queryset.values_list.assert_called_once_with(
"check_id", "status", "resource_regions", "compliance"
)
mock_queryset.iterator.assert_called_once()
# Verify filter was called with muted=False
mock_findings_filter.assert_called_once_with(
tenant_id=tenant_id,
@@ -3796,27 +3802,25 @@ class TestAggregateFindingsByRegion:
scan_id = str(uuid.uuid4())
modeled_threatscore_compliance_id = "ProwlerThreatScore-1.0"
# Finding with PASS status
mock_finding1 = MagicMock()
mock_finding1.check_id = "check1"
mock_finding1.status = "PASS"
mock_finding1.compliance = {modeled_threatscore_compliance_id: ["req1"]}
mock_resource1 = MagicMock()
mock_resource1.region = "us-east-1"
mock_finding1.small_resources = [mock_resource1]
# Finding with FAIL status
mock_finding2 = MagicMock()
mock_finding2.check_id = "check2"
mock_finding2.status = "FAIL"
mock_finding2.compliance = {modeled_threatscore_compliance_id: ["req1"]}
mock_resource2 = MagicMock()
mock_resource2.region = "us-east-1"
mock_finding2.small_resources = [mock_resource2]
# PASS and FAIL findings mapped to the same ThreatScore requirement
finding_rows = [
(
"check1",
"PASS",
["us-east-1"],
{modeled_threatscore_compliance_id: ["req1"]},
),
(
"check2",
"FAIL",
["us-east-1"],
{modeled_threatscore_compliance_id: ["req1"]},
),
]
mock_queryset = MagicMock()
mock_queryset.only.return_value = mock_queryset
mock_queryset.prefetch_related.return_value = [mock_finding1, mock_finding2]
mock_queryset.values_list.return_value = mock_queryset
mock_queryset.iterator.return_value = finding_rows
ctx = MagicMock()
ctx.__enter__.return_value = None
@@ -3828,6 +3832,12 @@ class TestAggregateFindingsByRegion:
tenant_id, scan_id, modeled_threatscore_compliance_id
)
# Streaming query contract: column-scoped values_list + iterator
mock_queryset.values_list.assert_called_once_with(
"check_id", "status", "resource_regions", "compliance"
)
mock_queryset.iterator.assert_called_once()
# Verify compliance counts
normalized_id = re.sub(
r"[^a-z0-9]", "", modeled_threatscore_compliance_id.lower()
@@ -3850,27 +3860,15 @@ class TestAggregateFindingsByRegion:
scan_id = str(uuid.uuid4())
modeled_threatscore_compliance_id = "ProwlerThreatScore-1.0"
# Finding in us-east-1
mock_finding1 = MagicMock()
mock_finding1.check_id = "check1"
mock_finding1.status = "FAIL"
mock_finding1.compliance = {}
mock_resource1 = MagicMock()
mock_resource1.region = "us-east-1"
mock_finding1.small_resources = [mock_resource1]
# Finding in us-west-2
mock_finding2 = MagicMock()
mock_finding2.check_id = "check1"
mock_finding2.status = "PASS"
mock_finding2.compliance = {}
mock_resource2 = MagicMock()
mock_resource2.region = "us-west-2"
mock_finding2.small_resources = [mock_resource2]
# One finding per region
finding_rows = [
("check1", "FAIL", ["us-east-1"], {}),
("check1", "PASS", ["us-west-2"], {}),
]
mock_queryset = MagicMock()
mock_queryset.only.return_value = mock_queryset
mock_queryset.prefetch_related.return_value = [mock_finding1, mock_finding2]
mock_queryset.values_list.return_value = mock_queryset
mock_queryset.iterator.return_value = finding_rows
ctx = MagicMock()
ctx.__enter__.return_value = None
@@ -3882,6 +3880,12 @@ class TestAggregateFindingsByRegion:
tenant_id, scan_id, modeled_threatscore_compliance_id
)
# Streaming query contract: column-scoped values_list + iterator
mock_queryset.values_list.assert_called_once_with(
"check_id", "status", "resource_regions", "compliance"
)
mock_queryset.iterator.assert_called_once()
# Verify both regions are present with correct statuses
assert "us-east-1" in check_status_by_region
assert "us-west-2" in check_status_by_region
@@ -3890,17 +3894,26 @@ class TestAggregateFindingsByRegion:
@patch("tasks.jobs.scan.Finding.all_objects.filter")
@patch("tasks.jobs.scan.rls_transaction")
def test_aggregate_findings_by_region_empty_findings(
def test_aggregate_findings_by_region_multi_region_finding(
self, mock_rls_transaction, mock_findings_filter
):
"""Test with no findings - should return empty dicts."""
"""A finding with multiple resource_regions is tallied in every region."""
tenant_id = str(uuid.uuid4())
scan_id = str(uuid.uuid4())
modeled_threatscore_compliance_id = "ProwlerThreatScore-1.0"
finding_rows = [
(
"check1",
"FAIL",
["us-east-1", "eu-west-1"],
{modeled_threatscore_compliance_id: ["req1"]},
)
]
mock_queryset = MagicMock()
mock_queryset.only.return_value = mock_queryset
mock_queryset.prefetch_related.return_value = []
mock_queryset.values_list.return_value = mock_queryset
mock_queryset.iterator.return_value = finding_rows
ctx = MagicMock()
ctx.__enter__.return_value = None
@@ -3914,6 +3927,92 @@ class TestAggregateFindingsByRegion:
)
)
# Streaming query contract: column-scoped values_list + iterator
mock_queryset.values_list.assert_called_once_with(
"check_id", "status", "resource_regions", "compliance"
)
mock_queryset.iterator.assert_called_once()
normalized_id = re.sub(
r"[^a-z0-9]", "", modeled_threatscore_compliance_id.lower()
)
for region in ("us-east-1", "eu-west-1"):
assert check_status_by_region[region]["check1"] == "FAIL"
req_stats = findings_count_by_compliance[region][normalized_id]["req1"]
assert req_stats == {"total": 1, "pass": 0}
@patch("tasks.jobs.scan.Finding.all_objects.filter")
@patch("tasks.jobs.scan.rls_transaction")
def test_aggregate_findings_by_region_skips_empty_regions(
self, mock_rls_transaction, mock_findings_filter
):
"""A finding with no denormalized regions contributes nothing."""
tenant_id = str(uuid.uuid4())
scan_id = str(uuid.uuid4())
modeled_threatscore_compliance_id = "ProwlerThreatScore-1.0"
finding_rows = [
("check1", "FAIL", [], {modeled_threatscore_compliance_id: ["req1"]}),
("check2", "PASS", None, {}),
]
mock_queryset = MagicMock()
mock_queryset.values_list.return_value = mock_queryset
mock_queryset.iterator.return_value = finding_rows
ctx = MagicMock()
ctx.__enter__.return_value = None
ctx.__exit__.return_value = False
mock_rls_transaction.return_value = ctx
mock_findings_filter.return_value = mock_queryset
check_status_by_region, findings_count_by_compliance = (
_aggregate_findings_by_region(
tenant_id, scan_id, modeled_threatscore_compliance_id
)
)
# Streaming query contract: column-scoped values_list + iterator
mock_queryset.values_list.assert_called_once_with(
"check_id", "status", "resource_regions", "compliance"
)
mock_queryset.iterator.assert_called_once()
assert check_status_by_region == {}
assert findings_count_by_compliance == {}
@patch("tasks.jobs.scan.Finding.all_objects.filter")
@patch("tasks.jobs.scan.rls_transaction")
def test_aggregate_findings_by_region_empty_findings(
self, mock_rls_transaction, mock_findings_filter
):
"""Test with no findings - should return empty dicts."""
tenant_id = str(uuid.uuid4())
scan_id = str(uuid.uuid4())
modeled_threatscore_compliance_id = "ProwlerThreatScore-1.0"
mock_queryset = MagicMock()
mock_queryset.values_list.return_value = mock_queryset
mock_queryset.iterator.return_value = []
ctx = MagicMock()
ctx.__enter__.return_value = None
ctx.__exit__.return_value = False
mock_rls_transaction.return_value = ctx
mock_findings_filter.return_value = mock_queryset
check_status_by_region, findings_count_by_compliance = (
_aggregate_findings_by_region(
tenant_id, scan_id, modeled_threatscore_compliance_id
)
)
# Streaming query contract: column-scoped values_list + iterator
mock_queryset.values_list.assert_called_once_with(
"check_id", "status", "resource_regions", "compliance"
)
mock_queryset.iterator.assert_called_once()
assert check_status_by_region == {}
assert findings_count_by_compliance == {}
Generated
+83 -76
View File
@@ -16,7 +16,7 @@ constraints = [
{ name = "aiobotocore", specifier = "==2.25.1" },
{ name = "aiofiles", specifier = "==24.1.0" },
{ name = "aiohappyeyeballs", specifier = "==2.6.1" },
{ name = "aiohttp", specifier = "==3.14.0" },
{ name = "aiohttp", specifier = "==3.13.5" },
{ 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.12" },
{ name = "authlib", specifier = "==1.6.9" },
{ 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.15" },
{ name = "idna", specifier = "==3.11" },
{ 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.2.6" },
{ name = "numpy", specifier = "==2.0.2" },
{ 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.3.0" },
{ name = "py-iam-expand", specifier = "==0.1.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.14.0"
version = "3.13.5"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "aiohappyeyeballs" },
@@ -478,47 +478,44 @@ dependencies = [
{ name = "frozenlist" },
{ name = "multidict" },
{ name = "propcache" },
{ name = "typing-extensions" },
{ name = "yarl" },
]
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" }
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" }
wheels = [
{ 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" },
{ 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" },
]
[[package]]
@@ -1048,6 +1045,15 @@ 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"
@@ -3158,11 +3164,11 @@ wheels = [
[[package]]
name = "idna"
version = "3.15"
version = "3.11"
source = { registry = "https://pypi.org/simple" }
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" }
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" }
wheels = [
{ 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" },
{ 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" },
]
[[package]]
@@ -3965,30 +3971,30 @@ wheels = [
[[package]]
name = "numpy"
version = "2.2.6"
version = "2.0.2"
source = { registry = "https://pypi.org/simple" }
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" }
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" }
wheels = [
{ 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" },
{ 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" },
]
[[package]]
@@ -4409,8 +4415,8 @@ wheels = [
[[package]]
name = "prowler"
version = "5.31.0"
source = { git = "https://github.com/prowler-cloud/prowler.git?rev=master#8081d0fd0552131dfbd885f1074377eb653fc785" }
version = "5.30.0"
source = { git = "https://github.com/prowler-cloud/prowler.git?rev=v5.30#f1d741214a60df17158c3fdc97804fd1fde64f3a" }
dependencies = [
{ name = "alibabacloud-actiontrail20200706" },
{ name = "alibabacloud-credentials" },
@@ -4426,6 +4432,7 @@ dependencies = [
{ name = "alibabacloud-tea-openapi" },
{ name = "alibabacloud-vpc20160428" },
{ name = "alive-progress" },
{ name = "awsipranges" },
{ name = "azure-identity" },
{ name = "azure-keyvault-keys" },
{ name = "azure-mgmt-apimanagement" },
@@ -4497,7 +4504,7 @@ dependencies = [
[[package]]
name = "prowler-api"
version = "1.32.0"
version = "1.31.2"
source = { virtual = "." }
dependencies = [
{ name = "cartography" },
@@ -4593,7 +4600,7 @@ requires-dist = [
{ name = "matplotlib", specifier = "==3.10.8" },
{ name = "neo4j", specifier = "==6.1.0" },
{ name = "openai", specifier = "==1.109.1" },
{ name = "prowler", git = "https://github.com/prowler-cloud/prowler.git?rev=master" },
{ name = "prowler", git = "https://github.com/prowler-cloud/prowler.git?rev=v5.30" },
{ name = "psycopg2-binary", specifier = "==2.9.9" },
{ name = "pytest-celery", extras = ["redis"], specifier = "==1.3.0" },
{ name = "reportlab", specifier = "==4.4.10" },
@@ -4685,14 +4692,14 @@ wheels = [
[[package]]
name = "py-iam-expand"
version = "0.3.0"
version = "0.1.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "iamdata" },
]
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" }
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" }
wheels = [
{ 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" },
{ 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" },
]
[[package]]
@@ -1,7 +1,7 @@
# Build command
# docker build --platform=linux/amd64 --no-cache -t prowler:latest .
ARG PROWLER_VERSION=latest@sha256:4b796c6df40a3350c7947747b59bdda230d0da6222287500e13b0a8e1574aad4
ARG PROWLER_VERSION=latest
FROM toniblyx/prowler:${PROWLER_VERSION}
@@ -16,7 +16,7 @@
services:
nginx:
image: nginx:alpine@sha256:8b1e78743a03dbb2c95171cc58639fef29abc8816598e27fb910ed2e621e589a
image: nginx:alpine
container_name: prowler-nginx
restart: unless-stopped
ports:
+4 -4
View File
@@ -1,6 +1,6 @@
services:
api-dev-init:
image: busybox:1.37.0@sha256:9532d8c39891ca2ecde4d30d7710e01fb739c87a8b9299685c63704296b16028
image: busybox:1.37.0
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@sha256:36ed71227ae36305d26382657c0b96cbaf298427b3f1eaeb10d77a6dea3eec41
image: postgres:16.3-alpine3.20
hostname: "postgres-db"
volumes:
- ./_data/postgres:/var/lib/postgresql/data
@@ -88,7 +88,7 @@ services:
retries: 5
valkey:
image: valkey/valkey:7-alpine3.19@sha256:4054fe7fc607b9326ac7c4691ed26e9670d2ff17a9fb28c2577adecf928acbcc
image: valkey/valkey:7-alpine3.19
hostname: "valkey"
volumes:
- ./_data/valkey:/data
@@ -104,7 +104,7 @@ services:
retries: 3
neo4j:
image: graphstack/dozerdb:5.26.3.0@sha256:a77526ea3918fdc46d1fff70c4aea7d71d3874a26ecec059179d6775845b1247
image: graphstack/dozerdb:5.26.3.0
hostname: "neo4j"
volumes:
- ./_data/neo4j:/data
+4 -4
View File
@@ -6,7 +6,7 @@
#
services:
api-init:
image: busybox:1.37.0@sha256:9532d8c39891ca2ecde4d30d7710e01fb739c87a8b9299685c63704296b16028
image: busybox:1.37.0
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@sha256:36ed71227ae36305d26382657c0b96cbaf298427b3f1eaeb10d77a6dea3eec41
image: postgres:16.3-alpine3.20
hostname: "postgres-db"
volumes:
- ./_data/postgres:/var/lib/postgresql/data
@@ -80,7 +80,7 @@ services:
retries: 5
valkey:
image: valkey/valkey:7-alpine3.19@sha256:4054fe7fc607b9326ac7c4691ed26e9670d2ff17a9fb28c2577adecf928acbcc
image: valkey/valkey:7-alpine3.19
hostname: "valkey"
volumes:
- ./_data/valkey:/data
@@ -96,7 +96,7 @@ services:
retries: 3
neo4j:
image: graphstack/dozerdb:5.26.3.0@sha256:a77526ea3918fdc46d1fff70c4aea7d71d3874a26ecec059179d6775845b1247
image: graphstack/dozerdb:5.26.3.0
hostname: "neo4j"
volumes:
- ./_data/neo4j:/data
@@ -128,8 +128,8 @@ To update the environment file:
Edit the `.env` file and change version values:
```env
PROWLER_UI_VERSION="5.30.0"
PROWLER_API_VERSION="5.30.0"
PROWLER_UI_VERSION="5.29.0"
PROWLER_API_VERSION="5.29.0"
```
<Note>
@@ -4,7 +4,7 @@ title: 'Installation'
## Installation
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/):
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/):
<Tabs>
<Tab title="pipx">
@@ -12,7 +12,7 @@ To install Prowler as a Python package, use `Python >= 3.10, <= 3.13`. Prowler i
_Requirements_:
* `Python >= 3.10, <= 3.13`
* `Python >= 3.10, <= 3.12`
* `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.13`. Prowler i
_Requirements_:
* `Python >= 3.10, <= 3.13`
* `Python >= 3.10, <= 3.12`
* `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.13`. Prowler i
<Tab title="Amazon Linux 2">
_Requirements_:
* `Python >= 3.10, <= 3.13`
* `Python >= 3.10, <= 3.12`
* AWS, GCP, Azure and/or Kubernetes credentials
_Commands_:
@@ -96,8 +96,8 @@ To install Prowler as a Python package, use `Python >= 3.10, <= 3.13`. 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.13` is installed.
* `Python >= 3.10, <= 3.13`
* `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`
* AWS, GCP, Azure and/or Kubernetes credentials
_Commands_:
@@ -1,838 +0,0 @@
# Attack Paths Adaptive Waiting Message — Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Replace the single full-page "No scans available" message on the Attack Paths page with a message that adapts to the real state — no scans / scan running / graph building / load error / completed-without-graph — and auto-advances to the graph when ready.
**Architecture:** A pure deriver (`getAttackPathsViewState`) maps `{ scansLoading, loadError, scans }` to a discriminated view-state; a presentational `<AttackPathsStatusPanel>` renders the copy/CTA per state; `attack-paths-page.tsx` renders the panel or the existing workflow. Polling/auto-advance is already provided by the always-mounted `<AutoRefresh>` (polls while `hasExecutingScan`), so no new polling code. UI-only — no API changes.
**Tech Stack:** Next.js 16 (App Router, client component), React 19 (compiler — no `useMemo`/`useCallback`), TypeScript (const-object enums), shadcn `Alert`/`Button`, Vitest (`unit` jsdom + `browser` projects), Testing Library, MSW.
**Spec:** `docs/superpowers/specs/2026-06-09-attack-paths-waiting-message-design.md`
**Conventions (from CLAUDE.md + user memory):**
- Const-object enums: `const X = {...} as const; type T = typeof X[keyof typeof X]`. Never bare union types.
- No `useMemo`/`useCallback`. No `import React`.
- **No barrel files** — import the new helper/panel from their direct file paths, do NOT add them to the existing `_lib/index.ts` or `_components/index.ts` barrels.
- Local-to-feature code lives in `_lib/` (pure utils) and `_components/` (components), matching the existing layout.
- TDD: failing test first, then minimal implementation.
---
## File Structure
| File | Responsibility | Action |
|------|----------------|--------|
| `ui/app/(prowler)/attack-paths/(workflow)/query-builder/_lib/get-attack-paths-view-state.ts` | Pure deriver + progress helper + view-state enum | Create |
| `…/_lib/get-attack-paths-view-state.test.ts` | Unit tests (all states, precedence, progress) | Create |
| `…/_components/attack-paths-status-panel.tsx` | Presentational panel (copy/CTA per state) | Create |
| `…/_components/attack-paths-status-panel.test.tsx` | RTL tests (copy/CTA/Retry/progress) | Create |
| `…/attack-paths-page.fixtures.ts` | Add `scanRunning`/`graphBuilding`/`noGraphData` fixtures | Modify |
| `…/attack-paths-page.browser.test.tsx` | Full-page browser tests for the new states | Modify |
| `…/attack-paths-page.tsx` | Wire deriver + panel; add `loadError`; lift `loadScans`; drop unused `Link` import | Modify |
| `ui/CHANGELOG.md` | Changelog entry | Modify |
All commands below are run from `ui/` unless stated otherwise.
---
### Task 1: Pure view-state deriver
**Files:**
- Create: `ui/app/(prowler)/attack-paths/(workflow)/query-builder/_lib/get-attack-paths-view-state.ts`
- Test: `ui/app/(prowler)/attack-paths/(workflow)/query-builder/_lib/get-attack-paths-view-state.test.ts`
- [ ] **Step 1: Write the failing test**
Create `…/_lib/get-attack-paths-view-state.test.ts`:
```ts
import { describe, expect, it } from "vitest";
import type { AttackPathScan, ScanState } from "@/types/attack-paths";
import {
ATTACK_PATHS_VIEW_STATES,
getAttackPathsViewState,
getGraphBuildingProgress,
} from "./get-attack-paths-view-state";
const scan = (
state: ScanState,
graph_data_ready: boolean,
progress = 0,
): AttackPathScan => ({
type: "attack-paths-scans",
id: `${state}-${String(graph_data_ready)}-${progress}`,
attributes: {
state,
progress,
graph_data_ready,
provider_alias: "Provider",
provider_type: "aws",
provider_uid: "123456789012",
inserted_at: "2026-04-21T10:00:00Z",
started_at: "2026-04-21T10:00:00Z",
completed_at: null,
duration: null,
},
relationships: {
provider: { data: { type: "providers", id: "p" } },
scan: { data: { type: "scans", id: "s" } },
task: { data: { type: "tasks", id: "t" } },
},
});
describe("getAttackPathsViewState", () => {
it("returns loading while scans are loading, regardless of other inputs", () => {
expect(
getAttackPathsViewState({ scansLoading: true, loadError: true, scans: [] }),
).toBe(ATTACK_PATHS_VIEW_STATES.LOADING);
});
it("returns error on load failure (error wins over empty scans)", () => {
expect(
getAttackPathsViewState({ scansLoading: false, loadError: true, scans: [] }),
).toBe(ATTACK_PATHS_VIEW_STATES.ERROR);
});
it("returns no-scans for an empty list", () => {
expect(
getAttackPathsViewState({ scansLoading: false, loadError: false, scans: [] }),
).toBe(ATTACK_PATHS_VIEW_STATES.NO_SCANS);
});
it("returns ready when any provider has a queryable graph", () => {
expect(
getAttackPathsViewState({
scansLoading: false,
loadError: false,
scans: [scan("executing", false, 50), scan("completed", true, 100)],
}),
).toBe(ATTACK_PATHS_VIEW_STATES.READY);
});
it("returns graph-building when none ready and some scan is executing (wins over scheduled)", () => {
expect(
getAttackPathsViewState({
scansLoading: false,
loadError: false,
scans: [scan("scheduled", false), scan("executing", false, 30)],
}),
).toBe(ATTACK_PATHS_VIEW_STATES.GRAPH_BUILDING);
});
it("returns scan-running when none ready and some scan is scheduled/available", () => {
expect(
getAttackPathsViewState({
scansLoading: false,
loadError: false,
scans: [scan("scheduled", false)],
}),
).toBe(ATTACK_PATHS_VIEW_STATES.SCAN_RUNNING);
expect(
getAttackPathsViewState({
scansLoading: false,
loadError: false,
scans: [scan("available", false)],
}),
).toBe(ATTACK_PATHS_VIEW_STATES.SCAN_RUNNING);
});
it("returns no-graph-data when none ready and all scans are terminal", () => {
expect(
getAttackPathsViewState({
scansLoading: false,
loadError: false,
scans: [scan("completed", false), scan("failed", false)],
}),
).toBe(ATTACK_PATHS_VIEW_STATES.NO_GRAPH_DATA);
});
});
describe("getGraphBuildingProgress", () => {
it("returns the max progress among executing scans", () => {
expect(
getGraphBuildingProgress([
scan("executing", false, 30),
scan("executing", false, 70),
scan("scheduled", false, 99),
]),
).toBe(70);
});
it("returns 0 when no scan is executing", () => {
expect(getGraphBuildingProgress([scan("scheduled", false, 50)])).toBe(0);
});
});
```
- [ ] **Step 2: Run test to verify it fails**
Run: `pnpm vitest run --project unit "app/(prowler)/attack-paths/(workflow)/query-builder/_lib/get-attack-paths-view-state.test.ts"`
Expected: FAIL — cannot resolve `./get-attack-paths-view-state`.
- [ ] **Step 3: Write minimal implementation**
Create `…/_lib/get-attack-paths-view-state.ts`:
```ts
import type { AttackPathScan } from "@/types/attack-paths";
import { SCAN_STATES } from "@/types/attack-paths";
export const ATTACK_PATHS_VIEW_STATES = {
LOADING: "loading",
ERROR: "error",
NO_SCANS: "no-scans",
SCAN_RUNNING: "scan-running",
GRAPH_BUILDING: "graph-building",
NO_GRAPH_DATA: "no-graph-data",
READY: "ready",
} as const;
export type AttackPathsViewState =
(typeof ATTACK_PATHS_VIEW_STATES)[keyof typeof ATTACK_PATHS_VIEW_STATES];
interface GetAttackPathsViewStateInput {
scansLoading: boolean;
loadError: boolean;
scans: AttackPathScan[];
}
/**
* Single source of truth for what the Attack Paths page shows. The full-page
* message owns every "not queryable yet" state; the workflow renders only once
* at least one provider's graph is ready.
*/
export const getAttackPathsViewState = ({
scansLoading,
loadError,
scans,
}: GetAttackPathsViewStateInput): AttackPathsViewState => {
if (scansLoading) return ATTACK_PATHS_VIEW_STATES.LOADING;
if (loadError) return ATTACK_PATHS_VIEW_STATES.ERROR;
if (scans.length === 0) return ATTACK_PATHS_VIEW_STATES.NO_SCANS;
if (scans.some((s) => s.attributes.graph_data_ready)) {
return ATTACK_PATHS_VIEW_STATES.READY;
}
if (scans.some((s) => s.attributes.state === SCAN_STATES.EXECUTING)) {
return ATTACK_PATHS_VIEW_STATES.GRAPH_BUILDING;
}
if (
scans.some(
(s) =>
s.attributes.state === SCAN_STATES.SCHEDULED ||
s.attributes.state === SCAN_STATES.AVAILABLE,
)
) {
return ATTACK_PATHS_VIEW_STATES.SCAN_RUNNING;
}
return ATTACK_PATHS_VIEW_STATES.NO_GRAPH_DATA;
};
/** Highest progress among scans whose graph is actively building. */
export const getGraphBuildingProgress = (scans: AttackPathScan[]): number =>
scans
.filter((s) => s.attributes.state === SCAN_STATES.EXECUTING)
.reduce((max, s) => Math.max(max, s.attributes.progress), 0);
```
- [ ] **Step 4: Run test to verify it passes**
Run: `pnpm vitest run --project unit "app/(prowler)/attack-paths/(workflow)/query-builder/_lib/get-attack-paths-view-state.test.ts"`
Expected: PASS (9 tests).
- [ ] **Step 5: Commit**
```bash
git add "ui/app/(prowler)/attack-paths/(workflow)/query-builder/_lib/get-attack-paths-view-state.ts" "ui/app/(prowler)/attack-paths/(workflow)/query-builder/_lib/get-attack-paths-view-state.test.ts"
git commit -m "feat(ui): derive attack-paths page view-state from scan status"
```
---
### Task 2: Status panel component
**Files:**
- Create: `ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/attack-paths-status-panel.tsx`
- Test: `ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/attack-paths-status-panel.test.tsx`
- [ ] **Step 1: Write the failing test**
Create `…/_components/attack-paths-status-panel.test.tsx`:
```tsx
import { fireEvent, render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { ATTACK_PATHS_VIEW_STATES } from "../_lib/get-attack-paths-view-state";
import { AttackPathsStatusPanel } from "./attack-paths-status-panel";
describe("AttackPathsStatusPanel", () => {
it("renders the no-scans message with a link to Scan Jobs", () => {
render(<AttackPathsStatusPanel state={ATTACK_PATHS_VIEW_STATES.NO_SCANS} />);
expect(screen.getByText(/no scans available/i)).toBeInTheDocument();
expect(
screen.getByRole("link", { name: /go to scan jobs/i }),
).toHaveAttribute("href", "/scans");
});
it("renders the scan-running message", () => {
render(
<AttackPathsStatusPanel state={ATTACK_PATHS_VIEW_STATES.SCAN_RUNNING} />,
);
expect(screen.getByText(/scan in progress/i)).toBeInTheDocument();
});
it("renders the graph-building message with progress", () => {
render(
<AttackPathsStatusPanel
state={ATTACK_PATHS_VIEW_STATES.GRAPH_BUILDING}
progress={45}
/>,
);
expect(screen.getByText(/preparing attack paths data/i)).toBeInTheDocument();
expect(screen.getByText(/45%/)).toBeInTheDocument();
});
it("renders the no-graph-data message", () => {
render(
<AttackPathsStatusPanel state={ATTACK_PATHS_VIEW_STATES.NO_GRAPH_DATA} />,
);
expect(screen.getByText(/no attack paths data/i)).toBeInTheDocument();
});
it("renders the error message and calls onRetry when Retry is clicked", () => {
const onRetry = vi.fn();
render(
<AttackPathsStatusPanel
state={ATTACK_PATHS_VIEW_STATES.ERROR}
onRetry={onRetry}
/>,
);
expect(screen.getByText(/couldn.t load scans/i)).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: /retry/i }));
expect(onRetry).toHaveBeenCalledOnce();
});
it("renders nothing for the ready state", () => {
const { container } = render(
<AttackPathsStatusPanel state={ATTACK_PATHS_VIEW_STATES.READY} />,
);
expect(container).toBeEmptyDOMElement();
});
});
```
- [ ] **Step 2: Run test to verify it fails**
Run: `pnpm vitest run --project unit "app/(prowler)/attack-paths/(workflow)/query-builder/_components/attack-paths-status-panel.test.tsx"`
Expected: FAIL — cannot resolve `./attack-paths-status-panel`.
- [ ] **Step 3: Write minimal implementation**
Create `…/_components/attack-paths-status-panel.tsx`:
```tsx
import { CircleAlert, Info } from "lucide-react";
import Link from "next/link";
import {
Alert,
AlertDescription,
AlertTitle,
Button,
} from "@/components/shadcn";
import {
ATTACK_PATHS_VIEW_STATES,
type AttackPathsViewState,
} from "../_lib/get-attack-paths-view-state";
interface AttackPathsStatusPanelProps {
state: AttackPathsViewState;
progress?: number;
onRetry?: () => void;
}
/**
* Full-page status message shown whenever the Attack Paths graph is not yet
* queryable. The page renders the normal workflow instead once `state` is
* `READY` (this component renders nothing for `READY`/`LOADING`).
*/
export const AttackPathsStatusPanel = ({
state,
progress = 0,
onRetry,
}: AttackPathsStatusPanelProps) => {
if (state === ATTACK_PATHS_VIEW_STATES.ERROR) {
return (
<Alert variant="error">
<CircleAlert className="size-4" />
<AlertTitle>Couldn&apos;t load scans</AlertTitle>
<AlertDescription className="flex flex-col items-start gap-3">
<span>Something went wrong loading your scans.</span>
{onRetry ? (
<Button variant="outline" size="sm" onClick={onRetry}>
Retry
</Button>
) : null}
</AlertDescription>
</Alert>
);
}
if (state === ATTACK_PATHS_VIEW_STATES.NO_SCANS) {
return (
<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>
);
}
if (state === ATTACK_PATHS_VIEW_STATES.SCAN_RUNNING) {
return (
<Alert variant="info">
<Info className="size-4" />
<AlertTitle>Scan in progress</AlertTitle>
<AlertDescription>
<span>
Your scan is running. Attack Paths will be available once it
completes.
</span>
</AlertDescription>
</Alert>
);
}
if (state === ATTACK_PATHS_VIEW_STATES.GRAPH_BUILDING) {
return (
<Alert variant="info">
<Info className="size-4" />
<AlertTitle>Preparing Attack Paths data</AlertTitle>
<AlertDescription>
<span>
We&apos;re building the graph from your latest scan ({progress}%).
This will be ready shortly.
</span>
</AlertDescription>
</Alert>
);
}
if (state === ATTACK_PATHS_VIEW_STATES.NO_GRAPH_DATA) {
return (
<Alert variant="info">
<Info className="size-4" />
<AlertTitle>No Attack Paths data</AlertTitle>
<AlertDescription>
<span>Your scan completed but didn&apos;t produce graph data.</span>
</AlertDescription>
</Alert>
);
}
return null;
};
```
- [ ] **Step 4: Run test to verify it passes**
Run: `pnpm vitest run --project unit "app/(prowler)/attack-paths/(workflow)/query-builder/_components/attack-paths-status-panel.test.tsx"`
Expected: PASS (6 tests).
- [ ] **Step 5: Commit**
```bash
git add "ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/attack-paths-status-panel.tsx" "ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/attack-paths-status-panel.test.tsx"
git commit -m "feat(ui): add attack-paths status panel for waiting states"
```
---
### Task 3: Add fixtures for the new states
**Files:**
- Modify: `ui/app/(prowler)/attack-paths/(workflow)/query-builder/attack-paths-page.fixtures.ts`
- [ ] **Step 1: Add three fixture builders**
In `attack-paths-page.fixtures.ts`, insert these builders immediately after `emptyScans` (after its closing `});`, currently line 144):
```ts
export const scanRunning = (): PageFixture => ({
scans: [
buildScan(TYPICAL_SCAN_ID, {
state: "scheduled",
progress: 0,
graph_data_ready: false,
completed_at: null,
duration: null,
}),
],
scanId: TYPICAL_SCAN_ID,
queries: [],
queryId: DEFAULT_QUERY_ID,
queryResult: null,
});
export const graphBuilding = (): PageFixture => ({
scans: [
buildScan(TYPICAL_SCAN_ID, {
state: "executing",
progress: 45,
graph_data_ready: false,
completed_at: null,
duration: null,
}),
],
scanId: TYPICAL_SCAN_ID,
queries: [],
queryId: DEFAULT_QUERY_ID,
queryResult: null,
});
export const noGraphData = (): PageFixture => ({
scans: [
buildScan(TYPICAL_SCAN_ID, {
state: "completed",
progress: 100,
graph_data_ready: false,
}),
],
scanId: TYPICAL_SCAN_ID,
queries: [],
queryId: DEFAULT_QUERY_ID,
queryResult: null,
});
```
- [ ] **Step 2: Register them in the `fixtures` aggregator**
Replace the existing `fixtures` object at the bottom of the file:
```ts
export const fixtures = {
typical,
emptyScans,
emptyGraph,
singleNode,
findingsOnly,
resourcesOnly,
disconnected,
large,
edgeCases,
};
```
with:
```ts
export const fixtures = {
typical,
emptyScans,
scanRunning,
graphBuilding,
noGraphData,
emptyGraph,
singleNode,
findingsOnly,
resourcesOnly,
disconnected,
large,
edgeCases,
};
```
- [ ] **Step 3: Typecheck the fixtures**
Run: `pnpm run typecheck`
Expected: PASS (no errors). This only verifies the new builders compile; they're exercised in Task 4.
- [ ] **Step 4: Commit**
```bash
git add "ui/app/(prowler)/attack-paths/(workflow)/query-builder/attack-paths-page.fixtures.ts"
git commit -m "test(ui): add attack-paths fixtures for running/building/no-graph states"
```
---
### Task 4: Wire the deriver + panel into the page (TDD via browser tests)
**Files:**
- Modify: `ui/app/(prowler)/attack-paths/(workflow)/query-builder/attack-paths-page.browser.test.tsx`
- Modify: `ui/app/(prowler)/attack-paths/(workflow)/query-builder/attack-paths-page.tsx`
- [ ] **Step 1: Write the failing browser tests**
In `attack-paths-page.browser.test.tsx`, add this `describe` block immediately after the existing `describe("loading the page", …)` block (after its closing `});`, currently line 76):
```tsx
describe("waiting states", () => {
test("a running scan shows the scan-in-progress message", async ({
mountWith,
}) => {
const graph = await mountWith(fixtures.scanRunning());
expect(await graph.emptyStateMessage()).toMatch(/scan in progress/i);
});
test("a building graph shows the preparing message with progress", async ({
mountWith,
}) => {
const graph = await mountWith(fixtures.graphBuilding());
const message = await graph.emptyStateMessage();
expect(message).toMatch(/preparing attack paths data/i);
expect(message).toMatch(/45%/);
});
test("a completed scan with no graph shows the no-data message", async ({
mountWith,
}) => {
const graph = await mountWith(fixtures.noGraphData());
expect(await graph.emptyStateMessage()).toMatch(/no attack paths data/i);
});
});
```
- [ ] **Step 2: Run the browser tests to verify they fail**
Run: `pnpm vitest run --project browser "app/(prowler)/attack-paths/(workflow)/query-builder/attack-paths-page.browser.test.tsx" -t "waiting states"`
Expected: FAIL — the page currently renders the scan table for these fixtures, so no matching `[role="alert"]` message appears (`emptyStateMessage` times out / text mismatch).
> If the browser project can't launch Chromium locally, install it once: `pnpm run test:e2e:install`.
- [ ] **Step 3: Add the `loadError` state**
In `attack-paths-page.tsx`, after the `scans` state declaration (line 72), add:
```tsx
const [loadError, setLoadError] = useState(false);
```
- [ ] **Step 4: Lift `loadScans` to component scope and set/clear `loadError`**
Replace the mount effect (currently lines 100120):
```tsx
// 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();
}, []);
```
with:
```tsx
// Load available scans; reused by the error-state Retry action.
const loadScans = async () => {
setScansLoading(true);
setLoadError(false);
try {
const scansData = await getAttackPathScans();
if (scansData?.data) {
setScans(scansData.data);
} else {
setScans([]);
setLoadError(true);
}
} catch (error) {
console.error("Failed to load scans:", error);
setScans([]);
setLoadError(true);
} finally {
setScansLoading(false);
}
};
useEffect(() => {
loadScans();
}, []); // eslint-disable-line react-hooks/exhaustive-deps -- run loadScans once on mount
```
- [ ] **Step 5: Compute the view-state**
In `attack-paths-page.tsx`, immediately after the `hasExecutingScan` declaration (currently ends at line 127), add:
```tsx
const viewState = getAttackPathsViewState({ scansLoading, loadError, scans });
```
- [ ] **Step 6: Replace the empty-state ternary head with the panel branch**
Replace this block (currently lines 389406):
```tsx
{scansLoading ? (
<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>
) : (
```
with:
```tsx
{viewState === ATTACK_PATHS_VIEW_STATES.LOADING ? (
<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>
) : viewState !== ATTACK_PATHS_VIEW_STATES.READY ? (
<AttackPathsStatusPanel
state={viewState}
progress={getGraphBuildingProgress(scans)}
onRetry={loadScans}
/>
) : (
```
(The large workflow `<>…</>` block — the final ternary branch and everything after it — is unchanged.)
- [ ] **Step 7: Add the new imports and drop the now-unused `Link` import**
Delete this line (currently line 4):
```tsx
import Link from "next/link";
```
Add these imports (placement is not critical — `pnpm run lint:fix` re-sorts imports in Step 9):
```tsx
import { AttackPathsStatusPanel } from "./_components/attack-paths-status-panel";
import {
ATTACK_PATHS_VIEW_STATES,
getAttackPathsViewState,
getGraphBuildingProgress,
} from "./_lib/get-attack-paths-view-state";
```
> Do NOT add these to the `./_components` or `./_lib` barrels — import from the direct file paths above (no-barrel rule).
- [ ] **Step 8: Run the browser tests to verify they pass**
Run: `pnpm vitest run --project browser "app/(prowler)/attack-paths/(workflow)/query-builder/attack-paths-page.browser.test.tsx"`
Expected: PASS — the new "waiting states" tests pass AND the pre-existing "an account with no scans shows the empty state" test still passes (NO_SCANS → panel renders "No scans available").
- [ ] **Step 9: Typecheck, lint, and confirm no unused imports**
Run: `pnpm run typecheck && pnpm run lint:fix`
Expected: typecheck PASS; lint reports no `Link`-unused error and re-sorts the new imports. If lint flags `Link` as still imported, re-check Step 7.
- [ ] **Step 10: Commit**
```bash
git add "ui/app/(prowler)/attack-paths/(workflow)/query-builder/attack-paths-page.tsx" "ui/app/(prowler)/attack-paths/(workflow)/query-builder/attack-paths-page.browser.test.tsx"
git commit -m "fix(ui): show adaptive Attack Paths message for running, building and error states"
```
---
### Task 5: Changelog entry
**Files:**
- Modify: `ui/CHANGELOG.md`
- [ ] **Step 1: Invoke the changelog skill**
Invoke the `prowler-changelog` skill (auto-invoke rule for changelog edits). Follow its format: describe the user-visible WHAT, not the implementation HOW.
- [ ] **Step 2: Add a `### 🐞 Fixed` entry under the UNRELEASED section**
In `ui/CHANGELOG.md`, under `## [1.30.0] (Prowler UNRELEASED)` (which currently has only `### 🚀 Added`), add a `### 🐞 Fixed` subsection:
```markdown
### 🐞 Fixed
- Attack Paths now shows distinct messages while a scan is running or its graph is being built, plus a separate "couldn't load scans" error, instead of always showing "No scans available" [(#PR_NUMBER)](https://github.com/prowler-cloud/prowler/pull/PR_NUMBER)
```
Replace `PR_NUMBER` with the real pull request number when the PR is opened.
- [ ] **Step 3: Commit**
```bash
git add ui/CHANGELOG.md
git commit -m "docs(ui): changelog for adaptive attack-paths waiting message"
```
---
### Task 6: Full verification
- [ ] **Step 1: Run the full UI QA gate**
Run: `pnpm run healthcheck`
Expected: PASS (`typecheck` + `lint:check` + `format:check`). If `format:check` fails, run `pnpm run format:write` and re-commit.
- [ ] **Step 2: Run the affected unit + browser tests**
Run: `pnpm vitest run --project unit "app/(prowler)/attack-paths/(workflow)/query-builder/_lib/get-attack-paths-view-state.test.ts" "app/(prowler)/attack-paths/(workflow)/query-builder/_components/attack-paths-status-panel.test.tsx"`
Expected: PASS.
Run: `pnpm vitest run --project browser "app/(prowler)/attack-paths/(workflow)/query-builder/attack-paths-page.browser.test.tsx"`
Expected: PASS.
- [ ] **Step 3: Commit any formatting fixes**
```bash
git add -A && git commit -m "style(ui): formatting for attack-paths waiting message" || echo "nothing to commit"
```
---
## Self-Review
**1. Spec coverage:**
- 3 adaptive states (no-scans / scan-running / graph-building) → Tasks 1, 2, 4. ✅
- Split load-error → `loadError` state + `ERROR` view-state + panel error branch + Retry → Tasks 1, 2, 4. ✅
- `no-graph-data` 5th terminal state → Tasks 1, 2, 4. ✅
- Polling / auto-advance → unchanged `<AutoRefresh>` (mounted unconditionally; `hasExecutingScan` true for `SCHEDULED`/`EXECUTING`); recompute of `viewState` flips to `READY` when `graph_data_ready` turns true. No code needed — verified, not a gap. ✅
- Multi-provider rule (`READY` if any provider ready) → deriver `scans.some(graph_data_ready)` checked before non-ready states; test covers it. ✅
- Out of scope (non-AWS honesty) → intentionally excluded. ✅
- Changelog gate → Task 5. ✅
**2. Placeholder scan:** Only intentional placeholder is `PR_NUMBER` in the changelog (resolved at PR time) — flagged explicitly. No `TODO`/`TBD`/"handle edge cases"/uncoded references. ✅
**3. Type/name consistency:** `ATTACK_PATHS_VIEW_STATES`, `AttackPathsViewState`, `getAttackPathsViewState`, `getGraphBuildingProgress`, `AttackPathsStatusPanel` (props `state`/`progress`/`onRetry`), and fixture names `scanRunning`/`graphBuilding`/`noGraphData` are used identically across Tasks 1, 2, 3, 4. The deriver returns the const-object values; the panel and page import the same enum. ✅
@@ -1,150 +0,0 @@
# Attack Paths — adaptive waiting message
**Date:** 2026-06-09
**Branch:** `fix/improve-attack-path-copy-waiting`
**Scope:** UI-only (`ui/`). No API changes.
## Problem
The Attack Paths page (`/attack-paths`) shows a single full-page message —
**"No scans available" + "Go to Scan Jobs"** — whenever no graph is queryable.
It conflates distinct situations the user wants told apart:
1. **No scans at all** — correct as-is.
2. **A scan is launched but not finished** — should say "scan in progress".
3. **Scan finished, but the post-scan Cartography graph build (queued job) hasn't
completed** — should say "preparing data / almost ready", *if detectable*.
It additionally mis-fires on a **fetch/auth error** (the loader's catch sets
`scans = []`, which renders the same "No scans available" message).
## Feasibility (verified)
All three states are detectable **today, client-side**, from data the page
already fetches once on mount via `getAttackPathScans()`
`GET /attack-paths-scans` (latest `AttackPathsScan` row per provider). Each row
carries `state`, `progress`, and **`graph_data_ready`** — the same boolean the API
uses to hard-gate querying (HTTP 400 when false).
- Scenario 3's signal **already exists**: `graph_data_ready === false` while the
Cartography build runs. No new API field or endpoint is required.
- The `AttackPathsScan` row is **pre-created when a scan starts** (`views.py:2623`
for manual, `beat.py:43` for scheduled) — **but only for AWS** (the sole provider
with a Cartography ingestion function today). For any other provider
`create_attack_paths_scan` returns `None` and no row is created.
Consequence of pre-creation: for AWS, scenarios 2 & 3 **never reach the current
full-page message** — the page shows the scan *table* with a disabled row whose
status lives only in a hover tooltip. So this work makes the full-page message
**own the waiting states** (replacing the table when *nothing* is queryable),
not merely reword scenario 1.
### Row state semantics (AttackPathsScan.state)
- `SCHEDULED` — row created; compute scan running, or graph job queued (not started).
- `EXECUTING` — Cartography graph build actively running (`progress` 1→99).
- `COMPLETED` — graph job finished (`graph_data_ready` true on success; false only
for the unsupported/failure-recovered edge).
- `FAILED` — graph build failed.
- `AVAILABLE` — initial default (rare for attack-paths rows; created as `SCHEDULED`).
## In scope
- Full-page message adapts to **3 states**: no-scans / scan-running / graph-building
(with `progress%`).
- **Split the load-error** out of "no scans" into a distinct error state with a
**Retry**.
- A **5th terminal state** (`no-graph-data`) so a *failed/unsupported* build does not
sit forever on "almost ready". Reuses wording already present in the per-row
tooltips.
- **Polling / auto-advance**: keep the existing `<AutoRefresh>` behaviour — it
already polls (~5s) while `hasExecutingScan` is true and no scan is selected, so
the view auto-reveals the workflow when `graph_data_ready` flips. No new polling
code.
## Out of scope (follow-ups)
- **Non-AWS honesty** ("you have providers but none supports Attack Paths"): needs the
UI to know which provider types support attack paths — deferred.
- Per-provider status changes in the scan table (unchanged).
## Architecture
Three units, all co-located in the `query-builder` feature directory (following the
existing `_components/` pattern). **No barrel files** — direct imports.
1. **`getAttackPathsViewState({ scansLoading, loadError, scans })`** — pure function
returning a discriminated union (the "view state"). Unit-testable in isolation.
2. **`<AttackPathsStatusPanel state={…} progress={…} onRetry={…} />`** —
presentational; renders the right `<Alert>` + copy + CTA per state. New file under
`_components/`.
3. **`attack-paths-page.tsx`** — replaces the ternary at lines 389406; adds a
`loadError` flag in the mount loader; renders the panel **or** the existing
workflow; leaves `<AutoRefresh>` mounted (already unconditional at lines 372375).
### View-state derivation (priority order)
Inputs: `scansLoading: boolean`, `loadError: boolean`, `scans: AttackPathScan[]`.
| State | Condition | Polls? |
|------------------|---------------------------------------------------------------------------|--------|
| `loading` | `scansLoading` | — |
| `error` | `loadError` (loader returned `undefined`) | on Retry |
| `no-scans` | `scans.length === 0` | no |
| `ready` | `scans.some(s => s.attributes.graph_data_ready)` → render workflow | n/a |
| `graph-building` | none ready, some `state === EXECUTING` (show max `progress` of those rows) | yes |
| `scan-running` | none ready, some `state ∈ {SCHEDULED, AVAILABLE}` | yes |
| `no-graph-data` | none ready, all `state ∈ {COMPLETED, FAILED}` | no |
`ready` is evaluated **before** the non-ready states: if *any* provider has a
queryable graph, the full-page message yields to the normal workflow (table +
builder + graph), and per-provider status for the still-building providers stays in
the table. This is the multi-provider rule: the full-page message takes over **only
when nothing is queryable**.
### Proposed copy (editable; English, inline — no i18n layer exists)
| State | Title | Body / CTA |
|------------------|--------------------------------|---------------------------------------------------------------------------------------------|
| `error` | "Couldn't load scans" | "Something went wrong loading your scans." + **[Retry]** |
| `no-scans` | "No scans available" | "You need to run a scan before you can analyze attack paths." + **[Go to Scan Jobs]** *(unchanged)* |
| `scan-running` | "Scan in progress" | "Your scan is running. Attack Paths will be available once it completes." |
| `graph-building` | "Preparing Attack Paths data" | "We're building the graph from your latest scan ({progress}%). This will be ready shortly." |
| `no-graph-data` | "No Attack Paths data" | "Your scan completed but didn't produce graph data." |
(`loading` keeps "Loading scans…".)
## Data flow
Unchanged. Single `getAttackPathScans()` on mount + the existing 5s `<AutoRefresh>`.
- The mount loader sets `loadError = true` when `getAttackPathScans()` resolves to
`undefined`; clears it on a successful (re)load. A successful load with
`{ data: [] }` is **not** an error → `no-scans`.
- **Retry** re-runs the same mount loader (toggles loading, sets/clears `loadError`).
The background `refreshScans` poll keeps its current silent-on-error behaviour.
## Testing (TDD — mandatory)
- **Vitest** unit tests for `getAttackPathsViewState`: one case per state, plus
multi-provider precedence (e.g. one `EXECUTING` + one `FAILED``graph-building`;
one ready + one building → `ready`).
- **RTL** test for `<AttackPathsStatusPanel>`: correct title/body/CTA per state;
Retry invokes `onRetry`; `graph-building` renders `progress%`.
- **Fixtures** in `attack-paths-page.fixtures.ts`: add `scanRunning` (rows
`SCHEDULED`, none ready), `graphBuilding` (rows `EXECUTING`, `graph_data_ready:false`,
`progress` mid-range), `noGraphData` (rows `COMPLETED`/`FAILED`, none ready); register
them in the existing `fixtures` aggregator. `emptyScans()` already covers `no-scans`.
- **Error state**: the pure deriver test covers `loadError`; the panel RTL test covers
the error UI. A full-page browser/MSW error fixture is optional (the harness's
`PageFixture` doesn't model a failing scans endpoint today).
- `ui/CHANGELOG.md` entry (changelog gate).
## Files
- `ui/app/(prowler)/attack-paths/(workflow)/query-builder/attack-paths-page.tsx` — modify
- `ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/attack-paths-status-panel.tsx` — new
- pure helper `getAttackPathsViewState` — new, local to the feature dir, placed per CLAUDE.md's local-util convention (`{feature}/utils/`, or alongside `_components/` to match the existing layout — confirm at implementation)
- `ui/app/(prowler)/attack-paths/(workflow)/query-builder/attack-paths-page.fixtures.ts` — extend
- Vitest specs for the helper + panel — new
- `ui/CHANGELOG.md` — entry
+2 -2
View File
@@ -127,7 +127,7 @@ Add the following to `.gitlab-ci.yml`:
```yaml
prowler-scan:
image: python:3.13-slim
image: python:3.12-slim
stage: test
script:
- pip install prowler
@@ -154,7 +154,7 @@ stages:
- security
.prowler-base:
image: python:3.13-slim
image: python:3.12-slim
stage: security
before_script:
- pip install prowler
@@ -138,6 +138,10 @@ To keep permissions focused:
4. Continue through the wizard and finish. No principals need to be granted access in step 3 unless you want other identities to impersonate this account.
<Note>
To use this service account with `--organization-id`, additionally grant `roles/cloudasset.viewer` at the organization node and enable the Cloud Asset API in the service account's host project. See [Scanning a Specific GCP Organization](./organization). Without these, organization-wide scans silently fall back to listing only the projects accessible to the service account.
</Note>
### Step 3: Generate a JSON Key
1. Open the newly created service account, move to the **Keys** tab, and choose **Add key > Create new key**.
+13 -2
View File
@@ -11,8 +11,19 @@ prowler gcp --organization-id organization-id
```
<Warning>
Ensure the credentials used have one of the following roles at the organization level:
Cloud Asset Viewer (`roles/cloudasset.viewer`), or Cloud Asset Owner (`roles/cloudasset.owner`).
Ensure the credentials used have one of the following roles bound **at the organization node** (not at a project): Cloud Asset Viewer (`roles/cloudasset.viewer`) or Cloud Asset Owner (`roles/cloudasset.owner`). The role must be bound directly on the organization so the Cloud Asset API can enumerate projects across the whole hierarchy.
```bash
gcloud organizations add-iam-policy-binding <organization-id> \
--member="serviceAccount:<service-account-email>" \
--role="roles/cloudasset.viewer"
```
The Cloud Asset API (`cloudasset.googleapis.com`) must also be enabled in the project that owns the credentials (the service account's host project, or the quota project for user credentials):
```bash
gcloud services enable cloudasset.googleapis.com --project <credentials-project-id>
```
</Warning>
<Note>
+2 -2
View File
@@ -1,7 +1,7 @@
# =============================================================================
# Build stage - Install dependencies and build the application
# =============================================================================
FROM ghcr.io/astral-sh/uv:0.11.21-python3.13-alpine3.23@sha256:f09cc61ffc001f202701fdeae14dbdd50f6ca4cfcf248f41fd3234a302c8534f AS builder
FROM ghcr.io/astral-sh/uv:python3.13-alpine@sha256:8f53782bb232ab0b5558f3071e86e2bbfde884e18815f2b19cc57f2d336e9ee2 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.14-alpine3.23@sha256:b0513989fa9be54569cac73f48a60320b74bb0f9ffa886568eea7e48a2432c04
FROM python:3.13-alpine@sha256:bb1f2fdb1065c85468775c9d680dcd344f6442a2d1181ef7916b60a623f11d40
LABEL maintainer="https://github.com/prowler-cloud"
+5 -19
View File
@@ -2,27 +2,13 @@
All notable changes to the **Prowler SDK** are documented in this file.
## [5.31.0] (Prowler UNRELEASED)
## [5.30.2] (Prowler UNRELEASED)
### 🚀 Added
### 🐞 Fixed
- 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)
### 🔄 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)
- 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)
- A broken built-in provider no longer aborts the CLI when a different provider was invoked [(#11618)](https://github.com/prowler-cloud/prowler/pull/11618)
- GCP organization scans with `--organization-id` no longer silently fall back to the credentials' host project when the Cloud Asset API call fails; the new `GCPGetOrganizationProjectsError` (3011) is raised instead, naming the required `roles/cloudasset.viewer` binding and Cloud Asset API enablement [(#11280)](https://github.com/prowler-cloud/prowler/pull/11280)
---
+1 -2
View File
@@ -438,8 +438,7 @@
"storage_geo_redundant_enabled",
"keyvault_recoverable",
"sqlserver_auditing_retention_90_days",
"postgresql_flexible_server_log_retention_days_greater_3",
"cosmosdb_account_backup_policy_continuous"
"postgresql_flexible_server_log_retention_days_greater_3"
]
},
{
@@ -1266,9 +1266,7 @@
"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": [
"cosmosdb_account_backup_policy_continuous"
]
"Checks": []
},
{
"Id": "A.8.14",
@@ -1295,8 +1293,7 @@
"storage_ensure_private_endpoints_in_storage_accounts",
"storage_secure_transfer_required_is_enabled",
"vm_ensure_using_managed_disks",
"vm_trusted_launch_enabled",
"cosmosdb_account_automatic_failover_enabled"
"vm_trusted_launch_enabled"
]
},
{
+1 -3
View File
@@ -1087,9 +1087,7 @@
"storage_blob_versioning_is_enabled",
"storage_geo_redundant_enabled",
"vm_scaleset_associated_with_load_balancer",
"vm_scaleset_not_empty",
"cosmosdb_account_automatic_failover_enabled",
"cosmosdb_account_backup_policy_continuous"
"vm_scaleset_not_empty"
],
"gcp": [
"compute_instance_automatic_restart_enabled",
@@ -302,9 +302,7 @@
{
"Id": "1.15",
"Description": "Ensure storage service-level admins cannot delete resources they manage",
"Checks": [
"identity_storage_service_level_admins_scoped"
],
"Checks": [],
"Attributes": [
{
"Section": "1. Identity and Access Management",
+1 -1
View File
@@ -49,7 +49,7 @@ class _MutableTimestamp:
timestamp = _MutableTimestamp(datetime.today())
timestamp_utc = _MutableTimestamp(datetime.now(timezone.utc))
prowler_version = "5.31.0"
prowler_version = "5.30.2"
html_logo_url = "https://github.com/prowler-cloud/prowler/"
square_logo_img = "https://raw.githubusercontent.com/prowler-cloud/prowler/dc7d2d5aeb92fdf12e8604f42ef6472cd3e8e889/docs/img/prowler-logo-black.png"
aws_logo = "https://user-images.githubusercontent.com/38561120/235953920-3e3fba08-0795-41dc-b480-9bea57db9f2e.png"
+9 -10
View File
@@ -58,17 +58,16 @@ def print_prowler_cloud_banner(provider: str = None):
bar = f"{banner_color}{Style.RESET_ALL}"
print(
f"""
{bar} {Style.BRIGHT}You're getting a snapshot 📸. Prowler Cloud gives you the full picture:{Style.RESET_ALL}
{bar} {Style.BRIGHT}You're getting a snapshot. Prowler Cloud gives you the full picture.{Style.RESET_ALL}
{bar}
{bar} {check} {Style.BRIGHT}Continuous Security Monitoring{Style.RESET_ALL} - scheduled scans with history, trends and alerts.
{bar} {check} {Style.BRIGHT}Lighthouse AI + MCP{Style.RESET_ALL} - autonomous triage, custom dashboards, prioritization with prevention and remediation.
{bar} {check} {Style.BRIGHT}Alerts{Style.RESET_ALL} - get notified when anything you want is happening.
{bar} {check} {Style.BRIGHT}Live Compliance{Style.RESET_ALL} - dashboards for 50+ frameworks, always up to date.
{bar} {check} {Style.BRIGHT}Remediation{Style.RESET_ALL} - complete guided remediation including Autonomous remediation with Lighthouse AI.
{bar} {check} {Style.BRIGHT}Attack Path Visualization{Style.RESET_ALL} - see how attackers chain risks to reach your crown jewels.
{bar} {check} {Style.BRIGHT}Bulk Provisioning{Style.RESET_ALL} - add your entire AWS Organization in seconds.
{bar} {check} {Style.BRIGHT}Integrations{Style.RESET_ALL} - Anything with our MCP + Jira, Slack, AWS Security Hub, Amazon S3, SSO and RBAC.
{bar} {check} {Style.BRIGHT}Attack Path Visualization{Style.RESET_ALL} - see how attackers chain risks to reach your crown jewels
{bar} {check} {Style.BRIGHT}Lighthouse AI + MCP{Style.RESET_ALL} - autonomous triage, prioritization and remediation
{bar} {check} {Style.BRIGHT}Organizations{Style.RESET_ALL} - all your AWS accounts under one organization
{bar} {check} {Style.BRIGHT}Continuous scanning{Style.RESET_ALL} - scheduled scans with history, trends and alerts
{bar} {check} {Style.BRIGHT}Integrations{Style.RESET_ALL} - Jira, Slack, AWS Security Hub, Amazon S3, SSO and RBAC
{bar} {check} {Style.BRIGHT}Reports{Style.RESET_ALL} - download ready-to-share PDF reports
{bar} {check} {Style.BRIGHT}Live compliance{Style.RESET_ALL} - dashboards for 50+ frameworks, always up to date
{bar}
{bar} {Fore.BLUE}Start free at 👉 cloud.prowler.com{Style.RESET_ALL}
{bar} {Fore.BLUE}Start free at cloud.prowler.com{Style.RESET_ALL}
"""
)
+9 -7
View File
@@ -15,6 +15,8 @@ from prowler.lib.check.models import Severity
from prowler.lib.cli.redact import warn_sensitive_argument_values
from prowler.lib.outputs.common import Status
from prowler.providers.common.arguments import (
PROVIDER_ALIASES,
enforce_invoked_provider_loaded,
init_providers_parser,
validate_asff_usage,
validate_provider_arguments,
@@ -166,13 +168,13 @@ Detailed documentation at https://docs.prowler.com
if sys.argv[1].startswith("-"):
sys.argv = self.__set_default_provider__(sys.argv)
# Provider aliases mapping
# Microsoft 365
elif sys.argv[1] == "microsoft365":
sys.argv[1] = "m365"
# Oracle Cloud Infrastructure
elif sys.argv[1] == "oci":
sys.argv[1] = "oraclecloud"
# Provider aliases mapping (single source: arguments.PROVIDER_ALIASES)
elif sys.argv[1] in PROVIDER_ALIASES:
sys.argv[1] = PROVIDER_ALIASES[sys.argv[1]]
# Selective fail-loud here (post argv-normalisation, pre parse_args)
# so the invoked-provider check stays correct under parse(args=...).
enforce_invoked_provider_loaded(self)
# Warn about sensitive flags passed with explicit values
# Snapshot argv before parse_args() which may exit on errors
@@ -2582,7 +2582,6 @@
"aws": [
"af-south-1",
"ap-east-1",
"ap-east-2",
"ap-northeast-1",
"ap-northeast-2",
"ap-northeast-3",
@@ -2592,9 +2591,6 @@
"ap-southeast-2",
"ap-southeast-3",
"ap-southeast-4",
"ap-southeast-5",
"ap-southeast-6",
"ap-southeast-7",
"ca-central-1",
"ca-west-1",
"eu-central-1",
@@ -2608,7 +2604,6 @@
"il-central-1",
"me-central-1",
"me-south-1",
"mx-central-1",
"sa-east-1",
"us-east-1",
"us-east-2",
@@ -7349,7 +7344,6 @@
"lightsail": {
"regions": {
"aws": [
"ap-east-1",
"ap-northeast-1",
"ap-northeast-2",
"ap-south-1",
@@ -7360,11 +7354,9 @@
"ca-central-1",
"eu-central-1",
"eu-north-1",
"eu-south-2",
"eu-west-1",
"eu-west-2",
"eu-west-3",
"sa-east-1",
"us-east-1",
"us-east-2",
"us-west-2"
@@ -8277,9 +8269,7 @@
"cn-north-1",
"cn-northwest-1"
],
"aws-eusc": [
"eusc-de-east-1"
],
"aws-eusc": [],
"aws-us-gov": [
"us-gov-east-1",
"us-gov-west-1"
@@ -9230,7 +9220,6 @@
"eu-west-1",
"eu-west-2",
"eu-west-3",
"sa-east-1",
"us-east-1",
"us-east-2",
"us-west-2"
@@ -9997,8 +9986,6 @@
"ap-south-1",
"ap-southeast-1",
"ap-southeast-2",
"ap-southeast-5",
"ap-southeast-7",
"ca-central-1",
"eu-central-1",
"eu-south-2",
@@ -1,51 +0,0 @@
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
@@ -1,44 +0,0 @@
{
"Provider": "aws",
"CheckID": "config_delegated_admin_and_org_aggregator_all_regions",
"CheckTitle": "AWS Config has a delegated administrator and an organization aggregator covering all AWS regions",
"CheckType": [
"Software and Configuration Checks/AWS Security Best Practices",
"Software and Configuration Checks/Industry and Regulatory Standards/AWS Foundational Security Best Practices"
],
"ServiceName": "config",
"SubServiceName": "",
"ResourceIdTemplate": "",
"Severity": "high",
"ResourceType": "AwsConfigConfigurationAggregator",
"ResourceGroup": "governance",
"Description": "**AWS Config** has a delegated administrator registered via AWS Organizations and at least one Configuration Aggregator with an OrganizationAggregationSource that covers all AWS regions, ensuring centralized org-wide configuration visibility.",
"Risk": "Without an org-wide **AWS Config** aggregator and a delegated administrator, configuration data is fragmented across accounts and regions, **compliance reporting** is incomplete, and **drift detection** is delayed. Adversaries or misconfigurations can persist in unmonitored accounts, eroding **audit readiness** and **regulatory posture**.",
"RelatedUrl": "",
"AdditionalURLs": [
"https://docs.aws.amazon.com/config/latest/developerguide/aggregate-data.html",
"https://docs.aws.amazon.com/config/latest/developerguide/set-up-aggregator-cli.html",
"https://docs.aws.amazon.com/organizations/latest/userguide/services-that-can-integrate-config.html"
],
"Remediation": {
"Code": {
"CLI": "aws organizations register-delegated-administrator --account-id <ADMIN_ACCOUNT_ID> --service-principal config.amazonaws.com && aws configservice put-configuration-aggregator --configuration-aggregator-name org-aggregator --organization-aggregation-source RoleArn=<ROLE_ARN>,AllAwsRegions=true",
"NativeIaC": "",
"Other": "1. From the AWS Organizations management account, register the delegated administrator for config.amazonaws.com\n2. In the delegated admin account, open AWS Config\n3. Create a Configuration Aggregator and select Add my organization as the source\n4. Enable Include all AWS Regions\n5. Confirm an IAM role with AWSConfigRoleForOrganizations is attached\n6. Verify the aggregator status reaches SUCCEEDED for all member accounts",
"Terraform": ""
},
"Recommendation": {
"Text": "Register a **delegated administrator** for AWS Config via AWS Organizations and create at least one **Configuration Aggregator** with an OrganizationAggregationSource that covers **all AWS regions**. This centralizes configuration data across the organization for unified compliance and audit reporting.",
"Url": "https://hub.prowler.com/check/config_delegated_admin_and_org_aggregator_all_regions"
}
},
"Categories": [
"forensics-ready"
],
"DependsOn": [],
"RelatedTo": [
"config_recorder_all_regions_enabled",
"guardduty_delegated_admin_enabled_all_regions"
],
"Notes": "This check requires execution from the organization management account or delegated administrator account to access organization-level APIs."
}
@@ -1,115 +0,0 @@
from prowler.lib.check.models import Check, Check_Report_AWS
from prowler.providers.aws.services.config.config_client import config_client
from prowler.providers.aws.services.config.config_service import Aggregator
class config_delegated_admin_and_org_aggregator_all_regions(Check):
"""Ensure AWS Config has a delegated admin and an org aggregator covering all regions.
This check verifies that:
1. A delegated administrator is registered for the config.amazonaws.com
service principal via AWS Organizations.
2. At least one AWS Config Configuration Aggregator exists with an
OrganizationAggregationSource that covers all AWS regions
(AllAwsRegions=true).
"""
def execute(self) -> list[Check_Report_AWS]:
"""Execute the check logic.
Returns:
A list of reports containing the result of the check. One finding per
aggregator-region, or a single synthetic FAIL when no aggregators
exist in any region.
"""
findings = []
has_delegated_admin = (
bool(config_client.delegated_administrators)
and not config_client.delegated_administrators_lookup_failed
)
delegated_admin_unknown = config_client.delegated_administrators_lookup_failed
# No aggregators in any region: emit one synthetic FAIL anchored to the
# audited account in the default region.
if not config_client.aggregators:
synthetic = Aggregator(
name="unknown",
arn=config_client.get_unknown_arn(
region=config_client.region,
resource_type="config-aggregator",
),
region=config_client.region,
all_aws_regions=False,
aws_regions=None,
organization_aggregation_source_present=False,
)
report = Check_Report_AWS(metadata=self.metadata(), resource=synthetic)
if delegated_admin_unknown:
delegated_state = (
"delegated administrator status could not be determined"
)
elif has_delegated_admin:
delegated_state = "delegated administrator configured"
else:
delegated_state = (
"no delegated administrator registered for config.amazonaws.com"
)
report.status = "FAIL"
report.status_extended = (
f"AWS Config has no Organization Aggregator configured in any "
f"region ({delegated_state})."
)
findings.append(report)
return findings
for region, aggregators_in_region in config_client.aggregators.items():
for aggregator in aggregators_in_region:
report = Check_Report_AWS(metadata=self.metadata(), resource=aggregator)
org_aware = aggregator.organization_aggregation_source_present
covers_all = aggregator.all_aws_regions
issues = []
if delegated_admin_unknown:
issues.append(
"delegated administrator status for config.amazonaws.com "
"could not be determined"
)
elif not has_delegated_admin:
issues.append(
"no delegated administrator registered for config.amazonaws.com"
)
if not org_aware:
issues.append(
f"aggregator {aggregator.name} is not an organization aggregator"
)
elif not covers_all:
issues.append(
f"aggregator {aggregator.name} does not cover all AWS regions"
)
if issues:
report.status = "FAIL"
report.status_extended = (
f"AWS Config aggregator {aggregator.name} in region "
f"{region} has issues: {', '.join(issues)}."
)
else:
report.status = "PASS"
report.status_extended = (
f"AWS Config aggregator {aggregator.name} in region "
f"{region} is an organization aggregator covering all "
f"AWS regions with delegated admin configured."
)
# Support muting non-default regions if configured
if report.status == "FAIL" and (
config_client.audit_config.get("mute_non_default_regions", False)
and region != config_client.region
):
report.muted = True
findings.append(report)
return findings
@@ -1,6 +1,5 @@
from typing import Optional
from botocore.client import ClientError
from pydantic.v1 import BaseModel
from prowler.lib.logger import logger
@@ -13,16 +12,10 @@ class Config(AWSService):
# Call AWSService's __init__
super().__init__(__class__.__name__, provider)
self.recorders = {}
self.aggregators: dict[str, list] = {}
self.delegated_administrators: list = []
self.delegated_administrators_lookup_failed: bool = False
self.__threading_call__(self.describe_configuration_recorders)
self.__threading_call__(
self._describe_configuration_recorder_status, self.recorders.values()
)
self.__threading_call__(self._describe_configuration_aggregators)
# Organizations API is not regional; single call.
self._list_config_delegated_administrators()
def _get_recorder_arn_template(self, region):
return f"arn:{self.audited_partition}:config:{region}:{self.audited_account}:recorder"
@@ -80,108 +73,6 @@ class Config(AWSService):
f"{recorder.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
)
def _describe_configuration_aggregators(self, regional_client):
"""Describe AWS Config configuration aggregators per region.
An aggregator counts as organization-aware when its
OrganizationAggregationSource key is present in the response.
"""
logger.info("Config - Describing Configuration Aggregators...")
try:
paginator = regional_client.get_paginator(
"describe_configuration_aggregators"
)
region_aggregators: list = []
for page in paginator.paginate():
for aggregator in page.get("ConfigurationAggregators", []):
name = aggregator.get("ConfigurationAggregatorName", "")
arn = aggregator.get("ConfigurationAggregatorArn", "")
org_source = aggregator.get("OrganizationAggregationSource")
org_aware = org_source is not None
all_aws_regions = False
aws_regions: Optional[list] = None
if org_aware:
all_aws_regions = org_source.get("AllAwsRegions", False)
aws_regions = org_source.get("AwsRegions")
if not self.audit_resources or (
is_resource_filtered(arn, self.audit_resources)
):
region_aggregators.append(
Aggregator(
name=name,
arn=arn,
region=regional_client.region,
all_aws_regions=all_aws_regions,
aws_regions=aws_regions,
organization_aggregation_source_present=org_aware,
)
)
if region_aggregators:
self.aggregators[regional_client.region] = region_aggregators
except ClientError as error:
if error.response["Error"]["Code"] in (
"AccessDeniedException",
"AccessDenied",
):
logger.warning(
f"{regional_client.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
)
else:
logger.error(
f"{regional_client.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
)
except Exception as error:
logger.error(
f"{regional_client.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
)
def _list_config_delegated_administrators(self):
"""List delegated administrators for the AWS Config service principal.
Uses the Organizations API directly (not regional). Sets
delegated_administrators_lookup_failed to True on AccessDenied so callers
can surface the unknown delegated-admin state in findings.
"""
logger.info(
"Config - Listing delegated administrators for config.amazonaws.com..."
)
try:
org_client = self.session.client("organizations")
paginator = org_client.get_paginator("list_delegated_administrators")
for page in paginator.paginate(ServicePrincipal="config.amazonaws.com"):
for admin in page.get("DelegatedAdministrators", []):
self.delegated_administrators.append(
ConfigDelegatedAdministrator(
id=admin.get("Id", ""),
arn=admin.get("Arn", ""),
name=admin.get("Name", ""),
email=admin.get("Email", ""),
status=admin.get("Status", ""),
joined_method=admin.get("JoinedMethod", ""),
)
)
except ClientError as error:
error_code = error.response["Error"]["Code"]
if error_code in (
"AccessDeniedException",
"AccessDenied",
"AWSOrganizationsNotInUseException",
):
self.delegated_administrators_lookup_failed = True
logger.warning(
f"{self.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
)
else:
self.delegated_administrators_lookup_failed = True
logger.error(
f"{self.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
)
except Exception as error:
self.delegated_administrators_lookup_failed = True
logger.error(
f"{self.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
)
class Recorder(BaseModel):
name: str
@@ -189,25 +80,3 @@ class Recorder(BaseModel):
recording: Optional[bool]
last_status: Optional[str]
region: str
class Aggregator(BaseModel):
"""Represents an AWS Config Configuration Aggregator."""
name: str
arn: str
region: str
all_aws_regions: bool = False
aws_regions: Optional[list] = None
organization_aggregation_source_present: bool = False
class ConfigDelegatedAdministrator(BaseModel):
"""Represents a delegated administrator registered for config.amazonaws.com."""
id: str
arn: str
name: str
email: str
status: str
joined_method: str
@@ -1,9 +1,10 @@
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
@@ -20,10 +21,6 @@ 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:
@@ -45,7 +42,6 @@ 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
)
@@ -57,12 +53,14 @@ 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 record_ip.is_private and record not in public_ips:
if (
not ip_address(record).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
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):
aws_ip_ranges = awsipranges.get_ranges()
if aws_ip_ranges.get(record):
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)
@@ -1,39 +0,0 @@
{
"Provider": "aws",
"CheckID": "sagemaker_clarify_exists",
"CheckTitle": "Amazon SageMaker Clarify processing jobs exist in the region",
"CheckType": [
"Software and Configuration Checks/AWS Security Best Practices"
],
"ServiceName": "sagemaker",
"SubServiceName": "",
"ResourceIdTemplate": "",
"Severity": "low",
"ResourceType": "Other",
"ResourceGroup": "ai_ml",
"Description": "**SageMaker Clarify** provides bias detection and model explainability for ML workloads.\n\nThis check verifies that at least one SageMaker processing job using the AWS-managed Clarify container image exists in each successfully scanned region. The absence of Clarify jobs indicates that responsible-AI controls such as bias detection and explainability are not in place.",
"Risk": "Without **SageMaker Clarify** processing jobs, ML models may be deployed without bias analysis or explainability reports. This can lead to:\n- **Regulatory non-compliance** with AI governance frameworks\n- **Undetected bias** in model predictions affecting protected groups\n- **Lack of accountability** for ML model decisions in production",
"RelatedUrl": "",
"AdditionalURLs": [
"https://docs.aws.amazon.com/sagemaker/latest/dg/clarify-configure-processing-jobs.html",
"https://docs.aws.amazon.com/sagemaker/latest/dg-ecr-paths/sagemaker-algo-docker-registry-paths.html"
],
"Remediation": {
"Code": {
"CLI": "aws sagemaker create-processing-job --processing-job-name clarify-bias-check --app-specification ImageUri=<clarify-image-uri> --role-arn <role-arn> --processing-resources 'ClusterConfig={InstanceCount=1,InstanceType=ml.m5.xlarge,VolumeSizeInGB=20}'",
"NativeIaC": "",
"Other": "1. Open the AWS Console and go to Amazon SageMaker\n2. Navigate to Processing > Processing jobs\n3. Click Create processing job\n4. Select the SageMaker Clarify container image for your region\n5. Configure input/output paths and the analysis configuration\n6. Click Create processing job",
"Terraform": ""
},
"Recommendation": {
"Text": "Create SageMaker Clarify processing jobs to evaluate models for bias and explainability before deployment. Integrate Clarify into your ML pipeline to ensure responsible AI practices.",
"Url": "https://hub.prowler.com/check/sagemaker_clarify_exists"
}
},
"Categories": [
"gen-ai"
],
"DependsOn": [],
"RelatedTo": [],
"Notes": "Results are generated per scanned region. Regions where `ListProcessingJobs` cannot be queried are omitted from the findings."
}
@@ -1,54 +0,0 @@
from prowler.lib.check.models import Check, Check_Report_AWS
from prowler.providers.aws.services.sagemaker.sagemaker_client import sagemaker_client
class sagemaker_clarify_exists(Check):
"""Check whether at least one SageMaker Clarify processing job exists per region.
A region is reported only when ListProcessingJobs succeeded for it; regions
where the API call failed (e.g. AccessDenied, unsupported region) are
skipped at the service layer and produce no finding.
- PASS: At least one processing job uses the AWS-managed Clarify container
image in the region (one finding per job).
- FAIL: No processing job uses the Clarify container image in the region
(one finding per region).
"""
def execute(self) -> list[Check_Report_AWS]:
"""Execute the SageMaker Clarify exists check.
Returns:
A list of reports containing the result of the check.
"""
findings = []
for region in sorted(sagemaker_client.processing_jobs_scanned_regions):
clarify_jobs = sorted(
(
job
for job in sagemaker_client.sagemaker_processing_jobs
if job.region == region
and job.image_uri
and "sagemaker-clarify-processing" in job.image_uri
),
key=lambda job: job.name,
)
if clarify_jobs:
for job in clarify_jobs:
report = Check_Report_AWS(metadata=self.metadata(), resource=job)
report.status = "PASS"
report.status_extended = f"SageMaker Clarify processing job {job.name} exists in region {region}."
findings.append(report)
else:
report = Check_Report_AWS(metadata=self.metadata(), resource={})
report.region = region
report.resource_id = "sagemaker-clarify"
report.resource_arn = f"arn:{sagemaker_client.audited_partition}:sagemaker:{region}:{sagemaker_client.audited_account}:processing-job"
report.status = "FAIL"
report.status_extended = (
f"No SageMaker Clarify processing jobs found in region {region}."
)
findings.append(report)
return findings
@@ -15,8 +15,6 @@ class SageMaker(AWSService):
self.sagemaker_notebook_instances = []
self.sagemaker_models = []
self.sagemaker_training_jobs = []
self.sagemaker_processing_jobs = []
self.processing_jobs_scanned_regions = set()
self.sagemaker_domains = []
self.endpoint_configs = {}
self.sagemaker_model_registries = []
@@ -26,7 +24,6 @@ class SageMaker(AWSService):
self.__threading_call__(self._list_notebook_instances)
self.__threading_call__(self._list_models)
self.__threading_call__(self._list_training_jobs)
self.__threading_call__(self._list_processing_jobs)
self.__threading_call__(self._list_endpoint_configs)
self.__threading_call__(self._list_domains)
self.__threading_call__(self._list_model_package_groups)
@@ -40,9 +37,6 @@ class SageMaker(AWSService):
self.__threading_call__(
self._describe_training_job, self.sagemaker_training_jobs
)
self.__threading_call__(
self._describe_processing_job, self.sagemaker_processing_jobs
)
self.__threading_call__(
self._describe_endpoint_config, list(self.endpoint_configs.values())
)
@@ -57,9 +51,6 @@ class SageMaker(AWSService):
self.__threading_call__(
self._list_tags_for_resource, self.sagemaker_training_jobs
)
self.__threading_call__(
self._list_tags_for_resource, self.sagemaker_processing_jobs
)
self.__threading_call__(
self._list_tags_for_resource, list(self.endpoint_configs.values())
)
@@ -137,66 +128,6 @@ class SageMaker(AWSService):
f"{regional_client.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
)
def _list_processing_jobs(self, regional_client):
"""List SageMaker processing jobs in a region.
Populates ``self.sagemaker_processing_jobs`` with `ProcessingJob`
entries and adds ``regional_client.region`` to
``self.processing_jobs_scanned_regions`` once pagination succeeds, so
regions where ``ListProcessingJobs`` fails are skipped by checks that
consume that set.
Args:
regional_client: Regional SageMaker boto3 client.
"""
logger.info("SageMaker - listing processing jobs...")
try:
list_processing_jobs_paginator = regional_client.get_paginator(
"list_processing_jobs"
)
for page in list_processing_jobs_paginator.paginate():
for processing_job in page["ProcessingJobSummaries"]:
if not self.audit_resources or (
is_resource_filtered(
processing_job["ProcessingJobArn"], self.audit_resources
)
):
self.sagemaker_processing_jobs.append(
ProcessingJob(
name=processing_job["ProcessingJobName"],
region=regional_client.region,
arn=processing_job["ProcessingJobArn"],
)
)
self.processing_jobs_scanned_regions.add(regional_client.region)
except Exception as error:
logger.error(
f"{regional_client.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
)
def _describe_processing_job(self, processing_job):
"""Describe a SageMaker processing job and enrich its image metadata.
Reads ``AppSpecification.ImageUri`` from ``DescribeProcessingJob`` and
stores it on ``processing_job.image_uri``. Errors are logged and
swallowed so a failure in one job does not abort the scan.
Args:
processing_job: ProcessingJob model to enrich in-place.
"""
logger.info("SageMaker - describing processing job...")
try:
regional_client = self.regional_clients[processing_job.region]
describe_processing_job = regional_client.describe_processing_job(
ProcessingJobName=processing_job.name
)
app_spec = describe_processing_job.get("AppSpecification", {})
processing_job.image_uri = app_spec.get("ImageUri")
except Exception as error:
logger.error(
f"{processing_job.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
)
def _describe_notebook_instance(self, notebook_instance):
logger.info("SageMaker - describing notebook instances...")
try:
@@ -520,25 +451,6 @@ class TrainingJob(BaseModel):
tags: Optional[list] = []
class ProcessingJob(BaseModel):
"""Represents a SageMaker processing job.
Attributes:
name: Processing job name.
region: AWS region where the job lives.
arn: Processing job ARN.
image_uri: Container image URI from `AppSpecification.ImageUri`,
populated by `_describe_processing_job`.
tags: Resource tags, populated by `_list_tags_for_resource`.
"""
name: str
region: str
arn: str
image_uri: Optional[str] = None
tags: Optional[list] = []
class ProductionVariant(BaseModel):
name: str
initial_instance_count: int
@@ -1,44 +0,0 @@
{
"Provider": "aws",
"CheckID": "securityhub_delegated_admin_enabled_all_regions",
"CheckTitle": "Security Hub has delegated admin configured and is enabled in all regions with organization auto-enable",
"CheckType": [
"Software and Configuration Checks/AWS Security Best Practices",
"Software and Configuration Checks/Industry and Regulatory Standards/AWS Foundational Security Best Practices"
],
"ServiceName": "securityhub",
"SubServiceName": "",
"ResourceIdTemplate": "",
"Severity": "high",
"ResourceType": "AwsSecurityHubHub",
"ResourceGroup": "security",
"Description": "**AWS Security Hub** has a delegated administrator configured at the organization level, hubs are active in all opted-in regions, and organization auto-enable is active so that new member accounts are automatically enrolled.",
"Risk": "Without org-wide **AWS Security Hub** configuration, findings can be aggregated inconsistently, delegated admin may be missing in some regions, and new accounts will not be auto-enrolled. This fragments **security posture visibility**, delays **incident response**, and lets misconfigurations and compliance drift go undetected across the organization.",
"RelatedUrl": "",
"AdditionalURLs": [
"https://docs.aws.amazon.com/securityhub/latest/userguide/designate-orgs-admin-account.html",
"https://docs.aws.amazon.com/securityhub/latest/userguide/accounts-orgs-auto-enable.html",
"https://docs.aws.amazon.com/securityhub/latest/userguide/securityhub-regions.html"
],
"Remediation": {
"Code": {
"CLI": "aws securityhub enable-organization-admin-account --admin-account-id <ADMIN_ACCOUNT_ID> && aws securityhub update-organization-configuration --auto-enable --auto-enable-standards DEFAULT",
"NativeIaC": "",
"Other": "1. Sign in to the AWS Organizations management account\n2. Open the AWS Organizations console\n3. Navigate to Services > AWS Security Hub\n4. Click Register delegated administrator and enter the security account ID\n5. Switch to the delegated admin account\n6. In Security Hub console, go to Settings > Accounts\n7. Enable auto-enable for new organization accounts\n8. Repeat hub enablement for all opted-in regions",
"Terraform": ""
},
"Recommendation": {
"Text": "Configure a **delegated administrator** for AWS Security Hub via AWS Organizations. Enable Security Hub in **all opted-in regions** and turn on **auto-enable** so new member accounts are automatically enrolled. This ensures uniform security posture monitoring across the entire organization.",
"Url": "https://hub.prowler.com/check/securityhub_delegated_admin_enabled_all_regions"
}
},
"Categories": [
"forensics-ready"
],
"DependsOn": [],
"RelatedTo": [
"securityhub_enabled",
"guardduty_delegated_admin_enabled_all_regions"
],
"Notes": "This check requires execution from the organization management account or delegated administrator account to access organization-level APIs."
}
@@ -1,84 +0,0 @@
from prowler.lib.check.models import Check, Check_Report_AWS
from prowler.providers.aws.services.securityhub.securityhub_client import (
securityhub_client,
)
class securityhub_delegated_admin_enabled_all_regions(Check):
"""Ensure Security Hub has a delegated admin and is enabled in all regions.
This check verifies that:
1. A delegated administrator account is configured for Security Hub
2. Security Hub is active (ACTIVE status) in each region
3. Organization auto-enable is configured for new member accounts
"""
def execute(self) -> list[Check_Report_AWS]:
"""Execute the check logic.
Returns:
A list of reports containing the result of the check for each region.
"""
findings = []
# Build a set of regions that have an organization admin account configured
regions_with_admin = {
admin.region
for admin in securityhub_client.organization_admin_accounts
if admin.admin_status == "ENABLED"
}
admin_lookup_failed = securityhub_client.organization_admin_lookup_failed
for securityhub in securityhub_client.securityhubs:
report = Check_Report_AWS(metadata=self.metadata(), resource=securityhub)
# Check if this region has a delegated admin
has_delegated_admin = securityhub.region in regions_with_admin
# Check if hub is active
hub_active = securityhub.status == "ACTIVE"
# Check if auto-enable is configured for organization members
auto_enable_on = securityhub.organization_auto_enable
# Determine overall status
issues = []
if admin_lookup_failed:
issues.append("delegated administrator status could not be determined")
elif not has_delegated_admin:
issues.append("no delegated administrator configured")
if not hub_active:
issues.append("Security Hub not enabled")
if (
hub_active
and securityhub.organization_config_available
and not auto_enable_on
):
# Only report auto-enable issue if hub is active and org config data
# is available (i.e., we could actually read AutoEnable from the API).
issues.append("organization auto-enable not configured")
if issues:
report.status = "FAIL"
report.status_extended = (
f"Security Hub in region {securityhub.region} has issues: "
f"{', '.join(issues)}."
)
else:
report.status = "PASS"
report.status_extended = (
f"Security Hub in region {securityhub.region} has delegated "
f"admin configured with hub active and organization auto-enable "
f"enabled."
)
# Support muting non-default regions if configured
if report.status == "FAIL" and (
securityhub_client.audit_config.get("mute_non_default_regions", False)
and securityhub.region != securityhub_client.region
):
report.muted = True
findings.append(report)
return findings
@@ -13,14 +13,8 @@ class SecurityHub(AWSService):
# Call AWSService's __init__
super().__init__(__class__.__name__, provider)
self.securityhubs = []
self.organization_admin_accounts = []
self.organization_admin_lookup_failed: bool = False
self.__threading_call__(self._describe_hub)
self.__threading_call__(self._list_tags, self.securityhubs)
self.__threading_call__(self._list_organization_admin_accounts)
self.__threading_call__(
self._describe_organization_configuration, self.securityhubs
)
def _describe_hub(self, regional_client):
logger.info("SecurityHub - Describing Hub...")
@@ -110,95 +104,6 @@ class SecurityHub(AWSService):
f"{resource.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
)
def _list_organization_admin_accounts(self, regional_client):
"""List Security Hub delegated administrator accounts for the organization.
This API is only available to the organization management account or
a delegated administrator account.
"""
logger.info("SecurityHub - listing organization admin accounts...")
try:
paginator = regional_client.get_paginator(
"list_organization_admin_accounts"
)
for page in paginator.paginate():
for admin in page.get("AdminAccounts", []):
admin_account = OrganizationAdminAccount(
admin_account_id=admin.get("AdminAccountId"),
admin_status=admin.get("AdminStatus"),
region=regional_client.region,
)
# Avoid duplicates across regions for the same admin account
if not any(
existing.admin_account_id == admin_account.admin_account_id
and existing.region == admin_account.region
for existing in self.organization_admin_accounts
):
self.organization_admin_accounts.append(admin_account)
except ClientError as error:
self.organization_admin_lookup_failed = True
if error.response["Error"]["Code"] in (
"AccessDeniedException",
"InvalidAccessException",
"BadRequestException",
):
logger.warning(
f"{regional_client.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
)
else:
logger.error(
f"{regional_client.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
)
except Exception as error:
self.organization_admin_lookup_failed = True
logger.error(
f"{regional_client.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
)
def _describe_organization_configuration(self, securityhub):
"""Describe the organization configuration for a Security Hub instance.
This provides information about auto-enable settings for the organization.
Only invoked for hubs in ACTIVE status.
"""
logger.info("SecurityHub - describing organization configuration...")
try:
if securityhub.status != "ACTIVE":
return
regional_client = self.regional_clients[securityhub.region]
org_config = regional_client.describe_organization_configuration()
securityhub.organization_auto_enable = org_config.get("AutoEnable", False)
securityhub.auto_enable_standards = org_config.get(
"AutoEnableStandards", "NONE"
)
securityhub.organization_config_available = True
except ClientError as error:
if error.response["Error"]["Code"] in (
"AccessDeniedException",
"InvalidAccessException",
"BadRequestException",
):
# Expected when not running from management or delegated admin account
logger.warning(
f"{securityhub.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
)
else:
logger.error(
f"{securityhub.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
)
except Exception as error:
logger.error(
f"{securityhub.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
)
class OrganizationAdminAccount(BaseModel):
"""Represents a Security Hub delegated administrator account."""
admin_account_id: str
admin_status: str # ENABLED or DISABLE_IN_PROGRESS
region: str
class SecurityHubHub(BaseModel):
arn: str
@@ -207,8 +112,4 @@ class SecurityHubHub(BaseModel):
standards: str
integrations: str
region: str
tags: Optional[list] = []
# Organization configuration fields
organization_auto_enable: bool = False
auto_enable_standards: str = "NONE"
organization_config_available: bool = False
tags: Optional[list]
@@ -1,38 +0,0 @@
{
"Provider": "azure",
"CheckID": "cosmosdb_account_automatic_failover_enabled",
"CheckTitle": "Cosmos DB account has automatic failover enabled",
"CheckType": [],
"ServiceName": "cosmosdb",
"SubServiceName": "",
"ResourceIdTemplate": "",
"Severity": "medium",
"ResourceType": "microsoft.documentdb/databaseaccounts",
"ResourceGroup": "database",
"Description": "**Azure Cosmos DB accounts** are evaluated for **automatic failover** configuration. When enabled, Cosmos DB automatically promotes a secondary region to primary during a regional outage, ensuring continuous availability without manual intervention.",
"Risk": "Without **automatic failover**, a regional outage requires **manual failover** which delays recovery and risks data unavailability. Applications dependent on the primary region experience downtime until an operator intervenes.",
"RelatedUrl": "",
"AdditionalURLs": [
"https://learn.microsoft.com/en-us/azure/cosmos-db/how-to-manage-database-account#automatic-failover",
"https://learn.microsoft.com/en-us/azure/cosmos-db/high-availability",
"https://learn.microsoft.com/en-us/azure/cosmos-db/distribute-data-globally"
],
"Remediation": {
"Code": {
"CLI": "az cosmosdb update --name <COSMOS_ACCOUNT_NAME> --resource-group <RESOURCE_GROUP> --enable-automatic-failover true",
"NativeIaC": "```bicep\n// Bicep: Enable automatic failover on a Cosmos DB account\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: [\n { locationName: '<primary_region>', failoverPriority: 0 }\n { locationName: '<secondary_region>', failoverPriority: 1 }\n ]\n enableAutomaticFailover: true // Critical: Promotes a secondary region during a primary region outage\n }\n}\n```",
"Other": "1. Sign in to the Azure portal and open your Cosmos DB account\n2. In the left menu, select Replicate data globally\n3. Click Automatic Failover\n4. Toggle Enable Automatic Failover to On\n5. Set failover priorities for each region\n6. Click Save",
"Terraform": "```hcl\n# Terraform: Enable automatic failover on a Cosmos DB account\nresource \"azurerm_cosmosdb_account\" \"<example_resource_name>\" {\n name = \"<example_resource_name>\"\n resource_group_name = \"<example_resource_name>\"\n location = \"<primary_region>\"\n offer_type = \"Standard\"\n kind = \"GlobalDocumentDB\"\n\n geo_location {\n location = \"<primary_region>\"\n failover_priority = 0\n }\n\n geo_location {\n location = \"<secondary_region>\"\n failover_priority = 1\n }\n\n enable_automatic_failover = true # Critical: Promotes a secondary region during a primary region outage\n}\n```"
},
"Recommendation": {
"Text": "Enable **automatic failover** on Cosmos DB accounts with **multi-region** deployments so a secondary region is promoted automatically when the primary region becomes unavailable. Configure **failover priorities** to reflect your recovery strategy, validate **RTO/RPO** expectations with periodic failover drills, and combine with **multi-region writes** where active-active is required.",
"Url": "https://hub.prowler.com/check/cosmosdb_account_automatic_failover_enabled"
}
},
"Categories": [
"forensics-ready"
],
"DependsOn": [],
"RelatedTo": [],
"Notes": ""
}
@@ -1,29 +0,0 @@
from prowler.lib.check.models import Check, Check_Report_Azure
from prowler.providers.azure.services.cosmosdb.cosmosdb_client import cosmosdb_client
class cosmosdb_account_automatic_failover_enabled(Check):
"""Ensure that Cosmos DB accounts have automatic failover enabled."""
def execute(self) -> Check_Report_Azure:
"""Execute the Cosmos DB automatic failover check.
Iterates over every Cosmos DB account fetched by the service and reports
PASS when `enableAutomaticFailover` is True, FAIL otherwise.
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 have automatic failover enabled."
if account.enable_automatic_failover:
report.status = "PASS"
report.status_extended = f"CosmosDB account {account.name} from subscription {subscription} has automatic failover enabled."
findings.append(report)
return findings
@@ -1,38 +0,0 @@
{
"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": ""
}
@@ -1,30 +0,0 @@
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,5 +1,5 @@
from dataclasses import dataclass
from typing import List, Optional
from typing import List
from azure.mgmt.cosmosdb import CosmosDBManagementClient
@@ -36,29 +36,14 @@ class CosmosDB(AzureService):
name=private_endpoint_connection.name,
type=private_endpoint_connection.type,
)
for private_endpoint_connection in (
getattr(account, "private_endpoint_connections", [])
or []
for private_endpoint_connection in getattr(
account, "private_endpoint_connections", []
)
if private_endpoint_connection
],
disable_local_auth=getattr(
account, "disable_local_auth", False
),
enable_automatic_failover=getattr(
account, "enable_automatic_failover", False
),
backup_policy_type=getattr(
getattr(account, "backup_policy", None),
"type",
None,
),
public_network_access=getattr(
account, "public_network_access", None
),
minimal_tls_version=getattr(
account, "minimal_tls_version", None
),
)
)
except Exception as error:
@@ -86,7 +71,3 @@ class Account:
location: str
private_endpoint_connections: List[PrivateEndpointConnection]
disable_local_auth: bool = False
enable_automatic_failover: bool = False
backup_policy_type: Optional[str] = None
public_network_access: Optional[str] = None
minimal_tls_version: Optional[str] = None
+79 -19
View File
@@ -10,16 +10,43 @@ provider_arguments_lib_path = "lib.arguments.arguments"
validate_provider_arguments_function = "validate_arguments"
init_provider_arguments_function = "init_parser"
# Kept in sync with parser.py's argv normalisation; both consumers import this.
PROVIDER_ALIASES = {
"microsoft365": "m365",
"oci": "oraclecloud",
}
def _invoked_provider_from_argv(available_providers: Sequence[str]) -> Optional[str]:
"""Return the provider name the user invoked, or None.
Mirrors `ProwlerArgumentParser.parse()` resolution: only inspects
`sys.argv[1]`. Scanning the whole argv would misclassify
`prowler --output-directory stackit` as `stackit`.
"""
available = set(available_providers)
if len(sys.argv) < 2:
return "aws" if "aws" in available else None
first = sys.argv[1]
if first in ("-h", "--help", "-v", "--version"):
return None
if first.startswith("-"):
return "aws" if "aws" in available else None
normalized = PROVIDER_ALIASES.get(first, first)
return normalized if normalized in available else None
def init_providers_parser(self):
"""init_providers_parser calls the provider init_parser function to load all the arguments and flags. Receives a ProwlerArgumentParser object"""
# We need to call the arguments parser for each provider
"""Build the subparser of each available provider.
Built-in load failures are captured silently on
`self._builtin_load_failures`; the warn/exit decision is deferred to
`enforce_invoked_provider_loaded()` because `parse(args=...)` can
override `sys.argv` after this function ran.
"""
self._builtin_load_failures = {}
providers = Provider.get_available_providers()
for provider in providers:
# Discriminate built-in vs external upfront via find_spec, so an
# ImportError from a transitive dependency missing inside a built-in
# arguments module surfaces clearly instead of being silently
# re-routed to the entry-point path (which only has external providers).
if Provider.is_builtin(provider):
try:
getattr(
@@ -28,21 +55,9 @@ def init_providers_parser(self):
),
init_provider_arguments_function,
)(self)
except ImportError as e:
logger.critical(
f"Failed to load arguments for built-in provider '{provider}'. "
f"Missing dependency: {e}. "
f"Ensure all required dependencies are installed."
)
logger.debug("Full traceback:", exc_info=True)
sys.exit(1)
except Exception as error:
logger.critical(
f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
)
sys.exit(1)
self._builtin_load_failures[provider] = error
else:
# External provider — init_parser classmethod via entry point
cls = Provider._load_ep_provider(provider)
if cls and hasattr(cls, "init_parser"):
try:
@@ -53,6 +68,51 @@ def init_providers_parser(self):
)
def enforce_invoked_provider_loaded(self):
"""Apply selective fail-loud over the failures captured at init time.
Called by `ProwlerArgumentParser.parse()` AFTER argv normalisation so
the invoked provider matches what argparse will dispatch to including
the case where `parse(args=...)` overrode the ambient `sys.argv`.
Invoked + failed critical + `sys.exit(1)`. Others warning.
"""
failures = getattr(self, "_builtin_load_failures", {})
if not failures:
return
invoked = _invoked_provider_from_argv(Provider.get_available_providers())
for provider, error in failures.items():
if provider == invoked:
continue
if isinstance(error, ImportError):
logger.warning(
f"Skipping built-in provider '{provider}' due to missing "
f"dependency: {error}. It will be unavailable in this "
f"invocation, but the CLI continues because you invoked a "
f"different provider."
)
else:
logger.warning(
f"Skipping built-in provider '{provider}': "
f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
)
if invoked is None or invoked not in failures:
return
error = failures[invoked]
if isinstance(error, ImportError):
logger.critical(
f"Failed to load arguments for built-in provider '{invoked}'. "
f"Missing dependency: {error}. "
f"Ensure all required dependencies are installed."
)
logger.debug("Full traceback:", exc_info=True)
else:
logger.critical(
f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
)
sys.exit(1)
def validate_provider_arguments(arguments: Namespace) -> tuple[bool, str]:
"""validate_provider_arguments returns {True, "} if the provider arguments passed are valid and can be used together"""
try:
+14 -1
View File
@@ -34,11 +34,17 @@ class GCPBaseException(ProwlerException):
"message": "Error loading Service Account Private Key credentials from dictionary",
"remediation": "Check the dictionary and ensure it contains a Service Account Private Key.",
},
(3011, "GCPGetOrganizationProjectsError"): {
"message": "Error retrieving projects under the organization via the Cloud Asset API",
"remediation": "Ensure the Cloud Asset API is enabled in the credentials' project and that the principal has 'roles/cloudasset.viewer' bound at the organization level. See https://cloud.google.com/asset-inventory/docs/access-control.",
},
}
def __init__(self, code, file=None, original_exception=None, message=None):
provider = "GCP"
error_info = self.GCP_ERROR_CODES.get((code, self.__class__.__name__))
# Copy the catalog entry so a custom message does not mutate the
# class-level GCP_ERROR_CODES shared across exception instances.
error_info = dict(self.GCP_ERROR_CODES.get((code, self.__class__.__name__)))
if message:
error_info["message"] = message
super().__init__(
@@ -104,3 +110,10 @@ class GCPLoadServiceAccountKeyFromDictError(GCPCredentialsError):
super().__init__(
3010, file=file, original_exception=original_exception, message=message
)
class GCPGetOrganizationProjectsError(GCPBaseException):
def __init__(self, file=None, original_exception=None, message=None):
super().__init__(
3011, file=file, original_exception=original_exception, message=message
)
+30 -12
View File
@@ -21,6 +21,8 @@ from prowler.providers.common.models import Audit_Metadata, Connection
from prowler.providers.common.provider import Provider
from prowler.providers.gcp.config import DEFAULT_RETRY_ATTEMPTS
from prowler.providers.gcp.exceptions.exceptions import (
GCPBaseException,
GCPGetOrganizationProjectsError,
GCPInvalidProviderIdError,
GCPLoadADCFromDictError,
GCPLoadServiceAccountKeyFromDictError,
@@ -621,10 +623,7 @@ class GcpProvider(Provider):
credentials_file: str
Returns:
dict[str, GCPProject]
Usage:
>>> GcpProvider.get_projects(credentials=credentials, organization_id=organization_id)
dict of project_id and GCPProject object
"""
projects = {}
try:
@@ -632,7 +631,10 @@ class GcpProvider(Provider):
try:
# Initialize Cloud Asset Inventory API for recursive project retrieval
asset_service = discovery.build(
"cloudasset", "v1", credentials=credentials
"cloudasset",
"v1",
credentials=credentials,
num_retries=DEFAULT_RETRY_ATTEMPTS,
)
# Set the scope to the specified organization and filter for projects
scope = f"organizations/{organization_id}"
@@ -643,7 +645,7 @@ class GcpProvider(Provider):
)
while request is not None:
response = request.execute()
response = request.execute(num_retries=DEFAULT_RETRY_ATTEMPTS)
for asset in response.get("assets", []):
# Extract labels and other project details
@@ -688,13 +690,25 @@ class GcpProvider(Provider):
)
except HttpError as http_error:
if "Cloud Asset API has not been used" in str(http_error):
logger.error(
f"Projects cannot be retrieved from the Organization since Cloud Asset API has not been used before or it is disabled [{http_error.__traceback__.tb_lineno}]. Enable it by visiting https://console.developers.google.com/apis/api/cloudasset.googleapis.com/ then retry."
message = (
"Projects cannot be retrieved from the Organization since the Cloud Asset API "
"has not been used before or it is disabled. Enable it by visiting "
"https://console.developers.google.com/apis/api/cloudasset.googleapis.com/ then retry."
)
else:
logger.error(
f"{http_error.__class__.__name__}[{http_error.__traceback__.tb_lineno}]: {http_error}"
message = (
f"Cloud Asset API call failed while listing projects under organization "
f"'{organization_id}': {http_error}. Ensure the credentials' principal has "
"'roles/cloudasset.viewer' bound at the organization level."
)
logger.critical(
f"{http_error.__class__.__name__}[{http_error.__traceback__.tb_lineno}]: {message}"
)
raise GCPGetOrganizationProjectsError(
file=__file__,
original_exception=http_error,
message=message,
)
else:
try:
# Initialize Cloud Resource Manager API for simple project listing
@@ -781,8 +795,10 @@ class GcpProvider(Provider):
labels={},
lifecycle_state="ACTIVE",
)
# If no projects were able to be accessed via API, add them manually from the credentials file
elif credentials_file:
# If no projects were able to be accessed via API, add them manually from the credentials file.
# Skip this fallback when an organization scan was explicitly requested: silently
# downgrading scope to the service account's home project hides permission errors.
elif credentials_file and not organization_id:
with open(credentials_file, "r", encoding="utf-8") as file:
project_id = json.load(file)["project_id"]
# Handle empty or null project names
@@ -798,6 +814,8 @@ class GcpProvider(Provider):
labels={},
lifecycle_state="ACTIVE",
)
except GCPBaseException as gcp_error:
raise gcp_error
except Exception as error:
logger.critical(
f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
@@ -1,38 +0,0 @@
{
"Provider": "gcp",
"CheckID": "cloudsql_instance_high_availability_enabled",
"CheckTitle": "Cloud SQL instance has high availability (REGIONAL) configured",
"CheckType": [],
"ServiceName": "cloudsql",
"SubServiceName": "",
"ResourceIdTemplate": "",
"Severity": "medium",
"ResourceType": "sqladmin.googleapis.com/Instance",
"Description": "Ensures that Cloud SQL instances have high availability configured by setting availabilityType to REGIONAL. A REGIONAL instance maintains a standby replica in a different zone within the same region and automatically fails over on zone-level outages.",
"Risk": "Instances with ZONAL availability have no standby replica. A zone-level outage will cause database downtime until manual recovery, violating availability requirements and potentially breaching SLAs and ISMS-P 2.12.1 disaster preparedness controls.",
"RelatedUrl": "",
"AdditionalURLs": [
"https://cloud.google.com/sql/docs/postgres/high-availability",
"https://cloud.google.com/sql/docs/sqlserver/high-availability"
],
"Remediation": {
"Code": {
"CLI": "gcloud sql instances patch <INSTANCE_NAME> --availability-type=REGIONAL",
"NativeIaC": "",
"Other": "1. Go to Google Cloud Console > SQL > Instances.\n2. Click the instance name, then Edit.\n3. Under Availability, select Multiple zones (Highly available).\n4. Click Save.",
"Terraform": "```hcl\nresource \"google_sql_database_instance\" \"example\" {\n name = \"<instance_name>\"\n database_version = \"POSTGRES_15\"\n region = \"<region>\"\n\n settings {\n tier = \"db-custom-2-7680\"\n\n availability_type = \"REGIONAL\" # Critical: enables HA standby replica\n\n backup_configuration {\n enabled = true\n start_time = \"02:00\"\n }\n }\n}\n```"
},
"Recommendation": {
"Text": "Set availabilityType to REGIONAL for all production Cloud SQL instances. This creates a standby replica in a different zone and enables automatic failover, reducing RTO in the event of a zone outage.",
"Url": "https://hub.prowler.com/check/cloudsql_instance_high_availability_enabled"
}
},
"Categories": [
"resilience"
],
"DependsOn": [],
"RelatedTo": [
"cloudsql_instance_automated_backups"
],
"Notes": "Enabling HA increases instance cost approximately 2x due to the standby replica. ZONAL instances are acceptable for non-production workloads where downtime is tolerable."
}
@@ -1,41 +0,0 @@
from prowler.lib.check.models import Check, Check_Report_GCP
from prowler.providers.gcp.services.cloudsql.cloudsql_client import cloudsql_client
class cloudsql_instance_high_availability_enabled(Check):
"""Check that Cloud SQL primary instances are configured for high availability.
Verifies that each Cloud SQL primary instance has `availabilityType` set to
`REGIONAL`, which provisions a standby replica in a different zone within
the same region and enables automatic failover on zone-level outages. Read
replicas are skipped because they inherit availability from their primary.
"""
def execute(self) -> list[Check_Report_GCP]:
"""Execute the high availability check across all Cloud SQL instances.
Returns:
A list of `Check_Report_GCP` findings, one per Cloud SQL primary
instance. Status is `PASS` when `availability_type == "REGIONAL"`
and `FAIL` otherwise.
"""
findings = []
for instance in cloudsql_client.instances:
if instance.instance_type != "CLOUD_SQL_INSTANCE":
continue
report = Check_Report_GCP(metadata=self.metadata(), resource=instance)
if instance.availability_type == "REGIONAL":
report.status = "PASS"
report.status_extended = (
f"Database instance {instance.name} has high availability "
f"(REGIONAL) configured."
)
else:
report.status = "FAIL"
report.status_extended = (
f"Database instance {instance.name} does not have high "
f"availability configured (current: "
f"{instance.availability_type})."
)
findings.append(report)
return findings
@@ -46,9 +46,6 @@ class CloudSQL(GCPService):
"authorizedNetworks", []
),
flags=settings.get("databaseFlags", []),
availability_type=settings.get(
"availabilityType", "ZONAL"
),
instance_type=instance.get(
"instanceType", "CLOUD_SQL_INSTANCE"
),
@@ -79,7 +76,6 @@ class Instance(BaseModel):
ssl_mode: str
automated_backups: bool
flags: list
availability_type: str = "ZONAL"
instance_type: str = "CLOUD_SQL_INSTANCE"
cmek_key_name: Optional[str] = None
project_id: str
@@ -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";
@@ -1,39 +0,0 @@
{
"Provider": "oraclecloud",
"CheckID": "identity_storage_service_level_admins_scoped",
"CheckTitle": "OCI IAM storage service-level admin policies exclude delete permissions",
"CheckType": [],
"ServiceName": "identity",
"SubServiceName": "",
"ResourceIdTemplate": "",
"Severity": "medium",
"ResourceType": "Policy",
"ResourceGroup": "IAM",
"Description": "**OCI IAM policies** are reviewed to ensure storage service-level administrator statements that grant `manage` permissions exclude the relevant storage delete permissions with `request.permission`. This supports CIS OCI 3.1 control 1.15 separation of duties for Block Volume, File Storage, and Object Storage administrators.",
"Risk": "Storage service-level administrators with unrestricted `manage` permissions can delete the resources they administer, including volumes, backups, file systems, mount targets, export sets, objects, or buckets. This weakens separation of duties and can lead to data loss, service disruption, or destructive insider activity.",
"RelatedUrl": "",
"AdditionalURLs": [
"https://docs.oracle.com/en-us/iaas/Content/Identity/policyreference/policyreference.htm",
"https://docs.oracle.com/en-us/iaas/Content/Block/home.htm",
"https://docs.oracle.com/en-us/iaas/Content/File/home.htm",
"https://docs.oracle.com/en-us/iaas/Content/Object/home.htm"
],
"Remediation": {
"Code": {
"CLI": "oci iam policy update --policy-id <policy-ocid> --statements \"[\\\"Allow group VolumeUsers to manage volumes in tenancy where request.permission!='VOLUME_DELETE'\\\"]\"",
"NativeIaC": "",
"Other": "1. In OCI Console, go to Identity & Security > Policies\n2. Open each active policy that grants storage service-level administrators `manage` permissions\n3. Edit storage manage statements to exclude the relevant delete permission with `request.permission`\n4. Example: Allow group BucketUsers to manage buckets in tenancy where request.permission!='BUCKET_DELETE'\n5. Save changes",
"Terraform": "```hcl\nresource \"oci_identity_policy\" \"storage_admins\" {\n compartment_id = var.compartment_id\n name = \"storage-admins\"\n description = \"Storage administrators without delete permissions\"\n\n statements = [\n \"Allow group VolumeUsers to manage volumes in tenancy where request.permission!='VOLUME_DELETE'\",\n \"Allow group VolumeUsers to manage volume-backups in tenancy where request.permission!='VOLUME_BACKUP_DELETE'\",\n \"Allow group FileUsers to manage file-systems in tenancy where request.permission!='FILE_SYSTEM_DELETE'\",\n \"Allow group FileUsers to manage mount-targets in tenancy where request.permission!='MOUNT_TARGET_DELETE'\",\n \"Allow group FileUsers to manage export-sets in tenancy where request.permission!='EXPORT_SET_DELETE'\",\n \"Allow group BucketUsers to manage objects in tenancy where request.permission!='OBJECT_DELETE'\",\n \"Allow group BucketUsers to manage buckets in tenancy where request.permission!='BUCKET_DELETE'\"\n ]\n}\n```"
},
"Recommendation": {
"Text": "Exclude delete permissions from storage service-level administrator policies. Use `request.permission!='VOLUME_DELETE'`, `request.permission!='VOLUME_BACKUP_DELETE'`, `request.permission!='FILE_SYSTEM_DELETE'`, `request.permission!='MOUNT_TARGET_DELETE'`, `request.permission!='EXPORT_SET_DELETE'`, `request.permission!='OBJECT_DELETE'`, and `request.permission!='BUCKET_DELETE'` as appropriate for each storage manage statement.",
"Url": "https://hub.prowler.com/check/identity_storage_service_level_admins_scoped"
}
},
"Categories": [
"identity-access"
],
"DependsOn": [],
"RelatedTo": [],
"Notes": ""
}
@@ -1,176 +0,0 @@
"""Check storage service-level administrators cannot delete managed resources."""
import re
from prowler.lib.check.models import Check, Check_Report_OCI
from prowler.providers.oraclecloud.services.identity.identity_client import (
identity_client,
)
STORAGE_DELETE_PERMISSIONS_BY_RESOURCE = {
"volumes": {"VOLUME_DELETE"},
"volume-backups": {"VOLUME_BACKUP_DELETE"},
"file-systems": {"FILE_SYSTEM_DELETE"},
"mount-targets": {"MOUNT_TARGET_DELETE"},
"export-sets": {"EXPORT_SET_DELETE"},
"objects": {"OBJECT_DELETE"},
"buckets": {"BUCKET_DELETE"},
"volume-family": {"VOLUME_DELETE", "VOLUME_BACKUP_DELETE"},
"file-family": {"FILE_SYSTEM_DELETE", "MOUNT_TARGET_DELETE", "EXPORT_SET_DELETE"},
"object-family": {"OBJECT_DELETE", "BUCKET_DELETE"},
}
ALL_STORAGE_DELETE_PERMISSIONS = set().union(
*STORAGE_DELETE_PERMISSIONS_BY_RESOURCE.values()
)
STORAGE_DELETE_PERMISSIONS_BY_RESOURCE["all-resources"] = ALL_STORAGE_DELETE_PERMISSIONS
MANAGE_STATEMENT_PATTERN = re.compile(
r"\ballow\s+group\b.+?\bto\s+manage\s+(?P<resource>[a-z-]+)\b",
re.IGNORECASE,
)
QUOTED_LITERAL_PATTERN = re.compile(r"'(?:\\.|[^'\\])*'|\"(?:\\.|[^\"\\])*\"")
def _normalize_statement(statement: str) -> str:
"""Collapse whitespace in an OCI policy statement."""
return " ".join(statement.strip().split())
def _has_disjunctive_condition(statement: str) -> bool:
"""Return True when the WHERE condition can allow alternate branches."""
condition = re.split(r"\bwhere\b", statement, flags=re.IGNORECASE, maxsplit=1)
if len(condition) != 2:
return False
condition_without_literals = QUOTED_LITERAL_PATTERN.sub("", condition[1])
return bool(
re.search(r"\b(any|or)\b|\|\|", condition_without_literals, re.IGNORECASE)
)
def _storage_manage_resource(statement: str) -> str | None:
"""Return the managed storage resource in a policy statement, if any."""
normalized_statement = _normalize_statement(statement)
match = MANAGE_STATEMENT_PATTERN.search(normalized_statement)
if not match:
return None
resource = match.group("resource").lower()
if resource not in STORAGE_DELETE_PERMISSIONS_BY_RESOURCE:
return None
return resource
def _excluded_permissions(statement: str) -> set[str]:
"""Return delete permissions explicitly excluded with request.permission != value."""
if _has_disjunctive_condition(statement):
return set()
exclusions = set()
for permission in ALL_STORAGE_DELETE_PERMISSIONS:
pattern = re.compile(
rf"\brequest\.permission\s*!=\s*['\"]?{re.escape(permission)}['\"]?\b",
re.IGNORECASE,
)
if pattern.search(statement):
exclusions.add(permission)
return exclusions
def _missing_delete_exclusions(statement: str) -> tuple[str, set[str]] | None:
"""Return the storage resource and missing delete exclusions for a statement."""
normalized_statement = _normalize_statement(statement)
resource = _storage_manage_resource(normalized_statement)
if not resource:
return None
required_permissions = STORAGE_DELETE_PERMISSIONS_BY_RESOURCE[resource]
excluded_permissions = _excluded_permissions(normalized_statement)
missing_permissions = required_permissions - excluded_permissions
if not missing_permissions:
return None
return resource, missing_permissions
class identity_storage_service_level_admins_scoped(Check):
"""Ensure storage service-level admins cannot delete resources they manage."""
def execute(self) -> list[Check_Report_OCI]:
"""Execute the storage service-level administrators scoped check.
Returns:
A list of OCI check reports for active non-tenant-admin policies.
"""
findings = []
for policy in identity_client.policies:
if policy.lifecycle_state != "ACTIVE":
continue
if policy.name.upper() == "TENANT ADMIN POLICY":
continue
region = policy.region if hasattr(policy, "region") else "global"
violations = []
has_storage_manage_statement = False
for statement in policy.statements:
if _storage_manage_resource(statement):
has_storage_manage_statement = True
missing_result = _missing_delete_exclusions(statement)
if not missing_result:
continue
resource, missing_permissions = missing_result
violations.append(
f"statement `{_normalize_statement(statement)}` manages {resource} without excluding: {', '.join(sorted(missing_permissions))}"
)
if not has_storage_manage_statement:
continue
report = Check_Report_OCI(
metadata=self.metadata(),
resource=policy,
region=region,
resource_id=policy.id,
resource_name=policy.name,
compartment_id=policy.compartment_id,
)
if violations:
report.status = "FAIL"
report.status_extended = (
f"Policy '{policy.name}' allows storage service-level administrators to manage storage resources without explicitly excluding required delete permissions: "
+ "; ".join(violations)
+ "."
)
else:
report.status = "PASS"
report.status_extended = f"Policy '{policy.name}' excludes required storage delete permissions from storage manage statements."
findings.append(report)
if not findings:
region = (
identity_client.audited_regions[0].key
if identity_client.audited_regions
else "global"
)
report = Check_Report_OCI(
metadata=self.metadata(),
resource={},
region=region,
resource_id=identity_client.audited_tenancy,
resource_name="Tenancy",
compartment_id=identity_client.audited_tenancy,
)
report.status = "PASS"
report.status_extended = "No active storage service-level administrator policies grant manage permissions without excluding delete permissions."
findings.append(report)
return findings
+14 -24
View File
@@ -32,10 +32,10 @@ classifiers = [
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"Programming Language :: Python :: 3.13"
"Programming Language :: Python :: 3.12"
]
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.9",
"numpy==2.2.6",
"microsoft-kiota-abstractions==1.9.2",
"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.3.0",
"py-iam-expand==0.1.0",
"h2==4.3.0",
"oci==2.169.0",
"alibabacloud_credentials==1.0.3",
@@ -123,8 +123,8 @@ license = "Apache-2.0"
maintainers = [{name = "Prowler Engineering", email = "engineering@prowler.com"}]
name = "prowler"
readme = "README.md"
requires-python = ">=3.10,<3.14"
version = "5.31.0"
requires-python = ">=3.10,<3.13"
version = "5.30.2"
[project.scripts]
prowler = "prowler.__main__:prowler"
@@ -162,7 +162,7 @@ constraint-dependencies = [
"aenum==3.1.17",
"aiofiles==24.1.0",
"aiohappyeyeballs==2.6.1",
"aiohttp==3.14.0",
"aiohttp==3.13.5",
"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.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",
"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",
"mock==5.2.0",
"moto==5.1.11",
"mpmath==1.3.0",
@@ -364,13 +364,3 @@ constraint-dependencies = [
"zstd==1.5.7.3"
]
override-dependencies = ["okta==3.4.2"]
[tool.vulture]
# Suppress known false positives. The CI command only passes --exclude and
# --min-confidence on the CLI, so ignore_names from here still applies (vulture
# only overrides the keys set on the CLI).
# - mock_* : pytest fixtures injected as test params but unused in the body
# (e.g. mock_sensitive_args in tests/lib/cli/redact_test.py)
# - view : DRF BasePermission.has_object_permission(self, request, view, obj)
# framework-required signature param in skills/django-drf template assets
ignore_names = ["mock_*", "view"]
-99
View File
@@ -1,99 +0,0 @@
---
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`.
@@ -1,51 +0,0 @@
// @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.",
},
],
});
@@ -1,31 +0,0 @@
# 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`.
@@ -1,44 +0,0 @@
# 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.
@@ -1,112 +0,0 @@
import json
import urllib.error
from ipaddress import IPv4Network, IPv6Network, ip_address
from unittest.mock import MagicMock, patch
from prowler.providers.aws.lib.ip_ranges.ip_ranges import get_public_ip_networks
URLOPEN_TARGET = "prowler.providers.aws.lib.ip_ranges.ip_ranges.urllib.request.urlopen"
SAMPLE_RANGES = {
"prefixes": [
{"ip_prefix": "54.152.0.0/16", "service": "AMAZON"},
{"ip_prefix": "3.5.140.0/22", "service": "S3"},
],
"ipv6_prefixes": [
{"ipv6_prefix": "2600:1f00::/24", "service": "AMAZON"},
],
}
def mock_urlopen(payload):
response = MagicMock()
response.read.return_value = json.dumps(payload).encode()
context_manager = MagicMock()
context_manager.__enter__.return_value = response
context_manager.__exit__.return_value = False
return context_manager
class TestGetPublicIPNetworks:
def test_parses_ipv4_and_ipv6_prefixes(self):
with patch(URLOPEN_TARGET, return_value=mock_urlopen(SAMPLE_RANGES)):
networks = get_public_ip_networks()
assert networks == [
IPv4Network("54.152.0.0/16"),
IPv4Network("3.5.140.0/22"),
IPv6Network("2600:1f00::/24"),
]
def test_known_aws_ip_is_contained(self):
with patch(URLOPEN_TARGET, return_value=mock_urlopen(SAMPLE_RANGES)):
networks = get_public_ip_networks()
assert any(ip_address("54.152.12.70") in network for network in networks)
def test_external_ip_is_not_contained(self):
with patch(URLOPEN_TARGET, return_value=mock_urlopen(SAMPLE_RANGES)):
networks = get_public_ip_networks()
assert not any(ip_address("17.5.7.3") in network for network in networks)
def test_empty_payload_returns_empty_list(self):
with patch(
"prowler.providers.aws.lib.ip_ranges.ip_ranges.urllib.request.urlopen",
return_value=mock_urlopen({}),
):
networks = get_public_ip_networks()
assert networks == []
def test_prefixes_missing_cidr_are_skipped(self):
payload = {
"prefixes": [{"ip_prefix": "10.0.0.0/8"}, {"service": "EC2"}],
"ipv6_prefixes": [{"service": "AMAZON"}],
}
with patch(URLOPEN_TARGET, return_value=mock_urlopen(payload)):
networks = get_public_ip_networks()
assert networks == [IPv4Network("10.0.0.0/8")]
def test_urlopen_failure_returns_empty_list(self):
with patch(URLOPEN_TARGET, side_effect=urllib.error.URLError("boom")):
networks = get_public_ip_networks()
assert networks == []
def test_timeout_returns_empty_list(self):
with patch(URLOPEN_TARGET, side_effect=TimeoutError("timed out")):
networks = get_public_ip_networks()
assert networks == []
def test_invalid_json_returns_empty_list(self):
response = MagicMock()
response.read.return_value = b"not json"
context_manager = MagicMock()
context_manager.__enter__.return_value = response
context_manager.__exit__.return_value = False
with patch(URLOPEN_TARGET, return_value=context_manager):
networks = get_public_ip_networks()
assert networks == []
def test_malformed_cidr_is_skipped(self):
payload = {
"prefixes": [
{"ip_prefix": "300.0.0.0/8"},
{"ip_prefix": "10.0.0.0/8"},
],
"ipv6_prefixes": [
{"ipv6_prefix": "2600::/129"},
{"ipv6_prefix": "2600:1f00::/24"},
],
}
with patch(URLOPEN_TARGET, return_value=mock_urlopen(payload)):
networks = get_public_ip_networks()
assert networks == [
IPv4Network("10.0.0.0/8"),
IPv6Network("2600:1f00::/24"),
]
@@ -1,491 +0,0 @@
from unittest.mock import patch
import botocore
from moto import mock_aws
from tests.providers.aws.utils import (
AWS_ACCOUNT_NUMBER,
AWS_REGION_EU_WEST_1,
AWS_REGION_US_EAST_1,
set_mocked_aws_provider,
)
orig = botocore.client.BaseClient._make_api_call
AGG_ARN_TEMPLATE = (
"arn:aws:config:{region}:" + AWS_ACCOUNT_NUMBER + ":config-aggregator/{name}"
)
def _aggregator_payload(
name, region, *, org_aware=True, all_regions=True, aws_regions=None
):
payload = {
"ConfigurationAggregatorName": name,
"ConfigurationAggregatorArn": AGG_ARN_TEMPLATE.format(region=region, name=name),
}
if org_aware:
org_source = {
"RoleArn": f"arn:aws:iam::{AWS_ACCOUNT_NUMBER}:role/AWSConfigRoleForOrganizations",
"AllAwsRegions": all_regions,
}
if not all_regions and aws_regions:
org_source["AwsRegions"] = aws_regions
payload["OrganizationAggregationSource"] = org_source
return payload
def make_mock_no_aggregators_no_admin():
def _mock(self, operation_name, api_params):
if operation_name == "DescribeConfigurationAggregators":
return {"ConfigurationAggregators": []}
if operation_name == "ListDelegatedAdministrators":
return {"DelegatedAdministrators": []}
return orig(self, operation_name, api_params)
return _mock
def make_mock_aggregator_not_org_aware():
def _mock(self, operation_name, api_params):
if operation_name == "DescribeConfigurationAggregators":
return {
"ConfigurationAggregators": [
_aggregator_payload(
"legacy-agg",
AWS_REGION_EU_WEST_1,
org_aware=False,
)
]
}
if operation_name == "ListDelegatedAdministrators":
return {"DelegatedAdministrators": []}
return orig(self, operation_name, api_params)
return _mock
def make_mock_org_aggregator_not_all_regions_with_admin():
def _mock(self, operation_name, api_params):
if operation_name == "DescribeConfigurationAggregators":
return {
"ConfigurationAggregators": [
_aggregator_payload(
"partial-org-agg",
AWS_REGION_EU_WEST_1,
org_aware=True,
all_regions=False,
aws_regions=[AWS_REGION_EU_WEST_1],
)
]
}
if operation_name == "ListDelegatedAdministrators":
return {
"DelegatedAdministrators": [
{
"Id": "123456789012",
"Arn": f"arn:aws:organizations::{AWS_ACCOUNT_NUMBER}:account/o-abc123/123456789012",
"Email": "admin@example.com",
"Name": "Security",
"Status": "ACTIVE",
"JoinedMethod": "CREATED",
}
]
}
return orig(self, operation_name, api_params)
return _mock
def make_mock_full_pass():
def _mock(self, operation_name, api_params):
if operation_name == "DescribeConfigurationAggregators":
return {
"ConfigurationAggregators": [
_aggregator_payload(
"org-aggregator",
AWS_REGION_EU_WEST_1,
org_aware=True,
all_regions=True,
)
]
}
if operation_name == "ListDelegatedAdministrators":
return {
"DelegatedAdministrators": [
{
"Id": "123456789012",
"Arn": f"arn:aws:organizations::{AWS_ACCOUNT_NUMBER}:account/o-abc123/123456789012",
"Email": "admin@example.com",
"Name": "Security",
"Status": "ACTIVE",
"JoinedMethod": "CREATED",
}
]
}
return orig(self, operation_name, api_params)
return _mock
def make_mock_access_denied_on_orgs():
def _mock(self, operation_name, api_params):
if operation_name == "DescribeConfigurationAggregators":
return {
"ConfigurationAggregators": [
_aggregator_payload(
"org-aggregator",
AWS_REGION_EU_WEST_1,
org_aware=True,
all_regions=True,
)
]
}
if operation_name == "ListDelegatedAdministrators":
raise botocore.exceptions.ClientError(
{
"Error": {
"Code": "AccessDeniedException",
"Message": "User is not authorized to perform: organizations:ListDelegatedAdministrators",
}
},
operation_name,
)
return orig(self, operation_name, api_params)
return _mock
def make_mock_aggregators_access_denied():
def _mock(self, operation_name, api_params):
if operation_name == "DescribeConfigurationAggregators":
raise botocore.exceptions.ClientError(
{
"Error": {
"Code": "AccessDeniedException",
"Message": "denied",
}
},
operation_name,
)
if operation_name == "ListDelegatedAdministrators":
return {"DelegatedAdministrators": []}
return orig(self, operation_name, api_params)
return _mock
def make_mock_aggregators_other_client_error():
def _mock(self, operation_name, api_params):
if operation_name == "DescribeConfigurationAggregators":
raise botocore.exceptions.ClientError(
{
"Error": {
"Code": "InternalServerError",
"Message": "boom",
}
},
operation_name,
)
if operation_name == "ListDelegatedAdministrators":
return {"DelegatedAdministrators": []}
return orig(self, operation_name, api_params)
return _mock
def make_mock_aggregators_unexpected_exception():
def _mock(self, operation_name, api_params):
if operation_name == "DescribeConfigurationAggregators":
raise RuntimeError("simulated transient error")
if operation_name == "ListDelegatedAdministrators":
return {"DelegatedAdministrators": []}
return orig(self, operation_name, api_params)
return _mock
def make_mock_delegated_admins_unexpected_exception():
def _mock(self, operation_name, api_params):
if operation_name == "DescribeConfigurationAggregators":
return {
"ConfigurationAggregators": [
_aggregator_payload(
"org-aggregator",
AWS_REGION_EU_WEST_1,
org_aware=True,
all_regions=True,
)
]
}
if operation_name == "ListDelegatedAdministrators":
raise RuntimeError("simulated transient error")
return orig(self, operation_name, api_params)
return _mock
class Test_config_delegated_admin_and_org_aggregator_all_regions:
@mock_aws
def test_no_aggregators_no_admin(self):
"""Test when no aggregators exist in any region and no delegated admin is set."""
with patch(
"botocore.client.BaseClient._make_api_call",
new=make_mock_no_aggregators_no_admin(),
):
aws_provider = set_mocked_aws_provider(
[AWS_REGION_EU_WEST_1, AWS_REGION_US_EAST_1]
)
from prowler.providers.aws.services.config.config_service import Config
with (
patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=aws_provider,
),
patch(
"prowler.providers.aws.services.config.config_delegated_admin_and_org_aggregator_all_regions.config_delegated_admin_and_org_aggregator_all_regions.config_client",
new=Config(aws_provider),
),
):
from prowler.providers.aws.services.config.config_delegated_admin_and_org_aggregator_all_regions.config_delegated_admin_and_org_aggregator_all_regions import (
config_delegated_admin_and_org_aggregator_all_regions,
)
check = config_delegated_admin_and_org_aggregator_all_regions()
result = check.execute()
assert len(result) == 1
assert result[0].status == "FAIL"
assert (
"no Organization Aggregator configured in any region"
in result[0].status_extended
)
assert (
"no delegated administrator registered for config.amazonaws.com"
in result[0].status_extended
)
@mock_aws
def test_aggregator_not_org_aware(self):
"""Test when an aggregator exists but is not an organization aggregator."""
with patch(
"botocore.client.BaseClient._make_api_call",
new=make_mock_aggregator_not_org_aware(),
):
aws_provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1])
from prowler.providers.aws.services.config.config_service import Config
with (
patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=aws_provider,
),
patch(
"prowler.providers.aws.services.config.config_delegated_admin_and_org_aggregator_all_regions.config_delegated_admin_and_org_aggregator_all_regions.config_client",
new=Config(aws_provider),
),
):
from prowler.providers.aws.services.config.config_delegated_admin_and_org_aggregator_all_regions.config_delegated_admin_and_org_aggregator_all_regions import (
config_delegated_admin_and_org_aggregator_all_regions,
)
check = config_delegated_admin_and_org_aggregator_all_regions()
result = check.execute()
eu_west_1_result = None
for finding in result:
if finding.region == AWS_REGION_EU_WEST_1:
eu_west_1_result = finding
break
assert eu_west_1_result is not None
assert eu_west_1_result.status == "FAIL"
assert (
"is not an organization aggregator"
in eu_west_1_result.status_extended
)
@mock_aws
def test_org_aggregator_not_all_regions_with_admin(self):
"""Test org aggregator that doesn't cover all AWS regions (delegated admin set)."""
with patch(
"botocore.client.BaseClient._make_api_call",
new=make_mock_org_aggregator_not_all_regions_with_admin(),
):
aws_provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1])
from prowler.providers.aws.services.config.config_service import Config
with (
patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=aws_provider,
),
patch(
"prowler.providers.aws.services.config.config_delegated_admin_and_org_aggregator_all_regions.config_delegated_admin_and_org_aggregator_all_regions.config_client",
new=Config(aws_provider),
),
):
from prowler.providers.aws.services.config.config_delegated_admin_and_org_aggregator_all_regions.config_delegated_admin_and_org_aggregator_all_regions import (
config_delegated_admin_and_org_aggregator_all_regions,
)
check = config_delegated_admin_and_org_aggregator_all_regions()
result = check.execute()
eu_west_1_result = None
for finding in result:
if finding.region == AWS_REGION_EU_WEST_1:
eu_west_1_result = finding
break
assert eu_west_1_result is not None
assert eu_west_1_result.status == "FAIL"
assert (
"does not cover all AWS regions" in eu_west_1_result.status_extended
)
@mock_aws
def test_full_pass(self):
"""Test PASS: delegated admin set and org aggregator covering all AWS regions."""
with patch(
"botocore.client.BaseClient._make_api_call",
new=make_mock_full_pass(),
):
aws_provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1])
from prowler.providers.aws.services.config.config_service import Config
with (
patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=aws_provider,
),
patch(
"prowler.providers.aws.services.config.config_delegated_admin_and_org_aggregator_all_regions.config_delegated_admin_and_org_aggregator_all_regions.config_client",
new=Config(aws_provider),
),
):
from prowler.providers.aws.services.config.config_delegated_admin_and_org_aggregator_all_regions.config_delegated_admin_and_org_aggregator_all_regions import (
config_delegated_admin_and_org_aggregator_all_regions,
)
check = config_delegated_admin_and_org_aggregator_all_regions()
result = check.execute()
eu_west_1_result = None
for finding in result:
if finding.region == AWS_REGION_EU_WEST_1:
eu_west_1_result = finding
break
assert eu_west_1_result is not None
assert eu_west_1_result.status == "PASS"
assert (
"is an organization aggregator covering all AWS regions"
in eu_west_1_result.status_extended
)
assert "delegated admin configured" in eu_west_1_result.status_extended
assert eu_west_1_result.resource_arn == AGG_ARN_TEMPLATE.format(
region=AWS_REGION_EU_WEST_1, name="org-aggregator"
)
@mock_aws
def test_access_denied_on_organizations(self):
"""Test that AccessDenied on Organizations is reported as unknown admin state."""
with patch(
"botocore.client.BaseClient._make_api_call",
new=make_mock_access_denied_on_orgs(),
):
aws_provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1])
from prowler.providers.aws.services.config.config_service import Config
with (
patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=aws_provider,
),
patch(
"prowler.providers.aws.services.config.config_delegated_admin_and_org_aggregator_all_regions.config_delegated_admin_and_org_aggregator_all_regions.config_client",
new=Config(aws_provider),
),
):
from prowler.providers.aws.services.config.config_delegated_admin_and_org_aggregator_all_regions.config_delegated_admin_and_org_aggregator_all_regions import (
config_delegated_admin_and_org_aggregator_all_regions,
)
check = config_delegated_admin_and_org_aggregator_all_regions()
result = check.execute()
eu_west_1_result = None
for finding in result:
if finding.region == AWS_REGION_EU_WEST_1:
eu_west_1_result = finding
break
assert eu_west_1_result is not None
# The check still runs; aggregator coverage is satisfied but the
# delegated-admin status is unknown, so it must FAIL.
assert eu_west_1_result.status == "FAIL"
assert (
"delegated administrator status for config.amazonaws.com could not be determined"
in eu_west_1_result.status_extended
)
@mock_aws
def test_aggregators_access_denied(self):
"""AccessDenied on DescribeConfigurationAggregators is swallowed: no aggregators recorded for that region."""
with patch(
"botocore.client.BaseClient._make_api_call",
new=make_mock_aggregators_access_denied(),
):
aws_provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1])
from prowler.providers.aws.services.config.config_service import Config
service = Config(aws_provider)
assert service.aggregators == {}
@mock_aws
def test_aggregators_other_client_error(self):
"""Non-access ClientError on DescribeConfigurationAggregators is logged at error level."""
with patch(
"botocore.client.BaseClient._make_api_call",
new=make_mock_aggregators_other_client_error(),
):
aws_provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1])
from prowler.providers.aws.services.config.config_service import Config
service = Config(aws_provider)
assert service.aggregators == {}
@mock_aws
def test_aggregators_unexpected_exception(self):
"""Non-ClientError on DescribeConfigurationAggregators is caught by bare except."""
with patch(
"botocore.client.BaseClient._make_api_call",
new=make_mock_aggregators_unexpected_exception(),
):
aws_provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1])
from prowler.providers.aws.services.config.config_service import Config
service = Config(aws_provider)
assert service.aggregators == {}
@mock_aws
def test_delegated_admins_unexpected_exception(self):
"""Non-ClientError on ListDelegatedAdministrators must still set lookup_failed."""
with patch(
"botocore.client.BaseClient._make_api_call",
new=make_mock_delegated_admins_unexpected_exception(),
):
aws_provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1])
from prowler.providers.aws.services.config.config_service import Config
service = Config(aws_provider)
assert service.delegated_administrators_lookup_failed is True
assert service.delegated_administrators == []
@@ -1,247 +0,0 @@
from unittest import mock
from prowler.providers.aws.services.sagemaker.sagemaker_service import ProcessingJob
from tests.providers.aws.utils import (
AWS_ACCOUNT_NUMBER,
AWS_REGION_EU_WEST_1,
AWS_REGION_US_EAST_1,
set_mocked_aws_provider,
)
CLARIFY_IMAGE_URI = f"{AWS_ACCOUNT_NUMBER}.dkr.ecr.{AWS_REGION_US_EAST_1}.amazonaws.com/sagemaker-clarify-processing:1.0"
NON_CLARIFY_IMAGE_URI = f"{AWS_ACCOUNT_NUMBER}.dkr.ecr.{AWS_REGION_US_EAST_1}.amazonaws.com/sagemaker-xgboost:1.0"
CUSTOM_CLARIFY_IMAGE_URI = f"{AWS_ACCOUNT_NUMBER}.dkr.ecr.{AWS_REGION_US_EAST_1}.amazonaws.com/my-clarify-thing:1.0"
PROCESSING_JOB_ARN = f"arn:aws:sagemaker:{AWS_REGION_US_EAST_1}:{AWS_ACCOUNT_NUMBER}:processing-job/clarify-job"
class Test_sagemaker_clarify_exists:
def test_no_processing_jobs_no_scanned_regions(self):
sagemaker_client = mock.MagicMock
sagemaker_client.sagemaker_processing_jobs = []
sagemaker_client.processing_jobs_scanned_regions = set()
aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1])
with (
mock.patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=aws_provider,
),
mock.patch(
"prowler.providers.aws.services.sagemaker.sagemaker_clarify_exists.sagemaker_clarify_exists.sagemaker_client",
sagemaker_client,
),
):
from prowler.providers.aws.services.sagemaker.sagemaker_clarify_exists.sagemaker_clarify_exists import (
sagemaker_clarify_exists,
)
check = sagemaker_clarify_exists()
result = check.execute()
assert len(result) == 0
def test_no_processing_jobs_region_scanned(self):
sagemaker_client = mock.MagicMock
sagemaker_client.sagemaker_processing_jobs = []
sagemaker_client.processing_jobs_scanned_regions = {AWS_REGION_US_EAST_1}
sagemaker_client.audited_partition = "aws"
sagemaker_client.audited_account = AWS_ACCOUNT_NUMBER
aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1])
with (
mock.patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=aws_provider,
),
mock.patch(
"prowler.providers.aws.services.sagemaker.sagemaker_clarify_exists.sagemaker_clarify_exists.sagemaker_client",
sagemaker_client,
),
):
from prowler.providers.aws.services.sagemaker.sagemaker_clarify_exists.sagemaker_clarify_exists import (
sagemaker_clarify_exists,
)
check = sagemaker_clarify_exists()
result = check.execute()
assert len(result) == 1
assert result[0].status == "FAIL"
assert (
result[0].status_extended
== f"No SageMaker Clarify processing jobs found in region {AWS_REGION_US_EAST_1}."
)
assert result[0].resource_id == "sagemaker-clarify"
def test_non_clarify_processing_job(self):
sagemaker_client = mock.MagicMock
sagemaker_client.sagemaker_processing_jobs = [
ProcessingJob(
name="xgboost-job",
arn=f"arn:aws:sagemaker:{AWS_REGION_US_EAST_1}:{AWS_ACCOUNT_NUMBER}:processing-job/xgboost-job",
region=AWS_REGION_US_EAST_1,
image_uri=NON_CLARIFY_IMAGE_URI,
)
]
sagemaker_client.processing_jobs_scanned_regions = {AWS_REGION_US_EAST_1}
sagemaker_client.audited_partition = "aws"
sagemaker_client.audited_account = AWS_ACCOUNT_NUMBER
aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1])
with (
mock.patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=aws_provider,
),
mock.patch(
"prowler.providers.aws.services.sagemaker.sagemaker_clarify_exists.sagemaker_clarify_exists.sagemaker_client",
sagemaker_client,
),
):
from prowler.providers.aws.services.sagemaker.sagemaker_clarify_exists.sagemaker_clarify_exists import (
sagemaker_clarify_exists,
)
check = sagemaker_clarify_exists()
result = check.execute()
assert len(result) == 1
assert result[0].status == "FAIL"
assert (
result[0].status_extended
== f"No SageMaker Clarify processing jobs found in region {AWS_REGION_US_EAST_1}."
)
def test_custom_image_with_clarify_in_name_does_not_match(self):
sagemaker_client = mock.MagicMock
sagemaker_client.sagemaker_processing_jobs = [
ProcessingJob(
name="my-clarify-thing-job",
arn=f"arn:aws:sagemaker:{AWS_REGION_US_EAST_1}:{AWS_ACCOUNT_NUMBER}:processing-job/my-clarify-thing-job",
region=AWS_REGION_US_EAST_1,
image_uri=CUSTOM_CLARIFY_IMAGE_URI,
)
]
sagemaker_client.processing_jobs_scanned_regions = {AWS_REGION_US_EAST_1}
sagemaker_client.audited_partition = "aws"
sagemaker_client.audited_account = AWS_ACCOUNT_NUMBER
aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1])
with (
mock.patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=aws_provider,
),
mock.patch(
"prowler.providers.aws.services.sagemaker.sagemaker_clarify_exists.sagemaker_clarify_exists.sagemaker_client",
sagemaker_client,
),
):
from prowler.providers.aws.services.sagemaker.sagemaker_clarify_exists.sagemaker_clarify_exists import (
sagemaker_clarify_exists,
)
check = sagemaker_clarify_exists()
result = check.execute()
assert len(result) == 1
assert result[0].status == "FAIL"
assert (
result[0].status_extended
== f"No SageMaker Clarify processing jobs found in region {AWS_REGION_US_EAST_1}."
)
def test_clarify_processing_job_exists(self):
sagemaker_client = mock.MagicMock
sagemaker_client.sagemaker_processing_jobs = [
ProcessingJob(
name="clarify-job",
arn=PROCESSING_JOB_ARN,
region=AWS_REGION_US_EAST_1,
image_uri=CLARIFY_IMAGE_URI,
)
]
sagemaker_client.processing_jobs_scanned_regions = {AWS_REGION_US_EAST_1}
aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1])
with (
mock.patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=aws_provider,
),
mock.patch(
"prowler.providers.aws.services.sagemaker.sagemaker_clarify_exists.sagemaker_clarify_exists.sagemaker_client",
sagemaker_client,
),
):
from prowler.providers.aws.services.sagemaker.sagemaker_clarify_exists.sagemaker_clarify_exists import (
sagemaker_clarify_exists,
)
check = sagemaker_clarify_exists()
result = check.execute()
assert len(result) == 1
assert result[0].status == "PASS"
assert (
result[0].status_extended
== f"SageMaker Clarify processing job clarify-job exists in region {AWS_REGION_US_EAST_1}."
)
assert result[0].resource_id == "clarify-job"
assert result[0].resource_arn == PROCESSING_JOB_ARN
def test_mixed_regions(self):
sagemaker_client = mock.MagicMock
sagemaker_client.sagemaker_processing_jobs = [
ProcessingJob(
name="clarify-job",
arn=PROCESSING_JOB_ARN,
region=AWS_REGION_US_EAST_1,
image_uri=CLARIFY_IMAGE_URI,
)
]
sagemaker_client.processing_jobs_scanned_regions = {
AWS_REGION_US_EAST_1,
AWS_REGION_EU_WEST_1,
}
sagemaker_client.audited_partition = "aws"
sagemaker_client.audited_account = AWS_ACCOUNT_NUMBER
aws_provider = set_mocked_aws_provider(
[AWS_REGION_US_EAST_1, AWS_REGION_EU_WEST_1]
)
with (
mock.patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=aws_provider,
),
mock.patch(
"prowler.providers.aws.services.sagemaker.sagemaker_clarify_exists.sagemaker_clarify_exists.sagemaker_client",
sagemaker_client,
),
):
from prowler.providers.aws.services.sagemaker.sagemaker_clarify_exists.sagemaker_clarify_exists import (
sagemaker_clarify_exists,
)
check = sagemaker_clarify_exists()
result = check.execute()
assert len(result) == 2
results_by_region = {r.region: r for r in result}
us_result = results_by_region[AWS_REGION_US_EAST_1]
assert us_result.status == "PASS"
assert (
us_result.status_extended
== f"SageMaker Clarify processing job clarify-job exists in region {AWS_REGION_US_EAST_1}."
)
eu_result = results_by_region[AWS_REGION_EU_WEST_1]
assert eu_result.status == "FAIL"
assert (
eu_result.status_extended
== f"No SageMaker Clarify processing jobs found in region {AWS_REGION_EU_WEST_1}."
)
@@ -396,13 +396,13 @@ class Test_SageMaker_Service:
sagemaker_service = SageMaker(audit_info)
# Check that __threading_call__ was called for _list_tags_for_resource
# (one for each resource type: models, notebooks, training jobs, processing jobs, endpoint configs, domains)
# (one for each resource type: models, notebooks, training jobs, endpoint configs, domains)
tag_calls = [
c
for c in mock_threading_call.call_args_list
if c[0][0] == sagemaker_service._list_tags_for_resource
]
assert len(tag_calls) == 6
assert len(tag_calls) == 5
# Test SageMaker list model package groups
def test_list_model_package_groups(self):
@@ -1,512 +0,0 @@
from unittest.mock import patch
import botocore
from moto import mock_aws
from tests.providers.aws.utils import (
AWS_ACCOUNT_NUMBER,
AWS_REGION_EU_WEST_1,
set_mocked_aws_provider,
)
orig = botocore.client.BaseClient._make_api_call
HUB_ARN = f"arn:aws:securityhub:{AWS_REGION_EU_WEST_1}:{AWS_ACCOUNT_NUMBER}:hub/default"
def _active_hub_responses(operation_name):
"""Return a moto-friendly response for hub-describing API calls.
Returns None if the operation is not one of the hub APIs (so the caller
can fall back to the default behavior).
"""
if operation_name == "DescribeHub":
return {
"HubArn": HUB_ARN,
"SubscribedAt": "2024-01-01T00:00:00.000Z",
"AutoEnableControls": True,
}
if operation_name == "GetEnabledStandards":
return {"StandardsSubscriptions": []}
if operation_name == "ListEnabledProductsForImport":
return {"ProductSubscriptions": []}
if operation_name == "ListTagsForResource":
return {"Tags": {}}
return None
def mock_make_api_call_org_admin_and_config(self, operation_name, api_params):
"""Mock organization admin accounts and configuration APIs - PASS scenario."""
hub_resp = _active_hub_responses(operation_name)
if hub_resp is not None:
return hub_resp
if operation_name == "ListOrganizationAdminAccounts":
return {
"AdminAccounts": [
{
"AdminAccountId": "123456789012",
"AdminStatus": "ENABLED",
}
]
}
if operation_name == "DescribeOrganizationConfiguration":
return {
"AutoEnable": True,
"AutoEnableStandards": "DEFAULT",
}
return orig(self, operation_name, api_params)
def mock_make_api_call_org_admin_no_auto_enable(self, operation_name, api_params):
"""Mock organization admin configured but auto-enable disabled."""
hub_resp = _active_hub_responses(operation_name)
if hub_resp is not None:
return hub_resp
if operation_name == "ListOrganizationAdminAccounts":
return {
"AdminAccounts": [
{
"AdminAccountId": "123456789012",
"AdminStatus": "ENABLED",
}
]
}
if operation_name == "DescribeOrganizationConfiguration":
return {
"AutoEnable": False,
"AutoEnableStandards": "NONE",
}
return orig(self, operation_name, api_params)
def mock_make_api_call_no_org_admin(self, operation_name, api_params):
"""Mock no organization admin configured."""
hub_resp = _active_hub_responses(operation_name)
if hub_resp is not None:
return hub_resp
if operation_name == "ListOrganizationAdminAccounts":
return {"AdminAccounts": []}
if operation_name == "DescribeOrganizationConfiguration":
return {
"AutoEnable": False,
"AutoEnableStandards": "NONE",
}
return orig(self, operation_name, api_params)
def mock_make_api_call_securityhub_not_subscribed(self, operation_name, api_params):
"""Simulate Security Hub not subscribed in the account (InvalidAccessException)."""
if operation_name == "DescribeHub":
raise botocore.exceptions.ClientError(
{
"Error": {
"Code": "InvalidAccessException",
"Message": "Account is not subscribed to AWS Security Hub",
}
},
operation_name,
)
if operation_name == "ListOrganizationAdminAccounts":
return {"AdminAccounts": []}
return orig(self, operation_name, api_params)
def mock_make_api_call_admin_lookup_access_denied(self, operation_name, api_params):
"""Hub is ACTIVE but ListOrganizationAdminAccounts is denied — lookup-failed path."""
hub_resp = _active_hub_responses(operation_name)
if hub_resp is not None:
return hub_resp
if operation_name == "ListOrganizationAdminAccounts":
raise botocore.exceptions.ClientError(
{
"Error": {
"Code": "AccessDeniedException",
"Message": "User is not authorized to perform: securityhub:ListOrganizationAdminAccounts",
}
},
operation_name,
)
if operation_name == "DescribeOrganizationConfiguration":
return {"AutoEnable": True, "AutoEnableStandards": "DEFAULT"}
return orig(self, operation_name, api_params)
def mock_make_api_call_admin_lookup_unexpected(self, operation_name, api_params):
"""ListOrganizationAdminAccounts raises a non-ClientError — bare Exception branch."""
hub_resp = _active_hub_responses(operation_name)
if hub_resp is not None:
return hub_resp
if operation_name == "ListOrganizationAdminAccounts":
raise RuntimeError("simulated transient error")
if operation_name == "DescribeOrganizationConfiguration":
return {"AutoEnable": True, "AutoEnableStandards": "DEFAULT"}
return orig(self, operation_name, api_params)
def mock_make_api_call_describe_org_config_other_client_error(
self, operation_name, api_params
):
"""DescribeOrganizationConfiguration raises a non-access ClientError — else branch."""
hub_resp = _active_hub_responses(operation_name)
if hub_resp is not None:
return hub_resp
if operation_name == "ListOrganizationAdminAccounts":
return {
"AdminAccounts": [
{"AdminAccountId": "123456789012", "AdminStatus": "ENABLED"}
]
}
if operation_name == "DescribeOrganizationConfiguration":
raise botocore.exceptions.ClientError(
{"Error": {"Code": "InternalServerError", "Message": "boom"}},
operation_name,
)
return orig(self, operation_name, api_params)
def mock_make_api_call_describe_org_config_unexpected(self, operation_name, api_params):
"""DescribeOrganizationConfiguration raises a non-ClientError — bare Exception branch."""
hub_resp = _active_hub_responses(operation_name)
if hub_resp is not None:
return hub_resp
if operation_name == "ListOrganizationAdminAccounts":
return {
"AdminAccounts": [
{"AdminAccountId": "123456789012", "AdminStatus": "ENABLED"}
]
}
if operation_name == "DescribeOrganizationConfiguration":
raise RuntimeError("simulated transient error")
return orig(self, operation_name, api_params)
class Test_securityhub_delegated_admin_enabled_all_regions:
def teardown_method(self):
"""Evict cached securityhub modules so legacy mock.patch-based tests
in the same session see a fresh import path."""
import sys
for mod in (
"prowler.providers.aws.services.securityhub.securityhub_client",
"prowler.providers.aws.services.securityhub.securityhub_delegated_admin_enabled_all_regions.securityhub_delegated_admin_enabled_all_regions",
):
sys.modules.pop(mod, None)
@patch(
"botocore.client.BaseClient._make_api_call",
new=mock_make_api_call_securityhub_not_subscribed,
)
@mock_aws
def test_no_securityhub(self):
"""Test when Security Hub is not subscribed in any region."""
aws_provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1])
from prowler.providers.aws.services.securityhub.securityhub_service import (
SecurityHub,
)
with (
patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=aws_provider,
),
patch(
"prowler.providers.aws.services.securityhub.securityhub_delegated_admin_enabled_all_regions.securityhub_delegated_admin_enabled_all_regions.securityhub_client",
new=SecurityHub(aws_provider),
),
):
from prowler.providers.aws.services.securityhub.securityhub_delegated_admin_enabled_all_regions.securityhub_delegated_admin_enabled_all_regions import (
securityhub_delegated_admin_enabled_all_regions,
)
check = securityhub_delegated_admin_enabled_all_regions()
result = check.execute()
# Should have findings for each region (with NOT_AVAILABLE hubs)
assert len(result) > 0
# All should fail since hub is not enabled
for finding in result:
assert finding.status == "FAIL"
assert "Security Hub not enabled" in finding.status_extended
@patch(
"botocore.client.BaseClient._make_api_call",
new=mock_make_api_call_no_org_admin,
)
@mock_aws
def test_securityhub_enabled_no_delegated_admin(self):
"""Test when Security Hub is enabled but no delegated admin is configured."""
aws_provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1])
from prowler.providers.aws.services.securityhub.securityhub_service import (
SecurityHub,
)
with (
patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=aws_provider,
),
patch(
"prowler.providers.aws.services.securityhub.securityhub_delegated_admin_enabled_all_regions.securityhub_delegated_admin_enabled_all_regions.securityhub_client",
new=SecurityHub(aws_provider),
),
):
from prowler.providers.aws.services.securityhub.securityhub_delegated_admin_enabled_all_regions.securityhub_delegated_admin_enabled_all_regions import (
securityhub_delegated_admin_enabled_all_regions,
)
check = securityhub_delegated_admin_enabled_all_regions()
result = check.execute()
eu_west_1_result = None
for finding in result:
if finding.region == AWS_REGION_EU_WEST_1:
eu_west_1_result = finding
break
assert eu_west_1_result is not None
assert eu_west_1_result.status == "FAIL"
assert (
"no delegated administrator configured"
in eu_west_1_result.status_extended
)
assert eu_west_1_result.resource_arn == HUB_ARN
@patch(
"botocore.client.BaseClient._make_api_call",
new=mock_make_api_call_org_admin_no_auto_enable,
)
@mock_aws
def test_securityhub_enabled_with_admin_no_auto_enable(self):
"""Test when Security Hub is enabled with delegated admin but auto-enable is off."""
aws_provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1])
from prowler.providers.aws.services.securityhub.securityhub_service import (
SecurityHub,
)
with (
patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=aws_provider,
),
patch(
"prowler.providers.aws.services.securityhub.securityhub_delegated_admin_enabled_all_regions.securityhub_delegated_admin_enabled_all_regions.securityhub_client",
new=SecurityHub(aws_provider),
),
):
from prowler.providers.aws.services.securityhub.securityhub_delegated_admin_enabled_all_regions.securityhub_delegated_admin_enabled_all_regions import (
securityhub_delegated_admin_enabled_all_regions,
)
check = securityhub_delegated_admin_enabled_all_regions()
result = check.execute()
eu_west_1_result = None
for finding in result:
if finding.region == AWS_REGION_EU_WEST_1:
eu_west_1_result = finding
break
assert eu_west_1_result is not None
assert eu_west_1_result.status == "FAIL"
assert (
"organization auto-enable not configured"
in eu_west_1_result.status_extended
)
@patch(
"botocore.client.BaseClient._make_api_call",
new=mock_make_api_call_org_admin_and_config,
)
@mock_aws
def test_securityhub_enabled_with_admin_and_auto_enable(self):
"""Test when Security Hub is enabled with delegated admin and auto-enable on (PASS)."""
aws_provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1])
from prowler.providers.aws.services.securityhub.securityhub_service import (
SecurityHub,
)
with (
patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=aws_provider,
),
patch(
"prowler.providers.aws.services.securityhub.securityhub_delegated_admin_enabled_all_regions.securityhub_delegated_admin_enabled_all_regions.securityhub_client",
new=SecurityHub(aws_provider),
),
):
from prowler.providers.aws.services.securityhub.securityhub_delegated_admin_enabled_all_regions.securityhub_delegated_admin_enabled_all_regions import (
securityhub_delegated_admin_enabled_all_regions,
)
check = securityhub_delegated_admin_enabled_all_regions()
result = check.execute()
eu_west_1_result = None
for finding in result:
if finding.region == AWS_REGION_EU_WEST_1:
eu_west_1_result = finding
break
assert eu_west_1_result is not None
assert eu_west_1_result.status == "PASS"
assert "delegated admin configured" in eu_west_1_result.status_extended
assert "auto-enable" in eu_west_1_result.status_extended
assert eu_west_1_result.resource_arn == HUB_ARN
@patch(
"botocore.client.BaseClient._make_api_call",
new=mock_make_api_call_admin_lookup_access_denied,
)
@mock_aws
def test_admin_lookup_access_denied(self):
"""AccessDenied on ListOrganizationAdminAccounts must FAIL with unknown-admin message."""
aws_provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1])
from prowler.providers.aws.services.securityhub.securityhub_service import (
SecurityHub,
)
with (
patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=aws_provider,
),
patch(
"prowler.providers.aws.services.securityhub.securityhub_delegated_admin_enabled_all_regions.securityhub_delegated_admin_enabled_all_regions.securityhub_client",
new=SecurityHub(aws_provider),
),
):
from prowler.providers.aws.services.securityhub.securityhub_delegated_admin_enabled_all_regions.securityhub_delegated_admin_enabled_all_regions import (
securityhub_delegated_admin_enabled_all_regions,
)
check = securityhub_delegated_admin_enabled_all_regions()
result = check.execute()
eu_west_1_result = None
for finding in result:
if finding.region == AWS_REGION_EU_WEST_1:
eu_west_1_result = finding
break
assert eu_west_1_result is not None
assert eu_west_1_result.status == "FAIL"
assert (
"delegated administrator status could not be determined"
in eu_west_1_result.status_extended
)
assert (
"no delegated administrator configured"
not in eu_west_1_result.status_extended
)
@patch(
"botocore.client.BaseClient._make_api_call",
new=mock_make_api_call_admin_lookup_unexpected,
)
@mock_aws
def test_admin_lookup_unexpected_exception(self):
"""Non-ClientError raised from ListOrganizationAdminAccounts still sets lookup_failed."""
aws_provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1])
from prowler.providers.aws.services.securityhub.securityhub_service import (
SecurityHub,
)
service = SecurityHub(aws_provider)
assert service.organization_admin_lookup_failed is True
with (
patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=aws_provider,
),
patch(
"prowler.providers.aws.services.securityhub.securityhub_delegated_admin_enabled_all_regions.securityhub_delegated_admin_enabled_all_regions.securityhub_client",
new=service,
),
):
from prowler.providers.aws.services.securityhub.securityhub_delegated_admin_enabled_all_regions.securityhub_delegated_admin_enabled_all_regions import (
securityhub_delegated_admin_enabled_all_regions,
)
result = securityhub_delegated_admin_enabled_all_regions().execute()
assert result and result[0].status == "FAIL"
assert (
"delegated administrator status could not be determined"
in result[0].status_extended
)
@patch(
"botocore.client.BaseClient._make_api_call",
new=mock_make_api_call_describe_org_config_other_client_error,
)
@mock_aws
def test_describe_org_config_other_client_error(self):
"""Non-access ClientError on DescribeOrganizationConfiguration is logged at error level."""
aws_provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1])
from prowler.providers.aws.services.securityhub.securityhub_service import (
SecurityHub,
)
service = SecurityHub(aws_provider)
# organization_config_available stays False, so the auto-enable issue is suppressed
assert service.securityhubs[0].organization_config_available is False
with (
patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=aws_provider,
),
patch(
"prowler.providers.aws.services.securityhub.securityhub_delegated_admin_enabled_all_regions.securityhub_delegated_admin_enabled_all_regions.securityhub_client",
new=service,
),
):
from prowler.providers.aws.services.securityhub.securityhub_delegated_admin_enabled_all_regions.securityhub_delegated_admin_enabled_all_regions import (
securityhub_delegated_admin_enabled_all_regions,
)
result = securityhub_delegated_admin_enabled_all_regions().execute()
# Admin is configured and hub is active; with org config unavailable the
# check should PASS because there are no other detectable issues.
assert result and result[0].status == "PASS"
@patch(
"botocore.client.BaseClient._make_api_call",
new=mock_make_api_call_describe_org_config_unexpected,
)
@mock_aws
def test_describe_org_config_unexpected_exception(self):
"""Non-ClientError on DescribeOrganizationConfiguration is caught by bare except."""
aws_provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1])
from prowler.providers.aws.services.securityhub.securityhub_service import (
SecurityHub,
)
service = SecurityHub(aws_provider)
assert service.securityhubs[0].organization_config_available is False
with (
patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=aws_provider,
),
patch(
"prowler.providers.aws.services.securityhub.securityhub_delegated_admin_enabled_all_regions.securityhub_delegated_admin_enabled_all_regions.securityhub_client",
new=service,
),
):
from prowler.providers.aws.services.securityhub.securityhub_delegated_admin_enabled_all_regions.securityhub_delegated_admin_enabled_all_regions import (
securityhub_delegated_admin_enabled_all_regions,
)
result = securityhub_delegated_admin_enabled_all_regions().execute()
assert result and result[0].status == "PASS"
@@ -1,115 +0,0 @@
from unittest import mock
from tests.providers.azure.azure_fixtures import (
AZURE_SUBSCRIPTION_ID,
set_mocked_azure_provider,
)
class Test_cosmosdb_account_automatic_failover_enabled:
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_automatic_failover_enabled.cosmosdb_account_automatic_failover_enabled.cosmosdb_client",
new=cosmosdb_client,
),
):
from prowler.providers.azure.services.cosmosdb.cosmosdb_account_automatic_failover_enabled.cosmosdb_account_automatic_failover_enabled import (
cosmosdb_account_automatic_failover_enabled,
)
cosmosdb_client.accounts = {}
check = cosmosdb_account_automatic_failover_enabled()
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_automatic_failover_enabled.cosmosdb_account_automatic_failover_enabled.cosmosdb_client",
new=cosmosdb_client,
),
):
from prowler.providers.azure.services.cosmosdb.cosmosdb_account_automatic_failover_enabled.cosmosdb_account_automatic_failover_enabled import (
cosmosdb_account_automatic_failover_enabled,
)
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,
enable_automatic_failover=True,
)
]
}
check = cosmosdb_account_automatic_failover_enabled()
result = check.execute()
assert len(result) == 1
assert result[0].status == "PASS"
def test_fail(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_automatic_failover_enabled.cosmosdb_account_automatic_failover_enabled.cosmosdb_client",
new=cosmosdb_client,
),
):
from prowler.providers.azure.services.cosmosdb.cosmosdb_account_automatic_failover_enabled.cosmosdb_account_automatic_failover_enabled import (
cosmosdb_account_automatic_failover_enabled,
)
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,
enable_automatic_failover=False,
)
]
}
check = cosmosdb_account_automatic_failover_enabled()
result = check.execute()
assert len(result) == 1
assert result[0].status == "FAIL"
@@ -1,157 +0,0 @@
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"
+297 -11
View File
@@ -417,17 +417,19 @@ class TestIsBuiltinProvider:
class TestInitProvidersParserBuiltinDependencyFailure:
"""Tests the critical behavior fix: when a built-in provider's arguments
module exists but its imports fail (e.g. boto3 not installed), we must
fail loudly with a clear message not silently fall through to entry
points as if the provider were external."""
"""Selective fail-loud: init captures failures silently, enforce emits
warning for non-invoked and exits for the invoked broken provider."""
@patch("sys.argv", ["prowler", "aws"])
@patch("prowler.providers.common.arguments.Provider.is_builtin")
@patch("prowler.providers.common.arguments.import_module")
def test_builtin_with_missing_transitive_dep_fails_loudly(
self, mock_import, mock_is_builtin
):
from prowler.providers.common.arguments import init_providers_parser
from prowler.providers.common.arguments import (
enforce_invoked_provider_loaded,
init_providers_parser,
)
mock_is_builtin.return_value = True
mock_import.side_effect = ImportError("No module named 'boto3'")
@@ -435,14 +437,14 @@ class TestInitProvidersParserBuiltinDependencyFailure:
parser = MagicMock()
parser._providers = ["aws"]
with (
patch(
"prowler.providers.common.arguments.Provider.get_available_providers",
return_value=["aws"],
),
pytest.raises(SystemExit),
with patch(
"prowler.providers.common.arguments.Provider.get_available_providers",
return_value=["aws"],
):
init_providers_parser(parser)
assert "aws" in parser._builtin_load_failures
with pytest.raises(SystemExit):
enforce_invoked_provider_loaded(parser)
@patch("prowler.providers.common.arguments.Provider.is_builtin")
@patch("prowler.providers.common.arguments.Provider._load_ep_provider")
@@ -466,6 +468,290 @@ class TestInitProvidersParserBuiltinDependencyFailure:
ext_cls.init_parser.assert_called_once_with(parser)
@patch("sys.argv", ["prowler", "aws"])
@patch("prowler.providers.common.arguments.Provider.is_builtin")
@patch("prowler.providers.common.arguments.import_module")
def test_unrelated_builtin_failure_does_not_abort_when_other_provider_invoked(
self, mock_import, mock_is_builtin
):
"""Broken stackit + invoked aws → warning, no abort."""
from prowler.providers.common.arguments import (
enforce_invoked_provider_loaded,
init_providers_parser,
)
mock_is_builtin.return_value = True
aws_module = MagicMock()
def import_side_effect(module_path):
if "stackit" in module_path:
raise ImportError("No module named 'stackit.objectstorage'")
return aws_module
mock_import.side_effect = import_side_effect
parser = MagicMock()
with patch(
"prowler.providers.common.arguments.Provider.get_available_providers",
return_value=["aws", "stackit"],
):
init_providers_parser(parser)
assert "stackit" in parser._builtin_load_failures
enforce_invoked_provider_loaded(parser)
aws_module.init_parser.assert_called_once_with(parser)
@patch("sys.argv", ["prowler", "-h"])
@patch("prowler.providers.common.arguments.Provider.is_builtin")
@patch("prowler.providers.common.arguments.import_module")
def test_no_provider_invoked_failure_does_not_abort(
self, mock_import, mock_is_builtin
):
"""`prowler -h` + broken built-in → warning, help still renders."""
from prowler.providers.common.arguments import (
enforce_invoked_provider_loaded,
init_providers_parser,
)
mock_is_builtin.return_value = True
mock_import.side_effect = ImportError("No module named 'stackit.objectstorage'")
parser = MagicMock()
with patch(
"prowler.providers.common.arguments.Provider.get_available_providers",
return_value=["stackit"],
):
init_providers_parser(parser)
enforce_invoked_provider_loaded(parser)
@patch("sys.argv", ["prowler", "microsoft365"])
@patch("prowler.providers.common.arguments.Provider.is_builtin")
@patch("prowler.providers.common.arguments.import_module")
def test_invoked_microsoft365_alias_still_triggers_fail_loud(
self, mock_import, mock_is_builtin
):
"""Alias `microsoft365 → m365` must be normalised before matching."""
from prowler.providers.common.arguments import (
enforce_invoked_provider_loaded,
init_providers_parser,
)
mock_is_builtin.return_value = True
mock_import.side_effect = ImportError("No module named 'msgraph'")
parser = MagicMock()
with patch(
"prowler.providers.common.arguments.Provider.get_available_providers",
return_value=["m365"],
):
init_providers_parser(parser)
with pytest.raises(SystemExit):
enforce_invoked_provider_loaded(parser)
@patch("sys.argv", ["prowler", "oci"])
@patch("prowler.providers.common.arguments.Provider.is_builtin")
@patch("prowler.providers.common.arguments.import_module")
def test_invoked_oci_alias_still_triggers_fail_loud(
self, mock_import, mock_is_builtin
):
"""Alias `oci → oraclecloud` must be normalised before matching."""
from prowler.providers.common.arguments import (
enforce_invoked_provider_loaded,
init_providers_parser,
)
mock_is_builtin.return_value = True
mock_import.side_effect = ImportError("No module named 'oci'")
parser = MagicMock()
with patch(
"prowler.providers.common.arguments.Provider.get_available_providers",
return_value=["oraclecloud"],
):
init_providers_parser(parser)
with pytest.raises(SystemExit):
enforce_invoked_provider_loaded(parser)
@patch("sys.argv", ["prowler", "--output-directory", "stackit"])
@patch("prowler.providers.common.arguments.Provider.is_builtin")
@patch("prowler.providers.common.arguments.import_module")
def test_flag_value_matching_provider_name_not_treated_as_invoked(
self, mock_import, mock_is_builtin
):
"""Flag-first invocation → invoked is 'aws' (default), not the flag's value."""
from prowler.providers.common.arguments import (
enforce_invoked_provider_loaded,
init_providers_parser,
)
mock_is_builtin.return_value = True
aws_module = MagicMock()
def import_side_effect(module_path):
if "stackit" in module_path:
raise ImportError("No module named 'stackit.objectstorage'")
return aws_module
mock_import.side_effect = import_side_effect
parser = MagicMock()
with patch(
"prowler.providers.common.arguments.Provider.get_available_providers",
return_value=["aws", "stackit"],
):
init_providers_parser(parser)
enforce_invoked_provider_loaded(parser)
aws_module.init_parser.assert_called_once_with(parser)
@patch("sys.argv", ["prowler", "aws"])
@patch("prowler.providers.common.arguments.Provider.is_builtin")
@patch("prowler.providers.common.arguments.import_module")
def test_invoked_builtin_non_import_error_fails_loudly(
self, mock_import, mock_is_builtin
):
"""Non-ImportError in invoked provider → still fail-loud."""
from prowler.providers.common.arguments import (
enforce_invoked_provider_loaded,
init_providers_parser,
)
mock_is_builtin.return_value = True
mock_import.side_effect = RuntimeError("Unexpected error in aws init_parser")
parser = MagicMock()
with patch(
"prowler.providers.common.arguments.Provider.get_available_providers",
return_value=["aws"],
):
init_providers_parser(parser)
with pytest.raises(SystemExit):
enforce_invoked_provider_loaded(parser)
@patch("sys.argv", ["prowler", "aws"])
@patch("prowler.providers.common.arguments.Provider.is_builtin")
@patch("prowler.providers.common.arguments.import_module")
def test_unrelated_builtin_non_import_error_does_not_abort(
self, mock_import, mock_is_builtin
):
"""Non-ImportError in unrelated provider → warning, no abort."""
from prowler.providers.common.arguments import (
enforce_invoked_provider_loaded,
init_providers_parser,
)
mock_is_builtin.return_value = True
aws_module = MagicMock()
def import_side_effect(module_path):
if "stackit" in module_path:
raise RuntimeError("Unexpected error in stackit init_parser")
return aws_module
mock_import.side_effect = import_side_effect
parser = MagicMock()
with patch(
"prowler.providers.common.arguments.Provider.get_available_providers",
return_value=["aws", "stackit"],
):
init_providers_parser(parser)
enforce_invoked_provider_loaded(parser)
aws_module.init_parser.assert_called_once_with(parser)
class TestParseArgsOverrideAlignment:
"""Regression: `parse(args=...)` overrides sys.argv AFTER __init__ ran;
the selective fail-loud must read argv at enforce time, not init time."""
def test_enforce_reads_current_sys_argv_not_init_time_sys_argv(self):
"""Init with argv=['prowler','-h'] (no provider) captures stackit
failure silently. Enforce with argv=['prowler','stackit'] must
fail-loud proving alignment under parse(args=...)."""
from prowler.providers.common.arguments import (
enforce_invoked_provider_loaded,
init_providers_parser,
)
def import_side_effect(path):
if "stackit" in path:
raise ImportError("No module named 'stackit.objectstorage'")
return MagicMock()
parser = MagicMock()
with (
patch(
"prowler.providers.common.arguments.Provider.is_builtin",
return_value=True,
),
patch(
"prowler.providers.common.arguments.Provider.get_available_providers",
return_value=["aws", "stackit"],
),
patch(
"prowler.providers.common.arguments.import_module",
side_effect=import_side_effect,
),
):
# Phase 1: __init__ with ambient argv = ['prowler', '-h']
with patch("sys.argv", ["prowler", "-h"]):
init_providers_parser(parser)
# Failure captured silently — no SystemExit during init
assert "stackit" in parser._builtin_load_failures
# Phase 2: parse(args=...) overrode sys.argv → stackit invoked
with patch("sys.argv", ["prowler", "stackit"]):
with pytest.raises(SystemExit):
enforce_invoked_provider_loaded(parser)
def test_enforce_reads_current_sys_argv_for_no_invocation(self):
"""Inverse: init's argv invokes stackit, but parse(args=['prowler',
'-h']) overrides. Enforce must NOT fail-loud."""
from prowler.providers.common.arguments import (
enforce_invoked_provider_loaded,
init_providers_parser,
)
def import_side_effect(path):
if "stackit" in path:
raise ImportError("No module named 'stackit.objectstorage'")
return MagicMock()
parser = MagicMock()
with (
patch(
"prowler.providers.common.arguments.Provider.is_builtin",
return_value=True,
),
patch(
"prowler.providers.common.arguments.Provider.get_available_providers",
return_value=["aws", "stackit"],
),
patch(
"prowler.providers.common.arguments.import_module",
side_effect=import_side_effect,
),
):
# Phase 1: __init__ with ambient argv pretending stackit invoked
with patch("sys.argv", ["prowler", "stackit"]):
init_providers_parser(parser)
assert "stackit" in parser._builtin_load_failures
# Phase 2: parse(args=['prowler', '-h']) overrode sys.argv →
# no provider invoked anymore → enforce must NOT exit
with patch("sys.argv", ["prowler", "-h"]):
enforce_invoked_provider_loaded(parser)
class TestInitGlobalProviderBuiltinDependencyFailure:
"""Same contract as TestInitProvidersParserBuiltinDependencyFailure but
-2
View File
@@ -722,7 +722,6 @@ def mock_api_instances_calls(client: MagicMock, service: str):
},
"backupConfiguration": {"enabled": True},
"databaseFlags": [],
"availabilityType": "REGIONAL",
},
},
{
@@ -738,7 +737,6 @@ def mock_api_instances_calls(client: MagicMock, service: str):
},
"backupConfiguration": {"enabled": False},
"databaseFlags": [],
"availabilityType": "ZONAL",
},
},
]
+64
View File
@@ -13,6 +13,7 @@ from prowler.config.config import (
)
from prowler.providers.common.models import Connection
from prowler.providers.gcp.exceptions.exceptions import (
GCPGetOrganizationProjectsError,
GCPInvalidProviderIdError,
GCPNoAccesibleProjectsError,
GCPTestConnectionError,
@@ -1077,3 +1078,66 @@ class TestGCPProvider:
assert gcp_provider.skip_api_check is True
mocked_is_api_active.assert_not_called()
def test_get_projects_organization_id_permission_denied_raises(self):
"""When --organization-id is set and the Cloud Asset API returns a 403,
get_projects must raise GCPGetOrganizationProjectsError instead of
silently falling back to the service account's home project.
Regression test for https://github.com/prowler-cloud/prowler/issues/11250.
"""
from googleapiclient.errors import HttpError
forbidden_response = MagicMock(status=403, reason="Forbidden")
http_error = HttpError(
resp=forbidden_response,
content=b'{"error": {"code": 403, "message": "Permission denied on resource organization"}}',
uri="https://cloudasset.googleapis.com/v1/organizations/123:listAssets",
)
asset_service = MagicMock()
asset_service.assets.return_value.list.return_value.execute.side_effect = (
http_error
)
with patch(
"prowler.providers.gcp.gcp_provider.discovery.build",
return_value=asset_service,
):
with pytest.raises(GCPGetOrganizationProjectsError):
GcpProvider.get_projects(
credentials=MagicMock(),
organization_id="test-organization-id",
credentials_file="test_credentials_file",
)
def test_get_projects_organization_id_cloud_asset_api_disabled_raises(self):
"""When --organization-id is set and the Cloud Asset API is disabled,
get_projects must raise GCPGetOrganizationProjectsError with the
enable-API remediation rather than swallowing the error."""
from googleapiclient.errors import HttpError
disabled_response = MagicMock(status=403, reason="Forbidden")
http_error = HttpError(
resp=disabled_response,
content=b'{"error": {"message": "Cloud Asset API has not been used in project 123 before or it is disabled."}}',
uri="https://cloudasset.googleapis.com/v1/organizations/123:listAssets",
)
asset_service = MagicMock()
asset_service.assets.return_value.list.return_value.execute.side_effect = (
http_error
)
with patch(
"prowler.providers.gcp.gcp_provider.discovery.build",
return_value=asset_service,
):
with pytest.raises(GCPGetOrganizationProjectsError) as exc_info:
GcpProvider.get_projects(
credentials=MagicMock(),
organization_id="test-organization-id",
credentials_file="test_credentials_file",
)
assert "Cloud Asset API" in str(exc_info.value)
@@ -1,205 +0,0 @@
from unittest import mock
from tests.providers.gcp.gcp_fixtures import (
GCP_EU1_LOCATION,
GCP_PROJECT_ID,
set_mocked_gcp_provider,
)
class Test_cloudsql_instance_high_availability_enabled:
"""Tests for the cloudsql_instance_high_availability_enabled check."""
def test_no_instances(self):
"""No Cloud SQL instances → no findings."""
cloudsql_client = mock.MagicMock()
with (
mock.patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=set_mocked_gcp_provider(),
),
mock.patch(
"prowler.providers.gcp.services.cloudsql.cloudsql_instance_high_availability_enabled.cloudsql_instance_high_availability_enabled.cloudsql_client",
new=cloudsql_client,
),
):
from prowler.providers.gcp.services.cloudsql.cloudsql_instance_high_availability_enabled.cloudsql_instance_high_availability_enabled import (
cloudsql_instance_high_availability_enabled,
)
cloudsql_client.instances = []
check = cloudsql_instance_high_availability_enabled()
result = check.execute()
assert len(result) == 0
def test_instance_ha_enabled(self):
"""A REGIONAL primary instance → PASS."""
cloudsql_client = mock.MagicMock()
with (
mock.patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=set_mocked_gcp_provider(),
),
mock.patch(
"prowler.providers.gcp.services.cloudsql.cloudsql_instance_high_availability_enabled.cloudsql_instance_high_availability_enabled.cloudsql_client",
new=cloudsql_client,
),
):
from prowler.providers.gcp.services.cloudsql.cloudsql_instance_high_availability_enabled.cloudsql_instance_high_availability_enabled import (
cloudsql_instance_high_availability_enabled,
)
from prowler.providers.gcp.services.cloudsql.cloudsql_service import (
Instance,
)
cloudsql_client.instances = [
Instance(
name="db-ha",
version="POSTGRES_15",
ip_addresses=[],
region=GCP_EU1_LOCATION,
public_ip=False,
require_ssl=False,
ssl_mode="ENCRYPTED_ONLY",
automated_backups=True,
authorized_networks=[],
flags=[],
project_id=GCP_PROJECT_ID,
availability_type="REGIONAL",
)
]
check = cloudsql_instance_high_availability_enabled()
result = check.execute()
assert len(result) == 1
assert result[0].status == "PASS"
assert result[0].resource_id == "db-ha"
assert result[0].location == GCP_EU1_LOCATION
assert result[0].project_id == GCP_PROJECT_ID
def test_instance_ha_disabled(self):
"""A ZONAL primary instance → FAIL with current availability in status_extended."""
cloudsql_client = mock.MagicMock()
with (
mock.patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=set_mocked_gcp_provider(),
),
mock.patch(
"prowler.providers.gcp.services.cloudsql.cloudsql_instance_high_availability_enabled.cloudsql_instance_high_availability_enabled.cloudsql_client",
new=cloudsql_client,
),
):
from prowler.providers.gcp.services.cloudsql.cloudsql_instance_high_availability_enabled.cloudsql_instance_high_availability_enabled import (
cloudsql_instance_high_availability_enabled,
)
from prowler.providers.gcp.services.cloudsql.cloudsql_service import (
Instance,
)
cloudsql_client.instances = [
Instance(
name="db-zonal",
version="POSTGRES_15",
ip_addresses=[],
region=GCP_EU1_LOCATION,
public_ip=False,
require_ssl=False,
ssl_mode="ENCRYPTED_ONLY",
automated_backups=True,
authorized_networks=[],
flags=[],
project_id=GCP_PROJECT_ID,
availability_type="ZONAL",
)
]
check = cloudsql_instance_high_availability_enabled()
result = check.execute()
assert len(result) == 1
assert result[0].status == "FAIL"
assert "ZONAL" in result[0].status_extended
assert result[0].resource_id == "db-zonal"
assert result[0].location == GCP_EU1_LOCATION
assert result[0].project_id == GCP_PROJECT_ID
def test_read_replica_skipped(self):
"""Read replicas (instance_type != CLOUD_SQL_INSTANCE) are skipped."""
cloudsql_client = mock.MagicMock()
with (
mock.patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=set_mocked_gcp_provider(),
),
mock.patch(
"prowler.providers.gcp.services.cloudsql.cloudsql_instance_high_availability_enabled.cloudsql_instance_high_availability_enabled.cloudsql_client",
new=cloudsql_client,
),
):
from prowler.providers.gcp.services.cloudsql.cloudsql_instance_high_availability_enabled.cloudsql_instance_high_availability_enabled import (
cloudsql_instance_high_availability_enabled,
)
from prowler.providers.gcp.services.cloudsql.cloudsql_service import (
Instance,
)
cloudsql_client.instances = [
Instance(
name="db-replica",
version="POSTGRES_15",
ip_addresses=[],
region=GCP_EU1_LOCATION,
public_ip=False,
require_ssl=False,
ssl_mode="ENCRYPTED_ONLY",
automated_backups=True,
authorized_networks=[],
flags=[],
project_id=GCP_PROJECT_ID,
availability_type="ZONAL",
instance_type="READ_REPLICA_INSTANCE",
)
]
check = cloudsql_instance_high_availability_enabled()
result = check.execute()
assert len(result) == 0
def test_instance_default_availability_type_fails(self):
"""An instance missing availabilityType defaults to ZONAL (service layer) and must FAIL."""
cloudsql_client = mock.MagicMock()
with (
mock.patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=set_mocked_gcp_provider(),
),
mock.patch(
"prowler.providers.gcp.services.cloudsql.cloudsql_instance_high_availability_enabled.cloudsql_instance_high_availability_enabled.cloudsql_client",
new=cloudsql_client,
),
):
from prowler.providers.gcp.services.cloudsql.cloudsql_instance_high_availability_enabled.cloudsql_instance_high_availability_enabled import (
cloudsql_instance_high_availability_enabled,
)
from prowler.providers.gcp.services.cloudsql.cloudsql_service import (
Instance,
)
cloudsql_client.instances = [
Instance(
name="db-default",
version="POSTGRES_15",
ip_addresses=[],
region=GCP_EU1_LOCATION,
public_ip=False,
require_ssl=False,
ssl_mode="ENCRYPTED_ONLY",
automated_backups=True,
authorized_networks=[],
flags=[],
project_id=GCP_PROJECT_ID,
# availability_type omitted → model default "ZONAL"
)
]
check = cloudsql_instance_high_availability_enabled()
result = check.execute()
assert len(result) == 1
assert result[0].status == "FAIL"
assert "ZONAL" in result[0].status_extended
@@ -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"
@@ -1,326 +0,0 @@
from datetime import datetime
from unittest import mock
import pytest
from prowler.lib.check.models import Check_Report_OCI
from prowler.providers.oraclecloud.services.identity.identity_service import Policy
from tests.providers.oraclecloud.oci_fixtures import (
OCI_COMPARTMENT_ID,
OCI_REGION,
OCI_TENANCY_ID,
set_mocked_oraclecloud_provider,
)
CHECK_PATH = "prowler.providers.oraclecloud.services.identity.identity_storage_service_level_admins_scoped.identity_storage_service_level_admins_scoped"
def _policy(
name: str, statements: list[str], lifecycle_state: str = "ACTIVE"
) -> Policy:
return Policy(
id=f"ocid1.policy.oc1..{name.lower().replace(' ', '-')}",
name=name,
description="Test policy",
compartment_id=OCI_COMPARTMENT_ID,
statements=statements,
time_created=datetime.now(),
lifecycle_state=lifecycle_state,
region=OCI_REGION,
)
def _identity_client(policies: list[Policy]) -> mock.MagicMock:
identity_client = mock.MagicMock()
identity_client.policies = policies
identity_client.audited_tenancy = OCI_TENANCY_ID
identity_client.audited_regions = [mock.MagicMock(key=OCI_REGION)]
return identity_client
def _run_check(policies: list[Policy]) -> list[Check_Report_OCI]:
identity_client = _identity_client(policies)
with (
mock.patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=set_mocked_oraclecloud_provider(),
),
mock.patch(f"{CHECK_PATH}.identity_client", new=identity_client),
):
from prowler.providers.oraclecloud.services.identity.identity_storage_service_level_admins_scoped.identity_storage_service_level_admins_scoped import (
identity_storage_service_level_admins_scoped,
)
return identity_storage_service_level_admins_scoped().execute()
class Test_identity_storage_service_level_admins_scoped:
def test_no_policies_passes_with_tenancy_finding(self):
result = _run_check([])
assert len(result) == 1
assert result[0].status == "PASS"
assert result[0].resource_id == OCI_TENANCY_ID
assert result[0].resource_name == "Tenancy"
assert (
result[0].status_extended
== "No active storage service-level administrator policies grant manage permissions without excluding delete permissions."
)
def test_manage_volumes_without_delete_exclusion_fails(self):
result = _run_check(
[
_policy(
"Volume Admins",
["Allow group VolumeUsers to manage volumes in tenancy"],
)
]
)
assert len(result) == 1
assert result[0].status == "FAIL"
assert result[0].resource_name == "Volume Admins"
assert "VOLUME_DELETE" in result[0].status_extended
assert (
"Allow group VolumeUsers to manage volumes in tenancy"
in result[0].status_extended
)
def test_manage_volumes_with_delete_exclusion_passes(self):
result = _run_check(
[
_policy(
"Volume Admins",
[
"Allow group VolumeUsers to manage volumes in tenancy where request.permission!='VOLUME_DELETE'"
],
)
]
)
assert len(result) == 1
assert result[0].status == "PASS"
assert (
result[0].status_extended
== "Policy 'Volume Admins' excludes required storage delete permissions from storage manage statements."
)
def test_delete_exclusion_parser_is_case_and_whitespace_insensitive(self):
result = _run_check(
[
_policy(
"Volume Admins",
[
" allow group VolumeUsers TO manage volumes in tenancy WHERE request.permission != 'volume_delete' "
],
)
]
)
assert len(result) == 1
assert result[0].status == "PASS"
def test_generic_where_clause_does_not_pass(self):
result = _run_check(
[
_policy(
"Bucket Admins",
[
"Allow group BucketUsers to manage buckets in tenancy where request.region='iad'"
],
)
]
)
assert len(result) == 1
assert result[0].status == "FAIL"
assert "BUCKET_DELETE" in result[0].status_extended
assert "request.region='iad'" in result[0].status_extended
@pytest.mark.parametrize(
"statement",
[
"Allow group BucketUsers to manage buckets in tenancy where ANY {request.permission!='BUCKET_DELETE', request.region='iad'}",
"Allow group BucketUsers to manage buckets in tenancy where request.permission!='BUCKET_DELETE' OR request.region='iad'",
],
)
def test_disjunctive_delete_exclusion_does_not_pass(self, statement):
result = _run_check([_policy("Bucket Admins", [statement])])
assert len(result) == 1
assert result[0].status == "FAIL"
assert "BUCKET_DELETE" in result[0].status_extended
def test_quoted_literals_do_not_make_delete_exclusion_disjunctive(self):
result = _run_check(
[
_policy(
"Bucket Admins",
[
"Allow group BucketUsers to manage buckets in tenancy where request.permission!='BUCKET_DELETE' and target.tag.namespace='any-tag'"
],
)
]
)
assert len(result) == 1
assert result[0].status == "PASS"
@pytest.mark.parametrize(
"resource,permission",
[
("file-systems", "FILE_SYSTEM_DELETE"),
("mount-targets", "MOUNT_TARGET_DELETE"),
("export-sets", "EXPORT_SET_DELETE"),
("volumes", "VOLUME_DELETE"),
("volume-backups", "VOLUME_BACKUP_DELETE"),
("objects", "OBJECT_DELETE"),
("buckets", "BUCKET_DELETE"),
],
)
def test_storage_resources_require_matching_delete_exclusion(
self, resource, permission
):
fail_result = _run_check(
[
_policy(
"Storage Admins",
[f"Allow group StorageUsers to manage {resource} in tenancy"],
)
]
)
pass_result = _run_check(
[
_policy(
"Storage Admins",
[
f"Allow group StorageUsers to manage {resource} in tenancy where request.permission != '{permission}'"
],
)
]
)
assert len(fail_result) == 1
assert fail_result[0].status == "FAIL"
assert permission in fail_result[0].status_extended
assert len(pass_result) == 1
assert pass_result[0].status == "PASS"
def test_file_family_fails_until_all_delete_permissions_are_excluded(self):
partial_result = _run_check(
[
_policy(
"File Admins",
[
"Allow group FileUsers to manage file-family in tenancy where ALL {request.permission!='FILE_SYSTEM_DELETE', request.permission!='MOUNT_TARGET_DELETE'}"
],
)
]
)
complete_result = _run_check(
[
_policy(
"File Admins",
[
"Allow group FileUsers to manage file-family in tenancy where ALL {request.permission!='FILE_SYSTEM_DELETE', request.permission!='MOUNT_TARGET_DELETE', request.permission!='EXPORT_SET_DELETE'}"
],
)
]
)
assert len(partial_result) == 1
assert partial_result[0].status == "FAIL"
assert "EXPORT_SET_DELETE" in partial_result[0].status_extended
assert len(complete_result) == 1
assert complete_result[0].status == "PASS"
@pytest.mark.parametrize(
"family,missing_permission,statement",
[
(
"volume-family",
"VOLUME_BACKUP_DELETE",
"Allow group VolumeUsers to manage volume-family in tenancy where request.permission!='VOLUME_DELETE'",
),
(
"object-family",
"BUCKET_DELETE",
"Allow group BucketUsers to manage object-family in tenancy where request.permission!='OBJECT_DELETE'",
),
(
"all-resources",
"BUCKET_DELETE",
"Allow group StorageUsers to manage all-resources in tenancy where ALL {request.permission!='VOLUME_DELETE', request.permission!='VOLUME_BACKUP_DELETE', request.permission!='FILE_SYSTEM_DELETE', request.permission!='MOUNT_TARGET_DELETE', request.permission!='EXPORT_SET_DELETE', request.permission!='OBJECT_DELETE'}",
),
],
)
def test_families_and_all_resources_fail_unless_all_delete_permissions_are_excluded(
self, family, missing_permission, statement
):
result = _run_check([_policy("Storage Admins", [statement])])
assert len(result) == 1
assert result[0].status == "FAIL"
assert family in result[0].status_extended
assert missing_permission in result[0].status_extended
def test_all_resources_passes_when_all_storage_delete_permissions_are_excluded(
self,
):
result = _run_check(
[
_policy(
"Storage Admins",
[
"Allow group StorageUsers to manage all-resources in tenancy where ALL {request.permission!='VOLUME_DELETE', request.permission!='VOLUME_BACKUP_DELETE', request.permission!='FILE_SYSTEM_DELETE', request.permission!='MOUNT_TARGET_DELETE', request.permission!='EXPORT_SET_DELETE', request.permission!='OBJECT_DELETE', request.permission!='BUCKET_DELETE'}"
],
)
]
)
assert len(result) == 1
assert result[0].status == "PASS"
def test_inactive_policies_are_ignored(self):
result = _run_check(
[
_policy(
"Inactive Volume Admins",
["Allow group VolumeUsers to manage volumes in tenancy"],
lifecycle_state="INACTIVE",
)
]
)
assert len(result) == 1
assert result[0].status == "PASS"
assert result[0].resource_name == "Tenancy"
def test_tenant_admin_policy_is_ignored(self):
result = _run_check(
[
_policy(
"Tenant Admin Policy",
["Allow group Administrators to manage all-resources in tenancy"],
)
]
)
assert len(result) == 1
assert result[0].status == "PASS"
assert result[0].resource_name == "Tenancy"
def test_policies_without_storage_manage_statements_are_ignored(self):
result = _run_check(
[
_policy(
"Network Admins",
["Allow group NetworkUsers to manage vcns in tenancy"],
)
]
)
assert len(result) == 1
assert result[0].status == "PASS"
assert result[0].resource_name == "Tenancy"
+29 -35
View File
@@ -14,46 +14,40 @@
> - [`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` |
| 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` |
| 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` |
---
-12
View File
@@ -2,18 +2,6 @@
All notable changes to the **Prowler UI** are documented in this file.
## [1.31.0] (Prowler UNRELEASED)
### 🐞 Fixed
- Attack Paths now shows distinct messages while a scan is running or its graph is being built, plus a separate "couldn't load scans" error, instead of always showing "No scans available" [(#11512)](https://github.com/prowler-cloud/prowler/pull/11512)
### 🔐 Security
- Bump vulnerable `Next.js`, React, AI SDK, `postcss`, `hono`, `qs`, `esbuild`, and Alpine OpenSSL packages (`libcrypto3` and `libssl3`) [(#11581)](https://github.com/prowler-cloud/prowler/pull/11581)
---
## [1.30.1] (Prowler v5.30.1)
### 🐞 Fixed
+2 -2
View File
@@ -3,8 +3,8 @@ FROM node:24.13.0-alpine@sha256:cd6fb7efa6490f039f3471a189214d5f548c11df1ff9e5b1
LABEL maintainer="https://github.com/prowler-cloud"
# Patch Alpine OpenSSL runtime packages before all stages inherit the base image.
RUN apk upgrade --no-cache libcrypto3 libssl3 && corepack enable
# Enable corepack for pnpm
RUN corepack enable
# Install dependencies only when needed
FROM base AS deps
-5
View File
@@ -15,7 +15,6 @@ 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 ({
@@ -65,10 +64,6 @@ 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(), {
@@ -1,64 +0,0 @@
import { fireEvent, render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { ATTACK_PATHS_VIEW_STATES } from "../_lib/get-attack-paths-view-state";
import { AttackPathsStatusPanel } from "./attack-paths-status-panel";
describe("AttackPathsStatusPanel", () => {
it("renders the no-scans message with a link to Scan Jobs", () => {
render(
<AttackPathsStatusPanel state={ATTACK_PATHS_VIEW_STATES.NO_SCANS} />,
);
expect(screen.getByText(/no scans available/i)).toBeInTheDocument();
expect(
screen.getByRole("link", { name: /go to scan jobs/i }),
).toHaveAttribute("href", "/scans");
});
it("renders the scan-running message", () => {
render(
<AttackPathsStatusPanel state={ATTACK_PATHS_VIEW_STATES.SCAN_RUNNING} />,
);
expect(screen.getByText(/scan in progress/i)).toBeInTheDocument();
});
it("renders the graph-building message with progress", () => {
render(
<AttackPathsStatusPanel
state={ATTACK_PATHS_VIEW_STATES.GRAPH_BUILDING}
progress={45}
/>,
);
expect(
screen.getByText(/preparing attack paths data/i),
).toBeInTheDocument();
expect(screen.getByText(/45%/)).toBeInTheDocument();
});
it("renders the no-graph-data message", () => {
render(
<AttackPathsStatusPanel state={ATTACK_PATHS_VIEW_STATES.NO_GRAPH_DATA} />,
);
expect(screen.getByText(/no attack paths data/i)).toBeInTheDocument();
});
it("renders the error message and calls onRetry when Retry is clicked", () => {
const onRetry = vi.fn();
render(
<AttackPathsStatusPanel
state={ATTACK_PATHS_VIEW_STATES.ERROR}
onRetry={onRetry}
/>,
);
expect(screen.getByText(/couldn.t load scans/i)).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: /retry/i }));
expect(onRetry).toHaveBeenCalledOnce();
});
it("renders nothing for the ready state", () => {
const { container } = render(
<AttackPathsStatusPanel state={ATTACK_PATHS_VIEW_STATES.READY} />,
);
expect(container).toBeEmptyDOMElement();
});
});

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