Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
acb6ff0425 | ||
|
|
ba564af4f4 | ||
|
|
0b9791ffdc | ||
|
|
f6defefb58 | ||
|
|
f3224d0988 | ||
|
|
450e6ba553 | ||
|
|
2cd93fe119 | ||
|
|
f807b22ea6 | ||
|
|
b6e9967da6 | ||
|
|
16e62f7514 | ||
|
|
13ce9436b3 | ||
|
|
d3ced63397 | ||
|
|
758b696ca5 | ||
|
|
0d3ce45374 | ||
|
|
f35666ff0a | ||
|
|
0758c3585d | ||
|
|
dd882c70e7 | ||
|
|
d05c9fbb31 | ||
|
|
7bde42ffb9 | ||
|
|
0d3df0fd0b | ||
|
|
ab996417e6 | ||
|
|
5f109bc00e | ||
|
|
472e04f4cc | ||
|
|
b848aace33 | ||
|
|
94c20eb9fe | ||
|
|
02df22ca19 | ||
|
|
de64df11b9 |
@@ -158,7 +158,7 @@ SENTRY_RELEASE=local
|
||||
# REO_DEV_CLIENT_ID=
|
||||
|
||||
#### Prowler release version ####
|
||||
NEXT_PUBLIC_PROWLER_RELEASE_VERSION=v5.39.0
|
||||
NEXT_PUBLIC_PROWLER_RELEASE_VERSION=v5.40.0
|
||||
|
||||
# Social login credentials
|
||||
SOCIAL_GOOGLE_OAUTH_CALLBACK_URL="${AUTH_URL}/api/auth/callback/google"
|
||||
|
||||
@@ -64,7 +64,7 @@ runs:
|
||||
scanners: 'vuln'
|
||||
ignore-unfixed: 'true' # A finding with no available fix is not actionable, so it must not gate
|
||||
timeout: '5m'
|
||||
version: 'v0.72.0'
|
||||
version: 'v0.74.0'
|
||||
# Not trivyignores: that input drops the .yaml extension Trivy parses by.
|
||||
env:
|
||||
TRIVY_IGNOREFILE: '.trivyignore.yaml'
|
||||
@@ -81,7 +81,7 @@ runs:
|
||||
scanners: 'vuln'
|
||||
ignore-unfixed: 'true' # A finding with no available fix is not actionable, so it must not gate
|
||||
timeout: '5m'
|
||||
version: 'v0.72.0'
|
||||
version: 'v0.74.0'
|
||||
# Not trivyignores: that input drops the .yaml extension Trivy parses by.
|
||||
env:
|
||||
TRIVY_IGNOREFILE: '.trivyignore.yaml'
|
||||
|
||||
@@ -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
|
||||
```
|
||||
|
||||
|
||||
@@ -61,6 +61,7 @@ jobs:
|
||||
api.github.com:443
|
||||
github.com:443
|
||||
objects.githubusercontent.com:443
|
||||
release-assets.githubusercontent.com:443
|
||||
pypi.org:443
|
||||
files.pythonhosted.org:443
|
||||
|
||||
|
||||
@@ -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 --
|
||||
|
||||
@@ -113,7 +113,7 @@ jobs:
|
||||
|
||||
- name: Publish prowler-mcp package to PyPI
|
||||
if: steps.pypi-check.outputs.skip != 'true'
|
||||
uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b # v1.14.0
|
||||
uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # v1.14.2
|
||||
with:
|
||||
packages-dir: ${{ env.WORKING_DIRECTORY }}/dist/
|
||||
print-hash: true
|
||||
|
||||
@@ -35,6 +35,7 @@ jobs:
|
||||
api.github.com:443
|
||||
github.com:443
|
||||
objects.githubusercontent.com:443
|
||||
release-assets.githubusercontent.com:443
|
||||
pypi.org:443
|
||||
files.pythonhosted.org:443
|
||||
|
||||
|
||||
@@ -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,8 +84,18 @@ jobs:
|
||||
- name: Build Prowler package
|
||||
run: uv build
|
||||
|
||||
- name: Verify the wheel installs with pip
|
||||
# Same check as "SDK: Package Checks", repeated on the exact artifact about to be
|
||||
# published. Plain pip, --isolated, from outside the repo: an unsatisfiable
|
||||
# Requires-Dist fails here instead of on users' machines (5.38.0 shipped one).
|
||||
run: |
|
||||
python -m venv "${RUNNER_TEMP}/consumer"
|
||||
"${RUNNER_TEMP}/consumer/bin/python" -m pip install --quiet --upgrade pip
|
||||
cd "${RUNNER_TEMP}"
|
||||
"${RUNNER_TEMP}/consumer/bin/python" -m pip install --isolated --no-cache-dir --dry-run "${GITHUB_WORKSPACE}"/dist/*.whl
|
||||
|
||||
- name: Publish Prowler package to PyPI
|
||||
uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b # v1.14.0
|
||||
uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # v1.14.2
|
||||
with:
|
||||
print-hash: true
|
||||
|
||||
@@ -128,7 +138,17 @@ jobs:
|
||||
- name: Build prowler-cloud package
|
||||
run: uv build
|
||||
|
||||
- name: Verify the wheel installs with pip
|
||||
# Same check as "SDK: Package Checks", repeated on the exact artifact about to be
|
||||
# published. Plain pip, --isolated, from outside the repo: an unsatisfiable
|
||||
# Requires-Dist fails here instead of on users' machines (5.38.0 shipped one).
|
||||
run: |
|
||||
python -m venv "${RUNNER_TEMP}/consumer"
|
||||
"${RUNNER_TEMP}/consumer/bin/python" -m pip install --quiet --upgrade pip
|
||||
cd "${RUNNER_TEMP}"
|
||||
"${RUNNER_TEMP}/consumer/bin/python" -m pip install --isolated --no-cache-dir --dry-run "${GITHUB_WORKSPACE}"/dist/*.whl
|
||||
|
||||
- name: Publish prowler-cloud package to PyPI
|
||||
uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b # v1.14.0
|
||||
uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # v1.14.2
|
||||
with:
|
||||
print-hash: true
|
||||
|
||||
@@ -57,6 +57,7 @@ jobs:
|
||||
egress-policy: block
|
||||
allowed-endpoints: >
|
||||
github.com:443
|
||||
release-assets.githubusercontent.com:443
|
||||
pypi.org:443
|
||||
files.pythonhosted.org:443
|
||||
|
||||
|
||||
@@ -135,8 +135,8 @@ vulnerabilities:
|
||||
# Modules compiled into the Trivy binary the images ship. The binary is pinned by version
|
||||
# and verified by checksum in the Dockerfile; only a rebuild by its vendor moves these.
|
||||
# CVE-2026-71556 affects go-git worktree operations that can follow symlinks outside a
|
||||
# cloned repository. Trivy 0.72.0 contains go-git 5.19.1, and even the latest published
|
||||
# Trivy release, 0.73.0, still pins that vulnerable version:
|
||||
# cloned repository. Trivy 0.73.0, the latest published release and the version the
|
||||
# images ship, still pins that vulnerable version:
|
||||
# https://github.com/aquasecurity/trivy/blob/v0.73.0/go.mod#L46
|
||||
# Trivy main already contains the 5.19.2 fix, but no published release includes it yet:
|
||||
# https://github.com/aquasecurity/trivy/commit/a2edba9a03987ba0d2ebc8212c1a9a1e6979497b
|
||||
@@ -164,7 +164,3 @@ vulnerabilities:
|
||||
purls:
|
||||
- "pkg:golang/oras.land/oras-go/v2"
|
||||
expired_at: 2026-12-31
|
||||
- id: CVE-2026-39822
|
||||
purls:
|
||||
- "pkg:golang/stdlib"
|
||||
expired_at: 2026-12-31
|
||||
|
||||
@@ -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.72.0
|
||||
ARG TRIVY_VERSION=0.74.0
|
||||
ENV TRIVY_VERSION=${TRIVY_VERSION}
|
||||
|
||||
ARG ZIZMOR_VERSION=1.24.1
|
||||
ENV ZIZMOR_VERSION=${ZIZMOR_VERSION}
|
||||
|
||||
# Pinned here, not fetched with the artefact: a compromised release ships its own checksum.
|
||||
ARG TRIVY_SHA256_AMD64=bbb64b9695866ce4a7a8f5c9592002c5961cab378577fa3f8a040df362b9b2ea
|
||||
ARG TRIVY_SHA256_ARM64=2ca2c023109c2db6b2b77366b6717291452d4531167377d95c79547f0c8e3467
|
||||
ARG TRIVY_SHA256_AMD64=2ae6fe3ee734b7fdf11335663e18c75ea12dccc76062f09f164a3b0f8be4371a
|
||||
ARG TRIVY_SHA256_ARM64=b94ce1976bbf3c15b514b605ee88be7c6d94a29be2302847ff01cb794d47aad5
|
||||
ARG POWERSHELL_SHA256_AMD64=492ff26bb958336bf61e597ce19e07648b4003bd2a08659e02f0e3e0446ebfe0
|
||||
ARG POWERSHELL_SHA256_ARM64=2503b71da3e83635592b092df59a0aca4c3606b4d9b068217bb00be989cb0d56
|
||||
ARG ZIZMOR_SHA256_AMD64=a8000f3c683319a523d3b20df0e75457ba591f049cfcbfa98966631b56733c03
|
||||
@@ -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,37 @@ All notable changes to the **Prowler API** are documented in this file.
|
||||
|
||||
<!-- changelog: release notes start -->
|
||||
|
||||
## [1.40.1] (Prowler v5.39.1)
|
||||
|
||||
### 🔄 Changed
|
||||
|
||||
- Bump alibabacloud-tea-openapi to 0.4.6, oci to 2.184.1 and pyopenssl to 26.4.0 to match the SDK; the cryptography override now names its actual blockers (azure-cli-core pins msal below 1.37, workos 8.3.0 requires cryptography 48) [(#12477)](https://github.com/prowler-cloud/prowler/pull/12477)
|
||||
|
||||
### 🐞 Fixed
|
||||
|
||||
- Pin zstd to 1.5.7.2; 1.5.7.3 was yanked from PyPI as not thread safe [(#12477)](https://github.com/prowler-cloud/prowler/pull/12477)
|
||||
|
||||
### 🔐 Security
|
||||
|
||||
- Trivy from v0.72.0 to v0.73.0 in the container image, fixing HIGH CVE-2026-46600 in the bundled `golang.org/x/net` [(#12445)](https://github.com/prowler-cloud/prowler/pull/12445)
|
||||
- Trivy v0.74.0 and Debian util-linux 2.41.5-0+deb13u1 in the API container image, patching Go standard library vulnerabilities and CVE-2026-53615 [(#12470)](https://github.com/prowler-cloud/prowler/pull/12470)
|
||||
|
||||
---
|
||||
|
||||
## [1.40.0] (Prowler v5.39.0)
|
||||
|
||||
### 🔄 Changed
|
||||
|
||||
- `GET /api/v1/users/me` membership relationships identify the active tenant with `meta.active` for JWT and API key authentication [(#12388)](https://github.com/prowler-cloud/prowler/pull/12388)
|
||||
|
||||
### 🐞 Fixed
|
||||
|
||||
- Tenant deletion no longer leaves memberships partially removed when exclusive-user cleanup fails [(#12379)](https://github.com/prowler-cloud/prowler/pull/12379)
|
||||
- `/api/v1/accounts/saml/{organization_slug}/acs/` rejects non-POST requests before SAML response processing [(#12393)](https://github.com/prowler-cloud/prowler/pull/12393)
|
||||
- Social login derives a valid user name when identity providers omit the profile name [(#12413)](https://github.com/prowler-cloud/prowler/pull/12413)
|
||||
|
||||
---
|
||||
|
||||
## [1.39.0] (Prowler v5.38.0)
|
||||
|
||||
### 🚀 Added
|
||||
|
||||
@@ -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.72.0
|
||||
ARG TRIVY_VERSION=0.74.0
|
||||
ENV TRIVY_VERSION=${TRIVY_VERSION}
|
||||
|
||||
ARG ZIZMOR_VERSION=1.24.1
|
||||
ENV ZIZMOR_VERSION=${ZIZMOR_VERSION}
|
||||
|
||||
# Pinned here, not fetched with the artefact: a compromised release ships its own checksum.
|
||||
ARG TRIVY_SHA256_AMD64=bbb64b9695866ce4a7a8f5c9592002c5961cab378577fa3f8a040df362b9b2ea
|
||||
ARG TRIVY_SHA256_ARM64=2ca2c023109c2db6b2b77366b6717291452d4531167377d95c79547f0c8e3467
|
||||
ARG TRIVY_SHA256_AMD64=2ae6fe3ee734b7fdf11335663e18c75ea12dccc76062f09f164a3b0f8be4371a
|
||||
ARG TRIVY_SHA256_ARM64=b94ce1976bbf3c15b514b605ee88be7c6d94a29be2302847ff01cb794d47aad5
|
||||
ARG POWERSHELL_SHA256_AMD64=492ff26bb958336bf61e597ce19e07648b4003bd2a08659e02f0e3e0446ebfe0
|
||||
ARG POWERSHELL_SHA256_ARM64=2503b71da3e83635592b092df59a0aca4c3606b4d9b068217bb00be989cb0d56
|
||||
ARG ZIZMOR_SHA256_AMD64=a8000f3c683319a523d3b20df0e75457ba591f049cfcbfa98966631b56733c03
|
||||
@@ -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 @@
|
||||
`/api/v1/accounts/saml/{organization_slug}/acs/` rejects non-POST requests before SAML response processing
|
||||
@@ -1 +0,0 @@
|
||||
Tenant deletion no longer leaves memberships partially removed when exclusive-user cleanup fails
|
||||
@@ -1 +0,0 @@
|
||||
`GET /api/v1/users/me` membership relationships identify the active tenant with `meta.active` for JWT and API key authentication
|
||||
@@ -71,7 +71,7 @@ name = "prowler-api"
|
||||
package-mode = false
|
||||
# Needed for the SDK compatibility
|
||||
requires-python = ">=3.11,<3.13"
|
||||
version = "1.40.0"
|
||||
version = "1.41.0"
|
||||
|
||||
# Shared ruff baseline (kept in sync with mcp_server/pyproject.toml).
|
||||
# target-version tracks this project's lowest supported Python.
|
||||
@@ -92,8 +92,7 @@ extend-select = [
|
||||
|
||||
[tool.uv]
|
||||
# Transitive pins matching master to avoid silent drift; bump deliberately.
|
||||
# workos and pyopenssl run ahead of master: the versions master pins cap cryptography
|
||||
# below 48, so both were bumped to versions that allow it (PROWLER-2310).
|
||||
# workos is api-only; pyopenssl matches master (PROWLER-2310).
|
||||
constraint-dependencies = [
|
||||
"about-time==4.2.1",
|
||||
"adal==1.2.7",
|
||||
@@ -130,7 +129,7 @@ constraint-dependencies = [
|
||||
"alibabacloud-sls20201230==5.9.0",
|
||||
"alibabacloud-sts20150401==1.1.6",
|
||||
"alibabacloud-tea==0.4.3",
|
||||
"alibabacloud-tea-openapi==0.4.5",
|
||||
"alibabacloud-tea-openapi==0.4.6",
|
||||
"alibabacloud-tea-util==0.3.14",
|
||||
"alibabacloud-tea-xml==0.0.3",
|
||||
"alibabacloud-vpc20160428==6.13.0",
|
||||
@@ -339,7 +338,7 @@ constraint-dependencies = [
|
||||
"nltk==3.9.4",
|
||||
"numpy==2.2.6",
|
||||
"oauthlib==3.3.1",
|
||||
"oci==2.183.0",
|
||||
"oci==2.184.1",
|
||||
"openai==1.109.1",
|
||||
"openstacksdk==4.2.0",
|
||||
"opentelemetry-api==1.39.1",
|
||||
@@ -380,7 +379,7 @@ constraint-dependencies = [
|
||||
"pylint==3.2.5",
|
||||
"pymsalruntime==0.18.1",
|
||||
"pynacl==1.6.2",
|
||||
"pyopenssl==26.2.0",
|
||||
"pyopenssl==26.4.0",
|
||||
"pyparsing==3.3.2",
|
||||
"pyreadline3==3.5.4",
|
||||
"pysocks==1.7.1",
|
||||
@@ -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",
|
||||
|
||||
@@ -12,11 +12,37 @@ from api.models import (
|
||||
UserRoleRelationship,
|
||||
)
|
||||
from api.utils import accept_invitation_for_user
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.db import transaction
|
||||
from django.http import HttpResponseForbidden
|
||||
|
||||
|
||||
class ProwlerSocialAccountAdapter(DefaultSocialAccountAdapter):
|
||||
@staticmethod
|
||||
def _get_social_account_name(extra_data: dict, email: str) -> str:
|
||||
name_field = User._meta.get_field("name")
|
||||
for value in (
|
||||
extra_data.get("name"),
|
||||
extra_data.get("login"),
|
||||
extra_data.get("username"),
|
||||
email,
|
||||
):
|
||||
if not isinstance(value, str):
|
||||
continue
|
||||
|
||||
candidate = value.strip()[: name_field.max_length].rstrip()
|
||||
if not candidate:
|
||||
continue
|
||||
|
||||
try:
|
||||
name_field.run_validators(candidate)
|
||||
except ValidationError:
|
||||
continue
|
||||
|
||||
return candidate
|
||||
|
||||
raise ValueError("Social account does not provide a valid user identity.")
|
||||
|
||||
@staticmethod
|
||||
def get_user_by_email(email: str):
|
||||
try:
|
||||
@@ -116,11 +142,8 @@ class ProwlerSocialAccountAdapter(DefaultSocialAccountAdapter):
|
||||
|
||||
if provider != "saml":
|
||||
# Handle other providers (e.g., GitHub, Google)
|
||||
user.name = self._get_social_account_name(extra, user.email)
|
||||
user.save(using=MainRouter.admin_db)
|
||||
social_account_name = extra.get("name")
|
||||
if social_account_name:
|
||||
user.name = social_account_name
|
||||
user.save(using=MainRouter.admin_db)
|
||||
|
||||
invitation_token = self._get_invitation_token(request)
|
||||
if invitation_token:
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
openapi: 3.0.3
|
||||
info:
|
||||
title: Prowler API
|
||||
version: 1.40.0
|
||||
version: 1.41.0
|
||||
description: |-
|
||||
Prowler API specification.
|
||||
|
||||
|
||||
@@ -111,6 +111,110 @@ def _verify_local_email(user):
|
||||
)
|
||||
|
||||
|
||||
def test_social_account_name_falls_back_to_login_for_blank_name():
|
||||
adapter = ProwlerSocialAccountAdapter()
|
||||
|
||||
name = adapter._get_social_account_name(
|
||||
{"name": " ", "login": "octocat"},
|
||||
"verified@example.com",
|
||||
)
|
||||
|
||||
assert name == "octocat"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("provider_name", [None, "", " ", 123, ["name"]])
|
||||
def test_social_account_name_ignores_unusable_provider_names(provider_name):
|
||||
adapter = ProwlerSocialAccountAdapter()
|
||||
|
||||
name = adapter._get_social_account_name(
|
||||
{"name": provider_name, "login": "octocat"},
|
||||
"verified@example.com",
|
||||
)
|
||||
|
||||
assert name == "octocat"
|
||||
|
||||
|
||||
def test_social_account_name_uses_login_when_name_is_missing():
|
||||
adapter = ProwlerSocialAccountAdapter()
|
||||
|
||||
name = adapter._get_social_account_name(
|
||||
{"login": "octocat"},
|
||||
"verified@example.com",
|
||||
)
|
||||
|
||||
assert name == "octocat"
|
||||
|
||||
|
||||
def test_social_account_name_falls_back_to_username_then_email():
|
||||
adapter = ProwlerSocialAccountAdapter()
|
||||
|
||||
username_name = adapter._get_social_account_name(
|
||||
{"name": "ab", "login": None, "username": " monalisa "},
|
||||
"verified@example.com",
|
||||
)
|
||||
email_name = adapter._get_social_account_name({}, " verified@example.com ")
|
||||
|
||||
assert username_name == "monalisa"
|
||||
assert email_name == "verified@example.com"
|
||||
|
||||
|
||||
def test_social_account_name_trims_and_limits_provider_name():
|
||||
adapter = ProwlerSocialAccountAdapter()
|
||||
max_length = User._meta.get_field("name").max_length
|
||||
|
||||
trimmed_name = adapter._get_social_account_name(
|
||||
{"name": " Ada Lovelace "},
|
||||
"verified@example.com",
|
||||
)
|
||||
limited_name = adapter._get_social_account_name(
|
||||
{"name": "a" * (max_length + 1)},
|
||||
"verified@example.com",
|
||||
)
|
||||
|
||||
assert trimmed_name == "Ada Lovelace"
|
||||
assert limited_name == "a" * max_length
|
||||
|
||||
|
||||
def test_social_account_name_rejects_missing_identity():
|
||||
adapter = ProwlerSocialAccountAdapter()
|
||||
|
||||
with pytest.raises(
|
||||
ValueError,
|
||||
match="Social account does not provide a valid user identity",
|
||||
):
|
||||
adapter._get_social_account_name({}, "")
|
||||
|
||||
|
||||
def test_save_user_applies_normalized_social_account_name(rf):
|
||||
adapter = ProwlerSocialAccountAdapter()
|
||||
request = rf.post("/")
|
||||
request.session = {}
|
||||
sociallogin = MagicMock(spec=SocialLogin)
|
||||
sociallogin.provider = MagicMock()
|
||||
sociallogin.provider.id = "github"
|
||||
sociallogin.account = MagicMock()
|
||||
sociallogin.account.extra_data = {"name": None, "login": " octocat "}
|
||||
user = User(email="verified@example.com")
|
||||
user.save = MagicMock()
|
||||
invitation = SimpleNamespace(tenant_id="tenant-id")
|
||||
|
||||
with (
|
||||
patch("api.adapters.super") as mock_super,
|
||||
patch("api.adapters.transaction.atomic"),
|
||||
patch("api.adapters.write_db_alias"),
|
||||
patch.object(adapter, "_get_invitation_token", return_value="token"),
|
||||
patch(
|
||||
"api.adapters.accept_invitation_for_user",
|
||||
return_value=(invitation, True),
|
||||
),
|
||||
):
|
||||
mock_super.return_value.save_user.return_value = user
|
||||
saved_user = adapter.save_user(request, sociallogin)
|
||||
|
||||
assert saved_user.name == "octocat"
|
||||
assert request.prowler_invitation_token == "token"
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
class TestProwlerSocialAccountAdapter:
|
||||
def test_get_user_by_email_returns_user(self, create_test_user):
|
||||
|
||||
@@ -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" },
|
||||
@@ -4935,7 +4935,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "prowler-api"
|
||||
version = "1.40.0"
|
||||
version = "1.41.0"
|
||||
source = { virtual = "." }
|
||||
dependencies = [
|
||||
{ name = "cartography" },
|
||||
@@ -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" },
|
||||
]
|
||||
|
||||
@@ -4,6 +4,117 @@ description: "New features and improvements in each Prowler release"
|
||||
rss: true
|
||||
---
|
||||
|
||||
<Update label="v5.39.0" description="August 13, 2026">
|
||||
### 🤖 Lighthouse AI — Finding Skills
|
||||
|
||||
<Note>
|
||||
This feature is available exclusively in **Prowler Cloud** and **Prowler Private Cloud** with a [subscription](https://prowler.com/pricing).
|
||||
</Note>
|
||||
|
||||
Lighthouse AI now embeds a Skills menu on every finding, answering the questions an analyst actually asks. **Contextual Fix** produces the fix for the finding, **Triage Decision** judges whether it is real and closes it out when it is not, and **Systemic Scope** determines whether the problem is a one-off or everywhere. A free-form "Ask Lighthouse anything" prompt sits in the same menu, and each run shows its progress and offers follow-up actions such as creating a Jira issue or muting the finding.
|
||||
|
||||

|
||||
|
||||
Read more in the [Lighthouse AI documentation](https://docs.prowler.com/getting-started/products/prowler-cloud-lighthouse).
|
||||
|
||||
### ☁️ Azure Management Group Onboarding
|
||||
|
||||
<Note>
|
||||
This feature is available exclusively in **Prowler Cloud** and **Prowler Private Cloud** with a [subscription](https://prowler.com/pricing).
|
||||
</Note>
|
||||
|
||||
Azure subscriptions no longer onboard one at a time. Choose "Add Multiple Subscriptions With Azure Management Group" in the add-provider wizard, enter the Microsoft Entra tenant ID, and authenticate once with a single tenant-wide service principal: Prowler discovers the entire management-group hierarchy under the tenant root, lets you select the subscriptions to onboard, and creates their providers with the management-group structure preserved. Azure now matches the one-step onboarding that AWS Organizations and GCP organizations already have.
|
||||
|
||||

|
||||
|
||||
Read more in the [Azure Management Groups documentation](https://docs.prowler.com/user-guide/tutorials/prowler-cloud-azure-management-groups).
|
||||
|
||||
### ✅ Findings Triage — Verify MANUAL Findings as PASS
|
||||
|
||||
<Note>
|
||||
This feature is available exclusively in **Prowler Cloud** and **Prowler Private Cloud** with a [subscription](https://prowler.com/pricing).
|
||||
</Note>
|
||||
|
||||
Checks that require human judgment report `MANUAL` findings. For these findings, and only for them, the triage status selector now offers **Resolved**: choosing it asks for the required written evidence and verifies the finding as passing. The finding then reports an effective `PASS` while preserving the raw `MANUAL` scan result, across findings, finding groups, compliance reports, and scans, with the attestation's author, evidence, and validity always visible. Attestations expire automatically after 90 days, or as soon as a new scan reports a real failure, returning the finding to the review queue.
|
||||
|
||||

|
||||
|
||||

|
||||
|
||||
Read more in the [Findings Triage documentation](https://docs.prowler.com/user-guide/tutorials/prowler-app-findings-triage#verify-a-manual-finding-as-pass).
|
||||
|
||||
### ☁️ Prowler Cloud MCP — Organizations Management and Grouped Jira Dispatch
|
||||
|
||||
<Note>
|
||||
This feature is available exclusively in **Prowler Cloud** and **Prowler Private Cloud** with a [subscription](https://prowler.com/pricing).
|
||||
</Note>
|
||||
|
||||
The hosted Prowler Cloud MCP server adds eight organization tools, so an agent can onboard and manage entire cloud organizations end to end: create the organization, discover its accounts, subscriptions, and projects, apply the selection, and manage the resulting providers. The tools cover AWS Organizations, GCP organizations, and Azure tenant root management groups, and they are available to Lighthouse AI.
|
||||
|
||||
`prowler_send_findings_to_jira` also gains Cloud-only dispatch capabilities: select failed findings by check IDs against the latest completed scan, and send them in grouped mode, one Jira work item per check listing up to 50 affected resources, with per-group error reporting.
|
||||
|
||||
Read more in the [Prowler MCP tools documentation](https://docs.prowler.com/getting-started/basic-usage/prowler-mcp-tools) and its [Jira operations reference](https://docs.prowler.com/getting-started/basic-usage/prowler-mcp-tools#jira-operations).
|
||||
|
||||
### 🕸️ Attack Paths — Grouped Graph with Outcome Destinations
|
||||
|
||||
<Note>
|
||||
This feature is available exclusively in **Prowler Cloud** and **Prowler Private Cloud** with a [subscription](https://prowler.com/pricing).
|
||||
</Note>
|
||||
|
||||
The Attack Paths graph now reads from source to destination. Resources of the same class collapse into a single expandable node with a count, clicking reveals its members, and every path terminates in an explicit outcome node naming the destination impact: code execution, privilege escalation, public exposure, or resource inventory. The per-account hub node is gone, and the clicked resource stays highlighted while its findings are expanded.
|
||||
|
||||

|
||||
|
||||
Explore the full Attack Paths query catalog at [Prowler Hub](https://hub.prowler.com/attack-paths).
|
||||
|
||||
Read more in the [Attack Paths documentation](https://docs.prowler.com/user-guide/tutorials/prowler-app-attack-paths).
|
||||
|
||||
### 📚 New Compliance Framework — CMMC 2.0
|
||||
|
||||
The Cybersecurity Maturity Model Certification (CMMC) is the certification the US Department of Defense requires from contractors and suppliers that handle federal contract data. Prowler now includes CMMC 2.0 as a universal framework with all 149 requirements defined by the CMMC Program rule (32 CFR Part 170), organized in its three levels:
|
||||
|
||||
- **Level 1 (Foundational):** 15 requirements for the basic safeguarding of Federal Contract Information, from FAR 52.204-21.
|
||||
- **Level 2 (Advanced):** 110 requirements from NIST SP 800-171 Rev 2, protecting Controlled Unclassified Information.
|
||||
- **Level 3 (Expert):** 24 enhanced requirements from NIST SP 800-172 for the most sensitive programs.
|
||||
|
||||
Requirements map to Prowler checks across AWS, Azure, GCP, Alibaba Cloud, Oracle Cloud, and Microsoft 365, so one framework reports the compliance posture of the whole estate.
|
||||
|
||||
Read more in the [Compliance documentation](https://docs.prowler.com/user-guide/compliance/tutorials/compliance).
|
||||
|
||||
### 🔍 Checks
|
||||
|
||||
#### Microsoft 365
|
||||
|
||||
Twenty new Entra ID checks expand the coverage of CIS Microsoft 365 Foundations Benchmark v7.0.0:
|
||||
|
||||
- **Password protection:** custom banned password list, on-premises enforcement, and lockout threshold and duration.
|
||||
- **Default user permissions:** security group and Microsoft 365 group creation restricted, and guest invitations limited to allowed domains.
|
||||
- **Conditional Access:** high and medium sign-in risk blocked, authentication transfer blocked, untrusted locations blocked, trusted named locations defined, sign-in frequency enforced, and token protection enforced.
|
||||
- **Sessions and authentication methods:** idle session timeout configured, email one-time passcodes disabled, and Microsoft Authenticator context shown.
|
||||
- **PIM and access reviews:** approval required to activate the Global Administrator and Privileged Role Administrator roles, and access reviews configured for guest users and privileged roles.
|
||||
|
||||
Explore all Microsoft 365 checks at [Prowler Hub](https://hub.prowler.com/check?provider=m365).
|
||||
|
||||
#### AWS
|
||||
|
||||
Two new checks detect hardcoded secrets:
|
||||
|
||||
- `batch_job_definition_no_secrets` scans Batch job definition environment variables and command parameters. Thanks to @praneetrajv!
|
||||
- `awslambda_layer_no_secrets_in_content` scans Lambda layer package content. Thanks to @ganiganesh25!
|
||||
|
||||
Explore all AWS checks at [Prowler Hub](https://hub.prowler.com/check?provider=aws).
|
||||
|
||||
### 🙌 External Contributors
|
||||
|
||||
Thank you to our community contributors for this release!
|
||||
|
||||
- @praneetrajv: AWS `batch_job_definition_no_secrets` check ([#12117](https://github.com/prowler-cloud/prowler/pull/12117))
|
||||
- @ganiganesh25: AWS `awslambda_layer_no_secrets_in_content` check ([#12233](https://github.com/prowler-cloud/prowler/pull/12233))
|
||||
- @andoniaf: GitHub `organization_repository_creation_limited` now reports low severity when repository creation is limited to private or internal visibility ([#12164](https://github.com/prowler-cloud/prowler/pull/12164))
|
||||
|
||||
See the [full release notes on GitHub](https://github.com/prowler-cloud/prowler/releases/tag/5.39.0) for the complete list of changes.
|
||||
</Update>
|
||||
|
||||
<Update label="v5.38.0" description="August 6, 2026">
|
||||
### 📌 Compliance Watchlist
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -44,11 +44,6 @@ The main server orchestrates three sub-servers with prefixed namespacing:
|
||||
mcp_server/prowler_mcp_server/
|
||||
├── server.py # Main orchestrator
|
||||
├── main.py # CLI entry point
|
||||
├── lib/
|
||||
│ ├── server.py # ProwlerMCP, the base class of every sub-server
|
||||
│ ├── errors.py # Exception types and the one error renderer
|
||||
│ ├── logger.py
|
||||
│ └── analytics.py
|
||||
├── prowler_hub/
|
||||
├── prowler_app/
|
||||
│ ├── tools/ # Tool implementations
|
||||
@@ -64,8 +59,6 @@ The MCP Server uses two patterns for tool registration:
|
||||
1. **Direct Decorators** (Prowler Hub/Docs): Tools are registered using `@mcp.tool()` decorators
|
||||
2. **Auto-Discovery** (`prowler_app`): All public methods of `BaseTool` subclasses are auto-registered
|
||||
|
||||
Both funnel through `ProwlerMCP.tool` (`lib/server.py`), which is what applies the error contract to every tool no matter how it was registered. Build sub-servers with `ProwlerMCP`, never `FastMCP` directly.
|
||||
|
||||
## Adding Tools to the `prowler_app` Sub-Server
|
||||
|
||||
### Step 1: Create the Tool Class
|
||||
@@ -127,10 +120,12 @@ class NewFeatureTools(BaseTool):
|
||||
|
||||
Returns complete feature details including configuration and metadata.
|
||||
"""
|
||||
# No try/except: a failure here raises, and the tool wrapper turns it into a
|
||||
# ToolError the client sees as `isError: true`. See "Error Handling" below.
|
||||
response = await self.api_client.get(f"/api/v1/features/{feature_id}")
|
||||
return DetailedFeature.from_api_response(response["data"]).model_dump()
|
||||
try:
|
||||
response = await self.api_client.get(f"/api/v1/features/{feature_id}")
|
||||
return DetailedFeature.from_api_response(response["data"]).model_dump()
|
||||
except Exception as e:
|
||||
self.logger.error(f"Failed to get feature {feature_id}: {e}")
|
||||
return {"error": str(e), "status": "failed"}
|
||||
```
|
||||
|
||||
### Step 2: Create the Models
|
||||
@@ -374,101 +369,18 @@ async def search_items(self, status: str = Field(...)) -> dict:
|
||||
|
||||
### Error Handling
|
||||
|
||||
Let failures raise. Every sub-server is a `ProwlerMCP` (`prowler_mcp_server/lib/server.py`),
|
||||
whose `tool()` wraps whatever it registers in `tool_errors`, turning any exception into a
|
||||
`ToolError`. The client sees `isError: true` and a message it can act on.
|
||||
|
||||
That wrapping is not something you apply — the two registration styles (the `@mcp.tool()`
|
||||
decorators, and the direct `mcp.tool(fn)` call `BaseTool` uses) both funnel through
|
||||
`ProwlerMCP.tool`. Build sub-servers with `ProwlerMCP`, never `FastMCP` directly: masking
|
||||
is on everywhere, so a tool that escaped the funnel would answer `Error calling tool 'x'`
|
||||
with no detail at all.
|
||||
|
||||
Never `return {"error": ...}`: a returned payload is `isError: false` at the MCP protocol
|
||||
level, so the client is told the call succeeded and only finds out otherwise if it happens
|
||||
to inspect the right key.
|
||||
Return structured error responses instead of raising exceptions:
|
||||
|
||||
```python
|
||||
async def get_item(self, item_id: str) -> dict:
|
||||
"""A rejected request, a timeout and a malformed payload all raise from here.
|
||||
|
||||
Each is rendered with the API's own words plus what it implies about retrying.
|
||||
"""
|
||||
response = await self.api_client.get(f"/api/v1/items/{item_id}")
|
||||
return DetailedItem.from_api_response(response["data"]).model_dump()
|
||||
try:
|
||||
response = await self.api_client.get(f"/api/v1/items/{item_id}")
|
||||
return DetailedItem.from_api_response(response["data"]).model_dump()
|
||||
except Exception as e:
|
||||
self.logger.error(f"Failed to get item {item_id}: {e}")
|
||||
return {"error": str(e), "status": "failed"}
|
||||
```
|
||||
|
||||
Raise `ToolError` whenever the message is one you wrote for the caller. Its text reaches
|
||||
the client verbatim, so anything they need in order to recover has to be *in* the message
|
||||
— an error carries nothing else:
|
||||
|
||||
```python
|
||||
from fastmcp.exceptions import ToolError
|
||||
|
||||
if not data:
|
||||
raise ToolError(
|
||||
f"Item '{item_id}' was not found. Use prowler_list_items to find valid IDs."
|
||||
)
|
||||
```
|
||||
|
||||
**Do not raise `ValueError` from a tool.** The two are not interchangeable: anything that
|
||||
is not a `ToolError` is described as a bug in this server. That is right for a model
|
||||
factory rejecting an API payload or a pydantic `ValidationError`, and wrong for a
|
||||
refusal — so the exception type is what carries the distinction:
|
||||
|
||||
```text
|
||||
Date range cannot exceed 2 days. Requested range: 2025-01-01 to 2025-01-10 (10 days)
|
||||
|
||||
The Prowler MCP Server hit an unexpected ValueError: Missing pagination metadata in API
|
||||
response. This is a bug in the server, not something you can fix by changing the
|
||||
arguments.
|
||||
```
|
||||
|
||||
If you surface an exception yourself — into a `ToolError` you build, or into a field of a
|
||||
structured result — pass it through `render_tool_error(e)` rather than `str(e)`, so the
|
||||
same failure is never described two ways. Pass `warn=False` when the result already
|
||||
reports the outcome.
|
||||
|
||||
#### Deciding between an error and a result
|
||||
|
||||
Ask two questions, in order:
|
||||
|
||||
1. **Did the tool finish its own job?** `test_integration_connection`'s job is to run the
|
||||
check and report what happened, so `connected: false` is the job finished.
|
||||
`get_finding_details`' job is to return the finding, so no finding means it did not.
|
||||
2. **Is the reported state a fact about the remote world or about our call?** The world
|
||||
(Jira refused the credentials, 3 of 40 items failed, a discovery found nothing) is a
|
||||
**result**. Our call (403, connection reset, invalid UUID, a bug in a model factory) is
|
||||
an **error**.
|
||||
|
||||
One rule overrides both: **if a write may have partially landed, that fact travels in a
|
||||
successful structured result, never in an error.** An agent reads `isError: true` as
|
||||
"nothing happened, safe to retry"; reporting "I may have created 17 Jira issues" that way
|
||||
invites a duplicate dispatch.
|
||||
|
||||
#### What the client reads
|
||||
|
||||
`render_tool_error` describes the failure in one plain sentence: the call, the status and
|
||||
whatever the API said, with the field named when it named one.
|
||||
|
||||
```text
|
||||
GET /findings/b1ca536c failed with HTTP 404. No Finding matches the given query.
|
||||
POST /integrations failed with HTTP 400. This field may not be blank. (/data/attributes/configuration/bucket_name); Enter a valid URL.
|
||||
Date range cannot exceed 2 days. Requested range: 2025-01-01 to 2025-01-10 (10 days)
|
||||
```
|
||||
|
||||
Nothing is added that the status code already implies. The one exception is a request that
|
||||
could have changed something and never came back with a verdict — a 5xx or a timeout on a
|
||||
write — which gets a warning, because an agent otherwise reads any failure as "nothing
|
||||
happened" and sends the write again:
|
||||
|
||||
```text
|
||||
DELETE /integrations/i1 failed with HTTP 500. A server error occurred. It may have been carried out anyway, so check the current state before retrying.
|
||||
```
|
||||
|
||||
Every server sets `mask_error_details=True`. That costs nothing, because `ToolError`
|
||||
bypasses masking; it only stops raw internals escaping from code paths outside a tool.
|
||||
|
||||
### Parameter Descriptions
|
||||
|
||||
Use Pydantic `Field()` with clear descriptions. This also helps LLMs understand
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -11,10 +11,10 @@ Complete reference guide for all tools available in the Prowler MCP Server. Tool
|
||||
| Prowler Hub | 10 tools | No | Cloud and Local MCP Server |
|
||||
| Prowler Documentation | 2 tools | No | Cloud and Local MCP Server |
|
||||
| Prowler Cloud, Private Cloud & Local Server | 49 tools | Yes | Cloud and Local MCP Server |
|
||||
| Prowler Cloud management | 32 tools | Yes | Cloud MCP Server only |
|
||||
| Prowler Cloud management | 40 tools | Yes | Cloud MCP Server only |
|
||||
|
||||
<Note>
|
||||
48 of the 49 Prowler tools are available on both servers. `prowler_schedule_daily_scan` is the exception: it is Local-only, because the Cloud MCP Server supersedes it with the `prowler_cloud_*` [Scan Scheduling](#scan-scheduling) tools.
|
||||
48 of the 49 Prowler tools are available on both servers. `prowler_schedule_daily_scan` is the exception: it is Local-only, because the Cloud MCP Server supersedes it with the `prowler_cloud_*` [Scan Scheduling](#scan-scheduling) tools. `prowler_send_findings_to_jira` is exposed by both servers but accepts two [extra parameters](#jira-operations) on the Cloud MCP Server.
|
||||
</Note>
|
||||
|
||||
## Tool Naming Convention
|
||||
@@ -124,7 +124,20 @@ Tools for managing where Prowler sends its results: Amazon S3 buckets, AWS Secur
|
||||
#### Jira Operations
|
||||
|
||||
- **`prowler_get_jira_issue_types`** - List the issue types available in a Jira project, fetched live from Jira
|
||||
- **`prowler_send_findings_to_jira`** - Create one Jira work item per finding, with its severity, resource, risk, and remediation steps
|
||||
- **`prowler_send_findings_to_jira`** - Create Jira work items from findings, each carrying the check title, severity, status, provider, region, resource, risk, and remediation steps. Select the findings either by ID with `finding_ids`, or — on Prowler Cloud only — by check with `check_ids`, and choose between one work item per finding or one per check with `dispatch_mode`
|
||||
|
||||
<Note>
|
||||
`check_ids` and `dispatch_mode` are **Prowler Cloud only**:
|
||||
|
||||
- **`check_ids`** - Send the failing findings of a check (for example `s3_bucket_public_access`) without listing their IDs. Prowler resolves them server-side, taking only the failed findings of the latest completed scan of every provider. Get the check IDs from `prowler_list_finding_groups`. Exactly one of `finding_ids` or `check_ids` is required — Prowler combines both filters, so sending both would only dispatch their intersection. A Local MCP Server rejects `check_ids` with a client error.
|
||||
- **`dispatch_mode`** - `individual` (the default) creates one work item per finding. `grouped` creates one work item per check instead, listing up to 50 affected resources and linking back to the finding group in Prowler Cloud, which keeps a noisy check to a single ticket. Grouped dispatch only covers failed, unmuted findings of the latest completed scan of every provider. A Local MCP Server ignores `dispatch_mode` instead of rejecting it, and creates one work item per finding.
|
||||
|
||||
In `grouped` mode the response counters change meaning: `created_count` counts work items (one per check) rather than findings, `failed_count` counts the entries of the new `failed_groups` field, and `failed_groups` details each failure with its reason and the `check_id` whose work item could not be created.
|
||||
</Note>
|
||||
|
||||
<Warning>
|
||||
`prowler_send_findings_to_jira` creates real work items that Prowler cannot delete or update afterwards. Only retry the same dispatch when the previous response returned `safe_to_retry: true`, otherwise the work items already created are duplicated. Combining `check_ids` with the default `individual` mode opens one work item per failing resource, which can be hundreds of them — use `dispatch_mode="grouped"` to keep it to one per check.
|
||||
</Warning>
|
||||
|
||||
### Attack Paths Analysis
|
||||
|
||||
@@ -167,6 +180,23 @@ Manage Prowler Cloud-only features and configuration. **Requires authentication.
|
||||
These tools are available **only on the Cloud MCP Server** (`https://mcp.prowler.com/mcp`). A Local MCP Server does not expose them, because the features they manage exist only in Prowler Cloud.
|
||||
</Note>
|
||||
|
||||
### Organizations
|
||||
|
||||
Tools for onboarding a cloud provider organization as a whole — an AWS Organization, an Azure tenant with its management groups, or a GCP organization with its folders. An organization holds org-level credentials, discovers the real account, subscription, or project structure in the cloud, and turns a selection from that discovery into Prowler providers linked into a hierarchy of nodes. Every tool that changes something — creating, updating, deleting, discovering, applying a discovery, or adjusting provider membership — requires the **Manage Providers** permission; listing and reading do not.
|
||||
|
||||
<Note>
|
||||
Use these tools for the whole organization. To register providers one by one, use the [Provider Management](#provider-management) tools instead; to build arbitrary RBAC buckets of providers, use provider groups.
|
||||
</Note>
|
||||
|
||||
- **`prowler_cloud_list_organizations`** - Browse the registered organizations with lightweight data (name, type, external id, provider and node counts), filtered by type or cloud-side external id
|
||||
- **`prowler_cloud_get_organization`** - Get one organization in full: attributes, linked providers, credentials status, latest discovery, and the OU / management group / folder hierarchy. Set `include_hierarchy` to `false` to skip the tree on large organizations
|
||||
- **`prowler_cloud_create_organization`** - Register an organization, optionally storing its org-level credentials in the same call. Idempotent: an organization with the same type and external id is reused and its credentials rotated, reported as `created: false`
|
||||
- **`prowler_cloud_update_organization`** - Rename an organization, replace its metadata, and/or create or rotate its org-level credentials. `org_type` and `external_id` are immutable after creation
|
||||
- **`prowler_cloud_delete_organization`** - Delete an organization, its entire hierarchy, and every linked provider
|
||||
- **`prowler_cloud_discover_organization`** - Enumerate the real cloud structure: AWS accounts and OUs, Azure subscriptions and management groups, or GCP projects and folders. Each item comes back with its registration state so you can choose what to onboard
|
||||
- **`prowler_cloud_apply_organization_discovery`** - Turn a discovery selection into Prowler providers and hierarchy nodes
|
||||
- **`prowler_cloud_manage_organization_providers`** - Manually `add`, `replace`, or `remove` the providers linked to an organization or to one of its hierarchy nodes. Providers are detached, never deleted
|
||||
|
||||
### Scan Configurations
|
||||
|
||||
Tools for managing reusable scan configurations — per-provider check and compliance selections — and attaching them to providers. Providers without a configuration attached use the default.
|
||||
|
||||
@@ -128,12 +128,12 @@ To update the environment file:
|
||||
Edit the `.env` file and change version values:
|
||||
|
||||
```env
|
||||
PROWLER_UI_VERSION="5.38.0"
|
||||
PROWLER_API_VERSION="5.38.0"
|
||||
PROWLER_UI_VERSION="5.39.0"
|
||||
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>
|
||||
|
||||
|
||||
|
||||
|
After Width: | Height: | Size: 210 KiB |
|
After Width: | Height: | Size: 182 KiB |
|
After Width: | Height: | Size: 563 KiB |
|
After Width: | Height: | Size: 401 KiB |
|
After Width: | Height: | Size: 374 KiB |
|
Before Width: | Height: | Size: 111 KiB After Width: | Height: | Size: 286 KiB |
|
Before Width: | Height: | Size: 97 KiB After Width: | Height: | Size: 252 KiB |
|
After Width: | Height: | Size: 366 KiB |
|
Before Width: | Height: | Size: 136 KiB After Width: | Height: | Size: 269 KiB |
|
Before Width: | Height: | Size: 107 KiB After Width: | Height: | Size: 103 KiB |
|
After Width: | Height: | Size: 401 KiB |
|
After Width: | Height: | Size: 456 KiB |
|
After Width: | Height: | Size: 374 KiB |
@@ -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:
|
||||
[
|
||||
|
||||
@@ -16,26 +16,24 @@ Attack Paths analyzes relationships between cloud resources, permissions, and se
|
||||
By mapping these relationships as a graph, Attack Paths reveals risks that individual security checks cannot detect on their own, such as an IAM role that can escalate its own permissions, or a chain of policies that grants unintended access to sensitive resources.
|
||||
|
||||
<Note>
|
||||
Attack Paths is currently available for **AWS** providers. Support for
|
||||
additional providers is planned.
|
||||
Attack Paths is currently available for **AWS** providers. Support for additional providers is planned.
|
||||
</Note>
|
||||
|
||||
## Prerequisites
|
||||
|
||||
The following prerequisites are required for Attack Paths:
|
||||
|
||||
- **An AWS provider is configured** with valid credentials in Prowler Cloud. For setup instructions, see [Getting Started with AWS](/user-guide/providers/aws/getting-started-aws).
|
||||
- **An AWS provider is configured** with valid credentials. For setup instructions, see [Getting Started with AWS](/user-guide/providers/aws/getting-started-aws).
|
||||
- **At least one scan has completed** on the configured AWS provider and produced graph data. Attack Paths scans run automatically alongside regular security scans, no separate configuration is required.
|
||||
|
||||
## How Attack Paths Scans Work
|
||||
|
||||
Attack Paths scans are generated automatically when a security scan runs on an AWS provider. Each completed scan produces graph data that maps relationships between IAM principals, policies, trust configurations, and other resources.
|
||||
Attack Paths scans are generated automatically when a security scan runs on an AWS provider. When a scan produces graph data, it maps relationships between IAM principals, policies, trust configurations, and other resources. A scan can complete without producing graph data.
|
||||
|
||||
Once the scan finishes and graph data is ready, the scan appears in the Attack Paths scan table with a **Completed** status and a check in the **Graph** column. Scans that are still queued or running remain visible, but they cannot be selected until graph data is ready.
|
||||
When graph data is ready, the scan appears in the Attack Paths scan table with a check in the **Graph** column and can be selected regardless of its current status. Scans without graph data remain visible but cannot be selected. If a new scan cycle starts after graph data is available, the previous cycle remains available while the new scan runs.
|
||||
|
||||
<Note>
|
||||
Since Prowler scans all configured providers every **24 hours** by default,
|
||||
Attack Paths data stays up to date automatically.
|
||||
Prowler Cloud and Prowler Private Cloud scan configured providers every **24 hours** by default, so Attack Paths data stays up to date automatically.
|
||||
</Note>
|
||||
|
||||
## Accessing Attack Paths
|
||||
@@ -66,7 +64,7 @@ The scans table displays all Attack Paths scans with the following columns:
|
||||
- **Graph:** Whether Attack Paths graph data is available for the scan.
|
||||
- **Duration:** Total scan time.
|
||||
|
||||
To select a scan for analysis, click the radio button on any row with a **Completed** status and available graph data.
|
||||
To select a scan for analysis, click any row with a check in the **Graph** column. A row can remain selectable while a new scan cycle runs because Attack Paths keeps the graph from the previous completed cycle available.
|
||||
|
||||
<img
|
||||
src="/images/prowler-app/attack-paths/scan-list-table.png"
|
||||
@@ -75,8 +73,7 @@ To select a scan for analysis, click the radio button on any row with a **Comple
|
||||
/>
|
||||
|
||||
<Note>
|
||||
Only scans with graph data can be selected. Disabled rows include a tooltip
|
||||
that explains why the graph is not available yet.
|
||||
Only scans with graph data can be selected. Disabled rows include a tooltip that explains why the graph is not available yet.
|
||||
</Note>
|
||||
|
||||
## Choosing a Query
|
||||
@@ -97,9 +94,7 @@ To choose a query, click the dropdown and select from the available options. Eac
|
||||
Once selected, a description panel appears below the dropdown with more context about the query.
|
||||
|
||||
<Note>
|
||||
In Prowler Cloud and Prowler Private Cloud, the query selector hides queries
|
||||
confirmed empty for the selected scan, so only queries that return data remain
|
||||
visible. See [Active Queries](/user-guide/tutorials/prowler-app-attack-paths-active-queries).
|
||||
In Prowler Cloud and Prowler Private Cloud, the query selector hides built-in queries confirmed empty for the selected scan. Built-in queries without a confirmed empty result and the **Custom openCypher query** remain visible. See [Active Queries](/user-guide/tutorials/prowler-app-attack-paths-active-queries).
|
||||
</Note>
|
||||
|
||||
## Configuring Query Parameters
|
||||
@@ -120,7 +115,7 @@ For example, **Internet-Exposed EC2 with Sensitive S3 Access** uses **Tag key**
|
||||
|
||||
## Writing Custom openCypher Queries
|
||||
|
||||
In addition to the built-in queries, Attack Paths supports custom read-only [openCypher](https://opencypher.org/) queries. Custom queries provide direct access to the underlying graph so security teams can answer ad-hoc questions, prototype detections, or extend coverage beyond the built-in catalogue.
|
||||
In addition to the built-in queries, Attack Paths supports custom read-only [openCypher](https://opencypher.org/) queries. Custom queries provide direct access to the underlying graph so security teams can answer ad-hoc questions, prototype detections, or extend coverage beyond the built-in catalog.
|
||||
|
||||
To write a custom query, select **Custom openCypher query** from the query dropdown. A code editor with syntax highlighting and line numbers appears, ready to receive the query.
|
||||
|
||||
@@ -192,11 +187,7 @@ Custom queries traverse the same Cartography graph the built-in queries use. Nod
|
||||
For the complete reference, including the graph model, list-typed and JSON-encoded properties, performance guidance, and openCypher compatibility rules, see [Attack Paths Queries](/developer-guide/attack-paths-queries) in the Developer Guide.
|
||||
|
||||
<Note>
|
||||
AI assistants connected through Prowler MCP Server can fetch the exact
|
||||
Cartography schema for the active scan via the
|
||||
`prowler_get_attack_paths_cartography_schema` tool. This guarantees that
|
||||
generated queries match the schema version pinned by the running Prowler
|
||||
release.
|
||||
AI assistants connected through Prowler MCP Server can fetch the exact Cartography schema for the active scan via the `prowler_get_attack_paths_cartography_schema` tool. This guarantees that generated queries match the schema version pinned by the running Prowler release.
|
||||
</Note>
|
||||
|
||||
## Executing a Query
|
||||
@@ -221,12 +212,22 @@ If the query returns no results, an informational message appears. Common reason
|
||||
|
||||
After a successful execution, the graph visualization renders below the query builder. The graph maps relationships between cloud resources, IAM entities, public exposure, and security findings.
|
||||
|
||||
### Grouped Graphs and Query Outcomes
|
||||
|
||||
<VersionBadge version="5.39.0" />
|
||||
|
||||
Prowler Cloud and Prowler Private Cloud group resources of the same class and graph level into expandable nodes. Built-in query graphs also end with a query outcome, which states the result that the path can lead to.
|
||||
|
||||
Prowler Local Server keeps the flat graph view, including the provider root. Custom openCypher queries do not have a catalog outcome, so their graphs do not include an outcome node.
|
||||
|
||||
### Node Types
|
||||
|
||||
- **Provider root nodes:** Represent the AWS account or provider root for the selected scan.
|
||||
- **Resource nodes:** Represent cloud resources such as IAM roles, policies, EC2 instances, security groups, and S3 buckets.
|
||||
- **Grouped resource nodes:** Represent multiple resources of the same class in Prowler Cloud and Prowler Private Cloud. A number in the upper-right corner shows how many resources the node contains. A red outline indicates that one or more resources in the group have findings.
|
||||
- **Resource nodes:** Represent individual cloud resources such as IAM roles, policies, EC2 instances, security groups, and S3 buckets. A class with one resource remains an individual node.
|
||||
- **Internet nodes:** Represent exposure from the public internet.
|
||||
- **Finding nodes:** Represent Prowler findings linked to resources. Finding colors indicate risk level, such as critical, high, medium, or low.
|
||||
- **Outcome nodes:** Mark the terminal result of a built-in query in Prowler Cloud and Prowler Private Cloud. The orange node displays outcomes such as **Code execution**, **Privilege escalation**, **Public exposure**, or **Resource inventory**. A dashed ring and the label **Latent outcome** indicate an inventory or another partial outcome.
|
||||
- **Provider root nodes:** Represent the AWS account or provider root in the Prowler Local Server flat graph.
|
||||
|
||||
### Edge Types
|
||||
|
||||
@@ -234,7 +235,7 @@ After a successful execution, the graph visualization renders below the query bu
|
||||
- **Finding edges:** Dashed relationships between resources and their associated findings.
|
||||
- **Highlighted paths:** Green edges that show the active path when you hover a node or focus a finding.
|
||||
|
||||
The standard graph view includes a minimap and a legend below the canvas. The legend shows the provider roots, visible node types, finding risk levels, node states, and edge types present in the current view.
|
||||
The standard graph view includes a minimap and a legend below the canvas. The legend shows the visible node types, finding risk levels, node states, and edge types present in the current view. Prowler Local Server also displays the provider root in the legend.
|
||||
|
||||
<img
|
||||
src="/images/prowler-app/attack-paths/graph-visualization.png"
|
||||
@@ -244,17 +245,32 @@ The standard graph view includes a minimap and a legend below the canvas. The le
|
||||
|
||||
## Interacting with the Graph
|
||||
|
||||
The graph banner describes the main interactions:
|
||||
The graph supports these interactions:
|
||||
|
||||
- Click a node with a number in the upper-right corner to expand its resource group.
|
||||
- Click a finding to focus its connected path.
|
||||
- Click a resource with findings to show or hide its related findings.
|
||||
- Hover a node to highlight its connected path.
|
||||
|
||||
### Expanding Resource Groups
|
||||
|
||||
In Prowler Cloud and Prowler Private Cloud, a node with a number in its upper-right corner represents a resource group. The number is the total number of resources in the group.
|
||||
|
||||
- Click the group node to display its resources. Multiple groups can remain expanded, and the graph automatically fits the visible nodes to the canvas.
|
||||
- Double-click any resource revealed from a group to collapse that group.
|
||||
- Click **Collapse all groups** in the graph toolbar to close every expanded group. This control appears only while at least one group is expanded.
|
||||
|
||||
<img
|
||||
src="/images/prowler-app/attack-paths/graph-visualization-expanded.png"
|
||||
alt="Attack Paths graph showing an expanded AWS Role group and remaining numbered resource groups"
|
||||
width="700"
|
||||
/>
|
||||
|
||||
### Showing Related Findings
|
||||
|
||||
Resource nodes with related findings are clickable. Click one of these resources to show its finding nodes. Click the resource again to hide them.
|
||||
|
||||
The graph automatically fits the selected resource and its related findings when the findings are shown.
|
||||
The selected resource is highlighted in green while its findings are visible. The graph automatically fits the selected resource and its related findings when the findings are shown.
|
||||
|
||||
### Focusing a Finding Path
|
||||
|
||||
@@ -279,12 +295,12 @@ The toolbar in the top-right corner of the graph provides:
|
||||
|
||||
- **Zoom in / Zoom out:** Adjust the zoom level
|
||||
- **Fit graph to view:** Reset the view to fit the visible graph
|
||||
- **Export graph:** Download the current graph as a PNG file
|
||||
- **Collapse all groups:** Close every expanded resource group. This control appears only when a group is expanded
|
||||
- **Export graph:** Download the current graph, including its grouped or expanded state and outcome, as a PNG file
|
||||
- **Fullscreen:** Open the graph in a full-size modal
|
||||
|
||||
<Note>
|
||||
Use **Ctrl + Scroll** (or **Cmd + Scroll** on macOS) to zoom directly within
|
||||
the graph area.
|
||||
Use **Ctrl + Scroll** (or **Cmd + Scroll** on macOS) to zoom directly within the graph area.
|
||||
</Note>
|
||||
|
||||
## Viewing Finding Details
|
||||
|
||||
@@ -42,12 +42,12 @@ The status selector includes manual statuses. Prowler also sets automatic status
|
||||
| **Remediating** | Manual | Work is in progress to fix the finding. |
|
||||
| **Risk Accepted** | Manual | The team accepts the risk and wants to mute the finding. |
|
||||
| **False Positive** | Manual | The finding does not apply and should be muted. |
|
||||
| **Resolved** | Automatic | A finding changed from `FAIL` to `PASS` in a later scan. A passed finding with no saved triage state also appears as **Resolved**. |
|
||||
| **Resolved** | Automatic / Manual | A finding changed from `FAIL` to `PASS` in a later scan. A passed finding with no saved triage state also appears as **Resolved**. On `MANUAL` findings, select it to verify the finding as passing (see [Verify a MANUAL Finding as Pass](#verify-a-manual-finding-as-pass)). |
|
||||
| **Reopened** | Automatic | A finding changed from `PASS` to `FAIL` in a later scan. |
|
||||
|
||||

|
||||
|
||||
Resolved and Reopened are not manual selector options.
|
||||
**Reopened** is never a manual selector option. **Resolved** appears in the selector only on `MANUAL` findings, where it starts the [Manual Pass verification](#verify-a-manual-finding-as-pass).
|
||||
|
||||
These automatic states keep triage tied to the finding UID across scans, even when each scan creates a new finding snapshot.
|
||||
|
||||
@@ -93,6 +93,39 @@ Triage notes are visible only to the team in the current organization. Each note
|
||||
|
||||
To remove an existing note, clear the note text and save the change.
|
||||
|
||||
## Verify a MANUAL Finding as Pass
|
||||
|
||||
<VersionBadge version="5.39.0" />
|
||||
|
||||
Checks that Prowler cannot judge automatically report `MANUAL` findings. When a team verifies such a control outside Prowler, the triage selector on that finding offers **Resolved**: choosing it records a Manual Pass attestation, and the finding reports an effective `PASS` while keeping the raw `MANUAL` scan result.
|
||||
|
||||

|
||||
|
||||
<Steps>
|
||||
<Step title="Filter MANUAL findings">
|
||||
Go to **Findings** and filter by status **Manual**.
|
||||
</Step>
|
||||
<Step title="Open the triage selector">
|
||||
Expand a Finding Group and click the current status in the **Triage** column of an individual finding.
|
||||
</Step>
|
||||
<Step title="Choose Resolved">
|
||||
Select **Resolved**. Prowler opens the triage note modal with a required **Manual pass evidence** field.
|
||||
</Step>
|
||||
<Step title="Record the evidence">
|
||||
Describe how the control was verified, then click **Save**. The evidence supports up to 500 characters.
|
||||
</Step>
|
||||
</Steps>
|
||||
|
||||

|
||||
|
||||
After saving, the finding reports `PASS` in finding tables, finding groups, compliance reports, and scans. While the attestation is active, the triage status is managed automatically and cannot be changed. **View Manual Pass details** shows who verified the finding, the evidence, the attestation time, and its expiration.
|
||||
|
||||

|
||||
|
||||
### Attestation Expiration
|
||||
|
||||
A Manual Pass attestation is valid for 90 days. It also ends early when a later scan reports a real failure for the finding. In both cases the finding returns to its raw `MANUAL` status for a new review.
|
||||
|
||||
## Mutelist Behavior
|
||||
|
||||
Findings Triage uses Mutelist when a status means the finding should be muted:
|
||||
@@ -118,7 +151,7 @@ Confirm that the user role has **Manage Scans** permission. Prowler Local Server
|
||||
|
||||
### Resolved or Reopened is missing from the selector
|
||||
|
||||
This is expected. Prowler sets **Resolved** and **Reopened** automatically from scan result changes.
|
||||
**Reopened** is always automatic. **Resolved** is set automatically from scan result changes and appears as a selector option only on `MANUAL` findings, where it records a [Manual Pass](#verify-a-manual-finding-as-pass). On findings with any other status, this is expected.
|
||||
|
||||
### Risk Accepted or False Positive muted a finding
|
||||
|
||||
|
||||
@@ -26,8 +26,6 @@ The Prowler MCP Server provides AI agents access to the Prowler ecosystem throug
|
||||
## CRITICAL RULES
|
||||
|
||||
### Tool Implementation
|
||||
- ALWAYS: Build sub-servers with `ProwlerMCP`, never `FastMCP` directly. It is what
|
||||
applies the error contract to every tool, whichever way it is registered
|
||||
- ALWAYS: Extend `BaseTool` ABC for Prowler tools (auto-registration)
|
||||
- ALWAYS: Use `@mcp.tool()` decorator for Hub/Docs tools
|
||||
- NEVER: Manually register BaseTool subclasses
|
||||
@@ -44,37 +42,6 @@ The Prowler MCP Server provides AI agents access to the Prowler ecosystem throug
|
||||
- ALWAYS: Use `build_filter_params()` for query parameters
|
||||
- NEVER: Create new httpx clients in tools
|
||||
|
||||
### Errors
|
||||
One rule: **`ToolError` is a message you wrote for the caller. Any other exception is
|
||||
a bug or an upstream failure**, and `render_tool_error` describes it.
|
||||
|
||||
- ALWAYS: `raise ToolError(...)` for anything the caller can act on — a rejected
|
||||
argument, a lookup that found nothing, a workflow step they must do first. Its text
|
||||
reaches the client verbatim, past `mask_error_details`
|
||||
- NEVER: `raise ValueError(...)` in a tool. It is reported as a bug in this server,
|
||||
which is correct for a model factory rejecting an API payload and wrong for a
|
||||
refusal
|
||||
- ALWAYS: Let an upstream failure propagate untouched. `ProwlerMCP.tool` wraps every
|
||||
registration, so it becomes a `ToolError` describing the call, the status and what
|
||||
the API said. There is nothing to remember to apply
|
||||
- NEVER: `return {"error": ...}` or `{"success": False}`. A returned payload is
|
||||
`isError: false`, so the client is told the call succeeded
|
||||
- NEVER: Raise a plain exception *after* a write has been accepted. `ToolError` is the
|
||||
only kind whose message reaches the client exactly as written
|
||||
- ALWAYS: `render_tool_error(e)` when you surface an exception yourself, so a failure
|
||||
is never described two different ways. Use `warn=False` when embedding it in a
|
||||
result that already reports the outcome
|
||||
- ALWAYS: Return a structured result, not an error, when a write may have partially
|
||||
landed (`status="unknown"`, `deleted="unknown"`, `safe_to_retry=False`). An agent
|
||||
reads `isError: true` as "nothing happened, safe to retry"
|
||||
- ALWAYS: Return a structured result for an outcome that *is* the tool's job to
|
||||
report: `connected: false`, an empty list, an idempotent no-op
|
||||
- NEVER: Wrap a whole tool body in `except Exception`. It reports bugs in this
|
||||
server as API failures, and the wrapper already handles the rest
|
||||
|
||||
See `prowler_mcp_server/lib/errors.py` and
|
||||
`docs/developer-guide/mcp-server.mdx` for the message format.
|
||||
|
||||
---
|
||||
|
||||
## ARCHITECTURE
|
||||
@@ -105,9 +72,6 @@ Python 3.12+ | FastMCP 3.4.4 | httpx (async) | Pydantic | uv | pytest
|
||||
```text
|
||||
mcp_server/prowler_mcp_server/
|
||||
├── server.py # Main orchestration
|
||||
├── lib/
|
||||
│ ├── server.py # ProwlerMCP: base class of every sub-server
|
||||
│ └── errors.py # Exception types + render_tool_error
|
||||
├── prowler_hub/server.py # Hub tools (no auth)
|
||||
├── prowler_app/
|
||||
│ ├── server.py
|
||||
@@ -149,8 +113,7 @@ make test-mcp # Run the MCP test suite exactly as CI does
|
||||
- [ ] Models use `MinimalSerializerMixin`
|
||||
- [ ] API responses transformed to simplified models
|
||||
- [ ] No hardcoded secrets
|
||||
- [ ] Failures raise (never `return {"error": ...}`); outcomes that may have changed
|
||||
something return a structured result
|
||||
- [ ] Error handling returns structured responses
|
||||
- [ ] Parameter descriptions use Pydantic `Field()`
|
||||
- [ ] Tests added under `mcp_server/tests/`, mirroring the source path below the
|
||||
package root (`prowler_mcp_server/prowler_app/tools/` -> `tests/prowler_app/tools/`),
|
||||
|
||||
@@ -1,347 +0,0 @@
|
||||
"""One way to fail: every tool failure reaches the client as a `ToolError`.
|
||||
|
||||
MCP draws a line this server used to blur. A tool that *returns* `{"error": ...}`
|
||||
produces a successful result (`isError: false`) whose failure is only discoverable by
|
||||
guessing which key to look at; a tool that *raises* produces `isError: true`, which
|
||||
every client and model already understands as "this call did not work".
|
||||
|
||||
`ToolError` is a `FastMCPError`, and `FastMCP._call_tool` re-raises those untouched
|
||||
(fastmcp/server/server.py:1241). So a message built here is what the client reads,
|
||||
verbatim, past every mount and past `mask_error_details`. That is what lets the servers
|
||||
mask by default while still telling the caller everything relevant.
|
||||
|
||||
`render_tool_error` is the single place an exception becomes that text, and
|
||||
`tool_errors` is what makes sure no tool can escape it.
|
||||
|
||||
The exception types live here rather than next to the API client because the hub and the
|
||||
documentation sub-servers must be able to raise and render them without taking a
|
||||
dependency on `prowler_app`.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import functools
|
||||
import inspect
|
||||
import json
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
from fastmcp.exceptions import ToolError
|
||||
|
||||
from prowler_mcp_server.lib.logger import logger
|
||||
|
||||
# Upstream bodies are not ours and may be large or HTML; enough to diagnose, not enough
|
||||
# to flood the model's context.
|
||||
_MAX_UPSTREAM_BODY = 500
|
||||
|
||||
# The one thing a status code does not say. Appended only when a request that could have
|
||||
# changed something did not come back with a verdict, because an agent reads a failure as
|
||||
# "nothing happened" and will happily send the write again.
|
||||
_UNKNOWN_OUTCOME = (
|
||||
" It may have been carried out anyway, so check the current state before retrying."
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ApiErrorDetail:
|
||||
"""One entry of a JSON:API `errors` array.
|
||||
|
||||
The API answers a rejected write with one error *per invalid field*, each naming the
|
||||
field in `source`. Keeping the whole shape is what turns "the request was invalid"
|
||||
into "these two fields were invalid, and here is which".
|
||||
|
||||
Field names are JSON:API's own. `source.pointer` and `source.parameter` are not two
|
||||
spellings of one thing: a pointer is a JSON Pointer into the request *document*
|
||||
(`/data/attributes/provider_id`, as `alerts/errors.py` and `tasks/beat.py` send),
|
||||
while a parameter is a *query parameter* name (`page[size]`, `lookback_days`, as
|
||||
`api/v1/views.py` sends). The spec has a third, `source.header`; the Prowler API
|
||||
never emits one, so there is nothing here to read it into.
|
||||
"""
|
||||
|
||||
detail: str | None = None
|
||||
title: str | None = None
|
||||
pointer: str | None = None
|
||||
"""JSON:API `source.pointer`: a JSON Pointer into the request document."""
|
||||
parameter: str | None = None
|
||||
"""JSON:API `source.parameter`: the query parameter that caused the error."""
|
||||
|
||||
@classmethod
|
||||
def from_jsonapi(cls, error: dict[str, Any]) -> ApiErrorDetail:
|
||||
"""Build from a single member of a JSON:API `errors` array.
|
||||
|
||||
`source` is optional and most errors omit it, so it supplies the location only,
|
||||
never whether there is a detail worth reporting.
|
||||
"""
|
||||
source = error.get("source", {})
|
||||
return cls(
|
||||
detail=error.get("detail"),
|
||||
title=error.get("title"),
|
||||
pointer=source.get("pointer"),
|
||||
parameter=source.get("parameter"),
|
||||
)
|
||||
|
||||
def render(self) -> str:
|
||||
"""The error text, and where the API said it is.
|
||||
|
||||
A pointer is left as-is because a leading `/` already reads as a path into the
|
||||
body. A parameter is labelled, since `(page[size])` on its own would read like
|
||||
one.
|
||||
"""
|
||||
text = self.detail or self.title or ""
|
||||
if not text:
|
||||
return ""
|
||||
if self.pointer:
|
||||
return f"{text} ({self.pointer})"
|
||||
if self.parameter:
|
||||
return f"{text} (parameter {self.parameter})"
|
||||
return text
|
||||
|
||||
|
||||
def parse_jsonapi_errors(payload: Any) -> tuple[ApiErrorDetail, ...]:
|
||||
"""Extract every error from a JSON:API error document.
|
||||
|
||||
Tolerant on purpose: this runs while handling a failure, and a body that is not the
|
||||
document it should be must not turn a useful API error into a parsing traceback.
|
||||
"""
|
||||
if not isinstance(payload, dict):
|
||||
return ()
|
||||
errors = payload.get("errors")
|
||||
if not isinstance(errors, list):
|
||||
return ()
|
||||
return tuple(
|
||||
ApiErrorDetail.from_jsonapi(error)
|
||||
for error in errors
|
||||
if isinstance(error, dict)
|
||||
)
|
||||
|
||||
|
||||
class ProwlerAPIError(Exception):
|
||||
"""An error response returned by the Prowler API.
|
||||
|
||||
Raised only when the API answered with an error status, which tells a caller
|
||||
something no plain exception can: the request reached Prowler and was
|
||||
rejected, so it changed nothing. A timeout or a dropped connection stays a
|
||||
bare exception because the request may well have been processed.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
message: str,
|
||||
status_code: int,
|
||||
*,
|
||||
method: str | None = None,
|
||||
path: str | None = None,
|
||||
errors: tuple[ApiErrorDetail, ...] = (),
|
||||
) -> None:
|
||||
super().__init__(message)
|
||||
self.status_code: int = status_code
|
||||
# Stored as plain strings so this module stays independent of the API client's
|
||||
# HTTPMethod enum; StrEnum members compare equal to their value either way.
|
||||
self.method: str | None = str(method) if method is not None else None
|
||||
self.path: str | None = path
|
||||
self.errors: tuple[ApiErrorDetail, ...] = tuple(errors)
|
||||
|
||||
@property
|
||||
def rejected(self) -> bool:
|
||||
"""The request reached Prowler and was refused, so it changed nothing."""
|
||||
return 400 <= self.status_code < 500
|
||||
|
||||
|
||||
class ProwlerTaskError(Exception):
|
||||
"""A background task this server was waiting on did not complete.
|
||||
|
||||
Separate from `ProwlerAPIError` because the API already accepted the work: the
|
||||
task exists and may still be running, so the outcome is unknown rather than refused.
|
||||
"""
|
||||
|
||||
def __init__(self, message: str, *, task_id: str, state: str) -> None:
|
||||
super().__init__(message)
|
||||
self.task_id: str = task_id
|
||||
self.state: str = state
|
||||
"""One of `timeout`, `failed` or `cancelled`."""
|
||||
|
||||
|
||||
class ProwlerAuthError(ValueError):
|
||||
"""The credentials are missing, malformed or expired.
|
||||
|
||||
Subclasses `ValueError` so that the handlers which already treat an
|
||||
authentication failure as a refusal-before-send keep working. It is matched by name
|
||||
in `render_tool_error` rather than by that base class, so it is described as the
|
||||
credential problem it is instead of falling through to the bug branch.
|
||||
"""
|
||||
|
||||
|
||||
class ProwlerHubError(Exception):
|
||||
"""The Prowler Hub answered with an error status.
|
||||
|
||||
The Hub is a separate public service with its own client, so its failures cannot be
|
||||
`ProwlerAPIError`. Everything the Hub exposes is a read.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
message: str,
|
||||
*,
|
||||
status_code: int,
|
||||
path: str,
|
||||
body: str | None = None,
|
||||
) -> None:
|
||||
super().__init__(message)
|
||||
self.status_code: int = status_code
|
||||
self.path: str = path
|
||||
self.body: str | None = body
|
||||
|
||||
|
||||
def _upstream_detail(body: str | None) -> str:
|
||||
"""A readable line from an error body that is not JSON:API.
|
||||
|
||||
The Prowler API answers with a JSON:API document, which is parsed into
|
||||
`ApiErrorDetail`. Every other host this server talks to has its own shape: the Hub
|
||||
answers `{"error": "Not found"}`, GitHub answers plain text, a proxy in between may
|
||||
answer HTML. Relaying any of those verbatim puts braces and markup in front of the
|
||||
model, so the message is pulled out when there is one and truncated when there is
|
||||
not.
|
||||
"""
|
||||
if not body:
|
||||
return ""
|
||||
text = body.strip()
|
||||
try:
|
||||
parsed = json.loads(text)
|
||||
except ValueError:
|
||||
parsed = None
|
||||
if isinstance(parsed, dict):
|
||||
for key in ("error", "message", "detail"):
|
||||
value = parsed.get(key)
|
||||
if isinstance(value, str) and value:
|
||||
return value
|
||||
return f"{text[:_MAX_UPSTREAM_BODY]}..." if len(text) > _MAX_UPSTREAM_BODY else text
|
||||
|
||||
|
||||
def render_tool_error(error: Exception, *, warn: bool = True) -> str:
|
||||
"""Describe an exception in the plainest sentence that keeps every useful detail.
|
||||
|
||||
Callers that surface a failure from anywhere other than a raised exception -- a
|
||||
message they write themselves, a field of a structured result -- go through here
|
||||
too, which is what keeps one failure from being described two different ways.
|
||||
|
||||
Pass `warn=False` when the caller already states the outcome, which a structured
|
||||
result reporting `status="unknown"` does by definition. Otherwise the generic
|
||||
warning lands next to a more specific one saying the same thing.
|
||||
|
||||
Ordered most specific first: `ProwlerAuthError` is a `ValueError` and
|
||||
`httpx.TimeoutException` is a `RequestError`, so the general branches come last.
|
||||
"""
|
||||
unknown = _UNKNOWN_OUTCOME if warn else ""
|
||||
|
||||
if isinstance(error, ProwlerAPIError):
|
||||
operation = (
|
||||
" ".join(p for p in (error.method, error.path) if p) or "The request"
|
||||
)
|
||||
details = "; ".join(text for text in (d.render() for d in error.errors) if text)
|
||||
message = f"{operation} failed with HTTP {error.status_code}."
|
||||
if details:
|
||||
message = f"{message} {details}"
|
||||
# A 4xx is a refusal, so it changed nothing and needs no warning.
|
||||
if error.rejected or error.method == "GET":
|
||||
return message
|
||||
return message + unknown
|
||||
|
||||
if isinstance(error, ProwlerTaskError):
|
||||
# The API accepted the work before the wait failed, so the outcome is open
|
||||
# whichever way the task ended.
|
||||
return f"{error}{unknown}"
|
||||
|
||||
if isinstance(error, ProwlerAuthError):
|
||||
return f"Prowler authentication failed: {error}"
|
||||
|
||||
if isinstance(error, ProwlerHubError):
|
||||
# Same shape as the API branch, with the service named because the Hub can be
|
||||
# down while the API is fine. Everything the Hub exposes is a GET.
|
||||
message = f"Prowler Hub GET {error.path} failed with HTTP {error.status_code}."
|
||||
detail = _upstream_detail(error.body)
|
||||
return f"{message} {detail}" if detail else message
|
||||
|
||||
if isinstance(error, httpx.HTTPStatusError):
|
||||
# An upstream that is not the Prowler API, such as the external-URL fetch.
|
||||
request = error.request
|
||||
message = (
|
||||
f"{request.method} {request.url} failed with HTTP "
|
||||
f"{error.response.status_code}."
|
||||
)
|
||||
detail = _upstream_detail(error.response.text)
|
||||
return f"{message} {detail}" if detail else message
|
||||
|
||||
if isinstance(error, httpx.RequestError):
|
||||
# No answer at all: a timeout, a dropped connection, a DNS failure.
|
||||
try:
|
||||
operation = f"{error.request.method} {error.request.url}"
|
||||
method = error.request.method
|
||||
except RuntimeError:
|
||||
# httpx only attaches the request once it has one, and reading it before
|
||||
# then raises. Never let that hide the failure being reported.
|
||||
operation, method = "The request", None
|
||||
suffix = "" if method == "GET" else unknown
|
||||
return f"{operation} got no answer ({type(error).__name__}: {error}).{suffix}"
|
||||
|
||||
# No `ValueError` branch, deliberately. A message written for the caller is raised
|
||||
# as a `ToolError`, which never reaches here. What is left -- a model factory
|
||||
# rejecting an API payload, a pydantic `ValidationError`, an `int()` on something
|
||||
# that is not a number -- is this server or the API breaking its own contract, and
|
||||
# saying so is the only useful thing to tell a caller who cannot fix it.
|
||||
return (
|
||||
f"The Prowler MCP Server hit an unexpected {type(error).__name__}: {error}. "
|
||||
"This is a bug in the server, not something you can fix by changing the "
|
||||
"arguments."
|
||||
)
|
||||
|
||||
|
||||
def tool_errors(fn: Callable[..., Any]) -> Callable[..., Any]:
|
||||
"""Wrap a tool so that every failure leaves it as a `ToolError`.
|
||||
|
||||
Applied by `ProwlerMCP.tool()` rather than by hand, so no registration can miss
|
||||
it. It wraps the callable handed to `mcp.tool()`, not the class attribute, so only
|
||||
the MCP boundary is normalised: a tool calling another tool internally still sees
|
||||
the real, typed exception and can branch on it.
|
||||
|
||||
Two constraints worth knowing before changing this:
|
||||
|
||||
- Never register the result with `exclude_args=`. That path
|
||||
(fastmcp/utilities/types.py) rebuilds the function from `__code__`, which on a
|
||||
wrapper is the wrapper's own. Nothing in this server passes it today.
|
||||
- `inspect.iscoroutinefunction`, not the `asyncio` one, which is deprecated from
|
||||
Python 3.14 and would be an error under this project's warning filters.
|
||||
"""
|
||||
name = getattr(fn, "__qualname__", repr(fn))
|
||||
|
||||
def mark(wrapper: Callable[..., Any]) -> Callable[..., Any]:
|
||||
"""Flag the wrapper so a test can prove every registered tool went through it."""
|
||||
wrapper.__prowler_tool_errors__ = True # ty: ignore[unresolved-attribute]
|
||||
return wrapper
|
||||
|
||||
if inspect.iscoroutinefunction(fn):
|
||||
|
||||
@functools.wraps(fn)
|
||||
async def async_wrapper(*args: Any, **kwargs: Any) -> Any:
|
||||
try:
|
||||
return await fn(*args, **kwargs)
|
||||
except ToolError:
|
||||
raise
|
||||
except Exception as error:
|
||||
logger.exception(f"Tool {name} failed: {error}")
|
||||
raise ToolError(render_tool_error(error)) from error
|
||||
|
||||
return mark(async_wrapper)
|
||||
|
||||
@functools.wraps(fn)
|
||||
def sync_wrapper(*args: Any, **kwargs: Any) -> Any:
|
||||
try:
|
||||
return fn(*args, **kwargs)
|
||||
except ToolError:
|
||||
raise
|
||||
except Exception as error:
|
||||
logger.exception(f"Tool {name} failed: {error}")
|
||||
raise ToolError(render_tool_error(error)) from error
|
||||
|
||||
return mark(sync_wrapper)
|
||||
@@ -1,32 +0,0 @@
|
||||
"""The FastMCP subclass every Prowler sub-server is built from."""
|
||||
|
||||
from typing import Any
|
||||
|
||||
from fastmcp import FastMCP
|
||||
|
||||
from prowler_mcp_server.lib.errors import tool_errors
|
||||
|
||||
|
||||
class ProwlerMCP(FastMCP):
|
||||
"""A FastMCP server whose tools all report failures the same way.
|
||||
|
||||
`FastMCP.tool()` is the single funnel every registration goes through, the
|
||||
`@server.tool()` and bare `@server.tool` decorator forms, and the direct
|
||||
`mcp.tool(fn)` call `BaseTool` uses to auto-register, so applying
|
||||
`tool_errors` here covers all of them at once.
|
||||
"""
|
||||
|
||||
def tool(self, name_or_fn: Any = None, **kwargs: Any) -> Any:
|
||||
"""Register a tool, wrapped so its failures reach the client as `ToolError`."""
|
||||
if callable(name_or_fn):
|
||||
# Direct call: mcp.tool(fn), or the bare @mcp.tool decorator.
|
||||
return super().tool(tool_errors(name_or_fn), **kwargs)
|
||||
|
||||
# Parameterised decorator: @mcp.tool() or @mcp.tool(name="..."). FastMCP hands
|
||||
# back the decorator that does the registering, so the wrap goes in front of it.
|
||||
register = super().tool(name_or_fn, **kwargs)
|
||||
|
||||
def decorator(fn: Any) -> Any:
|
||||
return register(tool_errors(fn))
|
||||
|
||||
return decorator
|
||||
@@ -1,8 +1,9 @@
|
||||
from prowler_mcp_server.lib.server import ProwlerMCP
|
||||
from fastmcp import FastMCP
|
||||
|
||||
from prowler_mcp_server.prowler_app.utils.tool_loader import load_all_tools
|
||||
|
||||
# Initialize MCP server
|
||||
app_mcp_server = ProwlerMCP("prowler-app", mask_error_details=True)
|
||||
app_mcp_server = FastMCP("prowler-app")
|
||||
|
||||
# Auto-discover and load all tools from the tools package
|
||||
load_all_tools(app_mcp_server)
|
||||
|
||||
@@ -72,9 +72,6 @@ class BaseTool(ABC):
|
||||
async methods (not starting with '_') as tools. Subclasses do not need
|
||||
to override this method.
|
||||
|
||||
Failures need no handling here: `ProwlerMCP.tool` wraps whatever it is
|
||||
given, so every tool reports them the same way.
|
||||
|
||||
Args:
|
||||
mcp: The FastMCP instance to register tools with
|
||||
"""
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
from typing import Any
|
||||
|
||||
from fastmcp import FastMCP
|
||||
from pydantic import Field
|
||||
|
||||
from prowler_mcp_server.lib.server import ProwlerMCP
|
||||
from prowler_mcp_server.prowler_documentation.search_engine import (
|
||||
ProwlerDocsSearchEngine,
|
||||
)
|
||||
|
||||
# Initialize MCP server
|
||||
docs_mcp_server = ProwlerMCP("prowler-docs", mask_error_details=True)
|
||||
# Initialize FastMCP server
|
||||
docs_mcp_server = FastMCP("prowler-docs")
|
||||
prowler_docs_search_engine = ProwlerDocsSearchEngine()
|
||||
|
||||
|
||||
|
||||
@@ -5,13 +5,13 @@ Provides access to Prowler Hub API for security checks and compliance frameworks
|
||||
"""
|
||||
|
||||
import httpx
|
||||
from fastmcp import FastMCP
|
||||
from pydantic import Field
|
||||
|
||||
from prowler_mcp_server import __version__
|
||||
from prowler_mcp_server.lib.server import ProwlerMCP
|
||||
|
||||
# Initialize MCP server for Prowler Hub
|
||||
hub_mcp_server = ProwlerMCP("prowler-hub", mask_error_details=True)
|
||||
# Initialize FastMCP for Prowler Hub
|
||||
hub_mcp_server = FastMCP("prowler-hub")
|
||||
|
||||
# API base URL
|
||||
BASE_URL = "https://hub.prowler.com/api"
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
from fastmcp import FastMCP
|
||||
from starlette.responses import JSONResponse
|
||||
|
||||
from prowler_mcp_server import __version__
|
||||
from prowler_mcp_server.lib.logger import logger
|
||||
from prowler_mcp_server.lib.server import ProwlerMCP
|
||||
|
||||
prowler_mcp_server = ProwlerMCP("prowler-mcp-server", mask_error_details=True)
|
||||
prowler_mcp_server = FastMCP("prowler-mcp-server")
|
||||
|
||||
|
||||
def setup_main_server():
|
||||
|
||||
@@ -1,311 +0,0 @@
|
||||
"""Tests for the sentence a failure is described with.
|
||||
|
||||
These assert on the *text* a model reads, because that text is the whole contract: a
|
||||
`ToolError` carries nothing else. What matters is that the API's own words survive
|
||||
intact, and that a write whose outcome nobody can report says so.
|
||||
|
||||
How that sentence reaches a client -- and that no tool can escape it -- is
|
||||
`test_server.py`.
|
||||
"""
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from prowler_mcp_server.lib.errors import (
|
||||
ApiErrorDetail,
|
||||
ProwlerAPIError,
|
||||
ProwlerAuthError,
|
||||
ProwlerHubError,
|
||||
ProwlerTaskError,
|
||||
parse_jsonapi_errors,
|
||||
render_tool_error,
|
||||
)
|
||||
|
||||
MAY_HAVE_LANDED = "It may have been carried out anyway"
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- parsing
|
||||
|
||||
|
||||
def test_every_error_of_the_document_is_preserved():
|
||||
"""A rejected write names one error per invalid field; all of them matter."""
|
||||
errors = parse_jsonapi_errors(
|
||||
{
|
||||
"errors": [
|
||||
{"status": "400", "detail": "This field may not be blank."},
|
||||
{"status": "400", "detail": "Enter a valid URL."},
|
||||
]
|
||||
}
|
||||
)
|
||||
|
||||
assert [error.detail for error in errors] == [
|
||||
"This field may not be blank.",
|
||||
"Enter a valid URL.",
|
||||
]
|
||||
|
||||
|
||||
def test_a_query_parameter_error_is_not_dressed_up_as_a_body_path():
|
||||
"""`source.parameter` and `source.pointer` are different places, per JSON:API.
|
||||
|
||||
The API sends a parameter for a bad query string (`api/v1/views.py` answers
|
||||
`page[size]` and `lookback_days` that way) and a pointer for a bad body field.
|
||||
Rendering a parameter bare would read as though `page[size]` were a path into the
|
||||
document, which is somewhere the caller never put it.
|
||||
"""
|
||||
(error,) = parse_jsonapi_errors(
|
||||
{
|
||||
"errors": [
|
||||
{
|
||||
"detail": "invalid parameter 'page[size]'",
|
||||
"source": {"parameter": "page[size]"},
|
||||
}
|
||||
]
|
||||
}
|
||||
)
|
||||
|
||||
assert error.parameter == "page[size]"
|
||||
assert error.pointer is None
|
||||
assert error.render() == "invalid parameter 'page[size]' (parameter page[size])"
|
||||
|
||||
|
||||
def test_the_field_an_error_points_at_is_kept():
|
||||
"""`source.pointer` is what turns "invalid" into "this field is invalid"."""
|
||||
(error,) = parse_jsonapi_errors(
|
||||
{
|
||||
"errors": [
|
||||
{
|
||||
"detail": "This field may not be blank.",
|
||||
"source": {"pointer": "/data/attributes/name"},
|
||||
}
|
||||
]
|
||||
}
|
||||
)
|
||||
|
||||
assert error.render() == ("This field may not be blank. (/data/attributes/name)")
|
||||
|
||||
|
||||
def test_an_error_with_only_a_title_still_says_something():
|
||||
"""`detail` is the useful field, but the API does not always send one."""
|
||||
assert ApiErrorDetail(title="Not Found").render() == "Not Found"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"payload",
|
||||
[None, "not a document", {}, {"errors": "not a list"}, {"errors": [None]}],
|
||||
ids=["none", "text", "empty", "errors-not-a-list", "member-not-a-dict"],
|
||||
)
|
||||
def test_a_body_that_is_not_an_error_document_is_tolerated(payload):
|
||||
"""Parsing runs while handling a failure; it must not become the failure."""
|
||||
assert parse_jsonapi_errors(payload) == ()
|
||||
|
||||
|
||||
def test_an_error_with_nothing_in_it_renders_empty():
|
||||
"""Rendered to nothing rather than to punctuation, so composition can skip it."""
|
||||
assert ApiErrorDetail().render() == ""
|
||||
|
||||
|
||||
# --------------------------------------------------------------------- api failures
|
||||
|
||||
|
||||
def test_a_failed_read_names_the_call_and_the_reason():
|
||||
message = render_tool_error(
|
||||
ProwlerAPIError(
|
||||
"API request failed: 404 - Not found.",
|
||||
404,
|
||||
method="GET",
|
||||
path="/findings/nope",
|
||||
errors=parse_jsonapi_errors(
|
||||
{"errors": [{"detail": "No Finding matches the given query."}]}
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
assert message == (
|
||||
"GET /findings/nope failed with HTTP 404. No Finding matches the given query."
|
||||
)
|
||||
|
||||
|
||||
def test_every_error_of_a_rejected_write_reaches_the_client():
|
||||
"""The API rejects a write with one error per invalid field, and all of them help."""
|
||||
message = render_tool_error(
|
||||
ProwlerAPIError(
|
||||
"API request failed: 400 - blank",
|
||||
400,
|
||||
method="POST",
|
||||
path="/integrations",
|
||||
errors=parse_jsonapi_errors(
|
||||
{
|
||||
"errors": [
|
||||
{
|
||||
"detail": "This field may not be blank.",
|
||||
"source": {"pointer": "/data/attributes/bucket_name"},
|
||||
},
|
||||
{"detail": "Enter a valid URL."},
|
||||
]
|
||||
}
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
assert message == (
|
||||
"POST /integrations failed with HTTP 400. "
|
||||
"This field may not be blank. (/data/attributes/bucket_name); "
|
||||
"Enter a valid URL."
|
||||
)
|
||||
|
||||
|
||||
def test_a_rejected_write_gets_no_warning():
|
||||
"""A 4xx changed nothing, so there is nothing to warn about."""
|
||||
message = render_tool_error(
|
||||
ProwlerAPIError("boom", 400, method="POST", path="/scans")
|
||||
)
|
||||
|
||||
assert MAY_HAVE_LANDED not in message
|
||||
|
||||
|
||||
def test_a_write_that_hit_a_server_error_warns_it_may_have_landed():
|
||||
"""The API validates and queues before answering, so a 500 may have gone through."""
|
||||
message = render_tool_error(
|
||||
ProwlerAPIError("boom", 500, method="DELETE", path="/integrations/i1")
|
||||
)
|
||||
|
||||
assert message.startswith("DELETE /integrations/i1 failed with HTTP 500.")
|
||||
assert MAY_HAVE_LANDED in message
|
||||
|
||||
|
||||
def test_a_failed_read_never_warns():
|
||||
"""A read cannot have changed anything, whatever went wrong."""
|
||||
message = render_tool_error(
|
||||
ProwlerAPIError("boom", 500, method="GET", path="/scans")
|
||||
)
|
||||
|
||||
assert MAY_HAVE_LANDED not in message
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ no answer at all
|
||||
|
||||
|
||||
def test_a_write_that_got_no_answer_warns_it_may_have_landed():
|
||||
"""A timeout is the case the warning exists for."""
|
||||
request = httpx.Request("POST", "https://api.testing.invalid/api/v1/scans")
|
||||
message = render_tool_error(httpx.ReadTimeout("timed out", request=request))
|
||||
|
||||
assert "POST https://api.testing.invalid/api/v1/scans got no answer" in message
|
||||
assert "ReadTimeout" in message
|
||||
assert MAY_HAVE_LANDED in message
|
||||
|
||||
|
||||
def test_a_read_that_got_no_answer_does_not_warn():
|
||||
request = httpx.Request("GET", "https://api.testing.invalid/api/v1/findings")
|
||||
message = render_tool_error(httpx.ReadTimeout("timed out", request=request))
|
||||
|
||||
assert MAY_HAVE_LANDED not in message
|
||||
|
||||
|
||||
def test_a_dropped_connection_names_what_went_wrong():
|
||||
request = httpx.Request("POST", "https://api.testing.invalid/api/v1/scans")
|
||||
message = render_tool_error(httpx.ConnectError("connection reset", request=request))
|
||||
|
||||
assert "ConnectError: connection reset" in message
|
||||
|
||||
|
||||
def test_an_unfinished_task_warns_it_may_have_landed():
|
||||
"""The API already accepted the work, so the outcome is open, not refused."""
|
||||
message = render_tool_error(
|
||||
ProwlerTaskError(
|
||||
"Task t1 polling timed out after 60 seconds.", task_id="t1", state="timeout"
|
||||
)
|
||||
)
|
||||
|
||||
assert message.startswith("Task t1 polling timed out after 60 seconds.")
|
||||
assert MAY_HAVE_LANDED in message
|
||||
|
||||
|
||||
# -------------------------------------------------------------- refusals before send
|
||||
|
||||
|
||||
def test_a_stray_value_error_is_reported_as_a_bug():
|
||||
"""The distinction the previous passthrough branch could not make.
|
||||
|
||||
A model factory rejecting an API payload, or an `int()` on something that is not a
|
||||
number, is not the caller's mistake. Describing it like a validation message sends
|
||||
an agent off rewriting arguments that were never the problem.
|
||||
"""
|
||||
message = render_tool_error(
|
||||
ValueError("Missing pagination metadata in API response")
|
||||
)
|
||||
|
||||
assert "unexpected ValueError" in message
|
||||
assert "bug in the server" in message
|
||||
|
||||
|
||||
def test_an_authentication_failure_says_so():
|
||||
"""A `ValueError` subclass, so it must be recognised before the generic branch."""
|
||||
assert render_tool_error(ProwlerAuthError("Token has expired")) == (
|
||||
"Prowler authentication failed: Token has expired"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------- other hosts
|
||||
|
||||
|
||||
def test_a_hub_failure_reads_like_an_api_failure():
|
||||
"""Same sentence as the Prowler API, with the service named.
|
||||
|
||||
The Hub can be down while the API is fine, so which one failed is worth the two
|
||||
extra words -- but the shape must not differ, or the two look like two contracts.
|
||||
"""
|
||||
message = render_tool_error(
|
||||
ProwlerHubError(
|
||||
"hub failed",
|
||||
status_code=404,
|
||||
path="/check/test",
|
||||
body='{"error": "Not found"}',
|
||||
)
|
||||
)
|
||||
|
||||
assert message == "Prowler Hub GET /check/test failed with HTTP 404. Not found"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("body", "expected"),
|
||||
[
|
||||
('{"error": "Not found"}', "Not found"),
|
||||
('{"message": "Bad gateway"}', "Bad gateway"),
|
||||
('{"detail": "Rate limited"}', "Rate limited"),
|
||||
("Service Unavailable", "Service Unavailable"),
|
||||
('{"unexpected": "shape"}', '{"unexpected": "shape"}'),
|
||||
],
|
||||
ids=["error", "message", "detail", "plain-text", "unknown-json"],
|
||||
)
|
||||
def test_an_upstream_message_is_pulled_out_of_whatever_shape_it_came_in(body, expected):
|
||||
"""Hosts that are not the Prowler API each have their own error shape.
|
||||
|
||||
Relaying the raw body puts JSON braces, or a whole HTML page, in front of the model.
|
||||
"""
|
||||
message = render_tool_error(
|
||||
ProwlerHubError("hub failed", status_code=500, path="/checks", body=body)
|
||||
)
|
||||
|
||||
assert message.endswith(expected)
|
||||
|
||||
|
||||
def test_an_upstream_body_is_truncated():
|
||||
"""An HTML error page must not flood the model's context."""
|
||||
message = render_tool_error(
|
||||
ProwlerHubError("hub failed", status_code=500, path="/checks", body="x" * 2000)
|
||||
)
|
||||
|
||||
assert "x" * 500 in message
|
||||
assert "x" * 501 not in message
|
||||
|
||||
|
||||
# --------------------------------------------------------------------- server bugs
|
||||
|
||||
|
||||
def test_a_bug_in_this_server_is_reported_as_a_bug():
|
||||
"""Named as ours, so the caller stops trying to fix it by changing arguments."""
|
||||
message = render_tool_error(KeyError("attributes"))
|
||||
|
||||
assert "unexpected KeyError" in message
|
||||
assert "bug in the server" in message
|
||||
@@ -1,170 +0,0 @@
|
||||
"""Tests for the server class every sub-server is built from.
|
||||
|
||||
These drive a real `ProwlerMCP` through an in-memory MCP client rather than calling
|
||||
`tool_errors` directly, because applying that wrapper by hand is exactly what this
|
||||
class exists to make unnecessary. What matters is that a tool registered *any* of the
|
||||
ways this server registers them ends up with the error contract, and that it keeps the
|
||||
name, description and schema FastMCP publishes.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from fastmcp import Client
|
||||
from fastmcp.exceptions import ToolError
|
||||
from pydantic import Field
|
||||
|
||||
from prowler_mcp_server.lib.errors import ProwlerAPIError
|
||||
from prowler_mcp_server.lib.server import ProwlerMCP
|
||||
|
||||
|
||||
async def call(server: ProwlerMCP, name: str, arguments: dict | None = None):
|
||||
"""Call a tool the way a client does, without raising on failure."""
|
||||
async with Client(server) as client:
|
||||
return await client.call_tool(name, arguments or {}, raise_on_error=False)
|
||||
|
||||
|
||||
async def test_the_parameterised_decorator_form_is_wrapped():
|
||||
"""`@mcp.tool()` -- how the hub and documentation sub-servers register."""
|
||||
server = ProwlerMCP("test", mask_error_details=True)
|
||||
|
||||
@server.tool()
|
||||
async def failing() -> dict:
|
||||
"""A tool that fails."""
|
||||
raise ProwlerAPIError("boom", 404, method="GET", path="/x")
|
||||
|
||||
result = await call(server, "failing")
|
||||
|
||||
assert result.is_error
|
||||
assert result.content[0].text == "GET /x failed with HTTP 404."
|
||||
|
||||
|
||||
async def test_the_bare_decorator_form_is_wrapped():
|
||||
"""`@mcp.tool` without parentheses is a different code path in FastMCP."""
|
||||
server = ProwlerMCP("test", mask_error_details=True)
|
||||
|
||||
@server.tool
|
||||
async def failing() -> dict:
|
||||
"""A tool that fails."""
|
||||
raise ProwlerAPIError("boom", 500, method="GET", path="/y")
|
||||
|
||||
result = await call(server, "failing")
|
||||
|
||||
assert result.is_error
|
||||
assert "GET /y failed with HTTP 500." in result.content[0].text
|
||||
|
||||
|
||||
async def test_the_direct_call_form_is_wrapped():
|
||||
"""`mcp.tool(fn)` -- how `BaseTool` auto-registers its methods."""
|
||||
server = ProwlerMCP("test", mask_error_details=True)
|
||||
|
||||
async def failing() -> dict:
|
||||
"""A tool that fails."""
|
||||
raise ProwlerAPIError("boom", 403, method="DELETE", path="/z")
|
||||
|
||||
server.tool(failing)
|
||||
|
||||
result = await call(server, "failing")
|
||||
|
||||
assert result.is_error
|
||||
assert "DELETE /z failed with HTTP 403." in result.content[0].text
|
||||
|
||||
|
||||
async def test_a_synchronous_tool_is_wrapped():
|
||||
"""The documentation sub-server registers plain `def` tools."""
|
||||
server = ProwlerMCP("test", mask_error_details=True)
|
||||
|
||||
@server.tool()
|
||||
def failing() -> dict:
|
||||
"""A synchronous tool that fails."""
|
||||
raise KeyError("attributes")
|
||||
|
||||
result = await call(server, "failing")
|
||||
|
||||
assert result.is_error
|
||||
assert "unexpected KeyError" in result.content[0].text
|
||||
|
||||
|
||||
async def test_a_refusal_reaches_the_caller_word_for_word():
|
||||
"""A `ToolError` is passed through untouched, masking included.
|
||||
|
||||
That is the whole reason refusals are raised as one: the tool already wrote the
|
||||
sentence the caller needs, and nothing downstream improves on it.
|
||||
"""
|
||||
server = ProwlerMCP("test", mask_error_details=True)
|
||||
|
||||
@server.tool()
|
||||
async def refusing() -> dict:
|
||||
"""A tool that refuses its arguments."""
|
||||
raise ToolError(
|
||||
"Date range cannot exceed 2 days. Requested range: 2025-01-01 to "
|
||||
"2025-01-10 (10 days)"
|
||||
)
|
||||
|
||||
result = await call(server, "refusing")
|
||||
|
||||
assert result.is_error
|
||||
assert result.content[0].text == (
|
||||
"Date range cannot exceed 2 days. Requested range: 2025-01-01 to "
|
||||
"2025-01-10 (10 days)"
|
||||
)
|
||||
|
||||
|
||||
async def test_a_result_is_passed_through_untouched():
|
||||
server = ProwlerMCP("test")
|
||||
|
||||
@server.tool()
|
||||
async def succeeding(value: int) -> dict:
|
||||
"""A tool that works."""
|
||||
return {"value": value}
|
||||
|
||||
result = await call(server, "succeeding", {"value": 3})
|
||||
|
||||
assert not result.is_error
|
||||
assert result.data == {"value": 3}
|
||||
|
||||
|
||||
async def test_wrapping_does_not_disturb_the_published_tool():
|
||||
"""The wrapper must be invisible to FastMCP's schema generation.
|
||||
|
||||
A wrapper that loses the signature takes the parameters with it, and a tool with no
|
||||
parameters and no description is unusable while still looking registered.
|
||||
"""
|
||||
server = ProwlerMCP("test")
|
||||
|
||||
@server.tool()
|
||||
async def search(
|
||||
query: str = Field(description="What to search for"),
|
||||
limit: int = Field(default=10, description="How many results"),
|
||||
) -> dict:
|
||||
"""Search for things."""
|
||||
return {"query": query, "limit": limit}
|
||||
|
||||
async with Client(server) as client:
|
||||
(tool,) = await client.list_tools()
|
||||
|
||||
assert tool.name == "search"
|
||||
assert tool.description == "Search for things."
|
||||
properties = tool.inputSchema["properties"]
|
||||
assert properties["query"]["description"] == "What to search for"
|
||||
assert properties["limit"]["default"] == 10
|
||||
|
||||
|
||||
@pytest.mark.parametrize("name", ["decorated", "direct"])
|
||||
async def test_every_registration_carries_the_marker(name):
|
||||
"""The marker is what lets the contract test prove no tool slipped past."""
|
||||
server = ProwlerMCP("test")
|
||||
|
||||
async def direct() -> dict:
|
||||
"""Registered by direct call."""
|
||||
return {}
|
||||
|
||||
@server.tool()
|
||||
async def decorated() -> dict:
|
||||
"""Registered by decorator."""
|
||||
return {}
|
||||
|
||||
server.tool(direct)
|
||||
|
||||
tool = await server.get_tool(name)
|
||||
assert tool is not None, f"{name!r} was not registered at all"
|
||||
# `get_tool` is typed as the base `Tool`; only `FunctionTool` carries `fn`.
|
||||
assert getattr(getattr(tool, "fn", None), "__prowler_tool_errors__", False)
|
||||
@@ -33,34 +33,6 @@ async def test_every_sub_server_contributes_tools(mcp_root_server):
|
||||
assert tools_in_namespace(tools, "prowler_"), "Prowler App registered no tools"
|
||||
|
||||
|
||||
async def test_no_tool_disappears_between_registration_and_the_client(mcp_root_server):
|
||||
"""Every tool registered on a sub-server must still be reachable through the mount.
|
||||
|
||||
`ProwlerMCP.tool` wraps every tool before handing it to FastMCP, whether it arrived
|
||||
by decorator or by the direct call `BaseTool.register_tools` makes. A wrapper that
|
||||
loses the signature, the name or the coroutine-ness of what it wraps drops the tool
|
||||
silently: the mount still succeeds and the count is the only thing that moves.
|
||||
"""
|
||||
from prowler_mcp_server.prowler_app.server import app_mcp_server
|
||||
from prowler_mcp_server.prowler_documentation.server import docs_mcp_server
|
||||
from prowler_mcp_server.prowler_hub.server import hub_mcp_server
|
||||
|
||||
async with Client(mcp_root_server) as client:
|
||||
tools = await client.list_tools()
|
||||
|
||||
for namespace, sub_server in (
|
||||
("prowler_hub_", hub_mcp_server),
|
||||
("prowler_docs_", docs_mcp_server),
|
||||
("prowler_", app_mcp_server),
|
||||
):
|
||||
expected = len(await sub_server.list_tools())
|
||||
published = len(tools_in_namespace(tools, namespace))
|
||||
assert published == expected, (
|
||||
f"'{namespace}' publishes {published} tools but its sub-server registered "
|
||||
f"{expected}"
|
||||
)
|
||||
|
||||
|
||||
async def test_every_tool_is_namespaced(mcp_root_server):
|
||||
"""Tool names are a published interface; nothing may escape the namespaces."""
|
||||
async with Client(mcp_root_server) as client:
|
||||
|
||||
@@ -31,6 +31,8 @@
|
||||
"ec2:GetInstanceMetadataDefaults",
|
||||
"ecr:Describe*",
|
||||
"ecr:GetRegistryScanningConfiguration",
|
||||
"ecr:BatchGetImage",
|
||||
"ecr:GetDownloadUrlForLayer",
|
||||
"elasticfilesystem:DescribeBackupPolicy",
|
||||
"glue:GetConnections",
|
||||
"glue:GetSecurityConfiguration*",
|
||||
@@ -42,6 +44,7 @@
|
||||
"lightsail:GetRelationalDatabases",
|
||||
"macie2:GetMacieSession",
|
||||
"macie2:GetAutomatedDiscoveryConfiguration",
|
||||
"rolesanywhere:ListProfiles",
|
||||
"rolesanywhere:ListTagsForResource",
|
||||
"rolesanywhere:ListTrustAnchors",
|
||||
"s3:GetAccountPublicAccessBlock",
|
||||
|
||||
@@ -203,6 +203,8 @@ Resources:
|
||||
- "ec2:GetInstanceMetadataDefaults"
|
||||
- "ecr:Describe*"
|
||||
- "ecr:GetRegistryScanningConfiguration"
|
||||
- "ecr:BatchGetImage"
|
||||
- "ecr:GetDownloadUrlForLayer"
|
||||
- "elasticfilesystem:DescribeBackupPolicy"
|
||||
- "glue:GetConnections"
|
||||
- "glue:GetSecurityConfiguration*"
|
||||
@@ -213,6 +215,7 @@ Resources:
|
||||
- "lightsail:GetRelationalDatabases"
|
||||
- "macie2:GetMacieSession"
|
||||
- "macie2:GetAutomatedDiscoveryConfiguration"
|
||||
- "rolesanywhere:ListProfiles"
|
||||
- "rolesanywhere:ListTagsForResource"
|
||||
- "rolesanywhere:ListTrustAnchors"
|
||||
- "s3:GetAccountPublicAccessBlock"
|
||||
@@ -469,6 +472,8 @@ Resources:
|
||||
- "ec2:GetInstanceMetadataDefaults"
|
||||
- "ecr:Describe*"
|
||||
- "ecr:GetRegistryScanningConfiguration"
|
||||
- "ecr:BatchGetImage"
|
||||
- "ecr:GetDownloadUrlForLayer"
|
||||
- "elasticfilesystem:DescribeBackupPolicy"
|
||||
- "glue:GetConnections"
|
||||
- "glue:GetSecurityConfiguration*"
|
||||
|
||||
@@ -4,6 +4,43 @@ All notable changes to the **Prowler SDK** are documented in this file.
|
||||
|
||||
<!-- changelog: release notes start -->
|
||||
|
||||
## [5.39.1] (Prowler v5.39.1)
|
||||
|
||||
### 🐞 Fixed
|
||||
|
||||
- Bump alibabacloud-tea-openapi to 0.4.6, oci to 2.184.1 and pyopenssl to 26.4.0 so the published wheel installs with cryptography 50.0.0; 5.38.0 declared cryptography 50.0.0 while those packages capped it below 50, so pip could not install it and `pip install prowler` silently fell back to 5.37.1 [(#12477)](https://github.com/prowler-cloud/prowler/pull/12477)
|
||||
- Pin zstd to 1.5.7.2; 1.5.7.3 was yanked from PyPI as not thread safe [(#12477)](https://github.com/prowler-cloud/prowler/pull/12477)
|
||||
- ECS task-definition checks no longer report PASS when `DescribeTaskDefinition` fails before container evidence is gathered [(#12478)](https://github.com/prowler-cloud/prowler/pull/12478)
|
||||
- `ses_identity_not_publicly_accessible` now evaluates every SES identity authorization policy and marks mixed public Allow and Deny statements for manual review [(#12480)](https://github.com/prowler-cloud/prowler/pull/12480)
|
||||
|
||||
### 🔐 Security
|
||||
|
||||
- Trivy from v0.72.0 to v0.73.0 in the container image, fixing HIGH CVE-2026-46600 in the bundled `golang.org/x/net` [(#12445)](https://github.com/prowler-cloud/prowler/pull/12445)
|
||||
- Trivy v0.74.0 and Debian util-linux 2.41.5-0+deb13u1 in the SDK container image, patching Go standard library vulnerabilities and CVE-2026-53615 [(#12470)](https://github.com/prowler-cloud/prowler/pull/12470)
|
||||
|
||||
---
|
||||
|
||||
## [5.39.0] (Prowler v5.39.0)
|
||||
|
||||
### 🚀 Added
|
||||
|
||||
- `batch_job_definition_no_secrets` check for AWS provider, scanning Batch job definition environment variables and command parameters for hardcoded secrets [(#12117)](https://github.com/prowler-cloud/prowler/pull/12117)
|
||||
- 7 M365 Entra checks covering CIS Microsoft 365 Foundations Benchmark v7.0.0 password protection, default user permissions, and guest invitation domain restrictions [(#12153)](https://github.com/prowler-cloud/prowler/pull/12153)
|
||||
- 7 M365 entra checks covering CIS Microsoft 365 Foundations Benchmark v7.0.0 Conditional Access (5.2.2.x) and idle session timeout controls [(#12154)](https://github.com/prowler-cloud/prowler/pull/12154)
|
||||
- `entra_authentication_method_email_otp_disabled`, `entra_authentication_method_authenticator_show_context`, `entra_pim_global_administrator_approval_required`, `entra_pim_privileged_role_administrator_approval_required`, `entra_access_review_guest_users_configured` and `entra_access_review_privileged_roles_configured` checks for M365 provider covering CIS Microsoft 365 Foundations Benchmark v7.0.0 authentication method, PIM approval and access review controls [(#12155)](https://github.com/prowler-cloud/prowler/pull/12155)
|
||||
- `awslambda_layer_no_secrets_in_content` check for AWS provider, scanning Lambda layer package content for hardcoded secrets [(#12233)](https://github.com/prowler-cloud/prowler/pull/12233)
|
||||
- CMMC 2.0 universal compliance framework (`cmmc_2.0`) with the 149 official requirements from 32 CFR Part 170 — Level 1 (15, 48 CFR 52.204-21), Level 2 (110, NIST SP 800-171 Rev 2) and Level 3 (24, NIST SP 800-172) — with AWS, Azure, GCP, Alibaba Cloud, Oracle Cloud and M365 check mappings and config guardrails [(#12401)](https://github.com/prowler-cloud/prowler/pull/12401)
|
||||
|
||||
### 🔄 Changed
|
||||
|
||||
- GitHub `organization_repository_creation_limited` check now reports low severity for FAIL findings when repository creation is provably limited to private/internal visibility, instead of always reporting high [(#12164)](https://github.com/prowler-cloud/prowler/pull/12164)
|
||||
|
||||
### 🔐 Security
|
||||
|
||||
- HTML report header now HTML-escapes every provider identity field across all 23 providers, closing a stored XSS in the header block (Secur0, CWE-79) that was left unaddressed by the earlier finding-row fix in #12221 [(#12424)](https://github.com/prowler-cloud/prowler/pull/12424)
|
||||
|
||||
---
|
||||
|
||||
## [5.38.0] (Prowler v5.38.0)
|
||||
|
||||
### 🚀 Added
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
`awslambda_layer_no_secrets_in_content` check for AWS provider, scanning Lambda layer package content for hardcoded secrets
|
||||
@@ -1 +0,0 @@
|
||||
`batch_job_definition_no_secrets` check for AWS provider, scanning Batch job definition environment variables and command parameters for hardcoded secrets
|
||||
@@ -1 +0,0 @@
|
||||
CMMC 2.0 universal compliance framework (`cmmc_2.0`) with the 149 official requirements from 32 CFR Part 170 — Level 1 (15, 48 CFR 52.204-21), Level 2 (110, NIST SP 800-171 Rev 2) and Level 3 (24, NIST SP 800-172) — with AWS, Azure, GCP, Alibaba Cloud, Oracle Cloud and M365 check mappings and config guardrails
|
||||
@@ -0,0 +1 @@
|
||||
`ecr_repository_image_no_secrets` check for AWS provider, scanning the latest ECR repository image's configuration and filesystem layers for hardcoded secrets
|
||||
@@ -0,0 +1 @@
|
||||
Add the `iam_workload_identity_pool_provider_attribute_condition` check to flag GCP Workload Identity Federation providers that trust a multi-tenant issuer without an attribute condition restricting which external identities can impersonate federated principals
|
||||
@@ -1 +0,0 @@
|
||||
GitHub `organization_repository_creation_limited` check now reports low severity for FAIL findings when repository creation is provably limited to private/internal visibility, instead of always reporting high
|
||||
@@ -1 +0,0 @@
|
||||
`entra_authentication_method_email_otp_disabled`, `entra_authentication_method_authenticator_show_context`, `entra_pim_global_administrator_approval_required`, `entra_pim_privileged_role_administrator_approval_required`, `entra_access_review_guest_users_configured` and `entra_access_review_privileged_roles_configured` checks for M365 provider covering CIS Microsoft 365 Foundations Benchmark v7.0.0 authentication method, PIM approval and access review controls
|
||||
@@ -1 +0,0 @@
|
||||
7 M365 entra checks covering CIS Microsoft 365 Foundations Benchmark v7.0.0 Conditional Access (5.2.2.x) and idle session timeout controls
|
||||
@@ -1 +0,0 @@
|
||||
7 M365 Entra checks covering CIS Microsoft 365 Foundations Benchmark v7.0.0 password protection, default user permissions, and guest invitation domain restrictions
|
||||
@@ -0,0 +1 @@
|
||||
Add the `rolesanywhere_profile_restricts_session_permissions` check to flag AWS IAM Roles Anywhere profiles that reference an administrative role without scoping down the vended session with a session policy or managed policies
|
||||
@@ -0,0 +1 @@
|
||||
`vpc_security_group_open_egress` check for Huawei Cloud provider: VPC security groups do not allow open egress to the internet
|
||||
@@ -49,7 +49,7 @@ class _MutableTimestamp:
|
||||
|
||||
timestamp = _MutableTimestamp(datetime.today())
|
||||
timestamp_utc = _MutableTimestamp(datetime.now(timezone.utc))
|
||||
prowler_version = "5.39.0"
|
||||
prowler_version = "5.40.0"
|
||||
html_logo_url = "https://github.com/prowler-cloud/prowler/"
|
||||
square_logo_img = "https://raw.githubusercontent.com/prowler-cloud/prowler/dc7d2d5aeb92fdf12e8604f42ef6472cd3e8e889/docs/img/prowler-logo-black.png"
|
||||
aws_logo = "https://user-images.githubusercontent.com/38561120/235953920-3e3fba08-0795-41dc-b480-9bea57db9f2e.png"
|
||||
|
||||
@@ -463,6 +463,11 @@ class HTML(Output):
|
||||
audited_regions = "All Regions"
|
||||
else:
|
||||
audited_regions = ", ".join(provider.identity.audited_regions)
|
||||
account = escape(str(provider.identity.account))
|
||||
profile = escape(str(profile))
|
||||
audited_regions = escape(str(audited_regions))
|
||||
user_id = escape(str(provider.identity.user_id))
|
||||
identity_arn = escape(str(provider.identity.identity_arn))
|
||||
return f"""
|
||||
<div class="col-md-2">
|
||||
<div class="card">
|
||||
@@ -471,7 +476,7 @@ class HTML(Output):
|
||||
</div>
|
||||
<ul class="list-group list-group-flush">
|
||||
<li class="list-group-item">
|
||||
<b>AWS Account:</b> {provider.identity.account}
|
||||
<b>AWS Account:</b> {account}
|
||||
</li>
|
||||
<li class="list-group-item">
|
||||
<b>AWS-CLI Profile:</b> {profile}
|
||||
@@ -489,10 +494,10 @@ class HTML(Output):
|
||||
</div>
|
||||
<ul class="list-group list-group-flush">
|
||||
<li class="list-group-item">
|
||||
<b>User Id:</b> {provider.identity.user_id}
|
||||
<b>User Id:</b> {user_id}
|
||||
</li>
|
||||
<li class="list-group-item">
|
||||
<b>Caller Identity ARN:</b> {provider.identity.identity_arn}
|
||||
<b>Caller Identity ARN:</b> {identity_arn}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
@@ -530,6 +535,11 @@ class HTML(Output):
|
||||
)
|
||||
else:
|
||||
html_identity = provider.identity.identity_id
|
||||
tenant_ids = escape(" ".join(provider.identity.tenant_ids))
|
||||
tenant_domain = escape(str(provider.identity.tenant_domain))
|
||||
subscriptions = escape(" ".join(printed_subscriptions))
|
||||
identity_type = escape(str(provider.identity.identity_type))
|
||||
html_identity = escape(str(html_identity))
|
||||
return f"""
|
||||
<div class="col-md-2">
|
||||
<div class="card">
|
||||
@@ -538,13 +548,13 @@ class HTML(Output):
|
||||
</div>
|
||||
<ul class="list-group list-group-flush">
|
||||
<li class="list-group-item">
|
||||
<b>Azure Tenant IDs:</b> {" ".join(provider.identity.tenant_ids)}
|
||||
<b>Azure Tenant IDs:</b> {tenant_ids}
|
||||
</li>
|
||||
<li class="list-group-item">
|
||||
<b>Azure Tenant Domain:</b> {provider.identity.tenant_domain}
|
||||
<b>Azure Tenant Domain:</b> {tenant_domain}
|
||||
</li>
|
||||
<li class="list-group-item">
|
||||
<b>Azure Subscriptions:</b> {" ".join(printed_subscriptions)}
|
||||
<b>Azure Subscriptions:</b> {subscriptions}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
@@ -556,7 +566,7 @@ class HTML(Output):
|
||||
</div>
|
||||
<ul class="list-group list-group-flush">
|
||||
<li class="list-group-item">
|
||||
<b>Azure Identity Type:</b> {provider.identity.identity_type}
|
||||
<b>Azure Identity Type:</b> {identity_type}
|
||||
</li>
|
||||
<li class="list-group-item">
|
||||
<b>Azure Identity ID:</b> {html_identity}
|
||||
@@ -591,6 +601,8 @@ class HTML(Output):
|
||||
)
|
||||
except AttributeError:
|
||||
profile = "default"
|
||||
project_ids = escape(", ".join(provider.project_ids))
|
||||
profile = escape(str(profile))
|
||||
return f"""
|
||||
<div class="col-md-2">
|
||||
<div class="card">
|
||||
@@ -599,7 +611,7 @@ class HTML(Output):
|
||||
</div>
|
||||
<ul class="list-group list-group-flush">
|
||||
<li class="list-group-item">
|
||||
<b>GCP Project IDs:</b> {", ".join(provider.project_ids)}
|
||||
<b>GCP Project IDs:</b> {project_ids}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
@@ -634,6 +646,8 @@ class HTML(Output):
|
||||
str: the HTML assessment summary
|
||||
"""
|
||||
try:
|
||||
cluster = escape(str(provider.identity.cluster))
|
||||
context = escape(str(provider.identity.context))
|
||||
return f"""
|
||||
<div class="col-md-2">
|
||||
<div class="card">
|
||||
@@ -643,7 +657,7 @@ class HTML(Output):
|
||||
<ul class="list-group
|
||||
list-group-flush">
|
||||
<li class="list-group-item">
|
||||
<b>Kubernetes Cluster:</b> {provider.identity.cluster}
|
||||
<b>Kubernetes Cluster:</b> {cluster}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
@@ -656,7 +670,7 @@ class HTML(Output):
|
||||
<ul class="list-group
|
||||
list-group-flush">
|
||||
<li class="list-group-item">
|
||||
<b>Kubernetes Context:</b> {provider.identity.context}
|
||||
<b>Kubernetes Context:</b> {context}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
@@ -679,11 +693,13 @@ class HTML(Output):
|
||||
str: the HTML assessment summary
|
||||
"""
|
||||
try:
|
||||
auth_method = escape(str(provider.auth_method))
|
||||
if hasattr(provider.identity, "account_name"):
|
||||
# GithubIdentityInfo (Personal Access Token, OAuth)
|
||||
account_name = escape(str(provider.identity.account_name))
|
||||
account_info_items = f"""
|
||||
<li class="list-group-item">
|
||||
<b>GitHub account:</b> {provider.identity.account_name}
|
||||
<b>GitHub account:</b> {account_name}
|
||||
</li>
|
||||
"""
|
||||
# Add email if available
|
||||
@@ -691,23 +707,27 @@ class HTML(Output):
|
||||
hasattr(provider.identity, "account_email")
|
||||
and provider.identity.account_email
|
||||
):
|
||||
account_email = escape(str(provider.identity.account_email))
|
||||
account_info_items += f"""
|
||||
<li class="list-group-item">
|
||||
<b>GitHub account email:</b> {provider.identity.account_email}
|
||||
<b>GitHub account email:</b> {account_email}
|
||||
</li>"""
|
||||
elif hasattr(provider.identity, "app_id"):
|
||||
# GithubAppIdentityInfo (GitHub App)
|
||||
# Assessment items: App Name and Installations
|
||||
app_name = escape(str(provider.identity.app_name))
|
||||
account_info_items = f"""
|
||||
<li class="list-group-item">
|
||||
<b>GitHub App Name:</b> {provider.identity.app_name}
|
||||
<b>GitHub App Name:</b> {app_name}
|
||||
</li>"""
|
||||
# Add installations if available
|
||||
if (
|
||||
hasattr(provider.identity, "installations")
|
||||
and provider.identity.installations
|
||||
):
|
||||
installations_display = ", ".join(provider.identity.installations)
|
||||
installations_display = escape(
|
||||
", ".join(provider.identity.installations)
|
||||
)
|
||||
account_info_items += f"""
|
||||
<li class="list-group-item">
|
||||
<b>Installations:</b> {installations_display}
|
||||
@@ -719,26 +739,27 @@ class HTML(Output):
|
||||
</li>"""
|
||||
|
||||
# Credentials items: Authentication method and App ID
|
||||
app_id = escape(str(provider.identity.app_id))
|
||||
credentials_items = f"""
|
||||
<li class="list-group-item">
|
||||
<b>GitHub authentication method:</b> {provider.auth_method}
|
||||
<b>GitHub authentication method:</b> {auth_method}
|
||||
</li>
|
||||
<li class="list-group-item">
|
||||
<b>GitHub App ID:</b> {provider.identity.app_id}
|
||||
<b>GitHub App ID:</b> {app_id}
|
||||
</li>"""
|
||||
else:
|
||||
# Fallback for other identity types
|
||||
account_info_items = ""
|
||||
credentials_items = f"""
|
||||
<li class="list-group-item">
|
||||
<b>GitHub authentication method:</b> {provider.auth_method}
|
||||
<b>GitHub authentication method:</b> {auth_method}
|
||||
</li>"""
|
||||
|
||||
# For PAT/OAuth, use default credentials structure
|
||||
if hasattr(provider.identity, "account_name"):
|
||||
credentials_items = f"""
|
||||
<li class="list-group-item">
|
||||
<b>GitHub authentication method:</b> {provider.auth_method}
|
||||
<b>GitHub authentication method:</b> {auth_method}
|
||||
</li>"""
|
||||
|
||||
return f"""
|
||||
@@ -779,6 +800,18 @@ class HTML(Output):
|
||||
str: the HTML assessment summary
|
||||
"""
|
||||
try:
|
||||
tenant_domain = escape(str(provider.identity.tenant_domain))
|
||||
identity_type = escape(str(provider.identity.identity_type))
|
||||
identity_id = escape(str(provider.identity.identity_id))
|
||||
user_item = ""
|
||||
if (
|
||||
hasattr(provider.identity, "user")
|
||||
and provider.identity.user is not None
|
||||
):
|
||||
user = escape(str(provider.identity.user))
|
||||
user_item = f"""<li class="list-group-item">
|
||||
<b>M365 User:</b> {user}
|
||||
</li>"""
|
||||
return f"""
|
||||
<div class="col-md-2">
|
||||
<div class="card">
|
||||
@@ -787,9 +820,7 @@ class HTML(Output):
|
||||
</div>
|
||||
<ul class="list-group list-group-flush">
|
||||
<li class="list-group-item">
|
||||
<b>M365 Tenant Domain:</b> {
|
||||
provider.identity.tenant_domain
|
||||
}
|
||||
<b>M365 Tenant Domain:</b> {tenant_domain}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
@@ -801,19 +832,12 @@ class HTML(Output):
|
||||
</div>
|
||||
<ul class="list-group list-group-flush">
|
||||
<li class="list-group-item">
|
||||
<b>M365 Identity Type:</b> {provider.identity.identity_type}
|
||||
<b>M365 Identity Type:</b> {identity_type}
|
||||
</li>
|
||||
<li class="list-group-item">
|
||||
<b>M365 Identity ID:</b> {provider.identity.identity_id}
|
||||
<b>M365 Identity ID:</b> {identity_id}
|
||||
</li>
|
||||
{
|
||||
f'''<li class="list-group-item">
|
||||
<b>M365 User:</b> {provider.identity.user}
|
||||
</li>'''
|
||||
if hasattr(provider.identity, "user")
|
||||
and provider.identity.user is not None
|
||||
else ""
|
||||
}
|
||||
{user_item}
|
||||
</ul>
|
||||
</div>
|
||||
</div>"""
|
||||
@@ -834,6 +858,9 @@ class HTML(Output):
|
||||
str: the HTML assessment summary
|
||||
"""
|
||||
try:
|
||||
tenant_domain = escape(str(provider.identity.tenant_domain))
|
||||
identity_type = escape(str(provider.identity.identity_type))
|
||||
identity_id = escape(str(provider.identity.identity_id))
|
||||
return f"""
|
||||
<div class="col-md-2">
|
||||
<div class="card">
|
||||
@@ -842,7 +869,7 @@ class HTML(Output):
|
||||
</div>
|
||||
<ul class="list-group list-group-flush">
|
||||
<li class="list-group-item">
|
||||
<b>NHN Tenant Domain:</b> {provider.identity.tenant_domain}
|
||||
<b>NHN Tenant Domain:</b> {tenant_domain}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
@@ -854,10 +881,10 @@ class HTML(Output):
|
||||
</div>
|
||||
<ul class="list-group list-group-flush">
|
||||
<li class="list-group-item">
|
||||
<b>NHN Identity Type:</b> {provider.identity.identity_type}
|
||||
<b>NHN Identity Type:</b> {identity_type}
|
||||
</li>
|
||||
<li class="list-group-item">
|
||||
<b>NHN Identity ID:</b> {provider.identity.identity_id}
|
||||
<b>NHN Identity ID:</b> {identity_id}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
@@ -880,6 +907,7 @@ class HTML(Output):
|
||||
str: the HTML assessment summary
|
||||
"""
|
||||
try:
|
||||
organization_name = escape(str(provider.identity.organization_name))
|
||||
return f"""
|
||||
<div class="col-md-2">
|
||||
<div class="card">
|
||||
@@ -889,7 +917,7 @@ class HTML(Output):
|
||||
<ul class="list-group
|
||||
list-group-flush">
|
||||
<li class="list-group-item">
|
||||
<b>MongoDB Atlas organization:</b> {provider.identity.organization_name}
|
||||
<b>MongoDB Atlas organization:</b> {organization_name}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
@@ -925,6 +953,13 @@ class HTML(Output):
|
||||
str: the HTML assessment summary
|
||||
"""
|
||||
try:
|
||||
if provider.scan_repository_url:
|
||||
target_info = "<b>IAC repository URL:</b> " + str(
|
||||
escape(str(provider.scan_repository_url))
|
||||
)
|
||||
else:
|
||||
target_info = "<b>IAC path:</b> " + str(escape(str(provider.scan_path)))
|
||||
auth_method = escape(str(provider.auth_method))
|
||||
return f"""
|
||||
<div class="col-md-2">
|
||||
<div class="card">
|
||||
@@ -934,7 +969,7 @@ class HTML(Output):
|
||||
<ul class="list-group
|
||||
list-group-flush">
|
||||
<li class="list-group-item">
|
||||
{"<b>IAC repository URL:</b> " + provider.scan_repository_url if provider.scan_repository_url else "<b>IAC path:</b> " + provider.scan_path}
|
||||
{target_info}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
@@ -947,7 +982,7 @@ class HTML(Output):
|
||||
<ul class="list-group
|
||||
list-group-flush">
|
||||
<li class="list-group-item">
|
||||
<b>IAC authentication method:</b> {provider.auth_method}
|
||||
<b>IAC authentication method:</b> {auth_method}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
@@ -971,10 +1006,13 @@ class HTML(Output):
|
||||
"""
|
||||
try:
|
||||
if provider.registry:
|
||||
target_info = f"<b>Registry URL:</b> {provider.registry}"
|
||||
registry = escape(str(provider.registry))
|
||||
target_info = f"<b>Registry URL:</b> {registry}"
|
||||
else:
|
||||
target_info = f'<b>Images:</b> {", ".join(provider.images)}'
|
||||
images = escape(", ".join(provider.images))
|
||||
target_info = f"<b>Images:</b> {images}"
|
||||
|
||||
auth_method = escape(str(provider.auth_method))
|
||||
return f"""
|
||||
<div class="col-md-2">
|
||||
<div class="card">
|
||||
@@ -997,7 +1035,7 @@ class HTML(Output):
|
||||
<ul class="list-group
|
||||
list-group-flush">
|
||||
<li class="list-group-item">
|
||||
<b>Image authentication method:</b> {provider.auth_method}
|
||||
<b>Image authentication method:</b> {auth_method}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
@@ -1020,6 +1058,14 @@ class HTML(Output):
|
||||
str: HTML assessment summary for the LLM provider
|
||||
"""
|
||||
try:
|
||||
model = escape(str(provider.model))
|
||||
plugins = escape(", ".join(provider.plugins))
|
||||
max_concurrency = escape(str(provider.max_concurrency))
|
||||
config_file = escape(
|
||||
str(provider.config_path)
|
||||
if provider.config_path
|
||||
else "Using promptfoo defaults"
|
||||
)
|
||||
return f"""
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
@@ -1031,16 +1077,16 @@ class HTML(Output):
|
||||
<ul class="list-group
|
||||
list-group-flush">
|
||||
<li class="list-group-item">
|
||||
<b>Target LLM:</b> {provider.model}
|
||||
<b>Target LLM:</b> {model}
|
||||
</li>
|
||||
<li class="list-group-item">
|
||||
<b>Plugins:</b> {", ".join(provider.plugins)}
|
||||
<b>Plugins:</b> {plugins}
|
||||
</li>
|
||||
<li class="list-group-item">
|
||||
<b>Max concurrency:</b> {provider.max_concurrency}
|
||||
<b>Max concurrency:</b> {max_concurrency}
|
||||
</li>
|
||||
<li class="list-group-item">
|
||||
<b>Config file:</b> {provider.config_path if provider.config_path else "Using promptfoo defaults"}
|
||||
<b>Config file:</b> {config_file}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
@@ -1069,6 +1115,10 @@ class HTML(Output):
|
||||
tenancy_name = getattr(provider.identity, "tenancy_name", "unknown")
|
||||
tenancy_id = getattr(provider.identity, "tenancy_id", "unknown")
|
||||
|
||||
tenancy = escape(
|
||||
str(tenancy_name if tenancy_name != "unknown" else tenancy_id)
|
||||
)
|
||||
profile = escape(str(profile))
|
||||
return f"""
|
||||
<div class="col-md-2">
|
||||
<div class="card">
|
||||
@@ -1077,7 +1127,7 @@ class HTML(Output):
|
||||
</div>
|
||||
<ul class="list-group list-group-flush">
|
||||
<li class="list-group-item">
|
||||
<b>OracleCloud Tenancy:</b> {tenancy_name if tenancy_name != "unknown" else tenancy_id}
|
||||
<b>OracleCloud Tenancy:</b> {tenancy}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
@@ -1116,10 +1166,11 @@ class HTML(Output):
|
||||
project_name = getattr(provider.identity, "project_name", "")
|
||||
audited_regions = getattr(provider.identity, "audited_regions", set())
|
||||
|
||||
project_id = escape(str(project_id))
|
||||
project_name_item = (
|
||||
f"""
|
||||
<li class="list-group-item">
|
||||
<b>Project Name:</b> {project_name}
|
||||
<b>Project Name:</b> {escape(str(project_name))}
|
||||
</li>"""
|
||||
if project_name
|
||||
else ""
|
||||
@@ -1128,7 +1179,7 @@ class HTML(Output):
|
||||
regions_item = (
|
||||
f"""
|
||||
<li class="list-group-item">
|
||||
<b>Regions:</b> {", ".join(sorted(audited_regions))}
|
||||
<b>Regions:</b> {escape(", ".join(sorted(audited_regions)))}
|
||||
</li>"""
|
||||
if audited_regions
|
||||
else ""
|
||||
@@ -1182,7 +1233,7 @@ class HTML(Output):
|
||||
# Build assessment summary items (only non-None values)
|
||||
assessment_items = ""
|
||||
if provider.accounts:
|
||||
accounts = ", ".join([acc.id for acc in provider.accounts])
|
||||
accounts = escape(", ".join([str(acc.id) for acc in provider.accounts]))
|
||||
assessment_items += f"""
|
||||
<li class="list-group-item">
|
||||
<b>Accounts:</b> {accounts}
|
||||
@@ -1208,6 +1259,7 @@ class HTML(Output):
|
||||
provider.session, "api_email", None
|
||||
)
|
||||
if email:
|
||||
email = escape(str(email))
|
||||
credentials_items += f"""
|
||||
<li class="list-group-item">
|
||||
<b>Email:</b> {email}
|
||||
@@ -1261,11 +1313,15 @@ class HTML(Output):
|
||||
account_name_item = (
|
||||
f"""
|
||||
<li class="list-group-item">
|
||||
<b>Account Name:</b> {account_name}
|
||||
<b>Account Name:</b> {escape(str(account_name))}
|
||||
</li>"""
|
||||
if account_name
|
||||
else ""
|
||||
)
|
||||
account_id = escape(str(account_id))
|
||||
audited_regions = escape(str(audited_regions))
|
||||
user_name = escape(str(user_name))
|
||||
identity_arn = escape(str(identity_arn))
|
||||
|
||||
return f"""
|
||||
<div class="col-md-2">
|
||||
@@ -1326,7 +1382,7 @@ class HTML(Output):
|
||||
project_name_item = (
|
||||
f"""
|
||||
<li class="list-group-item">
|
||||
<b>Project Name:</b> {project_name}
|
||||
<b>Project Name:</b> {escape(str(project_name))}
|
||||
</li>"""
|
||||
if project_name
|
||||
else ""
|
||||
@@ -1335,11 +1391,14 @@ class HTML(Output):
|
||||
user_id_item = (
|
||||
f"""
|
||||
<li class="list-group-item">
|
||||
<b>User ID:</b> {user_id}
|
||||
<b>User ID:</b> {escape(str(user_id))}
|
||||
</li>"""
|
||||
if user_id
|
||||
else ""
|
||||
)
|
||||
project_id = escape(str(project_id))
|
||||
region_name = escape(str(region_name))
|
||||
username = escape(str(username))
|
||||
|
||||
return f"""
|
||||
<div class="col-md-2">
|
||||
@@ -1389,6 +1448,9 @@ class HTML(Output):
|
||||
str: HTML assessment summary for the Google Workspace provider
|
||||
"""
|
||||
try:
|
||||
domain = escape(str(provider.identity.domain))
|
||||
customer_id = escape(str(provider.identity.customer_id))
|
||||
delegated_user = escape(str(provider.identity.delegated_user))
|
||||
return f"""
|
||||
<div class="col-md-2">
|
||||
<div class="card">
|
||||
@@ -1397,10 +1459,10 @@ class HTML(Output):
|
||||
</div>
|
||||
<ul class="list-group list-group-flush">
|
||||
<li class="list-group-item">
|
||||
<b>Domain:</b> {provider.identity.domain}
|
||||
<b>Domain:</b> {domain}
|
||||
</li>
|
||||
<li class="list-group-item">
|
||||
<b>Customer ID:</b> {provider.identity.customer_id}
|
||||
<b>Customer ID:</b> {customer_id}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
@@ -1412,7 +1474,7 @@ class HTML(Output):
|
||||
</div>
|
||||
<ul class="list-group list-group-flush">
|
||||
<li class="list-group-item">
|
||||
<b>Delegated User:</b> {provider.identity.delegated_user}
|
||||
<b>Delegated User:</b> {delegated_user}
|
||||
</li>
|
||||
<li class="list-group-item">
|
||||
<b>Authentication Method:</b> Service Account with Domain-Wide Delegation
|
||||
@@ -1482,9 +1544,11 @@ class HTML(Output):
|
||||
|
||||
team = getattr(provider.identity, "team", None)
|
||||
if team:
|
||||
team_name = escape(str(team.name))
|
||||
team_id = escape(str(team.id))
|
||||
assessment_items += f"""
|
||||
<li class="list-group-item">
|
||||
<b>Team:</b> {team.name} ({team.id})
|
||||
<b>Team:</b> {team_name} ({team_id})
|
||||
</li>"""
|
||||
|
||||
credentials_items = """
|
||||
@@ -1494,6 +1558,7 @@ class HTML(Output):
|
||||
|
||||
email = getattr(provider.identity, "email", None)
|
||||
if email:
|
||||
email = escape(str(email))
|
||||
credentials_items += f"""
|
||||
<li class="list-group-item">
|
||||
<b>Email:</b> {email}
|
||||
@@ -1501,6 +1566,7 @@ class HTML(Output):
|
||||
|
||||
username = getattr(provider.identity, "username", None)
|
||||
if username:
|
||||
username = escape(str(username))
|
||||
credentials_items += f"""
|
||||
<li class="list-group-item">
|
||||
<b>Username:</b> {username}
|
||||
@@ -1543,17 +1609,20 @@ class HTML(Output):
|
||||
str: HTML assessment summary for the Okta provider
|
||||
"""
|
||||
try:
|
||||
org_domain = escape(str(provider.identity.org_domain))
|
||||
auth_method = escape(str(provider.auth_method))
|
||||
client_id = escape(str(provider.identity.client_id))
|
||||
assessment_items = f"""
|
||||
<li class="list-group-item">
|
||||
<b>Okta Domain:</b> {provider.identity.org_domain}
|
||||
<b>Okta Domain:</b> {org_domain}
|
||||
</li>"""
|
||||
|
||||
credentials_items = f"""
|
||||
<li class="list-group-item">
|
||||
<b>Authentication:</b> {provider.auth_method}
|
||||
<b>Authentication:</b> {auth_method}
|
||||
</li>
|
||||
<li class="list-group-item">
|
||||
<b>Client ID:</b> {provider.identity.client_id}
|
||||
<b>Client ID:</b> {client_id}
|
||||
</li>"""
|
||||
|
||||
return f"""
|
||||
@@ -1593,9 +1662,10 @@ class HTML(Output):
|
||||
str: HTML assessment summary for the Scaleway provider
|
||||
"""
|
||||
try:
|
||||
organization_id = escape(str(provider.identity.organization_id))
|
||||
assessment_items = f"""
|
||||
<li class="list-group-item">
|
||||
<b>Organization ID:</b> {provider.identity.organization_id}
|
||||
<b>Organization ID:</b> {organization_id}
|
||||
</li>"""
|
||||
|
||||
credentials_items = """
|
||||
@@ -1605,6 +1675,7 @@ class HTML(Output):
|
||||
|
||||
access_key = getattr(provider.session, "access_key", None)
|
||||
if access_key:
|
||||
access_key = escape(str(access_key))
|
||||
credentials_items += f"""
|
||||
<li class="list-group-item">
|
||||
<b>Access Key:</b> {access_key}
|
||||
@@ -1615,6 +1686,8 @@ class HTML(Output):
|
||||
bearer_id = getattr(provider.identity, "bearer_id", None)
|
||||
if bearer_type:
|
||||
bearer_label = bearer_email or bearer_id or "-"
|
||||
bearer_type = escape(str(bearer_type))
|
||||
bearer_label = escape(str(bearer_label))
|
||||
credentials_items += f"""
|
||||
<li class="list-group-item">
|
||||
<b>Bearer:</b> {bearer_type} ({bearer_label})
|
||||
@@ -1622,6 +1695,7 @@ class HTML(Output):
|
||||
|
||||
region = getattr(provider.session, "default_region", None)
|
||||
if region:
|
||||
region = escape(str(region))
|
||||
credentials_items += f"""
|
||||
<li class="list-group-item">
|
||||
<b>Default Region:</b> {region}
|
||||
@@ -1668,6 +1742,9 @@ class HTML(Output):
|
||||
email = getattr(provider.identity, "email", None) or "-"
|
||||
account_id = getattr(provider.identity, "account_id", None) or "-"
|
||||
|
||||
username = escape(str(username))
|
||||
email = escape(str(email))
|
||||
account_id = escape(str(account_id))
|
||||
assessment_items = f"""
|
||||
<li class="list-group-item">
|
||||
<b>Account ID:</b> {account_id}
|
||||
@@ -1732,6 +1809,14 @@ class HTML(Output):
|
||||
audited_regions = "All Regions"
|
||||
else:
|
||||
audited_regions = ", ".join(provider.identity.regions)
|
||||
account_id = escape(str(provider.identity.account_id))
|
||||
account_name = escape(str(provider.identity.account_name))
|
||||
profile = escape(str(profile))
|
||||
audited_regions = escape(str(audited_regions))
|
||||
domain_id = escape(str(provider.identity.domain_id))
|
||||
user_id = escape(str(provider.identity.user_id))
|
||||
user_name = escape(str(provider.identity.user_name))
|
||||
identity_type = escape(str(provider.identity.identity_type))
|
||||
return f"""
|
||||
<div class="col-md-2">
|
||||
<div class="card">
|
||||
@@ -1740,10 +1825,10 @@ class HTML(Output):
|
||||
</div>
|
||||
<ul class="list-group list-group-flush">
|
||||
<li class="list-group-item">
|
||||
<b>Account ID:</b> {provider.identity.account_id}
|
||||
<b>Account ID:</b> {account_id}
|
||||
</li>
|
||||
<li class="list-group-item">
|
||||
<b>Account Name:</b> {provider.identity.account_name}
|
||||
<b>Account Name:</b> {account_name}
|
||||
</li>
|
||||
<li class="list-group-item">
|
||||
<b>Profile:</b> {profile}
|
||||
@@ -1761,16 +1846,16 @@ class HTML(Output):
|
||||
</div>
|
||||
<ul class="list-group list-group-flush">
|
||||
<li class="list-group-item">
|
||||
<b>Domain ID:</b> {provider.identity.domain_id}
|
||||
<b>Domain ID:</b> {domain_id}
|
||||
</li>
|
||||
<li class="list-group-item">
|
||||
<b>User ID:</b> {provider.identity.user_id}
|
||||
<b>User ID:</b> {user_id}
|
||||
</li>
|
||||
<li class="list-group-item">
|
||||
<b>User Name:</b> {provider.identity.user_name}
|
||||
<b>User Name:</b> {user_name}
|
||||
</li>
|
||||
<li class="list-group-item">
|
||||
<b>Identity Type:</b> {provider.identity.identity_type}
|
||||
<b>Identity Type:</b> {identity_type}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
@@ -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] = []
|
||||
|
||||
@@ -6,6 +6,8 @@ class ecs_task_definitions_containers_readonly_access(Check):
|
||||
def execute(self):
|
||||
findings = []
|
||||
for task_definition in ecs_client.task_definitions.values():
|
||||
if task_definition.container_definitions is None:
|
||||
continue
|
||||
report = Check_Report_AWS(
|
||||
metadata=self.metadata(), resource=task_definition
|
||||
)
|
||||
|
||||
@@ -6,6 +6,8 @@ class ecs_task_definitions_host_namespace_not_shared(Check):
|
||||
def execute(self):
|
||||
findings = []
|
||||
for task_definition in ecs_client.task_definitions.values():
|
||||
if task_definition.container_definitions is None:
|
||||
continue
|
||||
report = Check_Report_AWS(
|
||||
metadata=self.metadata(), resource=task_definition
|
||||
)
|
||||
|
||||
@@ -6,6 +6,8 @@ class ecs_task_definitions_host_networking_mode_users(Check):
|
||||
def execute(self):
|
||||
findings = []
|
||||
for task_definition in ecs_client.task_definitions.values():
|
||||
if task_definition.container_definitions is None:
|
||||
continue
|
||||
report = Check_Report_AWS(
|
||||
metadata=self.metadata(), resource=task_definition
|
||||
)
|
||||
|
||||
@@ -6,6 +6,8 @@ class ecs_task_definitions_logging_block_mode(Check):
|
||||
def execute(self):
|
||||
findings = []
|
||||
for task_definition in ecs_client.task_definitions.values():
|
||||
if task_definition.container_definitions is None:
|
||||
continue
|
||||
report = Check_Report_AWS(
|
||||
metadata=self.metadata(), resource=task_definition
|
||||
)
|
||||
|
||||
@@ -6,6 +6,8 @@ class ecs_task_definitions_logging_enabled(Check):
|
||||
def execute(self):
|
||||
findings = []
|
||||
for task_definition in ecs_client.task_definitions.values():
|
||||
if task_definition.container_definitions is None:
|
||||
continue
|
||||
report = Check_Report_AWS(
|
||||
metadata=self.metadata(), resource=task_definition
|
||||
)
|
||||
|
||||
@@ -16,7 +16,11 @@ class ecs_task_definitions_no_environment_secrets(Check):
|
||||
"secrets_ignore_patterns", []
|
||||
)
|
||||
validate = ecs_client.audit_config.get("secrets_validate", False)
|
||||
task_definitions = list(ecs_client.task_definitions.values())
|
||||
task_definitions = [
|
||||
task_definition
|
||||
for task_definition in ecs_client.task_definitions.values()
|
||||
if task_definition.container_definitions is not None
|
||||
]
|
||||
|
||||
# Scan every (task definition, container) environment in batched
|
||||
# Kingfisher invocations instead of one subprocess per container.
|
||||
|
||||
@@ -6,6 +6,8 @@ class ecs_task_definitions_no_privileged_containers(Check):
|
||||
def execute(self):
|
||||
findings = []
|
||||
for task_definition in ecs_client.task_definitions.values():
|
||||
if task_definition.container_definitions is None:
|
||||
continue
|
||||
report = Check_Report_AWS(
|
||||
metadata=self.metadata(), resource=task_definition
|
||||
)
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
{
|
||||
"Provider": "aws",
|
||||
"CheckID": "rolesanywhere_profile_restricts_session_permissions",
|
||||
"CheckTitle": "IAM Roles Anywhere profiles scope down the vended session permissions",
|
||||
"CheckType": [
|
||||
"Software and Configuration Checks/AWS Security Best Practices"
|
||||
],
|
||||
"ServiceName": "rolesanywhere",
|
||||
"SubServiceName": "",
|
||||
"ResourceIdTemplate": "",
|
||||
"Severity": "medium",
|
||||
"ResourceType": "AwsRolesAnywhereProfile",
|
||||
"ResourceGroup": "security",
|
||||
"Description": "**IAM Roles Anywhere profiles** that reference an administrative role are assessed for **session scoping**. A profile defining neither an inline `sessionPolicy` nor `managedPolicyArns` vends credentials with the full permissions of its roles. It is flagged only when a referenced role is administrative, since an unscoped session on a least-privilege role is already constrained.",
|
||||
"Risk": "Roles Anywhere profiles bind X.509 certificates to IAM roles. When a profile references an administrative role and does not scope the session, vended credentials carry full administrative permissions. An attacker presenting a valid certificate - or planting a rogue trust anchor and profile - gains durable privileged access that rotating IAM keys does not revoke.",
|
||||
"RelatedUrl": "",
|
||||
"AdditionalURLs": [
|
||||
"https://docs.aws.amazon.com/rolesanywhere/latest/userguide/introduction.html",
|
||||
"https://docs.aws.amazon.com/rolesanywhere/latest/APIReference/API_CreateProfile.html",
|
||||
"https://docs.aws.amazon.com/rolesanywhere/latest/userguide/security-best-practices.html"
|
||||
],
|
||||
"Remediation": {
|
||||
"Code": {
|
||||
"CLI": "aws rolesanywhere update-profile --profile-id <profile_id> --session-policy '{\"Version\":\"2012-10-17\",\"Statement\":[{\"Effect\":\"Allow\",\"Action\":[\"<least_privilege_actions>\"],\"Resource\":[\"<scoped_resources>\"]}]}'",
|
||||
"NativeIaC": "```yaml\nResources:\n <example_resource_name>:\n Type: AWS::RolesAnywhere::Profile\n Properties:\n Name: scoped-profile\n Enabled: true\n RoleArns:\n - <role_arn>\n SessionPolicy: '{\"Version\":\"2012-10-17\",\"Statement\":[{\"Effect\":\"Allow\",\"Action\":[\"<least_privilege_actions>\"],\"Resource\":[\"<scoped_resources>\"]}]}' # FIX: scope down the vended session\n```",
|
||||
"Other": "1. Identify the least-privilege actions the workload actually needs\n2. Attach a sessionPolicy or managedPolicyArns to the Roles Anywhere profile that grants only those actions\n3. Prefer purpose-built roles per workload over broad roles referenced by many profiles\n4. Review trust anchors and profiles regularly for entries you did not create",
|
||||
"Terraform": "```hcl\nresource \"aws_rolesanywhere_profile\" \"<example_resource_name>\" {\n name = \"scoped-profile\"\n enabled = true\n role_arns = [<role_arn>]\n session_policy = jsonencode({\n Version = \"2012-10-17\"\n Statement = [{\n Effect = \"Allow\"\n Action = [<least_privilege_actions>]\n Resource = [<scoped_resources>]\n }]\n }) # FIX: scope down the vended session\n}\n```"
|
||||
},
|
||||
"Recommendation": {
|
||||
"Text": "Attach a session policy or managed policies to every enabled IAM Roles Anywhere profile so the vended credentials are scoped below the referenced role's permissions. Pair each profile with a purpose-built least-privilege role and audit trust anchors and profiles regularly for unexpected entries.",
|
||||
"Url": "https://hub.prowler.com/check/rolesanywhere_profile_restricts_session_permissions"
|
||||
}
|
||||
},
|
||||
"Categories": [
|
||||
"identity-access",
|
||||
"trust-boundaries"
|
||||
],
|
||||
"DependsOn": [],
|
||||
"RelatedTo": [],
|
||||
"Notes": "A profile is failed only when it is enabled, its session is unscoped, and a referenced role effectively grants administrative (*:*) access. The session-policy set (inline sessionPolicy plus every managedPolicyArns entry, resolved to its policy document) is evaluated as a union: any member granting *:* leaves the session unrestricted. Role classification merges all attached and inline identity-policy documents so explicit denies negate allows across policies, excludes condition-guarded statements (not statically provable), and intersects the result with the role's permissions boundary: a role whose boundary does not grant *:* - or whose boundary document cannot be resolved - is not classified as administrative. Disabled profiles, scoped profiles, and profiles with no role identified as administrative are reported as PASS. Referenced roles absent from the IAM inventory (for example cross-account roles or denied ListRoles) and policy documents that could not be collected are treated as non-administrative to avoid false positives."
|
||||
}
|
||||
@@ -0,0 +1,272 @@
|
||||
import json
|
||||
|
||||
from prowler.lib.check.models import Check, Check_Report_AWS
|
||||
from prowler.lib.logger import logger
|
||||
from prowler.providers.aws.services.iam.iam_client import iam_client
|
||||
from prowler.providers.aws.services.iam.lib.policy import check_full_service_access
|
||||
from prowler.providers.aws.services.rolesanywhere.rolesanywhere_client import (
|
||||
rolesanywhere_client,
|
||||
)
|
||||
|
||||
# AWS-managed AdministratorAccess ARN suffix, partition-agnostic
|
||||
# (arn:aws:..., arn:aws-cn:..., arn:aws-us-gov:...).
|
||||
ADMIN_POLICY_ARN_SUFFIX = ":iam::aws:policy/AdministratorAccess"
|
||||
|
||||
# Synthetic statement equivalent to the AWS-managed AdministratorAccess
|
||||
# document, used when a policy is identified by that ARN but its document was
|
||||
# not collected by the IAM service.
|
||||
_ADMIN_STATEMENT = {"Effect": "Allow", "Action": "*", "Resource": "*"}
|
||||
|
||||
|
||||
def _grants_full_access(document) -> bool:
|
||||
"""Return True when a policy document grants administrative (``*:*``) access.
|
||||
|
||||
Args:
|
||||
document: Decoded IAM policy document, or None when unavailable.
|
||||
"""
|
||||
if not document:
|
||||
return False
|
||||
try:
|
||||
return check_full_service_access("*", document)
|
||||
except Exception as error:
|
||||
logger.error(
|
||||
f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
|
||||
)
|
||||
return False
|
||||
|
||||
|
||||
def _full_access_status(documents) -> bool | None:
|
||||
"""Return whether a policy-document set grants ``*:*``, or None when unknown.
|
||||
|
||||
Merges every condition-free statement across the given documents into a
|
||||
single evaluation so an explicit deny in one document negates an allow in
|
||||
another (Deny > Allow via the shared policy-evaluation helpers).
|
||||
|
||||
Unresolved or malformed documents and context-dependent semantics cannot
|
||||
produce a definitive classification and propagate as None:
|
||||
|
||||
- a document that is missing or not a well-formed statement container;
|
||||
- a ``Condition``-guarded Deny that could negate an otherwise proven
|
||||
full-access grant;
|
||||
- a ``Condition``-guarded Allow that could grant full access not proven
|
||||
by the unconditional statements.
|
||||
|
||||
Args:
|
||||
documents: Iterable of decoded IAM policy documents (None members mark
|
||||
documents that could not be resolved).
|
||||
"""
|
||||
statements = []
|
||||
conditional_effects = set()
|
||||
|
||||
for document in documents:
|
||||
if not isinstance(document, dict) or "Statement" not in document:
|
||||
return None
|
||||
|
||||
document_statements = document.get("Statement", [])
|
||||
if not isinstance(document_statements, list):
|
||||
document_statements = [document_statements]
|
||||
|
||||
for statement in document_statements:
|
||||
if not isinstance(statement, dict):
|
||||
return None
|
||||
|
||||
effect = str(statement.get("Effect", "")).lower()
|
||||
if (
|
||||
effect not in {"allow", "deny"}
|
||||
or not ("Action" in statement or "NotAction" in statement)
|
||||
or not ("Resource" in statement or "NotResource" in statement)
|
||||
):
|
||||
return None
|
||||
|
||||
if statement.get("Condition"):
|
||||
conditional_effects.add(effect)
|
||||
else:
|
||||
statements.append(statement)
|
||||
|
||||
grants_full_access = _grants_full_access({"Statement": statements})
|
||||
if (grants_full_access and "deny" in conditional_effects) or (
|
||||
not grants_full_access and "allow" in conditional_effects
|
||||
):
|
||||
return None
|
||||
|
||||
return grants_full_access
|
||||
|
||||
|
||||
def _role_is_privileged(role, policies) -> bool | None:
|
||||
"""Return whether an IAM role is administrative, or None when unknown.
|
||||
|
||||
Effective permissions are the intersection of the role's identity policies
|
||||
(attached and inline, evaluated together) and its permissions boundary.
|
||||
Unresolved policy documents, malformed policies, and condition-guarded
|
||||
statements that could change the outcome propagate as None instead of
|
||||
being collapsed into a definitive classification.
|
||||
|
||||
Args:
|
||||
role: An ``iam_service.Role`` referenced by a Roles Anywhere profile.
|
||||
policies: Mapping of policy ARN to ``iam_service.Policy`` from iam_client.
|
||||
"""
|
||||
documents = []
|
||||
for attached in role.attached_policies:
|
||||
policy_arn = attached.get("PolicyArn", "")
|
||||
document = getattr(policies.get(policy_arn), "document", None)
|
||||
if policy_arn.endswith(ADMIN_POLICY_ARN_SUFFIX) and not document:
|
||||
documents.append({"Statement": [_ADMIN_STATEMENT]})
|
||||
else:
|
||||
documents.append(document)
|
||||
for inline_name in role.inline_policies:
|
||||
policy = policies.get(f"{role.arn}:policy/{inline_name}")
|
||||
documents.append(getattr(policy, "document", None))
|
||||
|
||||
identity_status = _full_access_status(documents)
|
||||
if identity_status is False:
|
||||
# Identity policies provably do not grant *:*; no boundary can widen them.
|
||||
return False
|
||||
|
||||
boundary = getattr(role, "permissions_boundary", None)
|
||||
if not boundary:
|
||||
return identity_status
|
||||
boundary_arn = (
|
||||
boundary.get("PermissionsBoundaryArn", "") if isinstance(boundary, dict) else ""
|
||||
)
|
||||
if boundary_arn.endswith(ADMIN_POLICY_ARN_SUFFIX):
|
||||
# An AdministratorAccess boundary restricts nothing.
|
||||
return identity_status
|
||||
|
||||
boundary_status = _full_access_status(
|
||||
[getattr(policies.get(boundary_arn), "document", None)]
|
||||
)
|
||||
if boundary_status is False:
|
||||
# The boundary provably does not grant *:*: the intersection cannot be
|
||||
# administrative regardless of the identity policies.
|
||||
return False
|
||||
if boundary_status is None:
|
||||
return None
|
||||
return identity_status
|
||||
|
||||
|
||||
def _session_is_scoped(profile, policies) -> bool | None:
|
||||
"""Return whether session policies restrict permissions, or None when unknown.
|
||||
|
||||
AWS evaluates the inline ``sessionPolicy`` and every ``managedPolicyArns``
|
||||
entry together as a single session-policy category, so the complete set is
|
||||
merged into one evaluation: the session is scoped only when at least one
|
||||
session policy exists and the set does not grant ``*:*``. Managed entries
|
||||
are resolved through the collected IAM policies. An invalid inline policy
|
||||
or an unresolved managed policy does not prove that the session is
|
||||
restricted and propagates as None.
|
||||
|
||||
Args:
|
||||
profile: A ``rolesanywhere_service.Profile``.
|
||||
policies: Mapping of policy ARN to ``iam_service.Policy`` from iam_client.
|
||||
"""
|
||||
if not profile.session_policy and not profile.managed_policy_arns:
|
||||
return False
|
||||
|
||||
documents = []
|
||||
if profile.session_policy:
|
||||
try:
|
||||
documents.append(json.loads(profile.session_policy))
|
||||
except (ValueError, TypeError):
|
||||
return None
|
||||
for arn in profile.managed_policy_arns or []:
|
||||
if arn.endswith(ADMIN_POLICY_ARN_SUFFIX):
|
||||
documents.append({"Statement": [_ADMIN_STATEMENT]})
|
||||
else:
|
||||
documents.append(getattr(policies.get(arn), "document", None))
|
||||
|
||||
grants_full_access = _full_access_status(documents)
|
||||
return None if grants_full_access is None else not grants_full_access
|
||||
|
||||
|
||||
class rolesanywhere_profile_restricts_session_permissions(Check):
|
||||
"""Flag Roles Anywhere profiles that vend an unscoped session on a privileged role.
|
||||
|
||||
A Roles Anywhere profile that does not restrict the session with an inline
|
||||
``sessionPolicy`` or ``managedPolicyArns`` vends temporary credentials
|
||||
carrying the full permissions of every role it references. This is only a
|
||||
real risk when a referenced role is itself administrative: any certificate
|
||||
accepted by the trust anchor then wields administrative permissions, turning
|
||||
the profile into a durable privileged-access path that surviving key rotation
|
||||
does not remove. Profiles that scope the session, whose referenced roles were
|
||||
proven not administrative, or that are disabled are reported as PASS. When
|
||||
session scoping or role permissions cannot be evaluated (unresolved or
|
||||
invalid policy documents, condition-guarded grants, unknown roles), the
|
||||
report is MANUAL rather than a proven outcome.
|
||||
"""
|
||||
|
||||
def execute(self) -> list[Check_Report_AWS]:
|
||||
"""Evaluate session-permission scoping for Roles Anywhere profiles.
|
||||
|
||||
Returns:
|
||||
list[Check_Report_AWS]: One report per Roles Anywhere profile. FAIL
|
||||
for enabled, unscoped profiles that reference a proven administrative
|
||||
role; MANUAL when session scoping or role permissions could not be
|
||||
evaluated; PASS for scoped profiles, profiles whose roles were proven
|
||||
not administrative, and disabled profiles.
|
||||
"""
|
||||
findings = []
|
||||
roles_by_arn = {role.arn: role for role in iam_client.roles}
|
||||
for profile in rolesanywhere_client.profiles.values():
|
||||
report = Check_Report_AWS(metadata=self.metadata(), resource=profile)
|
||||
role_statuses = {
|
||||
arn: (
|
||||
_role_is_privileged(roles_by_arn[arn], iam_client.policies)
|
||||
if arn in roles_by_arn
|
||||
else None
|
||||
)
|
||||
for arn in profile.role_arns
|
||||
}
|
||||
privileged_role_arns = [
|
||||
arn for arn, status in role_statuses.items() if status is True
|
||||
]
|
||||
unknown_role_arns = [
|
||||
arn for arn, status in role_statuses.items() if status is None
|
||||
]
|
||||
session_scoped = _session_is_scoped(profile, iam_client.policies)
|
||||
|
||||
if not profile.enabled:
|
||||
report.status = "PASS"
|
||||
report.status_extended = (
|
||||
f"IAM Roles Anywhere profile {profile.name} is disabled and "
|
||||
"cannot vend session credentials."
|
||||
)
|
||||
elif session_scoped is True:
|
||||
report.status = "PASS"
|
||||
report.status_extended = (
|
||||
f"IAM Roles Anywhere profile {profile.name} restricts vended "
|
||||
"session permissions with a session policy or managed policies."
|
||||
)
|
||||
elif session_scoped is None:
|
||||
report.status = "MANUAL"
|
||||
report.status_extended = (
|
||||
f"IAM Roles Anywhere profile {profile.name} session scoping "
|
||||
"could not be evaluated because an inline or managed session "
|
||||
"policy was invalid or unresolved."
|
||||
)
|
||||
elif privileged_role_arns:
|
||||
report.status = "FAIL"
|
||||
report.status_extended = (
|
||||
f"IAM Roles Anywhere profile {profile.name} does not scope down "
|
||||
"sessions and references administrative role(s) "
|
||||
f"{', '.join(privileged_role_arns)}; certificates authenticated "
|
||||
"through it inherit administrative permissions, enabling durable "
|
||||
"privileged access."
|
||||
)
|
||||
elif unknown_role_arns:
|
||||
report.status = "MANUAL"
|
||||
report.status_extended = (
|
||||
f"IAM Roles Anywhere profile {profile.name} does not scope down "
|
||||
"sessions, and the effective permissions of referenced role(s) "
|
||||
f"{', '.join(unknown_role_arns)} could not be evaluated."
|
||||
)
|
||||
else:
|
||||
report.status = "PASS"
|
||||
report.status_extended = (
|
||||
f"IAM Roles Anywhere profile {profile.name} does not scope down "
|
||||
"sessions, but no referenced role was identified as "
|
||||
"administrative; scoping the session is recommended as "
|
||||
"defense-in-depth."
|
||||
)
|
||||
findings.append(report)
|
||||
|
||||
return findings
|
||||
@@ -11,7 +11,9 @@ class RolesAnywhere(AWSService):
|
||||
def __init__(self, provider):
|
||||
super().__init__(__class__.__name__, provider)
|
||||
self.trust_anchors = {}
|
||||
self.profiles = {}
|
||||
self.__threading_call__(self._list_trust_anchors)
|
||||
self.__threading_call__(self._list_profiles)
|
||||
|
||||
def _list_trust_anchors(self, regional_client):
|
||||
logger.info("RolesAnywhere - Listing Trust Anchors...")
|
||||
@@ -52,6 +54,53 @@ class RolesAnywhere(AWSService):
|
||||
f"{regional_client.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
|
||||
)
|
||||
|
||||
def _list_profiles(self, regional_client):
|
||||
"""List and cache IAM Roles Anywhere profiles for one AWS Region.
|
||||
|
||||
Args:
|
||||
regional_client: Roles Anywhere client for the audited Region.
|
||||
"""
|
||||
logger.info("RolesAnywhere - Listing Profiles...")
|
||||
try:
|
||||
paginator = regional_client.get_paginator("list_profiles")
|
||||
for page in paginator.paginate():
|
||||
for profile in page.get("profiles", []):
|
||||
arn = profile.get("profileArn", "")
|
||||
if not arn:
|
||||
continue
|
||||
if self.audit_resources and not is_resource_filtered(
|
||||
arn, self.audit_resources
|
||||
):
|
||||
continue
|
||||
tags = []
|
||||
try:
|
||||
tags = regional_client.list_tags_for_resource(
|
||||
resourceArn=arn
|
||||
).get("tags", [])
|
||||
except Exception as error:
|
||||
logger.warning(
|
||||
f"{regional_client.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
|
||||
)
|
||||
self.profiles[arn] = Profile(
|
||||
arn=arn,
|
||||
id=profile.get("profileId", ""),
|
||||
name=profile.get("name", ""),
|
||||
region=regional_client.region,
|
||||
enabled=profile.get("enabled", False),
|
||||
role_arns=profile.get("roleArns", []) or [],
|
||||
session_policy=profile.get("sessionPolicy", "") or "",
|
||||
managed_policy_arns=profile.get("managedPolicyArns", []) or [],
|
||||
duration_seconds=profile.get("durationSeconds", 0) or 0,
|
||||
accept_role_session_name=profile.get(
|
||||
"acceptRoleSessionName", False
|
||||
),
|
||||
tags=tags,
|
||||
)
|
||||
except Exception as error:
|
||||
logger.error(
|
||||
f"{regional_client.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
|
||||
)
|
||||
|
||||
|
||||
class TrustAnchor(BaseModel):
|
||||
arn: str
|
||||
@@ -62,3 +111,19 @@ class TrustAnchor(BaseModel):
|
||||
source_type: str = ""
|
||||
acm_pca_arn: str = ""
|
||||
tags: List[Dict[str, str]] = Field(default_factory=list)
|
||||
|
||||
|
||||
class Profile(BaseModel):
|
||||
"""Represent an IAM Roles Anywhere profile."""
|
||||
|
||||
arn: str
|
||||
id: str
|
||||
name: str
|
||||
region: str
|
||||
enabled: bool = False
|
||||
role_arns: List[str] = Field(default_factory=list)
|
||||
session_policy: str = ""
|
||||
managed_policy_arns: List[str] = Field(default_factory=list)
|
||||
duration_seconds: int = 0
|
||||
accept_role_session_name: bool = False
|
||||
tags: List[Dict[str, str]] = Field(default_factory=list)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -17,6 +17,8 @@ class IAM(GCPService):
|
||||
self.service_accounts = []
|
||||
self._get_service_accounts()
|
||||
self._get_service_accounts_keys()
|
||||
self.workload_identity_pool_providers = []
|
||||
self._get_workload_identity_pool_providers()
|
||||
|
||||
def _get_service_accounts(self):
|
||||
for project_id in self.project_ids:
|
||||
@@ -87,6 +89,94 @@ class IAM(GCPService):
|
||||
f"{self.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
|
||||
)
|
||||
|
||||
def _get_workload_identity_pool_providers(self):
|
||||
for project_id in self.project_ids:
|
||||
try:
|
||||
pools_request = (
|
||||
self.client.projects()
|
||||
.locations()
|
||||
.workloadIdentityPools()
|
||||
.list(parent=f"projects/{project_id}/locations/global")
|
||||
)
|
||||
while pools_request is not None:
|
||||
pools_response = pools_request.execute(
|
||||
num_retries=DEFAULT_RETRY_ATTEMPTS
|
||||
)
|
||||
for pool in pools_response.get("workloadIdentityPools", []):
|
||||
self._get_providers_for_pool(project_id, pool)
|
||||
pools_request = (
|
||||
self.client.projects()
|
||||
.locations()
|
||||
.workloadIdentityPools()
|
||||
.list_next(
|
||||
previous_request=pools_request,
|
||||
previous_response=pools_response,
|
||||
)
|
||||
)
|
||||
except Exception as error:
|
||||
logger.error(
|
||||
f"{self.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
|
||||
)
|
||||
|
||||
def _get_providers_for_pool(self, project_id, pool):
|
||||
try:
|
||||
pool_name = pool.get("name", "")
|
||||
pool_id = pool_name.split("/")[-1]
|
||||
# A provider can remain ACTIVE while its parent pool is disabled or
|
||||
# soft-deleted; a disabled pool cannot vend credentials, so the
|
||||
# pool's effective availability must travel with the provider.
|
||||
pool_disabled = (
|
||||
pool.get("disabled", False) or pool.get("state", "ACTIVE") != "ACTIVE"
|
||||
)
|
||||
request = (
|
||||
self.client.projects()
|
||||
.locations()
|
||||
.workloadIdentityPools()
|
||||
.providers()
|
||||
.list(parent=pool_name)
|
||||
)
|
||||
while request is not None:
|
||||
response = request.execute(num_retries=DEFAULT_RETRY_ATTEMPTS)
|
||||
for provider in response.get("workloadIdentityPoolProviders", []):
|
||||
provider_type = next(
|
||||
(
|
||||
key
|
||||
for key in ("oidc", "aws", "saml", "x509")
|
||||
if key in provider
|
||||
),
|
||||
"",
|
||||
)
|
||||
self.workload_identity_pool_providers.append(
|
||||
WorkloadIdentityPoolProvider(
|
||||
name=provider.get("name", ""),
|
||||
id=provider.get("name", "").split("/")[-1],
|
||||
pool_id=pool_id,
|
||||
pool_disabled=pool_disabled,
|
||||
project_id=project_id,
|
||||
state=provider.get("state", ""),
|
||||
disabled=provider.get("disabled", False),
|
||||
attribute_condition=provider.get("attributeCondition", ""),
|
||||
attribute_mapping=provider.get("attributeMapping", {})
|
||||
or {},
|
||||
provider_type=provider_type,
|
||||
issuer_uri=(provider.get("oidc", {}) or {}).get(
|
||||
"issuerUri", ""
|
||||
),
|
||||
display_name=provider.get("displayName", ""),
|
||||
)
|
||||
)
|
||||
request = (
|
||||
self.client.projects()
|
||||
.locations()
|
||||
.workloadIdentityPools()
|
||||
.providers()
|
||||
.list_next(previous_request=request, previous_response=response)
|
||||
)
|
||||
except Exception as error:
|
||||
logger.error(
|
||||
f"{self.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
|
||||
)
|
||||
|
||||
|
||||
class Key(BaseModel):
|
||||
name: str
|
||||
@@ -106,6 +196,25 @@ class ServiceAccount(BaseModel):
|
||||
disabled: bool = False
|
||||
|
||||
|
||||
class WorkloadIdentityPoolProvider(BaseModel):
|
||||
"""Represent a GCP Workload Identity Federation pool provider."""
|
||||
|
||||
name: str
|
||||
id: str
|
||||
pool_id: str
|
||||
# True when the parent pool is disabled or not ACTIVE; such a pool cannot
|
||||
# vend credentials regardless of the provider's own state.
|
||||
pool_disabled: bool = False
|
||||
project_id: str
|
||||
state: str = ""
|
||||
disabled: bool = False
|
||||
attribute_condition: str = ""
|
||||
attribute_mapping: dict = {}
|
||||
provider_type: str = ""
|
||||
issuer_uri: str = ""
|
||||
display_name: str = ""
|
||||
|
||||
|
||||
class AccessApproval(GCPService):
|
||||
def __init__(self, provider: GcpProvider):
|
||||
super().__init__(__class__.__name__, provider)
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
{
|
||||
"Provider": "gcp",
|
||||
"CheckID": "iam_workload_identity_pool_provider_attribute_condition",
|
||||
"CheckTitle": "Workload Identity Federation providers trusting a multi-tenant issuer enforce an attribute condition",
|
||||
"CheckType": [],
|
||||
"ServiceName": "iam",
|
||||
"SubServiceName": "",
|
||||
"ResourceIdTemplate": "",
|
||||
"Severity": "high",
|
||||
"ResourceType": "iam.googleapis.com/WorkloadIdentityPoolProvider",
|
||||
"Description": "**Workload Identity Federation providers** define an `attributeCondition` (CEL) restricting which external identities may impersonate Google Cloud principals. When a provider trusts a **multi-tenant issuer** (GitHub Actions, GitLab.com and other shared issuers), omitting the condition trusts every identity that issuer can mint. Providers trusting a dedicated single-tenant issuer are not flagged.",
|
||||
"Risk": "A provider trusting a multi-tenant issuer without an attribute condition accepts any external identity from that issuer - for example any GitHub repository when the issuer is GitHub Actions. An attacker controlling any tenant on that platform can authenticate through the provider and exchange tokens for federated credentials. Because no key is stored, this access survives credential rotation.",
|
||||
"RelatedUrl": "",
|
||||
"AdditionalURLs": [
|
||||
"https://cloud.google.com/iam/docs/workload-identity-federation",
|
||||
"https://cloud.google.com/iam/docs/workload-identity-federation#mapping",
|
||||
"https://cloud.google.com/iam/docs/best-practices-for-using-workload-identity-federation"
|
||||
],
|
||||
"Remediation": {
|
||||
"Code": {
|
||||
"CLI": "gcloud iam workload-identity-pools providers update-<oidc|aws|saml|x509> <PROVIDER_ID> --location=global --workload-identity-pool=<POOL_ID> --attribute-condition=\"<CEL_condition>\" # use the update subcommand matching the provider type",
|
||||
"NativeIaC": "",
|
||||
"Other": "1. In the Google Cloud console, go to IAM & Admin > Workload Identity Federation\n2. Open the affected pool and provider\n3. Set an attribute condition (CEL) restricting the allowed external identities, using an assertion that fits the provider type: OIDC by subject or claim (assertion.sub), SAML by NameID or attribute (assertion.subject or assertion.attributes[...]), AWS by account/role (assertion.account or assertion.arn), X.509 by certificate subject\n4. For OIDC providers, also restrict allowedAudiences to your own audience\n5. Review providers regularly for entries you did not create",
|
||||
"Terraform": "```hcl\nresource \"google_iam_workload_identity_pool_provider\" \"example\" {\n workload_identity_pool_id = \"my-pool\"\n workload_identity_pool_provider_id = \"my-provider\"\n attribute_condition = \"assertion.repository_owner == 'my-org'\" # FIX: restrict trusted identities\n\n # Declare exactly one of oidc {}, aws {}, saml {}, or x509 {} to match the provider type, e.g.:\n oidc {\n issuer_uri = \"https://token.actions.githubusercontent.com\"\n allowed_audiences = [\"https://my-audience.example.com\"]\n }\n}\n```"
|
||||
},
|
||||
"Recommendation": {
|
||||
"Text": "Set an attribute condition on any active Workload Identity Federation provider that trusts a multi-tenant issuer so only the intended external identities can impersonate Google Cloud principals, and pair OIDC providers with a restricted audience. Providers trusting a dedicated single-tenant issuer should still add a condition as defense-in-depth. Audit pools and providers regularly for unexpected entries.",
|
||||
"Url": "https://hub.prowler.com/check/iam_workload_identity_pool_provider_attribute_condition"
|
||||
}
|
||||
},
|
||||
"Categories": [
|
||||
"identity-access",
|
||||
"trust-boundaries"
|
||||
],
|
||||
"DependsOn": [],
|
||||
"RelatedTo": [],
|
||||
"Notes": "Only active providers that trust a known multi-tenant issuer (GitHub Actions, GitLab.com, Google, HCP Terraform) are failed when they omit an attribute condition; providers trusting a dedicated single-tenant issuer, AWS/SAML/X.509 providers, and disabled or non-ACTIVE providers are reported as PASS. This check verifies that an attribute condition is present; it does not evaluate whether the condition's expression is sufficiently restrictive."
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from prowler.lib.check.models import Check, Check_Report_GCP
|
||||
from prowler.providers.gcp.services.iam.iam_client import iam_client
|
||||
|
||||
# OIDC issuers whose tokens are minted for many independent tenants (any GitHub
|
||||
# repository, any GitLab project, any Google account, ...). A provider that
|
||||
# trusts one of these without an ``attributeCondition`` accepts identities
|
||||
# outside the operator's control, so omitting the condition genuinely expands
|
||||
# trust. A dedicated, single-tenant issuer only vends tokens to the operator's
|
||||
# own workloads, so an attribute condition there is defense-in-depth rather than
|
||||
# a requirement (see Google's guidance for GitHub and other shared issuers).
|
||||
MULTI_TENANT_OIDC_ISSUER_HOSTS = {
|
||||
"token.actions.githubusercontent.com", # GitHub Actions (any repository)
|
||||
"gitlab.com", # GitLab.com SaaS (any project)
|
||||
"accounts.google.com", # any Google account
|
||||
"app.terraform.io", # HCP Terraform (any organization)
|
||||
}
|
||||
|
||||
|
||||
def _is_multi_tenant_issuer(issuer_uri: str) -> bool:
|
||||
"""Return True when the OIDC issuer is a known multi-tenant/shared issuer."""
|
||||
if not issuer_uri:
|
||||
return False
|
||||
# hostname lowercases and strips port/userinfo (gitlab.com:443, user@host);
|
||||
# fall back to the raw string for bare hosts without a scheme.
|
||||
host = urlparse(issuer_uri).hostname or issuer_uri.lower()
|
||||
return host in MULTI_TENANT_OIDC_ISSUER_HOSTS
|
||||
|
||||
|
||||
class iam_workload_identity_pool_provider_attribute_condition(Check):
|
||||
"""Ensure WIF providers trusting a multi-tenant issuer enforce an attribute condition.
|
||||
|
||||
A workload identity pool provider that trusts a multi-tenant issuer (GitHub
|
||||
Actions, GitLab.com, ...) without an ``attributeCondition`` accepts every
|
||||
external identity that issuer can mint. An attacker controlling any tenant on
|
||||
that platform can then authenticate through the provider and exchange tokens
|
||||
for federated credentials, surviving credential rotation. Providers that
|
||||
enforce an attribute condition, that trust a dedicated single-tenant issuer,
|
||||
that are not OIDC-based, that are disabled/inactive, or whose parent pool is
|
||||
disabled are reported as PASS.
|
||||
"""
|
||||
|
||||
def execute(self) -> list[Check_Report_GCP]:
|
||||
"""Evaluate the attribute condition of each Workload Identity provider.
|
||||
|
||||
Returns:
|
||||
list[Check_Report_GCP]: One report per workload identity pool
|
||||
provider. FAIL for active providers that trust a multi-tenant issuer
|
||||
without an attribute condition; PASS for providers that enforce one,
|
||||
trust a dedicated issuer, are not OIDC-based, or are
|
||||
disabled/inactive.
|
||||
"""
|
||||
findings = []
|
||||
for provider in iam_client.workload_identity_pool_providers:
|
||||
report = Check_Report_GCP(
|
||||
metadata=self.metadata(),
|
||||
resource=provider,
|
||||
resource_id=provider.name,
|
||||
resource_name=provider.display_name or provider.id,
|
||||
location="global",
|
||||
)
|
||||
if provider.pool_disabled:
|
||||
report.status = "PASS"
|
||||
report.status_extended = (
|
||||
f"Workload Identity Federation provider {provider.id} belongs "
|
||||
f"to the disabled pool {provider.pool_id}, which cannot vend "
|
||||
"credentials."
|
||||
)
|
||||
elif provider.disabled or provider.state != "ACTIVE":
|
||||
report.status = "PASS"
|
||||
report.status_extended = (
|
||||
f"Workload Identity Federation provider {provider.id} in pool "
|
||||
f"{provider.pool_id} is not active and cannot vend credentials."
|
||||
)
|
||||
elif provider.attribute_condition:
|
||||
report.status = "PASS"
|
||||
report.status_extended = (
|
||||
f"Workload Identity Federation provider {provider.id} in pool "
|
||||
f"{provider.pool_id} enforces an attribute condition."
|
||||
)
|
||||
elif _is_multi_tenant_issuer(provider.issuer_uri):
|
||||
report.status = "FAIL"
|
||||
report.status_extended = (
|
||||
f"Workload Identity Federation provider {provider.id} in pool "
|
||||
f"{provider.pool_id} trusts the multi-tenant issuer "
|
||||
f"{provider.issuer_uri} without an attribute condition, so any "
|
||||
"identity from that issuer can authenticate through this provider."
|
||||
)
|
||||
elif provider.provider_type != "oidc":
|
||||
report.status = "PASS"
|
||||
report.status_extended = (
|
||||
f"Workload Identity Federation provider {provider.id} in pool "
|
||||
f"{provider.pool_id} is not an OIDC provider trusting a "
|
||||
"multi-tenant issuer; an attribute condition is recommended as "
|
||||
"defense-in-depth but not required."
|
||||
)
|
||||
else:
|
||||
report.status = "PASS"
|
||||
report.status_extended = (
|
||||
f"Workload Identity Federation provider {provider.id} in pool "
|
||||
f"{provider.pool_id} trusts a dedicated issuer; an attribute "
|
||||
"condition is recommended as defense-in-depth but not required."
|
||||
)
|
||||
findings.append(report)
|
||||
|
||||
return findings
|
||||
@@ -0,0 +1,36 @@
|
||||
{
|
||||
"Provider": "huaweicloud",
|
||||
"CheckID": "vpc_security_group_open_egress",
|
||||
"CheckTitle": "VPC security groups do not allow open egress to the internet",
|
||||
"CheckType": [],
|
||||
"ServiceName": "vpc",
|
||||
"SubServiceName": "",
|
||||
"ResourceIdTemplate": "",
|
||||
"Severity": "medium",
|
||||
"ResourceType": "HUAWEICLOUD::VPC::SecurityGroup",
|
||||
"ResourceGroup": "network",
|
||||
"Description": "Security groups should not allow unrestricted egress to all destinations. Huawei Cloud represents unrestricted destinations as 0.0.0.0/0, ::/0, or an empty remote IP prefix without a remote security group or address group. Open egress allows instances to reach any destination on the internet, increasing the risk of data exfiltration and command-and-control communication.",
|
||||
"Risk": "Unrestricted egress rules allow instances to communicate with any external destination. This increases the risk of data exfiltration, unauthorized outbound connections, and command-and-control channel establishment by compromised instances.",
|
||||
"RelatedUrl": "",
|
||||
"AdditionalURLs": [
|
||||
"https://support.huaweicloud.com/intl/en-us/usermanual-vpc/vpc_SecurityGroup_0001.html"
|
||||
],
|
||||
"Remediation": {
|
||||
"Code": {
|
||||
"CLI": "hcloud VPC UpdateSecurityGroupRule --security_group_rule_id <rule_id> --remote_ip_prefix <restricted_cidr>",
|
||||
"NativeIaC": "",
|
||||
"Other": "1. Log on to the Huawei Cloud console.\n2. Choose VPC.\n3. Click Security Groups.\n4. Select the security group.\n5. Edit each allow egress rule whose destination is 0.0.0.0/0, ::/0, or empty and unscoped.\n6. Restrict the destination to required IP ranges or an appropriate security group or address group.\n7. Click OK.",
|
||||
"Terraform": ""
|
||||
},
|
||||
"Recommendation": {
|
||||
"Text": "Restrict allow egress rules to required destination IP ranges, security groups, or address groups instead of allowing all IPv4 or IPv6 destinations.",
|
||||
"Url": "https://hub.prowler.com/check/vpc_security_group_open_egress"
|
||||
}
|
||||
},
|
||||
"Categories": [
|
||||
"trust-boundaries"
|
||||
],
|
||||
"DependsOn": [],
|
||||
"RelatedTo": [],
|
||||
"Notes": ""
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
from prowler.lib.check.models import Check, CheckReportHuaweiCloud
|
||||
from prowler.providers.huaweicloud.services.vpc.vpc_client import vpc_client
|
||||
|
||||
|
||||
class vpc_security_group_open_egress(Check):
|
||||
"""Check if VPC security groups allow unrestricted egress to the internet."""
|
||||
|
||||
def execute(self) -> list[CheckReportHuaweiCloud]:
|
||||
"""Execute the unrestricted egress check.
|
||||
|
||||
Returns:
|
||||
list[CheckReportHuaweiCloud]: Reports for the evaluated security groups.
|
||||
"""
|
||||
findings = []
|
||||
|
||||
for sg in vpc_client.security_groups.values():
|
||||
report = CheckReportHuaweiCloud(metadata=self.metadata(), resource=sg)
|
||||
report.region = sg.region
|
||||
report.resource_id = sg.id
|
||||
report.resource_arn = (
|
||||
f"huaweicloud:vpc:{sg.region}:"
|
||||
f"{vpc_client.audited_account}:security-group/{sg.id}"
|
||||
)
|
||||
|
||||
open_egress_destination = None
|
||||
for rule in sg.rules:
|
||||
if (
|
||||
rule.direction != "egress"
|
||||
or rule.action != "allow"
|
||||
or rule.remote_group_id
|
||||
or rule.remote_address_group_id
|
||||
):
|
||||
continue
|
||||
if rule.remote_ip_prefix in ("0.0.0.0/0", "::/0"):
|
||||
open_egress_destination = rule.remote_ip_prefix
|
||||
break
|
||||
if not rule.remote_ip_prefix:
|
||||
open_egress_destination = "all destinations"
|
||||
break
|
||||
|
||||
if open_egress_destination:
|
||||
report.status = "FAIL"
|
||||
report.status_extended = (
|
||||
f"Security group {sg.name} ({sg.id}) allows open egress "
|
||||
f"({open_egress_destination}) to the internet."
|
||||
)
|
||||
else:
|
||||
report.status = "PASS"
|
||||
report.status_extended = (
|
||||
f"Security group {sg.name} ({sg.id}) does not allow open egress "
|
||||
f"to the internet."
|
||||
)
|
||||
|
||||
findings.append(report)
|
||||
|
||||
return findings
|
||||
@@ -91,6 +91,8 @@ class VPC(HuaweiCloudService):
|
||||
id=getattr(rule_data, "id", None) or "",
|
||||
direction=getattr(rule_data, "direction", None)
|
||||
or "",
|
||||
action=getattr(rule_data, "action", None)
|
||||
or "allow",
|
||||
protocol=getattr(rule_data, "protocol", None)
|
||||
or "",
|
||||
ethertype=getattr(rule_data, "ethertype", None)
|
||||
@@ -109,6 +111,12 @@ class VPC(HuaweiCloudService):
|
||||
rule_data, "remote_group_id", None
|
||||
)
|
||||
or "",
|
||||
remote_address_group_id=getattr(
|
||||
rule_data,
|
||||
"remote_address_group_id",
|
||||
None,
|
||||
)
|
||||
or "",
|
||||
description=getattr(
|
||||
rule_data, "description", None
|
||||
)
|
||||
@@ -148,12 +156,14 @@ class SecurityGroupRule(HuaweiCloudBaseModel):
|
||||
|
||||
id: str
|
||||
direction: str
|
||||
action: str = "allow"
|
||||
protocol: str
|
||||
ethertype: str
|
||||
port_range_min: Optional[int] = None
|
||||
port_range_max: Optional[int] = None
|
||||
remote_ip_prefix: str = ""
|
||||
remote_group_id: str = ""
|
||||
remote_address_group_id: str = ""
|
||||
description: str = ""
|
||||
|
||||
|
||||
@@ -181,13 +191,16 @@ def rule_source_is_open(rule: SecurityGroupRule) -> bool:
|
||||
|
||||
Huawei Cloud represents "any source" in two ways: an explicit ``0.0.0.0/0``
|
||||
(or ``::/0``) in ``remote_ip_prefix``, or leaving both ``remote_ip_prefix``
|
||||
and ``remote_group_id`` empty. Rules that reference another security group
|
||||
via ``remote_group_id`` are NOT open even when ``remote_ip_prefix`` is
|
||||
empty.
|
||||
and both group identifiers empty. Rules that reference a security group or
|
||||
address group are NOT open even when ``remote_ip_prefix`` is empty.
|
||||
"""
|
||||
if rule.remote_ip_prefix in ("0.0.0.0/0", "::/0"):
|
||||
return True
|
||||
return not rule.remote_ip_prefix and not rule.remote_group_id
|
||||
return (
|
||||
not rule.remote_ip_prefix
|
||||
and not rule.remote_group_id
|
||||
and not rule.remote_address_group_id
|
||||
)
|
||||
|
||||
|
||||
def rule_covers_all_ports(rule: SecurityGroupRule) -> bool:
|
||||
|
||||