Compare commits

...
Author SHA1 Message Date
Hugo Pereira Brito 20c3a0ee62 fix(sdk): use system trust store for push-to-cloud (#12485)
(cherry picked from commit 4cfb4eeb96)
2026-08-27 10:08:20 +00:00
d833091397 fix(compliance): discover universal frameworks from entry point (#12568)
Co-authored-by: Pedro Martín <pedromarting3@gmail.com>
Co-authored-by: alejandrobailo <alejandrobailo94@gmail.com>
2026-08-27 09:56:20 +02:00
Prowler BotandDaniel Barranquero 0d206bcf47 fix(alibabacloud): read OSS bucket sub-resource configs via the SDK execute path (#12555)
Co-authored-by: Daniel Barranquero <danielbo2001@gmail.com>
2026-08-26 17:05:59 +02:00
Prowler BotandRubén De la Torre Vico 5ab6c5b4ec fix(mcp): patch the high openssl CVE in the container image (#12550)
Co-authored-by: Rubén De la Torre Vico <ruben@prowler.com>
2026-08-26 12:30:41 +02:00
Prowler BotandPedro Martín 9358dacc3c fix(container): patch the high OpenSSL CVEs for container img (#12551)
Co-authored-by: Pedro Martín <pedromarting3@gmail.com>
2026-08-26 11:23:13 +02:00
2fa984ac1c fix(compliance): correct AWS FSBP check mapping for IAM.9 and EKS.1 (#12543)
Co-authored-by: João Mesquita <151409184+jfgmesquita@users.noreply.github.com>
Co-authored-by: pedrooot <pedromarting3@gmail.com>
2026-08-25 21:32:32 +02:00
Prowler BotandHugo Pereira Brito 5db3a2f264 fix(api): upgrade sqlparse to 0.6.0 (#12511)
Co-authored-by: Hugo Pereira Brito <101209179+HugoPBrito@users.noreply.github.com>
2026-08-24 10:02:15 +02:00
Prowler Botandprowler-bot 36b365401d chore(release): Bump versions to v5.39.2 (#12484)
Co-authored-by: prowler-bot <179230569+prowler-bot@users.noreply.github.com>
2026-08-18 12:41:19 +02:00
Prowler Botandprowler-bot 40ecbd035e chore(changelog): v5.39.1 (#12482)
Co-authored-by: prowler-bot <179230569+prowler-bot@users.noreply.github.com>
2026-08-18 11:32:24 +02:00
Prowler BotandPepe Fagoaga 14bc94a402 chore(api): drop temporary SDK pin overrides after cryptography cap bump (#12479)
Co-authored-by: Pepe Fagoaga <pepe@prowler.com>
2026-08-18 11:10:34 +02:00
Prowler BotandPepe Fagoaga 7391798e8d fix(deps): make published wheels installable and add package checks (#12477)
Co-authored-by: Pepe Fagoaga <pepe@prowler.com>
2026-08-17 19:59:09 +02:00
f17916c7c2 fix(ses): evaluate all identity authorization policies (#12480)
Co-authored-by: ye11oc4t <138095093+ye11oc4t@users.noreply.github.com>
Co-authored-by: Hugo P.Brito <hugopbrit@gmail.com>
2026-08-17 14:30:07 +01:00
c6d95e22fa fix(sdk): skip undescribed ECS task definitions (#12478)
Co-authored-by: Haitao Zheng <h@tzheng.dev>
Co-authored-by: Nguyễn Công Thuận Huy <nguyencongthuanhuy@gmail.com>
Co-authored-by: Hugo P.Brito <hugopbrit@gmail.com>
2026-08-17 12:52:26 +01:00
Prowler BotandAdrián Peña 6a4df430c6 chore: update Trivy to 0.74.0 (#12470)
Co-authored-by: Adrián Peña <adrianjpr@gmail.com>
2026-08-17 10:47:12 +02:00
Prowler BotandPepe Fagoaga 725b0060f1 fix(pypi): bump to pypa/gh-action-pypi-publish v1.14.2 (#12457)
Co-authored-by: Pepe Fagoaga <pepe@prowler.com>
2026-08-14 15:45:14 +02:00
f3d2e51aab chore(release): Bump versions to v5.39.1 (#12442)
Co-authored-by: prowler-bot <179230569+prowler-bot@users.noreply.github.com>
Co-authored-by: Josema Camacho <josema@prowler.com>
2026-08-13 13:49:38 +02:00
Prowler BotandPedro Martín 544ff1cdc1 fix(ci): bump Trivy to v0.73.0 to fix CVE-2026-46600 (#12445)
Co-authored-by: Pedro Martín <pedromarting3@gmail.com>
2026-08-13 12:19:22 +02:00
Prowler Botandprowler-bot 1bb6b3cb39 chore(api): Update prowler dependency to v5.39 for release 5.39.0 (#12434)
Co-authored-by: prowler-bot <179230569+prowler-bot@users.noreply.github.com>
2026-08-13 09:18:08 +02:00
Prowler Botandprowler-bot 5f109bc00e chore(changelog): v5.39.0 (#12433)
Co-authored-by: prowler-bot <179230569+prowler-bot@users.noreply.github.com>
2026-08-13 09:01:51 +02:00
Pepe Fagoaga 472e04f4cc docs(triage): manual PASS verification for MANUAL findings (#12431) 2026-08-12 15:46:54 +02:00
Rubén De la Torre Vico b848aace33 docs(mcp): document the Cloud organization tools and Jira dispatch options (#12427) 2026-08-12 15:05:24 +02:00
Pedro Martín 94c20eb9fe feat(ui): add CMMC compliance framework (#12414) 2026-08-12 14:52:09 +02:00
02df22ca19 fix(html): escape provider identity fields in report header (#12424)
Co-authored-by: pedrooot <pedromarting3@gmail.com>
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: pedrooot <56402503+pedrooot@users.noreply.github.com>
2026-08-12 13:58:31 +02:00
Hugo Pereira BritoandJosema Camacho de64df11b9 fix(api): normalize social account names (#12413)
Co-authored-by: Josema Camacho <josema@prowler.com>
2026-08-12 12:41:44 +01:00
112 changed files with 4500 additions and 778 deletions
+1 -1
View File
@@ -158,7 +158,7 @@ SENTRY_RELEASE=local
# REO_DEV_CLIENT_ID=
#### Prowler release version ####
NEXT_PUBLIC_PROWLER_RELEASE_VERSION=v5.39.0
NEXT_PUBLIC_PROWLER_RELEASE_VERSION=v5.39.2
# Social login credentials
SOCIAL_GOOGLE_OAUTH_CALLBACK_URL="${AUTH_URL}/api/auth/callback/google"
+2 -2
View File
@@ -64,7 +64,7 @@ runs:
scanners: 'vuln'
ignore-unfixed: 'true' # A finding with no available fix is not actionable, so it must not gate
timeout: '5m'
version: 'v0.72.0'
version: 'v0.74.0'
# Not trivyignores: that input drops the .yaml extension Trivy parses by.
env:
TRIVY_IGNOREFILE: '.trivyignore.yaml'
@@ -81,7 +81,7 @@ runs:
scanners: 'vuln'
ignore-unfixed: 'true' # A finding with no available fix is not actionable, so it must not gate
timeout: '5m'
version: 'v0.72.0'
version: 'v0.74.0'
# Not trivyignores: that input drops the .yaml extension Trivy parses by.
env:
TRIVY_IGNOREFILE: '.trivyignore.yaml'
@@ -114,6 +114,8 @@ jobs:
egress-policy: block
allowed-endpoints: >
auth.docker.io:443
dl-cdn.alpinelinux.org:443
dualstack.j.sni.global.fastly.net:443
files.pythonhosted.org:443
ghcr.io:443
github.com:443
@@ -81,6 +81,8 @@ jobs:
pkg-containers.githubusercontent.com:443
files.pythonhosted.org:443
pypi.org:443
dl-cdn.alpinelinux.org:443
dualstack.j.sni.global.fastly.net:443
api.github.com:443
mirror.gcr.io:443
check.trivy.dev:443
+1 -1
View File
@@ -113,7 +113,7 @@ jobs:
- name: Publish prowler-mcp package to PyPI
if: steps.pypi-check.outputs.skip != 'true'
uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b # v1.14.0
uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # v1.14.2
with:
packages-dir: ${{ env.WORKING_DIRECTORY }}/dist/
print-hash: true
+196
View File
@@ -0,0 +1,196 @@
name: 'SDK: Package Checks'
# Rehearses the PyPI release on every packaging change and once a week, from the
# consumer's side. Two incidents this guards against:
#
# - 5.38.0 shipped an unsatisfiable Requires-Dist (cryptography==50.0.0 while
# alibabacloud-tea-openapi and pyopenssl cap it below 49). A [tool.uv] override hid
# the conflict inside the repo; pip could not install the wheel and silently
# resolved `pip install prowler` to 5.37.1 for a week.
# - 5.39.0 never published: an unpinned build backend started emitting core metadata
# 2.5 and the twine bundled in the publish action rejected it.
#
# Both were only detectable at release time because nothing built and installed the
# artifact earlier. The weekly run also catches releases yanked from PyPI after we
# pinned them (zstd 1.5.7.3, "buggy - not thread safe", sat in uv.lock for months).
on:
push:
branches:
- 'master'
- 'v5.*'
pull_request:
branches:
- 'master'
- 'v5.*'
schedule:
# Monday 06:00 UTC. Yanks and upstream releases happen without a commit here.
- cron: '0 6 * * 1'
workflow_dispatch:
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
permissions: {}
env:
# Must equal the twine bundled in the pypa/gh-action-pypi-publish pin used by
# sdk-pypi-release.yml (requirements/runtime.txt in that repo at the pinned tag).
# A metadata check that passes here must pass there.
TWINE_VERSION: '7.0.0'
jobs:
changes:
if: github.repository == 'prowler-cloud/prowler'
runs-on: ubuntu-latest
timeout-minutes: 5
permissions:
contents: read
outputs:
# Scheduled and manual runs always execute; pushes and PRs only when a packaging
# input changed. Jobs skipped this way still report success to branch protection.
run: ${{ github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' || steps.filter.outputs.any_changed == 'true' }}
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
with:
egress-policy: block
allowed-endpoints: >
github.com:443
api.github.com:443
- name: Checkout repository
if: github.event_name == 'push' || github.event_name == 'pull_request'
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
# zizmor: ignore[artipacked]
persist-credentials: true # Required by tj-actions/changed-files to fetch PR branch
- name: Detect packaging changes
if: github.event_name == 'push' || github.event_name == 'pull_request'
id: filter
uses: tj-actions/changed-files@9426d40962ed5378910ee2e21d5f8c6fcbf2dd96 # v47.0.6
with:
files: |
pyproject.toml
uv.lock
README.md
util/replicate_pypi_package.py
util/check_yanked_pins.py
api/pyproject.toml
api/uv.lock
mcp_server/pyproject.toml
mcp_server/uv.lock
.github/workflows/sdk-package-checks.yml
.github/workflows/sdk-pypi-release.yml
.github/actions/setup-python-uv/**
install-from-wheel:
needs: changes
if: needs.changes.outputs.run == 'true'
runs-on: ubuntu-latest
timeout-minutes: 20
permissions:
contents: read
strategy:
fail-fast: false
matrix:
python-version:
- '3.10'
- '3.11'
- '3.12'
- '3.13'
package:
- 'prowler'
include:
# prowler-cloud is the same tree renamed by util/replicate_pypi_package.py;
# one Python is enough to prove the rename and its build still work.
- python-version: '3.12'
package: 'prowler-cloud'
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
with:
egress-policy: block
allowed-endpoints: >
github.com:443
api.github.com:443
release-assets.githubusercontent.com:443
pypi.org:443
files.pythonhosted.org:443
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Setup Python with uv
uses: ./.github/actions/setup-python-uv
with:
python-version: ${{ matrix.python-version }}
install-dependencies: 'false'
- name: Rename package to prowler-cloud
if: matrix.package == 'prowler-cloud'
run: |
pip install --no-cache-dir toml
python util/replicate_pypi_package.py
- name: Build sdist and wheel
run: uv build
- name: Check metadata with the release workflow's twine
run: uvx --from "twine==${TWINE_VERSION}" twine check --strict dist/*
- name: Install the wheel with pip into a clean virtualenv
# Plain pip, --isolated, from outside the repo: consumers never see [tool.uv]
# override-dependencies or constraint-dependencies, so neither does this step.
run: |
python -m venv "${RUNNER_TEMP}/consumer"
"${RUNNER_TEMP}/consumer/bin/python" -m pip install --quiet --upgrade pip
cd "${RUNNER_TEMP}"
"${RUNNER_TEMP}/consumer/bin/python" -m pip install --isolated --no-cache-dir "${GITHUB_WORKSPACE}"/dist/*.whl
- name: Smoke test the installed CLI
run: |
cd "${RUNNER_TEMP}"
"${RUNNER_TEMP}/consumer/bin/prowler" --version
# Loads every AWS check module from the installed wheel: catches files missing
# from the package. grep fails the step if the summary line never appears.
"${RUNNER_TEMP}/consumer/bin/prowler" aws --list-checks | grep 'available checks'
pinned-releases-not-yanked:
needs: changes
if: needs.changes.outputs.run == 'true'
runs-on: ubuntu-latest
timeout-minutes: 10
permissions:
contents: read
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
with:
egress-policy: block
allowed-endpoints: >
github.com:443
api.github.com:443
release-assets.githubusercontent.com:443
pypi.org:443
files.pythonhosted.org:443
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Set up Python
uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v6.0.0
with:
python-version: '3.12'
- name: Check every pinned and locked release against PyPI
run: python util/check_yanked_pins.py . api mcp_server
+22 -2
View File
@@ -84,8 +84,18 @@ jobs:
- name: Build Prowler package
run: uv build
- name: Verify the wheel installs with pip
# Same check as "SDK: Package Checks", repeated on the exact artifact about to be
# published. Plain pip, --isolated, from outside the repo: an unsatisfiable
# Requires-Dist fails here instead of on users' machines (5.38.0 shipped one).
run: |
python -m venv "${RUNNER_TEMP}/consumer"
"${RUNNER_TEMP}/consumer/bin/python" -m pip install --quiet --upgrade pip
cd "${RUNNER_TEMP}"
"${RUNNER_TEMP}/consumer/bin/python" -m pip install --isolated --no-cache-dir --dry-run "${GITHUB_WORKSPACE}"/dist/*.whl
- name: Publish Prowler package to PyPI
uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b # v1.14.0
uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # v1.14.2
with:
print-hash: true
@@ -128,7 +138,17 @@ jobs:
- name: Build prowler-cloud package
run: uv build
- name: Verify the wheel installs with pip
# Same check as "SDK: Package Checks", repeated on the exact artifact about to be
# published. Plain pip, --isolated, from outside the repo: an unsatisfiable
# Requires-Dist fails here instead of on users' machines (5.38.0 shipped one).
run: |
python -m venv "${RUNNER_TEMP}/consumer"
"${RUNNER_TEMP}/consumer/bin/python" -m pip install --quiet --upgrade pip
cd "${RUNNER_TEMP}"
"${RUNNER_TEMP}/consumer/bin/python" -m pip install --isolated --no-cache-dir --dry-run "${GITHUB_WORKSPACE}"/dist/*.whl
- name: Publish prowler-cloud package to PyPI
uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b # v1.14.0
uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # v1.14.2
with:
print-hash: true
+2 -6
View File
@@ -135,8 +135,8 @@ vulnerabilities:
# Modules compiled into the Trivy binary the images ship. The binary is pinned by version
# and verified by checksum in the Dockerfile; only a rebuild by its vendor moves these.
# CVE-2026-71556 affects go-git worktree operations that can follow symlinks outside a
# cloned repository. Trivy 0.72.0 contains go-git 5.19.1, and even the latest published
# Trivy release, 0.73.0, still pins that vulnerable version:
# cloned repository. Trivy 0.73.0, the latest published release and the version the
# images ship, still pins that vulnerable version:
# https://github.com/aquasecurity/trivy/blob/v0.73.0/go.mod#L46
# Trivy main already contains the 5.19.2 fix, but no published release includes it yet:
# https://github.com/aquasecurity/trivy/commit/a2edba9a03987ba0d2ebc8212c1a9a1e6979497b
@@ -164,7 +164,3 @@ vulnerabilities:
purls:
- "pkg:golang/oras.land/oras-go/v2"
expired_at: 2026-12-31
- id: CVE-2026-39822
purls:
- "pkg:golang/stdlib"
expired_at: 2026-12-31
+13 -3
View File
@@ -8,24 +8,34 @@ ENV POWERSHELL_VERSION=${POWERSHELL_VERSION}
# Opt out of PowerShell telemetry (Application Insights -> dc.services.visualstudio.com)
ENV POWERSHELL_TELEMETRY_OPTOUT=1
ARG TRIVY_VERSION=0.72.0
ARG TRIVY_VERSION=0.74.0
ENV TRIVY_VERSION=${TRIVY_VERSION}
ARG ZIZMOR_VERSION=1.24.1
ENV ZIZMOR_VERSION=${ZIZMOR_VERSION}
# Pinned here, not fetched with the artefact: a compromised release ships its own checksum.
ARG TRIVY_SHA256_AMD64=bbb64b9695866ce4a7a8f5c9592002c5961cab378577fa3f8a040df362b9b2ea
ARG TRIVY_SHA256_ARM64=2ca2c023109c2db6b2b77366b6717291452d4531167377d95c79547f0c8e3467
ARG TRIVY_SHA256_AMD64=2ae6fe3ee734b7fdf11335663e18c75ea12dccc76062f09f164a3b0f8be4371a
ARG TRIVY_SHA256_ARM64=b94ce1976bbf3c15b514b605ee88be7c6d94a29be2302847ff01cb794d47aad5
ARG POWERSHELL_SHA256_AMD64=492ff26bb958336bf61e597ce19e07648b4003bd2a08659e02f0e3e0446ebfe0
ARG POWERSHELL_SHA256_ARM64=2503b71da3e83635592b092df59a0aca4c3606b4d9b068217bb00be989cb0d56
ARG ZIZMOR_SHA256_AMD64=a8000f3c683319a523d3b20df0e75457ba591f049cfcbfa98966631b56733c03
ARG ZIZMOR_SHA256_ARM64=d66e37ef8a375fb07939c630ebf9709a6e0f20242bdc3faf672a7ed97e0b768d
# High CVEs fixed in Debian trixie-security but not yet in the pinned base image:
# openssl/libssl3t64/openssl-provider-legacy 3.5.7-1~deb13u2 CVE-2026-14456,
# -14457, -18798, -54874, -63072, -63073, -63074, -63075, -63076, -75803
# (image ships 3.5.6-1~deb13u2)
# Taken as a targeted --only-upgrade rather than by moving the digest: the newest
# published python:3.12-slim-trixie carries the same vulnerable version. The three
# packages are all built from openssl and are flagged separately, so all are named.
# Drop them once the base image ships 3.5.7-1~deb13u2 or later.
# hadolint ignore=DL3008
RUN apt-get update && apt-get install -y --no-install-recommends \
wget libicu76 libunwind8 libssl3 libcurl4 ca-certificates apt-transport-https gnupg \
build-essential pkg-config libzstd-dev zlib1g-dev \
&& apt-get install -y --no-install-recommends --only-upgrade \
util-linux libssl3t64 openssl openssl-provider-legacy \
&& rm -rf /var/lib/apt/lists/*
# Install PowerShell
+31
View File
@@ -4,6 +4,37 @@ All notable changes to the **Prowler API** are documented in this file.
<!-- changelog: release notes start -->
## [1.40.1] (Prowler v5.39.1)
### 🔄 Changed
- Bump alibabacloud-tea-openapi to 0.4.6, oci to 2.184.1 and pyopenssl to 26.4.0 to match the SDK; the cryptography override now names its actual blockers (azure-cli-core pins msal below 1.37, workos 8.3.0 requires cryptography 48) [(#12477)](https://github.com/prowler-cloud/prowler/pull/12477)
### 🐞 Fixed
- Pin zstd to 1.5.7.2; 1.5.7.3 was yanked from PyPI as not thread safe [(#12477)](https://github.com/prowler-cloud/prowler/pull/12477)
### 🔐 Security
- Trivy from v0.72.0 to v0.73.0 in the container image, fixing HIGH CVE-2026-46600 in the bundled `golang.org/x/net` [(#12445)](https://github.com/prowler-cloud/prowler/pull/12445)
- Trivy v0.74.0 and Debian util-linux 2.41.5-0+deb13u1 in the API container image, patching Go standard library vulnerabilities and CVE-2026-53615 [(#12470)](https://github.com/prowler-cloud/prowler/pull/12470)
---
## [1.40.0] (Prowler v5.39.0)
### 🔄 Changed
- `GET /api/v1/users/me` membership relationships identify the active tenant with `meta.active` for JWT and API key authentication [(#12388)](https://github.com/prowler-cloud/prowler/pull/12388)
### 🐞 Fixed
- Tenant deletion no longer leaves memberships partially removed when exclusive-user cleanup fails [(#12379)](https://github.com/prowler-cloud/prowler/pull/12379)
- `/api/v1/accounts/saml/{organization_slug}/acs/` rejects non-POST requests before SAML response processing [(#12393)](https://github.com/prowler-cloud/prowler/pull/12393)
- Social login derives a valid user name when identity providers omit the profile name [(#12413)](https://github.com/prowler-cloud/prowler/pull/12413)
---
## [1.39.0] (Prowler v5.38.0)
### 🚀 Added
+13 -3
View File
@@ -7,20 +7,28 @@ ENV POWERSHELL_VERSION=${POWERSHELL_VERSION}
# Opt out of PowerShell telemetry (Application Insights -> dc.services.visualstudio.com)
ENV POWERSHELL_TELEMETRY_OPTOUT=1
ARG TRIVY_VERSION=0.72.0
ARG TRIVY_VERSION=0.74.0
ENV TRIVY_VERSION=${TRIVY_VERSION}
ARG ZIZMOR_VERSION=1.24.1
ENV ZIZMOR_VERSION=${ZIZMOR_VERSION}
# Pinned here, not fetched with the artefact: a compromised release ships its own checksum.
ARG TRIVY_SHA256_AMD64=bbb64b9695866ce4a7a8f5c9592002c5961cab378577fa3f8a040df362b9b2ea
ARG TRIVY_SHA256_ARM64=2ca2c023109c2db6b2b77366b6717291452d4531167377d95c79547f0c8e3467
ARG TRIVY_SHA256_AMD64=2ae6fe3ee734b7fdf11335663e18c75ea12dccc76062f09f164a3b0f8be4371a
ARG TRIVY_SHA256_ARM64=b94ce1976bbf3c15b514b605ee88be7c6d94a29be2302847ff01cb794d47aad5
ARG POWERSHELL_SHA256_AMD64=492ff26bb958336bf61e597ce19e07648b4003bd2a08659e02f0e3e0446ebfe0
ARG POWERSHELL_SHA256_ARM64=2503b71da3e83635592b092df59a0aca4c3606b4d9b068217bb00be989cb0d56
ARG ZIZMOR_SHA256_AMD64=a8000f3c683319a523d3b20df0e75457ba591f049cfcbfa98966631b56733c03
ARG ZIZMOR_SHA256_ARM64=d66e37ef8a375fb07939c630ebf9709a6e0f20242bdc3faf672a7ed97e0b768d
# High CVEs fixed in Debian trixie-security but not yet in the pinned base image:
# openssl/libssl3t64/openssl-provider-legacy 3.5.7-1~deb13u2 CVE-2026-14456,
# -14457, -18798, -54874, -63072, -63073, -63074, -63075, -63076, -75803
# (image ships 3.5.6-1~deb13u2)
# Taken as a targeted --only-upgrade rather than by moving the digest: the newest
# published python:3.12-slim-trixie carries the same vulnerable version. The three
# packages are all built from openssl and are flagged separately, so all are named.
# Drop them once the base image ships 3.5.7-1~deb13u2 or later.
# hadolint ignore=DL3008
RUN apt-get update && apt-get install -y --no-install-recommends \
wget \
@@ -36,6 +44,8 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
libtool \
libxslt1-dev \
python3-dev \
&& apt-get install -y --no-install-recommends --only-upgrade \
util-linux libssl3t64 openssl openssl-provider-legacy \
&& rm -rf /var/lib/apt/lists/*
# Install PowerShell
@@ -0,0 +1 @@
`openssl`, `libssl3t64` and `openssl-provider-legacy` upgraded to 3.5.7-1~deb13u2 in the API container image, patching ten high OpenSSL CVEs
@@ -1 +0,0 @@
`/api/v1/accounts/saml/{organization_slug}/acs/` rejects non-POST requests before SAML response processing
+1
View File
@@ -0,0 +1 @@
`sqlparse` upgraded to 0.6.0, patching CVE-2026-54284, CVE-2026-59893, and CVE-2026-71491
@@ -1 +0,0 @@
Tenant deletion no longer leaves memberships partially removed when exclusive-user cleanup fails
@@ -1 +0,0 @@
`GET /api/v1/users/me` membership relationships identify the active tenant with `meta.active` for JWT and API key authentication
+21 -11
View File
@@ -45,7 +45,7 @@ dependencies = [
"gunicorn==26.0.0",
"uvloop==0.22.1",
"lxml==6.1.0",
"prowler @ git+https://github.com/prowler-cloud/prowler.git@master",
"prowler @ git+https://github.com/prowler-cloud/prowler.git@v5.39",
"psycopg2-binary==2.9.9",
"pytest-celery[redis] (==1.3.0)",
"sentry-sdk[django] (==2.56.0)",
@@ -61,7 +61,7 @@ dependencies = [
"cartography (==0.138.1)",
"gevent (==25.9.1)",
"werkzeug (==3.1.7)",
"sqlparse (==0.5.5)",
"sqlparse (==0.6.0)",
"fonttools (==4.62.1)",
"uvicorn-worker (==0.4.0)"
]
@@ -71,7 +71,7 @@ name = "prowler-api"
package-mode = false
# Needed for the SDK compatibility
requires-python = ">=3.11,<3.13"
version = "1.40.0"
version = "1.40.2"
# Shared ruff baseline (kept in sync with mcp_server/pyproject.toml).
# target-version tracks this project's lowest supported Python.
@@ -92,8 +92,7 @@ extend-select = [
[tool.uv]
# Transitive pins matching master to avoid silent drift; bump deliberately.
# workos and pyopenssl run ahead of master: the versions master pins cap cryptography
# below 48, so both were bumped to versions that allow it (PROWLER-2310).
# workos is api-only; pyopenssl matches master (PROWLER-2310).
constraint-dependencies = [
"about-time==4.2.1",
"adal==1.2.7",
@@ -130,7 +129,7 @@ constraint-dependencies = [
"alibabacloud-sls20201230==5.9.0",
"alibabacloud-sts20150401==1.1.6",
"alibabacloud-tea==0.4.3",
"alibabacloud-tea-openapi==0.4.5",
"alibabacloud-tea-openapi==0.4.6",
"alibabacloud-tea-util==0.3.14",
"alibabacloud-tea-xml==0.0.3",
"alibabacloud-vpc20160428==6.13.0",
@@ -339,7 +338,7 @@ constraint-dependencies = [
"nltk==3.9.4",
"numpy==2.2.6",
"oauthlib==3.3.1",
"oci==2.183.0",
"oci==2.184.1",
"openai==1.109.1",
"openstacksdk==4.2.0",
"opentelemetry-api==1.39.1",
@@ -380,7 +379,7 @@ constraint-dependencies = [
"pylint==3.2.5",
"pymsalruntime==0.18.1",
"pynacl==1.6.2",
"pyopenssl==26.2.0",
"pyopenssl==26.4.0",
"pyparsing==3.3.2",
"pyreadline3==3.5.4",
"pysocks==1.7.1",
@@ -424,7 +423,7 @@ constraint-dependencies = [
"six==1.17.0",
"slack-sdk==3.39.0",
"sniffio==1.3.1",
"sqlparse==0.5.5",
"sqlparse==0.6.0",
"statsd==4.0.1",
"std-uritemplate==2.0.8",
"stevedore==5.6.0",
@@ -458,7 +457,7 @@ constraint-dependencies = [
"zipp==3.23.0",
"zope-event==6.1",
"zope-interface==8.2",
"zstd==1.5.7.3"
"zstd==1.5.7.2"
]
# prowler@master needs okta==3.4.2, but cartography 0.138.1 requires okta<1.0.0.
# Attack Paths does not ingest Okta today, so override the Cartography
@@ -485,8 +484,19 @@ constraint-dependencies = [
# that request pyjwt[crypto] and leave cryptography (needed for RS256) only transitive.
override-dependencies = [
"okta==3.4.2",
# alibabacloud-tea-openapi 0.4.5 caps cryptography below 49 and is the latest release.
# prowler requires cryptography==50.0.0. Two api-only dependencies still cap it below
# 49 and cannot move yet: msal, pinned exactly by azure-cli-core (2.83.0 -> 1.35.0b1,
# 2.89.1 -> 1.36.0, both <49; cartography needs azure-cli-core), and workos 8.3.0
# (~=48.0; workos 10.1.1+ needs ~=50.0 and is a separate SDK upgrade). This api is
# deployed from this lock with `uv sync --locked`, so the override applies to what runs.
# Remove when azure-cli-core pins msal>=1.37.0 and workos is on 10.x.
"cryptography==50.0.0",
# prowler@master hard-pins alibabacloud-tea-openapi and oci in [project.dependencies];
# the SDK bumped both to lift their cryptography caps. A constraint cannot satisfy the
# new pins against the older master rev locked here, so override until the SDK bump
# propagates to the pinned master rev, then drop these two.
"alibabacloud-tea-openapi==0.4.6",
"oci==2.184.1",
"azure-mgmt-containerservice==34.1.0",
"microsoft-kiota-abstractions==1.9.10",
"microsoft-kiota-authentication-azure==1.9.10",
+27 -4
View File
@@ -12,11 +12,37 @@ from api.models import (
UserRoleRelationship,
)
from api.utils import accept_invitation_for_user
from django.core.exceptions import ValidationError
from django.db import transaction
from django.http import HttpResponseForbidden
class ProwlerSocialAccountAdapter(DefaultSocialAccountAdapter):
@staticmethod
def _get_social_account_name(extra_data: dict, email: str) -> str:
name_field = User._meta.get_field("name")
for value in (
extra_data.get("name"),
extra_data.get("login"),
extra_data.get("username"),
email,
):
if not isinstance(value, str):
continue
candidate = value.strip()[: name_field.max_length].rstrip()
if not candidate:
continue
try:
name_field.run_validators(candidate)
except ValidationError:
continue
return candidate
raise ValueError("Social account does not provide a valid user identity.")
@staticmethod
def get_user_by_email(email: str):
try:
@@ -116,11 +142,8 @@ class ProwlerSocialAccountAdapter(DefaultSocialAccountAdapter):
if provider != "saml":
# Handle other providers (e.g., GitHub, Google)
user.name = self._get_social_account_name(extra, user.email)
user.save(using=MainRouter.admin_db)
social_account_name = extra.get("name")
if social_account_name:
user.name = social_account_name
user.save(using=MainRouter.admin_db)
invitation_token = self._get_invitation_token(request)
if invitation_token:
+1 -1
View File
@@ -1,7 +1,7 @@
openapi: 3.0.3
info:
title: Prowler API
version: 1.40.0
version: 1.40.2
description: |-
Prowler API specification.
+104
View File
@@ -111,6 +111,110 @@ def _verify_local_email(user):
)
def test_social_account_name_falls_back_to_login_for_blank_name():
adapter = ProwlerSocialAccountAdapter()
name = adapter._get_social_account_name(
{"name": " ", "login": "octocat"},
"verified@example.com",
)
assert name == "octocat"
@pytest.mark.parametrize("provider_name", [None, "", " ", 123, ["name"]])
def test_social_account_name_ignores_unusable_provider_names(provider_name):
adapter = ProwlerSocialAccountAdapter()
name = adapter._get_social_account_name(
{"name": provider_name, "login": "octocat"},
"verified@example.com",
)
assert name == "octocat"
def test_social_account_name_uses_login_when_name_is_missing():
adapter = ProwlerSocialAccountAdapter()
name = adapter._get_social_account_name(
{"login": "octocat"},
"verified@example.com",
)
assert name == "octocat"
def test_social_account_name_falls_back_to_username_then_email():
adapter = ProwlerSocialAccountAdapter()
username_name = adapter._get_social_account_name(
{"name": "ab", "login": None, "username": " monalisa "},
"verified@example.com",
)
email_name = adapter._get_social_account_name({}, " verified@example.com ")
assert username_name == "monalisa"
assert email_name == "verified@example.com"
def test_social_account_name_trims_and_limits_provider_name():
adapter = ProwlerSocialAccountAdapter()
max_length = User._meta.get_field("name").max_length
trimmed_name = adapter._get_social_account_name(
{"name": " Ada Lovelace "},
"verified@example.com",
)
limited_name = adapter._get_social_account_name(
{"name": "a" * (max_length + 1)},
"verified@example.com",
)
assert trimmed_name == "Ada Lovelace"
assert limited_name == "a" * max_length
def test_social_account_name_rejects_missing_identity():
adapter = ProwlerSocialAccountAdapter()
with pytest.raises(
ValueError,
match="Social account does not provide a valid user identity",
):
adapter._get_social_account_name({}, "")
def test_save_user_applies_normalized_social_account_name(rf):
adapter = ProwlerSocialAccountAdapter()
request = rf.post("/")
request.session = {}
sociallogin = MagicMock(spec=SocialLogin)
sociallogin.provider = MagicMock()
sociallogin.provider.id = "github"
sociallogin.account = MagicMock()
sociallogin.account.extra_data = {"name": None, "login": " octocat "}
user = User(email="verified@example.com")
user.save = MagicMock()
invitation = SimpleNamespace(tenant_id="tenant-id")
with (
patch("api.adapters.super") as mock_super,
patch("api.adapters.transaction.atomic"),
patch("api.adapters.write_db_alias"),
patch.object(adapter, "_get_invitation_token", return_value="token"),
patch(
"api.adapters.accept_invitation_for_user",
return_value=(invitation, True),
),
):
mock_super.return_value.save_user.return_value = user
saved_user = adapter.save_user(request, sociallogin)
assert saved_user.name == "octocat"
assert request.prowler_invitation_token == "token"
@pytest.mark.django_db
class TestProwlerSocialAccountAdapter:
def test_get_user_by_email_returns_user(self, create_test_user):
Generated
+45 -55
View File
@@ -45,7 +45,7 @@ constraints = [
{ name = "alibabacloud-sls20201230", specifier = "==5.9.0" },
{ name = "alibabacloud-sts20150401", specifier = "==1.1.6" },
{ name = "alibabacloud-tea", specifier = "==0.4.3" },
{ name = "alibabacloud-tea-openapi", specifier = "==0.4.5" },
{ name = "alibabacloud-tea-openapi", specifier = "==0.4.6" },
{ name = "alibabacloud-tea-util", specifier = "==0.3.14" },
{ name = "alibabacloud-tea-xml", specifier = "==0.0.3" },
{ name = "alibabacloud-vpc20160428", specifier = "==6.13.0" },
@@ -254,7 +254,7 @@ constraints = [
{ name = "nltk", specifier = "==3.9.4" },
{ name = "numpy", specifier = "==2.2.6" },
{ name = "oauthlib", specifier = "==3.3.1" },
{ name = "oci", specifier = "==2.183.0" },
{ name = "oci", specifier = "==2.184.1" },
{ name = "openai", specifier = "==1.109.1" },
{ name = "openstacksdk", specifier = "==4.2.0" },
{ name = "opentelemetry-api", specifier = "==1.39.1" },
@@ -295,7 +295,7 @@ constraints = [
{ name = "pylint", specifier = "==3.2.5" },
{ name = "pymsalruntime", specifier = "==0.18.1" },
{ name = "pynacl", specifier = "==1.6.2" },
{ name = "pyopenssl", specifier = "==26.2.0" },
{ name = "pyopenssl", specifier = "==26.4.0" },
{ name = "pyparsing", specifier = "==3.3.2" },
{ name = "pyreadline3", specifier = "==3.5.4" },
{ name = "pysocks", specifier = "==1.7.1" },
@@ -339,7 +339,7 @@ constraints = [
{ name = "six", specifier = "==1.17.0" },
{ name = "slack-sdk", specifier = "==3.39.0" },
{ name = "sniffio", specifier = "==1.3.1" },
{ name = "sqlparse", specifier = "==0.5.5" },
{ name = "sqlparse", specifier = "==0.6.0" },
{ name = "statsd", specifier = "==4.0.1" },
{ name = "std-uritemplate", specifier = "==2.0.8" },
{ name = "stevedore", specifier = "==5.6.0" },
@@ -373,9 +373,10 @@ constraints = [
{ name = "zipp", specifier = "==3.23.0" },
{ name = "zope-event", specifier = "==6.1" },
{ name = "zope-interface", specifier = "==8.2" },
{ name = "zstd", specifier = "==1.5.7.3" },
{ name = "zstd", specifier = "==1.5.7.2" },
]
overrides = [
{ name = "alibabacloud-tea-openapi", specifier = "==0.4.6" },
{ name = "azure-mgmt-containerservice", specifier = "==34.1.0" },
{ name = "cryptography", specifier = "==50.0.0" },
{ name = "dulwich", specifier = "==1.2.5" },
@@ -386,6 +387,7 @@ overrides = [
{ name = "microsoft-kiota-serialization-json", specifier = "==1.9.10" },
{ name = "microsoft-kiota-serialization-multipart", specifier = "==1.9.10" },
{ name = "microsoft-kiota-serialization-text", specifier = "==1.9.10" },
{ name = "oci", specifier = "==2.184.1" },
{ name = "okta", specifier = "==3.4.2" },
{ name = "pyjwt", extras = ["crypto"], specifier = "==2.13.0" },
]
@@ -860,7 +862,7 @@ sdist = { url = "https://files.pythonhosted.org/packages/9a/7d/b22cb9a0d4f396ee0
[[package]]
name = "alibabacloud-tea-openapi"
version = "0.4.5"
version = "0.4.6"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "alibabacloud-credentials" },
@@ -869,9 +871,9 @@ dependencies = [
{ name = "cryptography" },
{ name = "darabonba-core" },
]
sdist = { url = "https://files.pythonhosted.org/packages/3b/73/fb0c4d44759791ecdf269fc715c1e810fa1aba3981bfaaf8a01f61899296/alibabacloud_tea_openapi-0.4.5.tar.gz", hash = "sha256:75fa1f4360a46e41f5bf5f8d4917e52efb6f64885839bc1328c35590670c97b9", size = 26616, upload-time = "2026-07-14T13:15:39.364Z" }
sdist = { url = "https://files.pythonhosted.org/packages/ab/34/1918a2d780676494365c7f945bfab397ecddb988054d78025bd26f438977/alibabacloud_tea_openapi-0.4.6.tar.gz", hash = "sha256:dafc32401712f5b21c12dc3d05ba887a91ad156d9b49a7662279f9fd90526fb2", size = 26742, upload-time = "2026-08-17T08:34:11.55Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/8d/ec/6b368a10e9c2e8b1b394c69b96ac213ae66e8c4895e0baa1ffaf7178fd32/alibabacloud_tea_openapi-0.4.5-py3-none-any.whl", hash = "sha256:338979095c7beda80a5b413c31262892cafdc12069dde4ce4fc2e4f7ce0fc609", size = 33333, upload-time = "2026-07-14T13:15:38.365Z" },
{ url = "https://files.pythonhosted.org/packages/35/00/2f534f5884e5f299d9cb3a1e8be2def8071bc6a6e2a192ba4ff2a8cd5e02/alibabacloud_tea_openapi-0.4.6-py3-none-any.whl", hash = "sha256:c9e1727b9fb2936f487d050fc3590c99f9f2065256dc3a927e5b61f414674ed6", size = 33448, upload-time = "2026-08-17T08:34:10.472Z" },
]
[[package]]
@@ -4426,7 +4428,7 @@ wheels = [
[[package]]
name = "oci"
version = "2.183.0"
version = "2.184.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "certifi" },
@@ -4439,9 +4441,9 @@ dependencies = [
{ name = "pytz" },
{ name = "urllib3" },
]
sdist = { url = "https://files.pythonhosted.org/packages/1e/2a/77bd6cbf1c69b2f368fe3d6462d84369b0cba15e37ce713cdc08d459b95a/oci-2.183.0.tar.gz", hash = "sha256:ff572ef5f2030a788796bb509d257e6a41c6510ef9b4b6a75a079efd06e533ce", size = 17759723, upload-time = "2026-07-28T06:02:29.76Z" }
sdist = { url = "https://files.pythonhosted.org/packages/74/2d/fa5368cfabb868f4111c6978e8b5f66aa3a55076c40c1a59ac3081b0227b/oci-2.184.1.tar.gz", hash = "sha256:617dad69caf8dd6e521d224dbc3e8a8bc289906943a0214fd2c3419094e26435", size = 17990631, upload-time = "2026-08-11T11:01:26.194Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/a9/de/8574b3e527996a099d196e87794a4652d91a0c3185fcc7fdbb5649b75a8a/oci-2.183.0-py3-none-any.whl", hash = "sha256:bd789c98a94d7c5ea08c20d11dcf68c9cd1ad479b134727d80a930b84387070b", size = 36133501, upload-time = "2026-07-28T06:02:18.239Z" },
{ url = "https://files.pythonhosted.org/packages/5f/63/5ae22e42aaf96a5da74dc2b9de449c78b4d7418cce621d5da723b3e49f32/oci-2.184.1-py3-none-any.whl", hash = "sha256:bd814e38a70da2190e721937455a08689ab13c0750bd2ef8dd0c98b2dc5a38ea", size = 36628063, upload-time = "2026-08-11T11:01:18.178Z" },
]
[[package]]
@@ -4835,8 +4837,8 @@ wheels = [
[[package]]
name = "prowler"
version = "5.38.0"
source = { git = "https://github.com/prowler-cloud/prowler.git?rev=master#b3d174d0c1eb202ed7cb9a9daf0500683f4443be" }
version = "5.39.1"
source = { git = "https://github.com/prowler-cloud/prowler.git?rev=v5.39#7391798e8dfb1ea0f496846d6b4796a547b316f5" }
dependencies = [
{ name = "alibabacloud-actiontrail20200706" },
{ name = "alibabacloud-credentials" },
@@ -4935,7 +4937,7 @@ dependencies = [
[[package]]
name = "prowler-api"
version = "1.40.0"
version = "1.40.2"
source = { virtual = "." }
dependencies = [
{ name = "cartography" },
@@ -5035,12 +5037,12 @@ 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.39" },
{ name = "psycopg2-binary", specifier = "==2.9.9" },
{ name = "pytest-celery", extras = ["redis"], specifier = "==1.3.0" },
{ name = "reportlab", specifier = "==4.4.10" },
{ name = "sentry-sdk", extras = ["django"], specifier = "==2.56.0" },
{ name = "sqlparse", specifier = "==0.5.5" },
{ name = "sqlparse", specifier = "==0.6.0" },
{ name = "uuid6", specifier = "==2024.7.10" },
{ name = "uvicorn-worker", specifier = "==0.4.0" },
{ name = "uvloop", specifier = "==0.22.1" },
@@ -5426,15 +5428,15 @@ wheels = [
[[package]]
name = "pyopenssl"
version = "26.2.0"
version = "26.4.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "cryptography" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/1a/51/27a5ad5f939d08f690a326ef9582cda7140555180db71695f6fb747d6a36/pyopenssl-26.2.0.tar.gz", hash = "sha256:8c6fcecd1183a7fc897548dfe388b0cdb7f37e018200d8409cf33959dbe35387", size = 182195, upload-time = "2026-05-04T23:06:09.72Z" }
sdist = { url = "https://files.pythonhosted.org/packages/3f/e8/7325d258199b159eb2c03fe32107533e2832e70e63f4fb88a6aa00023201/pyopenssl-26.4.0.tar.gz", hash = "sha256:28dfcce0162b9211413e26dfbfdf1d24317fbeba18fc93c12400a1856b2a0bc7", size = 182046, upload-time = "2026-08-01T19:50:50.512Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/73/b8/a0e2790ae249d6f38c9f66de7a211621a7ab2650217bcd04e1262f578a56/pyopenssl-26.2.0-py3-none-any.whl", hash = "sha256:4f9d971bc5298b8bc1fab282803da04bf000c755d4ad9d99b52de2569ca19a70", size = 55823, upload-time = "2026-05-04T23:06:08.395Z" },
{ url = "https://files.pythonhosted.org/packages/51/ad/2cf6d3fa2fae5c79e1ed9960c0d42badd0f94d81dd12b50604cdc839e648/pyopenssl-26.4.0-py3-none-any.whl", hash = "sha256:f0eb0cb2d581d3ad2b9c489468485e7f2ab6727d08401bcf9d824c3caddf3c1c", size = 56026, upload-time = "2026-08-01T19:50:48.94Z" },
]
[[package]]
@@ -6049,11 +6051,11 @@ wheels = [
[[package]]
name = "sqlparse"
version = "0.5.5"
version = "0.6.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/90/76/437d71068094df0726366574cf3432a4ed754217b436eb7429415cf2d480/sqlparse-0.5.5.tar.gz", hash = "sha256:e20d4a9b0b8585fdf63b10d30066c7c94c5d7a7ec47c889a2d83a3caa93ff28e", size = 120815, upload-time = "2025-12-19T07:17:45.073Z" }
sdist = { url = "https://files.pythonhosted.org/packages/5f/d3/3f06a1006f2261d1342aefb3c71eed02f5d4ca5bdbecd86ebc12ad38306e/sqlparse-0.6.0.tar.gz", hash = "sha256:113c35c75365ab9cc9c7231d68c6428fb11c085fc8e9eb1ad659b7ddbf6cd2b9", size = 178477, upload-time = "2026-08-13T19:16:06.396Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/49/4b/359f28a903c13438ef59ebeee215fb25da53066db67b305c125f1c6d2a25/sqlparse-0.5.5-py3-none-any.whl", hash = "sha256:12a08b3bf3eec877c519589833aed092e2444e68240a3577e8e26148acc7b1ba", size = 46138, upload-time = "2025-12-19T07:17:46.573Z" },
{ url = "https://files.pythonhosted.org/packages/d9/50/f00935da0ec7cbf325f8dc4f772ae46fbc7b672dd62876e73f0a94adda57/sqlparse-0.6.0-py3-none-any.whl", hash = "sha256:b861c0288ce2fa56209a9a6412d2e066ac664b3873b89c26c9d8415e8e32996f", size = 50070, upload-time = "2026-08-13T19:16:04.062Z" },
]
[[package]]
@@ -6623,39 +6625,27 @@ wheels = [
[[package]]
name = "zstd"
version = "1.5.7.3"
version = "1.5.7.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/49/62/b9c075ad664e7c4cbb3d8d2be7c246506abe1bc7f778eb58d260ef9538c8/zstd-1.5.7.3.tar.gz", hash = "sha256:403e5205f4ac04b92e6b0cda654be2f51de268228a0db0067bc087faacf2f495", size = 672559, upload-time = "2026-01-08T16:24:43.361Z" }
sdist = { url = "https://files.pythonhosted.org/packages/0f/78/9a476e09c825304df47b98be80d1ffe223733b03550af71325415028f615/zstd-1.5.7.2.tar.gz", hash = "sha256:6d8684c69009be49e1b18ec251a5eb0d7e24f93624990a8a124a1da66a92fc8a", size = 670481, upload-time = "2025-06-23T12:36:08.131Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/75/0d/8c89c0d010b58c21a7865a239790bb1c6822029c053b1ded858d6b573e3a/zstd-1.5.7.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1a3c1781a24e2ced2c0ddee11d45b1f04018b03615eeb622a62eca4d56d3358a", size = 267641, upload-time = "2026-01-08T16:30:50.812Z" },
{ url = "https://files.pythonhosted.org/packages/a3/6d/155d8c344d96eca2a5a003a5ddd63373a5f13591fd5cf2b9490250d6805a/zstd-1.5.7.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a6c7c81056362b60a04baa34632e713d596662a860ec34efd8e9b109c10e6ec7", size = 230962, upload-time = "2026-01-08T16:30:49.155Z" },
{ url = "https://files.pythonhosted.org/packages/c8/c7/ab93916a26eb58cd501ad701974c31b4bc67a7f6abd6c24bef8fe4d7649b/zstd-1.5.7.3-cp311-cp311-manylinux_2_14_x86_64.whl", hash = "sha256:e564f34a55effc7d654eb293468edc80b64d476b0f899f82760ecd8323223ff5", size = 304166, upload-time = "2026-01-10T11:17:45.697Z" },
{ url = "https://files.pythonhosted.org/packages/c2/54/27a7040a360019a4602343e3c98c0c0a140f382186002c01e1992fd21837/zstd-1.5.7.3-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_24_aarch64.whl", hash = "sha256:fbc49a57188184931d5e3c9f1133cad7eea5a370a9e9418fb8122d58c14340a5", size = 1540288, upload-time = "2026-01-08T17:50:26.913Z" },
{ url = "https://files.pythonhosted.org/packages/96/93/4a4d4edd1b2e809e0ebbb16000404bdcc9a09743c04ee1661442c9581b75/zstd-1.5.7.3-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:d121d3e63722819e1fe5effbcd9628d8a7cfea0cddabcc5bb37ea861a6a83424", size = 1619134, upload-time = "2026-01-08T17:50:32.324Z" },
{ url = "https://files.pythonhosted.org/packages/31/6b/cd6f0a7f4f0d98e4110aa77763cf3e85f594d983ea9ca3d64cc0cee10684/zstd-1.5.7.3-cp311-cp311-manylinux_2_4_i686.whl", hash = "sha256:621f2e7ca8e9eb52a83eb9c91ec3cd283d87591bf75cc658de486b65f44742c7", size = 300166, upload-time = "2026-01-10T11:12:27.938Z" },
{ url = "https://files.pythonhosted.org/packages/05/3f/c717e0d15127d04b7fa58ba9b4c56e8b88b803048b9766cd9d158dbb22ea/zstd-1.5.7.3-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_24_i686.whl", hash = "sha256:c1950fcae690ba32d0f31702b335c548fb42547821565925e48576afdad774a5", size = 1525776, upload-time = "2026-01-08T17:50:35.518Z" },
{ url = "https://files.pythonhosted.org/packages/3e/a2/1813cd787d1a2f9ab8e8a90d28dcbc8e8098997dd04de38897ea8e75dd08/zstd-1.5.7.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:bac4f0d03da69115878bedbfa03c4a3f64364e8396b432028c4ce0f05141a0fb", size = 2096057, upload-time = "2026-01-08T17:50:33.984Z" },
{ url = "https://files.pythonhosted.org/packages/36/ce/f5a3c7c12de458dd9ce15c484d627fe5412b60c155da23dacb5fcf08d9d5/zstd-1.5.7.3-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:da0ab134b7fd28023dedf013751ca850de300a090eb11f689d2a1c178c87d9dc", size = 2132659, upload-time = "2026-01-08T17:50:29.534Z" },
{ url = "https://files.pythonhosted.org/packages/f1/66/151f9546498bfd8971a0b6ad67d87c26d7a0df17d57f724da674f3778666/zstd-1.5.7.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b9923175842ee8f7602ec9cc578f5fc396896f0e8460d3ac9a5adc3cea77244e", size = 2124811, upload-time = "2026-01-08T17:50:37.612Z" },
{ url = "https://files.pythonhosted.org/packages/6a/34/4d2dbb36cb2373d3f115c047cb901b64f89de0703d10779da39de9453812/zstd-1.5.7.3-cp311-cp311-win32.whl", hash = "sha256:0612b604948d7b58aecc6788c7ceb53c5f21d94a155bb6ea9bd0f54ffa43725d", size = 150363, upload-time = "2026-01-08T17:11:02.392Z" },
{ url = "https://files.pythonhosted.org/packages/d9/de/f53687e0dd8c0d0ebfaed9ae88f6a96a1a0388ae7424b469e74bb17ac57d/zstd-1.5.7.3-cp311-cp311-win_amd64.whl", hash = "sha256:5b7f8c81b2bd3b62c0345242247d484cafa4b518d59d18619813d9225af5c5c3", size = 167577, upload-time = "2026-01-08T17:11:03.356Z" },
{ url = "https://files.pythonhosted.org/packages/f2/58/d4a6a902e229e953ed273fe9b78587ed31f57567aa68d3e34af6056e42af/zstd-1.5.7.3-cp311-cp311-win_arm64.whl", hash = "sha256:ea112e3acd9e1765adca35df7b54ac75b36194290f64ea03a3a59664209c8527", size = 157238, upload-time = "2026-01-08T16:36:06.25Z" },
{ url = "https://files.pythonhosted.org/packages/aa/ed/5a3bf2e29dc56d4cc7619929bb51f0c758de6d02967cc73c5d8755a862c0/zstd-1.5.7.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:01a39efb0eeab7cc45cb308618233b624b0840d5e16dcf85456b6cca0592f203", size = 268124, upload-time = "2026-01-08T16:29:57.091Z" },
{ url = "https://files.pythonhosted.org/packages/e2/1d/efc2074ac90af938e78f2ed4004639fe24f294d9086c5280f8d9a02b9897/zstd-1.5.7.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7a8e8838cf35fa3987bfe1958584cc22e1797efce8e155a63544b4144fc671f8", size = 230988, upload-time = "2026-01-08T16:29:55.604Z" },
{ url = "https://files.pythonhosted.org/packages/2a/52/178393b8d70e23fba67f42dfce4663e4e8a30867110168beb490a36d4639/zstd-1.5.7.3-cp312-cp312-manylinux_2_14_i686.whl", hash = "sha256:f3920ac1d1cc7e9f252f3e29f217fe3cd36f2191bb3dbcae826c29e189b7ad54", size = 300207, upload-time = "2026-01-10T11:26:58.351Z" },
{ url = "https://files.pythonhosted.org/packages/6a/7a/8dcd86a2efb2ed3f9dae39545a05d3c7ed26c7678330786ce4a44cd8b099/zstd-1.5.7.3-cp312-cp312-manylinux_2_14_x86_64.whl", hash = "sha256:143f9062953fb5590cbd47c1040d357336742c79696bf90b6d5b835279a68304", size = 304154, upload-time = "2026-01-10T11:17:40.91Z" },
{ url = "https://files.pythonhosted.org/packages/6f/ce/0c96905ab01ffe0e53a3cec8132123b82db26bd583a71608029bcc789ebc/zstd-1.5.7.3-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:36d1fd8647e47e1f21b345e192f1a279e925678c23dad8236b547d04456cd699", size = 2162222, upload-time = "2026-01-08T18:02:22.762Z" },
{ url = "https://files.pythonhosted.org/packages/11/c4/db4807d6a68b4628c74fd379de7e3c67ec34f19a2a80ac246b3837cde6cb/zstd-1.5.7.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f1538db419afa62773cf534fc7f3009ff59ecf55ecee4e889587ac2ef0010ed8", size = 2201732, upload-time = "2026-01-08T18:02:20.835Z" },
{ url = "https://files.pythonhosted.org/packages/c5/99/c19a3c0f5580ff9c33a74f06d98d6060ed1fa6bd09b55aed9be852ec191f/zstd-1.5.7.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:c5efd16adb092e2a547a7d51cfdaf6fd5680528227684c5bafc7669ab4a55f41", size = 2096459, upload-time = "2026-01-08T18:02:25.336Z" },
{ url = "https://files.pythonhosted.org/packages/23/fd/02eac30419475dbe50212c119043a2d0698a0cbc756da85fd3fd9abddf42/zstd-1.5.7.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:39b3438e64637d80a5b1860526903b92020acb9bae9ceb5adffd9838c1441328", size = 2125442, upload-time = "2026-01-08T18:02:17.715Z" },
{ url = "https://files.pythonhosted.org/packages/bb/43/3a16ff0a8c913bb9825379db1bd533c75c57c2d2f31dd9111aa9b53711f4/zstd-1.5.7.3-cp312-cp312-win32.whl", hash = "sha256:cbf48c53461e224ffc2490cfe5120a1ff40d14c84d2b512c6d6d99fc91685cf3", size = 150367, upload-time = "2026-01-08T17:03:40.178Z" },
{ url = "https://files.pythonhosted.org/packages/46/83/b85875d7428e63dfa9247e41d17fac611443c774f7892f8643bd4164a6b2/zstd-1.5.7.3-cp312-cp312-win_amd64.whl", hash = "sha256:943a189910f2fea997462e3e4d7fbf727a06d231ef801ebee557b1c87568981c", size = 167604, upload-time = "2026-01-08T17:03:41.355Z" },
{ url = "https://files.pythonhosted.org/packages/37/42/cf291e26804de2f55500cdac93f5e9fa6267cf315def8aa402529bae3a87/zstd-1.5.7.3-cp312-cp312-win_arm64.whl", hash = "sha256:85c4d508f8109afa7c51c4960626c3325af2cf1e442c6c36ebfea15d04757e3f", size = 157241, upload-time = "2026-01-08T16:47:34.615Z" },
{ url = "https://files.pythonhosted.org/packages/04/b8/d13d584867d5eb1bc607877a870858e02a256d4706a4274e475413a000aa/zstd-1.5.7.3-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:76c49ea969bc08389ea59155cea7c5dea224522ffc62f443f3c0a915f5fd184d", size = 260025, upload-time = "2026-01-08T16:57:45.739Z" },
{ url = "https://files.pythonhosted.org/packages/16/a1/1e5faf75bedfd2bfccfb83e18736b115bed6e348504bd21800cd8f30dcea/zstd-1.5.7.3-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:6b1a638ff3dfce8f4cb1203c662fb5606dd99b4a62c5ddc4c406d2d1326bcfdd", size = 221038, upload-time = "2026-01-08T17:16:32.005Z" },
{ url = "https://files.pythonhosted.org/packages/b7/2c/0fe74d8b2029eef8000bc71aac5b3e5b55d00581238711cf627814183ea3/zstd-1.5.7.3-pp311-pypy311_pp73-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:5e96a5cb100a0edc162935227f2d9784b1031ce4a8a83e96e66eae2673c10143", size = 326792, upload-time = "2026-01-08T16:57:35.631Z" },
{ url = "https://files.pythonhosted.org/packages/96/e0/2c7f081f3524f872128ff31bea2acb6b21cb1dacccef920eb6a1a77a87c6/zstd-1.5.7.3-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1bda0bbf3a9553720cd33f1f85940a259656c7ffba4be717ff82b7f062052188", size = 322283, upload-time = "2026-01-08T16:57:36.759Z" },
{ url = "https://files.pythonhosted.org/packages/c9/a7/3bebfcc18d66b90bc7b506a61b2ff4af5ee1b0b16e784ea644afa06241c5/zstd-1.5.7.3-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ac36e4022422f6e49b3f07bdbb8a964fd348223d3dc9c82ad5398a4f0432a719", size = 311553, upload-time = "2026-01-08T16:57:38.465Z" },
{ url = "https://files.pythonhosted.org/packages/41/75/8a791cae2c98e5e44a158e15db50d21b7ec0b37aeaffa68d151bc8ffb6d6/zstd-1.5.7.3-pp311-pypy311_pp73-manylinux_2_14_x86_64.whl", hash = "sha256:fa4d760a220541b18ce732a3a2cf7547ea05afc76d05b3b39edebfeb721f6079", size = 317071, upload-time = "2026-01-08T16:36:07.47Z" },
{ url = "https://files.pythonhosted.org/packages/2f/25/b6624e6b08d515242154436c9d06fb20b790d300ac82e84f3c4c133e25e1/zstd-1.5.7.3-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:a69e60146bf8aaa6a0e6c9a94a7c5f3133d68091e2e5c5a3c5ababf71fd5ec7a", size = 167654, upload-time = "2026-01-08T17:00:56.667Z" },
{ url = "https://files.pythonhosted.org/packages/43/2a/0885f6f1921ec1ef4a8f8ab29ab0a335cc867abe4c7aaa4e5031435a32a5/zstd-1.5.7.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f799c1e9900ad77e7a3d994b9b5146d7cfd1cbd1b61c3db53a697bf21ffcc57b", size = 269702, upload-time = "2025-06-23T12:50:11.695Z" },
{ url = "https://files.pythonhosted.org/packages/05/e6/629cf6b77e47fc7149f5724fb4853c48edcdeb10d8c64e391d7026cb10e1/zstd-1.5.7.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1ff4c667f29101566a7b71f06bbd677a63192818396003354131f586383db042", size = 228145, upload-time = "2025-06-23T12:50:10.411Z" },
{ url = "https://files.pythonhosted.org/packages/c4/b8/9ddefd4670bfe9328ca6657ad335eb8d9c657466247e234a579818b6b0b9/zstd-1.5.7.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_24_aarch64.whl", hash = "sha256:8526a32fa9f67b07fd09e62474e345f8ca1daf3e37a41137643d45bd1bc90773", size = 1536530, upload-time = "2025-06-23T13:51:38.853Z" },
{ url = "https://files.pythonhosted.org/packages/d1/6a/1bb836c18760dc1e28ca7a9706016e482ebdea633b980d8505dbb65e18f8/zstd-1.5.7.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:2cec2472760d48a7a3445beaba509d3f7850e200fed65db15a1a66e315baec6a", size = 1616141, upload-time = "2025-06-23T13:51:34.152Z" },
{ url = "https://files.pythonhosted.org/packages/b5/7a/bb6c6e2cb2a066e347dc27d45d5205058b69d6c8b8d4ae2ee7d6b91c64a5/zstd-1.5.7.2-cp311-cp311-manylinux_2_4_i686.whl", hash = "sha256:a200c479ee1bb661bc45518e016a1fdc215a1d8f7e4bf6c7de0af254976cfdf6", size = 322188, upload-time = "2025-06-23T13:01:48.704Z" },
{ url = "https://files.pythonhosted.org/packages/5a/4f/cf0669c8a89fdcc91814bf92bd05cc363d5d12a79b656418c0add6f2d266/zstd-1.5.7.2-cp311-cp311-manylinux_2_4_x86_64.whl", hash = "sha256:f5d159e57a13147aa8293c0f14803a75e9039fd8afdf6cf1c8c2289fb4d2333a", size = 302736, upload-time = "2025-06-23T13:05:33.649Z" },
{ url = "https://files.pythonhosted.org/packages/be/bc/e5f8b7f61826323e39e099db1eb5c0e09b18315df1b1ff778f7ae9aadcac/zstd-1.5.7.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_24_i686.whl", hash = "sha256:7206934a2bd390080e972a1fed5a897e184dfd71dbb54e978dc11c6b295e1806", size = 1522687, upload-time = "2025-06-23T13:51:35.494Z" },
{ url = "https://files.pythonhosted.org/packages/d5/8c/7660a949a020ac9d02b3166a25dd1c12144572d77b11ae92a31d341016da/zstd-1.5.7.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:7e0027b20f296d1c9a8e85b8436834cf46560240a29d623aa8eaa8911832eb58", size = 2098794, upload-time = "2025-06-23T13:51:37.219Z" },
{ url = "https://files.pythonhosted.org/packages/bc/b2/730c811a78d670104d40c7f08cc8092577cdff870cba42b3158f20fceb57/zstd-1.5.7.2-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:d6b17e5581dd1a13437079bd62838d2635db8eb8aca9c0e9251faa5d4d40a6d7", size = 2112266, upload-time = "2025-06-23T13:51:31.258Z" },
{ url = "https://files.pythonhosted.org/packages/44/74/2c16e1632094db36c8920d4c13b8e2e843024d548ae26888c2d22af6a676/zstd-1.5.7.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b13285c99cc710f60dd270785ec75233018870a1831f5655d862745470a0ca29", size = 2109465, upload-time = "2025-06-23T13:51:32.884Z" },
{ url = "https://files.pythonhosted.org/packages/58/6e/b9c9a834769d96cab2122da1be8c8c700d3f76be796d2b7516e85d2eca0e/zstd-1.5.7.2-cp311-cp311-win32.whl", hash = "sha256:cdb5ec80da299f63f8aeccec0bff3247e96252d4c8442876363ff1b438d8049b", size = 149448, upload-time = "2025-06-23T13:06:21.144Z" },
{ url = "https://files.pythonhosted.org/packages/47/b7/fc22ad6292a32d7676ab815de3a23573beac3679e8abd9914288d1496ceb/zstd-1.5.7.2-cp311-cp311-win_amd64.whl", hash = "sha256:4f6861c8edceb25fda37cdaf422fc5f15dcc88ced37c6a5b3c9011eda51aa218", size = 166592, upload-time = "2025-06-23T13:06:22.126Z" },
{ url = "https://files.pythonhosted.org/packages/45/14/096bb77f3e5ef525b452cd6294da33de7f8a8c9647ba78293378fbb0a7ce/zstd-1.5.7.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d2ebe3e60dbace52525fa7aa604479e231dc3e4fcc76d0b4c54d8abce5e58734", size = 269408, upload-time = "2025-06-23T13:11:46.492Z" },
{ url = "https://files.pythonhosted.org/packages/08/b8/2bc2590a34c733ea0570f366e6ad7d889d05c7825bd3ccab01f36ece71c6/zstd-1.5.7.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ef201b6f7d3a6751d85cc52f9e6198d4d870e83d490172016b64a6dd654a9583", size = 228188, upload-time = "2025-06-23T13:11:47.539Z" },
{ url = "https://files.pythonhosted.org/packages/b7/80/6252de3a70cfd7767718ad476893f1c7dc129f942cc7ed0322e3137c03d9/zstd-1.5.7.2-cp312-cp312-manylinux_2_14_x86_64.whl", hash = "sha256:ac7bdfedda51b1fcdcf0ab69267d01256fc97ddf666ce894fde0fae9f3630eac", size = 302720, upload-time = "2025-06-23T12:40:11.522Z" },
{ url = "https://files.pythonhosted.org/packages/af/b6/af908387814b99172d3aea6aeb24b19583aadfa45f6021e5e2a0d6d8e99a/zstd-1.5.7.2-cp312-cp312-manylinux_2_4_i686.whl", hash = "sha256:b835405cc4080b378e45029f2fe500e408d1eaedfba7dd7402aba27af16955f9", size = 322237, upload-time = "2025-06-23T13:17:35.482Z" },
{ url = "https://files.pythonhosted.org/packages/ed/d7/ab9142e002a7eaa451cb4bb37a74c390c489ba8ae75ade543840496eda04/zstd-1.5.7.2-cp312-cp312-win32.whl", hash = "sha256:e4cf97bb97ed6dbb62d139d68fd42fa1af51fd26fd178c501f7b62040e897c50", size = 149453, upload-time = "2025-06-23T13:13:02.786Z" },
{ url = "https://files.pythonhosted.org/packages/3e/c7/c182ea7bc283f591e3f3c5f0f239e7a92c9bc1f626642ae2c4dfbe51d6f2/zstd-1.5.7.2-cp312-cp312-win_amd64.whl", hash = "sha256:55e2edc4560a5cf8ee9908595e90a15b1f47536ea9aad4b2889f0e6165890a38", size = 166628, upload-time = "2025-06-23T13:13:03.745Z" },
{ url = "https://files.pythonhosted.org/packages/cd/c9/a6495a7bf168a78f0a0c01d61d830ebfb401315a64fd1ae8d725c458114c/zstd-1.5.7.2-pp311-pypy311_pp73-manylinux_2_14_x86_64.whl", hash = "sha256:5fb2ff5718fe89181223c23ce7308bd0b4a427239379e2566294da805d8df68a", size = 315542, upload-time = "2025-06-23T12:39:27.598Z" },
]
@@ -11,10 +11,10 @@ Complete reference guide for all tools available in the Prowler MCP Server. Tool
| Prowler Hub | 10 tools | No | Cloud and Local MCP Server |
| Prowler Documentation | 2 tools | No | Cloud and Local MCP Server |
| Prowler Cloud, Private Cloud & Local Server | 49 tools | Yes | Cloud and Local MCP Server |
| Prowler Cloud management | 32 tools | Yes | Cloud MCP Server only |
| Prowler Cloud management | 40 tools | Yes | Cloud MCP Server only |
<Note>
48 of the 49 Prowler tools are available on both servers. `prowler_schedule_daily_scan` is the exception: it is Local-only, because the Cloud MCP Server supersedes it with the `prowler_cloud_*` [Scan Scheduling](#scan-scheduling) tools.
48 of the 49 Prowler tools are available on both servers. `prowler_schedule_daily_scan` is the exception: it is Local-only, because the Cloud MCP Server supersedes it with the `prowler_cloud_*` [Scan Scheduling](#scan-scheduling) tools. `prowler_send_findings_to_jira` is exposed by both servers but accepts two [extra parameters](#jira-operations) on the Cloud MCP Server.
</Note>
## Tool Naming Convention
@@ -124,7 +124,20 @@ Tools for managing where Prowler sends its results: Amazon S3 buckets, AWS Secur
#### Jira Operations
- **`prowler_get_jira_issue_types`** - List the issue types available in a Jira project, fetched live from Jira
- **`prowler_send_findings_to_jira`** - Create one Jira work item per finding, with its severity, resource, risk, and remediation steps
- **`prowler_send_findings_to_jira`** - Create Jira work items from findings, each carrying the check title, severity, status, provider, region, resource, risk, and remediation steps. Select the findings either by ID with `finding_ids`, or — on Prowler Cloud only — by check with `check_ids`, and choose between one work item per finding or one per check with `dispatch_mode`
<Note>
`check_ids` and `dispatch_mode` are **Prowler Cloud only**:
- **`check_ids`** - Send the failing findings of a check (for example `s3_bucket_public_access`) without listing their IDs. Prowler resolves them server-side, taking only the failed findings of the latest completed scan of every provider. Get the check IDs from `prowler_list_finding_groups`. Exactly one of `finding_ids` or `check_ids` is required — Prowler combines both filters, so sending both would only dispatch their intersection. A Local MCP Server rejects `check_ids` with a client error.
- **`dispatch_mode`** - `individual` (the default) creates one work item per finding. `grouped` creates one work item per check instead, listing up to 50 affected resources and linking back to the finding group in Prowler Cloud, which keeps a noisy check to a single ticket. Grouped dispatch only covers failed, unmuted findings of the latest completed scan of every provider. A Local MCP Server ignores `dispatch_mode` instead of rejecting it, and creates one work item per finding.
In `grouped` mode the response counters change meaning: `created_count` counts work items (one per check) rather than findings, `failed_count` counts the entries of the new `failed_groups` field, and `failed_groups` details each failure with its reason and the `check_id` whose work item could not be created.
</Note>
<Warning>
`prowler_send_findings_to_jira` creates real work items that Prowler cannot delete or update afterwards. Only retry the same dispatch when the previous response returned `safe_to_retry: true`, otherwise the work items already created are duplicated. Combining `check_ids` with the default `individual` mode opens one work item per failing resource, which can be hundreds of them — use `dispatch_mode="grouped"` to keep it to one per check.
</Warning>
### Attack Paths Analysis
@@ -167,6 +180,23 @@ Manage Prowler Cloud-only features and configuration. **Requires authentication.
These tools are available **only on the Cloud MCP Server** (`https://mcp.prowler.com/mcp`). A Local MCP Server does not expose them, because the features they manage exist only in Prowler Cloud.
</Note>
### Organizations
Tools for onboarding a cloud provider organization as a whole — an AWS Organization, an Azure tenant with its management groups, or a GCP organization with its folders. An organization holds org-level credentials, discovers the real account, subscription, or project structure in the cloud, and turns a selection from that discovery into Prowler providers linked into a hierarchy of nodes. Every tool that changes something — creating, updating, deleting, discovering, applying a discovery, or adjusting provider membership — requires the **Manage Providers** permission; listing and reading do not.
<Note>
Use these tools for the whole organization. To register providers one by one, use the [Provider Management](#provider-management) tools instead; to build arbitrary RBAC buckets of providers, use provider groups.
</Note>
- **`prowler_cloud_list_organizations`** - Browse the registered organizations with lightweight data (name, type, external id, provider and node counts), filtered by type or cloud-side external id
- **`prowler_cloud_get_organization`** - Get one organization in full: attributes, linked providers, credentials status, latest discovery, and the OU / management group / folder hierarchy. Set `include_hierarchy` to `false` to skip the tree on large organizations
- **`prowler_cloud_create_organization`** - Register an organization, optionally storing its org-level credentials in the same call. Idempotent: an organization with the same type and external id is reused and its credentials rotated, reported as `created: false`
- **`prowler_cloud_update_organization`** - Rename an organization, replace its metadata, and/or create or rotate its org-level credentials. `org_type` and `external_id` are immutable after creation
- **`prowler_cloud_delete_organization`** - Delete an organization, its entire hierarchy, and every linked provider
- **`prowler_cloud_discover_organization`** - Enumerate the real cloud structure: AWS accounts and OUs, Azure subscriptions and management groups, or GCP projects and folders. Each item comes back with its registration state so you can choose what to onboard
- **`prowler_cloud_apply_organization_discovery`** - Turn a discovery selection into Prowler providers and hierarchy nodes
- **`prowler_cloud_manage_organization_providers`** - Manually `add`, `replace`, or `remove` the providers linked to an organization or to one of its hierarchy nodes. Providers are detached, never deleted
### Scan Configurations
Tools for managing reusable scan configurations — per-provider check and compliance selections — and attaching them to providers. Providers without a configuration attached use the default.
Binary file not shown.

After

Width:  |  Height:  |  Size: 401 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 456 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 374 KiB

@@ -42,12 +42,12 @@ The status selector includes manual statuses. Prowler also sets automatic status
| **Remediating** | Manual | Work is in progress to fix the finding. |
| **Risk Accepted** | Manual | The team accepts the risk and wants to mute the finding. |
| **False Positive** | Manual | The finding does not apply and should be muted. |
| **Resolved** | Automatic | A finding changed from `FAIL` to `PASS` in a later scan. A passed finding with no saved triage state also appears as **Resolved**. |
| **Resolved** | Automatic / Manual | A finding changed from `FAIL` to `PASS` in a later scan. A passed finding with no saved triage state also appears as **Resolved**. On `MANUAL` findings, select it to verify the finding as passing (see [Verify a MANUAL Finding as Pass](#verify-a-manual-finding-as-pass)). |
| **Reopened** | Automatic | A finding changed from `PASS` to `FAIL` in a later scan. |
![Findings Triage Status Selector](/images/prowler-app/findings-triage/findings-triage-status-dropdown.png)
Resolved and Reopened are not manual selector options.
**Reopened** is never a manual selector option. **Resolved** appears in the selector only on `MANUAL` findings, where it starts the [Manual Pass verification](#verify-a-manual-finding-as-pass).
These automatic states keep triage tied to the finding UID across scans, even when each scan creates a new finding snapshot.
@@ -93,6 +93,39 @@ Triage notes are visible only to the team in the current organization. Each note
To remove an existing note, clear the note text and save the change.
## Verify a MANUAL Finding as Pass
<VersionBadge version="5.39.0" />
Checks that Prowler cannot judge automatically report `MANUAL` findings. When a team verifies such a control outside Prowler, the triage selector on that finding offers **Resolved**: choosing it records a Manual Pass attestation, and the finding reports an effective `PASS` while keeping the raw `MANUAL` scan result.
![Triage selector offering Resolved on a MANUAL finding](/images/prowler-app/findings-triage/findings-triage-manual-pass-selector.png)
<Steps>
<Step title="Filter MANUAL findings">
Go to **Findings** and filter by status **Manual**.
</Step>
<Step title="Open the triage selector">
Expand a Finding Group and click the current status in the **Triage** column of an individual finding.
</Step>
<Step title="Choose Resolved">
Select **Resolved**. Prowler opens the triage note modal with a required **Manual pass evidence** field.
</Step>
<Step title="Record the evidence">
Describe how the control was verified, then click **Save**. The evidence supports up to 500 characters.
</Step>
</Steps>
![Manual Pass attestation with required evidence](/images/prowler-app/findings-triage/findings-triage-manual-pass-note.png)
After saving, the finding reports `PASS` in finding tables, finding groups, compliance reports, and scans. While the attestation is active, the triage status is managed automatically and cannot be changed. **View Manual Pass details** shows who verified the finding, the evidence, the attestation time, and its expiration.
![Manual Pass details showing evidence, author, and validity](/images/prowler-app/findings-triage/findings-triage-manual-pass-details.png)
### Attestation Expiration
A Manual Pass attestation is valid for 90 days. It also ends early when a later scan reports a real failure for the finding. In both cases the finding returns to its raw `MANUAL` status for a new review.
## Mutelist Behavior
Findings Triage uses Mutelist when a status means the finding should be muted:
@@ -118,7 +151,7 @@ Confirm that the user role has **Manage Scans** permission. Prowler Local Server
### Resolved or Reopened is missing from the selector
This is expected. Prowler sets **Resolved** and **Reopened** automatically from scan result changes.
**Reopened** is always automatic. **Resolved** is set automatically from scan result changes and appears as a selector option only on `MANUAL` findings, where it records a [Manual Pass](#verify-a-manual-finding-as-pass). On findings with any other status, this is expected.
### Risk Accepted or False Positive muted a finding
@@ -153,6 +153,18 @@ export PROWLER_CLOUD_API_KEY="pk_your_api_key_here"
prowler aws --push-to-cloud
```
### TLS Certificate Trust
For `--push-to-cloud` uploads, Prowler CLI creates one ingestion-scoped TLS context and validates HTTPS certificates with one handshake and one POST request. The upload does not retry or fall back to another TLS configuration. Redirect responses are rejected.
The ingestion context combines the operating system roots with the default certificate authority (CA) roots bundled with Requests. If `REQUESTS_CA_BUNDLE` is configured, Prowler CLI also loads that file or directory into the ingestion context. Otherwise, Prowler CLI loads `CURL_CA_BUNDLE` when configured.
`REQUESTS_CA_BUNDLE` and `CURL_CA_BUNDLE` can also affect other Requests-based connections throughout the Prowler CLI process, including provider authentication. A bundle containing only a private CA can cause connections to public services to fail before the upload starts. Installing the private CA in the operating system or container trust store is recommended. If a custom bundle is required, it must include both the public CA roots, such as the certifi bundle, and the required private CA certificates.
For Prowler Private Cloud deployments that use an organization CA or a TLS-intercepting corporate proxy, installing the required root CA in the operating system or container store remains the recommended approach. Containers have an isolated system CA store, so add the organization or proxy CA to the container image or runtime, then run the operating system's CA update command, such as `update-ca-certificates`, before starting Prowler CLI. Installing a CA on the container host does not automatically install it inside the container.
Prowler CLI does not create, modify, or remove these environment variables. The custom TLS context created by `--push-to-cloud` applies only to the temporary ingestion session and does not change API, provider, integration, global SSL, or unrelated Requests session behavior.
### Combining with Output Formats
When using `--push-to-cloud` with custom output formats that exclude OCSF, Prowler generates a temporary OCSF file for upload:
+16
View File
@@ -29,6 +29,22 @@ FROM python:3.13.14-alpine3.23@sha256:9fdbf2e3e82628351513560b121e2ee6ce31cac212
LABEL maintainer="https://github.com/prowler-cloud"
# High CVEs fixed in Alpine 3.23 but not yet in the pinned base image:
# sqlite-libs 3.53.4-r0 CVE-2026-11822, CVE-2026-11824 (image ships 3.51.2-r0)
# libcrypto3/libssl3 3.5.8-r0 CVE-2026-14456 (image ships 3.5.7-r0)
# The base image pins python 3.13.14, which has not been rebuilt since those
# packages were published, so the upgrade is taken here rather than by moving
# the pin -- the newest published python:3.13-alpine3.23 carries the same
# vulnerable versions. libcrypto3 and libssl3 are both built from openssl and
# are flagged separately, so both are named.
# `>=` rather than `=`: Alpine keeps only the newest build of a package in a
# branch's index, so an exact pin breaks this build the day one of these is
# superseded. Drop an entry once the base image ships that version or later.
RUN apk add --no-cache --upgrade \
"sqlite-libs>=3.53.4-r0" \
"libcrypto3>=3.5.8-r0" \
"libssl3>=3.5.8-r0"
# Create non-root user for security
# Using specific UID/GID for consistency across environments
RUN addgroup -g 1001 prowler && \
@@ -0,0 +1 @@
`libcrypto3` and `libssl3` upgraded to 3.5.8-r0 in the container image, patching CVE-2026-14456
+37
View File
@@ -4,6 +4,43 @@ All notable changes to the **Prowler SDK** are documented in this file.
<!-- changelog: release notes start -->
## [5.39.1] (Prowler v5.39.1)
### 🐞 Fixed
- Bump alibabacloud-tea-openapi to 0.4.6, oci to 2.184.1 and pyopenssl to 26.4.0 so the published wheel installs with cryptography 50.0.0; 5.38.0 declared cryptography 50.0.0 while those packages capped it below 50, so pip could not install it and `pip install prowler` silently fell back to 5.37.1 [(#12477)](https://github.com/prowler-cloud/prowler/pull/12477)
- Pin zstd to 1.5.7.2; 1.5.7.3 was yanked from PyPI as not thread safe [(#12477)](https://github.com/prowler-cloud/prowler/pull/12477)
- ECS task-definition checks no longer report PASS when `DescribeTaskDefinition` fails before container evidence is gathered [(#12478)](https://github.com/prowler-cloud/prowler/pull/12478)
- `ses_identity_not_publicly_accessible` now evaluates every SES identity authorization policy and marks mixed public Allow and Deny statements for manual review [(#12480)](https://github.com/prowler-cloud/prowler/pull/12480)
### 🔐 Security
- Trivy from v0.72.0 to v0.73.0 in the container image, fixing HIGH CVE-2026-46600 in the bundled `golang.org/x/net` [(#12445)](https://github.com/prowler-cloud/prowler/pull/12445)
- Trivy v0.74.0 and Debian util-linux 2.41.5-0+deb13u1 in the SDK container image, patching Go standard library vulnerabilities and CVE-2026-53615 [(#12470)](https://github.com/prowler-cloud/prowler/pull/12470)
---
## [5.39.0] (Prowler v5.39.0)
### 🚀 Added
- `batch_job_definition_no_secrets` check for AWS provider, scanning Batch job definition environment variables and command parameters for hardcoded secrets [(#12117)](https://github.com/prowler-cloud/prowler/pull/12117)
- 7 M365 Entra checks covering CIS Microsoft 365 Foundations Benchmark v7.0.0 password protection, default user permissions, and guest invitation domain restrictions [(#12153)](https://github.com/prowler-cloud/prowler/pull/12153)
- 7 M365 entra checks covering CIS Microsoft 365 Foundations Benchmark v7.0.0 Conditional Access (5.2.2.x) and idle session timeout controls [(#12154)](https://github.com/prowler-cloud/prowler/pull/12154)
- `entra_authentication_method_email_otp_disabled`, `entra_authentication_method_authenticator_show_context`, `entra_pim_global_administrator_approval_required`, `entra_pim_privileged_role_administrator_approval_required`, `entra_access_review_guest_users_configured` and `entra_access_review_privileged_roles_configured` checks for M365 provider covering CIS Microsoft 365 Foundations Benchmark v7.0.0 authentication method, PIM approval and access review controls [(#12155)](https://github.com/prowler-cloud/prowler/pull/12155)
- `awslambda_layer_no_secrets_in_content` check for AWS provider, scanning Lambda layer package content for hardcoded secrets [(#12233)](https://github.com/prowler-cloud/prowler/pull/12233)
- CMMC 2.0 universal compliance framework (`cmmc_2.0`) with the 149 official requirements from 32 CFR Part 170 — Level 1 (15, 48 CFR 52.204-21), Level 2 (110, NIST SP 800-171 Rev 2) and Level 3 (24, NIST SP 800-172) — with AWS, Azure, GCP, Alibaba Cloud, Oracle Cloud and M365 check mappings and config guardrails [(#12401)](https://github.com/prowler-cloud/prowler/pull/12401)
### 🔄 Changed
- GitHub `organization_repository_creation_limited` check now reports low severity for FAIL findings when repository creation is provably limited to private/internal visibility, instead of always reporting high [(#12164)](https://github.com/prowler-cloud/prowler/pull/12164)
### 🔐 Security
- HTML report header now HTML-escapes every provider identity field across all 23 providers, closing a stored XSS in the header block (Secur0, CWE-79) that was left unaddressed by the earlier finding-row fix in #12221 [(#12424)](https://github.com/prowler-cloud/prowler/pull/12424)
---
## [5.38.0] (Prowler v5.38.0)
### 🚀 Added
+75 -34
View File
@@ -126,7 +126,10 @@ from prowler.lib.outputs.compliance.prowler_threatscore.prowler_threatscore_m365
from prowler.lib.outputs.csv.csv import CSV
from prowler.lib.outputs.finding import Finding
from prowler.lib.outputs.html.html import HTML
from prowler.lib.outputs.ocsf.ingestion import send_ocsf_to_api
from prowler.lib.outputs.ocsf.ingestion import (
SystemTrustStoreError,
send_ocsf_to_api,
)
from prowler.lib.outputs.ocsf.ocsf import OCSF
from prowler.lib.outputs.outputs import extract_findings_statistics, report
from prowler.lib.outputs.sarif.sarif import SARIF
@@ -162,6 +165,75 @@ from prowler.providers.stackit.models import StackITOutputOptions
from prowler.providers.vercel.models import VercelOutputOptions
def _send_ocsf_to_cloud(file_path: str) -> dict | None:
"""Upload OCSF findings and report safe, actionable CLI failures."""
try:
return send_ocsf_to_api(file_path)
except requests.exceptions.JSONDecodeError:
print(
f"{Style.BRIGHT}{Fore.RED}\nPush to Prowler Cloud failed: "
"the API returned an invalid JSON response. "
f"Scan results were saved to {file_path}{Style.RESET_ALL}"
)
except ValueError:
print(
f"{Style.BRIGHT}{Fore.YELLOW}\nPush to Prowler Cloud skipped: no API key configured. "
"Set the PROWLER_CLOUD_API_KEY environment variable to enable it. "
f"Scan results were saved to {file_path}{Style.RESET_ALL}"
)
except SystemTrustStoreError:
print(
f"{Style.BRIGHT}{Fore.RED}\nPush to Prowler Cloud failed: the operating system trust store "
"could not be initialized. Check the configured CA bundle paths, verify the host certificate "
"hostname, validity period, and certificate chain, and install the organization or TLS-intercepting "
"proxy CA in the operating system trust store. In containers, configure and update the container "
"system CA store. "
f"Scan results were saved to {file_path}{Style.RESET_ALL}"
)
except requests.exceptions.SSLError:
print(
f"{Style.BRIGHT}{Fore.RED}\nPush to Prowler Cloud failed: TLS certificate validation failed. "
"Verify the hostname, validity period, and certificate chain, and install the organization or "
"TLS-intercepting proxy CA in the operating system trust store. In containers, configure and update "
"the container system CA store. "
f"Scan results were saved to {file_path}{Style.RESET_ALL}"
)
except requests.ConnectionError:
print(
f"{Style.BRIGHT}{Fore.RED}\nPush to Prowler Cloud failed: could not reach the Prowler Cloud API at "
f"{cloud_api_base_url}. Check the URL and your network connection. "
f"Scan results were saved to {file_path}{Style.RESET_ALL}"
)
except requests.HTTPError as http_err:
status_code = (
http_err.response.status_code if http_err.response is not None else None
)
if status_code == 402:
print(
f"{Style.BRIGHT}{Fore.RED}\nPush to Prowler Cloud failed: "
"this feature is only available with a Prowler Cloud subscription. "
f"Scan results were saved to {file_path}{Style.RESET_ALL}"
)
elif status_code is None:
print(
f"{Style.BRIGHT}{Fore.RED}\nPush to Prowler Cloud failed: "
"the API request failed without a response status. "
f"Scan results were saved to {file_path}{Style.RESET_ALL}"
)
else:
print(
f"{Style.BRIGHT}{Fore.RED}\nPush to Prowler Cloud failed: the API returned HTTP "
f"{status_code}. Verify your API key is valid and has the right permissions. "
f"Scan results were saved to {file_path}{Style.RESET_ALL}"
)
except Exception:
print(
f"{Style.BRIGHT}{Fore.RED}\nPush to Prowler Cloud failed unexpectedly. "
f"Scan results were saved to {file_path}{Style.RESET_ALL}"
)
return None
def prowler():
# Parse Arguments
# Refactor(CLI)
@@ -641,39 +713,8 @@ def prowler():
print(
f"{Style.BRIGHT}\nPushing findings to Prowler Cloud, please wait...{Style.RESET_ALL}"
)
try:
response = send_ocsf_to_api(ocsf_output.file_path)
except ValueError:
print(
f"{Style.BRIGHT}{Fore.YELLOW}\nPush to Prowler Cloud skipped: no API key configured. "
"Set the PROWLER_CLOUD_API_KEY environment variable to enable it. "
f"Scan results were saved to {ocsf_output.file_path}{Style.RESET_ALL}"
)
except requests.ConnectionError:
print(
f"{Style.BRIGHT}{Fore.RED}\nPush to Prowler Cloud failed: could not reach the Prowler Cloud API at "
f"{cloud_api_base_url}. Check the URL and your network connection. "
f"Scan results were saved to {ocsf_output.file_path}{Style.RESET_ALL}"
)
except requests.HTTPError as http_err:
if http_err.response.status_code == 402:
print(
f"{Style.BRIGHT}{Fore.RED}\nPush to Prowler Cloud failed: "
"this feature is only available with a Prowler Cloud subscription. "
f"Scan results were saved to {ocsf_output.file_path}{Style.RESET_ALL}"
)
else:
print(
f"{Style.BRIGHT}{Fore.RED}\nPush to Prowler Cloud failed: the API returned HTTP {http_err.response.status_code}. "
"Verify your API key is valid and has the right permissions. "
f"Scan results were saved to {ocsf_output.file_path}{Style.RESET_ALL}"
)
except Exception as error:
print(
f"{Style.BRIGHT}{Fore.RED}\nPush to Prowler Cloud failed unexpectedly: {error}. "
f"Scan results were saved to {ocsf_output.file_path}{Style.RESET_ALL}"
)
else:
response = _send_ocsf_to_cloud(ocsf_output.file_path)
if response is not None:
job_id = response.get("data", {}).get("id") if response else None
if job_id:
print(
@@ -1 +0,0 @@
`awslambda_layer_no_secrets_in_content` check for AWS provider, scanning Lambda layer package content for hardcoded secrets
@@ -1 +0,0 @@
`batch_job_definition_no_secrets` check for AWS provider, scanning Batch job definition environment variables and command parameters for hardcoded secrets
@@ -1 +0,0 @@
CMMC 2.0 universal compliance framework (`cmmc_2.0`) with the 149 official requirements from 32 CFR Part 170 — Level 1 (15, 48 CFR 52.204-21), Level 2 (110, NIST SP 800-171 Rev 2) and Level 3 (24, NIST SP 800-172) — with AWS, Azure, GCP, Alibaba Cloud, Oracle Cloud and M365 check mappings and config guardrails
@@ -0,0 +1 @@
AWS FSBP compliance mapping for `IAM.9` and `EKS.1` referenced missing/renamed checks; both now point to their real, existing check IDs
@@ -1 +0,0 @@
GitHub `organization_repository_creation_limited` check now reports low severity for FAIL findings when repository creation is provably limited to private/internal visibility, instead of always reporting high
@@ -1 +0,0 @@
`entra_authentication_method_email_otp_disabled`, `entra_authentication_method_authenticator_show_context`, `entra_pim_global_administrator_approval_required`, `entra_pim_privileged_role_administrator_approval_required`, `entra_access_review_guest_users_configured` and `entra_access_review_privileged_roles_configured` checks for M365 provider covering CIS Microsoft 365 Foundations Benchmark v7.0.0 authentication method, PIM approval and access review controls
@@ -1 +0,0 @@
7 M365 entra checks covering CIS Microsoft 365 Foundations Benchmark v7.0.0 Conditional Access (5.2.2.x) and idle session timeout controls
@@ -1 +0,0 @@
7 M365 Entra checks covering CIS Microsoft 365 Foundations Benchmark v7.0.0 password protection, default user permissions, and guest invitation domain restrictions
@@ -0,0 +1 @@
OSS bucket logging and ACL configurations are now read correctly from the Alibaba Cloud SDK, so `oss_bucket_logging_enabled` and `oss_bucket_not_publicly_accessible` no longer report every bucket as unconfigured
@@ -0,0 +1 @@
`push-to-cloud` now validates Private Cloud TLS certificates with the operating system trust store without changing provider HTTP clients
@@ -0,0 +1 @@
`openssl`, `libssl3t64` and `openssl-provider-legacy` upgraded to 3.5.7-1~deb13u2 in the SDK container image, patching ten high OpenSSL CVEs
@@ -0,0 +1 @@
`prowler.compliance.universal` entry point directories are resolved through a single shared helper and deduplicated by resolved path, so a directory reached through two entry points is parsed once and a package that fails to import no longer hides the rest
@@ -1782,7 +1782,7 @@
"Name": "EKS cluster endpoints should not be publicly accessible",
"Description": "This control checks whether an Amazon EKS cluster endpoint is publicly accessible. The control fails if an EKS cluster has an endpoint that is publicly accessible.",
"Checks": [
"eks_endpoints_not_publicly_accessible"
"eks_cluster_not_publicly_accessible"
],
"Attributes": [
{
@@ -2634,7 +2634,9 @@
"Id": "IAM.9",
"Name": "MFA should be enabled for the root user",
"Description": "The root user has complete access to all the services and resources in an AWS account. MFA adds an extra layer of protection on top of a user name and password. With MFA enabled, when a user signs in to the AWS Management Console, they're prompted for their user name and password and for an authentication code from their AWS MFA device.",
"Checks": [],
"Checks": [
"iam_root_mfa_enabled"
],
"Attributes": [
{
"ItemId": "IAM.9",
+6 -17
View File
@@ -10,7 +10,10 @@ import requests
import yaml
from packaging import version
from prowler.lib.check.compliance_models import load_compliance_framework_universal
from prowler.lib.check.compliance_models import (
get_universal_compliance_entry_point_dirs,
load_compliance_framework_universal,
)
# Re-exported from a leaf module so prowler.lib.check.utils can import the
# constant without participating in the config <-> compliance_models <-> utils
@@ -49,7 +52,7 @@ class _MutableTimestamp:
timestamp = _MutableTimestamp(datetime.today())
timestamp_utc = _MutableTimestamp(datetime.now(timezone.utc))
prowler_version = "5.39.0"
prowler_version = "5.39.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"
@@ -170,21 +173,7 @@ def get_available_compliance_frameworks(provider=None):
available_compliance_frameworks.append(name)
# External multi-provider frameworks via the dedicated universal group;
# filtered by supports_provider when a provider is given.
for ep in importlib.metadata.entry_points(group="prowler.compliance.universal"):
try:
module = ep.load()
path = (
module.__path__[0]
if hasattr(module, "__path__")
else os.path.dirname(module.__file__)
)
except Exception as error:
logger.warning(
f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
)
continue
if not os.path.isdir(path):
continue
for path in get_universal_compliance_entry_point_dirs():
for file in os.scandir(path):
if file.is_file() and file.name.endswith(".json"):
name = file.name.removesuffix(".json")
+43 -13
View File
@@ -1049,6 +1049,46 @@ def load_compliance_framework_universal(path: str) -> ComplianceFramework:
return None
# Kept apart from the per-provider `prowler.compliance` group so the legacy
# loader never parses a universal JSON.
UNIVERSAL_COMPLIANCE_ENTRY_POINT_GROUP = "prowler.compliance.universal"
def get_universal_compliance_entry_point_dirs() -> list[str]:
"""Existing directories contributed through the universal compliance entry
point group, in entry point order.
Deduped by resolved path, so a directory reached through a symlink counts
once. A package that fails to import is logged and skipped: one broken
plugin must not hide the rest.
"""
dirs = []
seen = set()
for ep in importlib.metadata.entry_points(
group=UNIVERSAL_COMPLIANCE_ENTRY_POINT_GROUP
):
try:
module = ep.load()
path = (
module.__path__[0]
if hasattr(module, "__path__")
else os.path.dirname(module.__file__)
)
except Exception as error:
logger.warning(
f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
)
continue
if not os.path.isdir(path):
continue
resolved = os.path.realpath(path)
if resolved in seen:
continue
seen.add(resolved)
dirs.append(path)
return dirs
def _load_jsons_from_dir(dir_path: str, provider: str, bulk: dict) -> None:
"""Scan *dir_path* for JSON files and add matching frameworks to *bulk*."""
for filename in os.listdir(dir_path):
@@ -1109,20 +1149,10 @@ def get_bulk_compliance_frameworks_universal(provider: str) -> dict:
if compliance_root and os.path.isdir(compliance_root):
_load_jsons_from_dir(compliance_root, provider, bulk)
# External multi-provider frameworks via the dedicated universal entry
# point group, kept separate from the per-provider `prowler.compliance`
# group so the legacy loader never parses a universal JSON. Built-ins
# (already in bulk) win on a name collision.
for ep in importlib.metadata.entry_points(group="prowler.compliance.universal"):
# Built-ins are already in `bulk` and win on a name collision.
for ep_dir in get_universal_compliance_entry_point_dirs():
try:
module = ep.load()
ep_dir = (
module.__path__[0]
if hasattr(module, "__path__")
else os.path.dirname(module.__file__)
)
if os.path.isdir(ep_dir):
_load_jsons_from_dir(ep_dir, provider, bulk)
_load_jsons_from_dir(ep_dir, provider, bulk)
except Exception as error:
logger.warning(
f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
+150 -65
View File
@@ -463,6 +463,11 @@ class HTML(Output):
audited_regions = "All Regions"
else:
audited_regions = ", ".join(provider.identity.audited_regions)
account = escape(str(provider.identity.account))
profile = escape(str(profile))
audited_regions = escape(str(audited_regions))
user_id = escape(str(provider.identity.user_id))
identity_arn = escape(str(provider.identity.identity_arn))
return f"""
<div class="col-md-2">
<div class="card">
@@ -471,7 +476,7 @@ class HTML(Output):
</div>
<ul class="list-group list-group-flush">
<li class="list-group-item">
<b>AWS Account:</b> {provider.identity.account}
<b>AWS Account:</b> {account}
</li>
<li class="list-group-item">
<b>AWS-CLI Profile:</b> {profile}
@@ -489,10 +494,10 @@ class HTML(Output):
</div>
<ul class="list-group list-group-flush">
<li class="list-group-item">
<b>User Id:</b> {provider.identity.user_id}
<b>User Id:</b> {user_id}
</li>
<li class="list-group-item">
<b>Caller Identity ARN:</b> {provider.identity.identity_arn}
<b>Caller Identity ARN:</b> {identity_arn}
</li>
</ul>
</div>
@@ -530,6 +535,11 @@ class HTML(Output):
)
else:
html_identity = provider.identity.identity_id
tenant_ids = escape(" ".join(provider.identity.tenant_ids))
tenant_domain = escape(str(provider.identity.tenant_domain))
subscriptions = escape(" ".join(printed_subscriptions))
identity_type = escape(str(provider.identity.identity_type))
html_identity = escape(str(html_identity))
return f"""
<div class="col-md-2">
<div class="card">
@@ -538,13 +548,13 @@ class HTML(Output):
</div>
<ul class="list-group list-group-flush">
<li class="list-group-item">
<b>Azure Tenant IDs:</b> {" ".join(provider.identity.tenant_ids)}
<b>Azure Tenant IDs:</b> {tenant_ids}
</li>
<li class="list-group-item">
<b>Azure Tenant Domain:</b> {provider.identity.tenant_domain}
<b>Azure Tenant Domain:</b> {tenant_domain}
</li>
<li class="list-group-item">
<b>Azure Subscriptions:</b> {" ".join(printed_subscriptions)}
<b>Azure Subscriptions:</b> {subscriptions}
</li>
</ul>
</div>
@@ -556,7 +566,7 @@ class HTML(Output):
</div>
<ul class="list-group list-group-flush">
<li class="list-group-item">
<b>Azure Identity Type:</b> {provider.identity.identity_type}
<b>Azure Identity Type:</b> {identity_type}
</li>
<li class="list-group-item">
<b>Azure Identity ID:</b> {html_identity}
@@ -591,6 +601,8 @@ class HTML(Output):
)
except AttributeError:
profile = "default"
project_ids = escape(", ".join(provider.project_ids))
profile = escape(str(profile))
return f"""
<div class="col-md-2">
<div class="card">
@@ -599,7 +611,7 @@ class HTML(Output):
</div>
<ul class="list-group list-group-flush">
<li class="list-group-item">
<b>GCP Project IDs:</b> {", ".join(provider.project_ids)}
<b>GCP Project IDs:</b> {project_ids}
</li>
</ul>
</div>
@@ -634,6 +646,8 @@ class HTML(Output):
str: the HTML assessment summary
"""
try:
cluster = escape(str(provider.identity.cluster))
context = escape(str(provider.identity.context))
return f"""
<div class="col-md-2">
<div class="card">
@@ -643,7 +657,7 @@ class HTML(Output):
<ul class="list-group
list-group-flush">
<li class="list-group-item">
<b>Kubernetes Cluster:</b> {provider.identity.cluster}
<b>Kubernetes Cluster:</b> {cluster}
</li>
</ul>
</div>
@@ -656,7 +670,7 @@ class HTML(Output):
<ul class="list-group
list-group-flush">
<li class="list-group-item">
<b>Kubernetes Context:</b> {provider.identity.context}
<b>Kubernetes Context:</b> {context}
</li>
</ul>
</div>
@@ -679,11 +693,13 @@ class HTML(Output):
str: the HTML assessment summary
"""
try:
auth_method = escape(str(provider.auth_method))
if hasattr(provider.identity, "account_name"):
# GithubIdentityInfo (Personal Access Token, OAuth)
account_name = escape(str(provider.identity.account_name))
account_info_items = f"""
<li class="list-group-item">
<b>GitHub account:</b> {provider.identity.account_name}
<b>GitHub account:</b> {account_name}
</li>
"""
# Add email if available
@@ -691,23 +707,27 @@ class HTML(Output):
hasattr(provider.identity, "account_email")
and provider.identity.account_email
):
account_email = escape(str(provider.identity.account_email))
account_info_items += f"""
<li class="list-group-item">
<b>GitHub account email:</b> {provider.identity.account_email}
<b>GitHub account email:</b> {account_email}
</li>"""
elif hasattr(provider.identity, "app_id"):
# GithubAppIdentityInfo (GitHub App)
# Assessment items: App Name and Installations
app_name = escape(str(provider.identity.app_name))
account_info_items = f"""
<li class="list-group-item">
<b>GitHub App Name:</b> {provider.identity.app_name}
<b>GitHub App Name:</b> {app_name}
</li>"""
# Add installations if available
if (
hasattr(provider.identity, "installations")
and provider.identity.installations
):
installations_display = ", ".join(provider.identity.installations)
installations_display = escape(
", ".join(provider.identity.installations)
)
account_info_items += f"""
<li class="list-group-item">
<b>Installations:</b> {installations_display}
@@ -719,26 +739,27 @@ class HTML(Output):
</li>"""
# Credentials items: Authentication method and App ID
app_id = escape(str(provider.identity.app_id))
credentials_items = f"""
<li class="list-group-item">
<b>GitHub authentication method:</b> {provider.auth_method}
<b>GitHub authentication method:</b> {auth_method}
</li>
<li class="list-group-item">
<b>GitHub App ID:</b> {provider.identity.app_id}
<b>GitHub App ID:</b> {app_id}
</li>"""
else:
# Fallback for other identity types
account_info_items = ""
credentials_items = f"""
<li class="list-group-item">
<b>GitHub authentication method:</b> {provider.auth_method}
<b>GitHub authentication method:</b> {auth_method}
</li>"""
# For PAT/OAuth, use default credentials structure
if hasattr(provider.identity, "account_name"):
credentials_items = f"""
<li class="list-group-item">
<b>GitHub authentication method:</b> {provider.auth_method}
<b>GitHub authentication method:</b> {auth_method}
</li>"""
return f"""
@@ -779,6 +800,18 @@ class HTML(Output):
str: the HTML assessment summary
"""
try:
tenant_domain = escape(str(provider.identity.tenant_domain))
identity_type = escape(str(provider.identity.identity_type))
identity_id = escape(str(provider.identity.identity_id))
user_item = ""
if (
hasattr(provider.identity, "user")
and provider.identity.user is not None
):
user = escape(str(provider.identity.user))
user_item = f"""<li class="list-group-item">
<b>M365 User:</b> {user}
</li>"""
return f"""
<div class="col-md-2">
<div class="card">
@@ -787,9 +820,7 @@ class HTML(Output):
</div>
<ul class="list-group list-group-flush">
<li class="list-group-item">
<b>M365 Tenant Domain:</b> {
provider.identity.tenant_domain
}
<b>M365 Tenant Domain:</b> {tenant_domain}
</li>
</ul>
</div>
@@ -801,19 +832,12 @@ class HTML(Output):
</div>
<ul class="list-group list-group-flush">
<li class="list-group-item">
<b>M365 Identity Type:</b> {provider.identity.identity_type}
<b>M365 Identity Type:</b> {identity_type}
</li>
<li class="list-group-item">
<b>M365 Identity ID:</b> {provider.identity.identity_id}
<b>M365 Identity ID:</b> {identity_id}
</li>
{
f'''<li class="list-group-item">
<b>M365 User:</b> {provider.identity.user}
</li>'''
if hasattr(provider.identity, "user")
and provider.identity.user is not None
else ""
}
{user_item}
</ul>
</div>
</div>"""
@@ -834,6 +858,9 @@ class HTML(Output):
str: the HTML assessment summary
"""
try:
tenant_domain = escape(str(provider.identity.tenant_domain))
identity_type = escape(str(provider.identity.identity_type))
identity_id = escape(str(provider.identity.identity_id))
return f"""
<div class="col-md-2">
<div class="card">
@@ -842,7 +869,7 @@ class HTML(Output):
</div>
<ul class="list-group list-group-flush">
<li class="list-group-item">
<b>NHN Tenant Domain:</b> {provider.identity.tenant_domain}
<b>NHN Tenant Domain:</b> {tenant_domain}
</li>
</ul>
</div>
@@ -854,10 +881,10 @@ class HTML(Output):
</div>
<ul class="list-group list-group-flush">
<li class="list-group-item">
<b>NHN Identity Type:</b> {provider.identity.identity_type}
<b>NHN Identity Type:</b> {identity_type}
</li>
<li class="list-group-item">
<b>NHN Identity ID:</b> {provider.identity.identity_id}
<b>NHN Identity ID:</b> {identity_id}
</li>
</ul>
</div>
@@ -880,6 +907,7 @@ class HTML(Output):
str: the HTML assessment summary
"""
try:
organization_name = escape(str(provider.identity.organization_name))
return f"""
<div class="col-md-2">
<div class="card">
@@ -889,7 +917,7 @@ class HTML(Output):
<ul class="list-group
list-group-flush">
<li class="list-group-item">
<b>MongoDB Atlas organization:</b> {provider.identity.organization_name}
<b>MongoDB Atlas organization:</b> {organization_name}
</li>
</ul>
</div>
@@ -925,6 +953,13 @@ class HTML(Output):
str: the HTML assessment summary
"""
try:
if provider.scan_repository_url:
target_info = "<b>IAC repository URL:</b> " + str(
escape(str(provider.scan_repository_url))
)
else:
target_info = "<b>IAC path:</b> " + str(escape(str(provider.scan_path)))
auth_method = escape(str(provider.auth_method))
return f"""
<div class="col-md-2">
<div class="card">
@@ -934,7 +969,7 @@ class HTML(Output):
<ul class="list-group
list-group-flush">
<li class="list-group-item">
{"<b>IAC repository URL:</b> " + provider.scan_repository_url if provider.scan_repository_url else "<b>IAC path:</b> " + provider.scan_path}
{target_info}
</li>
</ul>
</div>
@@ -947,7 +982,7 @@ class HTML(Output):
<ul class="list-group
list-group-flush">
<li class="list-group-item">
<b>IAC authentication method:</b> {provider.auth_method}
<b>IAC authentication method:</b> {auth_method}
</li>
</ul>
</div>
@@ -971,10 +1006,13 @@ class HTML(Output):
"""
try:
if provider.registry:
target_info = f"<b>Registry URL:</b> {provider.registry}"
registry = escape(str(provider.registry))
target_info = f"<b>Registry URL:</b> {registry}"
else:
target_info = f'<b>Images:</b> {", ".join(provider.images)}'
images = escape(", ".join(provider.images))
target_info = f"<b>Images:</b> {images}"
auth_method = escape(str(provider.auth_method))
return f"""
<div class="col-md-2">
<div class="card">
@@ -997,7 +1035,7 @@ class HTML(Output):
<ul class="list-group
list-group-flush">
<li class="list-group-item">
<b>Image authentication method:</b> {provider.auth_method}
<b>Image authentication method:</b> {auth_method}
</li>
</ul>
</div>
@@ -1020,6 +1058,14 @@ class HTML(Output):
str: HTML assessment summary for the LLM provider
"""
try:
model = escape(str(provider.model))
plugins = escape(", ".join(provider.plugins))
max_concurrency = escape(str(provider.max_concurrency))
config_file = escape(
str(provider.config_path)
if provider.config_path
else "Using promptfoo defaults"
)
return f"""
<div class="card">
<div class="card-header">
@@ -1031,16 +1077,16 @@ class HTML(Output):
<ul class="list-group
list-group-flush">
<li class="list-group-item">
<b>Target LLM:</b> {provider.model}
<b>Target LLM:</b> {model}
</li>
<li class="list-group-item">
<b>Plugins:</b> {", ".join(provider.plugins)}
<b>Plugins:</b> {plugins}
</li>
<li class="list-group-item">
<b>Max concurrency:</b> {provider.max_concurrency}
<b>Max concurrency:</b> {max_concurrency}
</li>
<li class="list-group-item">
<b>Config file:</b> {provider.config_path if provider.config_path else "Using promptfoo defaults"}
<b>Config file:</b> {config_file}
</li>
</ul>
</div>
@@ -1069,6 +1115,10 @@ class HTML(Output):
tenancy_name = getattr(provider.identity, "tenancy_name", "unknown")
tenancy_id = getattr(provider.identity, "tenancy_id", "unknown")
tenancy = escape(
str(tenancy_name if tenancy_name != "unknown" else tenancy_id)
)
profile = escape(str(profile))
return f"""
<div class="col-md-2">
<div class="card">
@@ -1077,7 +1127,7 @@ class HTML(Output):
</div>
<ul class="list-group list-group-flush">
<li class="list-group-item">
<b>OracleCloud Tenancy:</b> {tenancy_name if tenancy_name != "unknown" else tenancy_id}
<b>OracleCloud Tenancy:</b> {tenancy}
</li>
</ul>
</div>
@@ -1116,10 +1166,11 @@ class HTML(Output):
project_name = getattr(provider.identity, "project_name", "")
audited_regions = getattr(provider.identity, "audited_regions", set())
project_id = escape(str(project_id))
project_name_item = (
f"""
<li class="list-group-item">
<b>Project Name:</b> {project_name}
<b>Project Name:</b> {escape(str(project_name))}
</li>"""
if project_name
else ""
@@ -1128,7 +1179,7 @@ class HTML(Output):
regions_item = (
f"""
<li class="list-group-item">
<b>Regions:</b> {", ".join(sorted(audited_regions))}
<b>Regions:</b> {escape(", ".join(sorted(audited_regions)))}
</li>"""
if audited_regions
else ""
@@ -1182,7 +1233,7 @@ class HTML(Output):
# Build assessment summary items (only non-None values)
assessment_items = ""
if provider.accounts:
accounts = ", ".join([acc.id for acc in provider.accounts])
accounts = escape(", ".join([str(acc.id) for acc in provider.accounts]))
assessment_items += f"""
<li class="list-group-item">
<b>Accounts:</b> {accounts}
@@ -1208,6 +1259,7 @@ class HTML(Output):
provider.session, "api_email", None
)
if email:
email = escape(str(email))
credentials_items += f"""
<li class="list-group-item">
<b>Email:</b> {email}
@@ -1261,11 +1313,15 @@ class HTML(Output):
account_name_item = (
f"""
<li class="list-group-item">
<b>Account Name:</b> {account_name}
<b>Account Name:</b> {escape(str(account_name))}
</li>"""
if account_name
else ""
)
account_id = escape(str(account_id))
audited_regions = escape(str(audited_regions))
user_name = escape(str(user_name))
identity_arn = escape(str(identity_arn))
return f"""
<div class="col-md-2">
@@ -1326,7 +1382,7 @@ class HTML(Output):
project_name_item = (
f"""
<li class="list-group-item">
<b>Project Name:</b> {project_name}
<b>Project Name:</b> {escape(str(project_name))}
</li>"""
if project_name
else ""
@@ -1335,11 +1391,14 @@ class HTML(Output):
user_id_item = (
f"""
<li class="list-group-item">
<b>User ID:</b> {user_id}
<b>User ID:</b> {escape(str(user_id))}
</li>"""
if user_id
else ""
)
project_id = escape(str(project_id))
region_name = escape(str(region_name))
username = escape(str(username))
return f"""
<div class="col-md-2">
@@ -1389,6 +1448,9 @@ class HTML(Output):
str: HTML assessment summary for the Google Workspace provider
"""
try:
domain = escape(str(provider.identity.domain))
customer_id = escape(str(provider.identity.customer_id))
delegated_user = escape(str(provider.identity.delegated_user))
return f"""
<div class="col-md-2">
<div class="card">
@@ -1397,10 +1459,10 @@ class HTML(Output):
</div>
<ul class="list-group list-group-flush">
<li class="list-group-item">
<b>Domain:</b> {provider.identity.domain}
<b>Domain:</b> {domain}
</li>
<li class="list-group-item">
<b>Customer ID:</b> {provider.identity.customer_id}
<b>Customer ID:</b> {customer_id}
</li>
</ul>
</div>
@@ -1412,7 +1474,7 @@ class HTML(Output):
</div>
<ul class="list-group list-group-flush">
<li class="list-group-item">
<b>Delegated User:</b> {provider.identity.delegated_user}
<b>Delegated User:</b> {delegated_user}
</li>
<li class="list-group-item">
<b>Authentication Method:</b> Service Account with Domain-Wide Delegation
@@ -1482,9 +1544,11 @@ class HTML(Output):
team = getattr(provider.identity, "team", None)
if team:
team_name = escape(str(team.name))
team_id = escape(str(team.id))
assessment_items += f"""
<li class="list-group-item">
<b>Team:</b> {team.name} ({team.id})
<b>Team:</b> {team_name} ({team_id})
</li>"""
credentials_items = """
@@ -1494,6 +1558,7 @@ class HTML(Output):
email = getattr(provider.identity, "email", None)
if email:
email = escape(str(email))
credentials_items += f"""
<li class="list-group-item">
<b>Email:</b> {email}
@@ -1501,6 +1566,7 @@ class HTML(Output):
username = getattr(provider.identity, "username", None)
if username:
username = escape(str(username))
credentials_items += f"""
<li class="list-group-item">
<b>Username:</b> {username}
@@ -1543,17 +1609,20 @@ class HTML(Output):
str: HTML assessment summary for the Okta provider
"""
try:
org_domain = escape(str(provider.identity.org_domain))
auth_method = escape(str(provider.auth_method))
client_id = escape(str(provider.identity.client_id))
assessment_items = f"""
<li class="list-group-item">
<b>Okta Domain:</b> {provider.identity.org_domain}
<b>Okta Domain:</b> {org_domain}
</li>"""
credentials_items = f"""
<li class="list-group-item">
<b>Authentication:</b> {provider.auth_method}
<b>Authentication:</b> {auth_method}
</li>
<li class="list-group-item">
<b>Client ID:</b> {provider.identity.client_id}
<b>Client ID:</b> {client_id}
</li>"""
return f"""
@@ -1593,9 +1662,10 @@ class HTML(Output):
str: HTML assessment summary for the Scaleway provider
"""
try:
organization_id = escape(str(provider.identity.organization_id))
assessment_items = f"""
<li class="list-group-item">
<b>Organization ID:</b> {provider.identity.organization_id}
<b>Organization ID:</b> {organization_id}
</li>"""
credentials_items = """
@@ -1605,6 +1675,7 @@ class HTML(Output):
access_key = getattr(provider.session, "access_key", None)
if access_key:
access_key = escape(str(access_key))
credentials_items += f"""
<li class="list-group-item">
<b>Access Key:</b> {access_key}
@@ -1615,6 +1686,8 @@ class HTML(Output):
bearer_id = getattr(provider.identity, "bearer_id", None)
if bearer_type:
bearer_label = bearer_email or bearer_id or "-"
bearer_type = escape(str(bearer_type))
bearer_label = escape(str(bearer_label))
credentials_items += f"""
<li class="list-group-item">
<b>Bearer:</b> {bearer_type} ({bearer_label})
@@ -1622,6 +1695,7 @@ class HTML(Output):
region = getattr(provider.session, "default_region", None)
if region:
region = escape(str(region))
credentials_items += f"""
<li class="list-group-item">
<b>Default Region:</b> {region}
@@ -1668,6 +1742,9 @@ class HTML(Output):
email = getattr(provider.identity, "email", None) or "-"
account_id = getattr(provider.identity, "account_id", None) or "-"
username = escape(str(username))
email = escape(str(email))
account_id = escape(str(account_id))
assessment_items = f"""
<li class="list-group-item">
<b>Account ID:</b> {account_id}
@@ -1732,6 +1809,14 @@ class HTML(Output):
audited_regions = "All Regions"
else:
audited_regions = ", ".join(provider.identity.regions)
account_id = escape(str(provider.identity.account_id))
account_name = escape(str(provider.identity.account_name))
profile = escape(str(profile))
audited_regions = escape(str(audited_regions))
domain_id = escape(str(provider.identity.domain_id))
user_id = escape(str(provider.identity.user_id))
user_name = escape(str(provider.identity.user_name))
identity_type = escape(str(provider.identity.identity_type))
return f"""
<div class="col-md-2">
<div class="card">
@@ -1740,10 +1825,10 @@ class HTML(Output):
</div>
<ul class="list-group list-group-flush">
<li class="list-group-item">
<b>Account ID:</b> {provider.identity.account_id}
<b>Account ID:</b> {account_id}
</li>
<li class="list-group-item">
<b>Account Name:</b> {provider.identity.account_name}
<b>Account Name:</b> {account_name}
</li>
<li class="list-group-item">
<b>Profile:</b> {profile}
@@ -1761,16 +1846,16 @@ class HTML(Output):
</div>
<ul class="list-group list-group-flush">
<li class="list-group-item">
<b>Domain ID:</b> {provider.identity.domain_id}
<b>Domain ID:</b> {domain_id}
</li>
<li class="list-group-item">
<b>User ID:</b> {provider.identity.user_id}
<b>User ID:</b> {user_id}
</li>
<li class="list-group-item">
<b>User Name:</b> {provider.identity.user_name}
<b>User Name:</b> {user_name}
</li>
<li class="list-group-item">
<b>Identity Type:</b> {provider.identity.identity_type}
<b>Identity Type:</b> {identity_type}
</li>
</ul>
</div>
+141 -12
View File
@@ -1,7 +1,9 @@
import os
import ssl
from typing import Any, Dict, Optional
import requests
from requests.adapters import HTTPAdapter
from prowler.config.config import (
cloud_api_base_url,
@@ -10,6 +12,116 @@ from prowler.config.config import (
)
class SystemTrustStoreError(RuntimeError):
"""Raised when the operating system trust store cannot be initialized."""
def _load_ca_bundle(ssl_context: ssl.SSLContext, ca_bundle_path: str) -> None:
"""Add a CA bundle file or directory to an existing TLS context."""
if os.path.isfile(ca_bundle_path):
ssl_context.load_verify_locations(cafile=ca_bundle_path)
elif os.path.isdir(ca_bundle_path):
ssl_context.load_verify_locations(capath=ca_bundle_path)
else:
raise FileNotFoundError
class _SystemTrustHTTPAdapter(HTTPAdapter):
"""Use one combined trust context for HTTPS origin connection pools."""
def __init__(self, ssl_context: ssl.SSLContext) -> None:
"""Initialize the adapter with a preconfigured TLS context.
Args:
ssl_context: TLS context used for HTTPS connections.
"""
self._ssl_context = ssl_context
super().__init__()
def build_connection_pool_key_attributes(
self,
request: requests.PreparedRequest,
verify: Any,
cert: Any = None,
) -> tuple[Dict[str, Any], Dict[str, Any]]:
"""Build pool attributes that enforce the configured TLS context.
Args:
request: Prepared request used to derive host parameters.
verify: Certificate verification setting supplied by Requests.
cert: Optional client certificate configuration.
Returns:
Host parameters and connection pool keyword arguments.
"""
verify = True
host_params, pool_kwargs = super().build_connection_pool_key_attributes(
request, verify, cert
)
pool_kwargs["ssl_context"] = self._ssl_context
pool_kwargs["cert_reqs"] = "CERT_REQUIRED"
pool_kwargs.pop("ca_certs", None)
pool_kwargs.pop("ca_cert_dir", None)
return host_params, pool_kwargs
def cert_verify(self, conn: Any, url: str, verify: Any, cert: Any) -> None:
"""Keep certificate verification in the supplied native context."""
if verify is not True:
raise ValueError("TLS certificate verification is required for ingestion.")
conn.cert_reqs = "CERT_REQUIRED"
conn.ca_certs = None
conn.ca_cert_dir = None
def proxy_manager_for(self, proxy: str, **proxy_kwargs: Any) -> Any:
"""Use native trust for HTTPS proxies without changing SOCKS support."""
if proxy.lower().startswith("https://"):
proxy_kwargs.setdefault("proxy_ssl_context", self._ssl_context)
return super().proxy_manager_for(proxy, **proxy_kwargs)
class _SystemTrustSession(requests.Session):
"""Requests session using native trust augmented with compatibility roots."""
def __init__(self) -> None:
super().__init__()
try:
import truststore
ssl_context = truststore.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
_load_ca_bundle(ssl_context, requests.certs.where())
configured_ca_bundle = os.environ.get(
"REQUESTS_CA_BUNDLE"
) or os.environ.get("CURL_CA_BUNDLE")
if configured_ca_bundle:
_load_ca_bundle(ssl_context, configured_ca_bundle)
ssl_context.check_hostname = True
ssl_context.verify_mode = ssl.CERT_REQUIRED
except Exception as error:
self.close()
raise SystemTrustStoreError(
"Could not initialize the operating system trust store. "
"Check the configured CA bundle paths."
) from error
self.mount("https://", _SystemTrustHTTPAdapter(ssl_context))
def merge_environment_settings(
self,
url: str,
proxies: Optional[Dict[str, str]],
stream: Optional[bool],
verify: Any,
cert: Any,
) -> Dict[str, Any]:
"""Preserve environment proxies without replacing the prepared context."""
verify = False
settings = super().merge_environment_settings(
url, proxies, stream, verify, cert
)
settings["verify"] = True
return settings
def send_ocsf_to_api(
file_path: str,
*,
@@ -32,6 +144,7 @@ def send_ocsf_to_api(
Raises:
FileNotFoundError: If the OCSF file does not exist.
ValueError: If no API key is available.
SystemTrustStoreError: If the operating system trust store cannot initialize.
requests.HTTPError: If the API returns an error status.
"""
if not file_path:
@@ -53,15 +166,31 @@ def send_ocsf_to_api(
url = f"{base_url}{cloud_api_ingestion_path}"
with open(file_path, "rb") as fh:
response = requests.post(
url,
headers={
"Authorization": f"Api-Key {api_key}",
"Accept": "application/vnd.api+json",
},
files={"file": (os.path.basename(file_path), fh, "application/json")},
timeout=timeout,
)
response.raise_for_status()
return response.json() if response.text else {}
session = _SystemTrustSession()
try:
with open(file_path, "rb") as fh:
response = session.post(
url,
headers={
"Authorization": f"Api-Key {api_key}",
"Accept": "application/vnd.api+json",
},
files={
"file": (
os.path.basename(file_path),
fh,
"application/json",
)
},
timeout=timeout,
allow_redirects=False,
)
if 300 <= response.status_code < 400:
raise requests.HTTPError(
f"Prowler Cloud ingestion refused HTTP redirect {response.status_code}.",
response=response,
)
response.raise_for_status()
return response.json() if response.text else {}
finally:
session.close()
@@ -8,6 +8,8 @@ from threading import Lock
from typing import Optional
import requests
from alibabacloud_tea_openapi import models as open_api_models
from alibabacloud_tea_util import models as util_models
from defusedxml import ElementTree
from pydantic.v1 import BaseModel
@@ -131,58 +133,59 @@ class OSS(AlibabaCloudService):
)
return
def _get_bucket_subresource(self, bucket, action: str, subresource: str) -> dict:
"""Call a bucket sub-resource API (GET /?<subresource>) and return its parsed body.
The generated OSS SDK methods return empty response models for these
APIs: the OSS gateway keeps the XML root element when it deserializes
the body, while the generated response models expect its children at the
top level. Calling the shared ``execute`` path directly and unwrapping the
root element preserves the actual configuration.
Args:
bucket: Bucket to query.
action: OSS API action name (e.g. ``GetBucketLogging``).
subresource: Sub-resource query string (e.g. ``logging``).
Returns:
dict: Content of the XML root element, or an empty dict when the
response carries no configuration.
Raises:
Exception: Any error raised by the OSS SDK, including ``TeaException``
with the OSS error code for 4xx/5xx responses.
"""
oss_client = self.session.client("oss", bucket.region)
params = open_api_models.Params(
action=action,
version="2019-05-17",
protocol="HTTPS",
pathname=f"/?{subresource}",
method="GET",
auth_type="AK",
style="ROA",
req_body_type="xml",
body_type="xml",
)
request = open_api_models.OpenApiRequest(
host_map={"bucket": bucket.name}, headers={}
)
response = oss_client.execute(params, request, util_models.RuntimeOptions())
body = response.get("body") if isinstance(response, dict) else None
if not isinstance(body, dict):
return {}
if len(body) == 1:
root_content = next(iter(body.values()))
return root_content if isinstance(root_content, dict) else {}
return body
def _get_bucket_acl(self, bucket):
"""Get bucket ACL."""
"""Get bucket ACL (private, public-read or public-read-write)."""
logger.info(f"OSS - Getting ACL for bucket {bucket.name}...")
try:
# Get OSS client for the bucket's region
# OSS bucket operations use regional endpoint: oss-{region}.aliyuncs.com
oss_client = self.session.client("oss", bucket.region)
# Get bucket ACL
response = oss_client.get_bucket_acl(bucket.name)
if response and response.body:
# ACL can be retrieved from the response
# The ACL value is typically in the response body
acl_value = getattr(response.body, "acl", None)
if acl_value:
# ACL values: private, public-read, public-read-write
bucket.acl = acl_value
else:
# Try to get from access_control_list if available
acl_list = getattr(response.body, "access_control_list", None)
if acl_list:
grant = getattr(acl_list, "grant", None)
if grant:
# Check grants to determine ACL type
if isinstance(grant, list):
# Check if any grant has public access
for g in grant:
permission = getattr(g, "permission", "")
if permission in ["READ", "FULL_CONTROL"]:
if permission == "READ":
bucket.acl = "public-read"
else:
bucket.acl = "public-read-write"
break
else:
bucket.acl = "private"
else:
permission = getattr(grant, "permission", "")
if permission == "READ":
bucket.acl = "public-read"
elif permission == "FULL_CONTROL":
bucket.acl = "public-read-write"
else:
bucket.acl = "private"
else:
bucket.acl = "private"
else:
bucket.acl = "private"
else:
bucket.acl = "private"
acl_policy = self._get_bucket_subresource(bucket, "GetBucketAcl", "acl")
grant = (acl_policy.get("AccessControlList") or {}).get("Grant")
bucket.acl = str(grant) if grant else "private"
except Exception as error:
logger.error(
f"{bucket.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
@@ -219,67 +222,24 @@ class OSS(AlibabaCloudService):
bucket.policy = {}
def _get_bucket_logging(self, bucket):
"""Get bucket logging configuration using OSS SDK."""
"""Get bucket logging configuration."""
logger.info(f"OSS - Getting logging configuration for bucket {bucket.name}...")
try:
oss_client = self.session.client("oss", bucket.region)
response = oss_client.get_bucket_logging(bucket.name)
if response and response.body:
logging_enabled = None
if hasattr(response.body, "logging_enabled"):
logging_enabled = response.body.logging_enabled
elif hasattr(response.body, "loggingenabled"):
logging_enabled = response.body.loggingenabled
elif hasattr(response.body, "bucket_logging"):
logging_enabled = response.body.bucket_logging
if logging_enabled:
target_bucket = None
target_prefix = None
for attr_name in [
"target_bucket",
"targetBucket",
"target_bucket_name",
"targetBucketName",
]:
if hasattr(logging_enabled, attr_name):
target_bucket = getattr(logging_enabled, attr_name)
break
for attr_name in [
"target_prefix",
"targetPrefix",
"target_prefix_name",
"targetPrefixName",
]:
if hasattr(logging_enabled, attr_name):
target_prefix = getattr(logging_enabled, attr_name)
break
if target_bucket:
bucket.logging_enabled = True
bucket.logging_target_bucket = (
str(target_bucket) if target_bucket else ""
)
bucket.logging_target_prefix = (
str(target_prefix) if target_prefix else ""
)
else:
bucket.logging_enabled = False
bucket.logging_target_bucket = ""
bucket.logging_target_prefix = ""
else:
bucket.logging_enabled = False
bucket.logging_target_bucket = ""
bucket.logging_target_prefix = ""
logging_status = self._get_bucket_subresource(
bucket, "GetBucketLogging", "logging"
)
logging_enabled = logging_status.get("LoggingEnabled") or {}
target_bucket = logging_enabled.get("TargetBucket")
if target_bucket:
bucket.logging_enabled = True
bucket.logging_target_bucket = str(target_bucket)
bucket.logging_target_prefix = str(
logging_enabled.get("TargetPrefix") or ""
)
else:
bucket.logging_enabled = False
bucket.logging_target_bucket = ""
bucket.logging_target_prefix = ""
except Exception as error:
logger.error(
f"{bucket.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
@@ -149,8 +149,8 @@ class ECS(AWSService):
"TAGS",
],
)
container_definitions = response["taskDefinition"]["containerDefinitions"]
for container in container_definitions:
container_definitions = []
for container in response["taskDefinition"]["containerDefinitions"]:
environment = []
if "environment" in container:
for env_var in container["environment"]:
@@ -159,7 +159,7 @@ class ECS(AWSService):
name=env_var["name"], value=env_var["value"]
)
)
task_definition.container_definitions.append(
container_definitions.append(
ContainerDefinition(
name=container["name"],
privileged=container.get("privileged", False),
@@ -176,14 +176,16 @@ class ECS(AWSService):
.get("mode", ""),
)
)
task_definition.pid_mode = response["taskDefinition"].get("pidMode", "")
task_definition.registered_at = response["taskDefinition"].get(
"registeredAt"
)
task_definition.tags = response.get("tags")
task_definition.network_mode = response["taskDefinition"].get(
"networkMode", "bridge"
)
pid_mode = response["taskDefinition"].get("pidMode", "")
registered_at = response["taskDefinition"].get("registeredAt")
tags = response.get("tags")
network_mode = response["taskDefinition"].get("networkMode", "bridge")
task_definition.container_definitions = container_definitions
task_definition.pid_mode = pid_mode
task_definition.registered_at = registered_at
task_definition.tags = tags
task_definition.network_mode = network_mode
except Exception as error:
logger.error(
f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
@@ -302,7 +304,7 @@ class TaskDefinition(BaseModel):
arn: str
revision: str
region: str
container_definitions: list[ContainerDefinition] = []
container_definitions: Optional[list[ContainerDefinition]] = None
pid_mode: Optional[str]
registered_at: Optional[datetime] = None
tags: Optional[list] = []
@@ -6,6 +6,8 @@ class ecs_task_definitions_containers_readonly_access(Check):
def execute(self):
findings = []
for task_definition in ecs_client.task_definitions.values():
if task_definition.container_definitions is None:
continue
report = Check_Report_AWS(
metadata=self.metadata(), resource=task_definition
)
@@ -6,6 +6,8 @@ class ecs_task_definitions_host_namespace_not_shared(Check):
def execute(self):
findings = []
for task_definition in ecs_client.task_definitions.values():
if task_definition.container_definitions is None:
continue
report = Check_Report_AWS(
metadata=self.metadata(), resource=task_definition
)
@@ -6,6 +6,8 @@ class ecs_task_definitions_host_networking_mode_users(Check):
def execute(self):
findings = []
for task_definition in ecs_client.task_definitions.values():
if task_definition.container_definitions is None:
continue
report = Check_Report_AWS(
metadata=self.metadata(), resource=task_definition
)
@@ -6,6 +6,8 @@ class ecs_task_definitions_logging_block_mode(Check):
def execute(self):
findings = []
for task_definition in ecs_client.task_definitions.values():
if task_definition.container_definitions is None:
continue
report = Check_Report_AWS(
metadata=self.metadata(), resource=task_definition
)
@@ -6,6 +6,8 @@ class ecs_task_definitions_logging_enabled(Check):
def execute(self):
findings = []
for task_definition in ecs_client.task_definitions.values():
if task_definition.container_definitions is None:
continue
report = Check_Report_AWS(
metadata=self.metadata(), resource=task_definition
)
@@ -16,7 +16,11 @@ class ecs_task_definitions_no_environment_secrets(Check):
"secrets_ignore_patterns", []
)
validate = ecs_client.audit_config.get("secrets_validate", False)
task_definitions = list(ecs_client.task_definitions.values())
task_definitions = [
task_definition
for task_definition in ecs_client.task_definitions.values()
if task_definition.container_definitions is not None
]
# Scan every (task definition, container) environment in batched
# Kingfisher invocations instead of one subprocess per container.
@@ -6,6 +6,8 @@ class ecs_task_definitions_no_privileged_containers(Check):
def execute(self):
findings = []
for task_definition in ecs_client.task_definitions.values():
if task_definition.container_definitions is None:
continue
report = Check_Report_AWS(
metadata=self.metadata(), resource=task_definition
)
@@ -1,25 +1,58 @@
from copy import deepcopy
from prowler.lib.check.models import Check, Check_Report_AWS
from prowler.providers.aws.services.iam.lib.policy import is_policy_public
from prowler.providers.aws.services.ses.ses_client import ses_client
def _normalize_policy_statements(policy: dict) -> dict:
statements = policy.get("Statement", [])
if isinstance(statements, dict):
return {**policy, "Statement": [statements]}
return policy
def _has_explicit_deny(policy: dict) -> bool:
return any(
isinstance(statement, dict) and statement.get("Effect") == "Deny"
for statement in _normalize_policy_statements(policy).get("Statement", [])
)
class ses_identity_not_publicly_accessible(Check):
def execute(self):
"""Ensure SES identities are not publicly accessible through authorization policies."""
def execute(self) -> list[Check_Report_AWS]:
"""Evaluate every authorization policy attached to each SES identity.
Returns:
A list of reports containing the public-access result for each identity.
"""
findings = []
for identity in ses_client.email_identities.values():
if identity.policy is None:
if not identity.policies:
continue
report = Check_Report_AWS(metadata=self.metadata(), resource=identity)
report.status = "PASS"
report.status_extended = (
f"SES identity {identity.name} is not publicly accessible."
)
if is_policy_public(
identity.policy,
ses_client.audited_account,
):
report.status = "FAIL"
report.status_extended = f"SES identity {identity.name} is publicly accessible due to its resource policy."
has_public_allow = any(
is_policy_public(
_normalize_policy_statements(deepcopy(policy)),
ses_client.audited_account,
)
for policy in identity.policies.values()
)
if has_public_allow:
if any(
_has_explicit_deny(policy) for policy in identity.policies.values()
):
report.status = "MANUAL"
report.status_extended = f"SES identity {identity.name} has public Allow and explicit Deny statements in its resource policies. Effective public access requires manual review."
else:
report.status = "FAIL"
report.status_extended = f"SES identity {identity.name} is publicly accessible due to its resource policies."
findings.append(report)
@@ -1,7 +1,7 @@
from json import loads
from typing import Optional
from pydantic.v1 import BaseModel
from pydantic.v1 import BaseModel, Field
from prowler.lib.logger import logger
from prowler.lib.scan_filters.scan_filters import is_resource_filtered
@@ -46,8 +46,11 @@ class SES(AWSService):
identity_attributes = regional_client.get_email_identity(
EmailIdentity=identity.name
)
for _, content in identity_attributes.get("Policies", {}).items():
identity.policy = loads(content)
identity.policies = {
name: loads(content)
for name, content in identity_attributes.get("Policies", {}).items()
}
identity.policy = next(reversed(identity.policies.values()), None)
identity.tags = identity_attributes.get("Tags", [])
dkim_attrs = identity_attributes.get("DkimAttributes", {}) or {}
identity.dkim_status = dkim_attrs.get("Status")
@@ -72,6 +75,7 @@ class Identity(BaseModel):
region: str
type: Optional[str]
policy: Optional[dict] = None
policies: dict[str, dict] = Field(default_factory=dict)
tags: Optional[list] = []
dkim_status: Optional[str] = None
dkim_signing_attributes_origin: Optional[str] = None
+14 -13
View File
@@ -1,6 +1,6 @@
[build-system]
build-backend = "hatchling.build"
requires = ["hatchling"]
requires = ["hatchling==1.32.0"]
[dependency-groups]
dev = [
@@ -68,6 +68,10 @@ dependencies = [
"boto3==1.40.61",
"botocore==1.40.61",
"colorama==0.4.6",
# cryptography 50 needs alibabacloud-tea-openapi>=0.4.6, oci>=2.184.1 and, in the
# [tool.uv] pins, msal>=1.37.0 and pyopenssl>=26.4.0: earlier releases cap it below 49
# or 50. Keep the five in step. Never widen a cap with [tool.uv] override-dependencies:
# overrides do not ship in the wheel, and 5.38.0 was uninstallable with pip because of one.
"cryptography==50.0.0",
"dash==3.1.1",
"dash-bootstrap-components==2.0.3",
@@ -99,14 +103,15 @@ dependencies = [
"stackit-objectstorage==1.4.0",
"stackit-resourcemanager==0.8.0",
"tabulate==0.9.0",
"truststore==0.10.4",
"tzlocal==5.3.1",
"uuid6==2024.7.10",
"py-iam-expand==0.3.0",
"h2==4.3.0",
"oci==2.183.0",
"oci==2.184.1",
"alibabacloud_credentials==1.0.3",
"alibabacloud_ram20150501==1.2.0",
"alibabacloud_tea_openapi==0.4.5",
"alibabacloud_tea_openapi==0.4.6",
"alibabacloud_sts20150401==1.1.6",
"alibabacloud_vpc20160428==6.13.0",
"alibabacloud_ecs20140526==7.2.5",
@@ -136,7 +141,7 @@ maintainers = [{name = "Prowler Engineering", email = "engineering@prowler.com"}
name = "prowler"
readme = "README.md"
requires-python = ">=3.10,<3.14"
version = "5.39.0"
version = "5.39.2"
[project.scripts]
prowler = "prowler.__main__:prowler"
@@ -199,7 +204,7 @@ constraint-dependencies = [
"alibabacloud-sas20181203==6.1.0",
"alibabacloud-sts20150401==1.1.6",
"alibabacloud-tea==0.4.3",
"alibabacloud-tea-openapi==0.4.5",
"alibabacloud-tea-openapi==0.4.6",
"alibabacloud-tea-util==0.3.14",
"alibabacloud-tea-xml==0.0.3",
"alibabacloud-vpc20160428==6.13.0",
@@ -300,7 +305,7 @@ constraint-dependencies = [
"mock==5.2.0",
"moto==5.1.11",
"mpmath==1.3.0",
"msal==1.36.0",
"msal==1.37.0",
"msal-extensions==1.3.1",
"msgraph-core==1.3.8",
"msrest==0.7.1",
@@ -343,7 +348,7 @@ constraint-dependencies = [
"pyjwt==2.13.0",
"pylint==3.3.4",
"pynacl==1.6.2",
"pyopenssl==26.2.0",
"pyopenssl==26.4.0",
"pyparsing==3.3.2",
"pytest==9.0.3",
"pytest-cov==6.0.0",
@@ -387,13 +392,9 @@ constraint-dependencies = [
"xmltodict==1.0.4",
"yarl==1.23.0",
"zipp==3.23.1",
"zstd==1.5.7.3"
]
override-dependencies = [
"okta==3.4.2",
# alibabacloud-tea-openapi 0.4.5 caps cryptography below 49 and is the latest release.
"cryptography==50.0.0",
"zstd==1.5.7.2"
]
override-dependencies = ["okta==3.4.2"]
[tool.vulture]
# Suppress known false positives. The CI command only passes --exclude and
@@ -26,6 +26,7 @@ from prowler.lib.check.compliance_models import (
UniversalComplianceRequirement,
adapt_legacy_to_universal,
get_bulk_compliance_frameworks_universal,
get_universal_compliance_entry_point_dirs,
load_compliance_framework_universal,
)
from tests.lib.outputs.compliance.fixtures import (
@@ -1237,3 +1238,91 @@ class TestGetBulkUniversalEntryPoints:
assert "pkg_a_1.0" in bulk
assert "pkg_b_1.0" in bulk
class TestGetUniversalComplianceEntryPointDirs:
"""Directories external packages contribute."""
@staticmethod
def _entry_point(path=None, *, file_path=None, load_error=None):
ep = MagicMock()
ep.name = "external"
ep.group = "prowler.compliance.universal"
if load_error is not None:
ep.load.side_effect = load_error
return ep
module = MagicMock()
if path is not None:
module.__path__ = [path]
else:
# A module, not a package: only __file__.
del module.__path__
module.__file__ = file_path
ep.load.return_value = module
return ep
@patch("prowler.lib.check.compliance_models.importlib.metadata.entry_points")
def test_returns_entry_point_dirs_in_order(self, mock_ep):
with (
tempfile.TemporaryDirectory() as dir_a,
tempfile.TemporaryDirectory() as dir_b,
):
mock_ep.return_value = [
self._entry_point(dir_a),
self._entry_point(dir_b),
]
assert get_universal_compliance_entry_point_dirs() == [dir_a, dir_b]
mock_ep.assert_called_with(group="prowler.compliance.universal")
@patch("prowler.lib.check.compliance_models.importlib.metadata.entry_points")
def test_dedupes_the_same_directory(self, mock_ep):
"""Two entry points, one directory: loaded once."""
with tempfile.TemporaryDirectory() as ep_dir:
mock_ep.return_value = [
self._entry_point(ep_dir),
self._entry_point(ep_dir),
]
assert get_universal_compliance_entry_point_dirs() == [ep_dir]
@patch("prowler.lib.check.compliance_models.importlib.metadata.entry_points")
def test_dedupes_a_directory_reached_through_a_symlink(self, mock_ep, tmp_path):
"""Same directory behind a symlink: still loaded once."""
real = tmp_path / "real"
real.mkdir()
link = tmp_path / "link"
link.symlink_to(real, target_is_directory=True)
mock_ep.return_value = [
self._entry_point(str(real)),
self._entry_point(str(link)),
]
assert get_universal_compliance_entry_point_dirs() == [str(real)]
@patch("prowler.lib.check.compliance_models.importlib.metadata.entry_points")
def test_skips_paths_that_are_not_directories(self, mock_ep):
mock_ep.return_value = [self._entry_point("/does/not/exist")]
assert get_universal_compliance_entry_point_dirs() == []
@patch("prowler.lib.check.compliance_models.importlib.metadata.entry_points")
def test_a_broken_entry_point_does_not_hide_the_others(self, mock_ep):
with tempfile.TemporaryDirectory() as ep_dir:
mock_ep.return_value = [
self._entry_point(load_error=ImportError("boom")),
self._entry_point(ep_dir),
]
assert get_universal_compliance_entry_point_dirs() == [ep_dir]
@patch("prowler.lib.check.compliance_models.importlib.metadata.entry_points")
def test_module_without_path_falls_back_to_its_file_directory(self, mock_ep):
with tempfile.TemporaryDirectory() as ep_dir:
module_file = os.path.join(ep_dir, "compliance.py")
with open(module_file, "w"):
pass
mock_ep.return_value = [self._entry_point(file_path=module_file)]
assert get_universal_compliance_entry_point_dirs() == [ep_dir]
+285
View File
@@ -1,6 +1,8 @@
import re
import sys
from io import StringIO
import pytest
from mock import MagicMock, patch
from prowler.config.config import prowler_version, timestamp
@@ -676,6 +678,192 @@ html_footer = """
"""
def _setup_aws_xss(provider, payload):
provider.identity.account = payload
provider.identity.profile = payload
provider.identity.audited_regions = [payload]
provider.identity.user_id = payload
provider.identity.identity_arn = payload
def _setup_azure_xss(provider, payload):
provider.identity.tenant_ids = [payload]
provider.identity.tenant_domain = payload
provider.identity.subscriptions = {payload: payload}
provider.identity.identity_type = payload
provider.identity.identity_id = payload
def _setup_gcp_xss(provider, payload):
provider.project_ids = [payload]
provider.session._service_account_email = payload
def _setup_kubernetes_xss(provider, payload):
provider.identity.cluster = payload
provider.identity.context = payload
def _setup_github_xss(provider, payload):
provider.identity = MagicMock(spec=["account_name", "account_email"])
provider.identity.account_name = payload
provider.identity.account_email = payload
provider.auth_method = payload
def _setup_m365_xss(provider, payload):
provider.identity.tenant_domain = payload
provider.identity.identity_type = payload
provider.identity.identity_id = payload
provider.identity.user = payload
def _setup_nhn_xss(provider, payload):
provider.identity.tenant_domain = payload
provider.identity.identity_type = payload
provider.identity.identity_id = payload
def _setup_mongodbatlas_xss(provider, payload):
provider.identity.organization_name = payload
def _setup_iac_xss(provider, payload):
provider.scan_repository_url = payload
provider.scan_path = None
provider.auth_method = payload
def _setup_image_xss(provider, payload):
provider.registry = payload
provider.images = [payload]
provider.auth_method = payload
def _setup_llm_xss(provider, payload):
provider.model = payload
provider.plugins = [payload]
provider.max_concurrency = payload
provider.config_path = payload
def _setup_oraclecloud_xss(provider, payload):
provider.session.profile = payload
provider.identity.tenancy_name = payload
provider.identity.tenancy_id = payload
def _setup_stackit_xss(provider, payload):
provider.identity.project_id = payload
provider.identity.project_name = payload
provider.identity.audited_regions = {payload}
def _setup_cloudflare_xss(provider, payload):
account = MagicMock()
account.id = payload
provider.accounts = [account]
provider.session.api_token = "token"
provider.session.api_key = None
provider.session.api_email = None
provider.identity.email = payload
def _setup_alibabacloud_xss(provider, payload):
provider.identity.account_id = payload
provider.identity.account_name = payload
provider.identity.audited_regions = payload
provider.identity.identity_arn = payload
provider.identity.user_name = payload
def _setup_openstack_xss(provider, payload):
provider.identity.project_id = payload
provider.identity.project_name = payload
provider.identity.region_name = payload
provider.identity.username = payload
provider.identity.user_id = payload
def _setup_googleworkspace_xss(provider, payload):
provider.identity.domain = payload
provider.identity.customer_id = payload
provider.identity.delegated_user = payload
def _setup_e2enetworks_xss(provider, payload):
provider.identity.project_id = payload
provider.identity.locations = [payload]
def _setup_vercel_xss(provider, payload):
team = MagicMock()
team.name = payload
team.id = payload
provider.identity.team = team
provider.identity.email = payload
provider.identity.username = payload
def _setup_okta_xss(provider, payload):
provider.identity.org_domain = payload
provider.auth_method = payload
provider.identity.client_id = payload
def _setup_scaleway_xss(provider, payload):
provider.identity.organization_id = payload
provider.session.access_key = payload
provider.identity.bearer_type = payload
provider.identity.bearer_email = payload
provider.identity.bearer_id = payload
provider.session.default_region = payload
def _setup_linode_xss(provider, payload):
provider.identity.username = payload
provider.identity.email = payload
provider.identity.account_id = payload
def _setup_huaweicloud_xss(provider, payload):
provider.identity.account_id = payload
provider.identity.account_name = payload
provider.identity.profile = payload
provider.identity.regions = {payload}
provider.identity.domain_id = payload
provider.identity.user_id = payload
provider.identity.user_name = payload
provider.identity.identity_type = payload
PROVIDER_XSS_SETUPS = [
("aws", _setup_aws_xss),
("azure", _setup_azure_xss),
("gcp", _setup_gcp_xss),
("kubernetes", _setup_kubernetes_xss),
("github", _setup_github_xss),
("m365", _setup_m365_xss),
("nhn", _setup_nhn_xss),
("mongodbatlas", _setup_mongodbatlas_xss),
("iac", _setup_iac_xss),
("image", _setup_image_xss),
("llm", _setup_llm_xss),
("oraclecloud", _setup_oraclecloud_xss),
("stackit", _setup_stackit_xss),
("cloudflare", _setup_cloudflare_xss),
("alibabacloud", _setup_alibabacloud_xss),
("openstack", _setup_openstack_xss),
("googleworkspace", _setup_googleworkspace_xss),
("e2enetworks", _setup_e2enetworks_xss),
("vercel", _setup_vercel_xss),
("okta", _setup_okta_xss),
("scaleway", _setup_scaleway_xss),
("linode", _setup_linode_xss),
("huaweicloud", _setup_huaweicloud_xss),
]
class TestHTML:
def test_transform_fail_finding(self):
findings = [
@@ -1100,6 +1288,103 @@ class TestHTML:
assert "<script>alert(1)</script>" not in summary
assert "Delhi&#34;&gt;&lt;script&gt;alert(1)&lt;/script&gt;" in summary
@pytest.mark.parametrize(
"provider_type,setup_fn",
PROVIDER_XSS_SETUPS,
ids=[t for t, _ in PROVIDER_XSS_SETUPS],
)
def test_get_assessment_summary_escapes_provider_identity(
self, provider_type, setup_fn
):
"""Every provider header must HTML-escape tenant-controlled identity fields."""
payload = "<script>alert(1)</script>"
findings = [generate_finding_output()]
output = HTML(findings)
provider = MagicMock()
provider.type = provider_type
setup_fn(provider, payload)
summary = output.get_assessment_summary(provider)
assert payload not in summary
assert "&lt;script&gt;alert(1)&lt;/script&gt;" in summary
def test_provider_xss_setups_covers_every_assessment_summary_method(self):
"""Adding a new get_<provider>_assessment_summary without a PROVIDER_XSS_SETUPS
entry must fail this test, so the escape guarantee cannot silently regress."""
pattern = re.compile(r"^get_(.+)_assessment_summary$")
discovered = set()
for name in dir(HTML):
match = pattern.match(name)
if match:
discovered.add(match.group(1))
covered = {ptype for ptype, _ in PROVIDER_XSS_SETUPS}
missing = discovered - covered
extra = covered - discovered
assert (
not missing
), f"providers without XSS coverage in PROVIDER_XSS_SETUPS: {sorted(missing)}"
assert (
not extra
), f"PROVIDER_XSS_SETUPS entries with no matching HTML method: {sorted(extra)}"
def test_github_app_get_assessment_summary_escapes_app_identity(self):
"""The GitHub App branch (elif hasattr app_id) must escape app_name/app_id/installations,
which the PAT setup does not exercise."""
payload = "<script>alert(1)</script>"
findings = [generate_finding_output()]
output = HTML(findings)
provider = MagicMock()
provider.type = "github"
provider.identity = MagicMock(spec=["app_id", "app_name", "installations"])
provider.identity.app_id = payload
provider.identity.app_name = payload
provider.identity.installations = [payload]
provider.auth_method = payload
summary = output.get_assessment_summary(provider)
assert payload not in summary
assert "&lt;script&gt;alert(1)&lt;/script&gt;" in summary
def test_iac_get_assessment_summary_escapes_scan_path(self):
"""The IAC scan_path branch (else of `if scan_repository_url`) must escape it."""
payload = "<script>alert(1)</script>"
findings = [generate_finding_output()]
output = HTML(findings)
provider = MagicMock()
provider.type = "iac"
provider.scan_repository_url = None
provider.scan_path = payload
provider.auth_method = payload
summary = output.get_assessment_summary(provider)
assert payload not in summary
assert "&lt;script&gt;alert(1)&lt;/script&gt;" in summary
def test_image_get_assessment_summary_escapes_images_list(self):
"""The Image `else` branch (no registry, images list) must escape each image."""
payload = "<script>alert(1)</script>"
findings = [generate_finding_output()]
output = HTML(findings)
provider = MagicMock()
provider.type = "image"
provider.registry = None
provider.images = [payload]
provider.auth_method = payload
summary = output.get_assessment_summary(provider)
assert payload not in summary
assert "&lt;script&gt;alert(1)&lt;/script&gt;" in summary
def test_process_markdown_bold_text(self):
"""Test that **text** is converted to <strong>text</strong>"""
test_text = "This is **bold text** and this is **also bold**"
@@ -0,0 +1,132 @@
from unittest.mock import MagicMock
import pytest
import requests
from prowler import __main__ as cli
from prowler.lib.outputs.ocsf.ingestion import SystemTrustStoreError
def test_ssl_error_uses_actionable_tls_message_before_connection_error(
monkeypatch, capsys
):
upload = MagicMock(
side_effect=requests.exceptions.SSLError("secret transport detail")
)
monkeypatch.setattr(cli, "send_ocsf_to_api", upload)
response = cli._send_ocsf_to_cloud("/tmp/saved-findings.ocsf.json")
output = capsys.readouterr().out
assert response is None
assert "TLS certificate validation failed" in output
assert "hostname, validity period, and certificate chain" in output
assert "operating system trust store" in output
assert "TLS-intercepting proxy CA" in output
assert "container system CA store" in output
assert "Scan results were saved to /tmp/saved-findings.ocsf.json" in output
assert "secret transport detail" not in output
def test_truststore_initialization_error_uses_safe_actionable_message(
monkeypatch, capsys
):
monkeypatch.setattr(
cli,
"send_ocsf_to_api",
MagicMock(side_effect=SystemTrustStoreError("secret initialization detail")),
)
response = cli._send_ocsf_to_cloud("/tmp/saved-findings.ocsf.json")
output = capsys.readouterr().out
assert response is None
assert "operating system trust store could not be initialized" in output
assert "configured CA bundle paths" in output
assert "TLS-intercepting proxy CA" in output
assert "container system CA store" in output
assert "Scan results were saved to /tmp/saved-findings.ocsf.json" in output
assert "secret initialization detail" not in output
def test_json_decode_error_uses_safe_api_response_message(monkeypatch, capsys):
monkeypatch.setattr(
cli,
"send_ocsf_to_api",
MagicMock(
side_effect=requests.exceptions.JSONDecodeError(
"secret response detail", "invalid", 0
)
),
)
response = cli._send_ocsf_to_cloud("/tmp/saved-findings.ocsf.json")
output = capsys.readouterr().out
assert response is None
assert "the API returned an invalid JSON response" in output
assert "no API key configured" not in output
assert "secret response detail" not in output
assert "Scan results were saved to /tmp/saved-findings.ocsf.json" in output
def test_http_error_without_response_uses_safe_fallback(monkeypatch, capsys):
monkeypatch.setattr(
cli,
"send_ocsf_to_api",
MagicMock(side_effect=requests.HTTPError("secret transport detail")),
)
response = cli._send_ocsf_to_cloud("/tmp/saved-findings.ocsf.json")
output = capsys.readouterr().out
assert response is None
assert "the API request failed without a response status" in output
assert "secret transport detail" not in output
assert "Scan results were saved to /tmp/saved-findings.ocsf.json" in output
@pytest.mark.parametrize(
("error", "expected_message"),
[
(
ValueError("missing"),
"Push to Prowler Cloud skipped: no API key configured",
),
(
requests.ConnectionError("offline"),
"could not reach the Prowler Cloud API",
),
(
requests.HTTPError(response=MagicMock(status_code=402)),
"only available with a Prowler Cloud subscription",
),
(
requests.HTTPError(response=MagicMock(status_code=403)),
"the API returned HTTP 403",
),
],
)
def test_existing_upload_errors_keep_specific_messages(
monkeypatch, capsys, error, expected_message
):
monkeypatch.setattr(cli, "send_ocsf_to_api", MagicMock(side_effect=error))
response = cli._send_ocsf_to_cloud("/tmp/saved-findings.ocsf.json")
output = capsys.readouterr().out
assert response is None
assert expected_message in output
assert "Scan results were saved to /tmp/saved-findings.ocsf.json" in output
def test_successful_upload_returns_response(monkeypatch, capsys):
expected_response = {"data": {"id": "job-id"}}
monkeypatch.setattr(
cli, "send_ocsf_to_api", MagicMock(return_value=expected_response)
)
response = cli._send_ocsf_to_cloud("/tmp/saved-findings.ocsf.json")
assert response == expected_response
assert capsys.readouterr().out == ""
+487
View File
@@ -0,0 +1,487 @@
import ssl
import sys
from types import SimpleNamespace
from unittest.mock import MagicMock, call
import pytest
import requests
from requests import PreparedRequest
from requests.adapters import HTTPAdapter
from prowler.lib.outputs.ocsf import ingestion
@pytest.fixture
def system_trust_session(monkeypatch):
monkeypatch.delenv("REQUESTS_CA_BUNDLE", raising=False)
monkeypatch.delenv("CURL_CA_BUNDLE", raising=False)
native_context = MagicMock()
ssl_context = MagicMock(return_value=native_context)
inject_into_ssl = MagicMock()
monkeypatch.setitem(
sys.modules,
"truststore",
SimpleNamespace(
SSLContext=ssl_context,
inject_into_ssl=inject_into_ssl,
),
)
session = ingestion._SystemTrustSession()
yield session, native_context, ssl_context, inject_into_ssl
session.close()
def test_https_origin_pool_uses_native_trust_context(system_trust_session):
session, native_context, ssl_context, _ = system_trust_session
adapter = session.get_adapter("https://private.prowler.example")
request = PreparedRequest()
request.prepare(method="POST", url="https://private.prowler.example/api/v1")
_, pool_kwargs = adapter.build_connection_pool_key_attributes(request, verify=True)
ssl_context.assert_called_once_with(ssl.PROTOCOL_TLS_CLIENT)
assert pool_kwargs["ssl_context"] is native_context
assert pool_kwargs["cert_reqs"] == "CERT_REQUIRED"
assert "ca_certs" not in pool_kwargs
assert "ca_cert_dir" not in pool_kwargs
def test_native_context_loads_requests_default_ca_bundle(monkeypatch, tmp_path):
monkeypatch.delenv("REQUESTS_CA_BUNDLE", raising=False)
monkeypatch.delenv("CURL_CA_BUNDLE", raising=False)
default_ca_bundle = tmp_path / "requests-default-ca.pem"
default_ca_bundle.write_text("default CA")
native_context = MagicMock()
monkeypatch.setattr(requests.certs, "where", lambda: str(default_ca_bundle))
monkeypatch.setitem(
sys.modules,
"truststore",
SimpleNamespace(SSLContext=MagicMock(return_value=native_context)),
)
session = ingestion._SystemTrustSession()
native_context.load_verify_locations.assert_called_once_with(
cafile=str(default_ca_bundle)
)
session.close()
def test_native_context_adds_requests_ca_bundle_file(monkeypatch, tmp_path):
default_ca_bundle = tmp_path / "requests-default-ca.pem"
configured_ca_bundle = tmp_path / "configured-ca.pem"
default_ca_bundle.write_text("default CA")
configured_ca_bundle.write_text("configured CA")
native_context = MagicMock()
monkeypatch.setattr(requests.certs, "where", lambda: str(default_ca_bundle))
monkeypatch.setenv("REQUESTS_CA_BUNDLE", str(configured_ca_bundle))
monkeypatch.delenv("CURL_CA_BUNDLE", raising=False)
monkeypatch.setitem(
sys.modules,
"truststore",
SimpleNamespace(SSLContext=MagicMock(return_value=native_context)),
)
session = ingestion._SystemTrustSession()
assert native_context.load_verify_locations.call_args_list == [
call(cafile=str(default_ca_bundle)),
call(cafile=str(configured_ca_bundle)),
]
session.close()
def test_native_context_uses_curl_ca_bundle_as_fallback(monkeypatch, tmp_path):
default_ca_bundle = tmp_path / "requests-default-ca.pem"
configured_ca_bundle = tmp_path / "curl-ca.pem"
default_ca_bundle.write_text("default CA")
configured_ca_bundle.write_text("configured CA")
native_context = MagicMock()
monkeypatch.setattr(requests.certs, "where", lambda: str(default_ca_bundle))
monkeypatch.delenv("REQUESTS_CA_BUNDLE", raising=False)
monkeypatch.setenv("CURL_CA_BUNDLE", str(configured_ca_bundle))
monkeypatch.setitem(
sys.modules,
"truststore",
SimpleNamespace(SSLContext=MagicMock(return_value=native_context)),
)
session = ingestion._SystemTrustSession()
assert native_context.load_verify_locations.call_args_list == [
call(cafile=str(default_ca_bundle)),
call(cafile=str(configured_ca_bundle)),
]
session.close()
def test_requests_ca_bundle_takes_precedence_over_curl_ca_bundle(monkeypatch, tmp_path):
default_ca_bundle = tmp_path / "requests-default-ca.pem"
requests_ca_bundle = tmp_path / "requests-ca.pem"
curl_ca_bundle = tmp_path / "curl-ca.pem"
for ca_bundle in (default_ca_bundle, requests_ca_bundle, curl_ca_bundle):
ca_bundle.write_text("CA")
native_context = MagicMock()
monkeypatch.setattr(requests.certs, "where", lambda: str(default_ca_bundle))
monkeypatch.setenv("REQUESTS_CA_BUNDLE", str(requests_ca_bundle))
monkeypatch.setenv("CURL_CA_BUNDLE", str(curl_ca_bundle))
monkeypatch.setitem(
sys.modules,
"truststore",
SimpleNamespace(SSLContext=MagicMock(return_value=native_context)),
)
session = ingestion._SystemTrustSession()
assert native_context.load_verify_locations.call_args_list == [
call(cafile=str(default_ca_bundle)),
call(cafile=str(requests_ca_bundle)),
]
session.close()
def test_native_context_adds_configured_ca_directory(monkeypatch, tmp_path):
default_ca_bundle = tmp_path / "requests-default-ca.pem"
configured_ca_directory = tmp_path / "configured-ca-directory"
default_ca_bundle.write_text("default CA")
configured_ca_directory.mkdir()
native_context = MagicMock()
monkeypatch.setattr(requests.certs, "where", lambda: str(default_ca_bundle))
monkeypatch.setenv("REQUESTS_CA_BUNDLE", str(configured_ca_directory))
monkeypatch.setitem(
sys.modules,
"truststore",
SimpleNamespace(SSLContext=MagicMock(return_value=native_context)),
)
session = ingestion._SystemTrustSession()
assert native_context.load_verify_locations.call_args_list == [
call(cafile=str(default_ca_bundle)),
call(capath=str(configured_ca_directory)),
]
session.close()
@pytest.mark.parametrize("invalid_bundle_source", ["default", "configured"])
def test_invalid_ca_bundle_raises_safe_initialization_error(
monkeypatch, tmp_path, invalid_bundle_source
):
default_ca_bundle = tmp_path / "requests-default-ca.pem"
invalid_ca_bundle = tmp_path / "secret-missing-ca.pem"
default_ca_bundle.write_text("default CA")
if invalid_bundle_source == "default":
monkeypatch.setattr(requests.certs, "where", lambda: str(invalid_ca_bundle))
monkeypatch.delenv("REQUESTS_CA_BUNDLE", raising=False)
else:
monkeypatch.setattr(requests.certs, "where", lambda: str(default_ca_bundle))
monkeypatch.setenv("REQUESTS_CA_BUNDLE", str(invalid_ca_bundle))
monkeypatch.delenv("CURL_CA_BUNDLE", raising=False)
monkeypatch.setitem(
sys.modules,
"truststore",
SimpleNamespace(SSLContext=MagicMock(return_value=MagicMock())),
)
with pytest.raises(
ingestion.SystemTrustStoreError,
match="Check the configured CA bundle paths",
) as error:
ingestion._SystemTrustSession()
assert str(invalid_ca_bundle) not in str(error.value)
def test_session_does_not_inject_global_ssl_or_change_default_adapters(
system_trust_session,
):
session, _, _, inject_into_ssl = system_trust_session
unrelated_session = requests.Session()
assert type(session.get_adapter("http://private.prowler.example")) is HTTPAdapter
assert type(unrelated_session.get_adapter("https://example.com")) is HTTPAdapter
assert type(requests.Session().get_adapter("https://example.com")) is HTTPAdapter
inject_into_ssl.assert_not_called()
unrelated_session.close()
def test_environment_proxy_is_preserved_without_replacing_prepared_context(
monkeypatch, system_trust_session
):
session, _, _, _ = system_trust_session
for variable in (
"ALL_PROXY",
"CURL_CA_BUNDLE",
"HTTP_PROXY",
"HTTPS_PROXY",
"NO_PROXY",
"all_proxy",
"http_proxy",
"https_proxy",
"no_proxy",
):
monkeypatch.delenv(variable, raising=False)
monkeypatch.setenv("HTTPS_PROXY", "https://proxy.example:8443")
settings = session.merge_environment_settings(
"https://private.prowler.example", {}, None, None, None
)
assert settings["verify"] is True
assert settings["proxies"]["https"] == "https://proxy.example:8443"
def test_https_proxy_uses_native_trust_context(monkeypatch, system_trust_session):
session, native_context, _, _ = system_trust_session
adapter = session.get_adapter("https://private.prowler.example")
proxy_manager = MagicMock()
proxy_from_url = MagicMock(return_value=proxy_manager)
monkeypatch.setattr(requests.adapters, "proxy_from_url", proxy_from_url)
result = adapter.proxy_manager_for("https://proxy.example:8443")
assert result is proxy_manager
assert proxy_from_url.call_args.kwargs["proxy_ssl_context"] is native_context
def test_socks_proxy_does_not_receive_proxy_ssl_context(
monkeypatch, system_trust_session
):
session, _, _, _ = system_trust_session
adapter = session.get_adapter("https://private.prowler.example")
socks_proxy_manager = MagicMock()
socks_proxy_factory = MagicMock(return_value=socks_proxy_manager)
monkeypatch.setattr(requests.adapters, "SOCKSProxyManager", socks_proxy_factory)
result = adapter.proxy_manager_for("socks5h://proxy.example:1080")
assert result is socks_proxy_manager
assert "proxy_ssl_context" not in socks_proxy_factory.call_args.kwargs
def test_adapter_keeps_native_verification_without_certifi(system_trust_session):
session, _, _, _ = system_trust_session
adapter = session.get_adapter("https://private.prowler.example")
connection = SimpleNamespace(
cert_reqs=None,
ca_certs=None,
ca_cert_dir=None,
cert_file=None,
key_file=None,
)
adapter.cert_verify(
connection,
"https://private.prowler.example",
verify=True,
cert=None,
)
assert connection.cert_reqs == "CERT_REQUIRED"
assert connection.ca_certs is None
assert connection.ca_cert_dir is None
def test_adapter_rejects_disabled_tls_verification(system_trust_session):
session, _, _, _ = system_trust_session
adapter = session.get_adapter("https://private.prowler.example")
with pytest.raises(ValueError, match="verification is required"):
adapter.cert_verify(
MagicMock(),
"https://private.prowler.example",
verify=False,
cert=None,
)
def test_adapter_does_not_retry_posts(system_trust_session):
session, _, _, _ = system_trust_session
adapter = session.get_adapter("https://private.prowler.example")
assert adapter.max_retries.total == 0
def test_truststore_is_loaded_lazily_and_initialization_errors_are_safe(
monkeypatch,
):
assert "truststore" not in ingestion.__dict__
monkeypatch.setitem(
sys.modules,
"truststore",
SimpleNamespace(SSLContext=MagicMock(side_effect=OSError("secret detail"))),
)
with pytest.raises(
ingestion.SystemTrustStoreError,
match="Could not initialize the operating system trust store",
) as error:
ingestion._SystemTrustSession()
assert "secret detail" not in str(error.value)
def test_truststore_import_failure_becomes_custom_error(monkeypatch):
monkeypatch.setitem(sys.modules, "truststore", None)
with pytest.raises(ingestion.SystemTrustStoreError):
ingestion._SystemTrustSession()
def test_upload_preserves_request_and_response_behavior(tmp_path, monkeypatch):
ocsf_file = tmp_path / "findings.ocsf.json"
ocsf_file.write_text('{"finding": true}')
response = MagicMock(status_code=200, text='{"data": {"id": "job-id"}}')
response.json.return_value = {"data": {"id": "job-id"}}
session = MagicMock()
uploaded_file = {}
def capture_upload(*args, **kwargs):
filename, file_handle, content_type = kwargs["files"]["file"]
uploaded_file.update(
filename=filename,
content=file_handle.read(),
content_type=content_type,
)
return response
session.post.side_effect = capture_upload
monkeypatch.setattr(
ingestion, "_SystemTrustSession", MagicMock(return_value=session)
)
result = ingestion.send_ocsf_to_api(
str(ocsf_file),
base_url="private.prowler.example/",
api_key="safe-api-key",
timeout=17,
)
assert result == {"data": {"id": "job-id"}}
assert uploaded_file == {
"filename": "findings.ocsf.json",
"content": b'{"finding": true}',
"content_type": "application/json",
}
session.post.assert_called_once_with(
f"https://private.prowler.example{ingestion.cloud_api_ingestion_path}",
headers={
"Authorization": "Api-Key safe-api-key",
"Accept": "application/vnd.api+json",
},
files=session.post.call_args.kwargs["files"],
timeout=17,
allow_redirects=False,
)
response.raise_for_status.assert_called_once_with()
response.json.assert_called_once_with()
session.close.assert_called_once_with()
@pytest.mark.parametrize(
"error",
[requests.exceptions.SSLError("TLS failed"), requests.ConnectionError("offline")],
)
def test_upload_closes_session_when_request_fails(tmp_path, monkeypatch, error):
ocsf_file = tmp_path / "findings.ocsf.json"
ocsf_file.write_text("[]")
session = MagicMock()
session.post.side_effect = error
monkeypatch.setattr(
ingestion, "_SystemTrustSession", MagicMock(return_value=session)
)
with pytest.raises(type(error)):
ingestion.send_ocsf_to_api(
str(ocsf_file),
base_url="https://private.prowler.example",
api_key="safe-api-key",
)
session.post.assert_called_once()
session.close.assert_called_once_with()
def test_empty_response_returns_empty_object_and_closes_session(tmp_path, monkeypatch):
ocsf_file = tmp_path / "findings.ocsf.json"
ocsf_file.write_text("[]")
response = MagicMock(status_code=200, text="")
session = MagicMock()
session.post.return_value = response
monkeypatch.setattr(
ingestion, "_SystemTrustSession", MagicMock(return_value=session)
)
result = ingestion.send_ocsf_to_api(str(ocsf_file), api_key="safe-api-key")
assert result == {}
response.json.assert_not_called()
session.close.assert_called_once_with()
def test_upload_rejects_redirect_response(tmp_path, monkeypatch):
ocsf_file = tmp_path / "findings.ocsf.json"
ocsf_file.write_text("[]")
response = requests.Response()
response.status_code = 307
session = MagicMock()
session.post.return_value = response
monkeypatch.setattr(
ingestion, "_SystemTrustSession", MagicMock(return_value=session)
)
with pytest.raises(requests.HTTPError, match="redirect") as error:
ingestion.send_ocsf_to_api(str(ocsf_file), api_key="safe-api-key")
assert error.value.response is response
session.close.assert_called_once_with()
@pytest.mark.parametrize("failure_stage", ["status", "json"])
def test_upload_closes_session_when_response_processing_fails(
tmp_path, monkeypatch, failure_stage
):
ocsf_file = tmp_path / "findings.ocsf.json"
ocsf_file.write_text("[]")
response = MagicMock(status_code=200, text="invalid-json")
if failure_stage == "status":
response.raise_for_status.side_effect = requests.HTTPError("forbidden")
else:
response.json.side_effect = ValueError("invalid response")
session = MagicMock()
session.post.return_value = response
monkeypatch.setattr(
ingestion, "_SystemTrustSession", MagicMock(return_value=session)
)
with pytest.raises((requests.HTTPError, ValueError)):
ingestion.send_ocsf_to_api(str(ocsf_file), api_key="safe-api-key")
session.close.assert_called_once_with()
def test_missing_api_key_fails_before_creating_session(tmp_path, monkeypatch):
ocsf_file = tmp_path / "findings.ocsf.json"
ocsf_file.write_text("[]")
session_factory = MagicMock()
monkeypatch.setattr(ingestion, "cloud_api_key", None)
monkeypatch.setattr(ingestion, "_SystemTrustSession", session_factory)
with pytest.raises(ValueError, match="Missing API key"):
ingestion.send_ocsf_to_api(str(ocsf_file))
session_factory.assert_not_called()
def test_missing_file_fails_before_creating_session(tmp_path, monkeypatch):
session_factory = MagicMock()
monkeypatch.setattr(ingestion, "_SystemTrustSession", session_factory)
with pytest.raises(FileNotFoundError, match="OCSF file not found"):
ingestion.send_ocsf_to_api(
str(tmp_path / "missing.ocsf.json"), api_key="safe-api-key"
)
session_factory.assert_not_called()
@@ -133,3 +133,169 @@ def test_list_buckets_inventory_is_loaded_once_across_regions():
oss.__threading_call__(oss._list_buckets)
assert get_mock.call_count == 1
def _build_bucket(name="prowler-test"):
from prowler.providers.alibabacloud.services.oss.oss_service import Bucket
return Bucket(arn=f"acs:oss::1234567890:{name}", name=name, region="ap-southeast-1")
def _mock_subresource_response(root_element, content):
"""Mimic the dict the OSS SDK execute path returns for XML bodies."""
return {"headers": {}, "statusCode": 200, "body": {root_element: content}}
def test_get_bucket_subresource_calls_execute_and_unwraps_root():
service = _build_oss_service()
bucket = _build_bucket()
oss_client = MagicMock()
oss_client.execute.return_value = _mock_subresource_response(
"BucketLoggingStatus", {"LoggingEnabled": {"TargetBucket": "log-bucket"}}
)
service.session.client.return_value = oss_client
result = service._get_bucket_subresource(bucket, "GetBucketLogging", "logging")
assert result == {"LoggingEnabled": {"TargetBucket": "log-bucket"}}
service.session.client.assert_called_once_with("oss", bucket.region)
params, request, _ = oss_client.execute.call_args.args
assert params.action == "GetBucketLogging"
assert params.pathname == "/?logging"
assert params.method == "GET"
assert params.style == "ROA"
assert params.body_type == "xml"
assert request.host_map == {"bucket": bucket.name}
def test_get_bucket_subresource_returns_empty_dict_for_empty_root():
service = _build_oss_service()
bucket = _build_bucket()
oss_client = MagicMock()
oss_client.execute.return_value = _mock_subresource_response(
"BucketLoggingStatus", None
)
service.session.client.return_value = oss_client
assert service._get_bucket_subresource(bucket, "GetBucketLogging", "logging") == {}
def test_get_bucket_logging_parses_target():
service = _build_oss_service()
bucket = _build_bucket()
oss_client = MagicMock()
oss_client.execute.return_value = _mock_subresource_response(
"BucketLoggingStatus",
{"LoggingEnabled": {"TargetBucket": "log-bucket", "TargetPrefix": "logs/"}},
)
service.session.client.return_value = oss_client
service._get_bucket_logging(bucket)
assert bucket.logging_enabled is True
assert bucket.logging_target_bucket == "log-bucket"
assert bucket.logging_target_prefix == "logs/"
def test_get_bucket_logging_disabled_when_no_target():
service = _build_oss_service()
bucket = _build_bucket()
oss_client = MagicMock()
oss_client.execute.return_value = _mock_subresource_response(
"BucketLoggingStatus", None
)
service.session.client.return_value = oss_client
service._get_bucket_logging(bucket)
assert bucket.logging_enabled is False
assert bucket.logging_target_bucket == ""
def test_get_bucket_acl_parses_grant():
service = _build_oss_service()
bucket = _build_bucket()
oss_client = MagicMock()
oss_client.execute.return_value = _mock_subresource_response(
"AccessControlPolicy",
{"Owner": {"ID": "1234567890"}, "AccessControlList": {"Grant": "public-read"}},
)
service.session.client.return_value = oss_client
service._get_bucket_acl(bucket)
assert bucket.acl == "public-read"
def test_get_bucket_acl_parses_private_grant_value():
service = _build_oss_service()
bucket = _build_bucket()
oss_client = MagicMock()
oss_client.execute.return_value = _mock_subresource_response(
"AccessControlPolicy",
{"Owner": {"ID": "1234567890"}, "AccessControlList": {"Grant": "private"}},
)
service.session.client.return_value = oss_client
service._get_bucket_acl(bucket)
assert bucket.acl == "private"
def test_get_bucket_acl_defaults_to_private_without_grant():
service = _build_oss_service()
bucket = _build_bucket()
oss_client = MagicMock()
oss_client.execute.return_value = _mock_subresource_response(
"AccessControlPolicy", {"Owner": {"ID": "1234567890"}}
)
service.session.client.return_value = oss_client
service._get_bucket_acl(bucket)
assert bucket.acl == "private"
def test_get_bucket_subresource_with_real_sdk_client_unwraps_xml_root():
"""Regression test against the SDK deserialization the helper works around.
The generated ``get_bucket_*`` methods return empty response models for
XML bodies (root element kept by the gateway, dropped by the models). Drive
the real client with only the HTTP call mocked to make sure the helper still
returns the configuration after SDK upgrades.
"""
import io
import darabonba.core as dara_core
from alibabacloud_oss20190517.client import Client as OssClient
from alibabacloud_tea_openapi import models as open_api_models
service = _build_oss_service()
bucket = _build_bucket()
service.session.client.return_value = OssClient(
open_api_models.Config(
access_key_id="AKID",
access_key_secret="SECRET",
endpoint="oss-ap-southeast-1.aliyuncs.com",
region_id="ap-southeast-1",
)
)
xml = (
b'<?xml version="1.0" encoding="UTF-8"?>'
b"<BucketLoggingStatus><LoggingEnabled>"
b"<TargetBucket>log-bucket</TargetBucket>"
b"<TargetPrefix>logs/</TargetPrefix>"
b"</LoggingEnabled></BucketLoggingStatus>"
)
class FakeHttpResponse:
status_code = 200
headers = {"content-type": "application/xml"}
body = io.BytesIO(xml)
with patch.object(dara_core.DaraCore, "do_action", return_value=FakeHttpResponse()):
result = service._get_bucket_subresource(bucket, "GetBucketLogging", "logging")
assert result == {
"LoggingEnabled": {"TargetBucket": "log-bucket", "TargetPrefix": "logs/"}
}
@@ -0,0 +1,164 @@
from datetime import datetime, timezone
from importlib import import_module
from types import SimpleNamespace
from unittest.mock import patch
import botocore
import pytest
from prowler.providers.aws.services.ecs.ecs_service import ECS, TaskDefinition
from tests.providers.aws.utils import (
AWS_ACCOUNT_NUMBER,
AWS_REGION_US_EAST_1,
set_mocked_aws_provider,
)
TASK_NAME = "test-task"
TASK_REVISION = "1"
TASK_ARN = (
f"arn:aws:ecs:{AWS_REGION_US_EAST_1}:{AWS_ACCOUNT_NUMBER}:"
f"task-definition/{TASK_NAME}:{TASK_REVISION}"
)
make_api_call = botocore.client.BaseClient._make_api_call
def _mock_ecs_api(describe_result):
def mock_make_api_call(self, operation_name, kwargs):
if operation_name == "ListTaskDefinitions":
return {"taskDefinitionArns": [TASK_ARN]}
if operation_name == "DescribeTaskDefinition":
if isinstance(describe_result, Exception):
raise describe_result
return describe_result
if operation_name == "ListClusters":
return {"clusterArns": []}
return make_api_call(self, operation_name, kwargs)
return mock_make_api_call
def _collect_task_definition(describe_result):
aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1])
with patch(
"botocore.client.BaseClient._make_api_call",
new=_mock_ecs_api(describe_result),
):
return ECS(aws_provider).task_definitions[TASK_ARN]
def _undescribed_ecs_client():
task_definition = TaskDefinition(
name=TASK_NAME,
arn=TASK_ARN,
revision=TASK_REVISION,
region=AWS_REGION_US_EAST_1,
environment_variables=[],
)
task_definition.container_definitions = None
return SimpleNamespace(
audit_config={},
task_definitions={TASK_ARN: task_definition},
)
def test_failed_describe_leaves_task_definition_undescribed():
error = botocore.exceptions.ClientError(
{"Error": {"Code": "ThrottlingException", "Message": "rate exceeded"}},
"DescribeTaskDefinition",
)
task_definition = _collect_task_definition(error)
assert task_definition.container_definitions is None
assert task_definition.pid_mode is None
assert task_definition.network_mode is None
def test_successful_describe_preserves_empty_container_definitions():
task_definition = _collect_task_definition(
{
"taskDefinition": {
"containerDefinitions": [],
"pidMode": "task",
"networkMode": "awsvpc",
},
"tags": [],
}
)
assert task_definition.container_definitions == []
assert task_definition.pid_mode == "task"
assert task_definition.network_mode == "awsvpc"
def test_partial_parse_leaves_task_definition_undescribed():
task_definition = _collect_task_definition(
{
"taskDefinition": {
"containerDefinitions": [
{"name": "valid-container"},
{"privileged": False},
],
"pidMode": "host",
"networkMode": "host",
"registeredAt": datetime(2026, 8, 13, tzinfo=timezone.utc),
},
"tags": [{"key": "Environment", "value": "production"}],
}
)
assert task_definition.container_definitions is None
assert task_definition.pid_mode is None
assert task_definition.network_mode is None
assert task_definition.registered_at is None
assert task_definition.tags == []
@pytest.mark.parametrize(
("check_package", "check_name"),
[
(
"ecs_task_definitions_containers_readonly_access",
"ecs_task_definitions_containers_readonly_access",
),
(
"ecs_task_definitions_host_namespace_not_shared",
"ecs_task_definitions_host_namespace_not_shared",
),
(
"ecs_task_definitions_host_networking_mode_users",
"ecs_task_definitions_host_networking_mode_users",
),
(
"ecs_task_definitions_logging_block_mode",
"ecs_task_definitions_logging_block_mode",
),
(
"ecs_task_definitions_logging_enabled",
"ecs_task_definitions_logging_enabled",
),
(
"ecs_task_definitions_no_environment_secrets",
"ecs_task_definitions_no_environment_secrets",
),
(
"ecs_task_definitions_no_privileged_containers",
"ecs_task_definitions_no_privileged_containers",
),
],
)
def test_undescribed_task_definitions_are_not_reported(
check_package, check_name, monkeypatch
):
with patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=set_mocked_aws_provider([AWS_REGION_US_EAST_1]),
):
module = import_module(
f"prowler.providers.aws.services.ecs.{check_package}.{check_name}"
)
monkeypatch.setattr(module, "ecs_client", _undescribed_ecs_client())
check = getattr(module, check_name)()
assert check.execute() == []
@@ -1,6 +1,8 @@
from copy import deepcopy
from unittest import mock
import botocore
import pytest
from boto3 import client
from moto import mock_aws
@@ -54,6 +56,113 @@ def mock_make_api_call_v2(self, operation_name, kwarg):
return make_api_call(self, operation_name, kwarg)
PUBLIC_ALLOW_POLICY = '{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":"*","Action":"ses:SendEmail","Resource":"*"}]}'
PRIVATE_ALLOW_POLICY = '{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"AWS":"arn:aws:iam::123456789012:root"},"Action":"ses:SendEmail","Resource":"*"}]}'
MATCHING_DENY_POLICY = '{"Version":"2012-10-17","Statement":[{"Effect":"Deny","Principal":"*","Action":"ses:SendEmail","Resource":"*"}]}'
UNRELATED_DENY_POLICY = '{"Version":"2012-10-17","Statement":[{"Effect":"Deny","Principal":"*","Action":"ses:SendRawEmail","Resource":"*"}]}'
PUBLIC_ALLOW_AND_DENY_POLICY = '{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":"*","Action":"ses:SendEmail","Resource":"*"},{"Effect":"Deny","Principal":"*","Action":"ses:SendEmail","Resource":"*"}]}'
PUBLIC_ALLOW_SINGLE_STATEMENT_POLICY = '{"Version":"2012-10-17","Statement":{"Effect":"Allow","Principal":"*","Action":"ses:SendEmail","Resource":"*"}}'
PRIVATE_ALLOW_SINGLE_STATEMENT_POLICY = '{"Version":"2012-10-17","Statement":{"Effect":"Allow","Principal":{"AWS":"arn:aws:iam::123456789012:root"},"Action":"ses:SendEmail","Resource":"*"}}'
MATCHING_DENY_SINGLE_STATEMENT_POLICY = '{"Version":"2012-10-17","Statement":{"Effect":"Deny","Principal":"*","Action":"ses:SendEmail","Resource":"*"}}'
CONDITIONAL_ALLOW_SINGLE_STATEMENT_POLICY = '{"Version":"2012-10-17","Statement":{"Effect":"Allow","Principal":"*","Action":"ses:SendEmail","Resource":"*","Condition":{"StringEquals":{"AWS:SourceAccount":"123456789012"}}}}'
def make_multiple_policies_api_mock(policies):
def mock_api_call(self, operation_name, kwarg):
if operation_name == "ListEmailIdentities":
return {
"EmailIdentities": [
{
"IdentityType": "DOMAIN",
"IdentityName": "test-email-identity-multiple-policies",
}
],
}
elif operation_name == "GetEmailIdentity":
return {"Policies": policies, "Tags": {}}
return make_api_call(self, operation_name, kwarg)
return mock_api_call
mock_make_api_call_multiple_policies = make_multiple_policies_api_mock(
{
"public-policy": PUBLIC_ALLOW_POLICY,
"private-policy": PRIVATE_ALLOW_POLICY,
}
)
mock_make_api_call_multiple_policies_reversed = make_multiple_policies_api_mock(
{
"private-policy": PRIVATE_ALLOW_POLICY,
"public-policy": PUBLIC_ALLOW_POLICY,
}
)
mock_make_api_call_public_allow_and_matching_deny = make_multiple_policies_api_mock(
{
"public-policy": PUBLIC_ALLOW_POLICY,
"deny-policy": MATCHING_DENY_POLICY,
}
)
mock_make_api_call_matching_deny_and_public_allow = make_multiple_policies_api_mock(
{
"deny-policy": MATCHING_DENY_POLICY,
"public-policy": PUBLIC_ALLOW_POLICY,
}
)
mock_make_api_call_public_allow_and_unrelated_deny = make_multiple_policies_api_mock(
{
"public-policy": PUBLIC_ALLOW_POLICY,
"deny-policy": UNRELATED_DENY_POLICY,
}
)
mock_make_api_call_same_policy_allow_and_deny = make_multiple_policies_api_mock(
{"combined-policy": PUBLIC_ALLOW_AND_DENY_POLICY}
)
mock_make_api_call_multiple_private_policies = make_multiple_policies_api_mock(
{
"private-policy-1": PRIVATE_ALLOW_POLICY,
"private-policy-2": PRIVATE_ALLOW_POLICY,
}
)
mock_make_api_call_public_single_statement = make_multiple_policies_api_mock(
{"public-policy": PUBLIC_ALLOW_SINGLE_STATEMENT_POLICY}
)
mock_make_api_call_private_single_statement = make_multiple_policies_api_mock(
{"private-policy": PRIVATE_ALLOW_SINGLE_STATEMENT_POLICY}
)
mock_make_api_call_public_and_deny_single_statements = make_multiple_policies_api_mock(
{
"public-policy": PUBLIC_ALLOW_SINGLE_STATEMENT_POLICY,
"deny-policy": MATCHING_DENY_SINGLE_STATEMENT_POLICY,
}
)
mock_make_api_call_conditional_single_statement = make_multiple_policies_api_mock(
{"conditional-policy": CONDITIONAL_ALLOW_SINGLE_STATEMENT_POLICY}
)
def execute_check_with_api_mock(api_call_mock):
with mock.patch("botocore.client.BaseClient._make_api_call", new=api_call_mock):
client("sesv2", region_name=AWS_REGION_EU_WEST_1)
aws_provider = set_mocked_aws_provider([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.ses.ses_identity_not_publicly_accessible.ses_identity_not_publicly_accessible.ses_client",
new=SES(aws_provider),
),
):
from prowler.providers.aws.services.ses.ses_identity_not_publicly_accessible.ses_identity_not_publicly_accessible import (
ses_identity_not_publicly_accessible,
)
return ses_identity_not_publicly_accessible().execute()
class Test_ses_identities_not_publicly_accessible:
@mock_aws
def test_no_identities(self):
@@ -114,6 +223,114 @@ class Test_ses_identities_not_publicly_accessible:
assert result[0].resource_tags == {"tag1": "value1", "tag2": "value2"}
assert result[0].region == AWS_REGION_EU_WEST_1
@mock_aws
@pytest.mark.parametrize(
"api_call_mock",
[
mock_make_api_call_multiple_policies,
mock_make_api_call_multiple_policies_reversed,
],
ids=["public-policy-first", "public-policy-last"],
)
def test_email_identity_public_when_any_policy_is_public(self, api_call_mock):
result = execute_check_with_api_mock(api_call_mock)
assert len(result) == 1
assert result[0].status == "FAIL"
assert (
result[0].status_extended
== "SES identity test-email-identity-multiple-policies is publicly accessible due to its resource policies."
)
@mock_aws
@pytest.mark.parametrize(
"api_call_mock",
[
mock_make_api_call_public_allow_and_matching_deny,
mock_make_api_call_matching_deny_and_public_allow,
mock_make_api_call_public_allow_and_unrelated_deny,
mock_make_api_call_same_policy_allow_and_deny,
],
ids=[
"matching-deny-last",
"matching-deny-first",
"unrelated-deny",
"same-policy-deny",
],
)
def test_email_identity_public_allow_with_explicit_deny_is_manual(
self, api_call_mock
):
result = execute_check_with_api_mock(api_call_mock)
assert len(result) == 1
assert result[0].status == "MANUAL"
assert (
result[0].status_extended
== "SES identity test-email-identity-multiple-policies has public Allow and explicit Deny statements in its resource policies. Effective public access requires manual review."
)
@mock_aws
def test_email_identity_multiple_private_policies(self):
result = execute_check_with_api_mock(
mock_make_api_call_multiple_private_policies
)
assert len(result) == 1
assert result[0].status == "PASS"
assert (
result[0].status_extended
== "SES identity test-email-identity-multiple-policies is not publicly accessible."
)
@mock_aws
@pytest.mark.parametrize(
("api_call_mock", "expected_status"),
[
(mock_make_api_call_public_single_statement, "FAIL"),
(mock_make_api_call_private_single_statement, "PASS"),
(mock_make_api_call_public_and_deny_single_statements, "MANUAL"),
],
ids=["public", "private", "public-with-deny"],
)
def test_email_identity_single_statement_policy(
self, api_call_mock, expected_status
):
result = execute_check_with_api_mock(api_call_mock)
assert len(result) == 1
assert result[0].status == expected_status
@mock_aws
def test_check_preserves_nested_policy_condition_keys(self):
with mock.patch(
"botocore.client.BaseClient._make_api_call",
new=mock_make_api_call_conditional_single_statement,
):
client("sesv2", region_name=AWS_REGION_EU_WEST_1)
aws_provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1])
ses_client = SES(aws_provider)
identity = next(iter(ses_client.email_identities.values()))
policies_before_check = deepcopy(identity.policies)
with (
mock.patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=aws_provider,
),
mock.patch(
"prowler.providers.aws.services.ses.ses_identity_not_publicly_accessible.ses_identity_not_publicly_accessible.ses_client",
new=ses_client,
),
):
from prowler.providers.aws.services.ses.ses_identity_not_publicly_accessible.ses_identity_not_publicly_accessible import (
ses_identity_not_publicly_accessible,
)
ses_identity_not_publicly_accessible().execute()
assert identity.policies == policies_before_check
@mock_aws
@mock.patch("botocore.client.BaseClient._make_api_call", new=mock_make_api_call_v2)
def test_email_identity_public(self):
@@ -140,7 +357,7 @@ class Test_ses_identities_not_publicly_accessible:
assert result[0].status == "FAIL"
assert (
result[0].status_extended
== "SES identity test-email-identity-public is publicly accessible due to its resource policy."
== "SES identity test-email-identity-public is publicly accessible due to its resource policies."
)
assert result[0].resource_id == "test-email-identity-public"
assert (
@@ -27,6 +27,7 @@ def mock_make_api_call(self, operation_name, kwarg):
return {
"Policies": {
"policy1": '{"policy1": "value1"}',
"policy2": '{"policy2": "value2"}',
},
"Tags": {"tag1": "value1", "tag2": "value2"},
"DkimAttributes": {
@@ -81,7 +82,11 @@ class Test_SES_Service:
assert ses.email_identities[arn].type == "EMAIL_ADDRESS"
assert ses.email_identities[arn].arn == arn
assert ses.email_identities[arn].region == AWS_REGION_EU_WEST_1
assert ses.email_identities[arn].policy == {"policy1": "value1"}
assert ses.email_identities[arn].policy == {"policy2": "value2"}
assert ses.email_identities[arn].policies == {
"policy1": {"policy1": "value1"},
"policy2": {"policy2": "value2"},
}
assert ses.email_identities[arn].tags == {"tag1": "value1", "tag2": "value2"}
assert ses.email_identities[arn].dkim_status == "SUCCESS"
assert ses.email_identities[arn].dkim_signing_attributes_origin == "AWS_SES"
+5 -2
View File
@@ -1624,8 +1624,11 @@ class TestCompliance:
assert "custom_1.0_ext" in frameworks
@patch("prowler.config.config.importlib.metadata.entry_points")
def test_get_available_compliance_includes_external_universal(self, mock_ep):
@patch("prowler.config.config._get_ep_compliance_dirs", return_value={})
@patch("prowler.lib.check.compliance_models.importlib.metadata.entry_points")
def test_get_available_compliance_includes_external_universal(
self, mock_ep, _mock_ep_dirs
):
"""External universal frameworks under prowler.compliance.universal are
listed, for a provider and for the provider=None case that feeds
--compliance choices."""
+202
View File
@@ -0,0 +1,202 @@
from pathlib import Path
import pytest
from util.check_yanked_pins import (
Pin,
collect_pins,
evaluate,
main,
normalize,
pins_from_pyproject,
pins_from_uv_lock,
)
PYPROJECT = """
[project]
name = "demo"
dependencies = [
"cryptography==48.0.1",
"alibabacloud_tea_openapi==0.4.5",
"Requests[security]==2.34.2 ; python_version >= '3.10'",
"boto3>=1.40",
]
[project.optional-dependencies]
extra = ["okta==3.4.2"]
[dependency-groups]
dev = ["pytest==9.0.3", {include-group = "lint"}]
lint = ["flake8==7.1.2"]
[tool.uv]
constraint-dependencies = ["zstd==1.5.7.3"]
override-dependencies = ["okta==3.4.2"]
"""
UV_LOCK = """
version = 1
[[package]]
name = "zstd"
version = "1.5.7.3"
source = { registry = "https://pypi.org/simple" }
[[package]]
name = "Cryptography"
version = "48.0.1"
source = { registry = "https://pypi.org/simple" }
[[package]]
name = "prowler"
version = "5.40.0"
source = { git = "https://github.com/prowler-cloud/prowler.git?rev=master#abc" }
[[package]]
name = "demo"
version = "0.1.0"
source = { editable = "." }
"""
class TestNormalize:
"""normalize() applies PEP 503 so spellings of one project compare equal."""
def test_pep503_equivalence(self):
"""Underscores, dots and case collapse to the canonical dashed lowercase form."""
assert normalize("alibabacloud_tea_openapi") == "alibabacloud-tea-openapi"
assert normalize("Requests") == "requests"
assert normalize("zope.interface") == "zope-interface"
class TestPinsFromPyproject:
"""pins_from_pyproject() reads exact pins from every dependency-bearing table."""
def test_collects_exact_pins_from_every_table(self):
"""Dependencies, extras, dependency groups and both [tool.uv] lists are covered."""
pins = pins_from_pyproject(PYPROJECT, "")
assert {(p.name, p.version) for p in pins} == {
("cryptography", "48.0.1"),
("alibabacloud-tea-openapi", "0.4.5"),
("requests", "2.34.2"),
("okta", "3.4.2"),
("pytest", "9.0.3"),
("flake8", "7.1.2"),
("zstd", "1.5.7.3"),
}
def test_ignores_ranges_and_records_source_table(self):
"""Non-exact specifiers are skipped and each pin remembers its table."""
pins = pins_from_pyproject(PYPROJECT, "api/")
names = {p.name for p in pins}
assert "boto3" not in names
zstd = next(p for p in pins if p.name == "zstd")
assert zstd.source == "api/pyproject.toml [tool.uv.constraint-dependencies]"
def test_same_pin_in_two_tables_keeps_both_sources(self):
"""The same version in two tables yields two pins, one per source."""
okta = {
p.source for p in pins_from_pyproject(PYPROJECT, "") if p.name == "okta"
}
assert okta == {
"pyproject.toml [project.optional-dependencies.extra]",
"pyproject.toml [tool.uv.override-dependencies]",
}
class TestPinsFromUvLock:
"""pins_from_uv_lock() reads locked versions that live on a registry."""
def test_only_registry_packages(self):
"""git, path and editable sources are not on PyPI and are skipped."""
pins = pins_from_uv_lock(UV_LOCK, "")
assert {(p.name, p.version) for p in pins} == {
("zstd", "1.5.7.3"),
("cryptography", "48.0.1"),
}
assert all(p.source == "uv.lock" for p in pins)
class TestCollectPins:
"""collect_pins() merges a project's pyproject.toml and uv.lock."""
def test_missing_files_raise(self, tmp_path: Path):
"""A directory with neither file is a caller error, not an empty result."""
with pytest.raises(FileNotFoundError):
collect_pins(tmp_path)
def test_merges_pyproject_and_lock(self, tmp_path: Path):
"""Pins from both files are returned with the directory as source prefix."""
(tmp_path / "pyproject.toml").write_text(PYPROJECT)
(tmp_path / "uv.lock").write_text(UV_LOCK)
sources = {p.source for p in collect_pins(tmp_path)}
prefix = f"{tmp_path.as_posix()}/"
assert f"{prefix}uv.lock" in sources
assert f"{prefix}pyproject.toml [project.dependencies]" in sources
class TestEvaluate:
"""evaluate() queries PyPI once per release and reports per pin."""
def test_queries_each_release_once_and_fans_out_to_every_source(self):
"""One fetch per (name, version); its verdict reaches every source of that pin."""
calls = []
def fake_fetch(name, version):
"""Stand-in for fetch_release() that records calls and returns fixed verdicts."""
calls.append((name, version))
if (name, version) == ("zstd", "1.5.7.3"):
return "yanked", "buggy - not thread safe"
if (name, version) == ("gone", "0.0.1"):
return "missing", "not found on PyPI"
return "ok", ""
pins = {
Pin("zstd", "1.5.7.3", "pyproject.toml [tool.uv.constraint-dependencies]"),
Pin("zstd", "1.5.7.3", "uv.lock"),
Pin("cryptography", "48.0.1", "uv.lock"),
Pin("gone", "0.0.1", "uv.lock"),
}
verdicts = evaluate(pins, fetch=fake_fetch, workers=2)
assert sorted(calls) == [
("cryptography", "48.0.1"),
("gone", "0.0.1"),
("zstd", "1.5.7.3"),
]
by_status = {}
for verdict in verdicts:
by_status.setdefault(verdict.status, []).append(verdict.pin)
assert len(by_status["yanked"]) == 2
assert {p.source for p in by_status["yanked"]} == {
"pyproject.toml [tool.uv.constraint-dependencies]",
"uv.lock",
}
assert by_status["missing"] == [Pin("gone", "0.0.1", "uv.lock")]
assert by_status["ok"] == [Pin("cryptography", "48.0.1", "uv.lock")]
class TestMain:
"""main() turns verdicts into a process exit code and annotations."""
def test_exit_code_reflects_verdicts(self, tmp_path: Path, monkeypatch, capsys):
"""0 when every pin is ok, 1 plus a ::error:: line when one is yanked."""
(tmp_path / "pyproject.toml").write_text(
'[project]\ndependencies = ["zstd==1.5.7.3"]\n'
)
monkeypatch.setattr(
"util.check_yanked_pins.fetch_release",
lambda name, version, retries=3: ("ok", ""),
)
assert main([str(tmp_path)]) == 0
monkeypatch.setattr(
"util.check_yanked_pins.fetch_release",
lambda name, version, retries=3: ("yanked", "buggy - not thread safe"),
)
assert main([str(tmp_path)]) == 1
assert (
"::error::zstd==1.5.7.3 is yanked (buggy - not thread safe)"
in capsys.readouterr().out
)
+18
View File
@@ -4,6 +4,24 @@ All notable changes to the **Prowler UI** are documented in this file.
<!-- changelog: release notes start -->
## [1.39.0] (Prowler v5.39.0)
### 🚀 Added
- Manual verification workflow for `MANUAL` findings with evidence, effective `PASS` status, and expiration details [(#12253)](https://github.com/prowler-cloud/prowler/pull/12253)
- Surface pre-configured credential creation links in the add-provider wizard. Cloudflare exposes the User API Token template and an Account-Owned template pinned to the Cloudflare Account ID entered in the wizard, GitHub exposes the personal-repositories template and an organization-scanning template pinned to the identifier entered in the wizard [(#12349)](https://github.com/prowler-cloud/prowler/pull/12349)
- Attack Paths graph groups resources by class into expandable nodes and marks the query outcome as the terminal node, with the clicked resource highlighted while its findings are expanded (Prowler Cloud only) [(#12381)](https://github.com/prowler-cloud/prowler/pull/12381)
- Azure Management Group onboarding: add every subscription in a tenant at once (Prowler Cloud only) [(#12386)](https://github.com/prowler-cloud/prowler/pull/12386)
- Manage Lighthouse AI role permission in the role forms and role details, so permission to change the Lighthouse AI configuration can be granted or restricted independently of other permissions (Prowler Cloud only) [(#12412)](https://github.com/prowler-cloud/prowler/pull/12412)
- CMMC 2.0 universal compliance framework rendering: dedicated icon, Domain/Level requirement mapper and cross-provider catalog tile [(#12414)](https://github.com/prowler-cloud/prowler/pull/12414)
### 🐞 Fixed
- Organization discovery describes a too-deep hierarchy in each provider's own vocabulary: AWS organizational units, Azure Management Groups, Google Cloud folders [(#12386)](https://github.com/prowler-cloud/prowler/pull/12386)
- `View Findings` on the Scans page no longer opens an empty list for users outside the UTC timezone [(#12411)](https://github.com/prowler-cloud/prowler/pull/12411)
---
## [1.38.0] (Prowler v5.38.0)
### 🚀 Added
+19 -2
View File
@@ -5,10 +5,27 @@ LABEL maintainer="https://github.com/prowler-cloud"
# The build uses pnpm via corepack, so npm is unused — remove it (and npx) to drop
# the bundled-npm CVE surface from every stage, incl. prod.
# No apk upgrade: it resolves against Alpine's live repo, so the digest pin above
# would not make the image reproducible. Move the digest forward instead.
# No blanket apk upgrade: it resolves against Alpine's live repo, so the digest pin
# above would not make the image reproducible. Move the digest forward instead, or
# take a named package as the targeted exception below.
RUN corepack enable && rm -rf /usr/local/lib/node_modules/npm /usr/local/bin/npm /usr/local/bin/npx
# High CVEs fixed in Alpine 3.24 but not yet in the pinned base image:
# libcrypto3/libssl3 3.5.8-r0 CVE-2026-14456, CVE-2026-14457, CVE-2026-18798,
# CVE-2026-54874, CVE-2026-63072, CVE-2026-63075,
# CVE-2026-63076 (image ships 3.5.7-r0)
# The base image pins node 24.18.1, which has not been rebuilt since that package
# was published, so the upgrade is taken here rather than by moving the pin -- the
# newest published node:24-alpine (24.19.0, built 2026-08-03) predates the
# 2026-08-13 advisory and carries the same vulnerable version. libcrypto3 and
# libssl3 are both built from openssl and are flagged separately, so both are named.
# `>=` rather than `=`: Alpine keeps only the newest build of a package in a
# branch's index, so an exact pin breaks this build the day 3.5.8-r0 is superseded.
# Drop this once the base image ships 3.5.8-r0 or later.
RUN apk add --no-cache --upgrade \
"libcrypto3>=3.5.8-r0" \
"libssl3>=3.5.8-r0"
# Install dependencies only when needed
FROM base AS deps
# Check https://github.com/nodejs/docker-node/tree/b4117f9333da4138b03a546ec926ef50a31506c3#nodealpine to understand why libc6-compat might be needed.
@@ -93,6 +93,7 @@ describe("getComplianceCatalog", () => {
watchlistCount: 0,
eligibleProviderTypes: [],
},
unavailable: true,
});
expect(fetchMock).not.toHaveBeenCalled();
});
@@ -163,7 +164,7 @@ describe("getComplianceCatalog", () => {
);
});
it("degrades to an empty catalog when the request fails", async () => {
it("flags an empty catalog as unavailable when the request fails", async () => {
fetchMock.mockResolvedValue(jsonResponse({ errors: [] }, 500));
const catalog = await getComplianceCatalog();
@@ -175,9 +176,21 @@ describe("getComplianceCatalog", () => {
watchlistCount: 0,
eligibleProviderTypes: [],
},
unavailable: true,
});
});
it("does not flag a catalog that is legitimately empty", async () => {
fetchMock.mockResolvedValue(
jsonResponse({ data: [], meta: { pagination: { page: 1, pages: 1 } } }),
);
const catalog = await getComplianceCatalog();
expect(catalog.entries).toEqual([]);
expect(catalog.unavailable).toBe(false);
});
it("degrades to an empty catalog when fetch throws", async () => {
fetchMock.mockRejectedValue(new Error("network down"));
@@ -186,7 +199,8 @@ describe("getComplianceCatalog", () => {
expect(catalog.entries).toEqual([]);
});
it("keeps the rest of the catalog when a single page fails", async () => {
it("flags the catalog as unavailable when a single page fails", async () => {
// Given - one of three catalog pages times out
fetchMock
.mockResolvedValueOnce(
jsonResponse(catalogPage("cis_1.4_aws", { page: 1, pages: 3 })),
@@ -196,12 +210,15 @@ describe("getComplianceCatalog", () => {
jsonResponse(catalogPage("iso27001_aws", { page: 3, pages: 3 })),
);
// When - the catalog keeps the pages it could read
const catalog = await getComplianceCatalog();
// Then - consumers know the merged result is incomplete
expect(catalog.entries.map((entry) => entry.complianceId)).toEqual([
"cis_1.4_aws",
"iso27001_aws",
]);
expect(catalog.unavailable).toBe(true);
});
it("bounds how many pages it requests at once", async () => {
@@ -12,6 +12,7 @@ import {
} from "@/lib/compliance/watchlist";
import type {
ComplianceCatalog,
ComplianceCatalogLoad,
ComplianceWatchlistActionResult,
ComplianceWatchlistBulkDiff,
ComplianceWatchlistTarget,
@@ -45,6 +46,12 @@ const EMPTY_CATALOG: ComplianceCatalog = {
meta: { totalEntries: 0, watchlistCount: 0, eligibleProviderTypes: [] },
};
// Gave up without an answer: distinct from a legitimately empty catalog.
const UNAVAILABLE_CATALOG: ComplianceCatalogLoad = {
...EMPTY_CATALOG,
unavailable: true,
};
const GENERIC_ERROR = "Could not update the compliance watchlist.";
const watchlistTargetSchema = z.object({
@@ -97,9 +104,9 @@ const buildCatalogUrl = (page: number, providerTypes?: string[]): string => {
export const getComplianceCatalog = async (
input: { providerTypes?: string[] } = {},
): Promise<ComplianceCatalog> => {
): Promise<ComplianceCatalogLoad> => {
const parsedInput = complianceCatalogInputSchema.safeParse(input);
if (!parsedInput.success) return EMPTY_CATALOG;
if (!parsedInput.success) return UNAVAILABLE_CATALOG;
const { providerTypes } = parsedInput.data;
@@ -127,15 +134,16 @@ export const getComplianceCatalog = async (
};
const firstPage = await fetchPage(1);
if (!firstPage) return EMPTY_CATALOG;
if (!firstPage) return UNAVAILABLE_CATALOG;
const pageCount = Math.min(
Math.max(1, firstPage.pageCount),
MAX_CATALOG_PAGES,
);
if (pageCount <= 1) return firstPage.catalog;
if (pageCount <= 1) return { ...firstPage.catalog, unavailable: false };
const rest: ComplianceCatalog[] = [];
let incomplete = firstPage.pageCount > MAX_CATALOG_PAGES;
for (let page = 2; page <= pageCount; page += CATALOG_FETCH_CONCURRENCY) {
const batch = await Promise.all(
Array.from(
@@ -143,13 +151,17 @@ export const getComplianceCatalog = async (
(_, index) => fetchPage(page + index),
),
);
if (batch.some((pageResult) => pageResult === null)) incomplete = true;
rest.push(...batch.flatMap((page) => (page ? [page.catalog] : [])));
}
return mergeCatalogPages([firstPage.catalog, ...rest]);
return {
...mergeCatalogPages([firstPage.catalog, ...rest]),
unavailable: incomplete,
};
} catch (error) {
console.error("Error fetching compliance catalog:", error);
return EMPTY_CATALOG;
return UNAVAILABLE_CATALOG;
}
};
@@ -120,10 +120,6 @@ vi.mock("../_components/cross-provider-detail", () => ({
CrossProviderDetail: () => null,
}));
vi.mock("../_lib/cross-provider-frameworks", () => ({
resolveCrossProviderFramework: vi.fn(),
}));
vi.mock("../_lib/search-params-key", () => ({
buildSearchParamsKey: vi.fn(() => "search-params"),
}));
@@ -51,7 +51,6 @@ import { ScanEntity } from "@/types/scans";
import { CrossAccountDetail } from "../_components/cross-account-detail";
import { CrossProviderDetail } from "../_components/cross-provider-detail";
import { resolveCrossProviderFramework } from "../_lib/cross-provider-frameworks";
import { buildSearchParamsKey } from "../_lib/search-params-key";
const getSingleSearchParam = (
@@ -86,20 +85,11 @@ export default async function ComplianceDetail({
redirect("/compliance");
}
const framework = resolveCrossProviderFramework(
complianceId,
compliancetitle,
);
if (!framework) {
notFound();
}
const crossProviderTitle = framework.title.split("-").join(" ");
return (
<ContentLayout title={`${crossProviderTitle} - ${framework.version}`}>
<Suspense
key={buildSearchParamsKey(resolvedSearchParams)}
fallback={
<Suspense
key={buildSearchParamsKey(resolvedSearchParams)}
fallback={
<ContentLayout title="Compliance">
<div className="flex flex-col gap-8">
<div className="grid grid-cols-1 gap-6 md:grid-cols-[minmax(280px,400px)_1fr]">
<RequirementsStatusCardSkeleton />
@@ -107,16 +97,16 @@ export default async function ComplianceDetail({
</div>
<SkeletonAccordion />
</div>
}
>
<CrossProviderDetail
compliancetitle={compliancetitle}
complianceId={complianceId}
searchParams={resolvedSearchParams}
targetSection={section}
/>
</Suspense>
</ContentLayout>
</ContentLayout>
}
>
<CrossProviderDetail
compliancetitle={compliancetitle}
complianceId={complianceId}
searchParams={resolvedSearchParams}
targetSection={section}
/>
</Suspense>
);
}
// Cross-account mode: one regular framework aggregated across every
@@ -36,12 +36,9 @@ vi.mock("@/actions/compliance-watchlist", () => ({
// The watchlist context reads the session through next-auth, which cannot be
// imported in this environment; the watchlist behaviour has its own tests.
// It also carries the catalog this section reads its exclusions from.
vi.mock("../_lib/watchlist-context", () => ({
loadComplianceWatchlistContext: vi.fn(async () => ({
entries: [],
eligibleProviderTypes: [],
canManage: false,
})),
loadComplianceWatchlistContext: vi.fn(),
}));
vi.mock("@/components/icons/providers-badge/provider-type-icon", () => ({
@@ -115,6 +112,18 @@ describe("CrossAccountOverviewSection", () => {
vi.mocked(getAllProviders).mockReset();
vi.mocked(getScans).mockReset();
vi.mocked(getCompliancesOverview).mockReset();
vi.mocked(loadComplianceWatchlistContext).mockResolvedValue({
entries: [
makeComplianceCatalogEntry({
complianceId: "csa_ccm_4.0",
providerType: "*",
framework: "CSA-CCM",
}),
],
eligibleProviderTypes: [],
canManage: false,
unavailable: false,
});
});
it("renders nothing when no provider type has two or more accounts", async () => {
@@ -324,11 +333,13 @@ describe("CrossAccountOverviewSection watchlist", () => {
const withWatchlist = (
entries: ReturnType<typeof catalogEntry>[],
canManage = true,
unavailable = false,
) =>
vi.mocked(loadComplianceWatchlistContext).mockResolvedValue({
entries,
eligibleProviderTypes: ["aws"],
canManage,
unavailable,
});
it("keeps the provider-type grouping when the filter is on", async () => {
@@ -365,6 +376,14 @@ describe("CrossAccountOverviewSection watchlist", () => {
expect(screen.queryByTestId("cross-account-card")).not.toBeInTheDocument();
});
it("renders nothing when the catalog could not be read", async () => {
withWatchlist([], true, true);
const { container } = await renderSection();
expect(container).toBeEmptyDOMElement();
});
it("ignores the filter without a catalog, so OSS never blanks out", async () => {
useComplianceWatchlistViewStore.setState({ showOnlyWatchlist: true });
withWatchlist([], false);
@@ -16,7 +16,7 @@ import type { SearchParamsProps } from "@/types";
import type { ComplianceOverviewData } from "@/types/compliance";
import { isKnownProviderType, type KnownProviderType } from "@/types/providers";
import { CROSS_PROVIDER_FRAMEWORKS } from "../_lib/cross-provider-frameworks";
import { loadCrossProviderFrameworks } from "../_lib/cross-provider-catalog";
import { loadComplianceWatchlistContext } from "../_lib/watchlist-context";
import type { CrossAccountFrameworkEntry } from "../_types";
@@ -87,8 +87,12 @@ export const CrossAccountOverviewSection = async ({
scansByType.filter((entry) => entry !== null),
);
// Universal frameworks belong to "Across providers" above. Without the
// catalog we cannot tell them apart, and that section already reports it.
const catalog = await loadCrossProviderFrameworks();
if (catalog.unavailable) return null;
const universalIds = new Set(
CROSS_PROVIDER_FRAMEWORKS.map((entry) => entry.complianceId),
catalog.frameworks.map((entry) => entry.complianceId),
);
const entriesByType = await Promise.all(
@@ -0,0 +1,170 @@
import { render, screen } from "@testing-library/react";
import type { ReactNode } from "react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { getAllProviderGroups } from "@/actions/manage-groups/manage-groups";
import { getAllProviders } from "@/actions/providers";
import { getComplianceIcon } from "@/components/icons/compliance/IconCompliance";
import {
getCrossProviderComplianceOverview,
getLatestCrossProviderPdf,
} from "../_actions/cross-provider";
import { CROSS_PROVIDER_OVERVIEW_RESULT_STATUS } from "../_types";
import { CrossProviderDetail } from "./cross-provider-detail";
vi.mock("@/actions/manage-groups/manage-groups", () => ({
getAllProviderGroups: vi.fn(),
}));
vi.mock("@/actions/providers", () => ({
getAllProviders: vi.fn(),
}));
vi.mock("@/components/icons/compliance/IconCompliance", () => ({
getComplianceIcon: vi.fn(() => "/compliance.svg"),
}));
vi.mock("@/components/lighthouse/context-contributor", () => ({
LighthouseContextContributor: () => null,
}));
vi.mock("@/components/shadcn/content-layout", () => ({
ContentLayout: ({
title,
children,
}: {
title: string;
children: ReactNode;
}) => (
<div data-testid="content-layout" data-title={title}>
{children}
</div>
),
}));
vi.mock("@/lib/compliance/compliance-mapper", () => ({
getComplianceMapper: () => ({
getTopFailedSections: () => ({
items: [],
type: "section",
prepopulated: false,
}),
mapComplianceData: () => [],
}),
}));
vi.mock("../_actions/cross-provider", () => ({
getCrossProviderComplianceOverview: vi.fn(),
getLatestCrossProviderPdf: vi.fn(),
}));
vi.mock("../_lib/aggregated-compliance-detail", () => ({
getAggregatedInitialExpandedKeys: () => [],
getAggregatedRequirementsTotals: () => ({ pass: 0, fail: 0, manual: 0 }),
}));
vi.mock("../_lib/cross-provider-accordion", () => ({
toCrossProviderAccordionItems: () => [],
}));
vi.mock("../_lib/cross-provider-adapter", () => ({
buildRequirementExtrasMap: () => new Map(),
computeProviderBreakdown: () => [],
crossProviderToMapperInput: () => ({
attributesData: {},
requirementsData: {},
}),
}));
vi.mock("./aggregated-compliance-detail", () => ({
AggregatedComplianceDetail: ({
compliancetitle,
}: {
compliancetitle: string;
}) => (
<div data-testid="aggregated-compliance" data-title={compliancetitle} />
),
}));
vi.mock("./cross-provider-filters", () => ({
CrossProviderFilters: () => null,
}));
vi.mock("./cross-provider-hub-link", () => ({
CrossProviderHubLink: () => null,
}));
vi.mock("./cross-provider-pdf-button", () => ({
CrossProviderPdfButton: () => null,
}));
vi.mock("./provider-coverage-card", () => ({
ProviderCoverageCard: () => null,
}));
describe("CrossProviderDetail", () => {
beforeEach(() => {
vi.mocked(getAllProviders).mockResolvedValue({
data: [],
links: { first: "", last: "", next: null, prev: null },
meta: { pagination: { page: 1, pages: 1, count: 0 }, version: "" },
});
vi.mocked(getAllProviderGroups).mockResolvedValue({
data: [],
links: { first: "", last: "", next: null, prev: null },
meta: { pagination: { page: 1, pages: 1, count: 0 }, version: "" },
});
vi.mocked(getLatestCrossProviderPdf).mockResolvedValue(null);
vi.mocked(getCrossProviderComplianceOverview).mockResolvedValue({
status: CROSS_PROVIDER_OVERVIEW_RESULT_STATUS.SUCCESS,
response: {
data: {
type: "cross-provider-compliance-overviews",
id: "acme_1.0",
attributes: {
compliance_id: "acme_1.0",
framework: "ACME",
name: "ACME Framework",
version: "1.0",
description: "External framework",
compatible_providers: ["aws"],
requested_providers: ["aws"],
providers: ["aws"],
scan_ids: [],
scan_ids_by_provider: {},
requirements_passed: 0,
requirements_failed: 0,
requirements_manual: 0,
total_requirements: 0,
requirements: [],
},
},
},
});
});
it("uses API identity instead of route-controlled title and version", async () => {
// Given - a valid framework id with spoofed route metadata
const props = {
compliancetitle: "Spoofed-Framework",
complianceId: "acme_1.0",
searchParams: { version: "999.0" },
};
// When - the server detail renders the API result
render(await CrossProviderDetail(props));
// Then - every visible identity comes from the validated overview
expect(screen.getByTestId("content-layout")).toHaveAttribute(
"data-title",
"ACME - 1.0",
);
expect(getComplianceIcon).toHaveBeenCalledWith("ACME");
expect(screen.getByTestId("aggregated-compliance")).toHaveAttribute(
"data-title",
"ACME",
);
});
});
@@ -5,9 +5,11 @@ import { getAllProviders } from "@/actions/providers";
import { getComplianceIcon } from "@/components/icons/compliance/IconCompliance";
import { LighthouseContextContributor } from "@/components/lighthouse/context-contributor";
import { Alert, AlertDescription } from "@/components/shadcn/alert";
import { ContentLayout } from "@/components/shadcn/content-layout";
import { getComplianceMapper } from "@/lib/compliance/compliance-mapper";
import { LIGHTHOUSE_COMPLIANCE_CONTEXT_MODE } from "@/lib/lighthouse/context/constants";
import { buildComplianceContext } from "@/lib/lighthouse/context/contributions";
import { isKnownProviderType } from "@/types/providers";
import {
getCrossProviderComplianceOverview,
@@ -23,10 +25,7 @@ import {
computeProviderBreakdown,
crossProviderToMapperInput,
} from "../_lib/cross-provider-adapter";
import {
CROSS_PROVIDER_FRAMEWORKS,
parseCrossProviderFilters,
} from "../_lib/cross-provider-frameworks";
import { parseCrossProviderFilters } from "../_lib/cross-provider-frameworks";
import { CROSS_PROVIDER_OVERVIEW_RESULT_STATUS } from "../_types";
import { AggregatedComplianceDetail } from "./aggregated-compliance-detail";
@@ -72,31 +71,45 @@ export const CrossProviderDetail = async ({
overviewResponse.status ===
CROSS_PROVIDER_OVERVIEW_RESULT_STATUS.ACTION_ERROR
) {
return <CrossProviderErrorAlert result={overviewResponse.result} />;
return (
<ContentLayout title="Compliance">
<CrossProviderErrorAlert result={overviewResponse.result} />
</ContentLayout>
);
}
if (
overviewResponse.status === CROSS_PROVIDER_OVERVIEW_RESULT_STATUS.LOAD_ERROR
) {
return <CrossProviderErrorAlert message={overviewResponse.message} />;
return (
<ContentLayout title="Compliance">
<CrossProviderErrorAlert message={overviewResponse.message} />
</ContentLayout>
);
}
const overviewData = overviewResponse.response.data;
if (!overviewData?.attributes) {
return (
<Alert variant="info">
<Info className="size-4" />
<AlertDescription>
No cross-provider compliance data was returned for this framework.
Universal frameworks aggregate the latest completed scan of every
compatible provider — run a scan to populate this view.
</AlertDescription>
</Alert>
<ContentLayout title="Compliance">
<Alert variant="info">
<Info className="size-4" />
<AlertDescription>
No cross-provider compliance data was returned for this framework.
Universal frameworks aggregate the latest completed scan of every
compatible provider — run a scan to populate this view.
</AlertDescription>
</Alert>
</ContentLayout>
);
}
const attrs = overviewData.attributes;
const frameworkTitle = attrs.framework || attrs.name || "Compliance";
const pageTitle = attrs.version
? `${frameworkTitle} - ${attrs.version}`
: frameworkTitle;
// Scoped to the EXACT scans the overview resolved (not the raw filters), so
// an offered "Download latest" always matches the data on screen even if a
@@ -128,13 +141,14 @@ export const CrossProviderDetail = async ({
targetSection,
);
const catalogEntry = CROSS_PROVIDER_FRAMEWORKS.find(
(entry) => entry.complianceId === complianceId,
);
const compatibleTypes =
catalogEntry?.compatibleProviders ??
providerBreakdown.map((b) => b.provider);
const logoPath = getComplianceIcon(compliancetitle);
// What the framework declares, so externally registered ones are covered.
const compatibleTypes: string[] = attrs.compatible_providers.length
? attrs.compatible_providers
: providerBreakdown.map((b) => b.provider);
// Select and breakdown both need an icon and a label; the summary above
// still counts the type as compatible.
const selectableTypes = compatibleTypes.filter(isKnownProviderType);
const logoPath = getComplianceIcon(frameworkTitle);
const providerAccounts: CrossProviderAccountOption[] = (
providersData?.data || []
@@ -155,7 +169,7 @@ export const CrossProviderDetail = async ({
).map((group) => ({ id: group.id, name: group.attributes.name }));
return (
<>
<ContentLayout title={pageTitle}>
<LighthouseContextContributor
key={`cross-provider-detail-${complianceId}-${totals.pass}-${totals.fail}`}
contributorId="compliance-detail"
@@ -172,11 +186,11 @@ export const CrossProviderDetail = async ({
})}
/>
<AggregatedComplianceDetail
compliancetitle={compliancetitle}
compliancetitle={frameworkTitle}
logoPath={logoPath}
title={
<span className="truncate text-sm font-medium">
{attrs.name || compliancetitle.split("-").join(" ")}
{attrs.name || frameworkTitle}
</span>
}
description={
@@ -196,7 +210,7 @@ export const CrossProviderDetail = async ({
}
filters={
<CrossProviderFilters
providerTypes={compatibleTypes}
providerTypes={selectableTypes}
providerAccounts={providerAccounts}
providerGroups={providerGroups}
/>
@@ -211,6 +225,6 @@ export const CrossProviderDetail = async ({
accordionItems={accordionItems}
initialExpandedKeys={initialExpandedKeys}
/>
</>
</ContentLayout>
);
};
@@ -23,9 +23,6 @@ interface CrossProviderFrameworkGridProps {
cards: CrossProviderCard[];
/** MANAGE_SCANS, forwarded to each card's pin. */
canManageWatchlist: boolean;
/** False when the tenant has no catalog at all (OSS), in which case the
* stored filter must not be able to blank the grid. */
watchlistEnabled: boolean;
}
/**
@@ -37,11 +34,9 @@ interface CrossProviderFrameworkGridProps {
export const CrossProviderFrameworkGrid = ({
cards,
canManageWatchlist,
watchlistEnabled,
}: CrossProviderFrameworkGridProps) => {
const showOnlyWatchlist = useShowOnlyWatchlist();
const filterToWatchlist = watchlistEnabled && showOnlyWatchlist;
// Cards derive from the catalog, so no catalog means no cards to filter.
const filterToWatchlist = useShowOnlyWatchlist();
const isPinned = (card: CrossProviderCard) =>
card.watchlist.state === WATCHLIST_PIN_STATE.PINNED;
const visibleCards = filterToWatchlist ? cards.filter(isPinned) : cards;
@@ -4,9 +4,9 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
import { ACTION_ERROR_STATUS, USAGE_LIMIT_MESSAGE } from "@/lib/action-errors";
import { useComplianceWatchlistViewStore } from "@/store/compliance/store";
import { makeComplianceCatalogEntry } from "@/test-utils/compliance-watchlist";
import type { ComplianceCatalogEntry } from "@/types/compliance-watchlist";
import { getCrossProviderComplianceOverview } from "../_actions/cross-provider";
import { CROSS_PROVIDER_FRAMEWORKS } from "../_lib/cross-provider-frameworks";
import { loadComplianceWatchlistContext } from "../_lib/watchlist-context";
import type { CrossProviderOverviewResult } from "../_types";
import {
@@ -40,12 +40,9 @@ vi.mock("@/actions/compliance-watchlist", () => ({
// The watchlist context reads the session through next-auth, which cannot be
// imported in this environment; the watchlist behaviour has its own tests.
// It also carries the catalog the section builds its cards from.
vi.mock("../_lib/watchlist-context", () => ({
loadComplianceWatchlistContext: vi.fn(async () => ({
entries: [],
eligibleProviderTypes: [],
canManage: false,
})),
loadComplianceWatchlistContext: vi.fn(),
}));
vi.mock("./cross-provider-filters", () => ({
@@ -72,6 +69,45 @@ vi.mock("./cross-provider-framework-card", () => ({
),
}));
const DORA_ID = "dora_2022_2554";
// One card per entry, ordered by title.
const UNIVERSAL_FRAMEWORKS = [
{ complianceId: "csa_ccm_4.0", framework: "CSA-CCM" },
{ complianceId: "cis_controls_8.1", framework: "CIS-Controls" },
{ complianceId: "cmmc_2.0", framework: "CMMC" },
{ complianceId: DORA_ID, framework: "DORA" },
];
const EXPECTED_TITLES = ["CIS-Controls", "CMMC", "CSA-CCM", "DORA"];
const catalogEntries = (pinned: string[] = []): ComplianceCatalogEntry[] =>
UNIVERSAL_FRAMEWORKS.map(({ complianceId, framework }) =>
makeComplianceCatalogEntry({
complianceId,
// The catalog keys a universal framework under `*`.
providerType: "*",
framework,
inWatchlist: pinned.includes(complianceId),
watchlistEntryId: pinned.includes(complianceId)
? `entry-${complianceId}`
: null,
}),
);
const withCatalog = (
entries: ComplianceCatalogEntry[],
eligibleProviderTypes: string[] = ["aws", "azure"],
canManage = true,
unavailable = false,
) =>
vi.mocked(loadComplianceWatchlistContext).mockResolvedValue({
entries,
eligibleProviderTypes,
canManage,
unavailable,
});
const successResult = (complianceId: string): CrossProviderOverviewResult => ({
status: CROSS_PROVIDER_OVERVIEW_RESULT_STATUS.SUCCESS,
response: {
@@ -110,13 +146,67 @@ const renderOverview = async () =>
describe("CrossProviderOverview", () => {
beforeEach(() => {
vi.mocked(getCrossProviderComplianceOverview).mockReset();
vi.mocked(loadComplianceWatchlistContext).mockReset();
withCatalog(catalogEntries());
});
it("renders one card per universal framework the catalog reports", async () => {
// ACME is what an entry-point package would contribute.
withCatalog([
...catalogEntries(),
makeComplianceCatalogEntry({
complianceId: "acme_1.0",
providerType: "*",
framework: "ACME",
}),
]);
vi.mocked(getCrossProviderComplianceOverview).mockImplementation(
async ({ complianceId }) => successResult(complianceId),
);
// When
await renderOverview();
// Then
const cards = screen.getAllByTestId("framework-card");
expect(cards.map((card) => card.textContent)).toEqual([
"ACME",
...EXPECTED_TITLES,
]);
});
it("renders no cards when the catalog reports no universal framework", async () => {
withCatalog([]);
await renderOverview();
expect(screen.queryByTestId("framework-card")).not.toBeInTheDocument();
expect(getCrossProviderComplianceOverview).not.toHaveBeenCalled();
expect(
screen.getByText(/No cross-provider compliance data yet/i),
).toBeInTheDocument();
});
it("reports the failure instead of claiming there is no data", async () => {
withCatalog([], ["aws", "azure"], true, true);
await renderOverview();
expect(
screen.getByText(CROSS_PROVIDER_OVERVIEW_LOAD_ERROR_MESSAGE),
).toBeInTheDocument();
expect(screen.queryByTestId("framework-card")).not.toBeInTheDocument();
expect(
screen.queryByText(/No cross-provider compliance data yet/i),
).not.toBeInTheDocument();
expect(getCrossProviderComplianceOverview).not.toHaveBeenCalled();
});
it("degrades to a partial view when a single framework fails to load", async () => {
// Given: DORA fails, the other frameworks load
vi.mocked(getCrossProviderComplianceOverview).mockImplementation(
async ({ complianceId }) =>
complianceId === "dora_2022_2554"
complianceId === DORA_ID
? loadErrorResult
: successResult(complianceId),
);
@@ -126,7 +216,7 @@ describe("CrossProviderOverview", () => {
// Then: loaded cards render, the failed framework is called out by name
expect(screen.getAllByTestId("framework-card")).toHaveLength(
CROSS_PROVIDER_FRAMEWORKS.length - 1,
UNIVERSAL_FRAMEWORKS.length - 1,
);
expect(screen.getByText(/Could not load DORA/)).toBeInTheDocument();
expect(
@@ -154,7 +244,7 @@ describe("CrossProviderOverview", () => {
// Given: one framework hits the usage limit (402)
vi.mocked(getCrossProviderComplianceOverview).mockImplementation(
async ({ complianceId }) =>
complianceId === "dora_2022_2554"
complianceId === DORA_ID
? {
status: CROSS_PROVIDER_OVERVIEW_RESULT_STATUS.ACTION_ERROR,
result: { status: ACTION_ERROR_STATUS.PAYMENT_REQUIRED },
@@ -173,58 +263,32 @@ describe("CrossProviderOverview", () => {
});
});
// DORA's compatible provider types, per the static catalog.
const DORA_ID = "dora_2022_2554";
const catalogEntry = (
complianceId: string,
providerType: string,
inWatchlist: boolean,
) =>
makeComplianceCatalogEntry({
complianceId,
providerType,
inWatchlist,
watchlistEntryId: inWatchlist ? `entry-${providerType}` : null,
});
describe("CrossProviderOverview watchlist", () => {
beforeEach(() => {
localStorage.clear();
useComplianceWatchlistViewStore.setState({ showOnlyWatchlist: false });
vi.mocked(loadComplianceWatchlistContext).mockReset();
vi.mocked(getCrossProviderComplianceOverview).mockImplementation(
async ({ complianceId }) => successResult(complianceId),
);
});
const withWatchlist = (
entries: ReturnType<typeof catalogEntry>[],
eligibleProviderTypes: string[],
canManage = true,
) =>
vi.mocked(loadComplianceWatchlistContext).mockResolvedValue({
entries,
eligibleProviderTypes,
canManage,
});
it("keeps the configured framework order when one is pinned", async () => {
// One card, one entry: the catalog keys a universal framework under `*`.
withWatchlist([catalogEntry(DORA_ID, "*", true)], ["aws", "azure"]);
it("keeps the catalog order when one framework is pinned", async () => {
withCatalog(catalogEntries([DORA_ID]));
await renderOverview();
const cards = screen.getAllByTestId("framework-card");
expect(cards).toHaveLength(CROSS_PROVIDER_FRAMEWORKS.length);
expect(cards.map((card) => card.textContent)).toEqual(
CROSS_PROVIDER_FRAMEWORKS.map((framework) => framework.title),
expect(cards.map((card) => card.textContent)).toEqual(EXPECTED_TITLES);
expect(cards[EXPECTED_TITLES.indexOf("DORA")]).toHaveAttribute(
"data-pin-state",
"pinned",
);
expect(cards[2]).toHaveAttribute("data-pin-state", "pinned");
});
it("narrows the grid to the pinned frameworks when the filter is on", async () => {
useComplianceWatchlistViewStore.setState({ showOnlyWatchlist: true });
withWatchlist([catalogEntry(DORA_ID, "*", true)], ["aws", "azure"]);
withCatalog(catalogEntries([DORA_ID]));
await renderOverview();
@@ -235,7 +299,7 @@ describe("CrossProviderOverview watchlist", () => {
it("explains the blank grid when nothing universal is pinned", async () => {
useComplianceWatchlistViewStore.setState({ showOnlyWatchlist: true });
withWatchlist([catalogEntry(DORA_ID, "*", false)], ["aws"]);
withCatalog(catalogEntries());
await renderOverview();
@@ -245,14 +309,13 @@ describe("CrossProviderOverview watchlist", () => {
expect(screen.queryByTestId("framework-card")).not.toBeInTheDocument();
});
it("ignores the filter without a catalog, so OSS never blanks out", async () => {
useComplianceWatchlistViewStore.setState({ showOnlyWatchlist: true });
withWatchlist([], [], false);
it("cannot manage the watchlist without the permission", async () => {
withCatalog(catalogEntries([DORA_ID]), ["aws", "azure"], false);
await renderOverview();
expect(screen.getAllByTestId("framework-card")).toHaveLength(
CROSS_PROVIDER_FRAMEWORKS.length,
);
for (const card of screen.getAllByTestId("framework-card")) {
expect(card).toHaveAttribute("data-can-manage", "false");
}
});
});
@@ -18,12 +18,12 @@ import {
} from "@/lib/lighthouse/context/constants";
import { buildComplianceContext } from "@/lib/lighthouse/context/contributions";
import { SearchParamsProps } from "@/types";
import type { KnownProviderType } from "@/types/providers";
import { isKnownProviderType } from "@/types/providers";
import { getCrossProviderComplianceOverview } from "../_actions/cross-provider";
import { computeProviderBreakdown } from "../_lib/cross-provider-adapter";
import { loadCrossProviderFrameworks } from "../_lib/cross-provider-catalog";
import {
CROSS_PROVIDER_FRAMEWORKS,
type CrossProviderFrameworkEntry,
parseCrossProviderFilters,
} from "../_lib/cross-provider-frameworks";
@@ -51,15 +51,18 @@ const emptySummary = (
requirementsFailed: 0,
requirementsManual: 0,
totalRequirements: 0,
providerBreakdown: entry.compatibleProviders.map((provider) => ({
provider,
pass: 0,
fail: 0,
manual: 0,
total: 0,
score: 0,
unscanned: true,
})),
// Chips need an icon and a label, which only known types have.
providerBreakdown: entry.providerTypes
.filter(isKnownProviderType)
.map((provider) => ({
provider,
pass: 0,
fail: 0,
manual: 0,
total: 0,
score: 0,
unscanned: true,
})),
});
export const CrossProviderOverview = async ({
@@ -69,16 +72,10 @@ export const CrossProviderOverview = async ({
}) => {
const filters = parseCrossProviderFilters(searchParams);
const [responses, providersData, providerGroupsData, watchlist] =
// The roll-ups can only fan out once we know which frameworks exist.
const [catalog, providersData, providerGroupsData, watchlist] =
await Promise.all([
Promise.all(
CROSS_PROVIDER_FRAMEWORKS.map((entry) =>
getCrossProviderComplianceOverview({
complianceId: entry.complianceId,
filters,
}).then((result) => ({ entry, result })),
),
),
loadCrossProviderFrameworks(),
getAllProviders(),
getAllProviderGroups(),
// No provider type narrowing: a universal framework spans many types and
@@ -86,6 +83,21 @@ export const CrossProviderOverview = async ({
loadComplianceWatchlistContext(),
]);
// The empty state would claim there is no data. We just don't know.
if (catalog.unavailable) {
return <CrossProviderErrorAlert />;
}
const frameworks = catalog.frameworks;
const responses = await Promise.all(
frameworks.map((entry) =>
getCrossProviderComplianceOverview({
complianceId: entry.complianceId,
filters,
}).then((result) => ({ entry, result })),
),
);
// Action errors (402 usage limit, 403) gate the whole feature, not one
// framework, so any of them replaces the tab instead of degrading it.
const actionError = responses.find(
@@ -137,17 +149,18 @@ export const CrossProviderOverview = async ({
};
});
const compatibleTypes = Array.from(
new Set<KnownProviderType>(
CROSS_PROVIDER_FRAMEWORKS.flatMap((entry) => entry.compatibleProviders),
),
const coveredTypes = Array.from(
new Set(frameworks.flatMap((entry) => entry.providerTypes)),
).sort();
// Externally registered types have no icon or label; their accounts still
// reach the account select below.
const selectableTypes = coveredTypes.filter(isKnownProviderType);
const providerAccounts: CrossProviderAccountOption[] = (
providersData?.data || []
)
.filter((provider) =>
compatibleTypes.some((type) => type === provider.attributes.provider),
coveredTypes.some((type) => type === provider.attributes.provider),
)
.map((provider) => ({
id: provider.id,
@@ -168,9 +181,8 @@ export const CrossProviderOverview = async ({
watchlist: resolveUniversalWatchlistState({
complianceId: summary.complianceId,
compatibleProviders:
CROSS_PROVIDER_FRAMEWORKS.find(
(entry) => entry.complianceId === summary.complianceId,
)?.compatibleProviders ?? [],
frameworks.find((entry) => entry.complianceId === summary.complianceId)
?.providerTypes ?? [],
eligibleProviderTypes: watchlist.eligibleProviderTypes,
catalogIndex,
}),
@@ -197,7 +209,7 @@ export const CrossProviderOverview = async ({
/>
))}
<CrossProviderFilters
providerTypes={compatibleTypes}
providerTypes={selectableTypes}
providerAccounts={providerAccounts}
providerGroups={providerGroups}
/>
@@ -237,7 +249,6 @@ export const CrossProviderOverview = async ({
<CrossProviderFrameworkGrid
cards={cards}
canManageWatchlist={watchlist.canManage}
watchlistEnabled={watchlist.entries.length > 0}
/>
</SectionContent>
</Section>
@@ -0,0 +1,162 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { getComplianceIcon } from "@/components/icons/compliance/IconCompliance";
import { makeComplianceCatalogEntry } from "@/test-utils/compliance-watchlist";
import type { ComplianceCatalogEntry } from "@/types/compliance-watchlist";
import { loadCrossProviderFrameworks } from "../cross-provider-catalog";
import { loadComplianceWatchlistContext } from "../watchlist-context";
// Reads the session through next-auth, unimportable here; tested separately.
vi.mock("../watchlist-context", () => ({
loadComplianceWatchlistContext: vi.fn(),
}));
const universalEntry = (
complianceId: string,
overrides: Partial<ComplianceCatalogEntry> = {},
) =>
makeComplianceCatalogEntry({
complianceId,
providerType: "*",
framework: complianceId.toUpperCase(),
...overrides,
});
const withCatalog = (entries: ComplianceCatalogEntry[], unavailable = false) =>
vi.mocked(loadComplianceWatchlistContext).mockResolvedValue({
entries,
eligibleProviderTypes: ["aws", "azure", "gcp"],
canManage: true,
unavailable,
});
describe("loadCrossProviderFrameworks", () => {
beforeEach(() => {
vi.mocked(loadComplianceWatchlistContext).mockReset();
});
it("maps the universal catalog entries onto framework cards", async () => {
withCatalog([
universalEntry("dora_2022_2554", {
framework: "DORA",
version: "2022/2554",
description: "Digital Operational Resilience Act.",
}),
]);
const { frameworks, unavailable } = await loadCrossProviderFrameworks();
expect(unavailable).toBe(false);
expect(frameworks).toEqual([
{
complianceId: "dora_2022_2554",
title: "DORA",
version: "2022/2554",
description: "Digital Operational Resilience Act.",
providerTypes: ["aws", "azure", "gcp"],
},
]);
});
it("includes a framework registered outside the SDK", async () => {
withCatalog([
universalEntry("acme_1.0", { framework: "ACME" }),
universalEntry("csa_ccm_4.0", { framework: "CSA-CCM" }),
]);
const { frameworks } = await loadCrossProviderFrameworks();
expect(frameworks.map((entry) => entry.complianceId)).toContain("acme_1.0");
});
it("keeps externally registered provider types", async () => {
// Dropping them here would leave the framework with no way to be pinned.
withCatalog([
universalEntry("acme_1.0", {
framework: "ACME",
providerTypes: ["aws", "totally-external-provider"],
}),
]);
const { frameworks } = await loadCrossProviderFrameworks();
expect(frameworks[0].providerTypes).toEqual([
"aws",
"totally-external-provider",
]);
});
it("drops provider-scoped entries", async () => {
withCatalog([
universalEntry("csa_ccm_4.0", { framework: "CSA-CCM" }),
makeComplianceCatalogEntry({
complianceId: "cis_1.4_aws",
providerType: "aws",
framework: "CIS",
}),
]);
const { frameworks } = await loadCrossProviderFrameworks();
expect(frameworks.map((entry) => entry.complianceId)).toEqual([
"csa_ccm_4.0",
]);
});
it("drops a framework with no title, which has no route to link to", async () => {
withCatalog([
universalEntry("csa_ccm_4.0", { framework: "CSA-CCM" }),
universalEntry("nameless_1.0", { framework: " " }),
]);
const { frameworks } = await loadCrossProviderFrameworks();
expect(frameworks.map((entry) => entry.complianceId)).toEqual([
"csa_ccm_4.0",
]);
});
it("orders the cards by title", async () => {
withCatalog([
universalEntry("dora_2022_2554", { framework: "DORA" }),
universalEntry("cmmc_2.0", { framework: "CMMC" }),
]);
const { frameworks } = await loadCrossProviderFrameworks();
expect(frameworks.map((entry) => entry.title)).toEqual(["CMMC", "DORA"]);
});
it("titles the shipped frameworks so their icon resolves", async () => {
withCatalog([
universalEntry("csa_ccm_4.0", { framework: "CSA-CCM" }),
universalEntry("cis_controls_8.1", { framework: "CIS-Controls" }),
universalEntry("dora_2022_2554", { framework: "DORA" }),
universalEntry("cmmc_2.0", { framework: "CMMC" }),
]);
const { frameworks } = await loadCrossProviderFrameworks();
for (const entry of frameworks) {
expect(getComplianceIcon(entry.title), entry.title).not.toBeNull();
}
});
it("returns nothing when the catalog is empty", async () => {
withCatalog([]);
expect(await loadCrossProviderFrameworks()).toEqual({
frameworks: [],
unavailable: false,
});
});
it("reports a catalog that could not be read", async () => {
withCatalog([], true);
expect(await loadCrossProviderFrameworks()).toEqual({
frameworks: [],
unavailable: true,
});
});
});
@@ -1,60 +1,17 @@
import { describe, expect, it } from "vitest";
import { getComplianceIcon } from "@/components/icons/compliance/IconCompliance";
import { PROVIDER_TYPES } from "@/types/providers";
import type { CrossProviderFrameworkEntry } from "../cross-provider-frameworks";
import { buildCrossProviderDetailHref } from "../cross-provider-frameworks";
import {
buildCrossProviderDetailHref,
CROSS_PROVIDER_FRAMEWORKS,
resolveCrossProviderFramework,
} from "../cross-provider-frameworks";
describe("CROSS_PROVIDER_FRAMEWORKS catalog", () => {
it("uses titles that resolve to a compliance icon", () => {
for (const entry of CROSS_PROVIDER_FRAMEWORKS) {
expect(getComplianceIcon(entry.title), entry.title).not.toBeNull();
}
});
it("only lists providers the UI knows how to render", () => {
for (const entry of CROSS_PROVIDER_FRAMEWORKS) {
for (const provider of entry.compatibleProviders) {
expect(PROVIDER_TYPES).toContain(provider);
}
expect(new Set(entry.compatibleProviders).size).toBe(
entry.compatibleProviders.length,
);
}
});
});
describe("resolveCrossProviderFramework", () => {
it.each([
[undefined, "CSA-CCM"],
["csa_ccm_4.0", "DORA"],
["csa_ccm_4.0", "csa-ccm"],
])("rejects invalid detail links", (complianceId, title) => {
expect(resolveCrossProviderFramework(complianceId, title)).toBeUndefined();
});
it("resolves the catalog entry for a valid detail link", () => {
// Given
const expected = CROSS_PROVIDER_FRAMEWORKS[0];
// When
const framework = resolveCrossProviderFramework(
expected.complianceId,
expected.title,
);
// Then
expect(framework).toEqual(expected);
});
});
const entry: CrossProviderFrameworkEntry = {
complianceId: "csa_ccm_4.0",
title: "CSA-CCM",
version: "4.0",
description: "CSA Cloud Controls Matrix v4.0.",
providerTypes: ["aws", "azure"],
};
describe("buildCrossProviderDetailHref", () => {
const entry = CROSS_PROVIDER_FRAMEWORKS[0];
it("builds the detail path with cross-provider mode and identity params", () => {
const href = buildCrossProviderDetailHref(entry);
@@ -90,17 +90,17 @@ describe("loadComplianceWatchlistContext in Cloud", () => {
});
it("degrades to the empty context when the session lookup rejects", async () => {
// The catalog already swallows its own failures; `auth()` rejecting has to
// cost the page its watchlist affordances rather than its compliance data,
// since every surface awaits this loader during the server render.
// Every surface awaits this loader, so a rejected `auth()` costs the
// watchlist affordances, not the compliance data.
authMock.mockRejectedValue(new Error("session unavailable"));
const consoleError = vi
.spyOn(console, "error")
.mockImplementation(() => {});
expect(await loadComplianceWatchlistContext()).toEqual(
EMPTY_WATCHLIST_CONTEXT,
);
expect(await loadComplianceWatchlistContext()).toEqual({
...EMPTY_WATCHLIST_CONTEXT,
unavailable: true,
});
consoleError.mockRestore();
});
@@ -0,0 +1,35 @@
import { WATCHLIST_SCOPE } from "@/types/compliance-watchlist";
import type { CrossProviderFrameworkEntry } from "./cross-provider-frameworks";
import { loadComplianceWatchlistContext } from "./watchlist-context";
export interface CrossProviderCatalog {
frameworks: CrossProviderFrameworkEntry[];
/** Empty for lack of an answer, not for lack of frameworks. */
unavailable: boolean;
}
/**
* Universal frameworks for the "Across providers" section, read from the API
* catalog (`scope=universal`) so entry-point-registered ones show up too.
* Backed by the same per-render cached request the watchlist context makes.
*/
export const loadCrossProviderFrameworks =
async (): Promise<CrossProviderCatalog> => {
const { entries, unavailable } = await loadComplianceWatchlistContext();
const frameworks = entries
.filter((entry) => entry.scope === WATCHLIST_SCOPE.UNIVERSAL)
.map((entry) => ({
complianceId: entry.complianceId,
// Short name (CSA-CCM, DORA), not `name`: it keys the icon and the route.
title: entry.framework,
version: entry.version,
description: entry.description,
providerTypes: entry.providerTypes,
}))
.filter((entry) => entry.title.trim().length > 0)
.sort((a, b) => a.title.localeCompare(b.title));
return { frameworks, unavailable };
};
@@ -1,88 +1,24 @@
import type { KnownProviderType } from "@/types/providers";
import type { CrossProviderApiFilters } from "../_types";
// Catalog of universal compliance frameworks served by the cross-provider
// endpoint. Hardcoded because the API has no listing endpoint for universal
// framework ids: when a new universal JSON ships in the SDK
// (prowler/compliance/<framework>.json), add an entry here.
// One universal framework card. Built from the API catalog, never hardcoded:
// see `./cross-provider-catalog`.
export interface CrossProviderFrameworkEntry {
/** Universal framework id used as filter[compliance_id]. */
complianceId: string;
/** Card/detail title; also the [compliancetitle] path segment and the
* key getComplianceIcon resolves the framework icon from. */
/** Also the [compliancetitle] segment and the icon lookup key. */
title: string;
version: string;
description: string;
/** Static fallback for the per-provider chips; the API response's
* compatible_providers is authoritative at runtime. */
compatibleProviders: KnownProviderType[];
/** Raw from the catalog — narrow with `isKnownProviderType` only where an
* icon or label is needed. */
providerTypes: string[];
}
export const CROSS_PROVIDER_FRAMEWORKS: CrossProviderFrameworkEntry[] = [
{
complianceId: "csa_ccm_4.0",
title: "CSA-CCM",
version: "4.0",
description:
"CSA Cloud Controls Matrix v4.0 — a cybersecurity control framework with 197 control objectives across 17 domains.",
compatibleProviders: ["aws", "azure", "gcp", "alibabacloud", "oraclecloud"],
},
{
complianceId: "cis_controls_8.1",
title: "CIS-Controls",
version: "8.1",
description:
"CIS Critical Security Controls v8.1 — prioritized safeguards organized into 18 controls to mitigate the most prevalent cyber-attacks.",
compatibleProviders: [
"aws",
"azure",
"gcp",
"m365",
"kubernetes",
"github",
"googleworkspace",
"okta",
"oraclecloud",
"alibabacloud",
"cloudflare",
"mongodbatlas",
"openstack",
"vercel",
],
},
{
complianceId: "dora_2022_2554",
title: "DORA",
version: "2022/2554",
description:
"Digital Operational Resilience Act (EU 2022/2554) — the EU framework for the digital operational resilience of the financial sector.",
compatibleProviders: ["aws", "azure", "gcp", "alibabacloud", "cloudflare"],
},
];
/** Resolves only canonical catalog links. Missing, unknown, or mismatched
* identities must not reach the API as an `undefined` or unrelated filter. */
export const resolveCrossProviderFramework = (
complianceId: string | undefined,
title: string,
): CrossProviderFrameworkEntry | undefined =>
CROSS_PROVIDER_FRAMEWORKS.find(
(entry) => entry.complianceId === complianceId && entry.title === title,
);
/** Cross-provider filter params forwarded from the overview into detail
* links (and consumed back by the detail page). */
const CROSS_PROVIDER_FILTER_PARAMS = [
"filter[provider_type__in]",
"filter[provider_id__in]",
"filter[provider_groups__in]",
] as const;
/** Parses the URL filter params every cross-provider endpoint accepts. Kept
* next to CROSS_PROVIDER_FILTER_PARAMS so the overview and detail islands
* build identical, typed filter objects. */
export const parseCrossProviderFilters = (
searchParams: Record<string, string | string[] | undefined>,
): CrossProviderApiFilters => ({
@@ -9,12 +9,15 @@ export interface ComplianceWatchlistContext {
entries: ComplianceCatalogEntry[];
eligibleProviderTypes: string[];
canManage: boolean;
/** Could not be read — not the same as legitimately empty. */
unavailable: boolean;
}
export const EMPTY_WATCHLIST_CONTEXT: ComplianceWatchlistContext = {
entries: [],
eligibleProviderTypes: [],
canManage: false,
unavailable: false,
};
// One cached catalog/session lookup feeds every compliance surface in a render.
@@ -34,10 +37,11 @@ const loadContextForKey = cache(
entries: catalog.entries,
eligibleProviderTypes: catalog.meta.eligibleProviderTypes,
canManage: Boolean(session?.user?.permissions?.manage_scans),
unavailable: catalog.unavailable,
};
} catch (error) {
console.error("Error loading the compliance watchlist context:", error);
return EMPTY_WATCHLIST_CONTEXT;
return { ...EMPTY_WATCHLIST_CONTEXT, unavailable: true };
}
},
);
@@ -1 +0,0 @@
Attack Paths graph groups resources by class into expandable nodes and marks the query outcome as the terminal node, with the clicked resource highlighted while its findings are expanded (Prowler Cloud only)
@@ -1 +0,0 @@
Azure Management Group onboarding: add every subscription in a tenant at once (Prowler Cloud only)
@@ -0,0 +1 @@
The compliance "Across providers" section reports a failed catalog request instead of rendering the "no data yet" empty state
@@ -0,0 +1 @@
The compliance "Across providers" section builds its framework list from the API catalog instead of a hardcoded set of ids, so a universal framework registered by an installed package renders like a shipped one
@@ -1 +0,0 @@
Manage Lighthouse AI role permission in the role forms and role details, so permission to change the Lighthouse AI configuration can be granted or restricted independently of other permissions (Prowler Cloud only)
@@ -1 +0,0 @@
Manual verification workflow for `MANUAL` findings with evidence, effective `PASS` status, and expiration details
@@ -1 +0,0 @@
Organization discovery describes a too-deep hierarchy in each provider's own vocabulary: AWS organizational units, Azure Management Groups, Google Cloud folders
@@ -1 +0,0 @@
Surface pre-configured credential creation links in the add-provider wizard. Cloudflare exposes the User API Token template and an Account-Owned template pinned to the Cloudflare Account ID entered in the wizard, GitHub exposes the personal-repositories template and an organization-scanning template pinned to the identifier entered in the wizard
@@ -1 +0,0 @@
`View Findings` on the Scans page no longer opens an empty list for users outside the UTC timezone
@@ -0,0 +1 @@
`libcrypto3` and `libssl3` upgraded to 3.5.8-r0 in the UI container image, patching seven high OpenSSL CVEs
@@ -0,0 +1,49 @@
import { Requirement } from "@/types/compliance";
import {
ComplianceBadge,
ComplianceBadgeContainer,
ComplianceDetailContainer,
ComplianceDetailSection,
ComplianceDetailText,
} from "./shared-components";
interface CMMCDetailsProps {
requirement: Requirement;
}
export const CMMCCustomDetails = ({ requirement }: CMMCDetailsProps) => {
return (
<ComplianceDetailContainer>
{requirement.description && (
<ComplianceDetailSection title="Description">
<ComplianceDetailText>{requirement.description}</ComplianceDetailText>
</ComplianceDetailSection>
)}
<ComplianceBadgeContainer>
{requirement.domain && (
<ComplianceBadge
label="Domain"
value={requirement.domain as string}
variant="tag"
/>
)}
{requirement.level && (
<ComplianceBadge
label="Level"
value={requirement.level as string}
variant="tag"
/>
)}
{requirement.source_requirement && (
<ComplianceBadge
label="Source Requirement"
value={requirement.source_requirement as string}
variant="tag"
/>
)}
</ComplianceBadgeContainer>
</ComplianceDetailContainer>
);
};

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