mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-08-19 17:40:25 +00:00
Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0b9791ffdc | ||
|
|
f6defefb58 | ||
|
|
f3224d0988 | ||
|
|
450e6ba553 | ||
|
|
2cd93fe119 | ||
|
|
f807b22ea6 | ||
|
|
b6e9967da6 | ||
|
|
16e62f7514 | ||
|
|
13ce9436b3 | ||
|
|
d3ced63397 |
@@ -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.73.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.73.0'
|
||||
version: 'v0.74.0'
|
||||
# Not trivyignores: that input drops the .yaml extension Trivy parses by.
|
||||
env:
|
||||
TRIVY_IGNOREFILE: '.trivyignore.yaml'
|
||||
|
||||
@@ -199,7 +199,7 @@ You MUST structure your response using this EXACT format. Do NOT include anythin
|
||||
|
||||
### For Check Logic Bug
|
||||
|
||||
```
|
||||
```markdown
|
||||
### AI Assessment [Experimental]: Check Logic Bug
|
||||
|
||||
**Component**: {component from issue template}
|
||||
@@ -297,7 +297,7 @@ Write tests FIRST (TDD). The skills contain all testing conventions and patterns
|
||||
|
||||
### For Bug (non-check)
|
||||
|
||||
```
|
||||
```markdown
|
||||
### AI Assessment [Experimental]: Bug
|
||||
|
||||
**Component**: {CLI/SDK | API | UI | Dashboard | MCP Server | Other}
|
||||
@@ -378,7 +378,7 @@ Write tests FIRST (TDD). The skills contain all testing conventions and patterns
|
||||
|
||||
### For Already Fixed
|
||||
|
||||
```
|
||||
```markdown
|
||||
### AI Assessment [Experimental]: Already Fixed
|
||||
|
||||
**Component**: {component}
|
||||
@@ -401,7 +401,7 @@ Upgrade to the latest version. Close the issue as resolved.
|
||||
|
||||
### For Feature Request
|
||||
|
||||
```
|
||||
```markdown
|
||||
### AI Assessment [Experimental]: Feature Request
|
||||
|
||||
**Component**: {component}
|
||||
@@ -419,7 +419,7 @@ Upgrade to the latest version. Close the issue as resolved.
|
||||
|
||||
### For Not a Bug
|
||||
|
||||
```
|
||||
```markdown
|
||||
### AI Assessment [Experimental]: Not a Bug
|
||||
|
||||
**Component**: {component}
|
||||
@@ -440,7 +440,7 @@ Upgrade to the latest version. Close the issue as resolved.
|
||||
|
||||
### For Needs More Information
|
||||
|
||||
```
|
||||
```markdown
|
||||
### AI Assessment [Experimental]: Needs More Information
|
||||
|
||||
**Component**: {component or "Unknown"}
|
||||
|
||||
@@ -52,6 +52,16 @@ provider/alibabacloud:
|
||||
- any-glob-to-any-file: "prowler/providers/alibabacloud/**"
|
||||
- any-glob-to-any-file: "tests/providers/alibabacloud/**"
|
||||
|
||||
provider/huaweicloud:
|
||||
- changed-files:
|
||||
- any-glob-to-any-file: "prowler/providers/huaweicloud/**"
|
||||
- any-glob-to-any-file: "tests/providers/huaweicloud/**"
|
||||
|
||||
provider/image:
|
||||
- changed-files:
|
||||
- any-glob-to-any-file: "prowler/providers/image/**"
|
||||
- any-glob-to-any-file: "tests/providers/image/**"
|
||||
|
||||
provider/cloudflare:
|
||||
- changed-files:
|
||||
- any-glob-to-any-file: "prowler/providers/cloudflare/**"
|
||||
@@ -82,6 +92,11 @@ provider/linode:
|
||||
- any-glob-to-any-file: "prowler/providers/linode/**"
|
||||
- any-glob-to-any-file: "tests/providers/linode/**"
|
||||
|
||||
provider/stackit:
|
||||
- changed-files:
|
||||
- any-glob-to-any-file: "prowler/providers/stackit/**"
|
||||
- any-glob-to-any-file: "tests/providers/stackit/**"
|
||||
|
||||
github_actions:
|
||||
- changed-files:
|
||||
- any-glob-to-any-file: ".github/workflows/*"
|
||||
|
||||
@@ -8,11 +8,11 @@ These JSON templates are used with the `slackapi/slack-github-action` using the
|
||||
|
||||
### Available Templates
|
||||
|
||||
**Container Releases**
|
||||
#### Container Releases
|
||||
- `container-release-started.json`: Simple one-line notification when container push starts
|
||||
- `container-release-completed.json`: Simple one-line notification when container release completes
|
||||
|
||||
**Deployments**
|
||||
#### Deployments
|
||||
- `deployment-started.json`: Deployment start notification with Block Kit formatting
|
||||
- `deployment-completed.json`: Deployment completion notification (updates the start message)
|
||||
|
||||
@@ -416,17 +416,17 @@ For deployments that start with one message and update it with the final status:
|
||||
### Container Release (Simple One-Line)
|
||||
|
||||
**Start message:**
|
||||
```
|
||||
```text
|
||||
API container release 4.5.0 push started... View run
|
||||
```
|
||||
|
||||
**Completion message (success):**
|
||||
```
|
||||
```text
|
||||
[✓] API container release 4.5.0 push completed successfully! View run
|
||||
```
|
||||
|
||||
**Completion message (failure):**
|
||||
```
|
||||
```text
|
||||
[✗] API container release 4.5.0 push failed View run
|
||||
```
|
||||
|
||||
|
||||
@@ -55,6 +55,10 @@ jobs:
|
||||
# Pin must match .pre-commit-config.yaml so prek and CI behave identically.
|
||||
# pnpm dlx doesn't accept --ignore-scripts as a flag; the env var
|
||||
# disables postinstall scripts on transitives the same way.
|
||||
#
|
||||
# Files come from `git ls-files` because markdownlint doesn't traverse
|
||||
# dot-directories, so `.github/**/*.md` went unlinted.
|
||||
# `.markdownlintignore` still applies to the listed paths.
|
||||
env:
|
||||
pnpm_config_ignore_scripts: 'true'
|
||||
run: pnpm dlx markdownlint-cli@0.45.0 '**/*.md'
|
||||
run: git ls-files -z '*.md' | xargs -0 -r pnpm dlx markdownlint-cli@0.45.0 --
|
||||
|
||||
@@ -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
|
||||
@@ -84,6 +84,16 @@ 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@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # v1.14.2
|
||||
with:
|
||||
@@ -128,6 +138,16 @@ 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@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # v1.14.2
|
||||
with:
|
||||
|
||||
@@ -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
|
||||
|
||||
+4
-3
@@ -8,15 +8,15 @@ 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.73.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=2edd39da482bb4e9831962487b68f68e3928ec3137794757f54d00383d79547b
|
||||
ARG TRIVY_SHA256_ARM64=13833d97e8a1a5367471c372a173180157f593bece570e20d5d925fef552f5dd
|
||||
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
|
||||
@@ -26,6 +26,7 @@ ARG ZIZMOR_SHA256_ARM64=d66e37ef8a375fb07939c630ebf9709a6e0f20242bdc3faf672a7ed9
|
||||
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 \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Install PowerShell
|
||||
|
||||
@@ -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
|
||||
|
||||
+4
-3
@@ -7,15 +7,15 @@ 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.73.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=2edd39da482bb4e9831962487b68f68e3928ec3137794757f54d00383d79547b
|
||||
ARG TRIVY_SHA256_ARM64=13833d97e8a1a5367471c372a173180157f593bece570e20d5d925fef552f5dd
|
||||
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
|
||||
@@ -36,6 +36,7 @@ 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 \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Install PowerShell
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
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`
|
||||
+11
-7
@@ -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",
|
||||
@@ -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,7 +484,12 @@ 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",
|
||||
"azure-mgmt-containerservice==34.1.0",
|
||||
"microsoft-kiota-abstractions==1.9.10",
|
||||
|
||||
Generated
+36
-48
@@ -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" },
|
||||
@@ -373,7 +373,7 @@ 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 = "azure-mgmt-containerservice", specifier = "==34.1.0" },
|
||||
@@ -860,7 +860,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 +869,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 +4426,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "oci"
|
||||
version = "2.183.0"
|
||||
version = "2.184.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "certifi" },
|
||||
@@ -4439,9 +4439,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 +4835,8 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "prowler"
|
||||
version = "5.38.0"
|
||||
source = { git = "https://github.com/prowler-cloud/prowler.git?rev=master#b3d174d0c1eb202ed7cb9a9daf0500683f4443be" }
|
||||
version = "5.40.0"
|
||||
source = { git = "https://github.com/prowler-cloud/prowler.git?rev=master#b6e9967da6bebd6c7b8b237317a2a95e2e0c65bc" }
|
||||
dependencies = [
|
||||
{ name = "alibabacloud-actiontrail20200706" },
|
||||
{ name = "alibabacloud-credentials" },
|
||||
@@ -5426,15 +5426,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]]
|
||||
@@ -6623,39 +6623,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" },
|
||||
]
|
||||
|
||||
@@ -224,9 +224,9 @@ Each check **must** populate the report with a unique identifier for the audited
|
||||
- `resource_name`: Description of the configuration (e.g., "SharePoint Settings")
|
||||
- GitHub
|
||||
- Resource ID — `report.resource_id`.
|
||||
- The ID of the Github resource. This is a system-generated integer that uniquely identifies the resource within the Github platform.
|
||||
- The ID of the GitHub resource. This is a system-generated integer that uniquely identifies the resource within the GitHub platform.
|
||||
- Resource Name — `report.resource_name`.
|
||||
- The name of the Github resource. In the case of a repository, this is just the repository name. For full repository names use the resource `full_name`.
|
||||
- The name of the GitHub resource. In the case of a repository, this is just the repository name. For full repository names use the resource `full_name`.
|
||||
|
||||
### Configurable Checks in Prowler
|
||||
|
||||
|
||||
@@ -107,7 +107,7 @@ Once you have decided the provider you want or need to add to Prowler, the next
|
||||
- **SDK Providers**: Low complexity. You have mature examples like AWS, Azure, GCP, Kubernetes, etc. that you can leverage to implement your provider.
|
||||
- **API Providers**: Medium complexity. You need to implement the authentication and session management, and the API calls to the provider. You now have NHN and MongoDB Atlas as example to follow.
|
||||
- **Tool/Wrapper Providers**: High complexity. You need to implement the argument/output mapping to the provider and handle problems that the tool/wrapper may have. You now have IAC and the PowerShell wrapper as example to follow.
|
||||
- **Hybrid Providers**: High complexity. You need to "customize" your provider, mixing the other types of providers in order to achieve the desired result. You have M365 (msgraph SDK + PowerShell wrapper) and Github (PyGithub SDK + graphql API requests) as examples.
|
||||
- **Hybrid Providers**: High complexity. You need to "customize" your provider, mixing the other types of providers in order to achieve the desired result. You have M365 (msgraph SDK + PowerShell wrapper) and GitHub (PyGithub SDK + graphql API requests) as examples.
|
||||
|
||||
### Determining Regional vs Non-Regional Architecture
|
||||
|
||||
@@ -814,7 +814,7 @@ class YourProviderMutelist(Mutelist):
|
||||
Region management is essential for cloud providers that operate across multiple geographic locations. This component handles region validation and provides region-specific functionality.
|
||||
|
||||
<Note>
|
||||
Regions are optional, only if the provider has regions, for example Github does not have regions, but AWS does.
|
||||
Regions are optional, only if the provider has regions, for example GitHub does not have regions, but AWS does.
|
||||
</Note>
|
||||
|
||||
**File:** `prowler/providers/<provider_name>/lib/regions/<provider_name>_regions.py`
|
||||
@@ -1773,7 +1773,7 @@ The implementation of the mutelist is the same as the [SDK providers](#step-5-im
|
||||
Region management is essential for cloud providers that operate across multiple geographic locations. This component handles region validation and provides region-specific functionality.
|
||||
|
||||
<Note>
|
||||
Regions are optional, only if the provider has regions, for example Github does not have regions, but AWS does.
|
||||
Regions are optional, only if the provider has regions, for example GitHub does not have regions, but AWS does.
|
||||
</Note>
|
||||
|
||||
**File:** `prowler/providers/<provider_name>/lib/regions/<provider_name>_regions.py`
|
||||
|
||||
@@ -133,7 +133,7 @@ PROWLER_API_VERSION="5.39.0"
|
||||
```
|
||||
|
||||
<Note>
|
||||
You can find the latest versions of Prowler Local Server in the [Releases Github section](https://github.com/prowler-cloud/prowler/releases) or in the [Container Versions](#container-versions) section of this documentation.
|
||||
You can find the latest versions of Prowler Local Server in the [Releases GitHub section](https://github.com/prowler-cloud/prowler/releases) or in the [Container Versions](#container-versions) section of this documentation.
|
||||
</Note>
|
||||
|
||||
|
||||
|
||||
@@ -495,7 +495,7 @@ aws:
|
||||
|
||||
# AWS CloudTrail Configuration
|
||||
# aws.cloudtrail_threat_detection_privilege_escalation
|
||||
threat_detection_privilege_escalation_threshold: 0.2 # Percentage of actions found to decide if it is an privilege_escalation attack event, by default is 0.2 (20%)
|
||||
threat_detection_privilege_escalation_threshold: 0.2 # Percentage of actions found to decide if it is a privilege_escalation attack event, by default is 0.2 (20%)
|
||||
threat_detection_privilege_escalation_minutes: 1440 # Past minutes to search from now for privilege_escalation attacks, by default is 1440 minutes (24 hours)
|
||||
threat_detection_privilege_escalation_actions:
|
||||
[
|
||||
|
||||
@@ -31,6 +31,8 @@
|
||||
"ec2:GetInstanceMetadataDefaults",
|
||||
"ecr:Describe*",
|
||||
"ecr:GetRegistryScanningConfiguration",
|
||||
"ecr:BatchGetImage",
|
||||
"ecr:GetDownloadUrlForLayer",
|
||||
"elasticfilesystem:DescribeBackupPolicy",
|
||||
"glue:GetConnections",
|
||||
"glue:GetSecurityConfiguration*",
|
||||
|
||||
@@ -203,6 +203,8 @@ Resources:
|
||||
- "ec2:GetInstanceMetadataDefaults"
|
||||
- "ecr:Describe*"
|
||||
- "ecr:GetRegistryScanningConfiguration"
|
||||
- "ecr:BatchGetImage"
|
||||
- "ecr:GetDownloadUrlForLayer"
|
||||
- "elasticfilesystem:DescribeBackupPolicy"
|
||||
- "glue:GetConnections"
|
||||
- "glue:GetSecurityConfiguration*"
|
||||
@@ -470,6 +472,8 @@ Resources:
|
||||
- "ec2:GetInstanceMetadataDefaults"
|
||||
- "ecr:Describe*"
|
||||
- "ecr:GetRegistryScanningConfiguration"
|
||||
- "ecr:BatchGetImage"
|
||||
- "ecr:GetDownloadUrlForLayer"
|
||||
- "elasticfilesystem:DescribeBackupPolicy"
|
||||
- "glue:GetConnections"
|
||||
- "glue:GetSecurityConfiguration*"
|
||||
|
||||
@@ -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 @@
|
||||
`ecr_repository_image_no_secrets` check for AWS provider, scanning the latest ECR repository image's configuration and filesystem layers for hardcoded secrets
|
||||
@@ -1 +0,0 @@
|
||||
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`
|
||||
+42
@@ -0,0 +1,42 @@
|
||||
{
|
||||
"Provider": "aws",
|
||||
"CheckID": "ecr_repository_image_no_secrets",
|
||||
"CheckTitle": "ECR repository image contains no hardcoded secrets",
|
||||
"CheckType": [
|
||||
"Software and Configuration Checks/AWS Security Best Practices",
|
||||
"Sensitive Data Identifications/Passwords",
|
||||
"Effects/Data Exposure"
|
||||
],
|
||||
"ServiceName": "ecr",
|
||||
"SubServiceName": "",
|
||||
"ResourceIdTemplate": "",
|
||||
"Severity": "high",
|
||||
"ResourceType": "AwsEcrRepository",
|
||||
"ResourceGroup": "container",
|
||||
"Description": "The **latest image** pushed to each **Amazon ECR repository** is analyzed for **embedded secrets**: environment variables and build history (Dockerfile instructions) recorded in the image configuration, plus the file contents of every filesystem layer. Findings reference the variable, build step, or file, never the secret value.",
|
||||
"Risk": "Anyone able to pull the image obtains any **credentials, tokens, or keys** embedded at build time via `ENV`, `ARG`, inline `RUN` commands, or files copied into the image (e.g. `COPY .env .`).\n\nLeaked credentials enable unauthorized access to databases, APIs, or cloud resources, and rotation is harder once secrets are baked into distributed image artifacts.",
|
||||
"RelatedUrl": "",
|
||||
"AdditionalURLs": [
|
||||
"https://docs.aws.amazon.com/AmazonECR/latest/userguide/security-best-practices.html",
|
||||
"https://docs.aws.amazon.com/secretsmanager/latest/userguide/best-practices.html",
|
||||
"https://docs.docker.com/build/building/secrets/"
|
||||
],
|
||||
"Remediation": {
|
||||
"Code": {
|
||||
"CLI": "aws ecr batch-delete-image --repository-name <repository-name> --image-ids imageDigest=<image-digest>",
|
||||
"NativeIaC": "",
|
||||
"Other": "1. Remove the secret from the Dockerfile (ENV/ARG/RUN) or from any file copied into the build context, and rebuild the image without it.\n2. Provide the secret at runtime instead: reference AWS Secrets Manager or SSM Parameter Store from your ECS task definition, EKS pod (Secrets Store CSI driver), or application code.\n3. Push the rebuilt image and delete the compromised image versions from the repository.\n4. Rotate the exposed credential immediately.",
|
||||
"Terraform": ""
|
||||
},
|
||||
"Recommendation": {
|
||||
"Text": "Never bake secrets into images with `ENV`, `ARG`, inline `RUN` commands, or copied files. Use **BuildKit build secrets** (`--mount=type=secret`) at build time and **AWS Secrets Manager**/Parameter Store at runtime. Add secret scanning to CI/CD before pushing images.",
|
||||
"Url": "https://hub.prowler.com/check/ecr_repository_image_no_secrets"
|
||||
}
|
||||
},
|
||||
"Categories": [
|
||||
"secrets"
|
||||
],
|
||||
"DependsOn": [],
|
||||
"RelatedTo": [],
|
||||
"Notes": "Only the most recently pushed image in each repository is scanned; older tagged images are not scanned. The latest scannable image is evaluated in every repository regardless of whether scan-on-push is enabled. The scanned image's configuration (environment variables and build history) plus every filesystem layer's file contents are analyzed. A multi-architecture image resolves to a single platform's manifest; other architectures in the same manifest list are not scanned. To bound cost, a single layer over 100 MB (compressed) is not downloaded, an individual file over 1 MB is not scanned, and scanning of an image stops after 5000 files or 500 MB (decompressed). When part of an image cannot be scanned this way, a clean result is reported as MANUAL (coverage was incomplete) rather than PASS, and a FAIL still discloses that some content was skipped. Requires the ecr:BatchGetImage and ecr:GetDownloadUrlForLayer permissions in addition to SecurityAudit."
|
||||
}
|
||||
+244
@@ -0,0 +1,244 @@
|
||||
import re
|
||||
|
||||
from prowler.lib.check.models import Check, Check_Report_AWS
|
||||
from prowler.lib.utils.utils import (
|
||||
SecretsScanError,
|
||||
annotate_verified_secrets,
|
||||
detect_secrets_scan_batch,
|
||||
)
|
||||
from prowler.providers.aws.services.ecr.ecr_client import ecr_client
|
||||
|
||||
_SAFE_ENVIRONMENT_VARIABLE_NAME = re.compile(r"[A-Za-z_][A-Za-z0-9_]*")
|
||||
|
||||
|
||||
class ecr_repository_image_no_secrets(Check):
|
||||
"""Ensure the latest ECR repository image embeds no hardcoded secrets.
|
||||
|
||||
The most recently pushed image in every ECR repository is resolved to a
|
||||
single scannable manifest (a multi-arch image resolves to one platform's
|
||||
manifest; other architectures in the same manifest list are not
|
||||
scanned) and scanned for plaintext secrets in its configuration
|
||||
(environment variables, build history) and every filesystem layer's
|
||||
file contents. Older tagged images are not scanned.
|
||||
- PASS: no secrets detected and the whole image was scanned.
|
||||
- FAIL: a potential secret was detected; the variable, build step, or
|
||||
file is reported, never the secret value.
|
||||
- MANUAL: the image could not be scanned in full, so a clean result would
|
||||
be misleading -- the manifest could not be retrieved or resolved, the
|
||||
scan itself failed, or part of the image exceeded configured size limits
|
||||
or could not be retrieved.
|
||||
"""
|
||||
|
||||
def execute(self) -> list[Check_Report_AWS]:
|
||||
"""Execute the check logic.
|
||||
|
||||
Returns:
|
||||
A list of reports containing the result of the check.
|
||||
"""
|
||||
findings = []
|
||||
secrets_ignore_patterns = ecr_client.audit_config.get(
|
||||
"secrets_ignore_patterns", []
|
||||
)
|
||||
validate = ecr_client.audit_config.get("secrets_validate", False)
|
||||
|
||||
# Phase 1: collect. The service yields (repository, image, scan_data)
|
||||
# lazily, downloading each image's manifest, config, and layers; each
|
||||
# image contributes an env/history/file payload per scannable unit so
|
||||
# a finding's key maps back to a variable, build step, or file.
|
||||
scanned = []
|
||||
|
||||
def image_payloads():
|
||||
"""Yield keyed scan payloads, recording each image into `scanned`."""
|
||||
for repository, image, scan_data in ecr_client._get_image_scan_data():
|
||||
index = len(scanned)
|
||||
scanned.append((repository, image, scan_data))
|
||||
if scan_data is None or isinstance(scan_data, Exception):
|
||||
continue
|
||||
for env_index, entry in enumerate(scan_data.env):
|
||||
yield (index, f"environment:{env_index}"), entry
|
||||
for history_index, entry in enumerate(scan_data.history):
|
||||
yield (index, f"history:{history_index}"), entry
|
||||
for file_index, scanned_file in enumerate(scan_data.files):
|
||||
yield (index, f"file:{file_index}"), scanned_file.content
|
||||
# Free the file's contents once handed to the scanner. The
|
||||
# report phase needs only its path and layer digest, so
|
||||
# retained memory stays flat instead of growing with the
|
||||
# number of repositories scanned.
|
||||
scanned_file.content = ""
|
||||
|
||||
# Phase 2: batch — one call, chunked Kingfisher subprocesses. This
|
||||
# must fully consume image_payloads() so every image is appended to
|
||||
# `scanned` before Phase 3 runs; detect_secrets_scan_batch does so
|
||||
# today, but a future short-circuit there would silently drop images
|
||||
# from the report loop.
|
||||
scan_error = None
|
||||
try:
|
||||
batch_results = detect_secrets_scan_batch(
|
||||
image_payloads(),
|
||||
excluded_secrets=secrets_ignore_patterns,
|
||||
validate=validate,
|
||||
)
|
||||
except SecretsScanError as error:
|
||||
batch_results = {}
|
||||
scan_error = error
|
||||
|
||||
if scan_error:
|
||||
# The scan failed and the payload generator may not have been
|
||||
# consumed, so build the MANUAL reports from the repositories
|
||||
# themselves rather than risk a false PASS or a missing finding.
|
||||
for registry in ecr_client.registries.values():
|
||||
for repository in registry.repositories:
|
||||
image = ecr_client._get_scan_target_image(repository)
|
||||
if isinstance(image, Exception):
|
||||
findings.append(
|
||||
self._build_scan_error_report(repository, image)
|
||||
)
|
||||
elif image is not None:
|
||||
report = self._build_report(repository, image)
|
||||
report.status = "MANUAL"
|
||||
report.status_extended = (
|
||||
f"Could not scan image '{image.latest_tag}' "
|
||||
f"({image.latest_digest}) of ECR repository "
|
||||
f"{repository.name} for secrets: {scan_error}; "
|
||||
f"manual review is required."
|
||||
)
|
||||
findings.append(report)
|
||||
return findings
|
||||
|
||||
# Phase 3: report — one finding per scanned image.
|
||||
for index, (repository, image, scan_data) in enumerate(scanned):
|
||||
if isinstance(scan_data, Exception):
|
||||
findings.append(self._build_scan_error_report(repository, scan_data))
|
||||
continue
|
||||
report = self._build_report(repository, image)
|
||||
image_reference = (
|
||||
f"image '{image.latest_tag}' ({image.latest_digest}) of ECR "
|
||||
f"repository {repository.name}"
|
||||
)
|
||||
|
||||
if scan_data is None:
|
||||
report.status = "MANUAL"
|
||||
report.status_extended = (
|
||||
f"Could not resolve or retrieve the manifest of the "
|
||||
f"{image_reference} to scan it for secrets; manual "
|
||||
f"review is required."
|
||||
)
|
||||
findings.append(report)
|
||||
continue
|
||||
|
||||
env_findings_by_index = {
|
||||
int(key[1].split(":", 1)[1]): entry_secrets
|
||||
for key, entry_secrets in batch_results.items()
|
||||
if key[0] == index and key[1].startswith("environment:")
|
||||
}
|
||||
history_findings_by_index = {
|
||||
int(key[1].split(":", 1)[1]): entry_secrets
|
||||
for key, entry_secrets in batch_results.items()
|
||||
if key[0] == index and key[1].startswith("history:")
|
||||
}
|
||||
file_findings_by_index = {
|
||||
int(key[1].split(":", 1)[1]): file_secrets
|
||||
for key, file_secrets in batch_results.items()
|
||||
if key[0] == index and key[1].startswith("file:")
|
||||
}
|
||||
|
||||
if (
|
||||
env_findings_by_index
|
||||
or history_findings_by_index
|
||||
or file_findings_by_index
|
||||
):
|
||||
secrets_found = []
|
||||
all_secrets = []
|
||||
|
||||
for env_index, env_findings in env_findings_by_index.items():
|
||||
variable = None
|
||||
if 0 <= env_index < len(scan_data.env):
|
||||
entry = scan_data.env[env_index]
|
||||
# Only a well-formed "NAME=value" entry has a name safe
|
||||
# to report; an entry with no "=" may itself be the
|
||||
# secret, so it is never echoed back.
|
||||
if "=" in entry:
|
||||
candidate = entry.split("=", 1)[0]
|
||||
if _SAFE_ENVIRONMENT_VARIABLE_NAME.fullmatch(candidate):
|
||||
variable = candidate
|
||||
all_secrets.extend(env_findings)
|
||||
for secret in env_findings:
|
||||
if variable is not None:
|
||||
secrets_found.append(
|
||||
f"{secret['type']} in environment variable {variable}"
|
||||
)
|
||||
else:
|
||||
secrets_found.append(
|
||||
f"{secret['type']} in image environment variables"
|
||||
)
|
||||
for (
|
||||
history_index,
|
||||
history_findings,
|
||||
) in history_findings_by_index.items():
|
||||
all_secrets.extend(history_findings)
|
||||
for secret in history_findings:
|
||||
secrets_found.append(
|
||||
f"{secret['type']} in image history step {history_index + 1}"
|
||||
)
|
||||
for file_index, file_secrets in file_findings_by_index.items():
|
||||
scanned_file = scan_data.files[file_index]
|
||||
all_secrets.extend(file_secrets)
|
||||
for secret in file_secrets:
|
||||
secrets_found.append(
|
||||
f"{secret['type']} in file {scanned_file.path} "
|
||||
f"(layer {scanned_file.layer_digest})"
|
||||
)
|
||||
|
||||
report.status = "FAIL"
|
||||
report.status_extended = (
|
||||
f"Potential {'secrets' if len(secrets_found) > 1 else 'secret'} "
|
||||
f"found in the {image_reference} -> {', '.join(secrets_found)}."
|
||||
)
|
||||
if scan_data.truncated:
|
||||
report.status_extended += (
|
||||
" Some of the image could not be retrieved or exceeded "
|
||||
"configured size limits and was not scanned."
|
||||
)
|
||||
annotate_verified_secrets(report, all_secrets)
|
||||
elif scan_data.truncated:
|
||||
# No secrets in what was scanned, but coverage was incomplete
|
||||
# (size/count limits, or the config could not be retrieved), so
|
||||
# a clean result would be misleading.
|
||||
report.status = "MANUAL"
|
||||
report.status_extended = (
|
||||
f"No secrets were found in the scanned portion of the "
|
||||
f"{image_reference}, but part of it could not be retrieved "
|
||||
f"or exceeded configured size limits and was not scanned; "
|
||||
f"manual review is required."
|
||||
)
|
||||
else:
|
||||
report.status = "PASS"
|
||||
report.status_extended = f"No secrets found in the {image_reference}."
|
||||
|
||||
findings.append(report)
|
||||
|
||||
return findings
|
||||
|
||||
def _build_scan_error_report(self, repository, error) -> Check_Report_AWS:
|
||||
"""Build a repository-level report for a latest-image lookup failure."""
|
||||
report = Check_Report_AWS(metadata=self.metadata(), resource=repository)
|
||||
report.status = "MANUAL"
|
||||
report.status_extended = (
|
||||
f"Could not determine the latest image of ECR repository "
|
||||
f"{repository.name}: {error}; manual review is required."
|
||||
)
|
||||
return report
|
||||
|
||||
def _build_report(self, repository, image) -> Check_Report_AWS:
|
||||
"""Build a report scoped to a single image within a repository.
|
||||
|
||||
ECR images have no ARN of their own, so the repository's ARN is
|
||||
reused with the image digest appended as a synthetic suffix,
|
||||
mirroring how other sub-resource checks (e.g. CodeArtifact packages
|
||||
within a repository) identify per-item findings.
|
||||
"""
|
||||
report = Check_Report_AWS(metadata=self.metadata(), resource=repository)
|
||||
digest_short = image.latest_digest.split(":")[-1][:12]
|
||||
report.resource_id = f"{repository.name}:{image.latest_tag}@{digest_short}"
|
||||
report.resource_arn = f"{repository.arn}/image/{digest_short}"
|
||||
return report
|
||||
@@ -1,3 +1,4 @@
|
||||
from concurrent.futures import FIRST_COMPLETED, Future, ThreadPoolExecutor, wait
|
||||
from datetime import datetime
|
||||
from json import loads
|
||||
from typing import Optional
|
||||
@@ -8,10 +9,21 @@ from pydantic.v1 import BaseModel
|
||||
from prowler.lib.logger import logger
|
||||
from prowler.lib.scan_filters.scan_filters import is_resource_filtered
|
||||
from prowler.providers.aws.lib.service.service import AWSService
|
||||
from prowler.providers.aws.services.ecr.image_inspection import ImageInspector
|
||||
|
||||
# Concurrency for the image-scan pipeline (_get_image_scan_data). Kept smaller
|
||||
# than the shared MAX_WORKERS metadata pool because each task can retain up to
|
||||
# MAX_LAYER_DOWNLOAD_BYTES compressed plus MAX_TOTAL_BYTES_PER_IMAGE decompressed
|
||||
# content (see image_inspection), so a high worker count would multiply peak
|
||||
# memory into several GB.
|
||||
IMAGE_SCAN_MAX_WORKERS = 4
|
||||
|
||||
|
||||
class ECR(AWSService):
|
||||
"""AWS Elastic Container Registry service."""
|
||||
|
||||
def __init__(self, provider):
|
||||
"""Discover registries, repositories, policies, and image metadata."""
|
||||
# Call AWSService's __init__
|
||||
super().__init__(__class__.__name__, provider)
|
||||
self.registry_id = self.audited_account
|
||||
@@ -24,6 +36,7 @@ class ECR(AWSService):
|
||||
self.__threading_call__(self._list_tags_for_resource)
|
||||
|
||||
def _describe_registries_and_repositories(self, regional_client):
|
||||
"""Populate the registry and its repositories for one region."""
|
||||
logger.info("ECR - Describing registries and repositories...")
|
||||
regional_registry_repositories = []
|
||||
try:
|
||||
@@ -68,6 +81,7 @@ class ECR(AWSService):
|
||||
)
|
||||
|
||||
def _describe_repository_policies(self, regional_client):
|
||||
"""Fetch and attach each repository's resource policy, if any."""
|
||||
logger.info("ECR - Describing repository policies...")
|
||||
try:
|
||||
if regional_client.region in self.registries:
|
||||
@@ -96,6 +110,7 @@ class ECR(AWSService):
|
||||
)
|
||||
|
||||
def _get_repository_lifecycle_policy(self, regional_client):
|
||||
"""Fetch and attach each repository's lifecycle policy, if any."""
|
||||
logger.info("ECR - Getting repository lifecycle policy...")
|
||||
try:
|
||||
if regional_client.region in self.registries:
|
||||
@@ -124,6 +139,7 @@ class ECR(AWSService):
|
||||
)
|
||||
|
||||
def _get_image_details(self, regional_client):
|
||||
"""Populate each scan-on-push repository's scannable, tagged images."""
|
||||
logger.info("ECR - Getting images details...")
|
||||
try:
|
||||
if regional_client.region in self.registries:
|
||||
@@ -158,12 +174,7 @@ class ECR(AWSService):
|
||||
image_scan_findings_field_name = (
|
||||
"imageScanFindingsSummary"
|
||||
)
|
||||
if "docker" in artifact_media_type:
|
||||
type = "Docker"
|
||||
elif "oci" in artifact_media_type:
|
||||
type = "OCI"
|
||||
else:
|
||||
type = ""
|
||||
type = ECR._artifact_type(artifact_media_type)
|
||||
|
||||
# If imageScanStatus is not present or imageScanFindingsSummary is missing,
|
||||
# we need to call DescribeImageScanFindings because AWS' new version of
|
||||
@@ -252,6 +263,7 @@ class ECR(AWSService):
|
||||
)
|
||||
|
||||
def _list_tags_for_resource(self, regional_client):
|
||||
"""Fetch and attach each repository's resource tags."""
|
||||
logger.info("ECR - List Tags...")
|
||||
try:
|
||||
if regional_client.region in self.registries:
|
||||
@@ -280,6 +292,7 @@ class ECR(AWSService):
|
||||
)
|
||||
|
||||
def _get_registry_scanning_configuration(self, regional_client):
|
||||
"""Fetch and attach the registry's image-scanning configuration."""
|
||||
logger.info("ECR - Getting Registry Scanning Configuration...")
|
||||
try:
|
||||
if regional_client.region in self.registries:
|
||||
@@ -315,6 +328,155 @@ class ECR(AWSService):
|
||||
f"{regional_client.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
|
||||
)
|
||||
|
||||
def _get_image_scan_data(self):
|
||||
"""Lazily fetch manifest, config, and layer file contents for the latest image.
|
||||
|
||||
Only the most recently pushed scannable image in each repository is
|
||||
scanned (resolved via _get_scan_target_image, which also covers
|
||||
scan-on-push-disabled repositories) to bound cost on repositories
|
||||
with many tags.
|
||||
|
||||
Not called from __init__: this is only invoked by the
|
||||
ecr_repository_image_no_secrets check, since it downloads and
|
||||
decompresses image layers and is significantly more expensive than
|
||||
the metadata gathered above. A dedicated, smaller thread pool bounds
|
||||
the concurrency (and therefore the peak memory) of this heavy
|
||||
pipeline independently of the shared metadata pool.
|
||||
|
||||
Yields:
|
||||
Tuple of repository, optional image, and scan data. The third item
|
||||
is an exception when the authoritative image lookup failed.
|
||||
"""
|
||||
logger.info("ECR - Fetching image manifests, configs, and layers...")
|
||||
inspector = ImageInspector()
|
||||
|
||||
def images_to_fetch():
|
||||
for registry in self.registries.values():
|
||||
for repository in registry.repositories:
|
||||
image = self._get_scan_target_image(repository)
|
||||
if isinstance(image, Exception):
|
||||
yield repository, None, image
|
||||
elif image is not None:
|
||||
yield repository, image, None
|
||||
|
||||
with ThreadPoolExecutor(max_workers=IMAGE_SCAN_MAX_WORKERS) as executor:
|
||||
pending = {}
|
||||
targets = iter(images_to_fetch())
|
||||
|
||||
def submit_next():
|
||||
try:
|
||||
repository, image, error = next(targets)
|
||||
except StopIteration:
|
||||
return False
|
||||
if error:
|
||||
future = Future()
|
||||
future.set_result(error)
|
||||
else:
|
||||
client = self.regional_clients[repository.region]
|
||||
registry_id = self.registries[repository.region].id
|
||||
future = executor.submit(
|
||||
inspector.fetch_image_scan_data,
|
||||
client,
|
||||
registry_id,
|
||||
repository.name,
|
||||
image.latest_digest,
|
||||
)
|
||||
pending[future] = (repository, image)
|
||||
return True
|
||||
|
||||
for _ in range(IMAGE_SCAN_MAX_WORKERS):
|
||||
if not submit_next():
|
||||
break
|
||||
|
||||
while pending:
|
||||
completed, _ = wait(pending, return_when=FIRST_COMPLETED)
|
||||
for future in completed:
|
||||
repository, image = pending.pop(future)
|
||||
scan_data = None
|
||||
try:
|
||||
scan_data = future.result()
|
||||
except Exception as error:
|
||||
logger.error(
|
||||
f"{repository.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
|
||||
)
|
||||
yield repository, image, scan_data
|
||||
submit_next()
|
||||
|
||||
@staticmethod
|
||||
def _artifact_type(artifact_media_type: Optional[str]) -> str:
|
||||
"""Map an image's artifact media type to a short image type label.
|
||||
|
||||
Returns:
|
||||
"Docker", "OCI", or "" for an unrecognized/absent media type.
|
||||
"""
|
||||
if artifact_media_type:
|
||||
if "docker" in artifact_media_type:
|
||||
return "Docker"
|
||||
if "oci" in artifact_media_type:
|
||||
return "OCI"
|
||||
return ""
|
||||
|
||||
def _get_scan_target_image(self, repository) -> Optional["ImageDetails"]:
|
||||
"""Resolve the latest scannable image to scan for secrets.
|
||||
|
||||
Secret scanning is independent of ECR's vulnerability scanning
|
||||
configuration, but `_get_image_details` only populates
|
||||
`images_details` for scan-on-push-enabled repositories. For a
|
||||
repository with scan-on-push disabled (empty `images_details`), this
|
||||
performs a dedicated `describe_images` lookup to find the most
|
||||
recently pushed scannable image, so those repositories are not
|
||||
silently skipped.
|
||||
|
||||
The synthesized ImageDetails is deliberately NOT appended to
|
||||
`repository.images_details`: other checks (e.g.
|
||||
ecr_repositories_scan_vulnerabilities_in_latest_image) treat any
|
||||
entry there as a scanned image and would FAIL scan-on-push-disabled
|
||||
repositories that currently produce no finding.
|
||||
|
||||
Returns:
|
||||
The latest scannable ImageDetails, or None if the repository has
|
||||
no scannable image; an exception if the lookup failed.
|
||||
"""
|
||||
latest = repository.images_details[-1] if repository.images_details else None
|
||||
try:
|
||||
client = self.regional_clients[repository.region]
|
||||
describe_images_paginator = client.get_paginator("describe_images")
|
||||
for page in describe_images_paginator.paginate(
|
||||
registryId=self.registries[repository.region].id,
|
||||
repositoryName=repository.name,
|
||||
PaginationConfig={"PageSize": 1000},
|
||||
):
|
||||
for image in page["imageDetails"]:
|
||||
if image is None:
|
||||
continue
|
||||
artifact_media_type = image.get("artifactMediaType", None)
|
||||
tags = image.get("imageTags", [])
|
||||
if not ECR._is_artifact_scannable(artifact_media_type, tags):
|
||||
continue
|
||||
image_pushed_at = image.get("imagePushedAt")
|
||||
if image_pushed_at is None:
|
||||
continue
|
||||
# Match _get_image_details' "sort ascending, take last"
|
||||
# selection: on equal push dates the later-listed image
|
||||
# wins, so `<` (not `<=`) is used to replace on ties.
|
||||
if latest is not None and image_pushed_at < latest.image_pushed_at:
|
||||
continue
|
||||
latest = ImageDetails(
|
||||
latest_tag=image.get("imageTags", ["None"])[0],
|
||||
image_pushed_at=image_pushed_at,
|
||||
latest_digest=image.get("imageDigest"),
|
||||
scan_findings_status=None,
|
||||
scan_findings_severity_count=None,
|
||||
artifact_media_type=artifact_media_type,
|
||||
type=ECR._artifact_type(artifact_media_type),
|
||||
)
|
||||
return latest
|
||||
except Exception as error:
|
||||
logger.error(
|
||||
f"{repository.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
|
||||
)
|
||||
return error
|
||||
|
||||
@staticmethod
|
||||
def _is_artifact_scannable(artifact_media_type: str, tags: list[str] = []) -> bool:
|
||||
"""
|
||||
@@ -355,12 +517,16 @@ class ECR(AWSService):
|
||||
|
||||
|
||||
class FindingSeverityCounts(BaseModel):
|
||||
"""Count of an image's vulnerability scan findings by severity."""
|
||||
|
||||
critical: int
|
||||
high: int
|
||||
medium: int
|
||||
|
||||
|
||||
class ImageDetails(BaseModel):
|
||||
"""A single scannable, tagged image within an ECR repository."""
|
||||
|
||||
latest_tag: str
|
||||
latest_digest: str
|
||||
image_pushed_at: datetime
|
||||
@@ -371,6 +537,8 @@ class ImageDetails(BaseModel):
|
||||
|
||||
|
||||
class Repository(BaseModel):
|
||||
"""An ECR repository and its policies, images, and tags."""
|
||||
|
||||
name: str
|
||||
arn: str
|
||||
region: str
|
||||
@@ -384,11 +552,15 @@ class Repository(BaseModel):
|
||||
|
||||
|
||||
class ScanningRule(BaseModel):
|
||||
"""A registry-level image-scanning rule and its repository filters."""
|
||||
|
||||
scan_frequency: str
|
||||
scan_filters: list[dict]
|
||||
|
||||
|
||||
class Registry(BaseModel):
|
||||
"""An ECR registry: its repositories and scanning configuration."""
|
||||
|
||||
id: str
|
||||
arn: str
|
||||
region: str
|
||||
|
||||
@@ -0,0 +1,494 @@
|
||||
import gzip
|
||||
import tarfile
|
||||
from contextlib import contextmanager
|
||||
from json import loads
|
||||
from typing import Optional
|
||||
|
||||
import requests
|
||||
import zstandard
|
||||
from pydantic.v1 import BaseModel
|
||||
|
||||
from prowler.lib.logger import logger
|
||||
|
||||
# Manifest media types that wrap several per-architecture manifests (a "fat
|
||||
# manifest") rather than a single scannable image.
|
||||
_MANIFEST_LIST_MEDIA_TYPES = {
|
||||
"application/vnd.docker.distribution.manifest.list.v2+json",
|
||||
"application/vnd.oci.image.index.v1+json",
|
||||
}
|
||||
|
||||
# Compressed size of a single layer, checked against the manifest-declared
|
||||
# size before downloading, and re-checked against actual bytes received.
|
||||
MAX_LAYER_DOWNLOAD_BYTES = 100 * 1024 * 1024
|
||||
# Size of a single extracted file considered for scanning.
|
||||
MAX_FILE_BYTES = 1 * 1024 * 1024
|
||||
# Hard cap on the number of files scanned per image, across all its layers.
|
||||
MAX_FILES_PER_IMAGE = 5000
|
||||
# Hard cap on total decompressed bytes read per image, across all its layers.
|
||||
MAX_TOTAL_BYTES_PER_IMAGE = 500 * 1024 * 1024
|
||||
LAYER_DOWNLOAD_TIMEOUT_SECONDS = 30
|
||||
|
||||
|
||||
class _LayerTooLargeError(Exception):
|
||||
"""Raised when a streamed layer exceeds MAX_LAYER_DOWNLOAD_BYTES."""
|
||||
|
||||
|
||||
class _ImageTooLargeError(Exception):
|
||||
"""Raised when decompressed image streams exceed their shared budget."""
|
||||
|
||||
|
||||
class _CappedLayerReader:
|
||||
"""A minimal read-only file object that caps the bytes it will yield.
|
||||
|
||||
Wraps a streaming HTTP body (urllib3's ``response.raw``) so ``tarfile`` can
|
||||
read a gzip/uncompressed layer incrementally while enforcing an upper bound
|
||||
on the compressed bytes consumed. A manifest that under-declares a layer's
|
||||
size (the declared size is pre-checked separately) cannot make this buffer
|
||||
an unbounded amount of untrusted data: once ``max_bytes`` is exceeded the
|
||||
read raises ``_LayerTooLargeError`` instead of continuing.
|
||||
"""
|
||||
|
||||
def __init__(self, raw, max_bytes: int):
|
||||
"""Store the underlying raw stream and the remaining byte budget."""
|
||||
self._raw = raw
|
||||
self._remaining = max_bytes
|
||||
|
||||
def read(self, size: int = -1) -> bytes:
|
||||
"""Read up to ``size`` bytes, never exceeding the remaining budget.
|
||||
|
||||
A negative/None ``size`` (``read all``) is treated as "read what's left
|
||||
of the budget, plus one" so a lying stream can never pull an unbounded
|
||||
amount into memory and an over-cap layer is still detected.
|
||||
"""
|
||||
if size is None or size < 0:
|
||||
size = self._remaining + 1
|
||||
to_read = min(size, self._remaining + 1)
|
||||
chunk = self._raw.read(to_read)
|
||||
self._remaining -= len(chunk)
|
||||
if self._remaining < 0:
|
||||
raise _LayerTooLargeError()
|
||||
return chunk
|
||||
|
||||
|
||||
class _DecompressedByteBudget:
|
||||
"""Track every decompressed byte consumed across an image's tar streams."""
|
||||
|
||||
def __init__(self, max_bytes: int):
|
||||
"""Set the shared decompressed-byte allowance."""
|
||||
self.remaining = max_bytes
|
||||
|
||||
def wrap(self, raw):
|
||||
"""Return a reader that charges bytes consumed from ``raw``."""
|
||||
return _BudgetedReader(raw, self)
|
||||
|
||||
|
||||
class _BudgetedReader:
|
||||
"""Charge all stream reads against a shared decompressed-byte budget."""
|
||||
|
||||
def __init__(self, raw, budget: _DecompressedByteBudget):
|
||||
self._raw = raw
|
||||
self._budget = budget
|
||||
|
||||
def read(self, size: int = -1) -> bytes:
|
||||
"""Read without allowing the shared budget to be exceeded."""
|
||||
if size is None or size < 0:
|
||||
size = self._budget.remaining + 1
|
||||
chunk = self._raw.read(min(size, self._budget.remaining + 1))
|
||||
self._budget.remaining -= len(chunk)
|
||||
if self._budget.remaining < 0:
|
||||
raise _ImageTooLargeError()
|
||||
return chunk
|
||||
|
||||
|
||||
class ImageScanFile(BaseModel):
|
||||
"""A single file extracted from an image layer for secret scanning."""
|
||||
|
||||
path: str
|
||||
layer_digest: str
|
||||
content: str
|
||||
|
||||
|
||||
class ImageScanData(BaseModel):
|
||||
"""An image's scannable content: config env/history and layer files."""
|
||||
|
||||
env: list[str] = []
|
||||
history: list[str] = []
|
||||
files: list[ImageScanFile] = []
|
||||
# True when part of the image was not scanned -- a layer/file exceeded a
|
||||
# configured size or count limit, or the config blob could not be
|
||||
# retrieved/parsed -- so a clean result can be reported as MANUAL
|
||||
# (coverage incomplete) rather than a false PASS.
|
||||
truncated: bool = False
|
||||
|
||||
|
||||
class ImageInspector:
|
||||
"""Bounded, opt-in extraction of an ECR image's scannable content.
|
||||
|
||||
Given a boto3 ECR client and an image digest, resolves the image's
|
||||
manifest (handling multi-arch manifest lists and skipping attestation
|
||||
manifests) and returns its configuration (environment variables, build
|
||||
history) and every filesystem layer's file contents, subject to this
|
||||
module's size and count limits.
|
||||
|
||||
This is deliberately isolated from the ECR service so a future check can
|
||||
reuse the bounded extraction without the service downloading and
|
||||
decompressing image layers by default: the service only pays this cost
|
||||
when a check explicitly drives the inspector.
|
||||
"""
|
||||
|
||||
def fetch_image_scan_data(
|
||||
self, client, registry_id, repository_name, image_digest
|
||||
) -> Optional[ImageScanData]:
|
||||
"""Resolve one image's manifest and return its scannable content.
|
||||
|
||||
Downloads the config blob (environment variables, build history)
|
||||
and every filesystem layer's file contents, bounded by the module's
|
||||
size/count limits.
|
||||
|
||||
Returns:
|
||||
An ImageScanData, or None if the manifest could not be resolved.
|
||||
"""
|
||||
manifest, truncated = self._resolve_image_manifest(
|
||||
client, registry_id, repository_name, image_digest
|
||||
)
|
||||
if manifest is None:
|
||||
return None
|
||||
|
||||
env = []
|
||||
history = []
|
||||
|
||||
config_digest = (manifest.get("config") or {}).get("digest")
|
||||
if config_digest:
|
||||
config_bytes = self._download_layer(
|
||||
client,
|
||||
registry_id,
|
||||
repository_name,
|
||||
config_digest,
|
||||
max_bytes=MAX_FILE_BYTES,
|
||||
)
|
||||
if config_bytes is None:
|
||||
# The config blob (env vars, build history) could not be
|
||||
# retrieved. Empty env/history would be indistinguishable
|
||||
# from a clean config, so mark coverage incomplete instead
|
||||
# of risking a false PASS.
|
||||
truncated = True
|
||||
else:
|
||||
try:
|
||||
config_json = loads(config_bytes)
|
||||
env = config_json.get("config", {}).get("Env", []) or []
|
||||
history = [
|
||||
step.get("created_by", "")
|
||||
for step in config_json.get("history", [])
|
||||
if step.get("created_by")
|
||||
]
|
||||
except Exception as error:
|
||||
logger.warning(
|
||||
f"{repository_name} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
|
||||
)
|
||||
truncated = True
|
||||
|
||||
files = []
|
||||
decompressed_budget = _DecompressedByteBudget(MAX_TOTAL_BYTES_PER_IMAGE)
|
||||
for layer in manifest.get("layers", []):
|
||||
if len(files) >= MAX_FILES_PER_IMAGE or decompressed_budget.remaining <= 0:
|
||||
truncated = True
|
||||
break
|
||||
|
||||
layer_digest = layer.get("digest")
|
||||
layer_size = layer.get("size", 0)
|
||||
if layer_size and layer_size > MAX_LAYER_DOWNLOAD_BYTES:
|
||||
truncated = True
|
||||
continue
|
||||
|
||||
try:
|
||||
with self._open_layer_tar_stream(
|
||||
client,
|
||||
registry_id,
|
||||
repository_name,
|
||||
layer_digest,
|
||||
layer.get("mediaType", ""),
|
||||
decompressed_budget,
|
||||
) as tar_stream:
|
||||
if tar_stream is None:
|
||||
truncated = True
|
||||
continue
|
||||
for member in tar_stream:
|
||||
if len(files) >= MAX_FILES_PER_IMAGE:
|
||||
truncated = True
|
||||
break
|
||||
if not member.isfile():
|
||||
continue
|
||||
base_name = member.name.rsplit("/", 1)[-1]
|
||||
if base_name.startswith(".wh."):
|
||||
# Whiteout marker: a deletion recorded by the union
|
||||
# filesystem, not real file content.
|
||||
continue
|
||||
if member.size > MAX_FILE_BYTES:
|
||||
truncated = True
|
||||
continue
|
||||
try:
|
||||
content = (
|
||||
tar_stream.extractfile(member).read().decode("latin-1")
|
||||
)
|
||||
except _LayerTooLargeError:
|
||||
# Over-cap while reading this member: truncate the
|
||||
# whole layer rather than silently skipping one file.
|
||||
raise
|
||||
except Exception:
|
||||
continue
|
||||
files.append(
|
||||
ImageScanFile(
|
||||
path=member.name,
|
||||
layer_digest=layer_digest,
|
||||
content=content,
|
||||
)
|
||||
)
|
||||
except _LayerTooLargeError:
|
||||
# The layer streamed more bytes than MAX_LAYER_DOWNLOAD_BYTES
|
||||
# (a manifest under-declaring its size); skip it and disclose
|
||||
# the partial coverage rather than buffer unbounded data.
|
||||
truncated = True
|
||||
continue
|
||||
except _ImageTooLargeError:
|
||||
truncated = True
|
||||
break
|
||||
|
||||
return ImageScanData(env=env, history=history, files=files, truncated=truncated)
|
||||
|
||||
def _resolve_image_manifest(
|
||||
self, client, registry_id, repository_name, image_digest
|
||||
) -> tuple[Optional[dict], bool]:
|
||||
"""Resolve an image digest to a single scannable image manifest.
|
||||
|
||||
Multi-arch images are stored as a manifest list/image index pointing
|
||||
at one manifest per platform (plus, often, an attestation manifest
|
||||
that isn't a real image). This picks one real platform manifest to
|
||||
scan; the other architectures in the same list are not scanned.
|
||||
"""
|
||||
try:
|
||||
manifest, media_type = self._batch_get_manifest(
|
||||
client, registry_id, repository_name, image_digest
|
||||
)
|
||||
if manifest is None:
|
||||
return None, False
|
||||
|
||||
truncated = False
|
||||
if media_type in _MANIFEST_LIST_MEDIA_TYPES:
|
||||
truncated = True
|
||||
child_digest = self._select_child_manifest_digest(manifest)
|
||||
if not child_digest:
|
||||
return None, truncated
|
||||
manifest, _ = self._batch_get_manifest(
|
||||
client, registry_id, repository_name, child_digest
|
||||
)
|
||||
if manifest is not None and not (
|
||||
manifest.get("config") or manifest.get("layers")
|
||||
):
|
||||
# A resolved manifest with neither a config nor layers has
|
||||
# nothing to scan (e.g. a nested manifest list, or an
|
||||
# unsupported manifest shape) -- treat it as unresolvable so
|
||||
# the caller reports MANUAL instead of a false PASS.
|
||||
return None, truncated
|
||||
return manifest, truncated
|
||||
except Exception as error:
|
||||
logger.error(
|
||||
f"{client.meta.region_name} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
|
||||
)
|
||||
return None, False
|
||||
|
||||
@staticmethod
|
||||
def _batch_get_manifest(client, registry_id, repository_name, image_digest):
|
||||
"""Fetch and parse the raw manifest JSON for a single image digest.
|
||||
|
||||
Returns:
|
||||
A (manifest, media_type) tuple, or (None, None) if not found.
|
||||
"""
|
||||
response = client.batch_get_image(
|
||||
registryId=registry_id,
|
||||
repositoryName=repository_name,
|
||||
imageIds=[{"imageDigest": image_digest}],
|
||||
)
|
||||
images = response.get("images", [])
|
||||
if not images:
|
||||
return None, None
|
||||
manifest = loads(images[0]["imageManifest"])
|
||||
media_type = manifest.get("mediaType") or images[0].get(
|
||||
"imageManifestMediaType"
|
||||
)
|
||||
return manifest, media_type
|
||||
|
||||
@staticmethod
|
||||
def _select_child_manifest_digest(manifest_list: dict) -> Optional[str]:
|
||||
"""Pick one real platform manifest's digest from a manifest list.
|
||||
|
||||
Prefers linux/amd64, falling back to the first remaining candidate
|
||||
once attestation manifests (platform "unknown/unknown", or
|
||||
annotated as an attestation manifest) are excluded.
|
||||
|
||||
Returns:
|
||||
The chosen manifest's digest, or None if no candidate remains.
|
||||
"""
|
||||
candidates = []
|
||||
for entry in manifest_list.get("manifests", []):
|
||||
platform = entry.get("platform", {}) or {}
|
||||
annotations = entry.get("annotations", {}) or {}
|
||||
if (
|
||||
platform.get("architecture") == "unknown"
|
||||
or platform.get("os") == "unknown"
|
||||
):
|
||||
# Attestation manifests (SBOMs, provenance, signatures) are
|
||||
# attached to the index as "unknown/unknown" platform entries.
|
||||
continue
|
||||
if annotations.get("vnd.docker.reference.type") == "attestation-manifest":
|
||||
continue
|
||||
candidates.append(entry)
|
||||
|
||||
for entry in candidates:
|
||||
platform = entry.get("platform", {}) or {}
|
||||
if (
|
||||
platform.get("architecture") == "amd64"
|
||||
and platform.get("os") == "linux"
|
||||
):
|
||||
return entry.get("digest")
|
||||
|
||||
return candidates[0].get("digest") if candidates else None
|
||||
|
||||
@staticmethod
|
||||
def _get_layer_download_url(
|
||||
client, registry_id, repository_name, layer_digest
|
||||
) -> Optional[str]:
|
||||
"""Resolve the presigned download URL for one layer or config blob.
|
||||
|
||||
Returns:
|
||||
The presigned URL, or None if ECR did not return one.
|
||||
"""
|
||||
response = client.get_download_url_for_layer(
|
||||
registryId=registry_id,
|
||||
repositoryName=repository_name,
|
||||
layerDigest=layer_digest,
|
||||
)
|
||||
return response.get("downloadUrl")
|
||||
|
||||
@staticmethod
|
||||
def _download_layer(
|
||||
client, registry_id, repository_name, layer_digest, max_bytes=None
|
||||
) -> Optional[bytes]:
|
||||
"""Download one layer or config blob via its presigned URL.
|
||||
|
||||
Streams the response, aborting once `max_bytes` is exceeded, so a
|
||||
lying or oversized blob is never buffered in full. Used for the config
|
||||
blob and for zstd layers (which cannot be streamed into tarfile);
|
||||
gzip/uncompressed layers are streamed by `_open_layer_tar_stream`.
|
||||
|
||||
Returns:
|
||||
The blob's bytes, or None if it could not be downloaded or
|
||||
exceeded `max_bytes`.
|
||||
"""
|
||||
try:
|
||||
download_url = ImageInspector._get_layer_download_url(
|
||||
client, registry_id, repository_name, layer_digest
|
||||
)
|
||||
if not download_url:
|
||||
return None
|
||||
|
||||
downloaded = bytearray()
|
||||
with requests.get(
|
||||
download_url,
|
||||
stream=True,
|
||||
timeout=LAYER_DOWNLOAD_TIMEOUT_SECONDS,
|
||||
allow_redirects=False,
|
||||
) as http_response:
|
||||
http_response.raise_for_status()
|
||||
for chunk in http_response.iter_content(chunk_size=1024 * 1024):
|
||||
downloaded.extend(chunk)
|
||||
if max_bytes and len(downloaded) > max_bytes:
|
||||
return None
|
||||
return bytes(downloaded)
|
||||
except Exception as error:
|
||||
logger.warning(
|
||||
f"{repository_name} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
|
||||
)
|
||||
return None
|
||||
|
||||
@contextmanager
|
||||
def _open_layer_tar_stream(
|
||||
self,
|
||||
client,
|
||||
registry_id,
|
||||
repository_name,
|
||||
layer_digest,
|
||||
media_type: str,
|
||||
decompressed_budget: _DecompressedByteBudget,
|
||||
):
|
||||
"""Yield an open TarFile for one layer, streamed from the download.
|
||||
|
||||
gzip, zstd, and uncompressed tar layers are all streamed straight from
|
||||
the download into `tarfile` (streaming mode reads a file-like object
|
||||
sequentially), so neither the compressed blob nor a decompressed copy is
|
||||
ever buffered in full. A `_CappedLayerReader` enforces
|
||||
`MAX_LAYER_DOWNLOAD_BYTES` on the compressed bytes (guarding a manifest
|
||||
that under-declares the layer size); zstd is decompressed incrementally
|
||||
via `zstandard`'s streaming reader, so a crafted frame can no longer
|
||||
expand unbounded in memory, and the decompressed side is bounded by the
|
||||
caller's per-image budget as it iterates members.
|
||||
|
||||
Yields:
|
||||
An open TarFile, or None for an unrecognized media type or a
|
||||
download/decompression failure. Raises `_LayerTooLargeError` if a
|
||||
streamed layer's compressed bytes exceed `MAX_LAYER_DOWNLOAD_BYTES`.
|
||||
"""
|
||||
if media_type.endswith("gzip"):
|
||||
decompress = "gzip"
|
||||
elif media_type.endswith("zstd"):
|
||||
decompress = "zstd"
|
||||
elif media_type.endswith("tar"):
|
||||
decompress = None
|
||||
else:
|
||||
yield None
|
||||
return
|
||||
|
||||
# Only the setup (URL resolution, connection, tar-header parse) is
|
||||
# guarded here; a failure yields None. The `yield tar_stream` below is
|
||||
# kept out of this try so exceptions raised while the caller iterates
|
||||
# members (e.g. _LayerTooLargeError) propagate instead of triggering a
|
||||
# forbidden second yield.
|
||||
try:
|
||||
download_url = ImageInspector._get_layer_download_url(
|
||||
client, registry_id, repository_name, layer_digest
|
||||
)
|
||||
if not download_url:
|
||||
yield None
|
||||
return
|
||||
http_response = requests.get(
|
||||
download_url,
|
||||
stream=True,
|
||||
timeout=LAYER_DOWNLOAD_TIMEOUT_SECONDS,
|
||||
allow_redirects=False,
|
||||
)
|
||||
try:
|
||||
http_response.raise_for_status()
|
||||
# Cap the compressed bytes read from the network; for zstd,
|
||||
# decompress that capped stream incrementally so the decompressed
|
||||
# data is never materialized in full.
|
||||
source = _CappedLayerReader(http_response.raw, MAX_LAYER_DOWNLOAD_BYTES)
|
||||
if decompress == "gzip":
|
||||
source = gzip.GzipFile(fileobj=source)
|
||||
elif decompress == "zstd":
|
||||
source = zstandard.ZstdDecompressor().stream_reader(source)
|
||||
source = decompressed_budget.wrap(source)
|
||||
tar_stream = tarfile.open(fileobj=source, mode="r|")
|
||||
except (_LayerTooLargeError, _ImageTooLargeError):
|
||||
http_response.close()
|
||||
raise
|
||||
except Exception:
|
||||
http_response.close()
|
||||
raise
|
||||
except Exception as error:
|
||||
logger.warning(
|
||||
f"{repository_name} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
|
||||
)
|
||||
yield None
|
||||
return
|
||||
|
||||
with http_response, tar_stream:
|
||||
yield tar_stream
|
||||
@@ -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] = []
|
||||
|
||||
+2
@@ -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
|
||||
)
|
||||
|
||||
+2
@@ -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
|
||||
)
|
||||
|
||||
+2
@@ -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
|
||||
)
|
||||
|
||||
+2
@@ -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
|
||||
)
|
||||
|
||||
+2
@@ -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
|
||||
)
|
||||
|
||||
+5
-1
@@ -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.
|
||||
|
||||
+2
@@ -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
|
||||
)
|
||||
|
||||
+41
-8
@@ -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
-12
@@ -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",
|
||||
@@ -128,7 +132,8 @@ dependencies = [
|
||||
"huaweicloudsdkobs==3.1.204",
|
||||
"huaweicloudsdkrds==3.1.204",
|
||||
"huaweicloudsdkvpc==3.1.204",
|
||||
"huaweicloudsdkwaf==3.1.204"
|
||||
"huaweicloudsdkwaf==3.1.204",
|
||||
"zstandard==0.25.0"
|
||||
]
|
||||
description = "Prowler is an Open Source security tool to perform AWS, GCP and Azure security best practices assessments, audits, incident response, continuous monitoring, hardening and forensics readiness. It contains hundreds of controls covering CIS, NIST 800, NIST CSF, CISA, RBI, FedRAMP, PCI-DSS, GDPR, HIPAA, FFIEC, SOC2, GXP, AWS Well-Architected Framework Security Pillar, AWS Foundational Technical Review (FTR), ENS (Spanish National Security Scheme) and your custom security frameworks."
|
||||
license = "Apache-2.0"
|
||||
@@ -199,7 +204,7 @@ constraint-dependencies = [
|
||||
"alibabacloud-sas20181203==6.1.0",
|
||||
"alibabacloud-sts20150401==1.1.6",
|
||||
"alibabacloud-tea==0.4.3",
|
||||
"alibabacloud-tea-openapi==0.4.5",
|
||||
"alibabacloud-tea-openapi==0.4.6",
|
||||
"alibabacloud-tea-util==0.3.14",
|
||||
"alibabacloud-tea-xml==0.0.3",
|
||||
"alibabacloud-vpc20160428==6.13.0",
|
||||
@@ -300,7 +305,7 @@ constraint-dependencies = [
|
||||
"mock==5.2.0",
|
||||
"moto==5.1.11",
|
||||
"mpmath==1.3.0",
|
||||
"msal==1.36.0",
|
||||
"msal==1.37.0",
|
||||
"msal-extensions==1.3.1",
|
||||
"msgraph-core==1.3.8",
|
||||
"msrest==0.7.1",
|
||||
@@ -343,7 +348,7 @@ constraint-dependencies = [
|
||||
"pyjwt==2.13.0",
|
||||
"pylint==3.3.4",
|
||||
"pynacl==1.6.2",
|
||||
"pyopenssl==26.2.0",
|
||||
"pyopenssl==26.4.0",
|
||||
"pyparsing==3.3.2",
|
||||
"pytest==9.0.3",
|
||||
"pytest-cov==6.0.0",
|
||||
@@ -387,13 +392,9 @@ constraint-dependencies = [
|
||||
"xmltodict==1.0.4",
|
||||
"yarl==1.23.0",
|
||||
"zipp==3.23.1",
|
||||
"zstd==1.5.7.3"
|
||||
]
|
||||
override-dependencies = [
|
||||
"okta==3.4.2",
|
||||
# alibabacloud-tea-openapi 0.4.5 caps cryptography below 49 and is the latest release.
|
||||
"cryptography==50.0.0"
|
||||
"zstd==1.5.7.2"
|
||||
]
|
||||
override-dependencies = ["okta==3.4.2"]
|
||||
|
||||
[tool.vulture]
|
||||
# Suppress known false positives. The CI command only passes --exclude and
|
||||
|
||||
+837
@@ -0,0 +1,837 @@
|
||||
from datetime import datetime
|
||||
from unittest import mock
|
||||
|
||||
from prowler.providers.aws.services.ecr.ecr_service import (
|
||||
ImageDetails,
|
||||
Registry,
|
||||
Repository,
|
||||
)
|
||||
from prowler.providers.aws.services.ecr.image_inspection import (
|
||||
ImageScanData,
|
||||
ImageScanFile,
|
||||
)
|
||||
from tests.providers.aws.utils import (
|
||||
AWS_ACCOUNT_NUMBER,
|
||||
AWS_REGION_US_EAST_1,
|
||||
set_mocked_aws_provider,
|
||||
)
|
||||
|
||||
# A real JWT: Kingfisher detects this regardless of the surrounding key name
|
||||
# or format (env-style KEY=value, Dockerfile RUN step, or source file).
|
||||
SECRET_VALUE = (
|
||||
"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9"
|
||||
".eyJzdWIiOiIxMjM0NTY3ODkwIn0"
|
||||
".dozjgNryP4J3jVmNHl0w5N_XgL0n3I9PlFUP0THsR8U"
|
||||
)
|
||||
|
||||
|
||||
def create_repository(name="test-repo", region=AWS_REGION_US_EAST_1) -> Repository:
|
||||
"""Build a minimal ECR Repository fixture."""
|
||||
return Repository(
|
||||
name=name,
|
||||
arn=f"arn:aws:ecr:{region}:{AWS_ACCOUNT_NUMBER}:repository/{name}",
|
||||
region=region,
|
||||
scan_on_push=True,
|
||||
images_details=[],
|
||||
)
|
||||
|
||||
|
||||
def create_image(tag="latest", digest=None) -> ImageDetails:
|
||||
"""Build a minimal ImageDetails fixture."""
|
||||
return ImageDetails(
|
||||
latest_tag=tag,
|
||||
latest_digest=digest or f"sha256:{'0' * 64}",
|
||||
image_pushed_at=datetime.now(),
|
||||
scan_findings_status=None,
|
||||
scan_findings_severity_count=None,
|
||||
artifact_media_type="application/vnd.docker.container.image.v1+json",
|
||||
type="Docker",
|
||||
)
|
||||
|
||||
|
||||
def mock_image_scan_data(pairs):
|
||||
"""Build a fake _get_image_scan_data generator yielding the given pairs."""
|
||||
|
||||
def _generator():
|
||||
"""Yield each (repository, image, scan_data) pair once."""
|
||||
for entry in pairs:
|
||||
yield entry
|
||||
|
||||
return _generator
|
||||
|
||||
|
||||
class Test_ecr_repository_image_no_secrets:
|
||||
"""Tests for the ecr_repository_image_no_secrets check."""
|
||||
|
||||
def test_no_repositories(self):
|
||||
"""No repositories yields no findings."""
|
||||
ecr_client = mock.MagicMock()
|
||||
ecr_client.registries = {}
|
||||
ecr_client.audit_config = {
|
||||
"secrets_ignore_patterns": [],
|
||||
"secrets_validate": False,
|
||||
}
|
||||
ecr_client._get_image_scan_data = mock_image_scan_data([])
|
||||
|
||||
with (
|
||||
mock.patch(
|
||||
"prowler.providers.common.provider.Provider.get_global_provider",
|
||||
return_value=set_mocked_aws_provider(),
|
||||
),
|
||||
mock.patch(
|
||||
"prowler.providers.aws.services.ecr.ecr_repository_image_no_secrets.ecr_repository_image_no_secrets.ecr_client",
|
||||
new=ecr_client,
|
||||
),
|
||||
):
|
||||
from prowler.providers.aws.services.ecr.ecr_repository_image_no_secrets.ecr_repository_image_no_secrets import (
|
||||
ecr_repository_image_no_secrets,
|
||||
)
|
||||
|
||||
check = ecr_repository_image_no_secrets()
|
||||
result = check.execute()
|
||||
|
||||
assert len(result) == 0
|
||||
|
||||
def test_clean_image(self):
|
||||
"""An image with no secrets passes."""
|
||||
repository = create_repository()
|
||||
image = create_image()
|
||||
scan_data = ImageScanData(
|
||||
env=["PATH=/usr/bin"],
|
||||
history=["RUN echo hello"],
|
||||
files=[],
|
||||
truncated=False,
|
||||
)
|
||||
|
||||
ecr_client = mock.MagicMock()
|
||||
ecr_client.registries = {}
|
||||
ecr_client.audit_config = {
|
||||
"secrets_ignore_patterns": [],
|
||||
"secrets_validate": False,
|
||||
}
|
||||
ecr_client._get_image_scan_data = mock_image_scan_data(
|
||||
[(repository, image, scan_data)]
|
||||
)
|
||||
|
||||
with (
|
||||
mock.patch(
|
||||
"prowler.providers.common.provider.Provider.get_global_provider",
|
||||
return_value=set_mocked_aws_provider(),
|
||||
),
|
||||
mock.patch(
|
||||
"prowler.providers.aws.services.ecr.ecr_repository_image_no_secrets.ecr_repository_image_no_secrets.ecr_client",
|
||||
new=ecr_client,
|
||||
),
|
||||
):
|
||||
from prowler.providers.aws.services.ecr.ecr_repository_image_no_secrets.ecr_repository_image_no_secrets import (
|
||||
ecr_repository_image_no_secrets,
|
||||
)
|
||||
|
||||
check = ecr_repository_image_no_secrets()
|
||||
result = check.execute()
|
||||
|
||||
digest_short = image.latest_digest.split(":")[-1][:12]
|
||||
assert len(result) == 1
|
||||
assert result[0].status == "PASS"
|
||||
assert result[0].status_extended == (
|
||||
f"No secrets found in the image '{image.latest_tag}' "
|
||||
f"({image.latest_digest}) of ECR repository {repository.name}."
|
||||
)
|
||||
assert result[0].region == AWS_REGION_US_EAST_1
|
||||
assert (
|
||||
result[0].resource_id
|
||||
== f"{repository.name}:{image.latest_tag}@{digest_short}"
|
||||
)
|
||||
assert result[0].resource_arn == f"{repository.arn}/image/{digest_short}"
|
||||
|
||||
def test_truncated_image_reports_manual(self):
|
||||
"""A clean but truncated image is MANUAL, since part was not scanned."""
|
||||
repository = create_repository()
|
||||
image = create_image()
|
||||
scan_data = ImageScanData(env=[], history=[], files=[], truncated=True)
|
||||
|
||||
ecr_client = mock.MagicMock()
|
||||
ecr_client.registries = {}
|
||||
ecr_client.audit_config = {
|
||||
"secrets_ignore_patterns": [],
|
||||
"secrets_validate": False,
|
||||
}
|
||||
ecr_client._get_image_scan_data = mock_image_scan_data(
|
||||
[(repository, image, scan_data)]
|
||||
)
|
||||
|
||||
with (
|
||||
mock.patch(
|
||||
"prowler.providers.common.provider.Provider.get_global_provider",
|
||||
return_value=set_mocked_aws_provider(),
|
||||
),
|
||||
mock.patch(
|
||||
"prowler.providers.aws.services.ecr.ecr_repository_image_no_secrets.ecr_repository_image_no_secrets.ecr_client",
|
||||
new=ecr_client,
|
||||
),
|
||||
):
|
||||
from prowler.providers.aws.services.ecr.ecr_repository_image_no_secrets.ecr_repository_image_no_secrets import (
|
||||
ecr_repository_image_no_secrets,
|
||||
)
|
||||
|
||||
check = ecr_repository_image_no_secrets()
|
||||
result = check.execute()
|
||||
|
||||
assert len(result) == 1
|
||||
assert result[0].status == "MANUAL"
|
||||
assert (
|
||||
"part of it could not be retrieved or exceeded configured size "
|
||||
"limits and was not scanned" in result[0].status_extended
|
||||
)
|
||||
|
||||
def test_secret_in_environment_variable(self):
|
||||
"""A secret in an environment variable fails, naming the variable."""
|
||||
from prowler.lib.check.models import Severity
|
||||
|
||||
repository = create_repository()
|
||||
image = create_image()
|
||||
scan_data = ImageScanData(
|
||||
env=["PATH=/usr/bin", f"DB_PASSWORD={SECRET_VALUE}"],
|
||||
history=[],
|
||||
files=[],
|
||||
truncated=False,
|
||||
)
|
||||
|
||||
ecr_client = mock.MagicMock()
|
||||
ecr_client.registries = {}
|
||||
ecr_client.audit_config = {
|
||||
"secrets_ignore_patterns": [],
|
||||
"secrets_validate": False,
|
||||
}
|
||||
ecr_client._get_image_scan_data = mock_image_scan_data(
|
||||
[(repository, image, scan_data)]
|
||||
)
|
||||
|
||||
with (
|
||||
mock.patch(
|
||||
"prowler.providers.common.provider.Provider.get_global_provider",
|
||||
return_value=set_mocked_aws_provider(),
|
||||
),
|
||||
mock.patch(
|
||||
"prowler.providers.aws.services.ecr.ecr_repository_image_no_secrets.ecr_repository_image_no_secrets.ecr_client",
|
||||
new=ecr_client,
|
||||
),
|
||||
):
|
||||
from prowler.providers.aws.services.ecr.ecr_repository_image_no_secrets.ecr_repository_image_no_secrets import (
|
||||
ecr_repository_image_no_secrets,
|
||||
)
|
||||
|
||||
check = ecr_repository_image_no_secrets()
|
||||
result = check.execute()
|
||||
|
||||
assert len(result) == 1
|
||||
assert result[0].status == "FAIL"
|
||||
assert "environment variable DB_PASSWORD" in result[0].status_extended
|
||||
assert SECRET_VALUE not in result[0].status_extended
|
||||
assert result[0].check_metadata.Severity == Severity.high
|
||||
|
||||
def test_secret_in_malformed_env_entry_is_redacted(self):
|
||||
"""An env entry without '=' is reported generically, never echoed."""
|
||||
repository = create_repository()
|
||||
image = create_image()
|
||||
# The entry has no "=" so no variable name can be split out; the entry
|
||||
# itself is the secret and must not appear in the finding.
|
||||
scan_data = ImageScanData(
|
||||
env=[SECRET_VALUE],
|
||||
history=[],
|
||||
files=[],
|
||||
truncated=False,
|
||||
)
|
||||
|
||||
ecr_client = mock.MagicMock()
|
||||
ecr_client.registries = {}
|
||||
ecr_client.audit_config = {
|
||||
"secrets_ignore_patterns": [],
|
||||
"secrets_validate": False,
|
||||
}
|
||||
ecr_client._get_image_scan_data = mock_image_scan_data(
|
||||
[(repository, image, scan_data)]
|
||||
)
|
||||
|
||||
with (
|
||||
mock.patch(
|
||||
"prowler.providers.common.provider.Provider.get_global_provider",
|
||||
return_value=set_mocked_aws_provider(),
|
||||
),
|
||||
mock.patch(
|
||||
"prowler.providers.aws.services.ecr.ecr_repository_image_no_secrets.ecr_repository_image_no_secrets.ecr_client",
|
||||
new=ecr_client,
|
||||
),
|
||||
):
|
||||
from prowler.providers.aws.services.ecr.ecr_repository_image_no_secrets.ecr_repository_image_no_secrets import (
|
||||
ecr_repository_image_no_secrets,
|
||||
)
|
||||
|
||||
check = ecr_repository_image_no_secrets()
|
||||
result = check.execute()
|
||||
|
||||
assert len(result) == 1
|
||||
assert result[0].status == "FAIL"
|
||||
assert "in image environment variables" in result[0].status_extended
|
||||
assert SECRET_VALUE not in result[0].status_extended
|
||||
|
||||
def test_secret_in_unsafe_environment_name_is_redacted(self):
|
||||
"""An unsafe name before '=' is never included in report text."""
|
||||
repository = create_repository()
|
||||
image = create_image()
|
||||
scan_data = ImageScanData(
|
||||
env=[f"{SECRET_VALUE}=safe-value"],
|
||||
history=[],
|
||||
files=[],
|
||||
truncated=False,
|
||||
)
|
||||
|
||||
ecr_client = mock.MagicMock()
|
||||
ecr_client.registries = {}
|
||||
ecr_client.audit_config = {
|
||||
"secrets_ignore_patterns": [],
|
||||
"secrets_validate": False,
|
||||
}
|
||||
ecr_client._get_image_scan_data = mock_image_scan_data(
|
||||
[(repository, image, scan_data)]
|
||||
)
|
||||
|
||||
with (
|
||||
mock.patch(
|
||||
"prowler.providers.common.provider.Provider.get_global_provider",
|
||||
return_value=set_mocked_aws_provider(),
|
||||
),
|
||||
mock.patch(
|
||||
"prowler.providers.aws.services.ecr.ecr_repository_image_no_secrets.ecr_repository_image_no_secrets.ecr_client",
|
||||
new=ecr_client,
|
||||
),
|
||||
):
|
||||
from prowler.providers.aws.services.ecr.ecr_repository_image_no_secrets.ecr_repository_image_no_secrets import (
|
||||
ecr_repository_image_no_secrets,
|
||||
)
|
||||
|
||||
result = ecr_repository_image_no_secrets().execute()
|
||||
|
||||
assert len(result) == 1
|
||||
assert result[0].status == "FAIL"
|
||||
assert "in image environment variables" in result[0].status_extended
|
||||
assert SECRET_VALUE not in str(vars(result[0]))
|
||||
|
||||
def test_scanned_file_content_is_freed_after_execute(self):
|
||||
"""File contents are released after scanning so memory stays flat."""
|
||||
repository = create_repository()
|
||||
image = create_image()
|
||||
scanned_file = ImageScanFile(
|
||||
path="app/config.py",
|
||||
layer_digest=f"sha256:{'a' * 64}",
|
||||
content="nothing secret here",
|
||||
)
|
||||
scan_data = ImageScanData(
|
||||
env=[], history=[], files=[scanned_file], truncated=False
|
||||
)
|
||||
|
||||
ecr_client = mock.MagicMock()
|
||||
ecr_client.registries = {}
|
||||
ecr_client.audit_config = {
|
||||
"secrets_ignore_patterns": [],
|
||||
"secrets_validate": False,
|
||||
}
|
||||
ecr_client._get_image_scan_data = mock_image_scan_data(
|
||||
[(repository, image, scan_data)]
|
||||
)
|
||||
|
||||
with (
|
||||
mock.patch(
|
||||
"prowler.providers.common.provider.Provider.get_global_provider",
|
||||
return_value=set_mocked_aws_provider(),
|
||||
),
|
||||
mock.patch(
|
||||
"prowler.providers.aws.services.ecr.ecr_repository_image_no_secrets.ecr_repository_image_no_secrets.ecr_client",
|
||||
new=ecr_client,
|
||||
),
|
||||
):
|
||||
from prowler.providers.aws.services.ecr.ecr_repository_image_no_secrets.ecr_repository_image_no_secrets import (
|
||||
ecr_repository_image_no_secrets,
|
||||
)
|
||||
|
||||
check = ecr_repository_image_no_secrets()
|
||||
result = check.execute()
|
||||
|
||||
assert len(result) == 1
|
||||
assert result[0].status == "PASS"
|
||||
# The check empties each file's content once it is handed to the
|
||||
# scanner; only path and layer digest are needed thereafter.
|
||||
assert scanned_file.content == ""
|
||||
|
||||
def test_secrets_ignore_patterns_suppresses_finding(self):
|
||||
"""A secret matching an ignore pattern is suppressed."""
|
||||
repository = create_repository()
|
||||
image = create_image()
|
||||
scan_data = ImageScanData(
|
||||
env=["PATH=/usr/bin", f"DB_PASSWORD={SECRET_VALUE}"],
|
||||
history=[],
|
||||
files=[],
|
||||
truncated=False,
|
||||
)
|
||||
|
||||
ecr_client = mock.MagicMock()
|
||||
ecr_client.registries = {}
|
||||
ecr_client.audit_config = {
|
||||
"secrets_ignore_patterns": [SECRET_VALUE],
|
||||
"secrets_validate": False,
|
||||
}
|
||||
ecr_client._get_image_scan_data = mock_image_scan_data(
|
||||
[(repository, image, scan_data)]
|
||||
)
|
||||
|
||||
with (
|
||||
mock.patch(
|
||||
"prowler.providers.common.provider.Provider.get_global_provider",
|
||||
return_value=set_mocked_aws_provider(),
|
||||
),
|
||||
mock.patch(
|
||||
"prowler.providers.aws.services.ecr.ecr_repository_image_no_secrets.ecr_repository_image_no_secrets.ecr_client",
|
||||
new=ecr_client,
|
||||
),
|
||||
):
|
||||
from prowler.providers.aws.services.ecr.ecr_repository_image_no_secrets.ecr_repository_image_no_secrets import (
|
||||
ecr_repository_image_no_secrets,
|
||||
)
|
||||
|
||||
check = ecr_repository_image_no_secrets()
|
||||
result = check.execute()
|
||||
|
||||
assert len(result) == 1
|
||||
assert result[0].status == "PASS"
|
||||
|
||||
def test_secret_in_build_history(self):
|
||||
"""A secret in a build history step fails, naming the step."""
|
||||
repository = create_repository()
|
||||
image = create_image()
|
||||
scan_data = ImageScanData(
|
||||
env=[],
|
||||
history=["RUN apt-get update", f'RUN export TOKEN="{SECRET_VALUE}"'],
|
||||
files=[],
|
||||
truncated=False,
|
||||
)
|
||||
|
||||
ecr_client = mock.MagicMock()
|
||||
ecr_client.registries = {}
|
||||
ecr_client.audit_config = {
|
||||
"secrets_ignore_patterns": [],
|
||||
"secrets_validate": False,
|
||||
}
|
||||
ecr_client._get_image_scan_data = mock_image_scan_data(
|
||||
[(repository, image, scan_data)]
|
||||
)
|
||||
|
||||
with (
|
||||
mock.patch(
|
||||
"prowler.providers.common.provider.Provider.get_global_provider",
|
||||
return_value=set_mocked_aws_provider(),
|
||||
),
|
||||
mock.patch(
|
||||
"prowler.providers.aws.services.ecr.ecr_repository_image_no_secrets.ecr_repository_image_no_secrets.ecr_client",
|
||||
new=ecr_client,
|
||||
),
|
||||
):
|
||||
from prowler.providers.aws.services.ecr.ecr_repository_image_no_secrets.ecr_repository_image_no_secrets import (
|
||||
ecr_repository_image_no_secrets,
|
||||
)
|
||||
|
||||
check = ecr_repository_image_no_secrets()
|
||||
result = check.execute()
|
||||
|
||||
assert len(result) == 1
|
||||
assert result[0].status == "FAIL"
|
||||
assert "image history step 2" in result[0].status_extended
|
||||
assert SECRET_VALUE not in result[0].status_extended
|
||||
|
||||
def test_multiline_environment_secret_keeps_entry_attribution(self):
|
||||
"""Embedded newlines do not shift an env finding to another entry."""
|
||||
repository = create_repository()
|
||||
image = create_image()
|
||||
scan_data = ImageScanData(
|
||||
env=[f"MULTILINE=prefix\r\n{SECRET_VALUE}", "WRONG=value"],
|
||||
history=[],
|
||||
files=[],
|
||||
truncated=False,
|
||||
)
|
||||
ecr_client = mock.MagicMock()
|
||||
ecr_client.registries = {}
|
||||
ecr_client.audit_config = {
|
||||
"secrets_ignore_patterns": [],
|
||||
"secrets_validate": False,
|
||||
}
|
||||
ecr_client._get_image_scan_data = mock_image_scan_data(
|
||||
[(repository, image, scan_data)]
|
||||
)
|
||||
|
||||
with (
|
||||
mock.patch(
|
||||
"prowler.providers.common.provider.Provider.get_global_provider",
|
||||
return_value=set_mocked_aws_provider(),
|
||||
),
|
||||
mock.patch(
|
||||
"prowler.providers.aws.services.ecr.ecr_repository_image_no_secrets.ecr_repository_image_no_secrets.ecr_client",
|
||||
new=ecr_client,
|
||||
),
|
||||
):
|
||||
from prowler.providers.aws.services.ecr.ecr_repository_image_no_secrets.ecr_repository_image_no_secrets import (
|
||||
ecr_repository_image_no_secrets,
|
||||
)
|
||||
|
||||
result = ecr_repository_image_no_secrets().execute()
|
||||
|
||||
assert "environment variable MULTILINE" in result[0].status_extended
|
||||
assert "environment variable WRONG" not in result[0].status_extended
|
||||
|
||||
def test_multiline_history_secret_keeps_step_attribution(self):
|
||||
"""Embedded newlines do not shift a history finding to another step."""
|
||||
repository = create_repository()
|
||||
image = create_image()
|
||||
scan_data = ImageScanData(
|
||||
env=[],
|
||||
history=[f"RUN first\nexport TOKEN={SECRET_VALUE}", "RUN second"],
|
||||
files=[],
|
||||
truncated=False,
|
||||
)
|
||||
ecr_client = mock.MagicMock()
|
||||
ecr_client.registries = {}
|
||||
ecr_client.audit_config = {
|
||||
"secrets_ignore_patterns": [],
|
||||
"secrets_validate": False,
|
||||
}
|
||||
ecr_client._get_image_scan_data = mock_image_scan_data(
|
||||
[(repository, image, scan_data)]
|
||||
)
|
||||
|
||||
with (
|
||||
mock.patch(
|
||||
"prowler.providers.common.provider.Provider.get_global_provider",
|
||||
return_value=set_mocked_aws_provider(),
|
||||
),
|
||||
mock.patch(
|
||||
"prowler.providers.aws.services.ecr.ecr_repository_image_no_secrets.ecr_repository_image_no_secrets.ecr_client",
|
||||
new=ecr_client,
|
||||
),
|
||||
):
|
||||
from prowler.providers.aws.services.ecr.ecr_repository_image_no_secrets.ecr_repository_image_no_secrets import (
|
||||
ecr_repository_image_no_secrets,
|
||||
)
|
||||
|
||||
result = ecr_repository_image_no_secrets().execute()
|
||||
|
||||
assert "image history step 1" in result[0].status_extended
|
||||
assert "image history step 2" not in result[0].status_extended
|
||||
|
||||
def test_secret_in_layer_file(self):
|
||||
"""A secret in a layer file fails, naming the file and layer."""
|
||||
repository = create_repository()
|
||||
image = create_image()
|
||||
layer_digest = f"sha256:{'a' * 64}"
|
||||
scan_data = ImageScanData(
|
||||
env=[],
|
||||
history=[],
|
||||
files=[
|
||||
ImageScanFile(
|
||||
path="app/config.py",
|
||||
layer_digest=layer_digest,
|
||||
content=f'TOKEN = "{SECRET_VALUE}"',
|
||||
)
|
||||
],
|
||||
truncated=False,
|
||||
)
|
||||
|
||||
ecr_client = mock.MagicMock()
|
||||
ecr_client.registries = {}
|
||||
ecr_client.audit_config = {
|
||||
"secrets_ignore_patterns": [],
|
||||
"secrets_validate": False,
|
||||
}
|
||||
ecr_client._get_image_scan_data = mock_image_scan_data(
|
||||
[(repository, image, scan_data)]
|
||||
)
|
||||
|
||||
with (
|
||||
mock.patch(
|
||||
"prowler.providers.common.provider.Provider.get_global_provider",
|
||||
return_value=set_mocked_aws_provider(),
|
||||
),
|
||||
mock.patch(
|
||||
"prowler.providers.aws.services.ecr.ecr_repository_image_no_secrets.ecr_repository_image_no_secrets.ecr_client",
|
||||
new=ecr_client,
|
||||
),
|
||||
):
|
||||
from prowler.providers.aws.services.ecr.ecr_repository_image_no_secrets.ecr_repository_image_no_secrets import (
|
||||
ecr_repository_image_no_secrets,
|
||||
)
|
||||
|
||||
check = ecr_repository_image_no_secrets()
|
||||
result = check.execute()
|
||||
|
||||
assert len(result) == 1
|
||||
assert result[0].status == "FAIL"
|
||||
assert "file app/config.py" in result[0].status_extended
|
||||
assert layer_digest in result[0].status_extended
|
||||
assert SECRET_VALUE not in result[0].status_extended
|
||||
|
||||
def test_manifest_unresolvable(self):
|
||||
"""An unresolvable manifest is reported as MANUAL."""
|
||||
repository = create_repository()
|
||||
image = create_image()
|
||||
|
||||
ecr_client = mock.MagicMock()
|
||||
ecr_client.registries = {}
|
||||
ecr_client.audit_config = {
|
||||
"secrets_ignore_patterns": [],
|
||||
"secrets_validate": False,
|
||||
}
|
||||
ecr_client._get_image_scan_data = mock_image_scan_data(
|
||||
[(repository, image, None)]
|
||||
)
|
||||
|
||||
with (
|
||||
mock.patch(
|
||||
"prowler.providers.common.provider.Provider.get_global_provider",
|
||||
return_value=set_mocked_aws_provider(),
|
||||
),
|
||||
mock.patch(
|
||||
"prowler.providers.aws.services.ecr.ecr_repository_image_no_secrets.ecr_repository_image_no_secrets.ecr_client",
|
||||
new=ecr_client,
|
||||
),
|
||||
):
|
||||
from prowler.providers.aws.services.ecr.ecr_repository_image_no_secrets.ecr_repository_image_no_secrets import (
|
||||
ecr_repository_image_no_secrets,
|
||||
)
|
||||
|
||||
check = ecr_repository_image_no_secrets()
|
||||
result = check.execute()
|
||||
|
||||
assert len(result) == 1
|
||||
assert result[0].status == "MANUAL"
|
||||
assert (
|
||||
"Could not resolve or retrieve the manifest"
|
||||
in result[0].status_extended
|
||||
)
|
||||
|
||||
def test_latest_image_lookup_error_reports_repository_manual(self):
|
||||
"""A failed authoritative image lookup is reported for the repository."""
|
||||
repository = create_repository()
|
||||
lookup_error = RuntimeError("authoritative lookup failed")
|
||||
ecr_client = mock.MagicMock()
|
||||
ecr_client.audit_config = {
|
||||
"secrets_ignore_patterns": [],
|
||||
"secrets_validate": False,
|
||||
}
|
||||
ecr_client._get_image_scan_data = mock_image_scan_data(
|
||||
[(repository, None, lookup_error)]
|
||||
)
|
||||
|
||||
with (
|
||||
mock.patch(
|
||||
"prowler.providers.common.provider.Provider.get_global_provider",
|
||||
return_value=set_mocked_aws_provider(),
|
||||
),
|
||||
mock.patch(
|
||||
"prowler.providers.aws.services.ecr.ecr_repository_image_no_secrets.ecr_repository_image_no_secrets.ecr_client",
|
||||
new=ecr_client,
|
||||
),
|
||||
):
|
||||
from prowler.providers.aws.services.ecr.ecr_repository_image_no_secrets.ecr_repository_image_no_secrets import (
|
||||
ecr_repository_image_no_secrets,
|
||||
)
|
||||
|
||||
result = ecr_repository_image_no_secrets().execute()
|
||||
|
||||
assert result[0].status == "MANUAL"
|
||||
assert "Could not determine the latest image" in result[0].status_extended
|
||||
|
||||
def test_scan_error_reports_manual_for_latest_image_per_repository(self):
|
||||
"""A scanner failure reports MANUAL once per repository's latest image."""
|
||||
from prowler.lib.utils.utils import SecretsScanError
|
||||
|
||||
# Each repository has multiple images; the scan-error fallback must
|
||||
# scope to the latest image per repository only, mirroring the
|
||||
# success-path scope, not emit one MANUAL per image.
|
||||
repo1 = create_repository(name="repo-1")
|
||||
repo1.images_details = [
|
||||
create_image(tag="v1", digest=f"sha256:{'1' * 64}"),
|
||||
create_image(tag="v2", digest=f"sha256:{'2' * 64}"),
|
||||
]
|
||||
repo2 = create_repository(name="repo-2")
|
||||
repo2.images_details = [
|
||||
create_image(tag="v1", digest=f"sha256:{'3' * 64}"),
|
||||
create_image(tag="v2", digest=f"sha256:{'4' * 64}"),
|
||||
]
|
||||
registry = Registry(
|
||||
id=AWS_ACCOUNT_NUMBER,
|
||||
arn=f"arn:aws:ecr:{AWS_REGION_US_EAST_1}:{AWS_ACCOUNT_NUMBER}:registry/{AWS_ACCOUNT_NUMBER}",
|
||||
region=AWS_REGION_US_EAST_1,
|
||||
repositories=[repo1, repo2],
|
||||
)
|
||||
|
||||
ecr_client = mock.MagicMock()
|
||||
ecr_client.registries = {AWS_REGION_US_EAST_1: registry}
|
||||
ecr_client.audit_config = {
|
||||
"secrets_ignore_patterns": [],
|
||||
"secrets_validate": False,
|
||||
}
|
||||
# Not consumed on this path, but must be a real generator to iterate.
|
||||
ecr_client._get_image_scan_data = mock_image_scan_data([])
|
||||
# The error fallback resolves each repository's scan target via
|
||||
# _get_scan_target_image; mirror the real method's latest-image scope.
|
||||
ecr_client._get_scan_target_image.side_effect = lambda repository: (
|
||||
repository.images_details[-1] if repository.images_details else None
|
||||
)
|
||||
|
||||
with (
|
||||
mock.patch(
|
||||
"prowler.providers.common.provider.Provider.get_global_provider",
|
||||
return_value=set_mocked_aws_provider(),
|
||||
),
|
||||
mock.patch(
|
||||
"prowler.providers.aws.services.ecr.ecr_repository_image_no_secrets.ecr_repository_image_no_secrets.ecr_client",
|
||||
new=ecr_client,
|
||||
),
|
||||
mock.patch(
|
||||
"prowler.providers.aws.services.ecr.ecr_repository_image_no_secrets.ecr_repository_image_no_secrets.detect_secrets_scan_batch",
|
||||
side_effect=SecretsScanError("Scanner failure"),
|
||||
),
|
||||
):
|
||||
from prowler.providers.aws.services.ecr.ecr_repository_image_no_secrets.ecr_repository_image_no_secrets import (
|
||||
ecr_repository_image_no_secrets,
|
||||
)
|
||||
|
||||
check = ecr_repository_image_no_secrets()
|
||||
result = check.execute()
|
||||
|
||||
# One MANUAL per repository (its latest image), not one per image.
|
||||
assert len(result) == 2
|
||||
for report in result:
|
||||
assert report.status == "MANUAL"
|
||||
assert "Could not scan image" in report.status_extended
|
||||
assert "Scanner failure" in report.status_extended
|
||||
|
||||
digests_reported = {report.resource_id.split("@")[-1] for report in result}
|
||||
latest_digest_repo1 = repo1.images_details[-1].latest_digest.split(":")[-1][
|
||||
:12
|
||||
]
|
||||
latest_digest_repo2 = repo2.images_details[-1].latest_digest.split(":")[-1][
|
||||
:12
|
||||
]
|
||||
assert digests_reported == {latest_digest_repo1, latest_digest_repo2}
|
||||
assert "Scanner failure" in result[0].status_extended
|
||||
|
||||
def test_verified_secret_escalates_to_critical(self):
|
||||
"""A verified secret escalates severity to critical."""
|
||||
from prowler.lib.check.models import Severity
|
||||
|
||||
repository = create_repository()
|
||||
image = create_image()
|
||||
scan_data = ImageScanData(
|
||||
env=[f"TOKEN={SECRET_VALUE}"], history=[], files=[], truncated=False
|
||||
)
|
||||
|
||||
ecr_client = mock.MagicMock()
|
||||
ecr_client.registries = {}
|
||||
ecr_client.audit_config = {
|
||||
"secrets_ignore_patterns": [],
|
||||
"secrets_validate": True,
|
||||
}
|
||||
ecr_client._get_image_scan_data = mock_image_scan_data(
|
||||
[(repository, image, scan_data)]
|
||||
)
|
||||
|
||||
def fake_scan_batch(payloads, **kwargs):
|
||||
# The real detect_secrets_scan_batch consumes the lazily-yielded
|
||||
# payloads generator as a side effect (that's what populates the
|
||||
# check's `scanned` list); replicate that here while returning
|
||||
# a controlled, pre-verified finding.
|
||||
"""Drain the payload generator like the real scanner, then return canned findings."""
|
||||
list(payloads)
|
||||
return {
|
||||
(0, "environment:0"): [
|
||||
{
|
||||
"type": "JSON Web Token (base64url-encoded)",
|
||||
"line_number": 1,
|
||||
"is_verified": True,
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
with (
|
||||
mock.patch(
|
||||
"prowler.providers.common.provider.Provider.get_global_provider",
|
||||
return_value=set_mocked_aws_provider(),
|
||||
),
|
||||
mock.patch(
|
||||
"prowler.providers.aws.services.ecr.ecr_repository_image_no_secrets.ecr_repository_image_no_secrets.ecr_client",
|
||||
new=ecr_client,
|
||||
),
|
||||
mock.patch(
|
||||
"prowler.providers.aws.services.ecr.ecr_repository_image_no_secrets.ecr_repository_image_no_secrets.detect_secrets_scan_batch",
|
||||
side_effect=fake_scan_batch,
|
||||
) as mock_scan,
|
||||
):
|
||||
from prowler.providers.aws.services.ecr.ecr_repository_image_no_secrets.ecr_repository_image_no_secrets import (
|
||||
ecr_repository_image_no_secrets,
|
||||
)
|
||||
|
||||
check = ecr_repository_image_no_secrets()
|
||||
result = check.execute()
|
||||
|
||||
assert mock_scan.call_args.kwargs.get("validate") is True
|
||||
assert len(result) == 1
|
||||
assert result[0].status == "FAIL"
|
||||
assert result[0].check_metadata.Severity == Severity.critical
|
||||
assert "confirmed to be live" in result[0].status_extended
|
||||
|
||||
def test_multiple_repositories_and_images(self):
|
||||
"""Mixed pass/fail results are reported across multiple repositories."""
|
||||
repo1 = create_repository(name="repo-1")
|
||||
repo2 = create_repository(name="repo-2")
|
||||
image1 = create_image(tag="v1", digest=f"sha256:{'1' * 64}")
|
||||
image2 = create_image(tag="v2", digest=f"sha256:{'2' * 64}")
|
||||
|
||||
clean_scan = ImageScanData(env=[], history=[], files=[], truncated=False)
|
||||
fail_scan = ImageScanData(
|
||||
env=[f"DB_PASSWORD={SECRET_VALUE}"],
|
||||
history=[],
|
||||
files=[],
|
||||
truncated=False,
|
||||
)
|
||||
|
||||
ecr_client = mock.MagicMock()
|
||||
ecr_client.registries = {}
|
||||
ecr_client.audit_config = {
|
||||
"secrets_ignore_patterns": [],
|
||||
"secrets_validate": False,
|
||||
}
|
||||
ecr_client._get_image_scan_data = mock_image_scan_data(
|
||||
[(repo1, image1, clean_scan), (repo2, image2, fail_scan)]
|
||||
)
|
||||
|
||||
with (
|
||||
mock.patch(
|
||||
"prowler.providers.common.provider.Provider.get_global_provider",
|
||||
return_value=set_mocked_aws_provider(),
|
||||
),
|
||||
mock.patch(
|
||||
"prowler.providers.aws.services.ecr.ecr_repository_image_no_secrets.ecr_repository_image_no_secrets.ecr_client",
|
||||
new=ecr_client,
|
||||
),
|
||||
):
|
||||
from prowler.providers.aws.services.ecr.ecr_repository_image_no_secrets.ecr_repository_image_no_secrets import (
|
||||
ecr_repository_image_no_secrets,
|
||||
)
|
||||
|
||||
check = ecr_repository_image_no_secrets()
|
||||
result = check.execute()
|
||||
|
||||
assert len(result) == 2
|
||||
statuses_by_repo = {r.resource_id.split(":")[0]: r.status for r in result}
|
||||
assert statuses_by_repo["repo-1"] == "PASS"
|
||||
assert statuses_by_repo["repo-2"] == "FAIL"
|
||||
report_by_repo = {r.resource_id.split(":")[0]: r for r in result}
|
||||
assert SECRET_VALUE not in report_by_repo["repo-2"].status_extended
|
||||
@@ -1,11 +1,21 @@
|
||||
import json
|
||||
from concurrent.futures import Future
|
||||
from datetime import datetime
|
||||
from unittest.mock import patch
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import botocore
|
||||
import pytest
|
||||
from boto3 import client
|
||||
from moto import mock_aws
|
||||
|
||||
from prowler.providers.aws.services.ecr.ecr_service import ECR, ScanningRule
|
||||
from prowler.providers.aws.services.ecr.ecr_service import (
|
||||
ECR,
|
||||
ScanningRule,
|
||||
)
|
||||
from tests.providers.aws.services.ecr.image_scan_fixtures import (
|
||||
MANIFESTS_BY_DIGEST,
|
||||
reset_image_fixtures,
|
||||
)
|
||||
from tests.providers.aws.utils import (
|
||||
AWS_ACCOUNT_NUMBER,
|
||||
AWS_REGION_EU_WEST_1,
|
||||
@@ -18,8 +28,20 @@ repo_name = "test-repo"
|
||||
# Mocking Access Analyzer Calls
|
||||
make_api_call = botocore.client.BaseClient._make_api_call
|
||||
|
||||
# BatchGetImage / GetDownloadUrlForLayer fixtures (which moto does not
|
||||
# implement) live in image_scan_fixtures and are served by mock_make_api_call.
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _reset_image_fixtures():
|
||||
"""Isolate the BatchGetImage/GetDownloadUrlForLayer fixtures per test."""
|
||||
reset_image_fixtures()
|
||||
yield
|
||||
reset_image_fixtures()
|
||||
|
||||
|
||||
def mock_make_api_call(self, operation_name, kwarg):
|
||||
"""Fake botocore responses for the ECR operations this suite exercises."""
|
||||
if operation_name == "DescribeImages":
|
||||
return {
|
||||
"imageDetails": [
|
||||
@@ -150,10 +172,37 @@ def mock_make_api_call(self, operation_name, kwarg):
|
||||
},
|
||||
}
|
||||
|
||||
if operation_name == "BatchGetImage":
|
||||
digest = kwarg["imageIds"][0]["imageDigest"]
|
||||
manifest = MANIFESTS_BY_DIGEST.get(digest)
|
||||
if manifest is None:
|
||||
return {
|
||||
"images": [],
|
||||
"failures": [
|
||||
{
|
||||
"imageId": {"imageDigest": digest},
|
||||
"failureCode": "ImageNotFound",
|
||||
}
|
||||
],
|
||||
}
|
||||
return {
|
||||
"images": [
|
||||
{
|
||||
"imageManifest": json.dumps(manifest),
|
||||
"imageManifestMediaType": manifest.get("mediaType", ""),
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
if operation_name == "GetDownloadUrlForLayer":
|
||||
digest = kwarg["layerDigest"]
|
||||
return {"downloadUrl": f"https://layers.example.com/{digest}"}
|
||||
|
||||
return make_api_call(self, operation_name, kwarg)
|
||||
|
||||
|
||||
def mock_generate_regional_clients(provider, service):
|
||||
"""Return a single regional client for every requested region."""
|
||||
regional_client = provider._session.current_session.client(
|
||||
service, region_name=AWS_REGION_EU_WEST_1
|
||||
)
|
||||
@@ -169,13 +218,17 @@ def mock_generate_regional_clients(provider, service):
|
||||
)
|
||||
class Test_ECR_Service:
|
||||
# Test ECR Service
|
||||
"""Tests for the ECR service."""
|
||||
|
||||
def test_service(self):
|
||||
"""The service name is set correctly."""
|
||||
aws_provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1])
|
||||
ecr = ECR(aws_provider)
|
||||
assert ecr.service == "ecr"
|
||||
|
||||
# Test ECR client
|
||||
def test_client(self):
|
||||
"""Each regional client is an ECR client."""
|
||||
aws_provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1])
|
||||
ecr = ECR(aws_provider)
|
||||
for regional_client in ecr.regional_clients.values():
|
||||
@@ -183,6 +236,7 @@ class Test_ECR_Service:
|
||||
|
||||
# Test ECR session
|
||||
def test_get_session(self):
|
||||
"""The session is set correctly."""
|
||||
aws_provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1])
|
||||
ecr = ECR(aws_provider)
|
||||
assert ecr.session.__class__.__name__ == "Session"
|
||||
@@ -190,6 +244,7 @@ class Test_ECR_Service:
|
||||
# Test describe ECR repositories
|
||||
@mock_aws
|
||||
def test_describe_registries_and_repositories(self):
|
||||
"""Registries and repositories are discovered."""
|
||||
ecr_client = client("ecr", region_name=AWS_REGION_EU_WEST_1)
|
||||
ecr_client.create_repository(
|
||||
repositoryName=repo_name,
|
||||
@@ -220,6 +275,7 @@ class Test_ECR_Service:
|
||||
# Test describe ECR repository policies
|
||||
@mock_aws
|
||||
def test_describe_repository_policies(self):
|
||||
"""Repository policies are fetched and parsed."""
|
||||
ecr_client = client("ecr", region_name=AWS_REGION_EU_WEST_1)
|
||||
ecr_client.create_repository(
|
||||
repositoryName=repo_name,
|
||||
@@ -249,6 +305,7 @@ class Test_ECR_Service:
|
||||
# Test describe ECR repository lifecycle policies
|
||||
@mock_aws
|
||||
def test_get_lifecycle_policies(self):
|
||||
"""Repository lifecycle policies are fetched."""
|
||||
ecr_client = client("ecr", region_name=AWS_REGION_EU_WEST_1)
|
||||
ecr_client.create_repository(
|
||||
repositoryName=repo_name,
|
||||
@@ -268,6 +325,7 @@ class Test_ECR_Service:
|
||||
# Test get image details
|
||||
@mock_aws
|
||||
def test_get_image_details(self):
|
||||
"""Scannable, tagged images are collected and sorted by push date."""
|
||||
ecr_client = client("ecr", region_name=AWS_REGION_EU_WEST_1)
|
||||
ecr_client.create_repository(
|
||||
repositoryName=repo_name,
|
||||
@@ -366,6 +424,7 @@ class Test_ECR_Service:
|
||||
# Test get ECR Registries Scanning Configuration
|
||||
@mock_aws
|
||||
def test_get_registry_scanning_configuration(self):
|
||||
"""The registry's scanning configuration is fetched."""
|
||||
aws_provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1])
|
||||
ecr = ECR(aws_provider)
|
||||
assert len(ecr.registries) == 1
|
||||
@@ -379,39 +438,188 @@ class Test_ECR_Service:
|
||||
]
|
||||
|
||||
def test_is_artifact_scannable_docker(self):
|
||||
"""A Docker image config is scannable."""
|
||||
assert ECR._is_artifact_scannable(
|
||||
"application/vnd.docker.container.image.v1+json"
|
||||
)
|
||||
|
||||
def test_is_artifact_scannable_layer_tar(self):
|
||||
"""An uncompressed Docker layer is scannable."""
|
||||
assert ECR._is_artifact_scannable(
|
||||
"application/vnd.docker.image.rootfs.diff.tar"
|
||||
)
|
||||
|
||||
def test_is_artifact_scannable_layer_gzip(self):
|
||||
"""A gzip-compressed Docker layer is scannable."""
|
||||
assert ECR._is_artifact_scannable(
|
||||
"application/vnd.docker.image.rootfs.diff.tar.gzip"
|
||||
)
|
||||
|
||||
def test_is_artifact_scannable_oci(self):
|
||||
"""An OCI image config is scannable."""
|
||||
assert ECR._is_artifact_scannable("application/vnd.oci.image.config.v1+json")
|
||||
|
||||
def test_is_artifact_scannable_oci_tar(self):
|
||||
"""An uncompressed OCI layer is scannable."""
|
||||
assert ECR._is_artifact_scannable("application/vnd.oci.image.layer.v1.tar")
|
||||
|
||||
def test_is_artifact_scannable_oci_compressed(self):
|
||||
"""A gzip-compressed OCI layer is scannable."""
|
||||
assert ECR._is_artifact_scannable("application/vnd.oci.image.layer.v1.tar+gzip")
|
||||
|
||||
def test_is_artifact_scannable_none(self):
|
||||
"""A missing media type is not scannable."""
|
||||
assert not ECR._is_artifact_scannable(None)
|
||||
|
||||
def test_is_artifact_scannable_empty(self):
|
||||
"""An empty media type is not scannable."""
|
||||
assert not ECR._is_artifact_scannable("")
|
||||
|
||||
def test_is_artifact_scannable_non_scannable_tags(self):
|
||||
"""A signature-tagged artifact is not scannable."""
|
||||
assert not ECR._is_artifact_scannable("", ["sha256-abcdefg123456.sig"])
|
||||
|
||||
def test_is_artifact_scannable_scannable_tags(self):
|
||||
"""A normally-tagged artifact is scannable."""
|
||||
assert ECR._is_artifact_scannable(
|
||||
"application/vnd.docker.container.image.v1+json", ["abcdefg123456"]
|
||||
)
|
||||
|
||||
@mock_aws
|
||||
def test_get_image_scan_data_selects_only_latest_image_per_repository(self):
|
||||
"""Only the latest image per repository is selected for scanning."""
|
||||
ecr_client_boto = client("ecr", region_name=AWS_REGION_EU_WEST_1)
|
||||
ecr_client_boto.create_repository(
|
||||
repositoryName=repo_name,
|
||||
imageScanningConfiguration={"scanOnPush": True},
|
||||
)
|
||||
aws_provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1])
|
||||
ecr = ECR(aws_provider)
|
||||
|
||||
repository = ecr.registries[AWS_REGION_EU_WEST_1].repositories[0]
|
||||
# Sanity check: this repository has several scannable tagged images.
|
||||
assert len(repository.images_details) == 4
|
||||
|
||||
results = list(ecr._get_image_scan_data())
|
||||
|
||||
# Only the most recently pushed image is selected, not all four.
|
||||
assert len(results) == 1
|
||||
fetched_repository, fetched_image, _ = results[0]
|
||||
assert fetched_repository.name == repo_name
|
||||
assert fetched_image.latest_tag == "test-tag4"
|
||||
assert (
|
||||
fetched_image.latest_digest
|
||||
== "sha256:43251ac64627fc331584f6c498b3aba5badc01574e2c70b2499af3af16630eed"
|
||||
)
|
||||
|
||||
@mock_aws
|
||||
def test_get_image_scan_data_covers_scan_on_push_disabled_repository(self):
|
||||
"""A scan-on-push-disabled repo (empty images_details) is still scanned."""
|
||||
ecr_client_boto = client("ecr", region_name=AWS_REGION_EU_WEST_1)
|
||||
ecr_client_boto.create_repository(
|
||||
repositoryName=repo_name,
|
||||
imageScanningConfiguration={"scanOnPush": False},
|
||||
)
|
||||
aws_provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1])
|
||||
ecr = ECR(aws_provider)
|
||||
|
||||
repository = ecr.registries[AWS_REGION_EU_WEST_1].repositories[0]
|
||||
# Scan-on-push disabled: the metadata pass leaves images_details empty...
|
||||
assert repository.scan_on_push is False
|
||||
assert repository.images_details == []
|
||||
|
||||
# ...yet the secret-scan path resolves the latest image via a dedicated
|
||||
# describe_images lookup, so the repository is not silently skipped.
|
||||
results = list(ecr._get_image_scan_data())
|
||||
|
||||
assert len(results) == 1
|
||||
fetched_repository, fetched_image, _ = results[0]
|
||||
assert fetched_repository.name == repo_name
|
||||
assert fetched_image.latest_tag == "test-tag4"
|
||||
# The dedicated lookup must NOT mutate the shared images_details, or
|
||||
# other checks would treat this repo as having a scanned image.
|
||||
assert repository.images_details == []
|
||||
|
||||
@mock_aws
|
||||
def test_get_image_scan_data_bounds_submitted_futures(self):
|
||||
"""Image fetches are submitted only as earlier results are consumed."""
|
||||
ecr_client_boto = client("ecr", region_name=AWS_REGION_EU_WEST_1)
|
||||
for index in range(10):
|
||||
ecr_client_boto.create_repository(
|
||||
repositoryName=f"{repo_name}-{index}",
|
||||
imageScanningConfiguration={"scanOnPush": True},
|
||||
)
|
||||
aws_provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1])
|
||||
ecr = ECR(aws_provider)
|
||||
repositories = ecr.registries[AWS_REGION_EU_WEST_1].repositories
|
||||
|
||||
executor = MagicMock()
|
||||
executor.__enter__.return_value = executor
|
||||
futures = []
|
||||
|
||||
def submit(*_args):
|
||||
future = Future()
|
||||
futures.append(future)
|
||||
if len(futures) == 1:
|
||||
future.set_result(None)
|
||||
return future
|
||||
|
||||
executor.submit.side_effect = submit
|
||||
with patch(
|
||||
"prowler.providers.aws.services.ecr.ecr_service.ThreadPoolExecutor",
|
||||
return_value=executor,
|
||||
):
|
||||
results = ecr._get_image_scan_data()
|
||||
first_result = next(results)
|
||||
|
||||
assert first_result[0] == repositories[0]
|
||||
assert (
|
||||
first_result[1].latest_digest
|
||||
== repositories[0].images_details[-1].latest_digest
|
||||
)
|
||||
assert executor.submit.call_count == 4
|
||||
|
||||
@mock_aws
|
||||
def test_get_scan_target_image_ignores_stale_scanned_image(self):
|
||||
"""Secret scanning selects a newer image absent from scan findings."""
|
||||
ecr_client_boto = client("ecr", region_name=AWS_REGION_EU_WEST_1)
|
||||
ecr_client_boto.create_repository(
|
||||
repositoryName=repo_name,
|
||||
imageScanningConfiguration={"scanOnPush": True},
|
||||
)
|
||||
aws_provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1])
|
||||
ecr = ECR(aws_provider)
|
||||
repository = ecr.registries[AWS_REGION_EU_WEST_1].repositories[0]
|
||||
older_scanned_image = repository.images_details[0]
|
||||
repository.images_details = [older_scanned_image]
|
||||
|
||||
target = ecr._get_scan_target_image(repository)
|
||||
|
||||
assert target.latest_tag == "test-tag4"
|
||||
assert target.image_pushed_at > older_scanned_image.image_pushed_at
|
||||
|
||||
@mock_aws
|
||||
def test_get_scan_target_image_lookup_failure_rejects_stale_image(self):
|
||||
"""A failed authoritative lookup does not select cached scan metadata."""
|
||||
ecr_client_boto = client("ecr", region_name=AWS_REGION_EU_WEST_1)
|
||||
ecr_client_boto.create_repository(
|
||||
repositoryName=repo_name,
|
||||
imageScanningConfiguration={"scanOnPush": True},
|
||||
)
|
||||
aws_provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1])
|
||||
ecr = ECR(aws_provider)
|
||||
repository = ecr.registries[AWS_REGION_EU_WEST_1].repositories[0]
|
||||
repository.images_details = [repository.images_details[0]]
|
||||
|
||||
with patch.object(
|
||||
ecr.regional_clients[AWS_REGION_EU_WEST_1],
|
||||
"get_paginator",
|
||||
side_effect=RuntimeError("authoritative lookup failed"),
|
||||
):
|
||||
target = ecr._get_scan_target_image(repository)
|
||||
scan_results = list(ecr._get_image_scan_data())
|
||||
|
||||
assert isinstance(target, RuntimeError)
|
||||
assert len(scan_results) == 1
|
||||
_, result_image, result_error = scan_results[0]
|
||||
assert result_image is None and isinstance(result_error, RuntimeError)
|
||||
|
||||
@@ -0,0 +1,512 @@
|
||||
import gzip
|
||||
import json
|
||||
import random
|
||||
import tarfile
|
||||
from io import BytesIO
|
||||
from unittest.mock import patch
|
||||
|
||||
import botocore
|
||||
import pytest
|
||||
import zstandard
|
||||
from boto3 import client
|
||||
from moto import mock_aws
|
||||
|
||||
from prowler.providers.aws.services.ecr.image_inspection import (
|
||||
MAX_FILE_BYTES,
|
||||
MAX_LAYER_DOWNLOAD_BYTES,
|
||||
ImageInspector,
|
||||
_CappedLayerReader,
|
||||
_LayerTooLargeError,
|
||||
)
|
||||
from tests.providers.aws.services.ecr.image_scan_fixtures import (
|
||||
BLOBS_BY_DIGEST,
|
||||
CHILD_AMD64_DIGEST,
|
||||
CHILD_ARM64_DIGEST,
|
||||
CONFIG_DIGEST,
|
||||
CONFIG_JSON,
|
||||
IMAGE_DIGEST,
|
||||
LAYER_DIGEST,
|
||||
MANIFESTS_BY_DIGEST,
|
||||
MULTI_ARCH_INDEX_DIGEST,
|
||||
MULTI_ARCH_MANIFEST_LIST,
|
||||
SIMPLE_MANIFEST,
|
||||
build_gzip_tar,
|
||||
build_tar,
|
||||
mock_requests_get,
|
||||
reset_image_fixtures,
|
||||
)
|
||||
from tests.providers.aws.utils import (
|
||||
AWS_ACCOUNT_NUMBER,
|
||||
AWS_REGION_EU_WEST_1,
|
||||
)
|
||||
|
||||
REPO_NAME = "test-repo"
|
||||
|
||||
_original_make_api_call = botocore.client.BaseClient._make_api_call
|
||||
|
||||
_REQUESTS_GET = "prowler.providers.aws.services.ecr.image_inspection.requests.get"
|
||||
|
||||
|
||||
def mock_make_api_call(self, operation_name, kwarg):
|
||||
"""Serve BatchGetImage/GetDownloadUrlForLayer from fixtures; delegate the rest.
|
||||
|
||||
moto implements neither operation, so they are answered from the per-test
|
||||
``MANIFESTS_BY_DIGEST`` fixtures; every other call falls through to the real
|
||||
(moto-backed) implementation.
|
||||
"""
|
||||
if operation_name == "BatchGetImage":
|
||||
digest = kwarg["imageIds"][0]["imageDigest"]
|
||||
manifest = MANIFESTS_BY_DIGEST.get(digest)
|
||||
if manifest is None:
|
||||
return {
|
||||
"images": [],
|
||||
"failures": [
|
||||
{
|
||||
"imageId": {"imageDigest": digest},
|
||||
"failureCode": "ImageNotFound",
|
||||
}
|
||||
],
|
||||
}
|
||||
return {
|
||||
"images": [
|
||||
{
|
||||
"imageManifest": json.dumps(manifest),
|
||||
"imageManifestMediaType": manifest.get("mediaType", ""),
|
||||
}
|
||||
]
|
||||
}
|
||||
if operation_name == "GetDownloadUrlForLayer":
|
||||
digest = kwarg["layerDigest"]
|
||||
return {"downloadUrl": f"https://layers.example.com/{digest}"}
|
||||
return _original_make_api_call(self, operation_name, kwarg)
|
||||
|
||||
|
||||
@patch("botocore.client.BaseClient._make_api_call", new=mock_make_api_call)
|
||||
class Test_ImageInspector:
|
||||
"""Tests for the bounded image-content extraction in image_inspection."""
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _reset_image_fixtures(self):
|
||||
"""Isolate the BatchGetImage/GetDownloadUrlForLayer fixtures per test."""
|
||||
reset_image_fixtures()
|
||||
yield
|
||||
reset_image_fixtures()
|
||||
|
||||
@staticmethod
|
||||
def _fetch(digest=IMAGE_DIGEST):
|
||||
"""Fetch scan data for one image, with the layer download stubbed."""
|
||||
ecr_client = client("ecr", region_name=AWS_REGION_EU_WEST_1)
|
||||
with patch(_REQUESTS_GET, new=mock_requests_get):
|
||||
return ImageInspector().fetch_image_scan_data(
|
||||
ecr_client, AWS_ACCOUNT_NUMBER, REPO_NAME, digest
|
||||
)
|
||||
|
||||
@mock_aws
|
||||
def test_fetch_image_scan_data_simple_image(self):
|
||||
"""A single-manifest image's config and layer file are scanned."""
|
||||
MANIFESTS_BY_DIGEST[IMAGE_DIGEST] = SIMPLE_MANIFEST
|
||||
BLOBS_BY_DIGEST[CONFIG_DIGEST] = json.dumps(CONFIG_JSON).encode()
|
||||
BLOBS_BY_DIGEST[LAYER_DIGEST] = build_gzip_tar({"app/config.py": "TOKEN = 'x'"})
|
||||
|
||||
scan_data = self._fetch()
|
||||
|
||||
assert scan_data is not None
|
||||
assert scan_data.env == ["PATH=/usr/bin", "TOKEN=super-secret-value"]
|
||||
assert scan_data.history == ["/bin/sh -c #(nop) ADD file", "RUN echo hi"]
|
||||
assert len(scan_data.files) == 1
|
||||
assert scan_data.files[0].path == "app/config.py"
|
||||
assert scan_data.files[0].layer_digest == LAYER_DIGEST
|
||||
assert scan_data.files[0].content == "TOKEN = 'x'"
|
||||
assert scan_data.truncated is False
|
||||
|
||||
@mock_aws
|
||||
def test_fetch_image_scan_data_resolves_multi_arch_manifest(self):
|
||||
"""A multi-arch scan is incomplete when only one child is inspected."""
|
||||
MANIFESTS_BY_DIGEST[MULTI_ARCH_INDEX_DIGEST] = MULTI_ARCH_MANIFEST_LIST
|
||||
MANIFESTS_BY_DIGEST[CHILD_AMD64_DIGEST] = SIMPLE_MANIFEST
|
||||
BLOBS_BY_DIGEST[CONFIG_DIGEST] = json.dumps(CONFIG_JSON).encode()
|
||||
BLOBS_BY_DIGEST[LAYER_DIGEST] = build_gzip_tar({"app/config.py": "TOKEN = 'x'"})
|
||||
|
||||
scan_data = self._fetch(digest=MULTI_ARCH_INDEX_DIGEST)
|
||||
|
||||
# Only the amd64/linux child manifest is resolved and scanned; the
|
||||
# arm64 and attestation entries in the manifest list are ignored.
|
||||
assert scan_data is not None
|
||||
assert scan_data.env == ["PATH=/usr/bin", "TOKEN=super-secret-value"]
|
||||
assert len(scan_data.files) == 1
|
||||
assert scan_data.files[0].path == "app/config.py"
|
||||
assert scan_data.truncated is True
|
||||
|
||||
@mock_aws
|
||||
def test_fetch_image_scan_data_skips_oversized_layer(self):
|
||||
"""A layer over the size cap is skipped, not downloaded."""
|
||||
oversized_manifest = {
|
||||
"schemaVersion": 2,
|
||||
"mediaType": "application/vnd.docker.distribution.manifest.v2+json",
|
||||
"config": {"digest": CONFIG_DIGEST, "size": 10},
|
||||
"layers": [
|
||||
{
|
||||
"mediaType": "application/vnd.docker.image.rootfs.diff.tar.gzip",
|
||||
"digest": LAYER_DIGEST,
|
||||
"size": MAX_LAYER_DOWNLOAD_BYTES + 1,
|
||||
}
|
||||
],
|
||||
}
|
||||
MANIFESTS_BY_DIGEST[IMAGE_DIGEST] = oversized_manifest
|
||||
BLOBS_BY_DIGEST[CONFIG_DIGEST] = json.dumps(CONFIG_JSON).encode()
|
||||
|
||||
scan_data = self._fetch()
|
||||
|
||||
# The oversized layer is never downloaded (only the config blob is in
|
||||
# BLOBS_BY_DIGEST), yet the fetch completes without raising.
|
||||
assert scan_data is not None
|
||||
assert scan_data.files == []
|
||||
assert scan_data.truncated is True
|
||||
|
||||
@mock_aws
|
||||
def test_fetch_image_scan_data_manifest_not_found_returns_none(self):
|
||||
"""An unknown digest resolves to no scan data."""
|
||||
ecr_client = client("ecr", region_name=AWS_REGION_EU_WEST_1)
|
||||
|
||||
scan_data = ImageInspector().fetch_image_scan_data(
|
||||
ecr_client, AWS_ACCOUNT_NUMBER, REPO_NAME, f"sha256:{'f' * 64}"
|
||||
)
|
||||
|
||||
assert scan_data is None
|
||||
|
||||
@mock_aws
|
||||
def test_fetch_image_scan_data_config_download_failure_marks_truncated(self):
|
||||
"""A config blob that cannot be retrieved marks coverage incomplete.
|
||||
|
||||
Empty env/history would otherwise be indistinguishable from a clean
|
||||
config, so the fetch flags the result as truncated rather than risking
|
||||
a false PASS at the check level.
|
||||
"""
|
||||
MANIFESTS_BY_DIGEST[IMAGE_DIGEST] = SIMPLE_MANIFEST
|
||||
# Register the layer but NOT the config blob, so the config download
|
||||
# fails while the layer is still scanned cleanly.
|
||||
BLOBS_BY_DIGEST[LAYER_DIGEST] = build_gzip_tar({"app/config.py": "clean"})
|
||||
|
||||
scan_data = self._fetch()
|
||||
|
||||
assert scan_data is not None
|
||||
assert scan_data.env == []
|
||||
assert scan_data.history == []
|
||||
assert scan_data.truncated is True
|
||||
assert [f.path for f in scan_data.files] == ["app/config.py"]
|
||||
|
||||
@mock_aws
|
||||
def test_fetch_image_scan_data_member_over_remaining_budget_is_truncated(self):
|
||||
"""A layer exceeding the remaining stream budget is truncated.
|
||||
|
||||
Tar headers and padding consume the authoritative decompressed-byte
|
||||
budget before member payloads are exposed for scanning.
|
||||
"""
|
||||
MANIFESTS_BY_DIGEST[IMAGE_DIGEST] = SIMPLE_MANIFEST
|
||||
BLOBS_BY_DIGEST[CONFIG_DIGEST] = json.dumps(CONFIG_JSON).encode()
|
||||
BLOBS_BY_DIGEST[LAYER_DIGEST] = build_gzip_tar(
|
||||
{"app/first.txt": "a" * 100, "app/second.txt": "b" * 5000}
|
||||
)
|
||||
|
||||
ecr_client = client("ecr", region_name=AWS_REGION_EU_WEST_1)
|
||||
with (
|
||||
patch(_REQUESTS_GET, new=mock_requests_get),
|
||||
patch(
|
||||
"prowler.providers.aws.services.ecr.image_inspection.MAX_TOTAL_BYTES_PER_IMAGE",
|
||||
1000,
|
||||
),
|
||||
):
|
||||
scan_data = ImageInspector().fetch_image_scan_data(
|
||||
ecr_client, AWS_ACCOUNT_NUMBER, REPO_NAME, IMAGE_DIGEST
|
||||
)
|
||||
|
||||
assert scan_data is not None
|
||||
assert scan_data.files == []
|
||||
assert scan_data.truncated is True
|
||||
|
||||
@mock_aws
|
||||
def test_fetch_image_scan_data_oversized_members_count_toward_budget(self):
|
||||
"""Members skipped for size still count toward the per-image budget.
|
||||
|
||||
A streaming reader must decompress each member to advance past it, so
|
||||
oversized-and-skipped members must still consume budget; otherwise a
|
||||
layer of many just-over-limit files would decompress unbounded. The
|
||||
loop must stop before reaching a later scannable member.
|
||||
"""
|
||||
MANIFESTS_BY_DIGEST[IMAGE_DIGEST] = SIMPLE_MANIFEST
|
||||
BLOBS_BY_DIGEST[CONFIG_DIGEST] = json.dumps(CONFIG_JSON).encode()
|
||||
# Ten 300-byte members (each over the patched 100-byte MAX_FILE_BYTES,
|
||||
# so each is skipped for content) followed by a small, scannable file.
|
||||
layer = {f"app/big{i}.bin": "x" * 300 for i in range(10)}
|
||||
layer["app/reachable.txt"] = "hello"
|
||||
BLOBS_BY_DIGEST[LAYER_DIGEST] = build_gzip_tar(layer)
|
||||
|
||||
ecr_client = client("ecr", region_name=AWS_REGION_EU_WEST_1)
|
||||
with (
|
||||
patch(_REQUESTS_GET, new=mock_requests_get),
|
||||
patch(
|
||||
"prowler.providers.aws.services.ecr.image_inspection.MAX_FILE_BYTES",
|
||||
100,
|
||||
),
|
||||
patch(
|
||||
"prowler.providers.aws.services.ecr.image_inspection.MAX_TOTAL_BYTES_PER_IMAGE",
|
||||
1000,
|
||||
),
|
||||
):
|
||||
scan_data = ImageInspector().fetch_image_scan_data(
|
||||
ecr_client, AWS_ACCOUNT_NUMBER, REPO_NAME, IMAGE_DIGEST
|
||||
)
|
||||
|
||||
assert scan_data is not None
|
||||
# The oversized members exhaust the 1000-byte budget after ~3 of them,
|
||||
# so the loop stops before ever reaching app/reachable.txt. If skipped
|
||||
# members were not counted, reachable.txt would be scanned.
|
||||
assert scan_data.files == []
|
||||
assert scan_data.truncated is True
|
||||
|
||||
@mock_aws
|
||||
def test_fetch_image_scan_data_tar_over_stream_budget_is_truncated(self):
|
||||
"""Tar headers, padding, and non-files consume the image byte budget."""
|
||||
MANIFESTS_BY_DIGEST[IMAGE_DIGEST] = SIMPLE_MANIFEST
|
||||
BLOBS_BY_DIGEST[CONFIG_DIGEST] = json.dumps(CONFIG_JSON).encode()
|
||||
layer = BytesIO()
|
||||
with tarfile.open(fileobj=layer, mode="w") as archive:
|
||||
for index in range(20):
|
||||
directory = tarfile.TarInfo(f"metadata-{index}/")
|
||||
directory.type = tarfile.DIRTYPE
|
||||
archive.addfile(directory)
|
||||
content = b"x"
|
||||
member = tarfile.TarInfo("app/reachable.txt")
|
||||
member.size = len(content)
|
||||
archive.addfile(member, BytesIO(content))
|
||||
BLOBS_BY_DIGEST[LAYER_DIGEST] = gzip.compress(layer.getvalue())
|
||||
|
||||
ecr_client = client("ecr", region_name=AWS_REGION_EU_WEST_1)
|
||||
with (
|
||||
patch(_REQUESTS_GET, new=mock_requests_get),
|
||||
patch(
|
||||
"prowler.providers.aws.services.ecr.image_inspection.MAX_TOTAL_BYTES_PER_IMAGE",
|
||||
10 * 1024 - 1,
|
||||
),
|
||||
):
|
||||
scan_data = ImageInspector().fetch_image_scan_data(
|
||||
ecr_client, AWS_ACCOUNT_NUMBER, REPO_NAME, IMAGE_DIGEST
|
||||
)
|
||||
|
||||
assert scan_data is not None
|
||||
assert scan_data.files == []
|
||||
assert scan_data.truncated is True
|
||||
|
||||
def test_select_child_manifest_digest_falls_back_to_non_amd64(self):
|
||||
"""With no amd64/linux entry, the first non-attestation candidate is picked."""
|
||||
manifest_list = {
|
||||
"manifests": [
|
||||
{
|
||||
"digest": CHILD_ARM64_DIGEST,
|
||||
"platform": {"architecture": "arm64", "os": "linux"},
|
||||
},
|
||||
{
|
||||
"digest": f"sha256:{'5' * 64}",
|
||||
"platform": {"architecture": "unknown", "os": "unknown"},
|
||||
"annotations": {
|
||||
"vnd.docker.reference.type": "attestation-manifest"
|
||||
},
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
digest = ImageInspector._select_child_manifest_digest(manifest_list)
|
||||
|
||||
assert digest == CHILD_ARM64_DIGEST
|
||||
|
||||
@mock_aws
|
||||
def test_fetch_image_scan_data_zstd_layer(self):
|
||||
"""A zstd-compressed layer is streamed, decompressed, and scanned."""
|
||||
zstd_manifest = {
|
||||
"schemaVersion": 2,
|
||||
"mediaType": "application/vnd.docker.distribution.manifest.v2+json",
|
||||
"config": {"digest": CONFIG_DIGEST, "size": 100},
|
||||
"layers": [
|
||||
{
|
||||
"mediaType": "application/vnd.oci.image.layer.v1.tar+zstd",
|
||||
"digest": LAYER_DIGEST,
|
||||
"size": 200,
|
||||
}
|
||||
],
|
||||
}
|
||||
MANIFESTS_BY_DIGEST[IMAGE_DIGEST] = zstd_manifest
|
||||
BLOBS_BY_DIGEST[CONFIG_DIGEST] = json.dumps(CONFIG_JSON).encode()
|
||||
BLOBS_BY_DIGEST[LAYER_DIGEST] = zstandard.ZstdCompressor().compress(
|
||||
build_tar({"app/config.py": "TOKEN = 'x'"})
|
||||
)
|
||||
|
||||
scan_data = self._fetch()
|
||||
|
||||
assert scan_data is not None
|
||||
assert len(scan_data.files) == 1
|
||||
assert scan_data.files[0].content == "TOKEN = 'x'"
|
||||
|
||||
@mock_aws
|
||||
def test_fetch_image_scan_data_zstd_layer_over_compressed_cap_is_truncated(self):
|
||||
"""A zstd layer whose compressed bytes exceed the cap is truncated.
|
||||
|
||||
The streaming decompressor reads through a _CappedLayerReader, so a
|
||||
layer whose compressed size exceeds MAX_LAYER_DOWNLOAD_BYTES (patched
|
||||
here) is cut off and disclosed via truncated instead of being buffered
|
||||
or decompressed unbounded.
|
||||
"""
|
||||
zstd_manifest = {
|
||||
"schemaVersion": 2,
|
||||
"mediaType": "application/vnd.docker.distribution.manifest.v2+json",
|
||||
"config": {"digest": CONFIG_DIGEST, "size": 100},
|
||||
"layers": [
|
||||
{
|
||||
"mediaType": "application/vnd.oci.image.layer.v1.tar+zstd",
|
||||
"digest": LAYER_DIGEST,
|
||||
# Declares zero size so it passes the pre-download check;
|
||||
# the actual compressed bytes exceed the (patched) cap.
|
||||
"size": 0,
|
||||
}
|
||||
],
|
||||
}
|
||||
# Incompressible payload (so the compressed frame stays large), split
|
||||
# across two members so the first is read before the cap trips while
|
||||
# the second is streamed.
|
||||
rng = random.Random(0)
|
||||
incompressible = bytes(rng.randrange(256) for _ in range(64 * 1024)).decode(
|
||||
"latin-1"
|
||||
)
|
||||
layer_blob = zstandard.ZstdCompressor().compress(
|
||||
build_tar({"app/config.py": "TOKEN = 'x'", "app/big.bin": incompressible})
|
||||
)
|
||||
assert len(layer_blob) > 10 * 1024
|
||||
MANIFESTS_BY_DIGEST[IMAGE_DIGEST] = zstd_manifest
|
||||
BLOBS_BY_DIGEST[CONFIG_DIGEST] = json.dumps(CONFIG_JSON).encode()
|
||||
BLOBS_BY_DIGEST[LAYER_DIGEST] = layer_blob
|
||||
|
||||
ecr_client = client("ecr", region_name=AWS_REGION_EU_WEST_1)
|
||||
with (
|
||||
patch(_REQUESTS_GET, new=mock_requests_get),
|
||||
patch(
|
||||
"prowler.providers.aws.services.ecr.image_inspection.MAX_LAYER_DOWNLOAD_BYTES",
|
||||
len(layer_blob) - 1,
|
||||
),
|
||||
):
|
||||
scan_data = ImageInspector().fetch_image_scan_data(
|
||||
ecr_client, AWS_ACCOUNT_NUMBER, REPO_NAME, IMAGE_DIGEST
|
||||
)
|
||||
|
||||
assert scan_data is not None
|
||||
assert scan_data.truncated is True
|
||||
|
||||
@mock_aws
|
||||
def test_fetch_image_scan_data_uncompressed_tar_layer(self):
|
||||
"""An uncompressed tar layer is read and scanned directly."""
|
||||
tar_manifest = {
|
||||
"schemaVersion": 2,
|
||||
"mediaType": "application/vnd.docker.distribution.manifest.v2+json",
|
||||
"config": {"digest": CONFIG_DIGEST, "size": 100},
|
||||
"layers": [
|
||||
{
|
||||
"mediaType": "application/vnd.oci.image.layer.v1.tar",
|
||||
"digest": LAYER_DIGEST,
|
||||
"size": 200,
|
||||
}
|
||||
],
|
||||
}
|
||||
MANIFESTS_BY_DIGEST[IMAGE_DIGEST] = tar_manifest
|
||||
BLOBS_BY_DIGEST[CONFIG_DIGEST] = json.dumps(CONFIG_JSON).encode()
|
||||
BLOBS_BY_DIGEST[LAYER_DIGEST] = build_tar({"app/config.py": "TOKEN = 'x'"})
|
||||
|
||||
scan_data = self._fetch()
|
||||
|
||||
assert scan_data is not None
|
||||
assert len(scan_data.files) == 1
|
||||
assert scan_data.files[0].content == "TOKEN = 'x'"
|
||||
|
||||
@mock_aws
|
||||
def test_fetch_image_scan_data_skips_whiteout_and_oversized_file(self):
|
||||
"""Whiteout markers and oversized files are skipped, not scanned."""
|
||||
MANIFESTS_BY_DIGEST[IMAGE_DIGEST] = SIMPLE_MANIFEST
|
||||
BLOBS_BY_DIGEST[CONFIG_DIGEST] = json.dumps(CONFIG_JSON).encode()
|
||||
BLOBS_BY_DIGEST[LAYER_DIGEST] = build_gzip_tar(
|
||||
{
|
||||
".wh.deleted": "should never appear",
|
||||
"app/config.py": "TOKEN = 'x'",
|
||||
"app/oversized.bin": "x" * (MAX_FILE_BYTES + 1),
|
||||
}
|
||||
)
|
||||
|
||||
scan_data = self._fetch()
|
||||
|
||||
assert scan_data is not None
|
||||
assert [f.path for f in scan_data.files] == ["app/config.py"]
|
||||
assert scan_data.truncated is True
|
||||
|
||||
def test_capped_layer_reader_allows_up_to_max(self):
|
||||
"""Reading exactly the byte budget succeeds and then reports EOF."""
|
||||
import io
|
||||
|
||||
reader = _CappedLayerReader(io.BytesIO(b"x" * 10), max_bytes=10)
|
||||
assert reader.read() == b"x" * 10
|
||||
assert reader.read() == b""
|
||||
|
||||
def test_capped_layer_reader_raises_when_exceeding_max(self):
|
||||
"""A stream longer than the byte budget raises _LayerTooLargeError."""
|
||||
import io
|
||||
|
||||
reader = _CappedLayerReader(io.BytesIO(b"x" * 100), max_bytes=10)
|
||||
with pytest.raises(_LayerTooLargeError):
|
||||
reader.read()
|
||||
|
||||
@mock_aws
|
||||
def test_fetch_image_scan_data_streamed_layer_over_cap_is_truncated(self):
|
||||
"""A layer streaming past MAX_LAYER_DOWNLOAD_BYTES is skipped, not buffered."""
|
||||
undersized_manifest = {
|
||||
"schemaVersion": 2,
|
||||
"mediaType": "application/vnd.docker.distribution.manifest.v2+json",
|
||||
"config": {"digest": CONFIG_DIGEST, "size": 100},
|
||||
"layers": [
|
||||
{
|
||||
"mediaType": "application/vnd.docker.image.rootfs.diff.tar.gzip",
|
||||
"digest": LAYER_DIGEST,
|
||||
# Declares zero size so it passes the pre-download check;
|
||||
# the actual streamed bytes exceed the (patched) cap.
|
||||
"size": 0,
|
||||
}
|
||||
],
|
||||
}
|
||||
# Two-member layer: a small first file that is scanned, then a large
|
||||
# incompressible second file. The blob must exceed tarfile's internal
|
||||
# read buffer (~10 KB) so tarfile.open() consumes only part of it and
|
||||
# the cap (set one byte below the full layer) is instead exceeded while
|
||||
# the second member's content is read during archive iteration.
|
||||
rng = random.Random(0)
|
||||
incompressible = bytes(rng.randrange(256) for _ in range(64 * 1024)).decode(
|
||||
"latin-1"
|
||||
)
|
||||
layer_blob = build_gzip_tar(
|
||||
{"app/config.py": "TOKEN = 'x'", "app/big.bin": incompressible}
|
||||
)
|
||||
assert len(layer_blob) > 10 * 1024 # larger than tarfile's read buffer
|
||||
MANIFESTS_BY_DIGEST[IMAGE_DIGEST] = undersized_manifest
|
||||
BLOBS_BY_DIGEST[CONFIG_DIGEST] = json.dumps(CONFIG_JSON).encode()
|
||||
BLOBS_BY_DIGEST[LAYER_DIGEST] = layer_blob
|
||||
|
||||
ecr_client = client("ecr", region_name=AWS_REGION_EU_WEST_1)
|
||||
with (
|
||||
patch(_REQUESTS_GET, new=mock_requests_get),
|
||||
patch(
|
||||
"prowler.providers.aws.services.ecr.image_inspection.MAX_LAYER_DOWNLOAD_BYTES",
|
||||
len(layer_blob) - 1,
|
||||
),
|
||||
):
|
||||
scan_data = ImageInspector().fetch_image_scan_data(
|
||||
ecr_client, AWS_ACCOUNT_NUMBER, REPO_NAME, IMAGE_DIGEST
|
||||
)
|
||||
|
||||
# The over-cap layer is disclosed via truncated, and the config-derived
|
||||
# env/history (fetched independently of the layer) are still returned.
|
||||
assert scan_data is not None
|
||||
assert scan_data.truncated is True
|
||||
assert scan_data.env == ["PATH=/usr/bin", "TOKEN=super-secret-value"]
|
||||
@@ -0,0 +1,145 @@
|
||||
"""Shared fixtures for ECR image-scanning tests.
|
||||
|
||||
Used by both the ECR service tests (orchestration) and the image_inspection
|
||||
tests (bounded extraction), so the manifest/layer fixtures and the fake layer
|
||||
download live in one place. moto implements neither BatchGetImage nor
|
||||
GetDownloadUrlForLayer, so each test registers the manifests/blobs it needs in
|
||||
``MANIFESTS_BY_DIGEST``/``BLOBS_BY_DIGEST`` and a patched ``_make_api_call``
|
||||
serves them.
|
||||
"""
|
||||
|
||||
import io
|
||||
import tarfile
|
||||
|
||||
IMAGE_DIGEST = f"sha256:{'1' * 64}"
|
||||
CONFIG_DIGEST = f"sha256:{'c' * 64}"
|
||||
LAYER_DIGEST = f"sha256:{'d' * 64}"
|
||||
MULTI_ARCH_INDEX_DIGEST = f"sha256:{'2' * 64}"
|
||||
CHILD_AMD64_DIGEST = f"sha256:{'3' * 64}"
|
||||
CHILD_ARM64_DIGEST = f"sha256:{'4' * 64}"
|
||||
ATTESTATION_DIGEST = f"sha256:{'5' * 64}"
|
||||
|
||||
SIMPLE_MANIFEST = {
|
||||
"schemaVersion": 2,
|
||||
"mediaType": "application/vnd.docker.distribution.manifest.v2+json",
|
||||
"config": {
|
||||
"mediaType": "application/vnd.docker.container.image.v1+json",
|
||||
"digest": CONFIG_DIGEST,
|
||||
"size": 100,
|
||||
},
|
||||
"layers": [
|
||||
{
|
||||
"mediaType": "application/vnd.docker.image.rootfs.diff.tar.gzip",
|
||||
"digest": LAYER_DIGEST,
|
||||
"size": 200,
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
MULTI_ARCH_MANIFEST_LIST = {
|
||||
"schemaVersion": 2,
|
||||
"mediaType": "application/vnd.docker.distribution.manifest.list.v2+json",
|
||||
"manifests": [
|
||||
{
|
||||
"mediaType": "application/vnd.docker.distribution.manifest.v2+json",
|
||||
"digest": CHILD_AMD64_DIGEST,
|
||||
"size": 10,
|
||||
"platform": {"architecture": "amd64", "os": "linux"},
|
||||
},
|
||||
{
|
||||
"mediaType": "application/vnd.docker.distribution.manifest.v2+json",
|
||||
"digest": CHILD_ARM64_DIGEST,
|
||||
"size": 10,
|
||||
"platform": {"architecture": "arm64", "os": "linux"},
|
||||
},
|
||||
{
|
||||
"mediaType": "application/vnd.oci.image.manifest.v1+json",
|
||||
"digest": ATTESTATION_DIGEST,
|
||||
"size": 10,
|
||||
"platform": {"architecture": "unknown", "os": "unknown"},
|
||||
"annotations": {"vnd.docker.reference.type": "attestation-manifest"},
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
CONFIG_JSON = {
|
||||
"config": {"Env": ["PATH=/usr/bin", "TOKEN=super-secret-value"]},
|
||||
"history": [
|
||||
{"created_by": "/bin/sh -c #(nop) ADD file"},
|
||||
{"created_by": "RUN echo hi"},
|
||||
],
|
||||
}
|
||||
|
||||
# Per-test fixtures keyed by digest. moto implements neither BatchGetImage nor
|
||||
# GetDownloadUrlForLayer, so tests populate these and a patched _make_api_call /
|
||||
# requests.get serves them. Cleared between tests via reset_image_fixtures().
|
||||
MANIFESTS_BY_DIGEST = {}
|
||||
BLOBS_BY_DIGEST = {}
|
||||
|
||||
|
||||
def reset_image_fixtures():
|
||||
"""Clear the per-test manifest/blob fixtures."""
|
||||
MANIFESTS_BY_DIGEST.clear()
|
||||
BLOBS_BY_DIGEST.clear()
|
||||
|
||||
|
||||
def build_tar(files: dict) -> bytes:
|
||||
"""Build an uncompressed tar archive from the given files."""
|
||||
tar_buffer = io.BytesIO()
|
||||
with tarfile.open(fileobj=tar_buffer, mode="w") as tar:
|
||||
for name, content in files.items():
|
||||
data = content.encode("latin-1")
|
||||
info = tarfile.TarInfo(name=name)
|
||||
info.size = len(data)
|
||||
tar.addfile(info, io.BytesIO(data))
|
||||
return tar_buffer.getvalue()
|
||||
|
||||
|
||||
def build_gzip_tar(files: dict) -> bytes:
|
||||
"""Build a gzip-compressed tar archive from the given files."""
|
||||
tar_buffer = io.BytesIO()
|
||||
with tarfile.open(fileobj=tar_buffer, mode="w:gz") as tar:
|
||||
for name, content in files.items():
|
||||
data = content.encode("latin-1")
|
||||
info = tarfile.TarInfo(name=name)
|
||||
info.size = len(data)
|
||||
tar.addfile(info, io.BytesIO(data))
|
||||
return tar_buffer.getvalue()
|
||||
|
||||
|
||||
class FakeLayerResponse:
|
||||
"""A minimal stand-in for a requests.Response over a layer download."""
|
||||
|
||||
def __init__(self, data: bytes):
|
||||
"""Store the fixture bytes to serve (via iter_content and .raw)."""
|
||||
self._data = data
|
||||
# Streaming gzip/tar layers read the compressed bytes straight from
|
||||
# response.raw; the buffered config/zstd path uses iter_content.
|
||||
self.raw = io.BytesIO(data)
|
||||
|
||||
def raise_for_status(self):
|
||||
"""No-op: fixture responses are always successful."""
|
||||
|
||||
def iter_content(self, chunk_size=1024 * 1024):
|
||||
"""Yield the fixture bytes in chunks."""
|
||||
for start in range(0, len(self._data), chunk_size):
|
||||
yield self._data[start : start + chunk_size]
|
||||
|
||||
def close(self):
|
||||
"""Close the backing raw stream, mirroring requests.Response.close."""
|
||||
self.raw.close()
|
||||
|
||||
def __enter__(self):
|
||||
"""Support use as a context manager."""
|
||||
return self
|
||||
|
||||
def __exit__(self, *_):
|
||||
"""Close on exit, mirroring requests.Response context-manager use."""
|
||||
self.close()
|
||||
return False
|
||||
|
||||
|
||||
def mock_requests_get(url, **_):
|
||||
"""Return the fixture bytes registered for the requested layer's URL."""
|
||||
digest = url.rsplit("/", 1)[-1]
|
||||
return FakeLayerResponse(BLOBS_BY_DIGEST[digest])
|
||||
@@ -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() == []
|
||||
+218
-1
@@ -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"
|
||||
|
||||
@@ -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
|
||||
)
|
||||
@@ -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())
|
||||
@@ -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]]
|
||||
@@ -3851,6 +3848,7 @@ dependencies = [
|
||||
{ name = "tabulate" },
|
||||
{ name = "tzlocal" },
|
||||
{ name = "uuid6" },
|
||||
{ name = "zstandard" },
|
||||
]
|
||||
|
||||
[package.dev-dependencies]
|
||||
@@ -3889,7 +3887,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 +3948,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" },
|
||||
@@ -3971,6 +3969,7 @@ requires-dist = [
|
||||
{ name = "tabulate", specifier = "==0.9.0" },
|
||||
{ name = "tzlocal", specifier = "==5.3.1" },
|
||||
{ name = "uuid6", specifier = "==2024.7.10" },
|
||||
{ name = "zstandard", specifier = "==0.25.0" },
|
||||
]
|
||||
|
||||
[package.metadata.requires-dev]
|
||||
@@ -4399,15 +4398,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]]
|
||||
@@ -5519,72 +5518,125 @@ wheels = [
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zstd"
|
||||
version = "1.5.7.3"
|
||||
name = "zstandard"
|
||||
version = "0.25.0"
|
||||
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/fd/aa/3e0508d5a5dd96529cdc5a97011299056e14c6505b678fd58938792794b1/zstandard-0.25.0.tar.gz", hash = "sha256:7713e1179d162cf5c7906da876ec2ccb9c3a9dcbdffef0cc7f70c3667a205f0b", size = 711513, upload-time = "2025-09-14T22:15:54.002Z" }
|
||||
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/56/7a/28efd1d371f1acd037ac64ed1c5e2b41514a6cc937dd6ab6a13ab9f0702f/zstandard-0.25.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:e59fdc271772f6686e01e1b3b74537259800f57e24280be3f29c8a0deb1904dd", size = 795256, upload-time = "2025-09-14T22:15:56.415Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/96/34/ef34ef77f1ee38fc8e4f9775217a613b452916e633c4f1d98f31db52c4a5/zstandard-0.25.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:4d441506e9b372386a5271c64125f72d5df6d2a8e8a2a45a0ae09b03cb781ef7", size = 640565, upload-time = "2025-09-14T22:15:58.177Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9d/1b/4fdb2c12eb58f31f28c4d28e8dc36611dd7205df8452e63f52fb6261d13e/zstandard-0.25.0-cp310-cp310-manylinux2010_i686.manylinux2014_i686.manylinux_2_12_i686.manylinux_2_17_i686.whl", hash = "sha256:ab85470ab54c2cb96e176f40342d9ed41e58ca5733be6a893b730e7af9c40550", size = 5345306, upload-time = "2025-09-14T22:16:00.165Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/73/28/a44bdece01bca027b079f0e00be3b6bd89a4df180071da59a3dd7381665b/zstandard-0.25.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:e05ab82ea7753354bb054b92e2f288afb750e6b439ff6ca78af52939ebbc476d", size = 5055561, upload-time = "2025-09-14T22:16:02.22Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e9/74/68341185a4f32b274e0fc3410d5ad0750497e1acc20bd0f5b5f64ce17785/zstandard-0.25.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:78228d8a6a1c177a96b94f7e2e8d012c55f9c760761980da16ae7546a15a8e9b", size = 5402214, upload-time = "2025-09-14T22:16:04.109Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8b/67/f92e64e748fd6aaffe01e2b75a083c0c4fd27abe1c8747fee4555fcee7dd/zstandard-0.25.0-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:2b6bd67528ee8b5c5f10255735abc21aa106931f0dbaf297c7be0c886353c3d0", size = 5449703, upload-time = "2025-09-14T22:16:06.312Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fd/e5/6d36f92a197c3c17729a2125e29c169f460538a7d939a27eaaa6dcfcba8e/zstandard-0.25.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4b6d83057e713ff235a12e73916b6d356e3084fd3d14ced499d84240f3eecee0", size = 5556583, upload-time = "2025-09-14T22:16:08.457Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d7/83/41939e60d8d7ebfe2b747be022d0806953799140a702b90ffe214d557638/zstandard-0.25.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:9174f4ed06f790a6869b41cba05b43eeb9a35f8993c4422ab853b705e8112bbd", size = 5045332, upload-time = "2025-09-14T22:16:10.444Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b3/87/d3ee185e3d1aa0133399893697ae91f221fda79deb61adbe998a7235c43f/zstandard-0.25.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:25f8f3cd45087d089aef5ba3848cd9efe3ad41163d3400862fb42f81a3a46701", size = 5572283, upload-time = "2025-09-14T22:16:12.128Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0a/1d/58635ae6104df96671076ac7d4ae7816838ce7debd94aecf83e30b7121b0/zstandard-0.25.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:3756b3e9da9b83da1796f8809dd57cb024f838b9eeafde28f3cb472012797ac1", size = 4959754, upload-time = "2025-09-14T22:16:14.225Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/75/d6/57e9cb0a9983e9a229dd8fd2e6e96593ef2aa82a3907188436f22b111ccd/zstandard-0.25.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:81dad8d145d8fd981b2962b686b2241d3a1ea07733e76a2f15435dfb7fb60150", size = 5266477, upload-time = "2025-09-14T22:16:16.343Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d1/a9/ee891e5edf33a6ebce0a028726f0bbd8567effe20fe3d5808c42323e8542/zstandard-0.25.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:a5a419712cf88862a45a23def0ae063686db3d324cec7edbe40509d1a79a0aab", size = 5440914, upload-time = "2025-09-14T22:16:18.453Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/58/08/a8522c28c08031a9521f27abc6f78dbdee7312a7463dd2cfc658b813323b/zstandard-0.25.0-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:e7360eae90809efd19b886e59a09dad07da4ca9ba096752e61a2e03c8aca188e", size = 5819847, upload-time = "2025-09-14T22:16:20.559Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6f/11/4c91411805c3f7b6f31c60e78ce347ca48f6f16d552fc659af6ec3b73202/zstandard-0.25.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:75ffc32a569fb049499e63ce68c743155477610532da1eb38e7f24bf7cd29e74", size = 5363131, upload-time = "2025-09-14T22:16:22.206Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ef/d6/8c4bd38a3b24c4c7676a7a3d8de85d6ee7a983602a734b9f9cdefb04a5d6/zstandard-0.25.0-cp310-cp310-win32.whl", hash = "sha256:106281ae350e494f4ac8a80470e66d1fe27e497052c8d9c3b95dc4cf1ade81aa", size = 436469, upload-time = "2025-09-14T22:16:25.002Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/93/90/96d50ad417a8ace5f841b3228e93d1bb13e6ad356737f42e2dde30d8bd68/zstandard-0.25.0-cp310-cp310-win_amd64.whl", hash = "sha256:ea9d54cc3d8064260114a0bbf3479fc4a98b21dffc89b3459edd506b69262f6e", size = 506100, upload-time = "2025-09-14T22:16:23.569Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2a/83/c3ca27c363d104980f1c9cee1101cc8ba724ac8c28a033ede6aab89585b1/zstandard-0.25.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:933b65d7680ea337180733cf9e87293cc5500cc0eb3fc8769f4d3c88d724ec5c", size = 795254, upload-time = "2025-09-14T22:16:26.137Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ac/4d/e66465c5411a7cf4866aeadc7d108081d8ceba9bc7abe6b14aa21c671ec3/zstandard-0.25.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a3f79487c687b1fc69f19e487cd949bf3aae653d181dfb5fde3bf6d18894706f", size = 640559, upload-time = "2025-09-14T22:16:27.973Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/12/56/354fe655905f290d3b147b33fe946b0f27e791e4b50a5f004c802cb3eb7b/zstandard-0.25.0-cp311-cp311-manylinux2010_i686.manylinux2014_i686.manylinux_2_12_i686.manylinux_2_17_i686.whl", hash = "sha256:0bbc9a0c65ce0eea3c34a691e3c4b6889f5f3909ba4822ab385fab9057099431", size = 5348020, upload-time = "2025-09-14T22:16:29.523Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3b/13/2b7ed68bd85e69a2069bcc72141d378f22cae5a0f3b353a2c8f50ef30c1b/zstandard-0.25.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:01582723b3ccd6939ab7b3a78622c573799d5d8737b534b86d0e06ac18dbde4a", size = 5058126, upload-time = "2025-09-14T22:16:31.811Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c9/dd/fdaf0674f4b10d92cb120ccff58bbb6626bf8368f00ebfd2a41ba4a0dc99/zstandard-0.25.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:5f1ad7bf88535edcf30038f6919abe087f606f62c00a87d7e33e7fc57cb69fcc", size = 5405390, upload-time = "2025-09-14T22:16:33.486Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0f/67/354d1555575bc2490435f90d67ca4dd65238ff2f119f30f72d5cde09c2ad/zstandard-0.25.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:06acb75eebeedb77b69048031282737717a63e71e4ae3f77cc0c3b9508320df6", size = 5452914, upload-time = "2025-09-14T22:16:35.277Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bb/1f/e9cfd801a3f9190bf3e759c422bbfd2247db9d7f3d54a56ecde70137791a/zstandard-0.25.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:9300d02ea7c6506f00e627e287e0492a5eb0371ec1670ae852fefffa6164b072", size = 5559635, upload-time = "2025-09-14T22:16:37.141Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/21/88/5ba550f797ca953a52d708c8e4f380959e7e3280af029e38fbf47b55916e/zstandard-0.25.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:bfd06b1c5584b657a2892a6014c2f4c20e0db0208c159148fa78c65f7e0b0277", size = 5048277, upload-time = "2025-09-14T22:16:38.807Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/46/c0/ca3e533b4fa03112facbe7fbe7779cb1ebec215688e5df576fe5429172e0/zstandard-0.25.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:f373da2c1757bb7f1acaf09369cdc1d51d84131e50d5fa9863982fd626466313", size = 5574377, upload-time = "2025-09-14T22:16:40.523Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/12/9b/3fb626390113f272abd0799fd677ea33d5fc3ec185e62e6be534493c4b60/zstandard-0.25.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:6c0e5a65158a7946e7a7affa6418878ef97ab66636f13353b8502d7ea03c8097", size = 4961493, upload-time = "2025-09-14T22:16:43.3Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cb/d3/23094a6b6a4b1343b27ae68249daa17ae0651fcfec9ed4de09d14b940285/zstandard-0.25.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:c8e167d5adf59476fa3e37bee730890e389410c354771a62e3c076c86f9f7778", size = 5269018, upload-time = "2025-09-14T22:16:45.292Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8c/a7/bb5a0c1c0f3f4b5e9d5b55198e39de91e04ba7c205cc46fcb0f95f0383c1/zstandard-0.25.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:98750a309eb2f020da61e727de7d7ba3c57c97cf6213f6f6277bb7fb42a8e065", size = 5443672, upload-time = "2025-09-14T22:16:47.076Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/27/22/503347aa08d073993f25109c36c8d9f029c7d5949198050962cb568dfa5e/zstandard-0.25.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:22a086cff1b6ceca18a8dd6096ec631e430e93a8e70a9ca5efa7561a00f826fa", size = 5822753, upload-time = "2025-09-14T22:16:49.316Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e2/be/94267dc6ee64f0f8ba2b2ae7c7a2df934a816baaa7291db9e1aa77394c3c/zstandard-0.25.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:72d35d7aa0bba323965da807a462b0966c91608ef3a48ba761678cb20ce5d8b7", size = 5366047, upload-time = "2025-09-14T22:16:51.328Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7b/a3/732893eab0a3a7aecff8b99052fecf9f605cf0fb5fb6d0290e36beee47a4/zstandard-0.25.0-cp311-cp311-win32.whl", hash = "sha256:f5aeea11ded7320a84dcdd62a3d95b5186834224a9e55b92ccae35d21a8b63d4", size = 436484, upload-time = "2025-09-14T22:16:55.005Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/43/a3/c6155f5c1cce691cb80dfd38627046e50af3ee9ddc5d0b45b9b063bfb8c9/zstandard-0.25.0-cp311-cp311-win_amd64.whl", hash = "sha256:daab68faadb847063d0c56f361a289c4f268706b598afbf9ad113cbe5c38b6b2", size = 506183, upload-time = "2025-09-14T22:16:52.753Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8c/3e/8945ab86a0820cc0e0cdbf38086a92868a9172020fdab8a03ac19662b0e5/zstandard-0.25.0-cp311-cp311-win_arm64.whl", hash = "sha256:22a06c5df3751bb7dc67406f5374734ccee8ed37fc5981bf1ad7041831fa1137", size = 462533, upload-time = "2025-09-14T22:16:53.878Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/82/fc/f26eb6ef91ae723a03e16eddb198abcfce2bc5a42e224d44cc8b6765e57e/zstandard-0.25.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7b3c3a3ab9daa3eed242d6ecceead93aebbb8f5f84318d82cee643e019c4b73b", size = 795738, upload-time = "2025-09-14T22:16:56.237Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/aa/1c/d920d64b22f8dd028a8b90e2d756e431a5d86194caa78e3819c7bf53b4b3/zstandard-0.25.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:913cbd31a400febff93b564a23e17c3ed2d56c064006f54efec210d586171c00", size = 640436, upload-time = "2025-09-14T22:16:57.774Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/53/6c/288c3f0bd9fcfe9ca41e2c2fbfd17b2097f6af57b62a81161941f09afa76/zstandard-0.25.0-cp312-cp312-manylinux2010_i686.manylinux2014_i686.manylinux_2_12_i686.manylinux_2_17_i686.whl", hash = "sha256:011d388c76b11a0c165374ce660ce2c8efa8e5d87f34996aa80f9c0816698b64", size = 5343019, upload-time = "2025-09-14T22:16:59.302Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1e/15/efef5a2f204a64bdb5571e6161d49f7ef0fffdbca953a615efbec045f60f/zstandard-0.25.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:6dffecc361d079bb48d7caef5d673c88c8988d3d33fb74ab95b7ee6da42652ea", size = 5063012, upload-time = "2025-09-14T22:17:01.156Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b7/37/a6ce629ffdb43959e92e87ebdaeebb5ac81c944b6a75c9c47e300f85abdf/zstandard-0.25.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:7149623bba7fdf7e7f24312953bcf73cae103db8cae49f8154dd1eadc8a29ecb", size = 5394148, upload-time = "2025-09-14T22:17:03.091Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e3/79/2bf870b3abeb5c070fe2d670a5a8d1057a8270f125ef7676d29ea900f496/zstandard-0.25.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:6a573a35693e03cf1d67799fd01b50ff578515a8aeadd4595d2a7fa9f3ec002a", size = 5451652, upload-time = "2025-09-14T22:17:04.979Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/53/60/7be26e610767316c028a2cbedb9a3beabdbe33e2182c373f71a1c0b88f36/zstandard-0.25.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5a56ba0db2d244117ed744dfa8f6f5b366e14148e00de44723413b2f3938a902", size = 5546993, upload-time = "2025-09-14T22:17:06.781Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/85/c7/3483ad9ff0662623f3648479b0380d2de5510abf00990468c286c6b04017/zstandard-0.25.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:10ef2a79ab8e2974e2075fb984e5b9806c64134810fac21576f0668e7ea19f8f", size = 5046806, upload-time = "2025-09-14T22:17:08.415Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/08/b3/206883dd25b8d1591a1caa44b54c2aad84badccf2f1de9e2d60a446f9a25/zstandard-0.25.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:aaf21ba8fb76d102b696781bddaa0954b782536446083ae3fdaa6f16b25a1c4b", size = 5576659, upload-time = "2025-09-14T22:17:10.164Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9d/31/76c0779101453e6c117b0ff22565865c54f48f8bd807df2b00c2c404b8e0/zstandard-0.25.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:1869da9571d5e94a85a5e8d57e4e8807b175c9e4a6294e3b66fa4efb074d90f6", size = 4953933, upload-time = "2025-09-14T22:17:11.857Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/18/e1/97680c664a1bf9a247a280a053d98e251424af51f1b196c6d52f117c9720/zstandard-0.25.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:809c5bcb2c67cd0ed81e9229d227d4ca28f82d0f778fc5fea624a9def3963f91", size = 5268008, upload-time = "2025-09-14T22:17:13.627Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1e/73/316e4010de585ac798e154e88fd81bb16afc5c5cb1a72eeb16dd37e8024a/zstandard-0.25.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:f27662e4f7dbf9f9c12391cb37b4c4c3cb90ffbd3b1fb9284dadbbb8935fa708", size = 5433517, upload-time = "2025-09-14T22:17:16.103Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5b/60/dd0f8cfa8129c5a0ce3ea6b7f70be5b33d2618013a161e1ff26c2b39787c/zstandard-0.25.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:99c0c846e6e61718715a3c9437ccc625de26593fea60189567f0118dc9db7512", size = 5814292, upload-time = "2025-09-14T22:17:17.827Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fc/5f/75aafd4b9d11b5407b641b8e41a57864097663699f23e9ad4dbb91dc6bfe/zstandard-0.25.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:474d2596a2dbc241a556e965fb76002c1ce655445e4e3bf38e5477d413165ffa", size = 5360237, upload-time = "2025-09-14T22:17:19.954Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ff/8d/0309daffea4fcac7981021dbf21cdb2e3427a9e76bafbcdbdf5392ff99a4/zstandard-0.25.0-cp312-cp312-win32.whl", hash = "sha256:23ebc8f17a03133b4426bcc04aabd68f8236eb78c3760f12783385171b0fd8bd", size = 436922, upload-time = "2025-09-14T22:17:24.398Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/79/3b/fa54d9015f945330510cb5d0b0501e8253c127cca7ebe8ba46a965df18c5/zstandard-0.25.0-cp312-cp312-win_amd64.whl", hash = "sha256:ffef5a74088f1e09947aecf91011136665152e0b4b359c42be3373897fb39b01", size = 506276, upload-time = "2025-09-14T22:17:21.429Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ea/6b/8b51697e5319b1f9ac71087b0af9a40d8a6288ff8025c36486e0c12abcc4/zstandard-0.25.0-cp312-cp312-win_arm64.whl", hash = "sha256:181eb40e0b6a29b3cd2849f825e0fa34397f649170673d385f3598ae17cca2e9", size = 462679, upload-time = "2025-09-14T22:17:23.147Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/35/0b/8df9c4ad06af91d39e94fa96cc010a24ac4ef1378d3efab9223cc8593d40/zstandard-0.25.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:ec996f12524f88e151c339688c3897194821d7f03081ab35d31d1e12ec975e94", size = 795735, upload-time = "2025-09-14T22:17:26.042Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3f/06/9ae96a3e5dcfd119377ba33d4c42a7d89da1efabd5cb3e366b156c45ff4d/zstandard-0.25.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a1a4ae2dec3993a32247995bdfe367fc3266da832d82f8438c8570f989753de1", size = 640440, upload-time = "2025-09-14T22:17:27.366Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d9/14/933d27204c2bd404229c69f445862454dcc101cd69ef8c6068f15aaec12c/zstandard-0.25.0-cp313-cp313-manylinux2010_i686.manylinux2014_i686.manylinux_2_12_i686.manylinux_2_17_i686.whl", hash = "sha256:e96594a5537722fdfb79951672a2a63aec5ebfb823e7560586f7484819f2a08f", size = 5343070, upload-time = "2025-09-14T22:17:28.896Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6d/db/ddb11011826ed7db9d0e485d13df79b58586bfdec56e5c84a928a9a78c1c/zstandard-0.25.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:bfc4e20784722098822e3eee42b8e576b379ed72cca4a7cb856ae733e62192ea", size = 5063001, upload-time = "2025-09-14T22:17:31.044Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/db/00/87466ea3f99599d02a5238498b87bf84a6348290c19571051839ca943777/zstandard-0.25.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:457ed498fc58cdc12fc48f7950e02740d4f7ae9493dd4ab2168a47c93c31298e", size = 5394120, upload-time = "2025-09-14T22:17:32.711Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2b/95/fc5531d9c618a679a20ff6c29e2b3ef1d1f4ad66c5e161ae6ff847d102a9/zstandard-0.25.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:fd7a5004eb1980d3cefe26b2685bcb0b17989901a70a1040d1ac86f1d898c551", size = 5451230, upload-time = "2025-09-14T22:17:34.41Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/63/4b/e3678b4e776db00f9f7b2fe58e547e8928ef32727d7a1ff01dea010f3f13/zstandard-0.25.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8e735494da3db08694d26480f1493ad2cf86e99bdd53e8e9771b2752a5c0246a", size = 5547173, upload-time = "2025-09-14T22:17:36.084Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4e/d5/ba05ed95c6b8ec30bd468dfeab20589f2cf709b5c940483e31d991f2ca58/zstandard-0.25.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:3a39c94ad7866160a4a46d772e43311a743c316942037671beb264e395bdd611", size = 5046736, upload-time = "2025-09-14T22:17:37.891Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/50/d5/870aa06b3a76c73eced65c044b92286a3c4e00554005ff51962deef28e28/zstandard-0.25.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:172de1f06947577d3a3005416977cce6168f2261284c02080e7ad0185faeced3", size = 5576368, upload-time = "2025-09-14T22:17:40.206Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5d/35/398dc2ffc89d304d59bc12f0fdd931b4ce455bddf7038a0a67733a25f550/zstandard-0.25.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:3c83b0188c852a47cd13ef3bf9209fb0a77fa5374958b8c53aaa699398c6bd7b", size = 4954022, upload-time = "2025-09-14T22:17:41.879Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9a/5c/36ba1e5507d56d2213202ec2b05e8541734af5f2ce378c5d1ceaf4d88dc4/zstandard-0.25.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:1673b7199bbe763365b81a4f3252b8e80f44c9e323fc42940dc8843bfeaf9851", size = 5267889, upload-time = "2025-09-14T22:17:43.577Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/70/e8/2ec6b6fb7358b2ec0113ae202647ca7c0e9d15b61c005ae5225ad0995df5/zstandard-0.25.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:0be7622c37c183406f3dbf0cba104118eb16a4ea7359eeb5752f0794882fc250", size = 5433952, upload-time = "2025-09-14T22:17:45.271Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7b/01/b5f4d4dbc59ef193e870495c6f1275f5b2928e01ff5a81fecb22a06e22fb/zstandard-0.25.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:5f5e4c2a23ca271c218ac025bd7d635597048b366d6f31f420aaeb715239fc98", size = 5814054, upload-time = "2025-09-14T22:17:47.08Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b2/e5/fbd822d5c6f427cf158316d012c5a12f233473c2f9c5fe5ab1ae5d21f3d8/zstandard-0.25.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4f187a0bb61b35119d1926aee039524d1f93aaf38a9916b8c4b78ac8514a0aaf", size = 5360113, upload-time = "2025-09-14T22:17:48.893Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8e/e0/69a553d2047f9a2c7347caa225bb3a63b6d7704ad74610cb7823baa08ed7/zstandard-0.25.0-cp313-cp313-win32.whl", hash = "sha256:7030defa83eef3e51ff26f0b7bfb229f0204b66fe18e04359ce3474ac33cbc09", size = 436936, upload-time = "2025-09-14T22:17:52.658Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d9/82/b9c06c870f3bd8767c201f1edbdf9e8dc34be5b0fbc5682c4f80fe948475/zstandard-0.25.0-cp313-cp313-win_amd64.whl", hash = "sha256:1f830a0dac88719af0ae43b8b2d6aef487d437036468ef3c2ea59c51f9d55fd5", size = 506232, upload-time = "2025-09-14T22:17:50.402Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d4/57/60c3c01243bb81d381c9916e2a6d9e149ab8627c0c7d7abb2d73384b3c0c/zstandard-0.25.0-cp313-cp313-win_arm64.whl", hash = "sha256:85304a43f4d513f5464ceb938aa02c1e78c2943b29f44a750b48b25ac999a049", size = 462671, upload-time = "2025-09-14T22:17:51.533Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zstd"
|
||||
version = "1.5.7.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
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/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" },
|
||||
]
|
||||
|
||||
Reference in New Issue
Block a user