Compare commits

...
17 Commits
Author SHA1 Message Date
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
69 changed files with 2434 additions and 652 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
+17
View File
@@ -4,6 +4,23 @@ 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
+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
View File
@@ -0,0 +1 @@
`sqlparse` upgraded to 0.6.0, patching CVE-2026-54284, CVE-2026-59893, and CVE-2026-71491
+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",
+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.
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" },
]
+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
+16
View File
@@ -4,6 +4,22 @@ 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
@@ -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
@@ -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 @@
`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}"
@@ -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
+13 -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",
@@ -103,10 +107,10 @@ dependencies = [
"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 +140,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 +203,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 +304,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 +347,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 +391,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]
@@ -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
)
+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,103 +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"],
},
{
complianceId: "cmmc_2.0",
title: "CMMC",
version: "2.0",
description:
"Cybersecurity Maturity Model Certification (CMMC) 2.0 (32 CFR Part 170) — the U.S. Department of Defense program verifying that defense contractors protect FCI and CUI across three levels.",
compatibleProviders: [
"aws",
"azure",
"gcp",
"m365",
"alibabacloud",
"oraclecloud",
],
},
];
/** 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 };
}
},
);
@@ -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
@@ -0,0 +1 @@
`libcrypto3` and `libssl3` upgraded to 3.5.8-r0 in the UI container image, patching seven high OpenSSL CVEs
+5
View File
@@ -51,6 +51,11 @@ export interface ComplianceCatalog {
meta: ComplianceCatalogMeta;
}
export interface ComplianceCatalogLoad extends ComplianceCatalog {
/** No answer was obtained — not the same as a legitimately empty catalog. */
unavailable: boolean;
}
export interface FindingComplianceFramework {
id: string;
complianceId: string;
+238
View File
@@ -0,0 +1,238 @@
"""Fail when a pinned or locked package version has been yanked from PyPI.
Exact pins (`==`) still install a yanked release: pip and uv both accept a yanked
version when it is the only candidate an exact specifier allows, printing at most a
warning. That is how zstd 1.5.7.3 (yanked as "buggy - not thread safe") stayed in
uv.lock for months. Yanks happen on PyPI's side after the pin lands, so this check
must run on a schedule, not only on pull requests.
For each project directory given (default: current directory) the script collects:
- exact `==` pins from pyproject.toml: [project] dependencies and optional
dependencies, [dependency-groups], and [tool.uv] constraint-dependencies and
override-dependencies
- every registry-sourced package in uv.lock
and asks the PyPI JSON API whether each (name, version) is yanked or gone.
Usage:
python util/check_yanked_pins.py # checks ./pyproject.toml and ./uv.lock
python util/check_yanked_pins.py . api mcp_server
Exit status is 1 when any pin is yanked or no longer exists on PyPI, 0 otherwise.
Network errors are retried; a persistent error also exits 1, because "unknown"
must not read as "clean".
"""
from __future__ import annotations
import argparse
import json
import re
import sys
import urllib.error
import urllib.request
from concurrent.futures import ThreadPoolExecutor
from dataclasses import dataclass
from pathlib import Path
from time import sleep
from typing import Callable, Iterable
try:
import tomllib
except ModuleNotFoundError: # Python 3.10: tomllib arrived in 3.11
import tomli as tomllib
PYPI_JSON = "https://pypi.org/pypi/{name}/{version}/json"
USER_AGENT = "prowler-check-yanked-pins (+https://github.com/prowler-cloud/prowler)"
# PEP 508 requirement with an exact pin: "name[extras]==version ; markers"
_EXACT_PIN = re.compile(
r"^\s*(?P<name>[A-Za-z0-9][A-Za-z0-9._-]*)\s*(\[[^\]]*\])?\s*==\s*(?P<version>[^\s;,]+)"
)
def normalize(name: str) -> str:
"""PEP 503 name normalization: alibabacloud_tea_openapi == alibabacloud-tea-openapi."""
return re.sub(r"[-_.]+", "-", name).lower()
@dataclass(frozen=True, order=True)
class Pin:
"""One exact version requirement and the file/table it was read from."""
name: str
version: str
source: str
@dataclass(frozen=True)
class Verdict:
"""PyPI's answer for one pin: ok, yanked, missing (404) or error (unreachable)."""
pin: Pin
status: str # "ok" | "yanked" | "missing" | "error"
detail: str = ""
def pins_from_pyproject(text: str, source_prefix: str) -> set[Pin]:
"""Collect exact == pins from every dependency-bearing table in a pyproject.toml."""
data = tomllib.loads(text)
tables: list[tuple[str, Iterable[str]]] = []
project = data.get("project", {})
tables.append(("project.dependencies", project.get("dependencies", [])))
for extra, reqs in project.get("optional-dependencies", {}).items():
tables.append((f"project.optional-dependencies.{extra}", reqs))
for group, reqs in data.get("dependency-groups", {}).items():
# dependency-groups entries may be tables ({include-group = ...}); keep strings only
tables.append(
(f"dependency-groups.{group}", [r for r in reqs if isinstance(r, str)])
)
uv = data.get("tool", {}).get("uv", {})
tables.append(
("tool.uv.constraint-dependencies", uv.get("constraint-dependencies", []))
)
tables.append(
("tool.uv.override-dependencies", uv.get("override-dependencies", []))
)
pins: set[Pin] = set()
for table, requirements in tables:
for requirement in requirements:
match = _EXACT_PIN.match(requirement)
if match:
pins.add(
Pin(
normalize(match.group("name")),
match.group("version"),
f"{source_prefix}pyproject.toml [{table}]",
)
)
return pins
def pins_from_uv_lock(text: str, source_prefix: str) -> set[Pin]:
"""Collect every registry-sourced (name, version) from a uv.lock."""
data = tomllib.loads(text)
pins: set[Pin] = set()
for package in data.get("package", []):
source = package.get("source", {})
# git, path, editable and virtual sources are not on PyPI; skip them
if "registry" not in source:
continue
pins.add(
Pin(
normalize(package["name"]),
package["version"],
f"{source_prefix}uv.lock",
)
)
return pins
def collect_pins(project_dir: Path) -> set[Pin]:
"""Gather pins from a project's pyproject.toml and uv.lock, whichever exist."""
prefix = "" if project_dir == Path(".") else f"{project_dir.as_posix()}/"
pins: set[Pin] = set()
pyproject = project_dir / "pyproject.toml"
lock = project_dir / "uv.lock"
if not pyproject.is_file() and not lock.is_file():
raise FileNotFoundError(
f"{project_dir}: neither pyproject.toml nor uv.lock found"
)
if pyproject.is_file():
pins |= pins_from_pyproject(pyproject.read_text(encoding="utf-8"), prefix)
if lock.is_file():
pins |= pins_from_uv_lock(lock.read_text(encoding="utf-8"), prefix)
return pins
def fetch_release(name: str, version: str, retries: int = 3) -> tuple[str, str]:
"""Return (status, detail) for one release, where status is ok|yanked|missing|error."""
request = urllib.request.Request(
PYPI_JSON.format(name=name, version=version), headers={"User-Agent": USER_AGENT}
)
last_error = ""
for attempt in range(retries):
try:
with urllib.request.urlopen(request, timeout=20) as response:
info = json.load(response)["info"]
except urllib.error.HTTPError as exc:
if exc.code == 404:
return "missing", "not found on PyPI"
last_error = f"HTTP {exc.code}"
except (
urllib.error.URLError,
TimeoutError,
OSError,
ValueError,
KeyError,
) as exc:
last_error = repr(exc)
else:
if info.get("yanked"):
return "yanked", info.get("yanked_reason") or "no reason given"
return "ok", ""
sleep(2**attempt)
return "error", last_error
def evaluate(
pins: Iterable[Pin],
fetch: Callable[[str, str], tuple[str, str]] | None = None,
workers: int = 16,
) -> list[Verdict]:
"""Query each distinct (name, version) once and fan the answer out to every source."""
if fetch is None:
fetch = fetch_release
pins = sorted(set(pins))
releases = sorted({(pin.name, pin.version) for pin in pins})
with ThreadPoolExecutor(max_workers=workers) as pool:
results = dict(
zip(
releases,
pool.map(lambda release: fetch(*release), releases),
strict=True,
)
)
return [Verdict(pin, *results[(pin.name, pin.version)]) for pin in pins]
def main(argv: list[str] | None = None) -> int:
"""Check every project on the command line; return 1 if any pin is not ok."""
parser = argparse.ArgumentParser(description=__doc__.split("\n\n")[0])
parser.add_argument(
"projects",
nargs="*",
default=["."],
help="project directories containing pyproject.toml and/or uv.lock (default: .)",
)
parser.add_argument(
"--workers", type=int, default=16, help="concurrent PyPI requests"
)
args = parser.parse_args(argv)
pins: set[Pin] = set()
for project in args.projects:
pins |= collect_pins(Path(project))
print(
f"Checking {len({(p.name, p.version) for p in pins})} pinned releases from {len(pins)} pins"
)
verdicts = evaluate(pins, workers=args.workers)
problems = [v for v in verdicts if v.status != "ok"]
for verdict in problems:
pin = verdict.pin
print(
f"::error::{pin.name}=={pin.version} is {verdict.status} ({verdict.detail}) in {pin.source}"
)
if problems:
print(f"{len(problems)} problem(s) found")
return 1
print("No yanked or missing releases")
return 0
if __name__ == "__main__":
sys.exit(main())
Generated
+63 -88
View File
@@ -39,7 +39,7 @@ constraints = [
{ name = "alibabacloud-sas20181203", specifier = "==6.1.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" },
@@ -140,7 +140,7 @@ constraints = [
{ name = "mock", specifier = "==5.2.0" },
{ name = "moto", specifier = "==5.1.11" },
{ name = "mpmath", specifier = "==1.3.0" },
{ name = "msal", specifier = "==1.36.0" },
{ name = "msal", specifier = "==1.37.0" },
{ name = "msal-extensions", specifier = "==1.3.1" },
{ name = "msgraph-core", specifier = "==1.3.8" },
{ name = "msrest", specifier = "==0.7.1" },
@@ -183,7 +183,7 @@ constraints = [
{ name = "pyjwt", specifier = "==2.13.0" },
{ name = "pylint", specifier = "==3.3.4" },
{ 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 = "pytest", specifier = "==9.0.3" },
{ name = "pytest-cov", specifier = "==6.0.0" },
@@ -227,12 +227,9 @@ constraints = [
{ name = "xmltodict", specifier = "==1.0.4" },
{ name = "yarl", specifier = "==1.23.0" },
{ name = "zipp", specifier = "==3.23.1" },
{ name = "zstd", specifier = "==1.5.7.3" },
]
overrides = [
{ name = "cryptography", specifier = "==50.0.0" },
{ name = "okta", specifier = "==3.4.2" },
{ name = "zstd", specifier = "==1.5.7.2" },
]
overrides = [{ name = "okta", specifier = "==3.4.2" }]
[[package]]
name = "about-time"
@@ -686,7 +683,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" },
@@ -695,9 +692,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]]
@@ -3045,16 +3042,16 @@ wheels = [
[[package]]
name = "msal"
version = "1.36.0"
version = "1.37.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "cryptography" },
{ name = "pyjwt", extra = ["crypto"] },
{ name = "requests" },
]
sdist = { url = "https://files.pythonhosted.org/packages/de/cb/b02b0f748ac668922364ccb3c3bff5b71628a05f5adfec2ba2a5c3031483/msal-1.36.0.tar.gz", hash = "sha256:3f6a4af2b036b476a4215111c4297b4e6e236ed186cd804faefba23e4990978b", size = 174217, upload-time = "2026-04-09T10:20:33.525Z" }
sdist = { url = "https://files.pythonhosted.org/packages/9a/99/d840198ecf6e8057bbc937f129ae940404485d736cda73253bbff9537f01/msal-1.37.0.tar.gz", hash = "sha256:1b1672a33ee467c1d70b341bb16cafd51bb3c817147a95b93263794b03971bec", size = 182444, upload-time = "2026-05-29T19:49:05.561Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/2a/d3/414d1f0a5f6f4fe5313c2b002c54e78a3332970feb3f5fed14237aa17064/msal-1.36.0-py3-none-any.whl", hash = "sha256:36ecac30e2ff4322d956029aabce3c82301c29f0acb1ad89b94edcabb0e58ec4", size = 121547, upload-time = "2026-04-09T10:20:32.336Z" },
{ url = "https://files.pythonhosted.org/packages/94/b0/d807279f4b55d16d1f120d5ac4344c6e39b56732e2a224d40bded7fd67ad/msal-1.37.0-py3-none-any.whl", hash = "sha256:dd17e95a7c71bce75e8108113438ba7c4a086b3bcad4f57a8c09b7af3d753c2d", size = 123725, upload-time = "2026-05-29T19:49:04.335Z" },
]
[[package]]
@@ -3337,7 +3334,7 @@ wheels = [
[[package]]
name = "oci"
version = "2.183.0"
version = "2.184.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "certifi" },
@@ -3350,9 +3347,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]]
@@ -3755,7 +3752,7 @@ wheels = [
[[package]]
name = "prowler"
version = "5.39.0"
version = "5.39.2"
source = { editable = "." }
dependencies = [
{ name = "alibabacloud-actiontrail20200706" },
@@ -3889,7 +3886,7 @@ requires-dist = [
{ name = "alibabacloud-sas20181203", specifier = "==6.1.0" },
{ name = "alibabacloud-sls20201230", specifier = "==5.9.0" },
{ name = "alibabacloud-sts20150401", specifier = "==1.1.6" },
{ name = "alibabacloud-tea-openapi", specifier = "==0.4.5" },
{ name = "alibabacloud-tea-openapi", specifier = "==0.4.6" },
{ name = "alibabacloud-vpc20160428", specifier = "==6.13.0" },
{ name = "alive-progress", specifier = "==3.3.0" },
{ name = "azure-identity", specifier = "==1.21.0" },
@@ -3950,7 +3947,7 @@ requires-dist = [
{ name = "microsoft-kiota-abstractions", specifier = "==1.9.10" },
{ name = "msgraph-sdk", specifier = "==1.55.0" },
{ name = "numpy", specifier = "==2.2.6" },
{ name = "oci", specifier = "==2.183.0" },
{ name = "oci", specifier = "==2.184.1" },
{ name = "okta", specifier = "==3.4.2" },
{ name = "openstacksdk", specifier = "==4.2.0" },
{ name = "pandas", specifier = "==2.2.3" },
@@ -4399,15 +4396,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", marker = "python_full_version < '3.13'" },
]
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]]
@@ -5520,71 +5517,49 @@ 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/8b/54/95fe3f714a4a0c2befc1f5734deb2706c635481feff4e5497ace0f307fef/zstd-1.5.7.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:76f3535616887a1a38e8c6d0de693a23c5bb1f190651eb20d96bfc8e4ab706a0", size = 267642, upload-time = "2026-01-08T16:46:57.829Z" },
{ url = "https://files.pythonhosted.org/packages/0f/af/e88d733bf7dac8bcb0f90e90f9ea2163909e873e37aaf90617e7e5ed34d8/zstd-1.5.7.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:67507937e8e4c2a8dfed8e7fa77f4043ec9e6e831a5faebf0f99138b1a25ccbd", size = 230964, upload-time = "2026-01-08T16:46:59.277Z" },
{ url = "https://files.pythonhosted.org/packages/ac/7a/9e8b541b5799bb699e70d6f0c4fa5a0607c9229634209e9662f4a6a8a6ce/zstd-1.5.7.3-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_24_aarch64.whl", hash = "sha256:bd0a2309c524608ce7b940abcc9f8eb5447c6ea2c834a630e0081211ab9d40ec", size = 1540287, upload-time = "2026-01-08T17:39:27.789Z" },
{ url = "https://files.pythonhosted.org/packages/4b/1e/d0fe5f8e860c39f50831889c499fbf91a5bfdb8adcd148d35f1f7a3e7ea7/zstd-1.5.7.3-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:2b497306580d544406b5414c8485c4037a9283ad2ca6ae4ccdf3732c9563141d", size = 1619041, upload-time = "2026-01-08T17:39:23.305Z" },
{ url = "https://files.pythonhosted.org/packages/10/bd/b9b381edad8cfdca944cd15025932c6b0edacc5164ed67b603b4d8a38f84/zstd-1.5.7.3-cp310-cp310-manylinux_2_4_i686.whl", hash = "sha256:e9939a98ea946d1f9e8f9fecc940ae939b8e9e5ef9d71b104f7843567d764f30", size = 300166, upload-time = "2026-01-10T11:12:23.088Z" },
{ url = "https://files.pythonhosted.org/packages/a7/a8/9b6f65a3bd7fb54148bceadf9a5a9a869b64454c9e15b6f1362160594785/zstd-1.5.7.3-cp310-cp310-manylinux_2_4_x86_64.whl", hash = "sha256:d32c0fe8f6b805b7cbeaade462b094a843e84d893d8c6f66ab705e8777cc1850", size = 304165, upload-time = "2026-01-10T11:22:59.825Z" },
{ url = "https://files.pythonhosted.org/packages/f9/22/2fb52f1d288bb5e8176108a4bdbc25484fc05cc902cdf5c99cf604aba979/zstd-1.5.7.3-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_24_i686.whl", hash = "sha256:8aa33b1ef24602b2ef1e8aa67ea3c8f821854a4dbf70c3c8c46b96b54b6ceb5d", size = 1525903, upload-time = "2026-01-08T17:39:26.4Z" },
{ url = "https://files.pythonhosted.org/packages/b4/be/26451e696c2cc5f604eb872408a4e0ddc64478e45928897c755b2ab0330c/zstd-1.5.7.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:1bd69fa9c4c97fd04206c919dedbf9f75f544ebb77880db51a13c1e3802cd655", size = 2095723, upload-time = "2026-01-08T17:39:30.473Z" },
{ url = "https://files.pythonhosted.org/packages/01/1a/43ee13d01e367eb5bb2dead554e2fb3931e4f2d4a45a7642601e44b138b1/zstd-1.5.7.3-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:aee96742a64ede2e35dc0316ef0cd1e50089e889ce77e82ca8edf40174a1439c", size = 2132397, upload-time = "2026-01-08T17:39:24.739Z" },
{ url = "https://files.pythonhosted.org/packages/00/d7/5497d54dadb172ee148820aff1551cb189344522c207bc83f073f8a85a59/zstd-1.5.7.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:5ac207573d2815a51f4f4fd4e255408396491729a01f690b9f5fb672d39e5610", size = 2124660, upload-time = "2026-01-08T17:39:29.146Z" },
{ url = "https://files.pythonhosted.org/packages/98/89/665c5fac2da24c129ef403f65c2d8e9b08142b97b02ab7daa3a44cddeca7/zstd-1.5.7.3-cp310-cp310-win32.whl", hash = "sha256:04e62e4f9eba79699d072d3c96731ed4aff99f1d334eb967489b091186a6078f", size = 150362, upload-time = "2026-01-08T17:09:29.33Z" },
{ url = "https://files.pythonhosted.org/packages/53/2e/cae4878efd693ddfb712577eee4ac37dd4c0fe757054c4ee3479530b416f/zstd-1.5.7.3-cp310-cp310-win_amd64.whl", hash = "sha256:0794b23b9950af240888087d2bd5943aa4be67273ba32cdafabdc5704778b90e", size = 167580, upload-time = "2026-01-08T17:09:30.639Z" },
{ url = "https://files.pythonhosted.org/packages/93/f9/9908234f86aafb48edd5fe630b41488c3acd8a4edbfbc921a7a6db1ab8b4/zstd-1.5.7.3-cp310-cp310-win_arm64.whl", hash = "sha256:7827fd4901f3e71a7a755d26719549658f08e04fdf0870a952ed08e71b484435", size = 157239, upload-time = "2026-01-08T16:43:01.449Z" },
{ 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/21/7c/f2fe6e09b9d064873ebd384f2692b9fcad3d8e9412298dfb09a935aec77d/zstd-1.5.7.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:b2455e56f1d265dacbd450510b8c2f632a5d8d92c23282e7723fb04af37001a2", size = 268133, upload-time = "2026-01-08T17:31:54.616Z" },
{ url = "https://files.pythonhosted.org/packages/14/8d/0a1e49844ed82c7ab0f66dce5e0dd822742fc7e9d04f147032db33740840/zstd-1.5.7.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:3486dc4f1b4e52bb059f8eec1f31daa3e540062c0f522f221782cf132a8bc9a8", size = 231005, upload-time = "2026-01-08T17:31:55.885Z" },
{ url = "https://files.pythonhosted.org/packages/3b/24/0ab682096da2411f83236a10c89423f26859a65431660e460e2d637b5628/zstd-1.5.7.3-cp313-cp313-manylinux_2_14_i686.whl", hash = "sha256:1cb47bf10ffcb6a782edacfe758da2c94879f7e89c6628feb3f1254daf8cc596", size = 300230, upload-time = "2026-01-08T16:30:52.654Z" },
{ url = "https://files.pythonhosted.org/packages/36/2e/3ff0d28ea8d6b9bd931af7477fad082b99633cb7901cdd657dcb7ecfee11/zstd-1.5.7.3-cp313-cp313-manylinux_2_14_x86_64.whl", hash = "sha256:07b1378d1230ddeea8773f99d7518a3060e6468c76edd502057cb795fe278d7e", size = 297097, upload-time = "2026-01-08T16:49:41.211Z" },
{ url = "https://files.pythonhosted.org/packages/3e/ef/25c15570fb6b06a4a03bd054afa2d084df687ac10e336b703139acc77182/zstd-1.5.7.3-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1ee34317f013e3405108f5baea53502159809cfc4510598d614257525500c70d", size = 2162274, upload-time = "2026-01-08T17:35:24.464Z" },
{ url = "https://files.pythonhosted.org/packages/f1/24/fd16ba9e9be877a2194f05462ae77dcb62c8f90c4ecc186d9ee71e9bdc9f/zstd-1.5.7.3-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c19127ca2c79855376a34a2d7a6969408094b25c1f44485b0373eba4be851b98", size = 2201877, upload-time = "2026-01-08T17:35:28.596Z" },
{ url = "https://files.pythonhosted.org/packages/85/ef/73c37a81ac36429bb1bbb69c8ac43f3a154cfab739dce6424d28f95301c3/zstd-1.5.7.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:2e79cae70dd08cb247391312463085c624c0302e8c860d13f87f4c76502d8202", size = 2096535, upload-time = "2026-01-08T17:35:30.001Z" },
{ url = "https://files.pythonhosted.org/packages/d3/1f/859a8049634e444feb6855347d5e558c1280d87b0bc6385f13cd3d95dbe6/zstd-1.5.7.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:0e83e91e5daf89037c737f5529da0f80da80a78a6ad0b1d70a09860eb267dea4", size = 2125473, upload-time = "2026-01-08T17:35:26.596Z" },
{ url = "https://files.pythonhosted.org/packages/b1/5c/e7b8aa8eea46f032891ecef187f1d469e20f9ddf0d10607e9523b3d306a1/zstd-1.5.7.3-cp313-cp313-win32.whl", hash = "sha256:2283f3bb910c028e1b9fe76b834016012ab021025a0ea197e27a1333f85e3031", size = 150370, upload-time = "2026-01-08T17:18:12.899Z" },
{ url = "https://files.pythonhosted.org/packages/50/2b/ba558ff87ba7f6c29e3a9b1c3b3e95338146aee7250b10807229d412a9c4/zstd-1.5.7.3-cp313-cp313-win_amd64.whl", hash = "sha256:3ad5fe4c36bab5dfa5a4b8d050bd07c50c1e69f94d381bc65337ab14cd69e5b1", size = 167602, upload-time = "2026-01-08T17:18:13.858Z" },
{ url = "https://files.pythonhosted.org/packages/f7/7d/4a5c9813fafad2949d42deee3857d7ecc8caf369bbf82a88b60519200083/zstd-1.5.7.3-cp313-cp313-win_arm64.whl", hash = "sha256:7e878172b0eb69ac2edc6576eb862e00747c7c25e638fb354630a1ea7cfddf49", size = 157239, upload-time = "2026-01-08T16:42:28.885Z" },
{ url = "https://files.pythonhosted.org/packages/0c/67/5fcec6bbf8aab4aeb26e3cbe8cc9fa2f323dd143d066e313b134b8c28dc8/zstd-1.5.7.3-cp313-cp313t-manylinux_2_14_x86_64.whl", hash = "sha256:7e0a7e94d5b63b4cacf2396079ca9584d11f49f87cb4e5aa21f126a8f6b83446", size = 297302, upload-time = "2026-01-08T16:36:49.027Z" },
{ url = "https://files.pythonhosted.org/packages/64/ad/f6588943c9fde34a28f1b0448a8ac824b2ebe341f7af56ff035d0489338d/zstd-1.5.7.3-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:2b9ec4d5ba8c170d3fdf21ae5da3c15eaea2beef9c419a5f3274a6f9e03c412a", size = 260091, upload-time = "2026-01-08T17:14:18.143Z" },
{ url = "https://files.pythonhosted.org/packages/7a/6a/6d2d3b9b7bad0124c684b7b77621ee6bdc3fc220a580f002014cf0a8f558/zstd-1.5.7.3-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:a7ab69fc4d90eeb64b98a567751f8e48373f4bcf301597fca344b8e8342e1d5e", size = 221149, upload-time = "2026-01-08T17:14:17.168Z" },
{ url = "https://files.pythonhosted.org/packages/90/8d/ad4d3c24293c70d8ae9c80e06b2da2922048933f9a00f35d18df5166346a/zstd-1.5.7.3-pp310-pypy310_pp73-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:da70f0918bf739bc75d7770410c9b94ea0dcb6f02d7ef70598b464bd5fcb193a", size = 326792, upload-time = "2026-01-08T17:12:35.38Z" },
{ url = "https://files.pythonhosted.org/packages/ed/37/98c3dd075935b5a7a1806837db343c1a37e6726c53db93c27aa4d7e5e86c/zstd-1.5.7.3-pp310-pypy310_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3dd5c069d0409284f1963b0b6b119f21b1da9e22a503e88933eb0696249d87d3", size = 322283, upload-time = "2026-01-08T17:12:32.042Z" },
{ url = "https://files.pythonhosted.org/packages/97/96/20fd30bd330529b4ad8420f4ba9030b80b971499be75d37c39306cdeb038/zstd-1.5.7.3-pp310-pypy310_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:46ca4a075f36f118e2ce07ba07d9ece7aeda193cea6f50b82aaee635df7b5fc2", size = 311551, upload-time = "2026-01-08T17:12:33.683Z" },
{ url = "https://files.pythonhosted.org/packages/51/b5/001cc10b6a221e4e5fb4ec19ff49d6dcc028f97a5ce53a8ef5cee67ac409/zstd-1.5.7.3-pp310-pypy310_pp73-manylinux_2_14_x86_64.whl", hash = "sha256:4a521cb7615fc61bfe9514bea182e224894b5987fc7843b6d6da20a61206ef24", size = 317071, upload-time = "2026-01-08T16:30:35.427Z" },
{ url = "https://files.pythonhosted.org/packages/41/81/75bdf0e4515c74094adff7e5119f4d50bc9af20359b78c04f8f6cac3f59c/zstd-1.5.7.3-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:71ea22c953a164f34eb4b8c2c3b97eaa22da6a75296ea80b3ba4473187f15046", size = 167655, upload-time = "2026-01-08T16:55:06.572Z" },
{ 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/18/76/825a002361bcfb4444d8ff0bd5c75d60e449158c5a9cd3b884971b3ecd1e/zstd-1.5.7.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:d3f14c5c405ea353b68fe105236780494eb67c756ecd346fd295498f5eab6d24", size = 269695, upload-time = "2025-06-23T12:54:29.916Z" },
{ url = "https://files.pythonhosted.org/packages/b9/0a/a8c936edc431217186085276a37eba8e52c9bd4cd3025b38403baa2466a4/zstd-1.5.7.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:07d2061df22a3efc06453089e6e8b96e58f5bb7a0c4074dcfd0b0ce243ddde72", size = 228243, upload-time = "2025-06-23T12:54:30.942Z" },
{ url = "https://files.pythonhosted.org/packages/ae/24/e81d1561ab3e32be2370de82e13d3c50b68a9fed6977b4d7d596d3ddd1b9/zstd-1.5.7.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_24_aarch64.whl", hash = "sha256:27e55aa2043ba7d8a08aba0978c652d4d5857338a8188aa84522569f3586c7bb", size = 1536535, upload-time = "2025-06-23T13:53:22.123Z" },
{ url = "https://files.pythonhosted.org/packages/8b/9d/60d956dc3f457620997906bc4c220fad12b2ad1a3a5e2224d3b5dbf0a28e/zstd-1.5.7.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:8e97933addfd71ea9608306f18dc18e7d2a5e64212ba2bb9a4ccb6d714f9f280", size = 1616160, upload-time = "2025-06-23T13:53:16.221Z" },
{ url = "https://files.pythonhosted.org/packages/71/6a/49fc94a39f44994c5db20259d44a849e558af5232072580a7614cdb2058d/zstd-1.5.7.2-cp310-cp310-manylinux_2_4_i686.whl", hash = "sha256:27e2ed58b64001c9ef0a8e028625477f1a6ed4ca949412ff6548544945cc59c2", size = 322186, upload-time = "2025-06-23T12:41:36.574Z" },
{ url = "https://files.pythonhosted.org/packages/17/20/e1e06a7f39c7eb27a1fe1c0281970c840fcde539a2f8ad99bb3155dbf3ad/zstd-1.5.7.2-cp310-cp310-manylinux_2_4_x86_64.whl", hash = "sha256:92f072819fc0c7e8445f51a232c9ad76642027c069d2f36470cdb5e663839cdb", size = 302736, upload-time = "2025-06-23T13:05:04.168Z" },
{ url = "https://files.pythonhosted.org/packages/a6/c0/86bb2d8e556062edf663f8d08c315418fefb80cae7c786cf39957e10455f/zstd-1.5.7.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_24_i686.whl", hash = "sha256:2a653cdd2c52d60c28e519d44bde8d759f2c1837f0ff8e8e1b0045ca62fcf70e", size = 1522689, upload-time = "2025-06-23T13:53:17.761Z" },
{ url = "https://files.pythonhosted.org/packages/80/24/60a125d82d64b4d2a823f490904d8b5861117771237e34bb02e2cc311572/zstd-1.5.7.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:047803d87d910f4905f48d99aeff1e0539ec2e4f4bf17d077701b5d0b2392a95", size = 2098532, upload-time = "2025-06-23T13:53:11.938Z" },
{ url = "https://files.pythonhosted.org/packages/09/eb/3274ea05a788bbdb90e3de90bfa27dc9113ee0114011d28bfad6d9fd34d7/zstd-1.5.7.2-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:0d8c1dc947e5ccea3bd81043080213685faf1d43886c27c51851fabf325f05c0", size = 2112079, upload-time = "2025-06-23T13:53:19.833Z" },
{ url = "https://files.pythonhosted.org/packages/93/c6/fa6898d55f8313e9649e2853ea3fede8b7301a5a1c40d8aa920252c31a52/zstd-1.5.7.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:8291d393321fac30604c6bbf40067103fee315aa476647a5eaecf877ee53496f", size = 2109450, upload-time = "2025-06-23T13:53:13.806Z" },
{ url = "https://files.pythonhosted.org/packages/ad/56/4180cd24fdc468f4f0beae3d4f5e8690a16995a561b1926dfdde223ecc3d/zstd-1.5.7.2-cp310-cp310-win32.whl", hash = "sha256:6922ceac5f2d60bb57a7875168c8aa442477b83e8951f2206cf1e9be788b0a6e", size = 149448, upload-time = "2025-06-23T13:09:43.678Z" },
{ url = "https://files.pythonhosted.org/packages/d5/55/3b315dc894b9726c16e5d58f48a618e6e2670e93c0eacc03fd30330444ee/zstd-1.5.7.2-cp310-cp310-win_amd64.whl", hash = "sha256:346d1e4774d89a77d67fc70d53964bfca57c0abecfd885a4e00f87fd7c71e074", size = 166591, upload-time = "2025-06-23T13:09:44.85Z" },
{ 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/a1/63/0d392a8ec2231dee9fc2290faea7a6642584686720d6b77899ad8b12e35a/zstd-1.5.7.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:6e684e27064b6550aa2e7dc85d171ea1b62cb5930a2c99b3df9b30bf620b5c06", size = 269438, upload-time = "2025-06-23T12:57:52.507Z" },
{ url = "https://files.pythonhosted.org/packages/be/1f/85aae095f92811bed3d2944bbed971fe07ec1dd2d82c9eb1395d69d2123c/zstd-1.5.7.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:fd6262788a98807d6b2befd065d127db177c1cd76bb8e536e0dded419eb7c7fb", size = 228179, upload-time = "2025-06-23T12:57:51.031Z" },
{ url = "https://files.pythonhosted.org/packages/31/4e/547949993ea347ac44f5908262ebe6e85edfa7b11a5df136319789be731d/zstd-1.5.7.2-cp313-cp313-manylinux_2_14_x86_64.whl", hash = "sha256:53948be45f286a1b25c07a6aa2aca5c902208eb3df9fe36cf891efa0394c8b71", size = 302763, upload-time = "2025-06-23T12:51:51.615Z" },
{ url = "https://files.pythonhosted.org/packages/25/ca/4a6882846e3049be249031f825251a9229ecad471e18e7fd27974540549c/zstd-1.5.7.2-cp313-cp313-win32.whl", hash = "sha256:edf816c218e5978033b7bb47dcb453dfb71038cb8a9bf4877f3f823e74d58174", size = 149452, upload-time = "2025-06-23T12:57:32.116Z" },
{ url = "https://files.pythonhosted.org/packages/e7/aa/89339605864c9803e4738f176932a6c9f1ad99d03c03ef2cb0634ddca680/zstd-1.5.7.2-cp313-cp313-win_amd64.whl", hash = "sha256:eea9bddf06f3f5e1e450fd647665c86df048a45e8b956d53522387c1dff41b7a", size = 166625, upload-time = "2025-06-23T12:57:33.334Z" },
{ url = "https://files.pythonhosted.org/packages/07/e9/501291a2f9b300b2c73dcc6d086df778e895e71573df9575def54d9dbab2/zstd-1.5.7.2-cp313-cp313t-manylinux_2_14_x86_64.whl", hash = "sha256:1d71f9f92b3abe18b06b5f0aefa5b9c42112beef3bff27e36028d147cb4426a6", size = 302906, upload-time = "2025-06-23T13:21:13.331Z" },
{ url = "https://files.pythonhosted.org/packages/56/b9/179ad7330e6ea33ce655b671ee6f961fbbf4714996aa7c5180ef08d1616a/zstd-1.5.7.2-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:426e5c6b7b3e2401b734bfd08050b071e17c15df5e3b31e63651d1fd9ba4c751", size = 262933, upload-time = "2025-06-23T13:03:44.34Z" },
{ url = "https://files.pythonhosted.org/packages/56/d9/f9b73abd3ccce44468ccbdc1ad48b8adb6eaffeacc556472a6e42331b2c3/zstd-1.5.7.2-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:53375b23f2f39359ade944169bbd88f8895eed91290ee608ccbc28810ac360ba", size = 218516, upload-time = "2025-06-23T13:19:05.55Z" },
{ url = "https://files.pythonhosted.org/packages/ca/3b/f6f6c4d009b5945bbe043e576a61a8adc71eba5e9adc7b1872c080508b26/zstd-1.5.7.2-pp310-pypy310_pp73-manylinux_2_14_x86_64.whl", hash = "sha256:1b301b2f9dbb0e848093127fb10cbe6334a697dc3aea6740f0bb726450ee9a34", size = 315543, upload-time = "2025-06-23T13:20:47.275Z" },
{ url = "https://files.pythonhosted.org/packages/7d/39/7edf76a442621d76dc18ee82dcce82f8a0df2fbc7b962ade42a833e30a32/zstd-1.5.7.2-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:5414c9ae27069ab3ec8420fe8d005cb1b227806cbc874a7b4c73a96b4697a633", size = 166648, upload-time = "2025-06-23T13:11:55.47Z" },
{ 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" },
]