mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-06-17 04:52:05 +00:00
Compare commits
14 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2de298fb7b | |||
| 11f0845a91 | |||
| 42d99a17a6 | |||
| 832f10b7f6 | |||
| d133ad18a4 | |||
| 3539940a26 | |||
| 1192d94648 | |||
| a578f4af34 | |||
| d6528b674e | |||
| 75decbbedf | |||
| 4a14559a5f | |||
| c6f8620a0d | |||
| ca4889b43e | |||
| 057d061c7e |
@@ -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"
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -29,7 +29,6 @@ jobs:
|
||||
- '3.10'
|
||||
- '3.11'
|
||||
- '3.12'
|
||||
- '3.13'
|
||||
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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,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
@@ -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 []
|
||||
|
||||
@@ -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
@@ -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:
|
||||
|
||||
@@ -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
@@ -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'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'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'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 100–120):
|
||||
|
||||
```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 389–406):
|
||||
|
||||
```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 389–406; adds a
|
||||
`loadError` flag in the mount loader; renders the panel **or** the existing
|
||||
workflow; leaves `<AutoRefresh>` mounted (already unconditional at lines 372–375).
|
||||
|
||||
### 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
|
||||
@@ -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**.
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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"
|
||||
]
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
@@ -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}
|
||||
"""
|
||||
)
|
||||
|
||||
@@ -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
|
||||
-44
@@ -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."
|
||||
}
|
||||
-115
@@ -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
|
||||
|
||||
+8
-10
@@ -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)
|
||||
|
||||
-39
@@ -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."
|
||||
}
|
||||
-54
@@ -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
|
||||
|
||||
-44
@@ -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."
|
||||
}
|
||||
-84
@@ -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]
|
||||
|
||||
-38
@@ -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": ""
|
||||
}
|
||||
-29
@@ -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
|
||||
-38
@@ -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": ""
|
||||
}
|
||||
-30
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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}"
|
||||
|
||||
-38
@@ -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."
|
||||
}
|
||||
-41
@@ -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";
|
||||
|
||||
-39
@@ -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": ""
|
||||
}
|
||||
-176
@@ -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
@@ -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"]
|
||||
|
||||
@@ -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"),
|
||||
]
|
||||
-491
@@ -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 == []
|
||||
-247
@@ -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):
|
||||
|
||||
-512
@@ -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"
|
||||
-115
@@ -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"
|
||||
-157
@@ -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
@@ -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
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
@@ -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)
|
||||
|
||||
-205
@@ -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"
|
||||
|
||||
-326
@@ -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
@@ -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` |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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(), {
|
||||
|
||||
-64
@@ -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
Reference in New Issue
Block a user