mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-08-20 18:42:57 +00:00
Compare commits
32
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
19c8a6bbb5 | ||
|
|
7585e8fda7 | ||
|
|
0489f10ed2 | ||
|
|
c5808fa343 | ||
|
|
b8f01d82b6 | ||
|
|
138d643119 | ||
|
|
c74eac1369 | ||
|
|
f9dbb0eee9 | ||
|
|
ab13d111c2 | ||
|
|
f0d2972969 | ||
|
|
90905dcc9f | ||
|
|
5cf49805a2 | ||
|
|
c610d9ac31 | ||
|
|
fb9d989be8 | ||
|
|
64c0cf900f | ||
|
|
0f39665ece | ||
|
|
162c6560d9 | ||
|
|
681be7537d | ||
|
|
94254555a4 | ||
|
|
2646068e7e | ||
|
|
caf27de6ee | ||
|
|
aab8154139 | ||
|
|
1de779c978 | ||
|
|
f4d6cd8609 | ||
|
|
b3d174d0c1 | ||
|
|
8ebb4a1ee7 | ||
|
|
abf660ce06 | ||
|
|
a19fd70001 | ||
|
|
9b6a239abe | ||
|
|
8c0fbf5073 | ||
|
|
ce77eb7f41 | ||
|
|
e15f6970ef |
@@ -158,7 +158,7 @@ SENTRY_RELEASE=local
|
||||
# REO_DEV_CLIENT_ID=
|
||||
|
||||
#### Prowler release version ####
|
||||
NEXT_PUBLIC_PROWLER_RELEASE_VERSION=v5.37.0
|
||||
NEXT_PUBLIC_PROWLER_RELEASE_VERSION=v5.38.0
|
||||
|
||||
# Social login credentials
|
||||
SOCIAL_GOOGLE_OAUTH_CALLBACK_URL="${AUTH_URL}/api/auth/callback/google"
|
||||
|
||||
@@ -18,6 +18,10 @@ inputs:
|
||||
description: 'Install Python dependencies with uv'
|
||||
required: false
|
||||
default: 'true'
|
||||
update-sdk-lock:
|
||||
description: 'Rewrite uv.lock to pin the latest prowler SDK commit on master'
|
||||
required: false
|
||||
default: 'true'
|
||||
|
||||
runs:
|
||||
using: 'composite'
|
||||
@@ -40,7 +44,7 @@ runs:
|
||||
fi
|
||||
|
||||
- name: Update uv.lock with latest Prowler commit
|
||||
if: github.repository_owner == 'prowler-cloud' && github.repository != 'prowler-cloud/prowler'
|
||||
if: inputs.update-sdk-lock == 'true' && github.repository_owner == 'prowler-cloud' && github.repository != 'prowler-cloud/prowler'
|
||||
shell: bash
|
||||
working-directory: ${{ inputs.working-directory }}
|
||||
env:
|
||||
@@ -60,7 +64,7 @@ runs:
|
||||
grep "prowler-cloud/prowler" uv.lock
|
||||
|
||||
- name: Update uv.lock SDK commit (prowler repo on push)
|
||||
if: github.event_name == 'push' && github.ref == 'refs/heads/master' && github.repository == 'prowler-cloud/prowler'
|
||||
if: inputs.update-sdk-lock == 'true' && github.event_name == 'push' && github.ref == 'refs/heads/master' && github.repository == 'prowler-cloud/prowler'
|
||||
shell: bash
|
||||
working-directory: ${{ inputs.working-directory }}
|
||||
env:
|
||||
|
||||
@@ -64,6 +64,9 @@ runs:
|
||||
scanners: 'vuln'
|
||||
timeout: '5m'
|
||||
version: 'v0.71.2'
|
||||
# Not trivyignores: that input drops the .yaml extension Trivy parses by.
|
||||
env:
|
||||
TRIVY_IGNOREFILE: '.trivyignore.yaml'
|
||||
|
||||
- name: Run Trivy vulnerability scan (SARIF)
|
||||
if: inputs.upload-sarif == 'true' && github.event_name == 'push'
|
||||
@@ -77,6 +80,9 @@ runs:
|
||||
scanners: 'vuln'
|
||||
timeout: '5m'
|
||||
version: 'v0.71.2'
|
||||
# Not trivyignores: that input drops the .yaml extension Trivy parses by.
|
||||
env:
|
||||
TRIVY_IGNOREFILE: '.trivyignore.yaml'
|
||||
|
||||
- name: Upload Trivy results to GitHub Security tab
|
||||
if: inputs.upload-sarif == 'true' && github.event_name == 'push'
|
||||
|
||||
@@ -42,6 +42,7 @@ jobs:
|
||||
timeout-minutes: 5
|
||||
outputs:
|
||||
short-sha: ${{ steps.set-short-sha.outputs.short-sha }}
|
||||
created: ${{ steps.set-short-sha.outputs.created }}
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
@@ -52,7 +53,9 @@ jobs:
|
||||
|
||||
- name: Calculate short SHA
|
||||
id: set-short-sha
|
||||
run: echo "short-sha=${GITHUB_SHA::7}" >> $GITHUB_OUTPUT
|
||||
run: |
|
||||
echo "short-sha=${GITHUB_SHA::7}" >> "${GITHUB_OUTPUT}"
|
||||
echo "created=$(date -u +%Y-%m-%dT%H:%M:%SZ)" >> "${GITHUB_OUTPUT}"
|
||||
|
||||
notify-release-started:
|
||||
if: github.repository == 'prowler-cloud/prowler' && (github.event_name == 'release' || github.event_name == 'workflow_dispatch')
|
||||
@@ -162,6 +165,14 @@ jobs:
|
||||
platforms: ${{ matrix.platform }}
|
||||
tags: |
|
||||
${{ env.PROWLERCLOUD_DOCKERHUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:${{ needs.setup.outputs.short-sha }}-${{ matrix.arch }}
|
||||
labels: |
|
||||
org.opencontainers.image.title=Prowler Local Server API
|
||||
org.opencontainers.image.description=API for Prowler Local Server (Django/DRF)
|
||||
org.opencontainers.image.vendor=ProwlerPro, Inc.
|
||||
org.opencontainers.image.source=https://github.com/${{ github.repository }}
|
||||
org.opencontainers.image.revision=${{ github.sha }}
|
||||
org.opencontainers.image.created=${{ needs.setup.outputs.created }}
|
||||
${{ (github.event_name == 'release' || github.event_name == 'workflow_dispatch') && format('org.opencontainers.image.version={0}', env.RELEASE_TAG) || '' }}
|
||||
cache-from: type=gha,scope=${{ matrix.arch }}
|
||||
cache-to: type=gha,mode=${{ github.event_name == 'pull_request' && 'min' || 'max' }},scope=${{ matrix.arch }}
|
||||
|
||||
@@ -179,12 +190,12 @@ jobs:
|
||||
with:
|
||||
egress-policy: block
|
||||
allowed-endpoints: >
|
||||
github.com:443
|
||||
release-assets.githubusercontent.com:443
|
||||
registry-1.docker.io:443
|
||||
auth.docker.io:443
|
||||
github.com:443
|
||||
production.cloudflare.docker.com:443
|
||||
production.cloudfront.docker.com:443
|
||||
registry-1.docker.io:443
|
||||
release-assets.githubusercontent.com:443
|
||||
- name: Login to DockerHub
|
||||
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
|
||||
with:
|
||||
|
||||
@@ -41,6 +41,7 @@ jobs:
|
||||
timeout-minutes: 5
|
||||
outputs:
|
||||
short-sha: ${{ steps.set-short-sha.outputs.short-sha }}
|
||||
created: ${{ steps.set-short-sha.outputs.created }}
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
@@ -51,7 +52,9 @@ jobs:
|
||||
|
||||
- name: Calculate short SHA
|
||||
id: set-short-sha
|
||||
run: echo "short-sha=${GITHUB_SHA::7}" >> $GITHUB_OUTPUT
|
||||
run: |
|
||||
echo "short-sha=${GITHUB_SHA::7}" >> "${GITHUB_OUTPUT}"
|
||||
echo "created=$(date -u +%Y-%m-%dT%H:%M:%SZ)" >> "${GITHUB_OUTPUT}"
|
||||
|
||||
notify-release-started:
|
||||
if: github.repository == 'prowler-cloud/prowler' && (github.event_name == 'release' || github.event_name == 'workflow_dispatch')
|
||||
@@ -110,15 +113,15 @@ jobs:
|
||||
with:
|
||||
egress-policy: block
|
||||
allowed-endpoints: >
|
||||
github.com:443
|
||||
registry-1.docker.io:443
|
||||
auth.docker.io:443
|
||||
files.pythonhosted.org:443
|
||||
ghcr.io:443
|
||||
github.com:443
|
||||
pkg-containers.githubusercontent.com:443
|
||||
production.cloudflare.docker.com:443
|
||||
production.cloudfront.docker.com:443
|
||||
ghcr.io:443
|
||||
pkg-containers.githubusercontent.com:443
|
||||
files.pythonhosted.org:443
|
||||
pypi.org:443
|
||||
registry-1.docker.io:443
|
||||
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
@@ -145,13 +148,13 @@ jobs:
|
||||
tags: |
|
||||
${{ env.PROWLERCLOUD_DOCKERHUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:${{ needs.setup.outputs.short-sha }}-${{ matrix.arch }}
|
||||
labels: |
|
||||
org.opencontainers.image.title=Prowler MCP Server
|
||||
org.opencontainers.image.title=Prowler MCP
|
||||
org.opencontainers.image.description=Model Context Protocol server for Prowler
|
||||
org.opencontainers.image.vendor=ProwlerPro, Inc.
|
||||
org.opencontainers.image.source=https://github.com/${{ github.repository }}
|
||||
org.opencontainers.image.revision=${{ github.sha }}
|
||||
org.opencontainers.image.created=${{ github.event_name == 'release' && github.event.release.published_at || github.event.head_commit.timestamp }}
|
||||
${{ github.event_name == 'release' && format('org.opencontainers.image.version={0}', env.RELEASE_TAG) || '' }}
|
||||
org.opencontainers.image.created=${{ needs.setup.outputs.created }}
|
||||
${{ (github.event_name == 'release' || github.event_name == 'workflow_dispatch') && format('org.opencontainers.image.version={0}', env.RELEASE_TAG) || '' }}
|
||||
cache-from: type=gha,scope=${{ matrix.arch }}
|
||||
cache-to: type=gha,mode=${{ github.event_name == 'pull_request' && 'min' || 'max' }},scope=${{ matrix.arch }}
|
||||
|
||||
@@ -169,11 +172,11 @@ jobs:
|
||||
with:
|
||||
egress-policy: block
|
||||
allowed-endpoints: >
|
||||
registry-1.docker.io:443
|
||||
auth.docker.io:443
|
||||
github.com:443
|
||||
production.cloudflare.docker.com:443
|
||||
production.cloudfront.docker.com:443
|
||||
github.com:443
|
||||
registry-1.docker.io:443
|
||||
release-assets.githubusercontent.com:443
|
||||
|
||||
- name: Login to DockerHub
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
name: 'MCP: Tests'
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- 'master'
|
||||
- 'v5.*'
|
||||
pull_request:
|
||||
branches:
|
||||
- 'master'
|
||||
- 'v5.*'
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
env:
|
||||
MCP_WORKING_DIR: ./mcp_server
|
||||
|
||||
permissions: {}
|
||||
|
||||
jobs:
|
||||
mcp-tests:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
permissions:
|
||||
contents: read
|
||||
strategy:
|
||||
matrix:
|
||||
# requires-python is >=3.12 while the shipped image is 3.13; testing both
|
||||
# is what keeps that floor honest.
|
||||
python-version:
|
||||
- '3.12'
|
||||
- '3.13'
|
||||
defaults:
|
||||
run:
|
||||
working-directory: ./mcp_server
|
||||
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
|
||||
with:
|
||||
egress-policy: block
|
||||
# hub.prowler.com and raw.githubusercontent.com are deliberately absent:
|
||||
# the suite mocks every outbound call, so a real one must fail the job.
|
||||
# The sentry.io entry is not the test suite: the Codecov uploader sends
|
||||
# its own telemetry there, so api-tests.yml and sdk-tests.yml allow it too.
|
||||
allowed-endpoints: >
|
||||
github.com:443
|
||||
pypi.org:443
|
||||
files.pythonhosted.org:443
|
||||
cli.codecov.io:443
|
||||
keybase.io:443
|
||||
ingest.codecov.io:443
|
||||
o26192.ingest.us.sentry.io:443
|
||||
storage.googleapis.com:443
|
||||
api.github.com:443
|
||||
|
||||
- name: Checkout repository
|
||||
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: Check for MCP server changes
|
||||
id: check-changes
|
||||
uses: tj-actions/changed-files@9426d40962ed5378910ee2e21d5f8c6fcbf2dd96 # v47.0.6
|
||||
with:
|
||||
files: |
|
||||
mcp_server/**
|
||||
.github/workflows/mcp-tests.yml
|
||||
codecov.yml
|
||||
files_ignore: |
|
||||
mcp_server/README.md
|
||||
mcp_server/CHANGELOG.md
|
||||
mcp_server/changelog.d/**
|
||||
mcp_server/AGENTS.md
|
||||
mcp_server/Dockerfile
|
||||
mcp_server/.dockerignore
|
||||
mcp_server/entrypoint.sh
|
||||
|
||||
- name: Setup Python with uv
|
||||
if: steps.check-changes.outputs.any_changed == 'true'
|
||||
uses: ./.github/actions/setup-python-uv
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
working-directory: ./mcp_server
|
||||
update-sdk-lock: 'false'
|
||||
|
||||
- name: Run tests with pytest
|
||||
if: steps.check-changes.outputs.any_changed == 'true'
|
||||
run: uv run pytest --cov=./prowler_mcp_server --cov-report=xml tests
|
||||
|
||||
- name: Upload coverage reports to Codecov
|
||||
if: steps.check-changes.outputs.any_changed == 'true'
|
||||
uses: codecov/codecov-action@671740ac38dd9b0130fbe1cec585b89eea48d3de # v5.5.2
|
||||
env:
|
||||
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
|
||||
with:
|
||||
flags: mcp
|
||||
@@ -54,6 +54,7 @@ jobs:
|
||||
timeout-minutes: 5
|
||||
outputs:
|
||||
prowler_version: ${{ steps.get-prowler-version.outputs.prowler_version }}
|
||||
created: ${{ steps.get-prowler-version.outputs.created }}
|
||||
latest_tag: ${{ steps.get-prowler-version.outputs.latest_tag }}
|
||||
stable_tag: ${{ steps.get-prowler-version.outputs.stable_tag }}
|
||||
permissions:
|
||||
@@ -64,9 +65,9 @@ jobs:
|
||||
with:
|
||||
egress-policy: block
|
||||
allowed-endpoints: >
|
||||
files.pythonhosted.org:443
|
||||
github.com:443
|
||||
pypi.org:443
|
||||
files.pythonhosted.org:443
|
||||
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
@@ -86,6 +87,7 @@ jobs:
|
||||
fi
|
||||
echo "latest_tag=latest" >> "${GITHUB_OUTPUT}"
|
||||
echo "stable_tag=stable" >> "${GITHUB_OUTPUT}"
|
||||
echo "created=$(date -u +%Y-%m-%dT%H:%M:%SZ)" >> "${GITHUB_OUTPUT}"
|
||||
|
||||
notify-release-started:
|
||||
if: github.repository == 'prowler-cloud/prowler' && (github.event_name == 'release' || github.event_name == 'workflow_dispatch')
|
||||
@@ -146,24 +148,24 @@ jobs:
|
||||
with:
|
||||
egress-policy: block
|
||||
allowed-endpoints: >
|
||||
_http._tcp.deb.debian.org:443
|
||||
aka.ms:443
|
||||
api.ecr-public.us-east-1.amazonaws.com:443
|
||||
public.ecr.aws:443
|
||||
sts.amazonaws.com:443
|
||||
sts.us-east-1.amazonaws.com:443
|
||||
registry-1.docker.io:443
|
||||
auth.docker.io:443
|
||||
cdn.powershellgallery.com:443
|
||||
debian.map.fastlydns.net:80
|
||||
files.pythonhosted.org:443
|
||||
github.com:443
|
||||
powershellinfraartifacts-gkhedzdeaghdezhr.z01.azurefd.net:443
|
||||
production.cloudflare.docker.com:443
|
||||
production.cloudfront.docker.com:443
|
||||
auth.docker.io:443
|
||||
debian.map.fastlydns.net:80
|
||||
github.com:443
|
||||
release-assets.githubusercontent.com:443
|
||||
public.ecr.aws:443
|
||||
pypi.org:443
|
||||
files.pythonhosted.org:443
|
||||
registry-1.docker.io:443
|
||||
release-assets.githubusercontent.com:443
|
||||
sts.amazonaws.com:443
|
||||
sts.us-east-1.amazonaws.com:443
|
||||
www.powershellgallery.com:443
|
||||
aka.ms:443
|
||||
cdn.powershellgallery.com:443
|
||||
_http._tcp.deb.debian.org:443
|
||||
powershellinfraartifacts-gkhedzdeaghdezhr.z01.azurefd.net:443
|
||||
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
@@ -201,6 +203,14 @@ jobs:
|
||||
platforms: ${{ matrix.platform }}
|
||||
tags: |
|
||||
${{ env.PROWLERCLOUD_DOCKERHUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:${{ needs.setup.outputs.latest_tag }}-${{ matrix.arch }}
|
||||
labels: |
|
||||
org.opencontainers.image.title=Prowler CLI
|
||||
org.opencontainers.image.description=Open Source security tool for cloud security assessments, audits, incident response, continuous monitoring, hardening and forensics readiness
|
||||
org.opencontainers.image.vendor=ProwlerPro, Inc.
|
||||
org.opencontainers.image.source=https://github.com/${{ github.repository }}
|
||||
org.opencontainers.image.revision=${{ github.sha }}
|
||||
org.opencontainers.image.created=${{ needs.setup.outputs.created }}
|
||||
org.opencontainers.image.version=${{ needs.setup.outputs.prowler_version }}
|
||||
cache-from: type=gha,scope=${{ matrix.arch }}
|
||||
cache-to: type=gha,mode=${{ github.event_name == 'pull_request' && 'min' || 'max' }},scope=${{ matrix.arch }}
|
||||
|
||||
@@ -219,14 +229,14 @@ jobs:
|
||||
with:
|
||||
egress-policy: block
|
||||
allowed-endpoints: >
|
||||
registry-1.docker.io:443
|
||||
api.ecr-public.us-east-1.amazonaws.com:443
|
||||
auth.docker.io:443
|
||||
public.ecr.aws:443
|
||||
github.com:443
|
||||
production.cloudflare.docker.com:443
|
||||
production.cloudfront.docker.com:443
|
||||
github.com:443
|
||||
public.ecr.aws:443
|
||||
registry-1.docker.io:443
|
||||
release-assets.githubusercontent.com:443
|
||||
api.ecr-public.us-east-1.amazonaws.com:443
|
||||
sts.amazonaws.com:443
|
||||
sts.us-east-1.amazonaws.com:443
|
||||
|
||||
|
||||
@@ -41,17 +41,20 @@ jobs:
|
||||
timeout-minutes: 5
|
||||
outputs:
|
||||
short-sha: ${{ steps.set-short-sha.outputs.short-sha }}
|
||||
created: ${{ steps.set-short-sha.outputs.created }}
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- name: Harden the runner (Audit all outbound calls)
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
egress-policy: block
|
||||
|
||||
- name: Calculate short SHA
|
||||
id: set-short-sha
|
||||
run: echo "short-sha=${GITHUB_SHA::7}" >> $GITHUB_OUTPUT
|
||||
run: |
|
||||
echo "short-sha=${GITHUB_SHA::7}" >> "${GITHUB_OUTPUT}"
|
||||
echo "created=$(date -u +%Y-%m-%dT%H:%M:%SZ)" >> "${GITHUB_OUTPUT}"
|
||||
|
||||
notify-release-started:
|
||||
if: github.repository == 'prowler-cloud/prowler' && (github.event_name == 'release' || github.event_name == 'workflow_dispatch')
|
||||
@@ -111,15 +114,15 @@ jobs:
|
||||
with:
|
||||
egress-policy: block
|
||||
allowed-endpoints: >
|
||||
registry-1.docker.io:443
|
||||
production.cloudflare.docker.com:443
|
||||
production.cloudfront.docker.com:443
|
||||
auth.docker.io:443
|
||||
registry.npmjs.org:443
|
||||
dl-cdn.alpinelinux.org:443
|
||||
fonts.googleapis.com:443
|
||||
fonts.gstatic.com:443
|
||||
github.com:443
|
||||
production.cloudflare.docker.com:443
|
||||
production.cloudfront.docker.com:443
|
||||
registry-1.docker.io:443
|
||||
registry.npmjs.org:443
|
||||
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
@@ -147,6 +150,14 @@ jobs:
|
||||
platforms: ${{ matrix.platform }}
|
||||
tags: |
|
||||
${{ env.PROWLERCLOUD_DOCKERHUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:${{ needs.setup.outputs.short-sha }}-${{ matrix.arch }}
|
||||
labels: |
|
||||
org.opencontainers.image.title=Prowler Local Server UI
|
||||
org.opencontainers.image.description=Web UI for Prowler Local Server (Next.js)
|
||||
org.opencontainers.image.vendor=ProwlerPro, Inc.
|
||||
org.opencontainers.image.source=https://github.com/${{ github.repository }}
|
||||
org.opencontainers.image.revision=${{ github.sha }}
|
||||
org.opencontainers.image.created=${{ needs.setup.outputs.created }}
|
||||
${{ (github.event_name == 'release' || github.event_name == 'workflow_dispatch') && format('org.opencontainers.image.version={0}', env.RELEASE_TAG) || '' }}
|
||||
cache-from: type=gha,scope=${{ matrix.arch }}
|
||||
cache-to: type=gha,mode=${{ github.event_name == 'pull_request' && 'min' || 'max' }},scope=${{ matrix.arch }}
|
||||
|
||||
@@ -164,12 +175,12 @@ jobs:
|
||||
with:
|
||||
egress-policy: block
|
||||
allowed-endpoints: >
|
||||
github.com:443
|
||||
release-assets.githubusercontent.com:443
|
||||
registry-1.docker.io:443
|
||||
auth.docker.io:443
|
||||
github.com:443
|
||||
production.cloudflare.docker.com:443
|
||||
production.cloudfront.docker.com:443
|
||||
registry-1.docker.io:443
|
||||
release-assets.githubusercontent.com:443
|
||||
|
||||
- name: Login to DockerHub
|
||||
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
|
||||
|
||||
-123
@@ -1,123 +0,0 @@
|
||||
# Trivy ignore file for prowlercloud/prowler SDK container image.
|
||||
# Each entry below documents (a) the affected package and why it ships in the
|
||||
# image, (b) why the CVE is not exploitable in Prowler's runtime, and (c) the
|
||||
# upstream fix status. Entries carry an expiry so they auto-force re-review.
|
||||
# Entries are scoped per-package so suppressions cannot drift onto unrelated
|
||||
# packages that may be assigned the same CVE in the future.
|
||||
#
|
||||
# Scanned by: .github/actions/trivy-scan via .github/workflows/sdk-container-checks.yml
|
||||
|
||||
# CVE-2026-42496 — perl-archive-tar path traversal via crafted symlinks.
|
||||
# CVE-2026-8376 — perl heap buffer overflow when compiling regex.
|
||||
# Packages: perl, perl-base, perl-modules-5.36, libperl5.36.
|
||||
# Why ignored: perl-base is part of Debian's "Essential: yes" set; it cannot be
|
||||
# removed without breaking dpkg. The Prowler SDK does not invoke perl at runtime;
|
||||
# neither vulnerable code path (Archive::Tar parsing or regex compilation of
|
||||
# attacker-controlled input) is reachable from Prowler. No Debian bookworm fix
|
||||
# is available yet.
|
||||
CVE-2026-42496 pkg:perl exp:2026-08-15
|
||||
CVE-2026-42496 pkg:perl-base exp:2026-08-15
|
||||
CVE-2026-42496 pkg:perl-modules-5.36 exp:2026-08-15
|
||||
CVE-2026-42496 pkg:libperl5.36 exp:2026-08-15
|
||||
CVE-2026-8376 pkg:perl exp:2026-08-15
|
||||
CVE-2026-8376 pkg:perl-base exp:2026-08-15
|
||||
CVE-2026-8376 pkg:perl-modules-5.36 exp:2026-08-15
|
||||
CVE-2026-8376 pkg:libperl5.36 exp:2026-08-15
|
||||
|
||||
# CVE-2026-13221 - Perl regex trie overflow.
|
||||
# Packages: perl, perl-base, perl-modules-5.36, libperl5.36.
|
||||
# Why ignored: upstream confirms Perl 5.36.0 is not affected; the regression
|
||||
# was introduced after this version. Debian currently marks bookworm as
|
||||
# vulnerable, which causes Trivy to report a false positive.
|
||||
# Ref: https://github.com/Perl/perl5/issues/23388
|
||||
CVE-2026-13221 pkg:perl exp:2026-08-15
|
||||
CVE-2026-13221 pkg:perl-base exp:2026-08-15
|
||||
CVE-2026-13221 pkg:perl-modules-5.36 exp:2026-08-15
|
||||
CVE-2026-13221 pkg:libperl5.36 exp:2026-08-15
|
||||
|
||||
# CVE-2026-57433 — Perl Storable signed integer overflow when deserializing a
|
||||
# crafted SX_HOOK record (retrieve_hook_common passes a wrapped negative count
|
||||
# to av_extend).
|
||||
# Packages: perl, perl-base, perl-modules-5.36, libperl5.36.
|
||||
# Why ignored: perl-base is part of Debian's "Essential: yes" set; it cannot be
|
||||
# removed without breaking dpkg. Prowler does not invoke perl at runtime and
|
||||
# never calls Storable's thaw/retrieve on attacker-controlled blobs, so the
|
||||
# vulnerable deserialization path is unreachable. Fixed upstream in
|
||||
# Storable 3.41; no Debian bookworm fix is available yet.
|
||||
CVE-2026-57433 pkg:perl exp:2026-08-15
|
||||
CVE-2026-57433 pkg:perl-base exp:2026-08-15
|
||||
CVE-2026-57433 pkg:perl-modules-5.36 exp:2026-08-15
|
||||
CVE-2026-57433 pkg:libperl5.36 exp:2026-08-15
|
||||
|
||||
# CVE-2025-7458 — SQLite integer overflow.
|
||||
# Package: libsqlite3-0.
|
||||
# Why ignored: transitive dependency of CPython's stdlib sqlite3 module. The
|
||||
# Prowler SDK does not open user-supplied SQLite databases; SQLite usage is
|
||||
# internal and bounded. No Debian bookworm fix is available.
|
||||
CVE-2025-7458 pkg:libsqlite3-0 exp:2026-08-15
|
||||
|
||||
# CVE-2026-43185 — Linux kernel ksmbd signedness bug.
|
||||
# Package: linux-libc-dev.
|
||||
# Why ignored: linux-libc-dev ships kernel headers for build-time compilation,
|
||||
# not a running kernel. Containers execute against the host kernel, so these
|
||||
# headers are inert at runtime. The upstream fix landed in kernel 7.0-rc2 and
|
||||
# has not been backported to Debian's 6.1 LTS line.
|
||||
CVE-2026-43185 pkg:linux-libc-dev exp:2026-08-15
|
||||
|
||||
# CVE-2023-45853 — zlib MiniZip integer overflow / heap overflow in
|
||||
# zipOpenNewFileInZip4_64.
|
||||
# Packages: zlib1g, zlib1g-dev.
|
||||
# Why ignored: Debian Security Tracker status for bookworm is <ignored>, with
|
||||
# the published rationale "contrib/minizip not built and src:zlib not producing
|
||||
# binary packages" — i.e. the vulnerable symbol is not present in the libz.so
|
||||
# shipped by Debian. Real-not-affected, not unpatched. Upstream fix is in
|
||||
# zlib 1.3.1, available in Debian trixie (13); migrating the base image would
|
||||
# clear it fully.
|
||||
# Ref: https://security-tracker.debian.org/tracker/CVE-2023-45853
|
||||
CVE-2023-45853 pkg:zlib1g exp:2026-08-15
|
||||
CVE-2023-45853 pkg:zlib1g-dev exp:2026-08-15
|
||||
|
||||
# CVE-2026-55200 — libssh2 out-of-bounds write in ssh2_transport_read() due to
|
||||
# an unchecked packet_length field in transport.c (heap corruption, possible RCE).
|
||||
# Package: libssh2-1.
|
||||
# Why ignored: libssh2-1 is pulled in only as a transitive dependency of libcurl4
|
||||
# (installed in the SDK Dockerfile for the networking/PowerShell stack). The
|
||||
# vulnerable path is reached exclusively when libssh2 acts as an SSH/SCP/SFTP
|
||||
# client parsing transport packets from a server. Prowler never uses libcurl's
|
||||
# SSH/SCP/SFTP transports; it talks to cloud provider HTTPS endpoints only, so the
|
||||
# affected code is unreachable at runtime. Fixed upstream in libssh2 commit
|
||||
# 97acf3df (PR #2052); no Debian bookworm fix is available yet.
|
||||
# Ref: https://security-tracker.debian.org/tracker/CVE-2026-55200
|
||||
CVE-2026-55200 pkg:libssh2-1 exp:2026-08-15
|
||||
|
||||
# --- API container image (api/Dockerfile) ---
|
||||
# The entries below are specific to the Prowler API image, which ships
|
||||
# PowerShell and additional build tooling on top of the same bookworm base.
|
||||
|
||||
# CVE-2026-7210 — CPython/Expat hash-flooding denial of service in
|
||||
# `xml.parsers.expat` and `xml.etree.ElementTree`.
|
||||
# Packages: the Debian system Python 3.11 (python3.11*, libpython3.11*).
|
||||
# Why ignored: the API runs under the Python 3.12 interpreter shipped in its
|
||||
# `.venv`; the system `python3.11` is only present because `python3-dev` is
|
||||
# pulled in to compile native extensions (xmlsec, lxml) and is never executed
|
||||
# at runtime. The vulnerable path requires parsing attacker-controlled XML with
|
||||
# the affected interpreter, which Prowler does not do with the system Python.
|
||||
# Full mitigation also needs libexpat >= 2.8.0; no Debian bookworm fix yet.
|
||||
CVE-2026-7210 pkg:python3.11 exp:2026-08-15
|
||||
CVE-2026-7210 pkg:python3.11-dev exp:2026-08-15
|
||||
CVE-2026-7210 pkg:python3.11-minimal exp:2026-08-15
|
||||
CVE-2026-7210 pkg:libpython3.11 exp:2026-08-15
|
||||
CVE-2026-7210 pkg:libpython3.11-dev exp:2026-08-15
|
||||
CVE-2026-7210 pkg:libpython3.11-minimal exp:2026-08-15
|
||||
CVE-2026-7210 pkg:libpython3.11-stdlib exp:2026-08-15
|
||||
|
||||
# CVE-2026-33278 — Unbound DNSSEC validator use-after-free (DoS, possible RCE).
|
||||
# CVE-2026-42960 — Unbound DNS cache poisoning via promiscuous additional records.
|
||||
# Package: libunbound8.
|
||||
# Why ignored: libunbound8 is a transitive apt dependency of the TLS/networking
|
||||
# stack (GnuTLS DANE support); only the shared library ships in the image. Both
|
||||
# vulnerabilities require operating a live Unbound recursive DNSSEC validator
|
||||
# that processes attacker-influenced DNS responses. Prowler never starts an
|
||||
# Unbound resolver, so neither code path is reachable. No Debian bookworm fix yet.
|
||||
CVE-2026-33278 pkg:libunbound8 exp:2026-08-15
|
||||
CVE-2026-42960 pkg:libunbound8 exp:2026-08-15
|
||||
@@ -0,0 +1,63 @@
|
||||
# Trivy suppressions for the prowlercloud/prowler SDK and API container images.
|
||||
#
|
||||
# This file replaces the classic .trivyignore, which parsed only the CVE id: the
|
||||
# `pkg:` selector written on each line was documentation and the entry suppressed
|
||||
# its CVE across every package in the image. The `purls` field below is honoured,
|
||||
# so each entry is scoped to the package it names. Verified against Trivy 0.71.2:
|
||||
# an entry given the wrong purl leaves the finding reported, where the classic
|
||||
# format suppressed it.
|
||||
#
|
||||
# `expired_at` forces re-review. Keep the dates staggered.
|
||||
#
|
||||
# perl-base is Debian "Essential: yes". Trivy spreads src:perl CVEs across every
|
||||
# binary package built from that source, so perl-base is flagged for modules only
|
||||
# perl-modules-* ships. Neither image installs those, and nothing in either
|
||||
# invokes perl.
|
||||
#
|
||||
# Why these four are accepted rather than fixed (reviewed 2026-07-31):
|
||||
#
|
||||
# 1. No fix exists. All four report no fixed version on perl-base 5.40.1-6.
|
||||
# Debian marks CVE-2026-42496 "fix_deferred" and the other three "affected".
|
||||
# A newer base image, apt upgrade, or a newer Debian release changes nothing.
|
||||
# 2. The package cannot be removed. "Essential: yes" means removal needs
|
||||
# dpkg --force-remove-essential, which breaks apt for anything built
|
||||
# downstream from these images.
|
||||
# 3. Changing base distribution was evaluated and rejected. Alpine drops perl
|
||||
# entirely, but PowerShell publishes no linux-musl-arm64 build in any
|
||||
# release, so M365 scanning would break on arm64 -- which is what we run in
|
||||
# production. Wolfi keeps glibc and drops perl, but pinnable versioned tags
|
||||
# are a paid tier, so builds would not be reproducibly pinnable.
|
||||
#
|
||||
# Not-invoked claim verified by sweeping both images for files with a perl
|
||||
# shebang, shell/python callers of perl, ELF binaries containing "perl", and
|
||||
# .pl/.pm files or perl subprocess calls anywhere in site-packages. The only
|
||||
# consumers found are dpkg/debconf/adduser/pam tooling, none of which runs at
|
||||
# runtime, plus one build-time script inside the ExchangeOnlineManagement
|
||||
# PowerShell module that is never invoked.
|
||||
|
||||
vulnerabilities:
|
||||
# Archive::Tar path traversal. Not installed: `perl -MArchive::Tar -e1` cannot locate it.
|
||||
- id: CVE-2026-42496
|
||||
purls:
|
||||
- "pkg:deb/debian/perl-base"
|
||||
expired_at: 2027-01-31
|
||||
|
||||
# Storable integer overflow. Not installed: `perl -MStorable -e1` cannot locate it.
|
||||
- id: CVE-2026-57433
|
||||
purls:
|
||||
- "pkg:deb/debian/perl-base"
|
||||
expired_at: 2027-01-31
|
||||
|
||||
# Regex heap overflow on 32-bit builds only; both published arches are 64-bit.
|
||||
- id: CVE-2026-8376
|
||||
purls:
|
||||
- "pkg:deb/debian/perl-base"
|
||||
expired_at: 2027-01-31
|
||||
|
||||
# Regex trie bug giving silently wrong matches above 65535 alternation branches.
|
||||
# perl 5.40.1 is in range, so this rests on nothing invoking perl. Short expiry
|
||||
# to force a re-look. Ref: https://github.com/Perl/perl5/issues/23388
|
||||
- id: CVE-2026-13221
|
||||
purls:
|
||||
- "pkg:deb/debian/perl-base"
|
||||
expired_at: 2026-11-30
|
||||
+18
-5
@@ -1,14 +1,14 @@
|
||||
FROM python:3.12.13-slim-bookworm@sha256:8a7e7cc04fd3e2bd787f7f24e22d5d119aa590d429b50c95dfe12b3abe52f48b AS build
|
||||
FROM python:3.12.13-slim-trixie@sha256:57cd7c3a7a273101a6485ba99423ee568157882804b1124b4dd04266317710de AS build
|
||||
|
||||
LABEL maintainer="https://github.com/prowler-cloud/prowler"
|
||||
LABEL org.opencontainers.image.source="https://github.com/prowler-cloud/prowler"
|
||||
|
||||
ARG POWERSHELL_VERSION=7.5.0
|
||||
ARG POWERSHELL_VERSION=7.5.9
|
||||
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.71.2
|
||||
ARG TRIVY_VERSION=0.72.0
|
||||
ENV TRIVY_VERSION=${TRIVY_VERSION}
|
||||
|
||||
ARG ZIZMOR_VERSION=1.24.1
|
||||
@@ -16,7 +16,7 @@ ENV ZIZMOR_VERSION=${ZIZMOR_VERSION}
|
||||
|
||||
# hadolint ignore=DL3008
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
wget libicu72 libunwind8 libssl3 libcurl4 ca-certificates apt-transport-https gnupg \
|
||||
wget libicu76 libunwind8 libssl3 libcurl4 ca-certificates apt-transport-https gnupg \
|
||||
build-essential pkg-config libzstd-dev zlib1g-dev \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
@@ -89,7 +89,7 @@ ENV HOME='/home/prowler'
|
||||
ENV PATH="${HOME}/.local/bin:${PATH}"
|
||||
#hadolint ignore=DL3013
|
||||
RUN pip install --no-cache-dir --upgrade pip && \
|
||||
pip install --no-cache-dir uv==0.11.14
|
||||
pip install --no-cache-dir uv==0.12.0
|
||||
|
||||
RUN uv sync --locked --compile-bytecode && \
|
||||
rm -rf ~/.cache/uv
|
||||
@@ -105,6 +105,9 @@ RUN apt-get purge -y --auto-remove \
|
||||
pkg-config \
|
||||
libzstd-dev \
|
||||
zlib1g-dev \
|
||||
wget \
|
||||
gnupg \
|
||||
apt-transport-https \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
USER prowler
|
||||
@@ -113,5 +116,15 @@ USER prowler
|
||||
RUN pip uninstall dash-html-components -y && \
|
||||
pip uninstall dash-core-components -y
|
||||
|
||||
USER root
|
||||
|
||||
# pip is build-only; the entrypoint runs the venv directly.
|
||||
RUN rm -rf /usr/local/lib/python3.12/site-packages/pip \
|
||||
/usr/local/lib/python3.12/site-packages/pip-*.dist-info \
|
||||
/home/prowler/.local/lib/python3.12/site-packages/pip \
|
||||
/home/prowler/.local/lib/python3.12/site-packages/pip-*.dist-info \
|
||||
/usr/local/bin/pip /usr/local/bin/pip3 /usr/local/bin/pip3.12 \
|
||||
/home/prowler/.local/bin/pip /home/prowler/.local/bin/pip3 /home/prowler/.local/bin/pip3.12
|
||||
|
||||
USER prowler
|
||||
ENTRYPOINT ["/home/prowler/.venv/bin/prowler"]
|
||||
|
||||
@@ -34,6 +34,9 @@ test: ## Test with pytest
|
||||
rm -rf .coverage && \
|
||||
pytest -n auto -vvv -s --cov=./prowler --cov-report=xml tests
|
||||
|
||||
test-mcp: ## Test MCP server with pytest (mirrors CI)
|
||||
cd mcp_server && uv run pytest --cov=./prowler_mcp_server --cov-report=term-missing tests
|
||||
|
||||
coverage: ## Show Test Coverage
|
||||
coverage run --skip-covered -m pytest -v && \
|
||||
coverage report -m && \
|
||||
|
||||
@@ -4,6 +4,24 @@ All notable changes to the **Prowler API** are documented in this file.
|
||||
|
||||
<!-- changelog: release notes start -->
|
||||
|
||||
## [1.38.1] (Prowler v5.37.1)
|
||||
|
||||
### 🐞 Fixed
|
||||
|
||||
- Entra Conditional Access guest-user checks no longer report false FAILs in M365 scans: microsoft-kiota packages overridden to 1.9.10 so `guestOrExternalUserTypes` (a flags enum Graph serializes as a comma-separated string) deserializes correctly instead of returning an empty list [(#12315)](https://github.com/prowler-cloud/prowler/pull/12315)
|
||||
|
||||
### 🔐 Security
|
||||
|
||||
- The API container image now builds on Debian 13 (trixie), taking its critical CVE count from 18 to 4 [(#12311)](https://github.com/prowler-cloud/prowler/pull/12311)
|
||||
- Bumped PowerShell, Trivy and uv in the API container image, clearing 14 high-severity CVEs [(#12311)](https://github.com/prowler-cloud/prowler/pull/12311)
|
||||
- Bumped `workos` and `pyopenssl` so the API can move to `cryptography` 48.0.1 [(#12311)](https://github.com/prowler-cloud/prowler/pull/12311)
|
||||
- Removed `gnupg` and `apt-transport-https` from the API container image [(#12311)](https://github.com/prowler-cloud/prowler/pull/12311)
|
||||
- The API container image no longer ships `git`; removing it also dropped `perl`, `perl-modules`, `libperl` and `liberror-perl`, clearing 12 critical CVEs. Only `perl-base` remains, which Debian marks Essential and cannot be removed [(#12311)](https://github.com/prowler-cloud/prowler/pull/12311)
|
||||
- Removed `pip` from the API container image, clearing two high-severity CVEs in the vendored copies of `setuptools` and `msgpack` [(#12311)](https://github.com/prowler-cloud/prowler/pull/12311)
|
||||
- Bumped `pillow` to 12.3.0, `httplib2` to 0.32.0 and `pyasn1` to 0.6.4 to resolve known CVEs [(#12311)](https://github.com/prowler-cloud/prowler/pull/12311)
|
||||
|
||||
---
|
||||
|
||||
## [1.38.0] (Prowler v5.37.0)
|
||||
|
||||
### 🚀 Added
|
||||
|
||||
+20
-6
@@ -1,13 +1,13 @@
|
||||
FROM python:3.12.13-slim-bookworm@sha256:8a7e7cc04fd3e2bd787f7f24e22d5d119aa590d429b50c95dfe12b3abe52f48b AS build
|
||||
FROM python:3.12.13-slim-trixie@sha256:57cd7c3a7a273101a6485ba99423ee568157882804b1124b4dd04266317710de AS build
|
||||
|
||||
LABEL maintainer="https://github.com/prowler-cloud/api"
|
||||
|
||||
ARG POWERSHELL_VERSION=7.5.0
|
||||
ARG POWERSHELL_VERSION=7.5.9
|
||||
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.71.2
|
||||
ARG TRIVY_VERSION=0.72.0
|
||||
ENV TRIVY_VERSION=${TRIVY_VERSION}
|
||||
|
||||
ARG ZIZMOR_VERSION=1.24.1
|
||||
@@ -17,7 +17,7 @@ ENV ZIZMOR_VERSION=${ZIZMOR_VERSION}
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
wget \
|
||||
git \
|
||||
libicu72 \
|
||||
libicu76 \
|
||||
gcc \
|
||||
g++ \
|
||||
make \
|
||||
@@ -28,7 +28,6 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
libtool \
|
||||
libxslt1-dev \
|
||||
python3-dev \
|
||||
git \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Install PowerShell
|
||||
@@ -94,7 +93,7 @@ RUN mkdir -p /tmp/prowler_api_output
|
||||
COPY --chown=prowler:prowler pyproject.toml uv.lock ./
|
||||
|
||||
RUN pip install --no-cache-dir --upgrade pip && \
|
||||
pip install --no-cache-dir uv==0.11.14
|
||||
pip install --no-cache-dir uv==0.12.0
|
||||
|
||||
ENV PATH="/home/prowler/.local/bin:$PATH"
|
||||
|
||||
@@ -109,19 +108,34 @@ RUN .venv/bin/python -m prowler.providers.m365.lib.powershell.m365_powershell
|
||||
USER root
|
||||
|
||||
# Remove build-only packages from the final image after Python dependencies are installed.
|
||||
# git is only needed by uv sync for the `prowler @ git+...` dependency; purging it drops perl too.
|
||||
# wget stays: the compose healthcheck shells out to it.
|
||||
RUN apt-get purge -y --auto-remove \
|
||||
gcc \
|
||||
g++ \
|
||||
git \
|
||||
make \
|
||||
libxml2-dev \
|
||||
libxmlsec1-dev \
|
||||
libxmlsec1-openssl \
|
||||
libxmlsec1t64 \
|
||||
libxmlsec1t64-openssl \
|
||||
pkg-config \
|
||||
libtool \
|
||||
libxslt1-dev \
|
||||
python3-dev \
|
||||
gnupg \
|
||||
apt-transport-https \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# pip is build-only; the entrypoint runs uv against the prepared venv. uv stays.
|
||||
RUN rm -rf /usr/local/lib/python3.12/site-packages/pip \
|
||||
/usr/local/lib/python3.12/site-packages/pip-*.dist-info \
|
||||
/home/prowler/.local/lib/python3.12/site-packages/pip \
|
||||
/home/prowler/.local/lib/python3.12/site-packages/pip-*.dist-info \
|
||||
/usr/local/bin/pip /usr/local/bin/pip3 /usr/local/bin/pip3.12 \
|
||||
/home/prowler/.local/bin/pip /home/prowler/.local/bin/pip3 /home/prowler/.local/bin/pip3.12
|
||||
|
||||
USER prowler
|
||||
|
||||
COPY --chown=prowler:prowler src/backend/ ./backend/
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
Restored the SDK dependency to `@master` now that the dependency bumps have landed there, and regenerated the lock. The API image no longer builds against a temporary integration branch
|
||||
@@ -0,0 +1 @@
|
||||
Attack Paths adds 20 AWS privilege-escalation detection queries from pathfinding.cloud, covering service PassRole escalations (Batch, Braket, Cognito Identity, ECS, EMR, EMR Serverless, GameLift, Glue, EC2 Image Builder, Kinesis Analytics, HealthOmics, EventBridge Scheduler, SSM, Step Functions), CodeDeploy and Step Functions existing-resource abuse, role permissions-boundary removal with role assumption, and IAM Identity Center permission-set policy injection
|
||||
+36
-25
@@ -45,7 +45,7 @@ dependencies = [
|
||||
"gunicorn==26.0.0",
|
||||
"uvloop==0.22.1",
|
||||
"lxml==6.1.0",
|
||||
"prowler @ git+https://github.com/prowler-cloud/prowler.git@v5.37",
|
||||
"prowler @ git+https://github.com/prowler-cloud/prowler.git@master",
|
||||
"psycopg2-binary==2.9.9",
|
||||
"pytest-celery[redis] (==1.3.0)",
|
||||
"sentry-sdk[django] (==2.56.0)",
|
||||
@@ -63,7 +63,7 @@ dependencies = [
|
||||
"werkzeug (==3.1.7)",
|
||||
"sqlparse (==0.5.5)",
|
||||
"fonttools (==4.62.1)",
|
||||
"uvicorn-worker (==0.4.0)",
|
||||
"uvicorn-worker (==0.4.0)"
|
||||
]
|
||||
description = "Prowler's API (Django/DRF)"
|
||||
license = "Apache-2.0"
|
||||
@@ -71,7 +71,7 @@ name = "prowler-api"
|
||||
package-mode = false
|
||||
# Needed for the SDK compatibility
|
||||
requires-python = ">=3.11,<3.13"
|
||||
version = "1.38.0"
|
||||
version = "1.39.0"
|
||||
|
||||
# Shared ruff baseline (kept in sync with mcp_server/pyproject.toml).
|
||||
# target-version tracks this project's lowest supported Python.
|
||||
@@ -92,6 +92,8 @@ 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).
|
||||
constraint-dependencies = [
|
||||
"about-time==4.2.1",
|
||||
"adal==1.2.7",
|
||||
@@ -128,7 +130,7 @@ constraint-dependencies = [
|
||||
"alibabacloud-sls20201230==5.9.0",
|
||||
"alibabacloud-sts20150401==1.1.6",
|
||||
"alibabacloud-tea==0.4.3",
|
||||
"alibabacloud-tea-openapi==0.4.4",
|
||||
"alibabacloud-tea-openapi==0.4.5",
|
||||
"alibabacloud-tea-util==0.3.14",
|
||||
"alibabacloud-tea-xml==0.0.3",
|
||||
"alibabacloud-vpc20160428==6.13.0",
|
||||
@@ -210,9 +212,9 @@ constraint-dependencies = [
|
||||
"coverage==7.5.4",
|
||||
"cron-descriptor==1.4.5",
|
||||
"crowdstrike-falconpy==1.6.0",
|
||||
"cryptography==46.0.7",
|
||||
"cryptography==48.0.1",
|
||||
"cycler==0.12.1",
|
||||
"darabonba-core==1.0.5",
|
||||
"darabonba-core==1.0.8",
|
||||
"dash==3.1.1",
|
||||
"dash-bootstrap-components==2.0.3",
|
||||
"debugpy==1.8.20",
|
||||
@@ -277,7 +279,7 @@ constraint-dependencies = [
|
||||
"h2==4.3.0",
|
||||
"hpack==4.1.0",
|
||||
"httpcore==1.0.9",
|
||||
"httplib2==0.31.2",
|
||||
"httplib2==0.32.0",
|
||||
"httpx==0.28.1",
|
||||
"humanfriendly==10.0",
|
||||
"hyperframe==6.1.0",
|
||||
@@ -314,13 +316,13 @@ constraint-dependencies = [
|
||||
"matplotlib==3.10.8",
|
||||
"mccabe==0.7.0",
|
||||
"mdurl==0.1.2",
|
||||
"microsoft-kiota-abstractions==1.9.9",
|
||||
"microsoft-kiota-authentication-azure==1.9.9",
|
||||
"microsoft-kiota-http==1.9.9",
|
||||
"microsoft-kiota-serialization-form==1.9.9",
|
||||
"microsoft-kiota-serialization-json==1.9.9",
|
||||
"microsoft-kiota-serialization-multipart==1.9.9",
|
||||
"microsoft-kiota-serialization-text==1.9.9",
|
||||
"microsoft-kiota-abstractions==1.9.10",
|
||||
"microsoft-kiota-authentication-azure==1.9.10",
|
||||
"microsoft-kiota-http==1.9.10",
|
||||
"microsoft-kiota-serialization-form==1.9.10",
|
||||
"microsoft-kiota-serialization-json==1.9.10",
|
||||
"microsoft-kiota-serialization-multipart==1.9.10",
|
||||
"microsoft-kiota-serialization-text==1.9.10",
|
||||
"microsoft-security-utilities-secret-masker==1.0.0b4",
|
||||
"msal==1.35.0b1",
|
||||
"msal-extensions==1.2.0",
|
||||
@@ -337,7 +339,7 @@ constraint-dependencies = [
|
||||
"nltk==3.9.4",
|
||||
"numpy==2.2.6",
|
||||
"oauthlib==3.3.1",
|
||||
"oci==2.169.0",
|
||||
"oci==2.183.0",
|
||||
"openai==1.109.1",
|
||||
"openstacksdk==4.2.0",
|
||||
"opentelemetry-api==1.39.1",
|
||||
@@ -349,7 +351,7 @@ constraint-dependencies = [
|
||||
"pagerduty==6.1.0",
|
||||
"pandas==2.2.3",
|
||||
"pbr==7.0.3",
|
||||
"pillow==12.2.0",
|
||||
"pillow==12.3.0",
|
||||
"pkginfo==1.12.1.2",
|
||||
"platformdirs==4.5.1",
|
||||
"plotly==6.5.2",
|
||||
@@ -365,8 +367,8 @@ constraint-dependencies = [
|
||||
"psycopg2-binary==2.9.9",
|
||||
"py-deviceid==0.1.1",
|
||||
"py-iam-expand==0.3.0",
|
||||
"py-ocsf-models==0.8.1",
|
||||
"pyasn1==0.6.3",
|
||||
"py-ocsf-models==0.10.0",
|
||||
"pyasn1==0.6.4",
|
||||
"pyasn1-modules==0.4.2",
|
||||
"pycodestyle==2.14.0",
|
||||
"pycparser==3.0",
|
||||
@@ -378,7 +380,7 @@ constraint-dependencies = [
|
||||
"pylint==3.2.5",
|
||||
"pymsalruntime==0.18.1",
|
||||
"pynacl==1.6.2",
|
||||
"pyopenssl==26.0.0",
|
||||
"pyopenssl==26.2.0",
|
||||
"pyparsing==3.3.2",
|
||||
"pyreadline3==3.5.4",
|
||||
"pysocks==1.7.1",
|
||||
@@ -447,7 +449,7 @@ constraint-dependencies = [
|
||||
"wcwidth==0.5.3",
|
||||
"websocket-client==1.9.0",
|
||||
"werkzeug==3.1.7",
|
||||
"workos==6.0.8",
|
||||
"workos==8.3.0",
|
||||
"wrapt==1.17.3",
|
||||
"xlsxwriter==3.2.9",
|
||||
"xmlsec==1.3.17",
|
||||
@@ -466,10 +468,13 @@ constraint-dependencies = [
|
||||
# 0.138.1 requires azure-mgmt-containerservice>=41.0.0. Attack Paths does not
|
||||
# ingest Azure today, so override the Cartography dependency to the Prowler pin.
|
||||
#
|
||||
# prowler@master hard-pins microsoft-kiota-abstractions==1.9.2 in [project.dependencies].
|
||||
# The microsoft-kiota-http security bump to 1.9.9 (GHSA-7j59-v9qr-6fq9) requires
|
||||
# microsoft-kiota-abstractions>=1.9.9, which a constraint cannot satisfy against the
|
||||
# SDK's hard pin; override it to the patched, kiota-aligned version.
|
||||
# prowler@master hard-pins the microsoft-kiota packages in [project.dependencies].
|
||||
# microsoft-kiota-serialization-json 1.9.10 fixes get_collection_of_enum_values
|
||||
# returning [] for flags enums serialized as CSV strings (microsoft/kiota-python#515),
|
||||
# which broke the Entra Conditional Access guest-user checks; the kiota packages
|
||||
# release in lockstep and 1.9.10 requires microsoft-kiota-abstractions>=1.9.10, which
|
||||
# a constraint cannot satisfy against the SDK's hard pins, so override the whole set
|
||||
# to 1.9.10 until the SDK bump propagates to the pinned master rev.
|
||||
#
|
||||
# prowler@master hard-pins dulwich==0.23.0 and pyjwt==2.12.1 in [project.dependencies].
|
||||
# dulwich 1.2.5 patches GHSA-897w-fcg9-f6xj (arbitrary file write) and pyjwt 2.13.0
|
||||
@@ -481,7 +486,13 @@ constraint-dependencies = [
|
||||
override-dependencies = [
|
||||
"okta==3.4.2",
|
||||
"azure-mgmt-containerservice==34.1.0",
|
||||
"microsoft-kiota-abstractions==1.9.9",
|
||||
"microsoft-kiota-abstractions==1.9.10",
|
||||
"microsoft-kiota-authentication-azure==1.9.10",
|
||||
"microsoft-kiota-http==1.9.10",
|
||||
"microsoft-kiota-serialization-form==1.9.10",
|
||||
"microsoft-kiota-serialization-json==1.9.10",
|
||||
"microsoft-kiota-serialization-multipart==1.9.10",
|
||||
"microsoft-kiota-serialization-text==1.9.10",
|
||||
"dulwich==1.2.5",
|
||||
"pyjwt[crypto]==2.13.0"
|
||||
]
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,7 @@
|
||||
openapi: 3.0.3
|
||||
info:
|
||||
title: Prowler API
|
||||
version: 1.38.0
|
||||
version: 1.39.0
|
||||
description: |-
|
||||
Prowler API specification.
|
||||
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
"""
|
||||
Structural validation for the pathfinding.cloud service privilege-escalation
|
||||
Attack Paths queries added in PROWLER-2279.
|
||||
|
||||
These assert the conventions documented in
|
||||
`docs/developer-guide/attack-paths-queries.mdx`: list-typed policy properties are
|
||||
reached through `HAS_*` child-item traversals (never read as node fields),
|
||||
predicate functions unsupported on Neptune (`any`/`all`/`none`, regex `=~`) are
|
||||
absent, the finding probe is typed and filters only on `status`, and the
|
||||
`RETURN` shape preserves the `paths, dpf, dpfr` contract.
|
||||
"""
|
||||
|
||||
import re
|
||||
|
||||
import pytest
|
||||
from api.attack_paths.queries.aws import AWS_QUERIES
|
||||
from api.attack_paths.queries.types import AttackPathsQueryDefinition
|
||||
|
||||
# IDs of the queries introduced for PROWLER-2279 (pathfinding.cloud coverage).
|
||||
PATHFINDING_2279_QUERY_IDS = [
|
||||
"aws-batch-privesc-passrole-submit-job",
|
||||
"aws-braket-privesc-passrole-create-job",
|
||||
"aws-cognito-privesc-passrole-set-identity-pool-roles",
|
||||
"aws-ecs-privesc-passrole-start-existing-task",
|
||||
"aws-emr-privesc-passrole-run-job-flow",
|
||||
"aws-emrserverless-privesc-passrole-start-job",
|
||||
"aws-gamelift-privesc-passrole-create-fleet",
|
||||
"aws-glue-privesc-passrole-create-session",
|
||||
"aws-imagebuilder-privesc-passrole-create-image",
|
||||
"aws-kinesisanalytics-privesc-passrole-create-application",
|
||||
"aws-omics-privesc-passrole-start-run",
|
||||
"aws-scheduler-privesc-passrole-create-schedule",
|
||||
"aws-ssm-privesc-passrole-automation",
|
||||
"aws-stepfunctions-privesc-passrole-create-state-machine",
|
||||
"aws-batch-privesc-submit-existing-job",
|
||||
"aws-codedeploy-privesc-create-deployment",
|
||||
"aws-stepfunctions-privesc-update-state-machine",
|
||||
"aws-iam-privesc-delete-role-boundary-assume-role",
|
||||
"aws-sso-privesc-attach-managed-policy-permission-set",
|
||||
"aws-sso-privesc-put-inline-policy-permission-set",
|
||||
]
|
||||
|
||||
_BY_ID = {q.id: q for q in AWS_QUERIES}
|
||||
NEW_QUERIES = [_BY_ID[qid] for qid in PATHFINDING_2279_QUERY_IDS if qid in _BY_ID]
|
||||
|
||||
NEPTUNE_UNSUPPORTED_PREDICATES = re.compile(r"\b(any|all|none)\s*\(", re.IGNORECASE)
|
||||
NORMALIZED_STATEMENT_FIELDS = ("action", "resource", "notaction", "notresource")
|
||||
|
||||
|
||||
def test_all_2279_queries_registered():
|
||||
missing = [qid for qid in PATHFINDING_2279_QUERY_IDS if qid not in _BY_ID]
|
||||
assert not missing, f"queries not registered in AWS_QUERIES: {missing}"
|
||||
|
||||
|
||||
class TestServicePrivescQuerySchema:
|
||||
@pytest.mark.parametrize("query", NEW_QUERIES, ids=lambda q: q.id)
|
||||
def test_is_query_definition(self, query):
|
||||
assert isinstance(query, AttackPathsQueryDefinition)
|
||||
|
||||
@pytest.mark.parametrize("query", NEW_QUERIES, ids=lambda q: q.id)
|
||||
def test_id_kebab_and_aws_prefixed(self, query):
|
||||
assert query.id.startswith("aws-")
|
||||
assert re.match(r"^[a-z0-9]+(-[a-z0-9]+)*$", query.id)
|
||||
|
||||
@pytest.mark.parametrize("query", NEW_QUERIES, ids=lambda q: q.id)
|
||||
def test_provider_is_aws(self, query):
|
||||
assert query.provider == "aws"
|
||||
|
||||
@pytest.mark.parametrize("query", NEW_QUERIES, ids=lambda q: q.id)
|
||||
def test_has_metadata(self, query):
|
||||
assert query.name and len(query.name) > 5
|
||||
assert query.short_description and len(query.short_description) > 10
|
||||
assert query.description and len(query.description) > 20
|
||||
assert isinstance(query.parameters, list)
|
||||
|
||||
@pytest.mark.parametrize("query", NEW_QUERIES, ids=lambda q: q.id)
|
||||
def test_attribution_links_pathfinding(self, query):
|
||||
assert query.attribution is not None
|
||||
assert "pathfinding.cloud" in query.attribution.text
|
||||
assert query.attribution.link.startswith("https://pathfinding.cloud/paths/")
|
||||
|
||||
|
||||
class TestServicePrivescQueryCypher:
|
||||
@pytest.mark.parametrize("query", NEW_QUERIES, ids=lambda q: q.id)
|
||||
def test_anchored_and_provider_scoped(self, query):
|
||||
assert "(aws:AWSAccount {id: $provider_uid})" in query.cypher
|
||||
|
||||
@pytest.mark.parametrize("query", NEW_QUERIES, ids=lambda q: q.id)
|
||||
def test_finding_label_interpolated(self, query):
|
||||
assert "PROWLER_FINDING_LABEL" not in query.cypher
|
||||
|
||||
@pytest.mark.parametrize("query", NEW_QUERIES, ids=lambda q: q.id)
|
||||
def test_typed_status_scoped_finding_probe(self, query):
|
||||
assert re.search(
|
||||
r"-\[pfr:HAS_FINDING\]-\(pf:ProwlerFinding \{status: 'FAIL'\}\)",
|
||||
query.cypher,
|
||||
), f"{query.id} lacks the typed, status-scoped finding probe"
|
||||
|
||||
@pytest.mark.parametrize("query", NEW_QUERIES, ids=lambda q: q.id)
|
||||
def test_return_contract(self, query):
|
||||
assert re.search(
|
||||
r"RETURN paths, collect\(DISTINCT pf\) as dpf, "
|
||||
r"collect\(DISTINCT pfr\) as dpfr",
|
||||
query.cypher,
|
||||
)
|
||||
|
||||
@pytest.mark.parametrize("query", NEW_QUERIES, ids=lambda q: q.id)
|
||||
def test_no_neptune_unsupported_predicates(self, query):
|
||||
m = NEPTUNE_UNSUPPORTED_PREDICATES.search(query.cypher)
|
||||
assert m is None, f"{query.id} uses '{m.group().strip()}' (not Neptune-safe)"
|
||||
|
||||
@pytest.mark.parametrize("query", NEW_QUERIES, ids=lambda q: q.id)
|
||||
def test_no_regex_operator(self, query):
|
||||
assert "=~" not in query.cypher
|
||||
|
||||
@pytest.mark.parametrize("query", NEW_QUERIES, ids=lambda q: q.id)
|
||||
def test_does_not_read_normalized_list_fields(self, query):
|
||||
for field in NORMALIZED_STATEMENT_FIELDS:
|
||||
assert not re.search(rf"\.{field}\b", query.cypher), (
|
||||
f"{query.id} reads normalized list field '.{field}' as a property; "
|
||||
f"traverse the HAS_{field.upper()} edge instead"
|
||||
)
|
||||
|
||||
@pytest.mark.parametrize("query", NEW_QUERIES, ids=lambda q: q.id)
|
||||
def test_read_only(self, query):
|
||||
no_comments = "\n".join(
|
||||
line
|
||||
for line in query.cypher.split("\n")
|
||||
if not line.strip().startswith("//")
|
||||
)
|
||||
assert not re.search(
|
||||
r"\b(CREATE|MERGE|SET|DELETE|REMOVE|DETACH)\b", no_comments, re.IGNORECASE
|
||||
)
|
||||
Generated
+169
-135
@@ -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.4" },
|
||||
{ name = "alibabacloud-tea-openapi", specifier = "==0.4.5" },
|
||||
{ name = "alibabacloud-tea-util", specifier = "==0.3.14" },
|
||||
{ name = "alibabacloud-tea-xml", specifier = "==0.0.3" },
|
||||
{ name = "alibabacloud-vpc20160428", specifier = "==6.13.0" },
|
||||
@@ -127,9 +127,9 @@ constraints = [
|
||||
{ name = "coverage", specifier = "==7.5.4" },
|
||||
{ name = "cron-descriptor", specifier = "==1.4.5" },
|
||||
{ name = "crowdstrike-falconpy", specifier = "==1.6.0" },
|
||||
{ name = "cryptography", specifier = "==46.0.7" },
|
||||
{ name = "cryptography", specifier = "==48.0.1" },
|
||||
{ name = "cycler", specifier = "==0.12.1" },
|
||||
{ name = "darabonba-core", specifier = "==1.0.5" },
|
||||
{ name = "darabonba-core", specifier = "==1.0.8" },
|
||||
{ name = "dash", specifier = "==3.1.1" },
|
||||
{ name = "dash-bootstrap-components", specifier = "==2.0.3" },
|
||||
{ name = "debugpy", specifier = "==1.8.20" },
|
||||
@@ -194,7 +194,7 @@ constraints = [
|
||||
{ name = "h2", specifier = "==4.3.0" },
|
||||
{ name = "hpack", specifier = "==4.1.0" },
|
||||
{ name = "httpcore", specifier = "==1.0.9" },
|
||||
{ name = "httplib2", specifier = "==0.31.2" },
|
||||
{ name = "httplib2", specifier = "==0.32.0" },
|
||||
{ name = "httpx", specifier = "==0.28.1" },
|
||||
{ name = "humanfriendly", specifier = "==10.0" },
|
||||
{ name = "hyperframe", specifier = "==6.1.0" },
|
||||
@@ -231,13 +231,13 @@ constraints = [
|
||||
{ name = "matplotlib", specifier = "==3.10.8" },
|
||||
{ name = "mccabe", specifier = "==0.7.0" },
|
||||
{ name = "mdurl", specifier = "==0.1.2" },
|
||||
{ name = "microsoft-kiota-abstractions", specifier = "==1.9.9" },
|
||||
{ name = "microsoft-kiota-authentication-azure", specifier = "==1.9.9" },
|
||||
{ name = "microsoft-kiota-http", specifier = "==1.9.9" },
|
||||
{ name = "microsoft-kiota-serialization-form", specifier = "==1.9.9" },
|
||||
{ name = "microsoft-kiota-serialization-json", specifier = "==1.9.9" },
|
||||
{ name = "microsoft-kiota-serialization-multipart", specifier = "==1.9.9" },
|
||||
{ name = "microsoft-kiota-serialization-text", specifier = "==1.9.9" },
|
||||
{ name = "microsoft-kiota-abstractions", specifier = "==1.9.10" },
|
||||
{ name = "microsoft-kiota-authentication-azure", specifier = "==1.9.10" },
|
||||
{ name = "microsoft-kiota-http", specifier = "==1.9.10" },
|
||||
{ name = "microsoft-kiota-serialization-form", specifier = "==1.9.10" },
|
||||
{ name = "microsoft-kiota-serialization-json", specifier = "==1.9.10" },
|
||||
{ name = "microsoft-kiota-serialization-multipart", specifier = "==1.9.10" },
|
||||
{ name = "microsoft-kiota-serialization-text", specifier = "==1.9.10" },
|
||||
{ name = "microsoft-security-utilities-secret-masker", specifier = "==1.0.0b4" },
|
||||
{ name = "msal", specifier = "==1.35.0b1" },
|
||||
{ name = "msal-extensions", specifier = "==1.2.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.169.0" },
|
||||
{ name = "oci", specifier = "==2.183.0" },
|
||||
{ name = "openai", specifier = "==1.109.1" },
|
||||
{ name = "openstacksdk", specifier = "==4.2.0" },
|
||||
{ name = "opentelemetry-api", specifier = "==1.39.1" },
|
||||
@@ -266,7 +266,7 @@ constraints = [
|
||||
{ name = "pagerduty", specifier = "==6.1.0" },
|
||||
{ name = "pandas", specifier = "==2.2.3" },
|
||||
{ name = "pbr", specifier = "==7.0.3" },
|
||||
{ name = "pillow", specifier = "==12.2.0" },
|
||||
{ name = "pillow", specifier = "==12.3.0" },
|
||||
{ name = "pkginfo", specifier = "==1.12.1.2" },
|
||||
{ name = "platformdirs", specifier = "==4.5.1" },
|
||||
{ name = "plotly", specifier = "==6.5.2" },
|
||||
@@ -282,8 +282,8 @@ constraints = [
|
||||
{ name = "psycopg2-binary", specifier = "==2.9.9" },
|
||||
{ name = "py-deviceid", specifier = "==0.1.1" },
|
||||
{ name = "py-iam-expand", specifier = "==0.3.0" },
|
||||
{ name = "py-ocsf-models", specifier = "==0.8.1" },
|
||||
{ name = "pyasn1", specifier = "==0.6.3" },
|
||||
{ name = "py-ocsf-models", specifier = "==0.10.0" },
|
||||
{ name = "pyasn1", specifier = "==0.6.4" },
|
||||
{ name = "pyasn1-modules", specifier = "==0.4.2" },
|
||||
{ name = "pycodestyle", specifier = "==2.14.0" },
|
||||
{ name = "pycparser", specifier = "==3.0" },
|
||||
@@ -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.0.0" },
|
||||
{ name = "pyopenssl", specifier = "==26.2.0" },
|
||||
{ name = "pyparsing", specifier = "==3.3.2" },
|
||||
{ name = "pyreadline3", specifier = "==3.5.4" },
|
||||
{ name = "pysocks", specifier = "==1.7.1" },
|
||||
@@ -364,7 +364,7 @@ constraints = [
|
||||
{ name = "wcwidth", specifier = "==0.5.3" },
|
||||
{ name = "websocket-client", specifier = "==1.9.0" },
|
||||
{ name = "werkzeug", specifier = "==3.1.7" },
|
||||
{ name = "workos", specifier = "==6.0.8" },
|
||||
{ name = "workos", specifier = "==8.3.0" },
|
||||
{ name = "wrapt", specifier = "==1.17.3" },
|
||||
{ name = "xlsxwriter", specifier = "==3.2.9" },
|
||||
{ name = "xmlsec", specifier = "==1.3.17" },
|
||||
@@ -378,7 +378,13 @@ constraints = [
|
||||
overrides = [
|
||||
{ name = "azure-mgmt-containerservice", specifier = "==34.1.0" },
|
||||
{ name = "dulwich", specifier = "==1.2.5" },
|
||||
{ name = "microsoft-kiota-abstractions", specifier = "==1.9.9" },
|
||||
{ name = "microsoft-kiota-abstractions", specifier = "==1.9.10" },
|
||||
{ name = "microsoft-kiota-authentication-azure", specifier = "==1.9.10" },
|
||||
{ name = "microsoft-kiota-http", specifier = "==1.9.10" },
|
||||
{ name = "microsoft-kiota-serialization-form", specifier = "==1.9.10" },
|
||||
{ name = "microsoft-kiota-serialization-json", specifier = "==1.9.10" },
|
||||
{ name = "microsoft-kiota-serialization-multipart", specifier = "==1.9.10" },
|
||||
{ name = "microsoft-kiota-serialization-text", specifier = "==1.9.10" },
|
||||
{ name = "okta", specifier = "==3.4.2" },
|
||||
{ name = "pyjwt", extras = ["crypto"], specifier = "==2.13.0" },
|
||||
]
|
||||
@@ -853,7 +859,7 @@ sdist = { url = "https://files.pythonhosted.org/packages/9a/7d/b22cb9a0d4f396ee0
|
||||
|
||||
[[package]]
|
||||
name = "alibabacloud-tea-openapi"
|
||||
version = "0.4.4"
|
||||
version = "0.4.5"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "alibabacloud-credentials" },
|
||||
@@ -862,9 +868,9 @@ dependencies = [
|
||||
{ name = "cryptography" },
|
||||
{ name = "darabonba-core" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/30/93/138bcdc8fc596add73e37cf2073798f285284d1240bda9ee02f9384fc6be/alibabacloud_tea_openapi-0.4.4.tar.gz", hash = "sha256:1b0917bc03cd49417da64945e92731716d53e2eb8707b235f54e45b7473221ce", size = 21960, upload-time = "2026-03-26T10:16:16.792Z" }
|
||||
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" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/f5/5a/6bfc4506438c1809c486f66217ad11eab78157192b3d5707b4e2f4212f6c/alibabacloud_tea_openapi-0.4.4-py3-none-any.whl", hash = "sha256:cea6bc1fe35b0319a8752cb99eb0ecb0dab7ca1a71b99c12970ba0867410995f", size = 26236, upload-time = "2026-03-26T10:16:15.861Z" },
|
||||
{ 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" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2088,6 +2094,37 @@ toml = [
|
||||
{ name = "tomli", marker = "python_full_version <= '3.11'" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crc32c"
|
||||
version = "2.8"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/e3/66/7e97aa77af7cf6afbff26e3651b564fe41932599bc2d3dce0b2f73d4829a/crc32c-2.8.tar.gz", hash = "sha256:578728964e59c47c356aeeedee6220e021e124b9d3e8631d95d9a5e5f06e261c", size = 48179, upload-time = "2025-10-17T06:20:13.61Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/dc/0b/5e03b22d913698e9cc563f39b9f6bbd508606bf6b8e9122cd6bf196b87ea/crc32c-2.8-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:e560a97fbb96c9897cb1d9b5076ef12fc12e2e25622530a1afd0de4240f17e1f", size = 66329, upload-time = "2025-10-17T06:19:01.771Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6b/38/2fe0051ffe8c6a650c8b1ac0da31b8802d1dbe5fa40a84e4b6b6f5583db5/crc32c-2.8-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:6762d276d90331a490ef7e71ffee53b9c0eb053bd75a272d786f3b08d3fe3671", size = 62988, upload-time = "2025-10-17T06:19:02.953Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3e/30/5837a71c014be83aba1469c58820d287fc836512a0cad6b8fdd43868accd/crc32c-2.8-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:60670569f5ede91e39f48fb0cb4060e05b8d8704dd9e17ede930bf441b2f73ef", size = 61522, upload-time = "2025-10-17T06:19:03.796Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ca/29/63972fc1452778e2092ae998c50cbfc2fc93e3fa9798a0278650cd6169c5/crc32c-2.8-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:711743da6ccc70b3c6718c328947b0b6f34a1fe6a6c27cc6c1d69cc226bf70e9", size = 80200, upload-time = "2025-10-17T06:19:04.617Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cb/3a/60eb49d7bdada4122b3ffd45b0df54bdc1b8dd092cda4b069a287bdfcff4/crc32c-2.8-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5eb4094a2054774f13b26f21bf56792bb44fa1fcee6c6ad099387a43ffbfb4fa", size = 81757, upload-time = "2025-10-17T06:19:05.496Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f5/63/6efc1b64429ef7d23bd58b75b7ac24d15df327e3ebbe9c247a0f7b1c2ed1/crc32c-2.8-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:fff15bf2bd3e95780516baae935ed12be88deaa5ebe6143c53eb0d26a7bdc7b7", size = 80830, upload-time = "2025-10-17T06:19:06.621Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e1/eb/0ae9f436f8004f1c88f7429e659a7218a3879bd11a6b18ed1257aad7e98b/crc32c-2.8-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:4c0e11e3826668121fa53e0745635baf5e4f0ded437e8ff63ea56f38fc4f970a", size = 80095, upload-time = "2025-10-17T06:19:07.381Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9e/81/4afc9d468977a4cd94a2eb62908553345009a7c0d30e74463a15d4b48ec3/crc32c-2.8-cp311-cp311-win32.whl", hash = "sha256:38f915336715d1f1353ab07d7d786f8a789b119e273aea106ba55355dfc9101d", size = 64886, upload-time = "2025-10-17T06:19:08.497Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d6/e8/94e839c9f7e767bf8479046a207afd440a08f5c59b52586e1af5e64fa4a0/crc32c-2.8-cp311-cp311-win_amd64.whl", hash = "sha256:60e0a765b1caab8d31b2ea80840639253906a9351d4b861551c8c8625ea20f86", size = 66639, upload-time = "2025-10-17T06:19:09.338Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b6/36/fd18ef23c42926b79c7003e16cb0f79043b5b179c633521343d3b499e996/crc32c-2.8-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:572ffb1b78cce3d88e8d4143e154d31044a44be42cb3f6fbbf77f1e7a941c5ab", size = 66379, upload-time = "2025-10-17T06:19:10.115Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7f/b8/c584958e53f7798dd358f5bdb1bbfc97483134f053ee399d3eeb26cca075/crc32c-2.8-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:cf827b3758ee0c4aacd21ceca0e2da83681f10295c38a10bfeb105f7d98f7a68", size = 63042, upload-time = "2025-10-17T06:19:10.946Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/62/e6/6f2af0ec64a668a46c861e5bc778ea3ee42171fedfc5440f791f470fd783/crc32c-2.8-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:106fbd79013e06fa92bc3b51031694fcc1249811ed4364ef1554ee3dd2c7f5a2", size = 61528, upload-time = "2025-10-17T06:19:11.768Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/17/8b/4a04bd80a024f1a23978f19ae99407783e06549e361ab56e9c08bba3c1d3/crc32c-2.8-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:6dde035f91ffbfe23163e68605ee5a4bb8ceebd71ed54bb1fb1d0526cdd125a2", size = 80028, upload-time = "2025-10-17T06:19:12.554Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/21/8f/01c7afdc76ac2007d0e6a98e7300b4470b170480f8188475b597d1f4b4c6/crc32c-2.8-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e41ebe7c2f0fdcd9f3a3fd206989a36b460b4d3f24816d53e5be6c7dba72c5e1", size = 81531, upload-time = "2025-10-17T06:19:13.406Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/32/2b/8f78c5a8cc66486be5f51b6f038fc347c3ba748d3ea68be17a014283c331/crc32c-2.8-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ecf66cf90266d9c15cea597d5cc86c01917cd1a238dc3c51420c7886fa750d7e", size = 80608, upload-time = "2025-10-17T06:19:14.223Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/db/86/fad1a94cdeeeb6b6e2323c87f970186e74bfd6fbfbc247bf5c88ad0873d5/crc32c-2.8-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:59eee5f3a69ad0793d5fa9cdc9b9d743b0cd50edf7fccc0a3988a821fef0208c", size = 79886, upload-time = "2025-10-17T06:19:15.345Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d5/db/1a7cb6757a1e32376fa2dfce00c815ea4ee614a94f9bff8228e37420c183/crc32c-2.8-cp312-cp312-win32.whl", hash = "sha256:a73d03ce3604aa5d7a2698e9057a0eef69f529c46497b27ee1c38158e90ceb76", size = 64896, upload-time = "2025-10-17T06:19:16.457Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bf/8e/2024de34399b2e401a37dcb54b224b56c747b0dc46de4966886827b4d370/crc32c-2.8-cp312-cp312-win_amd64.whl", hash = "sha256:56b3b7d015247962cf58186e06d18c3d75a1a63d709d3233509e1c50a2d36aa2", size = 66645, upload-time = "2025-10-17T06:19:17.235Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a7/1d/dd926c68eb8aac8b142a1a10b8eb62d95212c1cf81775644373fe7cceac2/crc32c-2.8-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:5833f4071da7ea182c514ba17d1eee8aec3c5be927d798222fbfbbd0f5eea02c", size = 62345, upload-time = "2025-10-17T06:20:09.39Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/51/be/803404e5abea2ef2c15042edca04bbb7f625044cca879e47f186b43887c2/crc32c-2.8-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:1dc4da036126ac07b39dd9d03e93e585ec615a2ad28ff12757aef7de175295a8", size = 61229, upload-time = "2025-10-17T06:20:10.236Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fc/3a/00cc578cd27ed0b22c9be25cef2c24539d92df9fa80ebd67a3fc5419724c/crc32c-2.8-pp311-pypy311_pp73-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:15905fa78344654e241371c47e6ed2411f9eeb2b8095311c68c88eccf541e8b4", size = 64108, upload-time = "2025-10-17T06:20:11.072Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6b/bc/0587ef99a1c7629f95dd0c9d4f3d894de383a0df85831eb16c48a6afdae4/crc32c-2.8-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c596f918688821f796434e89b431b1698396c38bf0b56de873621528fe3ecb1e", size = 64815, upload-time = "2025-10-17T06:20:11.919Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/73/42/94f2b8b92eae9064fcfb8deef2b971514065bd606231f8857ff8ae02bebd/crc32c-2.8-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:8d23c4fe01b3844cb6e091044bc1cebdef7d16472e058ce12d9fadf10d2614af", size = 66659, upload-time = "2025-10-17T06:20:12.766Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cron-descriptor"
|
||||
version = "1.4.5"
|
||||
@@ -2112,47 +2149,47 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "cryptography"
|
||||
version = "46.0.7"
|
||||
version = "48.0.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "cffi", marker = "platform_python_implementation != 'PyPy'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/47/93/ac8f3d5ff04d54bc814e961a43ae5b0b146154c89c61b47bb07557679b18/cryptography-46.0.7.tar.gz", hash = "sha256:e4cfd68c5f3e0bfdad0d38e023239b96a2fe84146481852dffbcca442c245aa5", size = 750652, upload-time = "2026-04-08T01:57:54.692Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/12/45/870e7f4bef50e5f53b9f51d4428aee5290eedf58ba443f16b1ebb7ab8e66/cryptography-48.0.1.tar.gz", hash = "sha256:266f4ee051abb2f725b74ef8072b521ce1feacf685a3364fa6a6b45548db791a", size = 832989, upload-time = "2026-06-09T22:32:31.8Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/0b/5d/4a8f770695d73be252331e60e526291e3df0c9b27556a90a6b47bccca4c2/cryptography-46.0.7-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:ea42cbe97209df307fdc3b155f1b6fa2577c0defa8f1f7d3be7d31d189108ad4", size = 7179869, upload-time = "2026-04-08T01:56:17.157Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5f/45/6d80dc379b0bbc1f9d1e429f42e4cb9e1d319c7a8201beffd967c516ea01/cryptography-46.0.7-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b36a4695e29fe69215d75960b22577197aca3f7a25b9cf9d165dcfe9d80bc325", size = 4275492, upload-time = "2026-04-08T01:56:19.36Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4a/9a/1765afe9f572e239c3469f2cb429f3ba7b31878c893b246b4b2994ffe2fe/cryptography-46.0.7-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5ad9ef796328c5e3c4ceed237a183f5d41d21150f972455a9d926593a1dcb308", size = 4426670, upload-time = "2026-04-08T01:56:21.415Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8f/3e/af9246aaf23cd4ee060699adab1e47ced3f5f7e7a8ffdd339f817b446462/cryptography-46.0.7-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:73510b83623e080a2c35c62c15298096e2a5dc8d51c3b4e1740211839d0dea77", size = 4280275, upload-time = "2026-04-08T01:56:23.539Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0f/54/6bbbfc5efe86f9d71041827b793c24811a017c6ac0fd12883e4caa86b8ed/cryptography-46.0.7-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:cbd5fb06b62bd0721e1170273d3f4d5a277044c47ca27ee257025146c34cbdd1", size = 4928402, upload-time = "2026-04-08T01:56:25.624Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2d/cf/054b9d8220f81509939599c8bdbc0c408dbd2bdd41688616a20731371fe0/cryptography-46.0.7-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:420b1e4109cc95f0e5700eed79908cef9268265c773d3a66f7af1eef53d409ef", size = 4459985, upload-time = "2026-04-08T01:56:27.309Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f9/46/4e4e9c6040fb01c7467d47217d2f882daddeb8828f7df800cb806d8a2288/cryptography-46.0.7-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:24402210aa54baae71d99441d15bb5a1919c195398a87b563df84468160a65de", size = 3990652, upload-time = "2026-04-08T01:56:29.095Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/36/5f/313586c3be5a2fbe87e4c9a254207b860155a8e1f3cca99f9910008e7d08/cryptography-46.0.7-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:8a469028a86f12eb7d2fe97162d0634026d92a21f3ae0ac87ed1c4a447886c83", size = 4279805, upload-time = "2026-04-08T01:56:30.928Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/69/33/60dfc4595f334a2082749673386a4d05e4f0cf4df8248e63b2c3437585f2/cryptography-46.0.7-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:9694078c5d44c157ef3162e3bf3946510b857df5a3955458381d1c7cfc143ddb", size = 4892883, upload-time = "2026-04-08T01:56:32.614Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c7/0b/333ddab4270c4f5b972f980adef4faa66951a4aaf646ca067af597f15563/cryptography-46.0.7-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:42a1e5f98abb6391717978baf9f90dc28a743b7d9be7f0751a6f56a75d14065b", size = 4459756, upload-time = "2026-04-08T01:56:34.306Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d2/14/633913398b43b75f1234834170947957c6b623d1701ffc7a9600da907e89/cryptography-46.0.7-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:91bbcb08347344f810cbe49065914fe048949648f6bd5c2519f34619142bbe85", size = 4410244, upload-time = "2026-04-08T01:56:35.977Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/10/f2/19ceb3b3dc14009373432af0c13f46aa08e3ce334ec6eff13492e1812ccd/cryptography-46.0.7-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:5d1c02a14ceb9148cc7816249f64f623fbfee39e8c03b3650d842ad3f34d637e", size = 4674868, upload-time = "2026-04-08T01:56:38.034Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1a/bb/a5c213c19ee94b15dfccc48f363738633a493812687f5567addbcbba9f6f/cryptography-46.0.7-cp311-abi3-win32.whl", hash = "sha256:d23c8ca48e44ee015cd0a54aeccdf9f09004eba9fc96f38c911011d9ff1bd457", size = 3026504, upload-time = "2026-04-08T01:56:39.666Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2b/02/7788f9fefa1d060ca68717c3901ae7fffa21ee087a90b7f23c7a603c32ae/cryptography-46.0.7-cp311-abi3-win_amd64.whl", hash = "sha256:397655da831414d165029da9bc483bed2fe0e75dde6a1523ec2fe63f3c46046b", size = 3488363, upload-time = "2026-04-08T01:56:41.893Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a7/7f/cd42fc3614386bc0c12f0cb3c4ae1fc2bbca5c9662dfed031514911d513d/cryptography-46.0.7-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:462ad5cb1c148a22b2e3bcc5ad52504dff325d17daf5df8d88c17dda1f75f2a4", size = 7165618, upload-time = "2026-04-08T01:57:10.645Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a5/d0/36a49f0262d2319139d2829f773f1b97ef8aef7f97e6e5bd21455e5a8fb5/cryptography-46.0.7-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:84d4cced91f0f159a7ddacad249cc077e63195c36aac40b4150e7a57e84fffe7", size = 4270628, upload-time = "2026-04-08T01:57:12.885Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8a/6c/1a42450f464dda6ffbe578a911f773e54dd48c10f9895a23a7e88b3e7db5/cryptography-46.0.7-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:128c5edfe5e5938b86b03941e94fac9ee793a94452ad1365c9fc3f4f62216832", size = 4415405, upload-time = "2026-04-08T01:57:14.923Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9a/92/4ed714dbe93a066dc1f4b4581a464d2d7dbec9046f7c8b7016f5286329e2/cryptography-46.0.7-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:5e51be372b26ef4ba3de3c167cd3d1022934bc838ae9eaad7e644986d2a3d163", size = 4272715, upload-time = "2026-04-08T01:57:16.638Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b7/e6/a26b84096eddd51494bba19111f8fffe976f6a09f132706f8f1bf03f51f7/cryptography-46.0.7-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:cdf1a610ef82abb396451862739e3fc93b071c844399e15b90726ef7470eeaf2", size = 4918400, upload-time = "2026-04-08T01:57:19.021Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c7/08/ffd537b605568a148543ac3c2b239708ae0bd635064bab41359252ef88ed/cryptography-46.0.7-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:1d25aee46d0c6f1a501adcddb2d2fee4b979381346a78558ed13e50aa8a59067", size = 4450634, upload-time = "2026-04-08T01:57:21.185Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/16/01/0cd51dd86ab5b9befe0d031e276510491976c3a80e9f6e31810cce46c4ad/cryptography-46.0.7-cp38-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:cdfbe22376065ffcf8be74dc9a909f032df19bc58a699456a21712d6e5eabfd0", size = 3985233, upload-time = "2026-04-08T01:57:22.862Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/92/49/819d6ed3a7d9349c2939f81b500a738cb733ab62fbecdbc1e38e83d45e12/cryptography-46.0.7-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:abad9dac36cbf55de6eb49badd4016806b3165d396f64925bf2999bcb67837ba", size = 4271955, upload-time = "2026-04-08T01:57:24.814Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/80/07/ad9b3c56ebb95ed2473d46df0847357e01583f4c52a85754d1a55e29e4d0/cryptography-46.0.7-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:935ce7e3cfdb53e3536119a542b839bb94ec1ad081013e9ab9b7cfd478b05006", size = 4879888, upload-time = "2026-04-08T01:57:26.88Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b8/c7/201d3d58f30c4c2bdbe9b03844c291feb77c20511cc3586daf7edc12a47b/cryptography-46.0.7-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:35719dc79d4730d30f1c2b6474bd6acda36ae2dfae1e3c16f2051f215df33ce0", size = 4449961, upload-time = "2026-04-08T01:57:29.068Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a5/ef/649750cbf96f3033c3c976e112265c33906f8e462291a33d77f90356548c/cryptography-46.0.7-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:7bbc6ccf49d05ac8f7d7b5e2e2c33830d4fe2061def88210a126d130d7f71a85", size = 4401696, upload-time = "2026-04-08T01:57:31.029Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/41/52/a8908dcb1a389a459a29008c29966c1d552588d4ae6d43f3a1a4512e0ebe/cryptography-46.0.7-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:a1529d614f44b863a7b480c6d000fe93b59acee9c82ffa027cfadc77521a9f5e", size = 4664256, upload-time = "2026-04-08T01:57:33.144Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4b/fa/f0ab06238e899cc3fb332623f337a7364f36f4bb3f2534c2bb95a35b132c/cryptography-46.0.7-cp38-abi3-win32.whl", hash = "sha256:f247c8c1a1fb45e12586afbb436ef21ff1e80670b2861a90353d9b025583d246", size = 3013001, upload-time = "2026-04-08T01:57:34.933Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d2/f1/00ce3bde3ca542d1acd8f8cfa38e446840945aa6363f9b74746394b14127/cryptography-46.0.7-cp38-abi3-win_amd64.whl", hash = "sha256:506c4ff91eff4f82bdac7633318a526b1d1309fc07ca76a3ad182cb5b686d6d3", size = 3472985, upload-time = "2026-04-08T01:57:36.714Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/63/0c/dca8abb64e7ca4f6b2978769f6fea5ad06686a190cec381f0a796fdcaaba/cryptography-46.0.7-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:fc9ab8856ae6cf7c9358430e49b368f3108f050031442eaeb6b9d87e4dcf4e4f", size = 3476879, upload-time = "2026-04-08T01:57:38.664Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3a/ea/075aac6a84b7c271578d81a2f9968acb6e273002408729f2ddff517fed4a/cryptography-46.0.7-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:d3b99c535a9de0adced13d159c5a9cf65c325601aa30f4be08afd680643e9c15", size = 4219700, upload-time = "2026-04-08T01:57:40.625Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6c/7b/1c55db7242b5e5612b29fc7a630e91ee7a6e3c8e7bf5406d22e206875fbd/cryptography-46.0.7-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:d02c738dacda7dc2a74d1b2b3177042009d5cab7c7079db74afc19e56ca1b455", size = 4385982, upload-time = "2026-04-08T01:57:42.725Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cb/da/9870eec4b69c63ef5925bf7d8342b7e13bc2ee3d47791461c4e49ca212f4/cryptography-46.0.7-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:04959522f938493042d595a736e7dbdff6eb6cc2339c11465b3ff89343b65f65", size = 4219115, upload-time = "2026-04-08T01:57:44.939Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f4/72/05aa5832b82dd341969e9a734d1812a6aadb088d9eb6f0430fc337cc5a8f/cryptography-46.0.7-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:3986ac1dee6def53797289999eabe84798ad7817f3e97779b5061a95b0ee4968", size = 4385479, upload-time = "2026-04-08T01:57:46.86Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/20/2a/1b016902351a523aa2bd446b50a5bc1175d7a7d1cf90fe2ef904f9b84ebc/cryptography-46.0.7-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:258514877e15963bd43b558917bc9f54cf7cf866c38aa576ebf47a77ddbc43a4", size = 3412829, upload-time = "2026-04-08T01:57:48.874Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1b/bc/ee4137cbbe105652c0ee4252792b78fc8e7afa4b8e61d9d5dc05a7f45731/cryptography-48.0.1-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:3e4a1a3232eef2e6c732827d5722db29a0cc8b27af2a4d865b094cf954be9ca1", size = 8008324, upload-time = "2026-06-09T22:31:00.702Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d5/85/6379d42181bfc713094f081360fc5784d6c816b599d45e7f082502d173ce/cryptography-48.0.1-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:32143b24adb918f078134e1e230f1eb8cc04886b92c28b5f0041aaf3e5699225", size = 4696243, upload-time = "2026-06-09T22:32:33.446Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9c/87/c85d147b53323c7eb4d850920c8901377323c2a0ff8d79c262d4fee89aa2/cryptography-48.0.1-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f0d27a5696721ef7a672b8c810f6aded391058e0b9486e63e6d93baf765da691", size = 4713235, upload-time = "2026-06-09T22:31:40.141Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/79/58/67cbf8cf1ee7c54b439ca07bbecf8362c07afc11a3724fea70f745784add/cryptography-48.0.1-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:eb86ce1af36fe65041b6db9a8bb064ee621a7e5fded0f80d475ec243477cd242", size = 4702323, upload-time = "2026-06-09T22:31:42.191Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/89/c6/24266ac10c47f6cd2a865f4446062b466da1d1f10b27189eac00e61bf0c9/cryptography-48.0.1-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:b024e784ad6c077ee0147b35ea9cbfc1e34e1fd4c1dcca214c2794d73a12df08", size = 5300085, upload-time = "2026-06-09T22:31:58.703Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d2/bb/cc4b78784f97efc8c5874c2a9743708d172be6663024b34a0467885ae0c8/cryptography-48.0.1-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:3752f2dbc8f07a30aad2932c986cea495b03bb554887828225da104f732852b6", size = 4746137, upload-time = "2026-06-09T22:31:31.01Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1f/52/0c44de3f5267f8fbe8e835138017522a333436166e406f0db9b9e6e3033f/cryptography-48.0.1-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:bd81490cd5801d755cf97bb68ac191f14b708470b1c7cf4580f669b9c9264cd8", size = 4333867, upload-time = "2026-06-09T22:32:28.096Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9a/2e/772d7adbfa931537bc401640b7cac9976bff689bda187833e5d63b428e49/cryptography-48.0.1-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:66fd0771e7b9c6dcd44cf1120690d2338d16d72795cf40cae2786a39eba65429", size = 4701805, upload-time = "2026-06-09T22:31:38.284Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f8/a3/b06844f303873493c963caf581c04df31c7035e0c1b0f02c4814d319ec80/cryptography-48.0.1-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:3fd2ca57062b241c856670b073487d2e86c4637937ca5601e48f97bf8e11fc8f", size = 5258461, upload-time = "2026-06-09T22:31:04.187Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9f/13/8b765e2e12b07c74941caadb9d1c8fdc006c4dfbf2b8f2d610519758954d/cryptography-48.0.1-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:0ee6ea481db1ab889cba043ec1eda17bb9c1ea79db6722f779c3667f9f70322f", size = 4745488, upload-time = "2026-06-09T22:32:30.07Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2e/aa/48972bce55049b32a94f4907eda4d75fa385aad8a39506cc2fc72196ecf0/cryptography-48.0.1-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:f2ceef93cb096aa3c4cc4b5c94ca6131f9196d28c64d6111533402a9b2054d41", size = 4830256, upload-time = "2026-06-09T22:31:43.868Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/47/a2/e5079a032fb85cf6005046ca92bbd78b0c82dad2b5751ab8c311659da06f/cryptography-48.0.1-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:9bd3f92d76217892b15df84ca256c2c113d386fdda7a7d8691aeeced976507c6", size = 4979117, upload-time = "2026-06-09T22:31:05.845Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b7/a0/8f50cae9c74e718ed769d63ed5c74bd0ea830c9550a74629cebd1b9c7bc7/cryptography-48.0.1-cp311-abi3-win32.whl", hash = "sha256:b9a32b876490d66c8bcc9963ef220199569748434ab01a9d6aaeabf88e7f5158", size = 3304154, upload-time = "2026-06-09T22:32:16.845Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c5/69/0572c77dbace6fef72f33755bd52ea399c71367250d366237f8691826b9e/cryptography-48.0.1-cp311-abi3-win_amd64.whl", hash = "sha256:39489bfca54c7a1f6b297efcd8bc608ab92d16c4ca631b0cad4da46724588b24", size = 3817138, upload-time = "2026-06-09T22:32:00.388Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ca/6c/00fa2a95997164c8b2072ce327c23d4ab20809ccc323ea5fab91e53a4bba/cryptography-48.0.1-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:4fdc69f8e4316bcf0c8c8ec1f26f285d12e8142d88d96c876a59a03be3f6ae67", size = 7987408, upload-time = "2026-06-09T22:32:20.777Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b0/d9/45f309a7e4e5f3f8f121d6d3be9e94024a7726ec598d6e08ae04edb2f04d/cryptography-48.0.1-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:48fe40804d4caa2288f24e70ca8c64c42dd826da0ad7e4f1b41b2128d679e6c8", size = 4690196, upload-time = "2026-06-09T22:31:54.74Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5f/9f/a1bc8bcc798811b8527eb374bbccf30a3f3e806829d967118222bf1125eb/cryptography-48.0.1-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:86be3b1b0b6bf09482fb50a979c508d2950ed95f5621ec77f4e385962006b83a", size = 4696782, upload-time = "2026-06-09T22:31:45.615Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/66/c2/81a4fb4e4373c500bb526bc337ac5719dd31dd15b970b84a238168c6aa08/cryptography-48.0.1-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:4ab0a343c807bbcd90c971cd1ecf072937cd01847a9e002bef88fb47ac6be577", size = 4696618, upload-time = "2026-06-09T22:31:11.564Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e5/0b/aa68b221dde92d09cb29a024ede17550ee21e77a404e59fc093c82bb51e1/cryptography-48.0.1-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:9621de99d2da096006b629979efd8ae7eb2d8b822488d0c89ee4000c306c59b1", size = 5289970, upload-time = "2026-06-09T22:31:20.368Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/78/13/fba657f958d2af66ea959a4ba01212632089249d34af1ae48054136344d7/cryptography-48.0.1-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:88c852a0ae366e262e5a1744b685e6a433dc8788dd2a277e418bf4904203609d", size = 4731873, upload-time = "2026-06-09T22:31:22.253Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4c/4c/9a964756d24a26b3e34dfcb16f961b89838786e6700b635b0d1e3adff4b6/cryptography-48.0.1-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:43c5835e2cb98c8733d86f57d6fc879b613f5c3478607281c3e36daffc6dd8a6", size = 4330804, upload-time = "2026-06-09T22:31:36.56Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4b/0f/a10f3a6eb12950a10e3a874070283aa2dd5875b2bfd15fad8a3e17b3f13e/cryptography-48.0.1-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:fe0180af5bf9236518a087e35bf2d9a347d5f5f51e63c579d683ddff424e3d46", size = 4696217, upload-time = "2026-06-09T22:31:13.351Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f3/6f/5cd12f951165ea73ef85266775d97e4c763b2474ccfd816dd69d3a18d6f8/cryptography-48.0.1-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:b7a2d1a937a738a881737cec135a38bb61470589b17515b9f73f571d0ae10401", size = 5245252, upload-time = "2026-06-09T22:32:02.193Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/68/ab/8aaa12e4516ec4464033ab79b6f3b592bd5a92102467c4ace8a0d970203f/cryptography-48.0.1-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:b74ca3b8e5ecdd833bf6a002ca41b4793bb27fb8f1c06ffaf2643c9e9140e31b", size = 4731388, upload-time = "2026-06-09T22:32:04.019Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1b/24/50027ea4dca85ec1f40688f3c24fb32ccacd520583c9592c3cc95628e6fb/cryptography-48.0.1-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:2c37f2461406063b417837f5f3daab668652acd82423efcd7f0a9f04be972de1", size = 4824186, upload-time = "2026-06-09T22:32:18.707Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/52/41/04cb5eb17085ade6f50cc611fb657df6a0f5885350de8764ece89c050197/cryptography-48.0.1-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:86fe77abb1bd87afb251d4d02ada7ecf53a32cee9b67d976abb2e45a13297475", size = 4964539, upload-time = "2026-06-09T22:31:18.793Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/36/bf/ed70785c496e89d7e73b7cda2d21f2447fd6d4e821714b8d04ff217fed92/cryptography-48.0.1-cp39-abi3-win32.whl", hash = "sha256:6b2c0c3e6ccf3ade7750f836ef3ee36eea250cc467d45c256895573ac08cc6f1", size = 3282307, upload-time = "2026-06-09T22:30:53.162Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b3/ff/371ea7d252656ee1eb6d83eeeef3d1d0c6baf1d6497687d081ea03814670/cryptography-48.0.1-cp39-abi3-win_amd64.whl", hash = "sha256:9a49ca6c81417f6a5edb50375a60cccdd70fa0a91a5211829dbea74eba94d2ac", size = 3793408, upload-time = "2026-06-09T22:32:15.191Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a9/d3/eb4e394e587341fdad09a09101fa76478ead3a78b0ad63e55c22f0d75c02/cryptography-48.0.1-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:08a597acce1ff37f347400087776599e2348a3a8bc53b44120e463cd274efe4a", size = 3951747, upload-time = "2026-06-09T22:31:23.871Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e0/4a/3f43451b4f858bfceaaaffc649e6e787e8d4fb332a1d443af39ab02cc8f1/cryptography-48.0.1-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:735824ec41b7f74a7c45fb1591349333e4c696cb6c044e5f46356e560143e4cd", size = 4641226, upload-time = "2026-06-09T22:31:02.532Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/73/4e/855584c2c23b09e4ce2d3b9c30e983e679cd60b068c513c6bbdb91e11782/cryptography-48.0.1-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:92a46e1d638daa264ba2971c0b0489c9409787943efae4d60ffda3d091ef832c", size = 4668958, upload-time = "2026-06-09T22:32:06.213Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/42/3b/d35750e41d803d1e516fd6d6011f065424924da7af1748cef4cc9cb3ede1/cryptography-48.0.1-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:7e234ac052af99f2700826a5c29ea99d9c1b1f80341cde62d11c8154dc8e0bd9", size = 4640793, upload-time = "2026-06-09T22:32:26.331Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ca/aa/cdb7181fe865285e87e96825aaab239400f1de0c3bfba9bd9769b79f1a92/cryptography-48.0.1-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:33842cf0888951cef5bc7ac724ab844a42044c1727b967b7f8997289a0464f92", size = 4668505, upload-time = "2026-06-09T22:31:27.534Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5d/8c/ce3823c06c2804f194f9e64f0d67fa3f4094a39f2bb1a990cd03603af8fc/cryptography-48.0.1-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:6184ca7b174f28d7c703f1290d4b297217c45355f77a98f67e9b7f14549ac54a", size = 3742204, upload-time = "2026-06-09T22:31:34.773Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2166,15 +2203,17 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "darabonba-core"
|
||||
version = "1.0.5"
|
||||
version = "1.0.8"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "aiohttp" },
|
||||
{ name = "alibabacloud-tea" },
|
||||
{ name = "requests" },
|
||||
{ name = "websocket-client" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/f5/83/9321ccdb7a800c2cb97d8fa34bead5f20141f27f804594fd1fd815c4cd07/darabonba_core-1.0.8.tar.gz", hash = "sha256:f1661960b368e342d3d36434be82d264b70a01c49e843921d8a4dacd217376ae", size = 27604, upload-time = "2026-07-13T02:07:34.093Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/66/d3/a7daaee544c904548e665829b51a9fa2572acb82c73ad787a8ff90273002/darabonba_core-1.0.5-py3-none-any.whl", hash = "sha256:671ab8dbc4edc2a8f88013da71646839bb8914f1259efc069353243ef52ea27c", size = 24580, upload-time = "2025-12-12T07:53:59.494Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6d/88/38800ca22f39a31fdb75c7b2867c61d3af5e2792cee0b72942a639c88a79/darabonba_core-1.0.8-py3-none-any.whl", hash = "sha256:ac093fdd40f88f2f9dfbbbfd7bc143495a3cb031f35b397c98d24edfa6b69483", size = 30957, upload-time = "2026-07-13T02:07:33.138Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3327,14 +3366,14 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "httplib2"
|
||||
version = "0.31.2"
|
||||
version = "0.32.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "pyparsing" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/c1/1f/e86365613582c027dda5ddb64e1010e57a3d53e99ab8a72093fa13d565ec/httplib2-0.31.2.tar.gz", hash = "sha256:385e0869d7397484f4eab426197a4c020b606edd43372492337c0b4010ae5d24", size = 250800, upload-time = "2026-01-23T11:04:44.165Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/84/f5/ccf58de92d61e3ad921119668f54ed36ca1d0cf5dcc5c1657dfb164fd78b/httplib2-0.32.0.tar.gz", hash = "sha256:48a0ef30a42db65d8f3399045e1d09ab0ba66e3b9efc360d07f80ea55d286025", size = 254283, upload-time = "2026-06-26T10:13:56.265Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/2f/90/fd509079dfcab01102c0fdd87f3a9506894bc70afcf9e9785ef6b2b3aff6/httplib2-0.31.2-py3-none-any.whl", hash = "sha256:dbf0c2fa3862acf3c55c078ea9c0bc4481d7dc5117cae71be9514912cf9f8349", size = 91099, upload-time = "2026-01-23T11:04:42.78Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/33/a0/550eec327e5f5c7b732531c489f5307efec41f047b0d703bd4ca1e5ad2db/httplib2-0.32.0-py3-none-any.whl", hash = "sha256:dc6705cacdf3fb0a2aba7629fa33c90fd93e30035db0c157325826be177e4816", size = 93148, upload-time = "2026-06-26T10:13:54.985Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4048,21 +4087,21 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "microsoft-kiota-abstractions"
|
||||
version = "1.9.9"
|
||||
version = "1.9.10"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "opentelemetry-api" },
|
||||
{ name = "opentelemetry-sdk" },
|
||||
{ name = "std-uritemplate" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/8f/94/37315b82a1bcc08145e5bc2af7396a4be8160ac138ec269611c3b9589b7a/microsoft_kiota_abstractions-1.9.9.tar.gz", hash = "sha256:5df9a8e0517a4568726c2cac6d9789284cc6ffa66043b68eba42ae55749fb861", size = 24468, upload-time = "2026-03-02T21:03:50.133Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/45/e1/39de28380fc0eddf12f66099469fb7561bc38f577ea06e3a074751ebbcd9/microsoft_kiota_abstractions-1.9.10.tar.gz", hash = "sha256:8eb62d64c35ad0eeb4e8bcdbb143c0b308dc4a494e757f8e44cb959d34f44ecf", size = 24473, upload-time = "2026-03-12T17:27:15.398Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/53/6a/7d5a1a8131f0eccc6b45839c091aa00ba29661854e7defaa7936cf342fa7/microsoft_kiota_abstractions-1.9.9-py3-none-any.whl", hash = "sha256:8d0a14eda42f3f0ccac2e9512227a338f69998dc9b782fd21cb8ca7c48302caa", size = 44453, upload-time = "2026-03-02T21:03:51.11Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4d/59/bf0cb26c80fbd3fa882df8474ad87e9dbd742656c376388c427c4e314171/microsoft_kiota_abstractions-1.9.10-py3-none-any.whl", hash = "sha256:cd169067ebe48e6feea1258630807034239e0c61c2abe5fd66896a58177e8f05", size = 44462, upload-time = "2026-03-12T17:27:16.532Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "microsoft-kiota-authentication-azure"
|
||||
version = "1.9.9"
|
||||
version = "1.9.10"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "aiohttp" },
|
||||
@@ -4071,14 +4110,14 @@ dependencies = [
|
||||
{ name = "opentelemetry-api" },
|
||||
{ name = "opentelemetry-sdk" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/ca/ce/5ae8b37ee4a50f0ed5e092c2d0105d60b592e6102a190959f76658a0994c/microsoft_kiota_authentication_azure-1.9.9.tar.gz", hash = "sha256:aca5e7dc8a0a28224f9025a479349ac2f9aaf166bfd6bc707f232658b45eec28", size = 5000, upload-time = "2026-03-02T21:04:02.355Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/d5/53/7760f979c141ec590f0c1cfcb92b3e410eb2909cc19feb42f3fce78db171/microsoft_kiota_authentication_azure-1.9.10.tar.gz", hash = "sha256:b9f10a9fa86e36114abfee448d2dab91a502d6a55d349a306e2e41a1218fe1ad", size = 4999, upload-time = "2026-03-12T17:27:26.323Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/98/de/dc504324b776d00a420886cc6f39e04be2cf48cab0e9b18f8450a5efcc29/microsoft_kiota_authentication_azure-1.9.9-py3-none-any.whl", hash = "sha256:73dc21a1a2861ea78a135327291db3322e2255542a18b311dd03fd908342e902", size = 6951, upload-time = "2026-03-02T21:04:03.18Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1e/4a/e7852f9358d897ada1eec4e825c815761befe36df4defa79f1ae6c7b588c/microsoft_kiota_authentication_azure-1.9.10-py3-none-any.whl", hash = "sha256:b5d98b0d17173c61c0c7ab4274ea4ca69253b3c13424137758034506694964e9", size = 6961, upload-time = "2026-03-12T17:27:27.238Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "microsoft-kiota-http"
|
||||
version = "1.9.9"
|
||||
version = "1.9.10"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "httpx", extra = ["http2"] },
|
||||
@@ -4086,57 +4125,57 @@ dependencies = [
|
||||
{ name = "opentelemetry-api" },
|
||||
{ name = "opentelemetry-sdk" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/5d/3f/fc18eb0d1d845daf6355fd54fd990af7f7e10043ef6a6da39b9e5981cbaf/microsoft_kiota_http-1.9.9.tar.gz", hash = "sha256:ae672b145df71b644f8da0951767a12a4ce47a40576d86eba19b7c22d9e160f9", size = 21493, upload-time = "2026-03-02T21:04:11.662Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/a7/e5/20972b620bd8cca086c284e97b285d437c108a23fee122ad7b92bd246c1a/microsoft_kiota_http-1.9.10.tar.gz", hash = "sha256:af1838d091f76426c974897357093ed977ce66f1d808cb161c190de873bb5833", size = 21493, upload-time = "2026-03-12T17:27:35.393Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/c4/6a/cc1b1055b4b6d4dfc1be7a71917c2f0ef19c070c6a18b16d3c1032d20925/microsoft_kiota_http-1.9.9-py3-none-any.whl", hash = "sha256:a5b1b217ac9afeb4054f12515417e3b1d2be12a9385a70a41d18d64379ea2e7e", size = 31945, upload-time = "2026-03-02T21:04:12.328Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ce/f4/78ce18330a626138b2ff6bb62574adac01e8b9ee87c1349ddfeb9cab0556/microsoft_kiota_http-1.9.10-py3-none-any.whl", hash = "sha256:6127032c8d94f8607e4d36d0822b88bc8689ab368b4c00d6c7beb7d2d0f2ab10", size = 31960, upload-time = "2026-03-12T17:27:36.1Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "microsoft-kiota-serialization-form"
|
||||
version = "1.9.9"
|
||||
version = "1.9.10"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "microsoft-kiota-abstractions" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/ee/b4/18e9fce60a30c8b6ea0a6278fb81cf352127340d48df2d7c52ff1b579488/microsoft_kiota_serialization_form-1.9.9.tar.gz", hash = "sha256:3cdc8b172baec5b5282af72f2ce02715edcd23252ce0b5af96075256edd75114", size = 9015, upload-time = "2026-03-02T21:04:20.39Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/56/90/7e1a090a2099acae1a1baa9a0762214b73b63d9268369b510994f75f54e4/microsoft_kiota_serialization_form-1.9.10.tar.gz", hash = "sha256:4c6655d8cd479d1ada63fdfe6a272e50d87d7c8369dbc8e13833ba4787fc798b", size = 9012, upload-time = "2026-03-12T17:27:44.214Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/0b/24/eb8436b882f1473bd0a868848d214df3df2d9b3db8e5422d111032f1114f/microsoft_kiota_serialization_form-1.9.9-py3-none-any.whl", hash = "sha256:1c426d4f0d463fc9215c41d7fa0f3dc5fe8d3c80573d555cf63ea67000148d84", size = 10718, upload-time = "2026-03-02T21:04:21.25Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d9/4c/5092fc896b34c21e8b9c03c63006b313a81e2377176a69c97aa6a9c8f5bb/microsoft_kiota_serialization_form-1.9.10-py3-none-any.whl", hash = "sha256:765d3f6408668f58bfdf892c32b45967c579d9131f3ba5a6b6868cb7ab956bfe", size = 10728, upload-time = "2026-03-12T17:27:45.103Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "microsoft-kiota-serialization-json"
|
||||
version = "1.9.9"
|
||||
version = "1.9.10"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "microsoft-kiota-abstractions" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/b8/2f/d36eba916c00136da122d1701acb862c5b1f2e22b6dc6fa4e0f4abda2786/microsoft_kiota_serialization_json-1.9.9.tar.gz", hash = "sha256:9b27479427f49bbac15ead8e8ff0176e47fcdf81153611acc408f5f399342079", size = 9545, upload-time = "2026-03-02T21:04:29.177Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/67/0e/55afd533a764ba77da988b7ca4242c84867a3a25f2ff0bf4c2b24b5e8fca/microsoft_kiota_serialization_json-1.9.10.tar.gz", hash = "sha256:6063028f30dd67afa2db20a72d9bde5e5d26d468f8bdedadd1445cf7c7630e17", size = 9746, upload-time = "2026-03-12T17:27:53.015Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/3f/7b/b3f606ef2dcbdebe12ae27004ed6e7542370cb2494265f11a8877a1de2d1/microsoft_kiota_serialization_json-1.9.9-py3-none-any.whl", hash = "sha256:bb80b93e81bab41dc142e9b254f79bf0b7b9fe49a796ca0c8e8691925bd3967f", size = 11210, upload-time = "2026-03-02T21:04:29.844Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2f/56/d14c0185c8092abde1a60ad2bdd4480bb2ddb551ce71c6de1e6133a4d8d1/microsoft_kiota_serialization_json-1.9.10-py3-none-any.whl", hash = "sha256:0545ae910160b19caaa8c30c90c7416e1966294fbd6cc5af01f0e116a18f223a", size = 11452, upload-time = "2026-03-12T17:27:53.909Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "microsoft-kiota-serialization-multipart"
|
||||
version = "1.9.9"
|
||||
version = "1.9.10"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "microsoft-kiota-abstractions" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/5f/44/24087f0fac7c5682c13c7fb61468a0c5a5185b9f243de3a99309aa6fcaa7/microsoft_kiota_serialization_multipart-1.9.9.tar.gz", hash = "sha256:f8730be6da5f6c63a6bf4ea310a9723b9998a47a04745887dc156d08f119a829", size = 5162, upload-time = "2026-03-02T21:04:48.1Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/24/34/eadc15c2a3131e2a76126f3112c32b73502cb5a335e2e40cac2877e5d843/microsoft_kiota_serialization_multipart-1.9.10.tar.gz", hash = "sha256:8f2da4f93e79b09f9738b6889685e47acfafcca870db94ab1d4cd233d69e4268", size = 5167, upload-time = "2026-03-12T17:28:18.507Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/61/db/6b988fdf771c3d07dff4a116176d575832daf2a43823444d145d71da5b61/microsoft_kiota_serialization_multipart-1.9.9-py3-none-any.whl", hash = "sha256:572e9cbafa2eb946452cdadfb019a4e9245768c0d61c3089d3436d4f5106c550", size = 6696, upload-time = "2026-03-02T21:04:48.98Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fa/40/345cbcee6c52b4261fedf4ae2ff8573aec47ce4ae2015ea8b57c75ef978b/microsoft_kiota_serialization_multipart-1.9.10-py3-none-any.whl", hash = "sha256:7cadc26483b567c738f926b044521569e0b797446053c9e8eab02269d4a81062", size = 6708, upload-time = "2026-03-12T17:28:19.397Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "microsoft-kiota-serialization-text"
|
||||
version = "1.9.9"
|
||||
version = "1.9.10"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "microsoft-kiota-abstractions" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/a3/3c/d244ad08e03003134871698aa54de8243bcc61c0faf3ab114293bb76d6ad/microsoft_kiota_serialization_text-1.9.9.tar.gz", hash = "sha256:18bc0764dda4078a4c953300253344e05d0cdb9c17136f1a2f695d438cedb402", size = 7325, upload-time = "2026-03-02T21:04:37.567Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/74/a6/28a4a8d5c01f08e363135fc9585cab3c02d1b1a69c3c16032e6abb35dfed/microsoft_kiota_serialization_text-1.9.10.tar.gz", hash = "sha256:cfc433c2a95ea3c3ec43c8b09002fbf65c998c5c0571205df161fe0e9d5d8de7", size = 7326, upload-time = "2026-03-12T17:28:01.621Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/37/f8/43f8d00fed6e090810d3ce0c05e06c23eaa5dee6e87ab1fb89d96ca9559f/microsoft_kiota_serialization_text-1.9.9-py3-none-any.whl", hash = "sha256:84418119d4929a76fde7f31e957e240e003bf145757838b9aa3a0f36dec1b789", size = 8885, upload-time = "2026-03-02T21:04:38.76Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/dd/bf/dd36e4a6d1cff3f2d30f03e2479cd38210e32d4715bb6a9f0e2737f13604/microsoft_kiota_serialization_text-1.9.10-py3-none-any.whl", hash = "sha256:742890cfd4450d12f58d42da7cfa474fe1ee5d6442e016bf70ab76e5c876c0ea", size = 8896, upload-time = "2026-03-12T17:28:02.328Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4164,7 +4203,7 @@ wheels = [
|
||||
|
||||
[package.optional-dependencies]
|
||||
broker = [
|
||||
{ name = "pymsalruntime", marker = "sys_platform == 'win32'" },
|
||||
{ name = "pymsalruntime" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4388,20 +4427,22 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "oci"
|
||||
version = "2.169.0"
|
||||
version = "2.183.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "certifi" },
|
||||
{ name = "circuitbreaker" },
|
||||
{ name = "crc32c" },
|
||||
{ name = "cryptography" },
|
||||
{ name = "pyjwt", extra = ["crypto"] },
|
||||
{ name = "pyopenssl" },
|
||||
{ name = "python-dateutil" },
|
||||
{ name = "pytz" },
|
||||
{ name = "urllib3" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/11/f4/3c2eddccc75dd06a692dbb3290f20f4bc733d99dc60de21f22d65efdeae4/oci-2.169.0.tar.gz", hash = "sha256:f3c5fff00b01783b5325ea7b13bf140053ec1e9f41da20bfb9c8a349ee7662fa", size = 16885837, upload-time = "2026-03-31T06:14:58.981Z" }
|
||||
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" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/e4/bf/19643bd939ab595193779ee25c2c12aef8e9a54e0a68de5ed79f209702e3/oci-2.169.0-py3-none-any.whl", hash = "sha256:c71bb5143f307791082b3e33cc1545c2490a518cfed85ab1948ef5107c36d30b", size = 34460447, upload-time = "2026-03-31T06:14:51.373Z" },
|
||||
{ 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" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4595,39 +4636,33 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "pillow"
|
||||
version = "12.2.0"
|
||||
version = "12.3.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/8c/21/c2bcdd5906101a30244eaffc1b6e6ce71a31bd0742a01eb89e660ebfac2d/pillow-12.2.0.tar.gz", hash = "sha256:a830b1a40919539d07806aa58e1b114df53ddd43213d9c8b75847eee6c0182b5", size = 46987819, upload-time = "2026-04-01T14:46:17.687Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/1c/3d/bb7fca845737cf9d7dbde16ed1843984665ff2e0a518f5db43e77ec540b9/pillow-12.3.0.tar.gz", hash = "sha256:3b8182a766685eaa002637e28b4ec8d6b18819a0c71f579bf0dbaa5830297cce", size = 47025035, upload-time = "2026-07-01T11:56:38.965Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/68/e1/748f5663efe6edcfc4e74b2b93edfb9b8b99b67f21a854c3ae416500a2d9/pillow-12.2.0-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:8be29e59487a79f173507c30ddf57e733a357f67881430449bb32614075a40ab", size = 5354347, upload-time = "2026-04-01T14:42:44.255Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/47/a1/d5ff69e747374c33a3b53b9f98cca7889fce1fd03d79cdc4e1bccc6c5a87/pillow-12.2.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:71cde9a1e1551df7d34a25462fc60325e8a11a82cc2e2f54578e5e9a1e153d65", size = 4695873, upload-time = "2026-04-01T14:42:46.452Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/df/21/e3fbdf54408a973c7f7f89a23b2cb97a7ef30c61ab4142af31eee6aebc88/pillow-12.2.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f490f9368b6fc026f021db16d7ec2fbf7d89e2edb42e8ec09d2c60505f5729c7", size = 6280168, upload-time = "2026-04-01T14:42:49.228Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d3/f1/00b7278c7dd52b17ad4329153748f87b6756ec195ff786c2bdf12518337d/pillow-12.2.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8bd7903a5f2a4545f6fd5935c90058b89d30045568985a71c79f5fd6edf9b91e", size = 8088188, upload-time = "2026-04-01T14:42:51.735Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ad/cf/220a5994ef1b10e70e85748b75649d77d506499352be135a4989c957b701/pillow-12.2.0-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3997232e10d2920a68d25191392e3a4487d8183039e1c74c2297f00ed1c50705", size = 6394401, upload-time = "2026-04-01T14:42:54.343Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e9/bd/e51a61b1054f09437acfbc2ff9106c30d1eb76bc1453d428399946781253/pillow-12.2.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e74473c875d78b8e9d5da2a70f7099549f9eb37ded4e2f6a463e60125bccd176", size = 7079655, upload-time = "2026-04-01T14:42:56.954Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6b/3d/45132c57d5fb4b5744567c3817026480ac7fc3ce5d4c47902bc0e7f6f853/pillow-12.2.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:56a3f9c60a13133a98ecff6197af34d7824de9b7b38c3654861a725c970c197b", size = 6503105, upload-time = "2026-04-01T14:42:59.847Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7d/2e/9df2fc1e82097b1df3dce58dc43286aa01068e918c07574711fcc53e6fb4/pillow-12.2.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:90e6f81de50ad6b534cab6e5aef77ff6e37722b2f5d908686f4a5c9eba17a909", size = 7203402, upload-time = "2026-04-01T14:43:02.664Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bd/2e/2941e42858ebb67e50ae741473de81c2984e6eff7b397017623c676e2e8d/pillow-12.2.0-cp311-cp311-win32.whl", hash = "sha256:8c984051042858021a54926eb597d6ee3012393ce9c181814115df4c60b9a808", size = 6378149, upload-time = "2026-04-01T14:43:05.274Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/69/42/836b6f3cd7f3e5fa10a1f1a5420447c17966044c8fbf589cc0452d5502db/pillow-12.2.0-cp311-cp311-win_amd64.whl", hash = "sha256:6e6b2a0c538fc200b38ff9eb6628228b77908c319a005815f2dde585a0664b60", size = 7082626, upload-time = "2026-04-01T14:43:08.557Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c2/88/549194b5d6f1f494b485e493edc6693c0a16f4ada488e5bd974ed1f42fad/pillow-12.2.0-cp311-cp311-win_arm64.whl", hash = "sha256:9a8a34cc89c67a65ea7437ce257cea81a9dad65b29805f3ecee8c8fe8ff25ffe", size = 2463531, upload-time = "2026-04-01T14:43:10.743Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/58/be/7482c8a5ebebbc6470b3eb791812fff7d5e0216c2be3827b30b8bb6603ed/pillow-12.2.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:2d192a155bbcec180f8564f693e6fd9bccff5a7af9b32e2e4bf8c9c69dbad6b5", size = 5308279, upload-time = "2026-04-01T14:43:13.246Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d8/95/0a351b9289c2b5cbde0bacd4a83ebc44023e835490a727b2a3bd60ddc0f4/pillow-12.2.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f3f40b3c5a968281fd507d519e444c35f0ff171237f4fdde090dd60699458421", size = 4695490, upload-time = "2026-04-01T14:43:15.584Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/de/af/4e8e6869cbed569d43c416fad3dc4ecb944cb5d9492defaed89ddd6fe871/pillow-12.2.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:03e7e372d5240cc23e9f07deca4d775c0817bffc641b01e9c3af208dbd300987", size = 6284462, upload-time = "2026-04-01T14:43:18.268Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e9/9e/c05e19657fd57841e476be1ab46c4d501bffbadbafdc31a6d665f8b737b6/pillow-12.2.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b86024e52a1b269467a802258c25521e6d742349d760728092e1bc2d135b4d76", size = 8094744, upload-time = "2026-04-01T14:43:20.716Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2b/54/1789c455ed10176066b6e7e6da1b01e50e36f94ba584dc68d9eebfe9156d/pillow-12.2.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7371b48c4fa448d20d2714c9a1f775a81155050d383333e0a6c15b1123dda005", size = 6398371, upload-time = "2026-04-01T14:43:23.443Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/43/e3/fdc657359e919462369869f1c9f0e973f353f9a9ee295a39b1fea8ee1a77/pillow-12.2.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:62f5409336adb0663b7caa0da5c7d9e7bdbaae9ce761d34669420c2a801b2780", size = 7087215, upload-time = "2026-04-01T14:43:26.758Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8b/f8/2f6825e441d5b1959d2ca5adec984210f1ec086435b0ed5f52c19b3b8a6e/pillow-12.2.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:01afa7cf67f74f09523699b4e88c73fb55c13346d212a59a2db1f86b0a63e8c5", size = 6509783, upload-time = "2026-04-01T14:43:29.56Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/67/f9/029a27095ad20f854f9dba026b3ea6428548316e057e6fc3545409e86651/pillow-12.2.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fc3d34d4a8fbec3e88a79b92e5465e0f9b842b628675850d860b8bd300b159f5", size = 7212112, upload-time = "2026-04-01T14:43:32.091Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/be/42/025cfe05d1be22dbfdb4f264fe9de1ccda83f66e4fc3aac94748e784af04/pillow-12.2.0-cp312-cp312-win32.whl", hash = "sha256:58f62cc0f00fd29e64b29f4fd923ffdb3859c9f9e6105bfc37ba1d08994e8940", size = 6378489, upload-time = "2026-04-01T14:43:34.601Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5d/7b/25a221d2c761c6a8ae21bfa3874988ff2583e19cf8a27bf2fee358df7942/pillow-12.2.0-cp312-cp312-win_amd64.whl", hash = "sha256:7f84204dee22a783350679a0333981df803dac21a0190d706a50475e361c93f5", size = 7084129, upload-time = "2026-04-01T14:43:37.213Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/10/e1/542a474affab20fd4a0f1836cb234e8493519da6b76899e30bcc5d990b8b/pillow-12.2.0-cp312-cp312-win_arm64.whl", hash = "sha256:af73337013e0b3b46f175e79492d96845b16126ddf79c438d7ea7ff27783a414", size = 2463612, upload-time = "2026-04-01T14:43:39.421Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4e/b7/2437044fb910f499610356d1352e3423753c98e34f915252aafecc64889f/pillow-12.2.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:0538bd5e05efec03ae613fd89c4ce0368ecd2ba239cc25b9f9be7ed426b0af1f", size = 5273969, upload-time = "2026-04-01T14:45:55.538Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f6/f4/8316e31de11b780f4ac08ef3654a75555e624a98db1056ecb2122d008d5a/pillow-12.2.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:394167b21da716608eac917c60aa9b969421b5dcbbe02ae7f013e7b85811c69d", size = 4659674, upload-time = "2026-04-01T14:45:58.093Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d4/37/664fca7201f8bb2aa1d20e2c3d5564a62e6ae5111741966c8319ca802361/pillow-12.2.0-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5d04bfa02cc2d23b497d1e90a0f927070043f6cbf303e738300532379a4b4e0f", size = 5288479, upload-time = "2026-04-01T14:46:01.141Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/49/62/5b0ed78fce87346be7a5cfcfaaad91f6a1f98c26f86bdbafa2066c647ef6/pillow-12.2.0-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0c838a5125cee37e68edec915651521191cef1e6aa336b855f495766e77a366e", size = 7032230, upload-time = "2026-04-01T14:46:03.874Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c3/28/ec0fc38107fc32536908034e990c47914c57cd7c5a3ece4d8d8f7ffd7e27/pillow-12.2.0-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4a6c9fa44005fa37a91ebfc95d081e8079757d2e904b27103f4f5fa6f0bf78c0", size = 5355404, upload-time = "2026-04-01T14:46:06.33Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5e/8b/51b0eddcfa2180d60e41f06bd6d0a62202b20b59c68f5a132e615b75aecf/pillow-12.2.0-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:25373b66e0dd5905ed63fa3cae13c82fbddf3079f2c8bf15c6fb6a35586324c1", size = 6002215, upload-time = "2026-04-01T14:46:08.83Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bc/60/5382c03e1970de634027cee8e1b7d39776b778b81812aaf45b694dfe9e28/pillow-12.2.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:bfa9c230d2fe991bed5318a5f119bd6780cda2915cca595393649fc118ab895e", size = 7080946, upload-time = "2026-04-01T14:46:11.734Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fb/c8/0a78b0e02d7ac54bc03e5321c9220da52f0c2ea83b21f7c40e7f3169c502/pillow-12.3.0-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:00808c5e14ef63ac5161091d242999076604ff74b883423a11e5d7bbb38bf756", size = 5392415, upload-time = "2026-07-01T11:53:47.162Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b2/5b/a02d30018abd97ced9f5a6c63d28597694a00d066516b9c1c6de45859fc9/pillow-12.3.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:37d6d0a00072fd2948eb22bce7e1475f34569d90c87c59f7a2ec59541b77f7a6", size = 4785266, upload-time = "2026-07-01T11:53:49.079Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c8/98/766667a4be768150a202836acd9fad19c06824ca86c4286d3cf6b274964e/pillow-12.3.0-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bcb46e2f9feff8d06323983bd83ed00c201fdcab3d74973e7072a889b3979fcd", size = 6263814, upload-time = "2026-07-01T11:53:51.32Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3b/2d/ede717bc1144f63886c21fd349bb95860b0d1a21149ff16f2bb362b612b6/pillow-12.3.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:23d27a3e0307ec2244cc51e7287b919aa68d097504ebe19df4e76a98a3eea5bd", size = 6934408, upload-time = "2026-07-01T11:53:53.487Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a3/48/9c58b685e69d49c31af6c8eb9012055fab7e665785165c84796e2c73ce72/pillow-12.3.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:4f883547d4b7f0495ebe7056b0cc2aea76094e7a4abc8e933540f3271df27d9c", size = 6337160, upload-time = "2026-07-01T11:53:55.457Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ff/fa/dc2a5c0ba6df93f67c31d34b808b7ce440b40cdbf96f0b81cde1d1e6fa93/pillow-12.3.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:236ff70b9312fb68943c703aa842ca6a758abfa45ac187a5e7c1452e96ef72b5", size = 7045172, upload-time = "2026-07-01T11:53:57.736Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/86/a5/444817a4d4c4c2417df00513086ca196f388d8f9ef40c2e4ccd1ad1af54b/pillow-12.3.0-cp311-cp311-win32.whl", hash = "sha256:10e41f0fbf1eec8cfd234b8fe17a4caac7c9d0db4c204d3c173a8f9f6ef3232b", size = 6472232, upload-time = "2026-07-01T11:53:59.767Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/63/c6/4bad1b18d132a50b27e1365e1ab163616f7a5bb56d330f66f9d1d9d4f9d4/pillow-12.3.0-cp311-cp311-win_amd64.whl", hash = "sha256:8e95e1385e4998ae9694eeaa4730ba5457ff61185b3a55e2e7bea0880aef452a", size = 7233653, upload-time = "2026-07-01T11:54:02.066Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fd/16/00f91ab7760dc842f5aad55217e80fc4a7067a0604535249bc8a2d6d9870/pillow-12.3.0-cp311-cp311-win_arm64.whl", hash = "sha256:ebaea975e03d3141d9d3a507df75c9b3ec90fa9d2ffd07567b3a978d9d790b26", size = 2568195, upload-time = "2026-07-01T11:54:04.622Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/37/bf/fb3ebff8ddcb76aac5a01389251bbbb9519922a9b520d8247c1ca864a25d/pillow-12.3.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:ba09209fbe443b4acccebe845d8a138b89a8f4fbaeedd44953490b5315d5e965", size = 5345969, upload-time = "2026-07-01T11:54:06.397Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d8/66/9a386a92561f402389a4fc70c18838bf6d35eb5eb5c6850b4b2dc64f5048/pillow-12.3.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ffd0c5368496f41b0944be820fcb7a838aa6e623d250b01acf2643939c3f99d7", size = 4780323, upload-time = "2026-07-01T11:54:09.351Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/25/27/ac8f99618ffd3dde21db0f4d4b1d2ab00c0880595bfd17df103f7f39fd0c/pillow-12.3.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d9c7f76c0673154f044e9d78c8655fb4213f6ca31a836df48b40fe5d187717b9", size = 6266838, upload-time = "2026-07-01T11:54:11.71Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/84/21/a35af28dcc61f37ed850a2d64c65c701321dfbf25085e469d5559360cbbf/pillow-12.3.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:78cb2c6865a35ab8ff8b75fd122f6033b92a62c82801110e48ddd6c936a45d91", size = 6940830, upload-time = "2026-07-01T11:54:13.732Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/eb/51/8b08617af3ad95e33ce6d7dd2c99ed6c8298f7fb131636303956be022e25/pillow-12.3.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e491916b378fba47242221bb9ead245211b70d504f495d105d17b14a24b4907c", size = 6344383, upload-time = "2026-07-01T11:54:15.756Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1d/72/cf78ac9780bb93c28328f408973845a309d4d145041665f734572ced1b52/pillow-12.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:0dd2064cbc55aaec028ef5fbb60fa47bb6c3e7918e07ff17935284b227a9d2df", size = 7052934, upload-time = "2026-07-01T11:54:17.721Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/20/20/25e0f4dc178a6bc0696793720055519a0de89e7661dae886992decbd2f81/pillow-12.3.0-cp312-cp312-win32.whl", hash = "sha256:dbce0b29841537a2fa4a214c2bbf14de3587c9680caa9b4e217568472490b28f", size = 6472684, upload-time = "2026-07-01T11:54:19.839Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/45/89/da2f7971a317f83d807fdd4065c0af40208e59e692cc43d315a71a0e96d1/pillow-12.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:a2b55dd6b2a4c4b7d87ffa56bdb33fdc5fdb9a462173861a7bc097f17d91cb09", size = 7227137, upload-time = "2026-07-01T11:54:22.025Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/de/47/4845a0a6c0dbf1db8456bd9fc791f13c5ced7ced20606d08a0aacfd25b49/pillow-12.3.0-cp312-cp312-win_arm64.whl", hash = "sha256:331b624368d4f1d069149002f25f44bc61c8919ce8ddb3c45bdad8f6e2d89510", size = 2568267, upload-time = "2026-07-01T11:54:24.051Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/75/18/2e8b40223153ccbc60df07f9e8928dc0c76202aa4e55ae9f53962b6510d6/pillow-12.3.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:b3c777e849237620b022f7f297dd67705f9f5cf1685f09f02e46f93e92725468", size = 5302510, upload-time = "2026-07-01T11:56:25.736Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/46/3e/51fabf59d5ab801ceab709453d3ab6b180083496579549de4c45ced6528a/pillow-12.3.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:b343699e8308bdc51978310e1c959c584e7869cc8c40780058c87da7781a1e94", size = 4736058, upload-time = "2026-07-01T11:56:28.041Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bf/20/22fe9384b7949e25fb1293bcfc84fb82590ff4ea6b37c95b24d26d793d86/pillow-12.3.0-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fbd139c8447d25dd750ab79ee274cc5e1fe80fc56340ab10b18a195e1b6eca3e", size = 5237776, upload-time = "2026-07-01T11:56:30.263Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/08/14/f6ba68107680ffa74b39985f3f30884e41318fbc4250caa423c79b4788bb/pillow-12.3.0-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e7e480451b9fa137494bccd3a7d69adbe8ac65a87d97be61e11f1b1050a5bac3", size = 5860358, upload-time = "2026-07-01T11:56:32.68Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/36/54/0169bc772ec491108b62f644f8ecf1fe5d8ae5ebafde2ee2142210166903/pillow-12.3.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:04f01d28a6aaff387bf842a13be313df23ba0597a44f1a976c9feb3c6ff4711a", size = 7231786, upload-time = "2026-07-01T11:56:35.046Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4801,8 +4836,8 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "prowler"
|
||||
version = "5.37.0"
|
||||
source = { git = "https://github.com/prowler-cloud/prowler.git?rev=v5.37#5cd673787bb98f28109f671898295a77fd755622" }
|
||||
version = "5.38.0"
|
||||
source = { git = "https://github.com/prowler-cloud/prowler.git?rev=master#b3d174d0c1eb202ed7cb9a9daf0500683f4443be" }
|
||||
dependencies = [
|
||||
{ name = "alibabacloud-actiontrail20200706" },
|
||||
{ name = "alibabacloud-credentials" },
|
||||
@@ -4901,7 +4936,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "prowler-api"
|
||||
version = "1.38.0"
|
||||
version = "1.39.0"
|
||||
source = { virtual = "." }
|
||||
dependencies = [
|
||||
{ name = "cartography" },
|
||||
@@ -5001,7 +5036,7 @@ requires-dist = [
|
||||
{ name = "matplotlib", specifier = "==3.10.8" },
|
||||
{ name = "neo4j", specifier = "==6.1.0" },
|
||||
{ name = "openai", specifier = "==1.109.1" },
|
||||
{ name = "prowler", git = "https://github.com/prowler-cloud/prowler.git?rev=v5.37" },
|
||||
{ name = "prowler", git = "https://github.com/prowler-cloud/prowler.git?rev=master" },
|
||||
{ name = "psycopg2-binary", specifier = "==2.9.9" },
|
||||
{ name = "pytest-celery", extras = ["redis"], specifier = "==1.3.0" },
|
||||
{ name = "reportlab", specifier = "==4.4.10" },
|
||||
@@ -5117,25 +5152,24 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "py-ocsf-models"
|
||||
version = "0.8.1"
|
||||
version = "0.10.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "cryptography" },
|
||||
{ name = "email-validator" },
|
||||
{ name = "pydantic" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/f5/70/61e2f9ce3d7e83aa5339ed6ae17e473c15c7a36f161c6dbea0e939e3af0c/py_ocsf_models-0.8.1.tar.gz", hash = "sha256:c9045237857f951e073c9f9d1f57954c90d86875b469260725292d47f7a7d73c", size = 36540, upload-time = "2026-02-12T16:50:15.233Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/70/d6/f0787cbe953e3cf6ef4430f3cc7d66cbbaabe4b20cb82cc27cc2d21e622a/py_ocsf_models-0.10.0.tar.gz", hash = "sha256:29abaa5a3d4ebba0e2a21757508a4848fa5e1d57da233af57e580f97f0223c59", size = 36498, upload-time = "2026-07-13T07:05:44.448Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/f9/18/63790884bf33f820e2c60f8d5038b5d6de967a03343ddf237c054e1d6d08/py_ocsf_models-0.8.1-py3-none-any.whl", hash = "sha256:061eb446c4171534c09a8b37f5a9d2a2fe9f87c5db32edbd1182446bc5fd097e", size = 64354, upload-time = "2026-02-12T16:50:12.983Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/75/56/eca45ec87a02f930cc7eaa7cb36660f69fb00c3d77bb4a84bb92d6c94c25/py_ocsf_models-0.10.0-py3-none-any.whl", hash = "sha256:a9d1e245b1c9fba1d2cb8c042253ef1b83a2dbfec30ed69975bbce599b4510bb", size = 64334, upload-time = "2026-07-13T07:05:42.93Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pyasn1"
|
||||
version = "0.6.3"
|
||||
version = "0.6.4"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/5c/5f/6583902b6f79b399c9c40674ac384fd9cd77805f9e6205075f828ef11fb2/pyasn1-0.6.3.tar.gz", hash = "sha256:697a8ecd6d98891189184ca1fa05d1bb00e2f84b5977c481452050549c8a72cf", size = 148685, upload-time = "2026-03-17T01:06:53.382Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/a4/9a/23310166d960def5897e91fe20e5b724601b02a22e84ba1f94232c0b7f67/pyasn1-0.6.4.tar.gz", hash = "sha256:9c447d8431c947fe4c8febc4ed9e760bc29011a5b01e5c74b67025bd9fb8ce81", size = 151262, upload-time = "2026-07-09T01:12:33.988Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/5d/a0/7d793dce3fa811fe047d6ae2431c672364b462850c6235ae306c0efd025f/pyasn1-0.6.3-py3-none-any.whl", hash = "sha256:a80184d120f0864a52a073acc6fc642847d0be408e7c7252f31390c0f4eadcde", size = 83997, upload-time = "2026-03-17T01:06:52.036Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9a/3b/6163796d69c3977d1e4287bea4a6979161cbbdd170ebb430511e8e1999ce/pyasn1-0.6.4-py3-none-any.whl", hash = "sha256:deda9277cfd454080ec40b207fb6df82206a3a2688735233cdcd8d3d565f088b", size = 84410, upload-time = "2026-07-09T01:12:32.92Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -5393,15 +5427,15 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "pyopenssl"
|
||||
version = "26.0.0"
|
||||
version = "26.2.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "cryptography" },
|
||||
{ name = "typing-extensions" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/8e/11/a62e1d33b373da2b2c2cd9eb508147871c80f12b1cacde3c5d314922afdd/pyopenssl-26.0.0.tar.gz", hash = "sha256:f293934e52936f2e3413b89c6ce36df66a0b34ae1ea3a053b8c5020ff2f513fc", size = 185534, upload-time = "2026-03-15T14:28:26.353Z" }
|
||||
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" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/fb/7d/d4f7d908fa8415571771b30669251d57c3cf313b36a856e6d7548ae01619/pyopenssl-26.0.0-py3-none-any.whl", hash = "sha256:df94d28498848b98cc1c0ffb8ef1e71e40210d3b0a8064c9d29571ed2904bf81", size = 57969, upload-time = "2026-03-15T14:28:24.864Z" },
|
||||
{ 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" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -6416,16 +6450,16 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "workos"
|
||||
version = "6.0.8"
|
||||
version = "8.3.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "cryptography" },
|
||||
{ name = "httpx" },
|
||||
{ name = "pyjwt", extra = ["crypto"] },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/ca/0d/0a7f78912657f99412c788932ea1f3f4089916e77bdef7d2463842febe08/workos-6.0.8.tar.gz", hash = "sha256:43aa3f1992a0a4ca8933d9b6e5ada846dd3b1fe0ee10e64c876ee2000fc6090d", size = 178137, upload-time = "2026-04-24T18:48:03.203Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/cd/f6/bb27fe77e70b5e2c5da72500ca0ece8b0e8318010fec92c31d68483314e3/workos-8.3.0.tar.gz", hash = "sha256:07b66c2fb287adb593e4d77a2e6cb05b48bd8ff0b2722f343d18eeb5e14f7472", size = 201587, upload-time = "2026-06-30T15:19:22.834Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/b2/3f/3d96da80d650b2f97d58af626053354584f619dbb769051e118bd9cd1ca5/workos-6.0.8-py3-none-any.whl", hash = "sha256:a00dd4930333aded2babbba824f8032eea05c5ca8c44d04a3fa068cf6be6e21a", size = 524505, upload-time = "2026-04-24T18:48:01.389Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/49/ed/7e6fe07c5bc0222fd92c1cf1f3c4c24293e4e5fc7bb5d7df90e4ee61c17f/workos-8.3.0-py3-none-any.whl", hash = "sha256:d0fa842b93bfc5fb33bf49e69cf8c379936cf54b87c6e2f50bcc6dd2e84f8fe4", size = 592275, upload-time = "2026-06-30T15:19:21.333Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
@@ -6,12 +6,19 @@ component_management:
|
||||
- component_id: "api"
|
||||
paths:
|
||||
- "api/**"
|
||||
- component_id: "mcp_server"
|
||||
paths:
|
||||
- "mcp_server/**"
|
||||
|
||||
flags:
|
||||
api:
|
||||
paths:
|
||||
- "api/**"
|
||||
carryforward: true
|
||||
mcp:
|
||||
paths:
|
||||
- "mcp_server/**"
|
||||
carryforward: true
|
||||
|
||||
comment:
|
||||
layout: "header, diff, flags, components"
|
||||
|
||||
@@ -18,15 +18,12 @@ spec:
|
||||
triggers:
|
||||
- type: {{ .Values.worker.keda.triggerType }}
|
||||
metadata:
|
||||
userName: "postgres"
|
||||
passwordFromEnv: POSTGRES_ADMIN_PASSWORD
|
||||
host: {{ .Release.Name }}-postgresql
|
||||
port: {{ .Values.postgresql.port | quote }}
|
||||
dbName: {{ .Values.postgresql.auth.database | quote }}
|
||||
sslmode: disable
|
||||
# Query for KEDA to count the number of scans that are in executing, available, or scheduled states,
|
||||
# where the scheduled time is within the last 2 hours and is before NOW(). Used for scaling workers.
|
||||
query: >-
|
||||
SELECT COUNT(*) FROM scans WHERE ((state='executing' OR state='available' OR state='scheduled') and scheduled_at < NOW() and scheduled_at > NOW() - INTERVAL '2 hours')
|
||||
targetQueryValue: "1"
|
||||
userName: {{ .Values.worker.keda.postgresql.userName | quote }}
|
||||
passwordFromEnv: {{ .Values.worker.keda.postgresql.passwordFromEnv | quote }}
|
||||
host: {{ .Values.worker.keda.postgresql.host | default (printf "%s-postgresql.%s.svc.cluster.local" .Release.Name .Release.Namespace) | quote }}
|
||||
port: {{ .Values.worker.keda.postgresql.port | quote }}
|
||||
dbName: {{ .Values.worker.keda.postgresql.database | default .Values.postgresql.auth.database | quote }}
|
||||
sslmode: {{ .Values.worker.keda.postgresql.sslmode | quote }}
|
||||
query: {{ .Values.worker.keda.query | quote }}
|
||||
targetQueryValue: {{ .Values.worker.keda.targetQueryValue | quote }}
|
||||
{{- end }}
|
||||
|
||||
@@ -427,10 +427,61 @@ worker:
|
||||
pollingInterval: 30
|
||||
# -- The cooldown period in seconds for scaling
|
||||
cooldownPeriod: 120
|
||||
# -- The trigger type for scaling (cpu or memory)
|
||||
# -- The KEDA scaler type. Only `postgresql` is supported by the default query below.
|
||||
triggerType: "postgresql"
|
||||
# -- The target utilization percentage for the worker pods
|
||||
value: "50"
|
||||
# PostgreSQL connection used by the scaler query. The KEDA operator opens this
|
||||
# connection from its own namespace, so `host` must resolve from there. The
|
||||
# defaults target the bundled postgresql subchart; set them explicitly when
|
||||
# using an external database (postgresql.enabled: false).
|
||||
postgresql:
|
||||
# -- Scaler database host. Defaults to the bundled "<release>-postgresql.<namespace>.svc.cluster.local" service.
|
||||
host: ""
|
||||
# -- Scaler database port.
|
||||
port: "5432"
|
||||
# -- Scaler database name. Defaults to `postgresql.auth.database`.
|
||||
database: ""
|
||||
# -- User the scaler authenticates as.
|
||||
userName: "postgres"
|
||||
# -- Name of an env var on the worker container holding the password.
|
||||
passwordFromEnv: "POSTGRES_ADMIN_PASSWORD"
|
||||
# -- sslmode for the scaler connection.
|
||||
sslmode: "disable"
|
||||
# -- The scaler divides the query result by this value to get the desired replica count.
|
||||
targetQueryValue: "1"
|
||||
# -- Query the scaler runs to measure pending work. It replaces the previous
|
||||
# 2-hour scheduled-only window, which missed manual scans, older backlogs and
|
||||
# in-progress scans. Override to tune scaling for your workload.
|
||||
#
|
||||
# The default sums three signals:
|
||||
# 1. Scans executing or available, bounded to rows updated in the last 24h so
|
||||
# orphaned rows do not pin the worker up, plus scheduled scans that are due
|
||||
# (no lower bound, so an overdue backlog still scales up).
|
||||
# 2. Scan tasks published in the last 48h that no worker has finished. A PENDING
|
||||
# TaskResult is written at publish time (before_task_publish in api/signals.py),
|
||||
# so Beat's daily publishes are visible even with zero workers. Signal 1 alone
|
||||
# deadlocks with minReplicas 0: every scan row after the first is created by
|
||||
# the worker, so once the initial row ages out of the 24h bound there is
|
||||
# nothing to count and nothing to create more.
|
||||
# 3. Non-scan tasks pending in the last hour. Provider connection checks,
|
||||
# deletions, reports and backfills never touch the scans table, so without
|
||||
# this they are never picked up while the worker is scaled to zero.
|
||||
# This includes reconcile-orphan-tasks, a Beat watchdog that runs every two
|
||||
# minutes, so with minReplicas 0 the worker is woken about that often. Add
|
||||
# it to the excluded task names below, or raise cooldownPeriod, if you would
|
||||
# rather trade watchdog latency for longer idle periods.
|
||||
query: >-
|
||||
SELECT
|
||||
(SELECT COUNT(*) FROM scans
|
||||
WHERE (state IN ('executing', 'available') AND updated_at > NOW() - INTERVAL '24 hours')
|
||||
OR (state = 'scheduled' AND scheduled_at < NOW()))
|
||||
+ (SELECT COUNT(*) FROM django_celery_results_taskresult
|
||||
WHERE task_name IN ('scan-perform', 'scan-perform-scheduled')
|
||||
AND status IN ('PENDING', 'RECEIVED', 'STARTED')
|
||||
AND date_created > NOW() - INTERVAL '48 hours')
|
||||
+ (SELECT COUNT(*) FROM django_celery_results_taskresult
|
||||
WHERE task_name NOT IN ('scan-perform', 'scan-perform-scheduled')
|
||||
AND status IN ('PENDING', 'RECEIVED', 'STARTED')
|
||||
AND date_created > NOW() - INTERVAL '1 hour')
|
||||
|
||||
worker_beat:
|
||||
# This will set the replicaset count more information can be found here: https://kubernetes.io/docs/concepts/workloads/controllers/replicaset/
|
||||
|
||||
@@ -64,7 +64,7 @@ services:
|
||||
condition: service_healthy
|
||||
|
||||
postgres:
|
||||
image: postgres:16.3-alpine3.20@sha256:36ed71227ae36305d26382657c0b96cbaf298427b3f1eaeb10d77a6dea3eec41
|
||||
image: postgres:16-alpine@sha256:57c72fd2a128e416c7fcc499958864df5301e940bca0a56f58fddf30ffc07777
|
||||
hostname: "postgres-db"
|
||||
volumes:
|
||||
- ./_data/postgres:/var/lib/postgresql/data
|
||||
@@ -88,7 +88,7 @@ services:
|
||||
retries: 5
|
||||
|
||||
valkey:
|
||||
image: valkey/valkey:7-alpine3.19@sha256:4054fe7fc607b9326ac7c4691ed26e9670d2ff17a9fb28c2577adecf928acbcc
|
||||
image: valkey/valkey:8-alpine@sha256:a038175878d66b9d274fbf8be73c0305e93798b83917647f167e18cef3c71eec
|
||||
hostname: "valkey"
|
||||
volumes:
|
||||
- ./_data/valkey:/data
|
||||
@@ -104,7 +104,7 @@ services:
|
||||
retries: 3
|
||||
|
||||
neo4j:
|
||||
image: graphstack/dozerdb:5.26.3.0@sha256:a77526ea3918fdc46d1fff70c4aea7d71d3874a26ecec059179d6775845b1247
|
||||
image: graphstack/dozerdb:5.26.27.0@sha256:9b54d6b3a98a76c00bd23e8e78d8c82081ff168162aebd47b25c234e092cb0a0
|
||||
hostname: "neo4j"
|
||||
volumes:
|
||||
- ./_data/neo4j:/data
|
||||
|
||||
+3
-3
@@ -60,7 +60,7 @@ services:
|
||||
start_period: 60s
|
||||
|
||||
postgres:
|
||||
image: postgres:16.3-alpine3.20@sha256:36ed71227ae36305d26382657c0b96cbaf298427b3f1eaeb10d77a6dea3eec41
|
||||
image: postgres:16-alpine@sha256:57c72fd2a128e416c7fcc499958864df5301e940bca0a56f58fddf30ffc07777
|
||||
hostname: "postgres-db"
|
||||
volumes:
|
||||
- ./_data/postgres:/var/lib/postgresql/data
|
||||
@@ -80,7 +80,7 @@ services:
|
||||
retries: 5
|
||||
|
||||
valkey:
|
||||
image: valkey/valkey:7-alpine3.19@sha256:4054fe7fc607b9326ac7c4691ed26e9670d2ff17a9fb28c2577adecf928acbcc
|
||||
image: valkey/valkey:8-alpine@sha256:a038175878d66b9d274fbf8be73c0305e93798b83917647f167e18cef3c71eec
|
||||
hostname: "valkey"
|
||||
volumes:
|
||||
- ./_data/valkey:/data
|
||||
@@ -96,7 +96,7 @@ services:
|
||||
retries: 3
|
||||
|
||||
neo4j:
|
||||
image: graphstack/dozerdb:5.26.3.0@sha256:a77526ea3918fdc46d1fff70c4aea7d71d3874a26ecec059179d6775845b1247
|
||||
image: graphstack/dozerdb:5.26.27.0@sha256:9b54d6b3a98a76c00bd23e8e78d8c82081ff168162aebd47b25c234e092cb0a0
|
||||
hostname: "neo4j"
|
||||
volumes:
|
||||
- ./_data/neo4j:/data
|
||||
|
||||
@@ -4,6 +4,157 @@ description: "New features and improvements in each Prowler release"
|
||||
rss: true
|
||||
---
|
||||
|
||||
<Update label="v5.37.0" description="August 3, 2026">
|
||||
### 💬 Lighthouse AI — Context-Aware Chat and a Bigger Toolbox
|
||||
|
||||
<Note>
|
||||
This feature is available exclusively in **Prowler Cloud** and **Prowler Private Cloud** with a [subscription](https://prowler.com/pricing).
|
||||
</Note>
|
||||
|
||||
Lighthouse AI is now aware of your working context when in Prowler Cloud. Messages carry page-aware context — the page you are on, the finding or resource open in the side panel, and its metadata — so "explain this" just works, and each page offers concise contextual suggestions to start from.
|
||||
|
||||

|
||||
|
||||
Lighthouse also gained access to every tool family the Prowler MCP server advertises: scan configurations, scan scheduling, finding triage, alert rules and recipients, integrations, users, and roles. Every action remains gated by RBAC: Lighthouse AI can only do what the user asking could do themselves.
|
||||
|
||||
Read more in the [Lighthouse AI documentation](/getting-started/products/prowler-cloud-lighthouse).
|
||||
|
||||
### 🔌 Prowler MCP — Integrations, Users, and Roles
|
||||
|
||||
Prowler MCP gained three tool families, available on both the Cloud and the self-hosted Local MCP Server:
|
||||
|
||||
- **[Integrations](/getting-started/basic-usage/prowler-mcp-tools#integrations-management)** — manage where Prowler sends its results, with the full lifecycle for Amazon S3, AWS Security Hub, and Jira: create them, update credentials, configuration and attached providers, re-check connections, and delete them — plus turning findings into Jira work items directly from a conversation.
|
||||
- **[Users](/getting-started/basic-usage/prowler-mcp-tools#user-management)** — read-only tools to list the tenant users with their emails and identify the authenticated user.
|
||||
- **[Roles](/getting-started/basic-usage/prowler-mcp-tools#role-management)** — browse the RBAC roles defined in the tenant, inspect the capabilities each one grants, and set the role a user holds.
|
||||
|
||||
### ☁️ Prowler MCP — Cloud-Only Tools
|
||||
|
||||
<Note>
|
||||
This feature is available exclusively in **Prowler Cloud** and **Prowler Private Cloud** with a [subscription](https://prowler.com/pricing). These tools are exposed only by the Cloud MCP Server at `https://mcp.prowler.com/mcp`; the self-hosted Local MCP Server **does not** include them.
|
||||
</Note>
|
||||
|
||||
A new `prowler_cloud_*` namespace adds 32 tools so your AI assistant can run Prowler Cloud workflows end to end instead of only reading from them:
|
||||
|
||||
- **[Alerts](/getting-started/basic-usage/prowler-mcp-tools#alerts)** — create and manage alert rules and email recipients, and browse the fired-alert history. Rule conditions can be dry-run before saving, so you can see what a rule would match without persisting anything.
|
||||
- **[Findings Triage](/getting-started/basic-usage/prowler-mcp-tools#findings-triage)** — set a finding's triage status and attach notes documenting the decision. Unlike muting, the finding stays visible.
|
||||
- **[Scan Scheduling](/getting-started/basic-usage/prowler-mcp-tools#scan-scheduling)** — configure daily, interval, weekly, or monthly recurring scans, one provider at a time or applied across many at once.
|
||||
- **[Scan Configurations](/getting-started/basic-usage/prowler-mcp-tools#scan-configurations)** — build reusable check and compliance selections and attach them to providers.
|
||||
|
||||
Read more in the [Prowler MCP tools reference](/getting-started/basic-usage/prowler-mcp-tools#prowler-cloud-tools).
|
||||
|
||||
### 🧭 Compliance — Grouped by provider of the same type
|
||||
|
||||
<Note>
|
||||
This feature is available exclusively in **Prowler Cloud** and **Prowler Private Cloud** with a [subscription](https://prowler.com/pricing).
|
||||
</Note>
|
||||
|
||||
One framework, every provider, a single answer. Building on the cross-provider-type roll-up, the Compliance section now groups compliance for all providers of the same type: a **single-provider framework** — CIS AWS, CIS GCP, ENS for Azure — is aggregated across the latest completed scan of every provider of that type. Each framework card rolls up into a consolidated posture with a per-provider breakdown, a findings drill-down, and a combined executive PDF report. Requirement status follows the same strict precedence (FAIL over PASS over MANUAL), so one failing provider flags the requirement for the whole estate.
|
||||
|
||||

|
||||
|
||||
The Compliance tabs were also renamed to say what they aggregate: "Per Scan" is now **Single Scan**, "Cross-Provider" is now **Multiple Scans**, and Compliance lands on Multiple Scans by default.
|
||||
|
||||

|
||||
|
||||
Read more in the [Cross-Provider Compliance documentation](/user-guide/compliance/tutorials/cross-provider-compliance).
|
||||
|
||||
### ☁️ GCP Organization Onboarding
|
||||
|
||||
<Note>
|
||||
This feature is available exclusively in **Prowler Cloud** and **Prowler Private Cloud** with a [subscription](https://prowler.com/pricing).
|
||||
</Note>
|
||||
|
||||
Onboarding an entire Google Cloud organization is now a single guided flow. Provide an organization-level credential and Prowler discovers the full hierarchy, every folder and project. Pick the folders and projects to onboard from a selection tree, set custom aliases, test the connection, and launch: each selected project is registered as a provider, with no need to add them one by one. Post-onboarding management is covered too, including credential replacement and organization-wide deletion.
|
||||
|
||||
Read more in the [GCP Organizations documentation](/user-guide/tutorials/prowler-cloud-gcp-organizations).
|
||||
|
||||
### 🕸️ Attack Paths — More Privilege Escalation Queries
|
||||
|
||||
Attack Paths adds four AWS privilege-escalation detection queries from [pathfinding.cloud](https://pathfinding.cloud). Thanks to @paramanandmallik!
|
||||
|
||||
- **[STS-002](https://hub.prowler.com/attack-paths/aws-sts-privesc-cross-account-trust)** — cross-account role trust
|
||||
- **[STS-003](https://hub.prowler.com/attack-paths/aws-sts-privesc-wildcard-trust)** — wildcard role trust
|
||||
- **[IAM-022](https://hub.prowler.com/attack-paths/aws-iam-privesc-delete-user-permissions-boundary)** — user permissions-boundary removal
|
||||
- **[SSO-001](https://hub.prowler.com/attack-paths/aws-sso-privesc-permission-set-escalation)** — IAM Identity Center permission-set escalation
|
||||
|
||||
The query info panel now links every query to its page on [Prowler Hub](https://hub.prowler.com), and the IAM privilege-escalation queries were reworked to run efficiently on accounts with many IAM roles, users, or groups, fixing runtime errors and timeouts on large graphs.
|
||||
|
||||
Read more in the [Attack Paths documentation](/user-guide/tutorials/prowler-app-attack-paths).
|
||||
|
||||
### 🛡️ AWS Confidential Computing — Nitro Enclaves Checks
|
||||
|
||||
Prowler adds the first CSPM coverage for confidential computing workloads on AWS, with **11 new checks** for [Nitro Enclaves](https://aws.amazon.com/ec2/nitro/nitro-enclaves/), developed together with [Guillermo Ruiz](https://www.linkedin.com/in/gruizesteban/) from AWS.
|
||||
|
||||
- **Workload host environment (EC2)** — five `ec2_confidential_workload_host_*` checks for the parent instance: IMDSv2 not enforced, public IP exposure, unrestricted ingress, exposed vsock proxy ports, and hosts not running.
|
||||
- **KMS attestation policy** — six `kms_key_enclave_*` checks for the key policies gating enclave secrets: attestation not enforced or bypassable, missing deployment binding, debug-mode attestations, PCR mismatches, and unknown enclave images.
|
||||
|
||||
All checks are fully passive, using AWS APIs and CloudTrail with no instance access or SSM agent required, and are mapped across 23 compliance frameworks, including NIST 800-53 Rev 5, PCI-DSS v4.0, ISO 27001:2022, SOC 2, HIPAA, and MITRE ATT&CK.
|
||||
|
||||
Read more about it this [blog post](https://prowler.com/blog/your-llm-runs-in-a-nitro-enclave-who-is-checking-the-enclave).
|
||||
|
||||
Try them out now at [cloud.prowler.com](https://cloud.prowler.com/sign-up)!
|
||||
|
||||
### 🏢 New Provider — Huawei Cloud
|
||||
|
||||
Prowler now scans [**Huawei Cloud**](https://www.huaweicloud.com/), with **25 checks** across ten services: CTS, ECS, ELB, EVS, IAM, KMS, OBS, RDS, VPC, and WAF, plus the CIS Huawei Cloud Foundations Benchmark 1.0 compliance framework. Thanks to @tomitobio for their 1st provider in Prowler!
|
||||
|
||||
To scan a Huawei Cloud account, export the IAM user's access key credentials and run Prowler CLI:
|
||||
|
||||
```bash
|
||||
export HUAWEICLOUD_ACCESS_KEY_ID="your-access-key-id"
|
||||
export HUAWEICLOUD_SECRET_ACCESS_KEY="your-secret-access-key"
|
||||
|
||||
prowler huaweicloud
|
||||
```
|
||||
|
||||
Read more in the [Huawei Cloud documentation](/user-guide/providers/huaweicloud/getting-started-huaweicloud). Explore all Huawei Cloud checks at [Prowler Hub](https://hub.prowler.com/check?provider=huaweicloud).
|
||||
|
||||
### 🔍 Checks
|
||||
|
||||
#### AWS
|
||||
|
||||
- `codecommit_repository_no_secrets`, alongside the new `codecommit` service, scans files tracked at the tip of each repository's default branch for hardcoded secrets. Thanks to @Sid-0602!
|
||||
- `glue_catalog_connection_no_secrets` detects secrets in Glue Data Catalog connection properties. Thanks to @l46983284-cpu, @Rishi943, and @UTKARSH698!
|
||||
- `ec2_instance_stopped_older_than_specific_days` detects EC2 instances stopped longer than a configurable number of days (default 30). Thanks to @Nithin078!
|
||||
- `sagemaker_endpoint_config_kms_encryption_enabled` verifies SageMaker endpoint configurations use a KMS key for storage volume encryption. Thanks to @Nithin078 and @l46983284-cpu!
|
||||
|
||||
Read more in the [AWS documentation](/user-guide/providers/aws/getting-started-aws). Explore all AWS checks at [Prowler Hub](https://hub.prowler.com/check?provider=aws).
|
||||
|
||||
### 📤 OCSF Output — MITRE ATT&CK Enrichment
|
||||
|
||||
OCSF detection finding output now populates `finding_info.analytic` with the Prowler check rule and `finding_info.attacks` with MITRE ATT&CK technique and tactic objects for findings with MITRE ATT&CK compliance metadata. Thanks to @AlexanderSanin!
|
||||
|
||||
### 🐞 Fixed
|
||||
|
||||
- AWS Security Hub integrations now persist successful recovery checks during finding delivery, keeping connection status and the last-checked time accurate.
|
||||
- Social sign-up now creates authentication, tenant, and membership records in a single transaction, fully rolling back failed provisioning to prevent incomplete accounts.
|
||||
- The SAML configuration form keeps the ACS URL field stable while generating the callback URL and exposes the copy action only after a valid URL is available.
|
||||
- SAML users without a `userType` attribute and without an existing role now receive a least-privilege `read_only` fallback role, so role-dependent operations continue to work without granting management permissions.
|
||||
|
||||
### 🔐 Security
|
||||
|
||||
- Provider deletion, connection checks, scan creation, provider secrets, provider groups, and daily schedules now respect role provider-group visibility.
|
||||
- HTML reports escape provider-originated finding fields, preventing stored cross-site scripting through malicious cloud resource tags. https://github.com/prowler-cloud/prowler/security/advisories/GHSA-c2jg-2778-ggm4
|
||||
- Authentication with an API key whose owning user was deleted now returns `401`, and user deletion revokes the user's API keys across all their tenants.
|
||||
|
||||
### 🙌 External Contributors
|
||||
|
||||
Thank you to our community contributors for this release!
|
||||
|
||||
- @tomitobio: Huawei Cloud provider with CIS 1.0 benchmark ([#11950](https://github.com/prowler-cloud/prowler/pull/11950))
|
||||
- @paramanandmallik: four AWS privilege-escalation Attack Paths queries ([#11460](https://github.com/prowler-cloud/prowler/pull/11460))
|
||||
- @Sid-0602: AWS `codecommit` service and `codecommit_repository_no_secrets` check ([#11846](https://github.com/prowler-cloud/prowler/pull/11846))
|
||||
- @l46983284-cpu, @Rishi943, and @UTKARSH698: AWS `glue_catalog_connection_no_secrets` check ([#11963](https://github.com/prowler-cloud/prowler/pull/11963))
|
||||
- @Nithin078: AWS `ec2_instance_stopped_older_than_specific_days` ([#12076](https://github.com/prowler-cloud/prowler/pull/12076)) and `sagemaker_endpoint_config_kms_encryption_enabled` ([#12118](https://github.com/prowler-cloud/prowler/pull/12118), co-authored with @l46983284-cpu) checks
|
||||
- @AlexanderSanin: MITRE ATT&CK enrichment in OCSF detection finding output ([#11492](https://github.com/prowler-cloud/prowler/pull/11492))
|
||||
- @stefanobaldo: GCP gen2 Cloud Functions IAM policy retrieval is now thread-safe ([#12107](https://github.com/prowler-cloud/prowler/pull/12107))
|
||||
- @rayair250-droid: GCP SSH and RDP firewall checks now detect exposed ports in any position within multi-port rules ([#12115](https://github.com/prowler-cloud/prowler/pull/12115))
|
||||
- @jbchief-dev: secret ignore patterns now use Kingfisher-compatible LF line indexing ([#12141](https://github.com/prowler-cloud/prowler/pull/12141))
|
||||
- @bmbferreira: Helm chart improvements — immutable chart versions on release ([#12056](https://github.com/prowler-cloud/prowler/pull/12056)) and capped Celery worker concurrency ([#12054](https://github.com/prowler-cloud/prowler/pull/12054))
|
||||
|
||||
See the [full release notes on GitHub](https://github.com/prowler-cloud/prowler/releases/tag/5.37.0) for the complete list of changes.
|
||||
</Update>
|
||||
|
||||
<Update label="v5.36.0" description="July 24, 2026">
|
||||
### 🎫 Finding Groups - Jira
|
||||
|
||||
|
||||
@@ -426,6 +426,150 @@ For complete installation and deployment options, see:
|
||||
|
||||
For development I recommend to use the [Model Context Protocol Inspector](https://github.com/modelcontextprotocol/inspector) as MCP client to test and debug your tools.
|
||||
|
||||
## Testing
|
||||
|
||||
Tests live in `mcp_server/tests/`, mirroring the source tree, and use the `test_*.py`
|
||||
prefix (the same convention as the API, not the SDK's `*_test.py` suffix).
|
||||
|
||||
From `mcp_server/`:
|
||||
|
||||
```bash
|
||||
cd mcp_server
|
||||
|
||||
uv run pytest # Whole suite
|
||||
uv run pytest tests/prowler_app/models # One area
|
||||
uv run pytest --cov=./prowler_mcp_server # With coverage
|
||||
```
|
||||
|
||||
From the repository root:
|
||||
|
||||
```bash
|
||||
make test-mcp # Runs the MCP suite exactly as CI does
|
||||
```
|
||||
|
||||
Async tests need no marker — `asyncio_mode` is set to `auto`.
|
||||
|
||||
### Reading the Coverage Numbers
|
||||
|
||||
<Warning>
|
||||
Coverage here has a high floor that means nothing. `coverage.py` measures
|
||||
*statements*, and in a Pydantic model module nearly every statement is a class-body
|
||||
field declaration that runs at **import** time. `prowler_app/server.py` imports
|
||||
every tool module — and therefore every model module — when it is first imported,
|
||||
so all of those declarations execute and count as covered before a single test runs.
|
||||
|
||||
Importing the package and executing no tests at all already reports **36% overall**,
|
||||
with individual model modules between 54% and 84%. A model module sitting at ~68%
|
||||
with no tests written for it has **none** of its behaviour covered: the covered lines
|
||||
are its imports, `class` statements and `Field(...)` declarations, and the missing
|
||||
ranges are its `from_api_response()` bodies.
|
||||
|
||||
Judge a module against that import-only floor, not against zero, and do not set a
|
||||
Codecov target from the raw total.
|
||||
</Warning>
|
||||
|
||||
### Shared Fixtures
|
||||
|
||||
All fixtures live in `mcp_server/tests/conftest.py`. Three are autouse and apply to
|
||||
every test: the environment is pinned to deterministic values, real socket
|
||||
connections are blocked, and the API client singleton registry is snapshotted and
|
||||
restored.
|
||||
|
||||
| Fixture | What it gives you |
|
||||
|---------|-------------------|
|
||||
| `mock_api_client` | The API client singleton with its transport mocked. The workhorse. |
|
||||
| `mock_router` | Route registry and request recorder |
|
||||
| `mcp_root_server` | The mounted root server, for in-memory client tests |
|
||||
| `health_client` | Starlette `TestClient` for the `/health` route |
|
||||
| `http_request_headers` | Injects request headers for HTTP-transport auth tests |
|
||||
| `hub_router` / `docs_router` | Mock the Hub and Docs sub-servers' sync HTTP clients |
|
||||
| `api_client` / `isolated_api_client` | The live singleton / a freshly-constructed one |
|
||||
|
||||
Helpers live in `mcp_server/tests/helpers/`: JSON:API document builders
|
||||
(`jsonapi.py`), the `MockRouter` (`http.py`), tool-contract assertions
|
||||
(`assertions.py`) and fake credentials (`tokens.py`).
|
||||
|
||||
### Writing a Tool Test
|
||||
|
||||
Drive tools through an in-memory MCP client, and open the client inside the test —
|
||||
FastMCP warns that holding a client in a fixture causes event-loop problems.
|
||||
|
||||
```python
|
||||
from fastmcp import Client
|
||||
|
||||
from tests.helpers.jsonapi import jsonapi_collection, jsonapi_resource
|
||||
|
||||
FINDING_ATTRIBUTES = {
|
||||
"uid": "prowler-aws-s3_bucket_public_access-123456789012-us-east-1-my-bucket",
|
||||
"status": "FAIL",
|
||||
"severity": "high",
|
||||
"status_extended": "S3 bucket my-bucket is publicly accessible.",
|
||||
"delta": "new",
|
||||
"muted": False,
|
||||
"muted_reason": None,
|
||||
"check_metadata": {"checkid": "s3_bucket_public_access"},
|
||||
}
|
||||
|
||||
|
||||
async def test_search_without_dates_queries_the_latest_scan_endpoint(
|
||||
mcp_root_server, mock_api_client, mock_router
|
||||
):
|
||||
"""With no date range the tool targets the cheaper `/findings/latest`."""
|
||||
mock_router.add(
|
||||
"GET",
|
||||
"/api/v1/findings/latest",
|
||||
json=jsonapi_collection(
|
||||
[jsonapi_resource("findings", "f1", FINDING_ATTRIBUTES)]
|
||||
),
|
||||
)
|
||||
|
||||
async with Client(mcp_root_server) as client:
|
||||
result = await client.call_tool("prowler_search_security_findings", {})
|
||||
|
||||
assert result.data["findings"][0]["check_id"] == "s3_bucket_public_access"
|
||||
assert mock_router.paths() == ["GET /api/v1/findings/latest"]
|
||||
```
|
||||
|
||||
The exemplar suite covers `findings` end to end — `tests/prowler_app/models/test_findings.py`
|
||||
and `tests/prowler_app/tools/test_findings.py`. It is deliberately one feature
|
||||
across both layers rather than a scattering of unrelated samples, and `findings`
|
||||
is the feature that exercises the whole foundation: two-tier models, nested
|
||||
sub-models, both relationship shapes, endpoint switching on a date range,
|
||||
list-to-CSV filter encoding, and a tool that returns prose instead of a model.
|
||||
|
||||
Note the two files share a name. That is why `__init__.py` is required in every
|
||||
`tests/` subdirectory here — without it they would collide on import.
|
||||
|
||||
<Warning>
|
||||
Tool parameters are declared with pydantic `Field(default=...)`, and only FastMCP's
|
||||
tool wrapper resolves those defaults. Calling a tool method directly with an
|
||||
argument omitted leaves it as a raw `FieldInfo` object, which is truthy — so a
|
||||
filter such as `if email:` silently builds a query out of the `FieldInfo` repr.
|
||||
Call tools through the client, or pass every argument explicitly.
|
||||
</Warning>
|
||||
|
||||
### Why the API Key Is Pinned, Not Stripped
|
||||
|
||||
`prowler_app/server.py` builds every tool at import time. Constructing a tool
|
||||
reaches `ProwlerAppAuth`, which raises when `PROWLER_API_KEY` is missing, and
|
||||
`load_all_tools` swallows that error per tool class. The result is that the whole
|
||||
`prowler_*` namespace registers **zero** tools while the server still logs
|
||||
"Successfully mounted Prowler tools server".
|
||||
|
||||
The suite therefore pins a fake key in `[tool.pytest_env]`, which is applied before
|
||||
any test module is imported, and `tests/test_server.py` asserts each namespace is
|
||||
non-empty so this failure can never return silently.
|
||||
|
||||
<Note>
|
||||
`ProwlerAppAuth` resolves `PROWLER_MCP_TRANSPORT_MODE` and `API_BASE_URL` in its
|
||||
default arguments, which Python evaluates once at module import. `monkeypatch.setenv`
|
||||
cannot change them — pass `mode=` and `base_url=` explicitly in auth tests.
|
||||
</Note>
|
||||
|
||||
For the full set of rules and templates, see the
|
||||
[`prowler-test-mcp` skill](https://github.com/prowler-cloud/prowler/blob/master/skills/prowler-test-mcp/SKILL.md)
|
||||
and the [official FastMCP testing guide](https://gofastmcp.com/development/tests).
|
||||
|
||||
## Related Documentation
|
||||
|
||||
<CardGroup cols={2}>
|
||||
|
||||
@@ -128,8 +128,8 @@ To update the environment file:
|
||||
Edit the `.env` file and change version values:
|
||||
|
||||
```env
|
||||
PROWLER_UI_VERSION="5.36.0"
|
||||
PROWLER_API_VERSION="5.36.0"
|
||||
PROWLER_UI_VERSION="5.37.0"
|
||||
PROWLER_API_VERSION="5.37.0"
|
||||
```
|
||||
|
||||
<Note>
|
||||
|
||||
@@ -3,6 +3,7 @@ title: 'Overview'
|
||||
---
|
||||
|
||||
import { SubscriptionBanner } from "/snippets/subscription-banner.mdx"
|
||||
import { VersionBadge } from "/snippets/version-badge.mdx"
|
||||
|
||||
Prowler Cloud runs an enhanced version of Lighthouse AI in Open Source repository, the Agentic Cloud Defender that helps teams understand, prioritize, and remediate security findings across cloud environments.
|
||||
|
||||
@@ -34,6 +35,8 @@ The Agentic Cloud Defender does more than answer questions, it helps teams **fin
|
||||
|
||||
## Chat View
|
||||
|
||||
<VersionBadge version="5.33.0" />
|
||||
|
||||
Lighthouse AI is no longer a separate section in the left navigation. Prowler Cloud now offers two application views: a normal view for browsing dashboards, findings, and configuration, and an agentic chat view, powered by Lighthouse AI, for conversational, multi-step security analysis. Conversations are saved automatically, so earlier sessions can be reopened and resumed at any time.
|
||||
|
||||
Promoting the chat to a top-level view gives Lighthouse AI the room it needs for a fully agentic workflow and makes the Agentic Cloud Defender a primary way to work in Prowler Cloud.
|
||||
@@ -42,6 +45,8 @@ Promoting the chat to a top-level view gives Lighthouse AI the room it needs for
|
||||
|
||||
### Side Panel
|
||||
|
||||
<VersionBadge version="5.35.0" />
|
||||
|
||||
You do not have to switch to the full chat view to reach Lighthouse AI. A side panel is available on every page of Prowler Cloud. While collapsed it stays out of the way; open it from any dashboard, findings list, or configuration screen to ask questions without leaving what you are working on. Open it using the Lighthouse AI button, circled in red in the image below.
|
||||
|
||||
<img src="/images/prowler-app/lighthouse/prowler-cloud/side-panel-closed.png" alt="Collapsed Lighthouse AI side panel on a Prowler Cloud page, with the button to open it circled in red" />
|
||||
@@ -54,6 +59,14 @@ Once open, the panel slides in alongside your current page and shares the same a
|
||||
- **Context-aware help:** Ask about the findings, resources, or compliance data you are currently looking at.
|
||||
- **Continuous sessions:** Conversations opened in the side panel are saved alongside the rest of your chat history.
|
||||
|
||||
### Context-Aware Chat
|
||||
|
||||
<VersionBadge version="5.37.0" />
|
||||
|
||||
The panel knows where you are in the app. Messages carry the page you are on and, when a finding or resource is open in the side panel, its metadata too, so questions like "explain this" resolve against what is on screen. The active context appears as a chip in the composer, circled in red in the image below, and each page offers contextual suggestions to start from.
|
||||
|
||||
<img src="/images/prowler-app/lighthouse/prowler-cloud/side-panel-context-aware.png" alt="Lighthouse AI answering a question about the open finding from the side panel, with the page context chip highlighted in red in the composer" />
|
||||
|
||||
### Tool Usage
|
||||
|
||||
Lighthouse AI on Prowler Cloud renders the agent's work as it happens, so responses are easier to follow and to trust. Tool calls and reasoning steps appear in the order they occur within the conversation.
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 191 KiB After Width: | Height: | Size: 148 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 236 KiB After Width: | Height: | Size: 215 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 364 KiB |
@@ -41,7 +41,7 @@ Every GitHub Actions workflow uses runner hardening, pinned action versions, and
|
||||
|
||||
### Workflow Security Audit With Zizmor
|
||||
|
||||
- **[zizmor](https://github.com/zizmorcore/zizmor)** audits every workflow file for known security anti-patterns. Runs via [`ci-zizmor.yml`](https://github.com/prowler-cloud/prowler/blob/master/.github/workflows/ci-zizmor.yml).
|
||||
- **[zizmor](https://github.com/zizmorcore/zizmor)** audits every workflow file for known security anti-patterns. Runs on every pull request and push.
|
||||
- Triggers on every push, every pull request that touches `.github/`, and on a daily schedule.
|
||||
- Results upload to the GitHub Security tab via Static Analysis Results Interchange Format (SARIF).
|
||||
- Key [audit rules](https://docs.zizmor.sh/audits/) the build gates on:
|
||||
@@ -65,19 +65,19 @@ Multiple SAST tools run on every push and pull request to catch vulnerabilities
|
||||
|
||||
### Cross-Language
|
||||
|
||||
- **CodeQL:** semantic code analysis for the UI (JavaScript/TypeScript), API (Python), and SDK (Python). Runs on every push and pull request, plus a daily scheduled scan, via [`sdk-codeql.yml`](https://github.com/prowler-cloud/prowler/blob/master/.github/workflows/sdk-codeql.yml), [`api-codeql.yml`](https://github.com/prowler-cloud/prowler/blob/master/.github/workflows/api-codeql.yml), and [`ui-codeql.yml`](https://github.com/prowler-cloud/prowler/blob/master/.github/workflows/ui-codeql.yml). Results upload to the GitHub Security tab via SARIF.
|
||||
- **CodeQL:** semantic code analysis for the UI (JavaScript/TypeScript), API (Python), and SDK (Python). Runs on every push and pull request, plus a daily scheduled scan. Results upload to the GitHub Security tab via SARIF.
|
||||
|
||||
### Python (SDK + API)
|
||||
|
||||
- **Bandit:** detects common Python security issues (SQL injection, hardcoded credentials, insecure deserialization). Runs in pre-commit and on every PR/push in [`sdk-security.yml`](https://github.com/prowler-cloud/prowler/blob/master/.github/workflows/sdk-security.yml) and [`api-security.yml`](https://github.com/prowler-cloud/prowler/blob/master/.github/workflows/api-security.yml).
|
||||
- **Pylint:** analyzes your code without actually running it. It checks for errors, enforces a coding standard, looks for code smells, and can suggest refactors. Runs in pre-commit and on every PR/push in [`sdk-code-quality.yml`](https://github.com/prowler-cloud/prowler/blob/master/.github/workflows/sdk-code-quality.yml) and [`api-code-quality.yml`](https://github.com/prowler-cloud/prowler/blob/master/.github/workflows/api-code-quality.yml).
|
||||
- **Vulture:** dead-code detection at `--min-confidence 100`. Unused code can hide incomplete implementations or stale security paths. Runs in pre-commit and on every PR/push in `sdk-security.yml` and `api-security.yml`.
|
||||
- **Flake8:** style and correctness checks for the SDK. Runs in pre-commit and on every PR/push in [`sdk-code-quality.yml`](https://github.com/prowler-cloud/prowler/blob/master/.github/workflows/sdk-code-quality.yml).
|
||||
- **Bandit:** detects common Python security issues (SQL injection, hardcoded credentials, insecure deserialization). Runs in pre-commit and on every pull request and push.
|
||||
- **Pylint:** analyzes your code without actually running it. It checks for errors, enforces a coding standard, looks for code smells, and can suggest refactors. Runs in pre-commit and on every pull request and push.
|
||||
- **Vulture:** dead-code detection at `--min-confidence 100`. Unused code can hide incomplete implementations or stale security paths. Runs in pre-commit and on every pull request and push.
|
||||
- **Flake8:** style and correctness checks for the SDK. Runs in pre-commit and on every pull request and push.
|
||||
|
||||
### JavaScript/TypeScript (UI)
|
||||
|
||||
- **TypeScript (`tsc`):** strict type checking for the UI. Catches whole classes of null/undefined and type-confusion bugs at build time. Runs on every PR/push via `pnpm run healthcheck` in [`ui-tests.yml`](https://github.com/prowler-cloud/prowler/blob/master/.github/workflows/ui-tests.yml).
|
||||
- **ESLint:** UI linting with a capped warning budget (`--max-warnings 40`). Runs on every PR/push via `pnpm run healthcheck` in `ui-tests.yml`.
|
||||
- **TypeScript (`tsc`):** strict type checking for the UI. Catches whole classes of null/undefined and type-confusion bugs at build time. Runs on every pull request and push via `pnpm run healthcheck`.
|
||||
- **ESLint:** UI linting with a capped warning budget (`--max-warnings 40`). Runs on every pull request and push via `pnpm run healthcheck`.
|
||||
- **Knip:** dead-code and unused-export detection for the UI. The UI analogue to Vulture.
|
||||
|
||||
<Note>
|
||||
@@ -94,12 +94,12 @@ Dependencies are scanned against public vulnerability databases on every pull re
|
||||
|
||||
### Cross-Language
|
||||
|
||||
- **osv-scanner:** scans lockfiles against the [OSV.dev](https://osv.dev) vulnerability database for SDK (`uv.lock`), API (`api/uv.lock`), and UI (`ui/pnpm-lock.yaml`). Runs via [`sdk-security.yml`](https://github.com/prowler-cloud/prowler/blob/master/.github/workflows/sdk-security.yml), [`api-security.yml`](https://github.com/prowler-cloud/prowler/blob/master/.github/workflows/api-security.yml), and [`ui-security.yml`](https://github.com/prowler-cloud/prowler/blob/master/.github/workflows/ui-security.yml).
|
||||
- **osv-scanner:** scans lockfiles against the [OSV.dev](https://osv.dev) vulnerability database for SDK (`uv.lock`), API (`api/uv.lock`), and UI (`ui/pnpm-lock.yaml`). Runs on every pull request and push.
|
||||
- The action installs the `osv-scanner` binary and verifies its SHA-256 checksum against the upstream-signed `SHA256SUMS` manifest before running. Any mismatch aborts the scan.
|
||||
- Gates the build on `HIGH`, `CRITICAL`, and `UNKNOWN` severity findings.
|
||||
- Posts and updates a per-lockfile report as a pull request comment.
|
||||
- Per-vulnerability ignores live in [`osv-scanner.toml`](https://github.com/prowler-cloud/prowler/blob/master/osv-scanner.toml) at the repo root, each with a reason and an expiry date.
|
||||
- **Trivy:** scans container images for OS-package and application-dependency vulnerabilities. Runs in [`sdk-container-checks.yml`](https://github.com/prowler-cloud/prowler/blob/master/.github/workflows/sdk-container-checks.yml), [`api-container-checks.yml`](https://github.com/prowler-cloud/prowler/blob/master/.github/workflows/api-container-checks.yml), [`ui-container-checks.yml`](https://github.com/prowler-cloud/prowler/blob/master/.github/workflows/ui-container-checks.yml), and [`mcp-container-checks.yml`](https://github.com/prowler-cloud/prowler/blob/master/.github/workflows/mcp-container-checks.yml). Trivy uploads SARIF to the GitHub Security tab and posts a scan summary on the PR.
|
||||
- **Trivy:** scans container images for OS-package and application-dependency vulnerabilities. Runs on every pull request and push that touches an image or its dependencies. Trivy uploads SARIF to the GitHub Security tab and posts a scan summary on the PR.
|
||||
- **Dependabot:** [configured](https://github.com/prowler-cloud/prowler/blob/master/.github/dependabot.yml) for monthly updates of the SDK Python dependencies, GitHub Actions, Docker base images, and pre-commit hooks. Dependabot opens pull requests for known security advisories, so critical patches reach the team without delay. A 7-day default cooldown reduces exposure to compromised package releases.
|
||||
- **Renovate:** [configured](https://github.com/prowler-cloud/prowler/blob/master/.github/renovate.json) dependency update automation is transitioning from Dependabot to **Renovate** to gain finer control over update cadence, grouping, and per-component scope. Both tools currently run in parallel during the migration.
|
||||
|
||||
@@ -126,7 +126,7 @@ Dependabot is paused for the API and UI; Renovate now handles those components.
|
||||
|
||||
### JavaScript/TypeScript (UI)
|
||||
|
||||
- **pnpm audit:** runs `pnpm audit --audit-level critical` on every UI pull request and push as part of `pnpm run audit` in [`ui-tests.yml`](https://github.com/prowler-cloud/prowler/blob/master/.github/workflows/ui-tests.yml). Cross-checks the npm registry's advisory database in addition to the OSV scan and surfaces npm-specific advisories that may not yet have an OSV identifier.
|
||||
- **pnpm audit:** runs `pnpm audit --audit-level critical` on every UI pull request and push as part of `pnpm run audit`. Cross-checks the npm registry's advisory database in addition to the OSV scan and surfaces npm-specific advisories that may not yet have an OSV identifier.
|
||||
|
||||
## Supply-Chain Pinning
|
||||
|
||||
@@ -150,7 +150,7 @@ The controls applied across all three:
|
||||
- **uv itself pinned** in the [`setup-python-uv`](https://github.com/prowler-cloud/prowler/tree/master/.github/actions/setup-python-uv) composite action.
|
||||
|
||||
<Note>
|
||||
The MCP Server has a small direct-dependency surface and does not yet declare a separate constraint set. Its lock file is the source of truth.
|
||||
The MCP Server declares a small constraint set of its own, covering transitive pins that `fastmcp` does not raise on its own. Its lock file remains the source of truth for everything else.
|
||||
</Note>
|
||||
|
||||
### JavaScript/TypeScript (pnpm)
|
||||
@@ -181,8 +181,8 @@ Container images get scanned twice: once in CI before they push to a registry, a
|
||||
|
||||
### Pre-Publish (CI)
|
||||
|
||||
- **Trivy** scans for OS-package and application-dependency vulnerabilities. Runs in [`sdk-container-checks.yml`](https://github.com/prowler-cloud/prowler/blob/master/.github/workflows/sdk-container-checks.yml), [`api-container-checks.yml`](https://github.com/prowler-cloud/prowler/blob/master/.github/workflows/api-container-checks.yml), [`ui-container-checks.yml`](https://github.com/prowler-cloud/prowler/blob/master/.github/workflows/ui-container-checks.yml), and [`mcp-container-checks.yml`](https://github.com/prowler-cloud/prowler/blob/master/.github/workflows/mcp-container-checks.yml). Trivy uploads SARIF to the GitHub Security tab and posts a summary on the PR. Builds can fail on critical findings when configured to.
|
||||
- **Hadolint** validates Dockerfile syntax and structure against secure-build best practices. Runs in pre-commit and in the same `*-container-checks.yml` workflows linked above.
|
||||
- **Trivy** scans for OS-package and application-dependency vulnerabilities. Runs on every pull request and push that touches an image or its dependencies. Trivy uploads SARIF to the GitHub Security tab and posts a summary on the PR. Builds fail on any critical finding that is not explicitly accepted. Accepted findings live in [`.trivyignore.yaml`](https://github.com/prowler-cloud/prowler/blob/master/.trivyignore.yaml), each carrying a reason and an expiry date, the same policy `osv-scanner.toml` follows. A local `trivy image` run does not apply these suppressions unless you pass `--ignorefile .trivyignore.yaml`: Trivy auto-loads only the classic `.trivyignore` format, never the YAML one.
|
||||
- **Hadolint** validates Dockerfile syntax and structure against secure-build best practices. Runs in pre-commit and alongside the image scans above.
|
||||
|
||||
### Post-Publish (Registries)
|
||||
|
||||
@@ -190,9 +190,27 @@ Container images get scanned twice: once in CI before they push to a registry, a
|
||||
- **Docker Hub:** Docker Hub continuously scans the same images mirrored from ECR.
|
||||
- The security team reviews findings from both registries for triage and remediation.
|
||||
|
||||
### Known Findings
|
||||
|
||||
A small number of findings remain in the published images and cannot be resolved by Prowler: the upstream project has released no fix, the package cannot be removed without breaking the image, or the finding comes from a vendored SBOM rather than from a package that is actually installed. Alternative base distributions have been evaluated and none currently satisfies both the vulnerability profile and the runtime requirements of every supported provider.
|
||||
|
||||
Each suppression is recorded in [`.trivyignore.yaml`](https://github.com/prowler-cloud/prowler/blob/master/.trivyignore.yaml) with the reason it cannot be fixed, why it is not exploitable in Prowler's runtime, and an expiry date that forces re-review. Nothing is suppressed without that rationale, and a build fails on any critical finding that is not listed there.
|
||||
|
||||
To see the current set for any image, scan it directly. This reports everything, including the accepted findings above, because Trivy does not read `.trivyignore.yaml` unless it is named:
|
||||
|
||||
```bash
|
||||
trivy image prowlercloud/prowler:latest
|
||||
```
|
||||
|
||||
To see only what is *not* already accepted, point Trivy at the suppression file:
|
||||
|
||||
```bash
|
||||
trivy image --ignorefile .trivyignore.yaml prowlercloud/prowler:latest
|
||||
```
|
||||
|
||||
## Secrets Detection
|
||||
|
||||
- **[TruffleHog](https://github.com/trufflesecurity/trufflehog)** scans the codebase and git history on every push and pull request via [`find-secrets.yml`](https://github.com/prowler-cloud/prowler/blob/master/.github/workflows/find-secrets.yml). Detects high-entropy strings, API keys, tokens, and credentials, and reports verified and unknown findings.
|
||||
- **[TruffleHog](https://github.com/trufflesecurity/trufflehog)** scans the codebase and git history on every push and pull request. Detects high-entropy strings, API keys, tokens, and credentials, and reports verified and unknown findings.
|
||||
- A pre-commit hook runs the same check locally and blocks secrets before they leave the developer machine.
|
||||
|
||||
## Security Monitoring
|
||||
|
||||
+25
-6
@@ -15,6 +15,7 @@ When performing these actions, ALWAYS invoke the corresponding skill FIRST:
|
||||
| Review changelog format and conventions | `prowler-changelog` |
|
||||
| Update CHANGELOG.md in any component | `prowler-changelog` |
|
||||
| Working on MCP server tools | `prowler-mcp` |
|
||||
| Writing tests for the MCP server | `prowler-test-mcp` |
|
||||
|
||||
## Project Overview
|
||||
|
||||
@@ -48,9 +49,9 @@ The Prowler MCP Server provides AI agents access to the Prowler ecosystem throug
|
||||
### Three Sub-Servers
|
||||
|
||||
```python
|
||||
await prowler_mcp_server.import_server(hub_mcp_server, prefix="prowler_hub")
|
||||
await prowler_mcp_server.import_server(app_mcp_server, prefix="prowler_app")
|
||||
await prowler_mcp_server.import_server(docs_mcp_server, prefix="prowler_docs")
|
||||
prowler_mcp_server.mount(hub_mcp_server, namespace="prowler_hub")
|
||||
prowler_mcp_server.mount(app_mcp_server, namespace="prowler")
|
||||
prowler_mcp_server.mount(docs_mcp_server, namespace="prowler_docs")
|
||||
```
|
||||
|
||||
### Tool Naming
|
||||
@@ -62,7 +63,7 @@ await prowler_mcp_server.import_server(docs_mcp_server, prefix="prowler_docs")
|
||||
|
||||
## TECH STACK
|
||||
|
||||
Python 3.12+ | FastMCP 2.13.1 | httpx (async) | Pydantic | uv
|
||||
Python 3.12+ | FastMCP 3.4.4 | httpx (async) | Pydantic | uv | pytest
|
||||
|
||||
---
|
||||
|
||||
@@ -85,9 +86,23 @@ mcp_server/prowler_mcp_server/
|
||||
|
||||
## COMMANDS
|
||||
|
||||
From `mcp_server/`:
|
||||
|
||||
```bash
|
||||
cd mcp_server && uv run prowler-mcp # STDIO mode
|
||||
cd mcp_server && uv run prowler-mcp --transport http --port 8000 # HTTP mode
|
||||
cd mcp_server
|
||||
|
||||
uv run prowler-mcp # STDIO mode
|
||||
uv run prowler-mcp --transport http --port 8000 # HTTP mode
|
||||
|
||||
uv run pytest # Run the test suite
|
||||
uv run pytest tests/prowler_app/models # Run one area
|
||||
uv run pytest --cov=./prowler_mcp_server # With coverage
|
||||
```
|
||||
|
||||
From the repository root:
|
||||
|
||||
```bash
|
||||
make test-mcp # Run the MCP test suite exactly as CI does
|
||||
```
|
||||
|
||||
---
|
||||
@@ -100,3 +115,7 @@ cd mcp_server && uv run prowler-mcp --transport http --port 8000 # HTTP mode
|
||||
- [ ] No hardcoded secrets
|
||||
- [ ] 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/`),
|
||||
as the SDK does for `prowler/` -> `tests/`
|
||||
- [ ] `uv run pytest` passes
|
||||
|
||||
@@ -4,6 +4,14 @@ All notable changes to the **Prowler MCP Server** are documented in this file.
|
||||
|
||||
<!-- changelog: release notes start -->
|
||||
|
||||
## [0.9.1] (Prowler v5.37.1)
|
||||
|
||||
### 🔐 Security
|
||||
|
||||
- Bumped `fastmcp` and pinned `cryptography`, `joserfc`, `mcp` and `python-multipart`, clearing all 7 high-severity CVEs from the MCP image [(#12307)](https://github.com/prowler-cloud/prowler/pull/12307)
|
||||
|
||||
---
|
||||
|
||||
## [0.9.0] (Prowler v5.37.0)
|
||||
|
||||
### 🚀 Added
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
Test coverage for the integrations tools and models, pinning the connection-check choreography and the Jira dispatch retry safety
|
||||
@@ -0,0 +1 @@
|
||||
`prowler_list_integrations` no longer requests the `configuration` it discards, now that the API tolerates a sparse fieldset without it
|
||||
@@ -0,0 +1 @@
|
||||
Test foundation for the MCP server with shared fixtures, JSON:API builders, mocked HTTP transports and CI coverage reporting
|
||||
@@ -295,14 +295,20 @@ class JiraDispatchResult(MinimalSerializerMixin, BaseModel):
|
||||
|
||||
@classmethod
|
||||
def from_task_result(
|
||||
cls, result: dict[str, Any], task_id: str | None = None
|
||||
cls, result: Any, task_id: str | None = None
|
||||
) -> "JiraDispatchResult":
|
||||
"""Build the dispatch result from the completed background task result.
|
||||
|
||||
Raises:
|
||||
ValueError: If the task result does not carry both counters. Defaulting them to
|
||||
zero would report a dispatch as retryable when it may have created work items
|
||||
ValueError: If the task result is not an object, or does not carry both
|
||||
counters. Defaulting them to zero would report a dispatch as retryable
|
||||
when it may have created work items
|
||||
"""
|
||||
if not isinstance(result, dict):
|
||||
raise ValueError(
|
||||
"The completed dispatch task did not report a result object."
|
||||
)
|
||||
|
||||
created_count = result.get("created_count")
|
||||
failed_count = result.get("failed_count")
|
||||
|
||||
|
||||
@@ -17,7 +17,6 @@ from prowler_mcp_server.prowler_app.models.integrations import (
|
||||
IntegrationsListResponse,
|
||||
JiraDispatchResult,
|
||||
JiraIssueTypes,
|
||||
SimplifiedIntegration,
|
||||
)
|
||||
from prowler_mcp_server.prowler_app.tools.base import BaseTool
|
||||
|
||||
@@ -25,7 +24,7 @@ from prowler_mcp_server.prowler_app.tools.base import BaseTool
|
||||
# detailed view returned by prowler_get_integration
|
||||
INTEGRATION_LIST_FIELDS = (
|
||||
"enabled,connected,connection_last_checked_at,integration_type,providers,"
|
||||
"configuration,inserted_at,updated_at"
|
||||
"inserted_at,updated_at"
|
||||
)
|
||||
|
||||
CONNECTION_CHECK_TIMEOUT = 120
|
||||
@@ -36,6 +35,17 @@ JIRA_DISPATCH_TIMEOUT = 300
|
||||
JIRA_REQUIRED_CREDENTIALS = ("domain", "user_mail", "api_token")
|
||||
|
||||
|
||||
def _providers_relationship(provider_ids: list[str]) -> dict[str, Any]:
|
||||
"""Build the JSON:API relationship linkage attaching an integration to providers."""
|
||||
return {
|
||||
"providers": {
|
||||
"data": [
|
||||
{"type": "providers", "id": provider_id} for provider_id in provider_ids
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class IntegrationsTools(BaseTool):
|
||||
"""Tools for integration management operations.
|
||||
|
||||
@@ -484,12 +494,22 @@ class IntegrationsTools(BaseTool):
|
||||
self.logger.info(f"Updating integration {integration_id}...")
|
||||
|
||||
try:
|
||||
current = await self._get_integration_raw(integration_id)
|
||||
current_attributes = current["attributes"]
|
||||
integration_type = current_attributes["integration_type"]
|
||||
current = DetailedIntegration.from_api_response(
|
||||
await self._get_integration_raw(integration_id)
|
||||
)
|
||||
integration_type = current.integration_type
|
||||
|
||||
if provider_ids is not None:
|
||||
self._validate_provider_ids(integration_type, provider_ids)
|
||||
if integration_type == "jira":
|
||||
raise ValueError(
|
||||
"Jira integrations are tenant-wide and cannot be attached to providers."
|
||||
)
|
||||
if integration_type == "aws_security_hub" and len(provider_ids) != 1:
|
||||
raise ValueError(
|
||||
"AWS Security Hub integrations must stay attached to exactly one AWS "
|
||||
f"provider, got {len(provider_ids)}. Pass a single provider ID, or use "
|
||||
"prowler_delete_integration to stop sending findings to Security Hub."
|
||||
)
|
||||
|
||||
attributes: dict[str, Any] = {}
|
||||
if enabled is not None:
|
||||
@@ -507,20 +527,16 @@ class IntegrationsTools(BaseTool):
|
||||
"Update the credentials instead, or run prowler_test_integration_connection to "
|
||||
"refresh the available projects and issue types."
|
||||
)
|
||||
merged = dict(current_attributes.get("configuration") or {})
|
||||
merged = dict(current.configuration)
|
||||
merged.update(self._as_dict(configuration, "configuration"))
|
||||
# Server-owned, the API repopulates it from the connection check
|
||||
merged.pop("regions", None)
|
||||
merged.pop("enabled_regions", None)
|
||||
attributes["configuration"] = merged
|
||||
|
||||
providers_changed = provider_ids is not None and sorted(
|
||||
provider_ids
|
||||
) != sorted(SimplifiedIntegration._extract_provider_ids(current))
|
||||
|
||||
if not attributes and provider_ids is None:
|
||||
self.logger.info("No changes provided, returning the current state")
|
||||
return DetailedIntegration.from_api_response(current).model_dump()
|
||||
return current.model_dump()
|
||||
|
||||
update_body: dict[str, Any] = {
|
||||
"data": {
|
||||
@@ -530,33 +546,35 @@ class IntegrationsTools(BaseTool):
|
||||
}
|
||||
}
|
||||
if provider_ids is not None:
|
||||
update_body["data"]["relationships"] = {
|
||||
"providers": {
|
||||
"data": [
|
||||
{"type": "providers", "id": provider_id}
|
||||
for provider_id in provider_ids
|
||||
]
|
||||
}
|
||||
}
|
||||
update_body["data"]["relationships"] = _providers_relationship(
|
||||
provider_ids
|
||||
)
|
||||
|
||||
await self.api_client.patch(
|
||||
f"/integrations/{integration_id}", json_data=update_body
|
||||
)
|
||||
|
||||
# A different provider means different effective credentials and different
|
||||
# discovered configuration, so the stored connection state is stale
|
||||
if (
|
||||
# discovered configuration, so the stored connection state is stale too
|
||||
providers_changed = provider_ids is not None and set(provider_ids) != set(
|
||||
current.provider_ids
|
||||
)
|
||||
recheck_connection = (
|
||||
credentials is not None
|
||||
or configuration is not None
|
||||
or providers_changed
|
||||
):
|
||||
connection_status = await self._test_connection(integration_id)
|
||||
updated = await self._get_integration_raw(integration_id)
|
||||
)
|
||||
connection_status = (
|
||||
await self._test_connection(integration_id)
|
||||
if recheck_connection
|
||||
else None
|
||||
)
|
||||
|
||||
updated = await self._get_integration_raw(integration_id)
|
||||
if connection_status is not None:
|
||||
return IntegrationConnectionStatus.create(
|
||||
updated, connection_status
|
||||
).model_dump()
|
||||
|
||||
updated = await self._get_integration_raw(integration_id)
|
||||
return DetailedIntegration.from_api_response(updated).model_dump()
|
||||
except Exception as e:
|
||||
self.logger.error(f"Integration update failed: {e}")
|
||||
@@ -769,14 +787,10 @@ class IntegrationsTools(BaseTool):
|
||||
self.logger.error(f"Jira dispatch did not complete cleanly: {e}")
|
||||
return await self._jira_dispatch_fallback(task_id, str(e))
|
||||
|
||||
task_result = completed_task.get("data", {}).get("attributes", {}).get("result")
|
||||
|
||||
try:
|
||||
if not isinstance(task_result, dict):
|
||||
raise ValueError(
|
||||
"The completed dispatch task did not report a result object."
|
||||
)
|
||||
return JiraDispatchResult.from_task_result(task_result).model_dump()
|
||||
return JiraDispatchResult.from_task_result(
|
||||
completed_task.get("data", {}).get("attributes", {}).get("result")
|
||||
).model_dump()
|
||||
except ValueError as e:
|
||||
self.logger.error(f"Jira dispatch result could not be read: {e}")
|
||||
return self._jira_dispatch_unknown(task_id, str(e))
|
||||
@@ -828,22 +842,6 @@ class IntegrationsTools(BaseTool):
|
||||
)
|
||||
return normalized
|
||||
|
||||
def _validate_provider_ids(
|
||||
self, integration_type: str, provider_ids: list[str]
|
||||
) -> None:
|
||||
"""Reject provider changes an integration type cannot survive."""
|
||||
if integration_type == "jira":
|
||||
raise ValueError(
|
||||
"Jira integrations are tenant-wide and cannot be attached to providers."
|
||||
)
|
||||
|
||||
if integration_type == "aws_security_hub" and len(provider_ids) != 1:
|
||||
raise ValueError(
|
||||
"AWS Security Hub integrations must stay attached to exactly one AWS provider, "
|
||||
f"got {len(provider_ids)}. Pass a single provider ID, or use "
|
||||
"prowler_delete_integration to stop sending findings to Security Hub."
|
||||
)
|
||||
|
||||
def _validate_credentials(
|
||||
self, integration_type: str, credentials: dict[str, Any]
|
||||
) -> dict[str, Any]:
|
||||
@@ -934,14 +932,7 @@ class IntegrationsTools(BaseTool):
|
||||
}
|
||||
}
|
||||
if provider_ids:
|
||||
create_body["data"]["relationships"] = {
|
||||
"providers": {
|
||||
"data": [
|
||||
{"type": "providers", "id": provider_id}
|
||||
for provider_id in provider_ids
|
||||
]
|
||||
}
|
||||
}
|
||||
create_body["data"]["relationships"] = _providers_relationship(provider_ids)
|
||||
|
||||
api_response = await self.api_client.post(
|
||||
"/integrations", json_data=create_body
|
||||
|
||||
@@ -5,14 +5,18 @@ requires = ["setuptools>=61.0", "wheel"]
|
||||
[dependency-groups]
|
||||
dev = [
|
||||
"bandit==1.8.3",
|
||||
"coverage==7.15.2",
|
||||
"pytest==9.0.3",
|
||||
"pytest-asyncio==1.4.0",
|
||||
"pytest-cov==6.0.0",
|
||||
"pytest-env==1.1.5",
|
||||
"ruff==0.15.11",
|
||||
"vulture==2.14"
|
||||
]
|
||||
|
||||
[project]
|
||||
dependencies = [
|
||||
"fastmcp==3.4.4",
|
||||
"fastmcp==3.4.5",
|
||||
"httpx==0.28.1"
|
||||
]
|
||||
description = "MCP server for Prowler ecosystem"
|
||||
@@ -27,8 +31,32 @@ prowler-mcp = "prowler_mcp_server.main:main"
|
||||
[tool.pytest]
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
addopts = "--strict-markers --strict-config"
|
||||
# `asyncio_mode = "auto"` lets `async def test_*` run without a per-test marker;
|
||||
# the server is async end to end, so requiring one would be pure noise. Setting
|
||||
# the fixture loop scope explicitly silences a pytest-asyncio deprecation warning.
|
||||
asyncio_default_fixture_loop_scope = "function"
|
||||
asyncio_mode = "auto"
|
||||
filterwarnings = [
|
||||
"error",
|
||||
# Starlette's TestClient warns that it will require httpx2. The httpx pin is a
|
||||
# deliberate project-wide choice, so this stays allowed until that pin moves.
|
||||
"default::starlette.exceptions.StarletteDeprecationWarning"
|
||||
]
|
||||
pythonpath = ["."]
|
||||
testpaths = ["tests"]
|
||||
|
||||
# Applied before any conftest or test module is imported, which is what makes it
|
||||
# work: `prowler_app/server.py` builds every tool at import time, and a tool whose
|
||||
# construction raises (as it does without an API key) is swallowed by
|
||||
# `load_all_tools`, leaving the `prowler_*` namespace silently empty. Pinning a
|
||||
# fake key here keeps the full tool surface loadable and stops a developer's
|
||||
# `mcp_server/.env` from reaching the suite.
|
||||
[tool.pytest_env]
|
||||
API_BASE_URL = "https://api.testing.invalid/api/v1"
|
||||
PROWLER_API_KEY = "pk_fake_api_key_for_unit_testing_only"
|
||||
PROWLER_MCP_TRANSPORT_MODE = "stdio"
|
||||
|
||||
# Shared ruff baseline (kept in sync with api/pyproject.toml).
|
||||
# target-version tracks this project's lowest supported Python.
|
||||
[tool.ruff]
|
||||
@@ -47,3 +75,11 @@ extend-select = [
|
||||
|
||||
[tool.uv]
|
||||
package = true
|
||||
|
||||
# Transitive pins fastmcp does not raise on its own; each carries a known HIGH.
|
||||
constraint-dependencies = [
|
||||
"cryptography==48.0.1",
|
||||
"joserfc==1.6.8",
|
||||
"mcp==1.28.1",
|
||||
"python-multipart==0.0.30"
|
||||
]
|
||||
|
||||
@@ -0,0 +1,264 @@
|
||||
"""Shared fixtures for the Prowler MCP Server test suite.
|
||||
|
||||
This module deliberately does not import ``prowler_mcp_server.server`` at module
|
||||
scope. That import builds every tool and reads the environment, so it must happen
|
||||
only once the environment is settled. Environment pinning itself lives in
|
||||
``[tool.pytest_env]`` in ``pyproject.toml``, which is applied before any conftest
|
||||
or test module is imported; the fixtures here only keep it pinned per test.
|
||||
|
||||
Three properties of the runtime shape everything below and are easy to get wrong:
|
||||
|
||||
1. ``prowler_app/server.py`` builds every tool at import time. A tool whose
|
||||
construction raises -- which is what happens with no API key -- is swallowed by
|
||||
``load_all_tools``, leaving the ``prowler_*`` namespace silently empty. So the
|
||||
suite pins a fake key rather than stripping the real one.
|
||||
2. ``BaseTool.__init__`` captured the ``ProwlerAPIClient`` singleton by reference
|
||||
at import time. Evicting it from the registry does not re-point the tools, so
|
||||
the client must be patched in place.
|
||||
3. ``ProwlerAppAuth`` resolves ``PROWLER_MCP_TRANSPORT_MODE`` and ``API_BASE_URL``
|
||||
in its default arguments, which are evaluated once at module import.
|
||||
``monkeypatch.setenv`` cannot change them -- pass ``mode=``/``base_url=``
|
||||
explicitly instead.
|
||||
"""
|
||||
|
||||
import socket
|
||||
from collections.abc import Callable, Iterator
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
from starlette.requests import Request
|
||||
from starlette.testclient import TestClient
|
||||
|
||||
from tests.helpers.http import MockRouter
|
||||
from tests.helpers.tokens import FAKE_API_KEY
|
||||
|
||||
# Must match [tool.pytest_env] in pyproject.toml: the env var is what the code
|
||||
# reads at import time, this constant is what tests assert against.
|
||||
TEST_API_BASE_URL = "https://api.testing.invalid/api/v1"
|
||||
|
||||
|
||||
# --------------------------------------------------------------- environment
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _pinned_environment(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Pin the runtime environment to deterministic test values.
|
||||
|
||||
Pinned rather than stripped: a missing ``PROWLER_API_KEY`` collapses the
|
||||
``prowler_*`` namespace to zero tools instead of failing loudly.
|
||||
``PROWLER_APP_API_KEY`` is the deprecated fallback and is removed so only a
|
||||
test that sets it exercises that path.
|
||||
|
||||
This also stops a developer's gitignored ``mcp_server/.env`` or shell
|
||||
environment from reaching the suite.
|
||||
"""
|
||||
monkeypatch.setenv("PROWLER_API_KEY", FAKE_API_KEY)
|
||||
monkeypatch.setenv("API_BASE_URL", TEST_API_BASE_URL)
|
||||
monkeypatch.setenv("PROWLER_MCP_TRANSPORT_MODE", "stdio")
|
||||
monkeypatch.delenv("PROWLER_APP_API_KEY", raising=False)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _no_real_network(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Fail loudly on any real outbound socket connection.
|
||||
|
||||
The subject under test is an HTTP client, so a route that was not mocked must
|
||||
fail fast and obviously rather than quietly reaching hub.prowler.com and
|
||||
making the suite slow, flaky and dependent on someone else's uptime.
|
||||
|
||||
In-process transports (Starlette's ``TestClient``, fastmcp's in-memory
|
||||
client) do not open sockets, so this does not interfere with them.
|
||||
"""
|
||||
|
||||
def _blocked(self: socket.socket, address: object, *_: object) -> None:
|
||||
raise RuntimeError(
|
||||
f"Blocked a real network connection to {address}. Drive HTTP through "
|
||||
"the mock_api_client, hub_router or docs_router fixtures."
|
||||
)
|
||||
|
||||
monkeypatch.setattr(socket.socket, "connect", _blocked)
|
||||
monkeypatch.setattr(socket.socket, "connect_ex", _blocked)
|
||||
|
||||
|
||||
# ----------------------------------------------------------------- API client
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _singleton_registry_guard() -> Iterator[None]:
|
||||
"""Snapshot and restore the singleton registry around every test.
|
||||
|
||||
Deliberately a snapshot, not a clear. ``BaseTool.__init__`` captured the
|
||||
``ProwlerAPIClient`` instance by reference at import time, so evicting it
|
||||
would leave every registered tool pointing at an orphan that later fixtures
|
||||
cannot patch -- one holding a real ``httpx.AsyncClient``. Restoring keeps a
|
||||
test that resets on purpose from leaking into the next one.
|
||||
"""
|
||||
from prowler_mcp_server.prowler_app.utils.api_client import SingletonMeta
|
||||
|
||||
snapshot = dict(SingletonMeta._instances)
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
SingletonMeta._instances.clear()
|
||||
SingletonMeta._instances.update(snapshot)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_router() -> MockRouter:
|
||||
"""An empty route registry and request recorder for this test."""
|
||||
return MockRouter()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def api_client():
|
||||
"""The live ``ProwlerAPIClient`` singleton that every registered tool holds."""
|
||||
from prowler_mcp_server.prowler_app.utils.api_client import ProwlerAPIClient
|
||||
|
||||
return ProwlerAPIClient()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_api_client(api_client, mock_router: MockRouter) -> Iterator:
|
||||
"""The API client singleton, with its transport driven by ``mock_router``.
|
||||
|
||||
Swaps ``.client`` in place rather than constructing a fresh client, so tools
|
||||
reached through the MCP protocol -- which hold this exact instance -- are
|
||||
mocked too. Everything else still runs for real: URL joining, query encoding,
|
||||
auth headers, ``raise_for_status()`` and the JSON:API error unwrapping.
|
||||
"""
|
||||
original = api_client.client
|
||||
api_client.client = httpx.AsyncClient(transport=mock_router.transport, timeout=30.0)
|
||||
try:
|
||||
yield api_client
|
||||
finally:
|
||||
api_client.client = original
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def isolated_api_client() -> Iterator[type]:
|
||||
"""Evict the singleton so a test can exercise construction semantics.
|
||||
|
||||
Only for tests *about* ``ProwlerAPIClient`` itself -- its ``__init__`` or its
|
||||
singleton identity. Anything reached through a tool must use
|
||||
``mock_api_client``, because the tools still point at the original instance.
|
||||
"""
|
||||
from prowler_mcp_server.prowler_app.utils.api_client import (
|
||||
ProwlerAPIClient,
|
||||
SingletonMeta,
|
||||
)
|
||||
|
||||
SingletonMeta._instances.pop(ProwlerAPIClient, None)
|
||||
yield ProwlerAPIClient
|
||||
|
||||
|
||||
# --------------------------------------------------------------- MCP surface
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def mcp_root_server():
|
||||
"""The mounted root MCP server, imported lazily because importing has effects.
|
||||
|
||||
Tests open their own client over this (``async with Client(mcp_root_server)``)
|
||||
rather than receiving a connected one, because FastMCP warns that holding a
|
||||
client in a fixture causes hard-to-diagnose event-loop problems.
|
||||
"""
|
||||
from prowler_mcp_server.server import prowler_mcp_server
|
||||
|
||||
return prowler_mcp_server
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def health_client() -> Iterator[TestClient]:
|
||||
"""An ASGI client over the stateless HTTP app, for the ``/health`` route."""
|
||||
from prowler_mcp_server.server import app
|
||||
|
||||
with TestClient(app) as client:
|
||||
yield client
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def http_request_headers() -> Iterator[Callable[..., None]]:
|
||||
"""Return a callable that makes ``get_http_headers()`` observe given headers.
|
||||
|
||||
In HTTP transport mode ``ProwlerAppAuth`` reads the authorization header
|
||||
through fastmcp's request context variable. Setting that variable directly is
|
||||
what lets an auth test run without standing up a real HTTP server.
|
||||
|
||||
Underscores in keyword names become hyphens, so ``x_request_id=`` sets
|
||||
``x-request-id``.
|
||||
"""
|
||||
from fastmcp.server.http import _current_http_request
|
||||
|
||||
def _set(**headers: str) -> None:
|
||||
scope = {
|
||||
"type": "http",
|
||||
"http_version": "1.1",
|
||||
"method": "POST",
|
||||
"path": "/mcp",
|
||||
"raw_path": b"/mcp",
|
||||
"root_path": "",
|
||||
"scheme": "http",
|
||||
"query_string": b"",
|
||||
"server": ("testserver", 80),
|
||||
"client": ("testclient", 50000),
|
||||
"headers": [
|
||||
(name.lower().replace("_", "-").encode(), value.encode())
|
||||
for name, value in headers.items()
|
||||
],
|
||||
}
|
||||
_current_http_request.set(Request(scope))
|
||||
|
||||
try:
|
||||
yield _set
|
||||
finally:
|
||||
# Not a token-based reset: an async test calls `_set` inside its task,
|
||||
# and asyncio gives each task its own copy of the context, so the token
|
||||
# cannot be reset from here and the task's value is discarded with the
|
||||
# task anyway. Clearing the value covers the sync-test case, where the
|
||||
# set would otherwise persist into the next test.
|
||||
_current_http_request.set(None)
|
||||
|
||||
|
||||
# ------------------------------------------------------- hub / docs sub-servers
|
||||
|
||||
|
||||
def _clone_with_transport(
|
||||
client: httpx.Client, transport: httpx.MockTransport
|
||||
) -> httpx.Client:
|
||||
"""Copy a sync client's base URL and headers onto a mock transport."""
|
||||
return httpx.Client(
|
||||
base_url=client.base_url,
|
||||
headers=dict(client.headers),
|
||||
transport=transport,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def hub_router(monkeypatch: pytest.MonkeyPatch, mock_router: MockRouter) -> MockRouter:
|
||||
"""Route the Prowler Hub sub-server's two module-level sync clients.
|
||||
|
||||
Hub tools are synchronous and reach for these clients by module global, so
|
||||
they are replaced on the module rather than injected.
|
||||
"""
|
||||
from prowler_mcp_server.prowler_hub import server as hub
|
||||
|
||||
for name in ("prowler_hub_client", "github_raw_client"):
|
||||
monkeypatch.setattr(
|
||||
hub, name, _clone_with_transport(getattr(hub, name), mock_router.transport)
|
||||
)
|
||||
return mock_router
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def docs_router(monkeypatch: pytest.MonkeyPatch, mock_router: MockRouter) -> MockRouter:
|
||||
"""Route the documentation search engine's two sync clients."""
|
||||
from prowler_mcp_server.prowler_documentation import server as docs
|
||||
|
||||
engine = docs.prowler_docs_search_engine
|
||||
for name in ("mintlify_client", "docs_client"):
|
||||
monkeypatch.setattr(
|
||||
engine,
|
||||
name,
|
||||
_clone_with_transport(getattr(engine, name), mock_router.transport),
|
||||
)
|
||||
return mock_router
|
||||
@@ -0,0 +1,49 @@
|
||||
"""Shared test helpers for the Prowler MCP Server suite.
|
||||
|
||||
Import from the submodules directly (``from tests.helpers.jsonapi import ...``);
|
||||
this package only re-exports the surface so it is discoverable in one place.
|
||||
|
||||
Nothing here is collected by pytest -- ``python_files`` is ``test_*.py``.
|
||||
"""
|
||||
|
||||
from tests.helpers.assertions import (
|
||||
NAMESPACES,
|
||||
assert_namespaced,
|
||||
assert_tool_contract,
|
||||
tools_in_namespace,
|
||||
)
|
||||
from tests.helpers.http import MockRouter
|
||||
from tests.helpers.jsonapi import (
|
||||
jsonapi_collection,
|
||||
jsonapi_document,
|
||||
jsonapi_error,
|
||||
jsonapi_relationship_many,
|
||||
jsonapi_relationship_one,
|
||||
jsonapi_resource,
|
||||
task_document,
|
||||
)
|
||||
from tests.helpers.tokens import (
|
||||
FAKE_API_KEY,
|
||||
FAKE_LEGACY_API_KEY,
|
||||
MALFORMED_API_KEY,
|
||||
fake_jwt,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"FAKE_API_KEY",
|
||||
"FAKE_LEGACY_API_KEY",
|
||||
"MALFORMED_API_KEY",
|
||||
"NAMESPACES",
|
||||
"MockRouter",
|
||||
"assert_namespaced",
|
||||
"assert_tool_contract",
|
||||
"fake_jwt",
|
||||
"jsonapi_collection",
|
||||
"jsonapi_document",
|
||||
"jsonapi_error",
|
||||
"jsonapi_relationship_many",
|
||||
"jsonapi_relationship_one",
|
||||
"jsonapi_resource",
|
||||
"task_document",
|
||||
"tools_in_namespace",
|
||||
]
|
||||
@@ -0,0 +1,66 @@
|
||||
"""Assertions for the MCP tool contract every sub-server must honour.
|
||||
|
||||
A tool's description and its parameter descriptions are not documentation -- they
|
||||
are the only thing a model sees when deciding whether and how to call it. A tool
|
||||
that registers without them is invisible in practice, so these are correctness
|
||||
assertions rather than style ones.
|
||||
"""
|
||||
|
||||
from mcp.types import Tool
|
||||
|
||||
# Mounted namespaces, most specific first so prefix matching is unambiguous.
|
||||
NAMESPACES = ("prowler_hub_", "prowler_docs_", "prowler_")
|
||||
|
||||
|
||||
def assert_tool_contract(tool: Tool) -> None:
|
||||
"""Assert the tool and all of its parameters carry a usable description.
|
||||
|
||||
Missing and blank are asserted separately because they are different
|
||||
mistakes: a missing description was never written, a blank one exists but was
|
||||
left empty. One truthiness check would report both the same way.
|
||||
"""
|
||||
assert tool.description is not None, (
|
||||
f"Tool '{tool.name}' has no description. Its docstring is what the model reads."
|
||||
)
|
||||
assert tool.description.strip(), (
|
||||
f"Tool '{tool.name}' has a blank description. "
|
||||
"Its docstring is what the model reads."
|
||||
)
|
||||
|
||||
# `inputSchema` is a required field of the MCP Tool type, so it is always a
|
||||
# dict; a tool that takes no arguments simply has no `properties`.
|
||||
for parameter, schema in tool.inputSchema.get("properties", {}).items():
|
||||
description = schema.get("description")
|
||||
assert description is not None, (
|
||||
f"Parameter '{parameter}' of tool '{tool.name}' has no description. "
|
||||
"Declare it with pydantic Field(description=...)."
|
||||
)
|
||||
assert description.strip(), (
|
||||
f"Parameter '{parameter}' of tool '{tool.name}' has a blank description. "
|
||||
"Declare it with pydantic Field(description=...)."
|
||||
)
|
||||
|
||||
|
||||
def assert_namespaced(tool: Tool) -> None:
|
||||
"""Assert the tool is reachable under one of the published namespaces."""
|
||||
assert tool.name.startswith(NAMESPACES), (
|
||||
f"Tool '{tool.name}' is outside the published namespaces {NAMESPACES}"
|
||||
)
|
||||
|
||||
|
||||
def tools_in_namespace(tools: list[Tool], namespace: str) -> list[Tool]:
|
||||
"""Return the tools in a namespace.
|
||||
|
||||
``prowler_`` is a prefix of the other two namespaces, so tools belonging to a
|
||||
more specific one are excluded rather than counted twice.
|
||||
"""
|
||||
more_specific = tuple(
|
||||
other
|
||||
for other in NAMESPACES
|
||||
if other != namespace and other.startswith(namespace)
|
||||
)
|
||||
return [
|
||||
tool
|
||||
for tool in tools
|
||||
if tool.name.startswith(namespace) and not tool.name.startswith(more_specific)
|
||||
]
|
||||
@@ -0,0 +1,118 @@
|
||||
"""Route registry and request recorder backed by ``httpx.MockTransport``.
|
||||
|
||||
Mocking at the transport boundary rather than stubbing ``client.request`` keeps
|
||||
the parts of httpx the code under test actually relies on in play: base-URL
|
||||
joining, query-parameter encoding, header assembly, ``raise_for_status()`` and
|
||||
JSON decoding. A test that asserts on a recorded request is therefore asserting
|
||||
on the bytes that would really have gone out.
|
||||
"""
|
||||
|
||||
import json
|
||||
from collections.abc import Callable
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
|
||||
_UNSET = object()
|
||||
|
||||
ResponseFactory = Callable[[httpx.Request], httpx.Response]
|
||||
|
||||
|
||||
class MockRouter:
|
||||
"""Declare ``(METHOD, path) -> response`` and inspect what was requested.
|
||||
|
||||
Responses registered for the same route are consumed in order and the last
|
||||
one repeats forever. That is what makes polling testable: register
|
||||
``executing``, ``executing``, ``completed`` and the loop sees each in turn.
|
||||
|
||||
An unregistered request raises instead of returning a default, so a test can
|
||||
never silently exercise a different endpoint than the one it set up.
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._routes: dict[tuple[str, str], list[ResponseFactory]] = {}
|
||||
self.requests: list[httpx.Request] = []
|
||||
|
||||
# --- registration -----------------------------------------------------
|
||||
|
||||
def add(
|
||||
self,
|
||||
method: str,
|
||||
path: str,
|
||||
*,
|
||||
status: int = 200,
|
||||
json: Any = _UNSET,
|
||||
text: str | None = None,
|
||||
headers: dict[str, str] | None = None,
|
||||
) -> "MockRouter":
|
||||
"""Register a canned response for a route. Chainable."""
|
||||
kwargs: dict[str, Any] = {"headers": headers}
|
||||
if json is not _UNSET:
|
||||
kwargs["json"] = json
|
||||
if text is not None:
|
||||
kwargs["text"] = text
|
||||
return self.add_handler(
|
||||
method, path, lambda _request: httpx.Response(status, **kwargs)
|
||||
)
|
||||
|
||||
def add_handler(
|
||||
self, method: str, path: str, handler: ResponseFactory
|
||||
) -> "MockRouter":
|
||||
"""Register a callable that builds the response from the request."""
|
||||
self._routes.setdefault((method.upper(), path), []).append(handler)
|
||||
return self
|
||||
|
||||
# --- transport --------------------------------------------------------
|
||||
|
||||
@property
|
||||
def transport(self) -> httpx.MockTransport:
|
||||
"""A transport that serves this router. Works for sync and async clients."""
|
||||
return httpx.MockTransport(self._handle)
|
||||
|
||||
def _handle(self, request: httpx.Request) -> httpx.Response:
|
||||
self.requests.append(request)
|
||||
queue = self._routes.get((request.method.upper(), request.url.path))
|
||||
if not queue:
|
||||
registered = (
|
||||
", ".join(f"{method} {path}" for method, path in sorted(self._routes))
|
||||
or "none"
|
||||
)
|
||||
raise AssertionError(
|
||||
f"Unregistered request {request.method} {request.url}. "
|
||||
f"Registered routes: {registered}"
|
||||
)
|
||||
# Keep the final response so a route can be polled repeatedly.
|
||||
factory = queue.pop(0) if len(queue) > 1 else queue[0]
|
||||
return factory(request)
|
||||
|
||||
# --- inspection -------------------------------------------------------
|
||||
|
||||
def request_for(self, method: str, path: str) -> httpx.Request:
|
||||
"""Return the last recorded request for a route, failing if there is none."""
|
||||
matches = [
|
||||
request
|
||||
for request in self.requests
|
||||
if request.method.upper() == method.upper() and request.url.path == path
|
||||
]
|
||||
if not matches:
|
||||
raise AssertionError(
|
||||
f"No {method.upper()} {path} request was made. Made: {self.paths()}"
|
||||
)
|
||||
return matches[-1]
|
||||
|
||||
def query_params(self, method: str, path: str) -> dict[str, str]:
|
||||
"""Return the decoded query parameters of the last request for a route."""
|
||||
return dict(self.request_for(method, path).url.params)
|
||||
|
||||
def json_body(self, method: str, path: str) -> Any:
|
||||
"""Return the decoded JSON body of the last request for a route.
|
||||
|
||||
Write tools build a JSON:API document by hand, and the API silently
|
||||
ignores an attribute it does not recognise, so the body is the only place
|
||||
a misspelled key shows up.
|
||||
"""
|
||||
return json.loads(self.request_for(method, path).content)
|
||||
|
||||
def paths(self) -> list[str]:
|
||||
"""Return every request made so far, as ``"METHOD /path"`` strings."""
|
||||
return [f"{request.method} {request.url.path}" for request in self.requests]
|
||||
@@ -0,0 +1,112 @@
|
||||
"""Builders for the JSON:API documents the Prowler API returns.
|
||||
|
||||
Every model's ``from_api_response()`` and every tool's error path consumes one of
|
||||
these shapes, so building them by hand in each test would duplicate the document
|
||||
structure hundreds of times. The builders keep the *shape* in one place so tests
|
||||
only express the part they actually care about.
|
||||
"""
|
||||
|
||||
from typing import Any
|
||||
|
||||
|
||||
def jsonapi_relationship_many(resource_type: str, *ids: str) -> dict[str, Any]:
|
||||
"""Build a to-many relationship.
|
||||
|
||||
Passing no ids yields a present-but-empty relationship (``{"data": []}``),
|
||||
which ``extract_relationship_ids`` reports as ``[]`` rather than ``None``.
|
||||
"""
|
||||
return {"data": [{"type": resource_type, "id": resource_id} for resource_id in ids]}
|
||||
|
||||
|
||||
def jsonapi_relationship_one(resource_type: str, resource_id: str) -> dict[str, Any]:
|
||||
"""Build a to-one relationship."""
|
||||
return {"data": {"type": resource_type, "id": resource_id}}
|
||||
|
||||
|
||||
def jsonapi_resource(
|
||||
resource_type: str,
|
||||
resource_id: str,
|
||||
attributes: dict[str, Any] | None = None,
|
||||
relationships: dict[str, Any] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Build a single JSON:API resource object.
|
||||
|
||||
``relationships`` is omitted from the result entirely when not supplied, so a
|
||||
test can express "the document did not expose this relationship"
|
||||
(``extract_relationship_ids`` -> ``None``) distinctly from "the relationship
|
||||
is present and empty" (-> ``[]``). Conflating the two is exactly the bug the
|
||||
models go out of their way to avoid.
|
||||
"""
|
||||
resource: dict[str, Any] = {
|
||||
"type": resource_type,
|
||||
"id": resource_id,
|
||||
"attributes": attributes or {},
|
||||
}
|
||||
if relationships is not None:
|
||||
resource["relationships"] = relationships
|
||||
return resource
|
||||
|
||||
|
||||
def jsonapi_document(
|
||||
data: dict[str, Any] | list[dict[str, Any]],
|
||||
included: list[dict[str, Any]] | None = None,
|
||||
meta: dict[str, Any] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Build a top-level JSON:API document."""
|
||||
document: dict[str, Any] = {"data": data}
|
||||
if included is not None:
|
||||
document["included"] = included
|
||||
if meta is not None:
|
||||
document["meta"] = meta
|
||||
return document
|
||||
|
||||
|
||||
def jsonapi_collection(
|
||||
items: list[dict[str, Any]],
|
||||
*,
|
||||
page: int = 1,
|
||||
pages: int = 1,
|
||||
count: int | None = None,
|
||||
included: list[dict[str, Any]] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Build a paginated collection document.
|
||||
|
||||
The ``meta.pagination`` keys are exactly the ones every ``*ListResponse``
|
||||
reads (``page``, ``pages``, ``count``). ``count`` defaults to the number of
|
||||
items so the common single-page case needs no arguments.
|
||||
"""
|
||||
return jsonapi_document(
|
||||
data=items,
|
||||
included=included,
|
||||
meta={
|
||||
"pagination": {
|
||||
"page": page,
|
||||
"pages": pages,
|
||||
"count": len(items) if count is None else count,
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def jsonapi_error(status: int, detail: str, title: str | None = None) -> dict[str, Any]:
|
||||
"""Build an error document.
|
||||
|
||||
``ProwlerAPIClient._make_request`` surfaces ``errors[0].detail`` in the
|
||||
exception message it raises, and tools relay that straight to the model.
|
||||
"""
|
||||
error: dict[str, Any] = {"status": str(status), "detail": detail}
|
||||
if title is not None:
|
||||
error["title"] = title
|
||||
return {"errors": [error]}
|
||||
|
||||
|
||||
def task_document(task_id: str, state: str, error: str | None = None) -> dict[str, Any]:
|
||||
"""Build a ``/tasks/{id}`` document for driving ``poll_task_until_complete``.
|
||||
|
||||
Register a sequence of these on a ``MockRouter`` route (for example
|
||||
``executing``, ``executing``, ``completed``) to exercise the polling loop.
|
||||
"""
|
||||
attributes: dict[str, Any] = {"state": state}
|
||||
if error is not None:
|
||||
attributes["error"] = error
|
||||
return jsonapi_document(jsonapi_resource("tasks", task_id, attributes))
|
||||
@@ -0,0 +1,34 @@
|
||||
"""Obviously-fake credentials for tests.
|
||||
|
||||
Deliberately unrealistic so repository secret scanning does not flag them. Never
|
||||
put a value here that could be mistaken for a real key.
|
||||
"""
|
||||
|
||||
import base64
|
||||
import json
|
||||
import time
|
||||
|
||||
# Prowler API keys are recognised by their `pk_` prefix; anything else is rejected.
|
||||
FAKE_API_KEY = "pk_fake_api_key_for_unit_testing_only"
|
||||
FAKE_LEGACY_API_KEY = "pk_fake_legacy_api_key_for_unit_testing_only"
|
||||
MALFORMED_API_KEY = "not_a_prowler_api_key"
|
||||
|
||||
|
||||
def fake_jwt(expires_in: int = 3600, **claims: object) -> str:
|
||||
"""Mint an unsigned JWT whose ``exp`` is ``expires_in`` seconds from now.
|
||||
|
||||
Pass a negative ``expires_in`` for an already-expired token.
|
||||
|
||||
``ProwlerAppAuth._parse_jwt`` only base64url-decodes the payload and reads
|
||||
``exp`` -- it never verifies the signature, because the Prowler API is what
|
||||
validates the token. A placeholder signature is therefore enough, and avoids
|
||||
adding a JWT library just for tests.
|
||||
"""
|
||||
|
||||
def _segment(payload: dict[str, object]) -> str:
|
||||
raw = json.dumps(payload, separators=(",", ":")).encode()
|
||||
return base64.urlsafe_b64encode(raw).decode().rstrip("=")
|
||||
|
||||
header = _segment({"alg": "HS256", "typ": "JWT"})
|
||||
body = _segment({"exp": int(time.time()) + expires_in, **claims})
|
||||
return f"{header}.{body}.fake-signature-not-verified"
|
||||
@@ -0,0 +1 @@
|
||||
"""Tests for the Prowler App sub-server."""
|
||||
@@ -0,0 +1 @@
|
||||
"""Tests for the Prowler App Pydantic models."""
|
||||
@@ -0,0 +1,205 @@
|
||||
"""Tests for the security finding models.
|
||||
|
||||
Reference for later branches: build the API document with the ``jsonapi``
|
||||
helpers, run it through ``from_api_response()``, then assert on both the model
|
||||
and its ``model_dump()``. The dump is what the agent actually receives, and
|
||||
``MinimalSerializerMixin`` makes the two differ.
|
||||
"""
|
||||
|
||||
from prowler_mcp_server.prowler_app.models.findings import (
|
||||
DetailedFinding,
|
||||
FindingsListResponse,
|
||||
FindingsOverview,
|
||||
SimplifiedFinding,
|
||||
)
|
||||
from tests.helpers.jsonapi import (
|
||||
jsonapi_collection,
|
||||
jsonapi_relationship_many,
|
||||
jsonapi_relationship_one,
|
||||
jsonapi_resource,
|
||||
)
|
||||
|
||||
CHECK_METADATA = {
|
||||
"checkid": "s3_bucket_public_access",
|
||||
"checktitle": "Ensure S3 buckets block public access",
|
||||
"description": "Checks whether the bucket blocks public access.",
|
||||
"provider": "aws",
|
||||
"servicename": "s3",
|
||||
"resourcetype": "AwsS3Bucket",
|
||||
"risk": "Public buckets expose data to the internet.",
|
||||
"additionalurls": ["https://docs.aws.amazon.com/s3/"],
|
||||
"categories": ["encryption", "internet-exposed"],
|
||||
}
|
||||
|
||||
FINDING_ATTRIBUTES = {
|
||||
"uid": "prowler-aws-s3_bucket_public_access-123456789012-us-east-1-my-bucket",
|
||||
"status": "FAIL",
|
||||
"severity": "high",
|
||||
"status_extended": "S3 bucket my-bucket is publicly accessible.",
|
||||
"delta": "new",
|
||||
"muted": False,
|
||||
"muted_reason": None,
|
||||
"check_metadata": CHECK_METADATA,
|
||||
}
|
||||
|
||||
DETAILED_ATTRIBUTES = {
|
||||
**FINDING_ATTRIBUTES,
|
||||
"inserted_at": "2025-01-15T10:00:00Z",
|
||||
"updated_at": "2025-01-15T10:00:00Z",
|
||||
"first_seen_at": "2025-01-10T09:00:00Z",
|
||||
}
|
||||
|
||||
|
||||
def test_simplified_finding_lifts_the_check_id_out_of_the_check_metadata():
|
||||
"""`check_id` is nested under `check_metadata.checkid` in the API document.
|
||||
|
||||
Flattening it is what lets an agent filter findings by check without being
|
||||
handed the whole metadata blob for every row in a list.
|
||||
"""
|
||||
finding = SimplifiedFinding.from_api_response(
|
||||
jsonapi_resource("findings", "f1", FINDING_ATTRIBUTES)
|
||||
)
|
||||
|
||||
assert finding.check_id == "s3_bucket_public_access"
|
||||
assert finding.severity == "high"
|
||||
assert finding.status == "FAIL"
|
||||
|
||||
|
||||
def test_empty_finding_fields_are_dropped_from_the_serialized_payload():
|
||||
"""Empty values are removed to keep the payload small for the model.
|
||||
|
||||
`muted_reason` is None on an unmuted finding; emitting it would spend tokens
|
||||
on every row of every list response to say nothing.
|
||||
"""
|
||||
finding = SimplifiedFinding.from_api_response(
|
||||
jsonapi_resource("findings", "f1", FINDING_ATTRIBUTES)
|
||||
)
|
||||
|
||||
dumped = finding.model_dump()
|
||||
|
||||
assert "muted_reason" not in dumped
|
||||
assert dumped["uid"] == FINDING_ATTRIBUTES["uid"]
|
||||
|
||||
|
||||
def test_detailed_finding_parses_both_relationship_shapes():
|
||||
"""`scan` is a to-one relationship and `resources` is to-many.
|
||||
|
||||
They are read from the same `relationships` object but reduce to a single id
|
||||
and a list of ids respectively.
|
||||
"""
|
||||
resource = jsonapi_resource(
|
||||
"findings",
|
||||
"f1",
|
||||
attributes=DETAILED_ATTRIBUTES,
|
||||
relationships={
|
||||
"scan": jsonapi_relationship_one("scans", "s1"),
|
||||
"resources": jsonapi_relationship_many("resources", "r1", "r2"),
|
||||
},
|
||||
)
|
||||
|
||||
finding = DetailedFinding.from_api_response(resource)
|
||||
|
||||
assert finding.scan_id == "s1"
|
||||
assert finding.resource_ids == ["r1", "r2"]
|
||||
|
||||
|
||||
def test_detailed_finding_tolerates_missing_relationships():
|
||||
"""A document without relationships must not raise.
|
||||
|
||||
`get_finding_details` requests `include=scan,resources`, but a finding whose
|
||||
scan has been pruned still has to render rather than fail the tool call.
|
||||
"""
|
||||
finding = DetailedFinding.from_api_response(
|
||||
jsonapi_resource("findings", "f1", DETAILED_ATTRIBUTES)
|
||||
)
|
||||
|
||||
assert finding.scan_id is None
|
||||
assert finding.resource_ids == []
|
||||
|
||||
|
||||
def test_detailed_finding_flattens_the_nested_remediation_guidance():
|
||||
"""Remediation is the payload an agent needs to actually fix the finding.
|
||||
|
||||
The API nests it under `remediation.code.*` and `remediation.recommendation.text`;
|
||||
the model flattens both into one object.
|
||||
"""
|
||||
attributes = {
|
||||
**DETAILED_ATTRIBUTES,
|
||||
"check_metadata": {
|
||||
**CHECK_METADATA,
|
||||
"remediation": {
|
||||
"code": {
|
||||
"cli": "aws s3api put-public-access-block ...",
|
||||
"terraform": 'resource "aws_s3_bucket_public_access_block" ...',
|
||||
"nativeiac": "",
|
||||
"other": "",
|
||||
},
|
||||
"recommendation": {"text": "Block all public access on the bucket."},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
finding = DetailedFinding.from_api_response(
|
||||
jsonapi_resource("findings", "f1", attributes)
|
||||
)
|
||||
|
||||
remediation = finding.check_metadata.remediation
|
||||
assert remediation.cli.startswith("aws s3api")
|
||||
assert remediation.recommendation == "Block all public access on the bucket."
|
||||
# Empty code snippets are dropped rather than shown as blank fields.
|
||||
assert "nativeiac" not in remediation.model_dump()
|
||||
|
||||
|
||||
def test_check_metadata_without_remediation_is_left_unset():
|
||||
"""Not every check ships remediation guidance; absence must not fabricate one."""
|
||||
finding = DetailedFinding.from_api_response(
|
||||
jsonapi_resource("findings", "f1", DETAILED_ATTRIBUTES)
|
||||
)
|
||||
|
||||
assert finding.check_metadata.remediation is None
|
||||
assert "remediation" not in finding.check_metadata.model_dump()
|
||||
|
||||
|
||||
def test_list_response_carries_the_api_pagination_metadata():
|
||||
"""Pagination tells an agent whether it has seen everything it asked for."""
|
||||
response = jsonapi_collection(
|
||||
[jsonapi_resource("findings", "f1", FINDING_ATTRIBUTES)],
|
||||
page=2,
|
||||
pages=7,
|
||||
count=312,
|
||||
)
|
||||
|
||||
result = FindingsListResponse.from_api_response(response)
|
||||
|
||||
assert result.current_page == 2
|
||||
assert result.total_num_pages == 7
|
||||
assert result.total_num_finding == 312
|
||||
assert result.findings[0].check_id == "s3_bucket_public_access"
|
||||
|
||||
|
||||
def test_overview_renames_the_pass_attribute_to_a_valid_identifier():
|
||||
"""The API's `pass` count cannot keep its name -- `pass` is a Python keyword."""
|
||||
response = jsonapi_resource(
|
||||
"findings-overview",
|
||||
"overview",
|
||||
{
|
||||
"total": 100,
|
||||
"fail": 30,
|
||||
"pass": 60,
|
||||
"muted": 10,
|
||||
"new": 5,
|
||||
"changed": 3,
|
||||
"fail_new": 2,
|
||||
"fail_changed": 1,
|
||||
"pass_new": 2,
|
||||
"pass_changed": 1,
|
||||
"muted_new": 1,
|
||||
"muted_changed": 1,
|
||||
},
|
||||
)
|
||||
|
||||
overview = FindingsOverview.from_api_response({"data": response})
|
||||
|
||||
assert overview.passed == 60
|
||||
assert overview.fail == 30
|
||||
assert overview.total == 100
|
||||
@@ -0,0 +1,280 @@
|
||||
"""Tests for the integration models.
|
||||
|
||||
Two things here are not ordinary serialization and carry the weight of the
|
||||
module: the Security Hub ``regions`` map, which is rewritten into the far smaller
|
||||
``enabled_regions`` list before an agent ever sees it, and the Jira dispatch
|
||||
result, whose ``safe_to_retry`` flag is the only thing standing between a
|
||||
half-finished dispatch and a project full of duplicated work items.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
from prowler_mcp_server.prowler_app.models.integrations import (
|
||||
DetailedIntegration,
|
||||
IntegrationConnectionStatus,
|
||||
IntegrationsListResponse,
|
||||
JiraDispatchResult,
|
||||
JiraIssueTypes,
|
||||
SimplifiedIntegration,
|
||||
)
|
||||
from tests.helpers.jsonapi import (
|
||||
jsonapi_collection,
|
||||
jsonapi_relationship_many,
|
||||
jsonapi_resource,
|
||||
)
|
||||
|
||||
S3_ATTRIBUTES = {
|
||||
"integration_type": "amazon_s3",
|
||||
"enabled": True,
|
||||
"connected": True,
|
||||
"connection_last_checked_at": "2025-01-15T10:00:00Z",
|
||||
"inserted_at": "2025-01-10T09:00:00Z",
|
||||
"updated_at": "2025-01-15T10:00:00Z",
|
||||
"configuration": {"bucket_name": "my-reports", "output_directory": "prowler"},
|
||||
}
|
||||
|
||||
SECURITY_HUB_ATTRIBUTES = {
|
||||
"integration_type": "aws_security_hub",
|
||||
"enabled": True,
|
||||
"connected": True,
|
||||
"configuration": {
|
||||
"send_only_fails": True,
|
||||
"archive_previous_findings": False,
|
||||
"regions": {"us-east-1": True, "eu-west-1": False, "eu-west-3": True},
|
||||
},
|
||||
}
|
||||
|
||||
JIRA_ATTRIBUTES = {
|
||||
"integration_type": "jira",
|
||||
"enabled": True,
|
||||
"connected": None,
|
||||
"configuration": {"domain": "acme", "projects": {}, "issue_types": {}},
|
||||
}
|
||||
|
||||
|
||||
def test_simplified_integration_lifts_the_attached_provider_ids():
|
||||
"""`provider_ids` comes from the relationship linkage, not the attributes.
|
||||
|
||||
It is what tells an agent whether an integration covers the account it is
|
||||
looking at, so reading it out of the wrong place silently scopes every
|
||||
integration to the whole tenant.
|
||||
"""
|
||||
integration = SimplifiedIntegration.from_api_response(
|
||||
jsonapi_resource(
|
||||
"integrations",
|
||||
"i1",
|
||||
S3_ATTRIBUTES,
|
||||
relationships={
|
||||
"providers": jsonapi_relationship_many("providers", "p1", "p2")
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
assert integration.provider_ids == ["p1", "p2"]
|
||||
assert integration.integration_type == "amazon_s3"
|
||||
|
||||
|
||||
def test_a_never_checked_integration_still_reports_its_connected_field():
|
||||
"""`connected: null` means "never checked", which is not "not connected".
|
||||
|
||||
Every other empty value is dropped to save tokens, so without the override
|
||||
this field would vanish exactly when its absence is most misleading.
|
||||
"""
|
||||
integration = SimplifiedIntegration.from_api_response(
|
||||
jsonapi_resource("integrations", "i1", {**JIRA_ATTRIBUTES, "connected": None})
|
||||
)
|
||||
|
||||
dumped = integration.model_dump()
|
||||
|
||||
assert dumped["connected"] is None
|
||||
# Contrast: an untouched empty field is dropped
|
||||
assert "connection_last_checked_at" not in dumped
|
||||
|
||||
|
||||
def test_the_list_view_drops_a_configuration_the_api_still_sends():
|
||||
"""The sparse fieldset asks the API to leave `configuration` out.
|
||||
|
||||
The model must drop it anyway rather than pass it through: the fieldset is a
|
||||
request, not a guarantee, and a Jira configuration listing every project of
|
||||
the site is exactly what the separate detailed view exists to hold back.
|
||||
"""
|
||||
integration = SimplifiedIntegration.from_api_response(
|
||||
jsonapi_resource("integrations", "i1", JIRA_ATTRIBUTES)
|
||||
)
|
||||
|
||||
assert "configuration" not in integration.model_dump()
|
||||
|
||||
|
||||
def test_security_hub_regions_are_collapsed_into_the_enabled_ones():
|
||||
"""The API returns every region of the partition with a boolean.
|
||||
|
||||
Only the enabled ones carry information, so the map is rewritten as a sorted
|
||||
list. Passing the raw map through would spend tokens listing dozens of
|
||||
regions to say "no".
|
||||
"""
|
||||
integration = DetailedIntegration.from_api_response(
|
||||
jsonapi_resource("integrations", "i1", SECURITY_HUB_ATTRIBUTES)
|
||||
)
|
||||
|
||||
assert integration.configuration["enabled_regions"] == ["eu-west-3", "us-east-1"]
|
||||
assert "regions" not in integration.configuration
|
||||
|
||||
|
||||
def test_an_unexpected_regions_shape_is_preserved_rather_than_dropped():
|
||||
"""A shape the rewrite does not understand is kept verbatim.
|
||||
|
||||
Silently dropping it would hide a real API change behind an integration that
|
||||
merely looks like it has no regions enabled.
|
||||
"""
|
||||
attributes = {
|
||||
**SECURITY_HUB_ATTRIBUTES,
|
||||
"configuration": {"regions": ["us-east-1"]},
|
||||
}
|
||||
|
||||
integration = DetailedIntegration.from_api_response(
|
||||
jsonapi_resource("integrations", "i1", attributes)
|
||||
)
|
||||
|
||||
assert integration.configuration["regions"] == ["us-east-1"]
|
||||
assert "enabled_regions" not in integration.configuration
|
||||
|
||||
|
||||
def test_a_non_security_hub_configuration_is_passed_through_untouched():
|
||||
"""Only Security Hub has a configuration worth rewriting."""
|
||||
integration = DetailedIntegration.from_api_response(
|
||||
jsonapi_resource("integrations", "i1", S3_ATTRIBUTES)
|
||||
)
|
||||
|
||||
assert integration.configuration == S3_ATTRIBUTES["configuration"]
|
||||
|
||||
|
||||
def test_the_list_response_reports_the_pagination_of_the_whole_query():
|
||||
"""Counts come from `meta.pagination`, not from the length of this page."""
|
||||
response = IntegrationsListResponse.from_api_response(
|
||||
jsonapi_collection(
|
||||
[jsonapi_resource("integrations", "i1", S3_ATTRIBUTES)],
|
||||
page=2,
|
||||
pages=3,
|
||||
count=7,
|
||||
)
|
||||
)
|
||||
|
||||
assert [integration.id for integration in response.integrations] == ["i1"]
|
||||
assert (response.total_num_integrations, response.total_num_pages) == (7, 3)
|
||||
assert response.current_page == 2
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("connected", "expected"),
|
||||
[(True, "connected"), (False, "failed"), (None, "not_tested")],
|
||||
)
|
||||
def test_the_connection_check_maps_its_tri_state_onto_a_readable_outcome(
|
||||
connected, expected
|
||||
):
|
||||
"""`null` is "the check did not run", which is not the same as a failure.
|
||||
|
||||
Collapsing it onto `failed` would send an agent chasing credentials that were
|
||||
never actually tested.
|
||||
"""
|
||||
status = IntegrationConnectionStatus.create(
|
||||
jsonapi_resource("integrations", "i1", S3_ATTRIBUTES),
|
||||
{"connected": connected},
|
||||
)
|
||||
|
||||
assert status.connected == expected
|
||||
|
||||
|
||||
def test_an_unreadable_connection_result_raises_instead_of_guessing():
|
||||
"""Anything other than a boolean or null is an API change, not a failure."""
|
||||
with pytest.raises(ValueError, match="unexpected connection check result"):
|
||||
IntegrationConnectionStatus.create(
|
||||
jsonapi_resource("integrations", "i1", S3_ATTRIBUTES),
|
||||
{"connected": "yes"},
|
||||
)
|
||||
|
||||
|
||||
def test_the_connection_error_is_only_reported_when_there_is_one():
|
||||
"""A successful check must not carry an empty `error` key."""
|
||||
status = IntegrationConnectionStatus.create(
|
||||
jsonapi_resource("integrations", "i1", S3_ATTRIBUTES), {"connected": True}
|
||||
)
|
||||
|
||||
assert "error" not in status.model_dump()
|
||||
|
||||
|
||||
def test_jira_issue_types_are_read_from_a_wrapped_or_a_bare_payload():
|
||||
"""This endpoint returns a non-model resource, so both shapes must work."""
|
||||
wrapped = JiraIssueTypes.from_api_response(
|
||||
jsonapi_resource(
|
||||
"jira-issue-types", "i1", {"project_key": "PROJ", "issue_types": ["Task"]}
|
||||
)
|
||||
)
|
||||
bare = JiraIssueTypes.from_api_response(
|
||||
{"project_key": "PROJ", "issue_types": ["Task"]}
|
||||
)
|
||||
|
||||
assert (
|
||||
wrapped.model_dump()
|
||||
== bare.model_dump()
|
||||
== {
|
||||
"project_key": "PROJ",
|
||||
"issue_types": ["Task"],
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def test_an_unreadable_issue_types_payload_raises():
|
||||
"""Returning an empty list would read as "this project has no issue types"."""
|
||||
with pytest.raises(ValueError, match="unexpected Jira issue types payload"):
|
||||
JiraIssueTypes.from_api_response({"project_key": "PROJ"})
|
||||
|
||||
|
||||
def test_a_dispatch_that_created_nothing_is_the_only_one_safe_to_retry():
|
||||
"""Work items are created one by one and Prowler cannot delete them.
|
||||
|
||||
So a retry is only safe when the run provably created none. Anything else
|
||||
duplicates work items in a project a human then has to clean up.
|
||||
"""
|
||||
empty = JiraDispatchResult.from_task_result({"created_count": 0, "failed_count": 3})
|
||||
partial = JiraDispatchResult.from_task_result(
|
||||
{"created_count": 1, "failed_count": 2}
|
||||
)
|
||||
|
||||
assert empty.safe_to_retry is True
|
||||
assert partial.safe_to_retry is False
|
||||
|
||||
|
||||
def test_a_zero_count_survives_serialization():
|
||||
"""Zero created work items is an outcome; an unknown count is not.
|
||||
|
||||
The minimal serializer drops empty values, so without the override a fully
|
||||
failed dispatch would report no counts at all.
|
||||
"""
|
||||
dumped = JiraDispatchResult.from_task_result(
|
||||
{"created_count": 0, "failed_count": 3}
|
||||
).model_dump()
|
||||
|
||||
assert dumped["created_count"] == 0
|
||||
assert dumped["failed_count"] == 3
|
||||
assert dumped["status"] == "completed"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"result",
|
||||
[
|
||||
{"failed_count": 2},
|
||||
{"created_count": 1},
|
||||
{"created_count": "1", "failed_count": 0},
|
||||
None,
|
||||
"done",
|
||||
],
|
||||
ids=["no-created", "no-failed", "not-an-int", "null", "not-an-object"],
|
||||
)
|
||||
def test_a_dispatch_result_without_usable_counters_raises(result):
|
||||
"""Defaulting the counters to zero would report the run as safe to retry.
|
||||
|
||||
That is the one wrong answer here: it invites a second dispatch on top of
|
||||
work items that may already exist.
|
||||
"""
|
||||
with pytest.raises(ValueError, match="dispatch task did not report"):
|
||||
JiraDispatchResult.from_task_result(result)
|
||||
@@ -0,0 +1,57 @@
|
||||
"""Tests for the shared JSON:API response-parsing helpers.
|
||||
|
||||
These back every model's ``from_api_response()``, so they are foundation-level
|
||||
rather than tied to any one feature.
|
||||
"""
|
||||
|
||||
from prowler_mcp_server.prowler_app.models.utils import extract_relationship_ids
|
||||
from tests.helpers.jsonapi import jsonapi_relationship_many, jsonapi_relationship_one
|
||||
|
||||
|
||||
def test_an_absent_relationship_is_unknown_rather_than_empty():
|
||||
"""A relationship the document never mentioned yields None, not [].
|
||||
|
||||
Returning [] would tell an agent "this role is assigned to nobody" when the
|
||||
serializer simply did not expose the relationship -- for example a role
|
||||
included via `?include=roles`, which carries no `users`.
|
||||
"""
|
||||
assert extract_relationship_ids({}, "users") is None
|
||||
|
||||
|
||||
def test_a_present_but_empty_relationship_is_explicitly_empty():
|
||||
"""An empty relationship yields [], which genuinely means "none"."""
|
||||
relationships = {"users": jsonapi_relationship_many("users")}
|
||||
|
||||
assert extract_relationship_ids(relationships, "users") == []
|
||||
|
||||
|
||||
def test_a_to_many_relationship_is_flattened_to_its_ids():
|
||||
"""Linkage objects are reduced to the plain ids the tools pass around."""
|
||||
relationships = {"users": jsonapi_relationship_many("users", "u1", "u2")}
|
||||
|
||||
assert extract_relationship_ids(relationships, "users") == ["u1", "u2"]
|
||||
|
||||
|
||||
def test_a_to_one_relationship_is_returned_as_a_single_element_list():
|
||||
"""To-one and to-many both return a list so callers need no shape check."""
|
||||
relationships = {"scan": jsonapi_relationship_one("scans", "s1")}
|
||||
|
||||
assert extract_relationship_ids(relationships, "scan") == ["s1"]
|
||||
|
||||
|
||||
def test_a_null_to_one_relationship_is_empty():
|
||||
"""An explicitly null to-one link means "not related", not "unknown"."""
|
||||
relationships = {"scan": {"data": None}}
|
||||
|
||||
assert extract_relationship_ids(relationships, "scan") == []
|
||||
|
||||
|
||||
def test_members_without_an_id_are_discarded():
|
||||
"""Malformed linkage must not surface as a None entry in the id list.
|
||||
|
||||
A None id would flow into a tool's next request and produce a confusing
|
||||
404 rather than a clean, short list.
|
||||
"""
|
||||
relationships = {"users": {"data": [{"type": "users", "id": "u1"}, {}]}}
|
||||
|
||||
assert extract_relationship_ids(relationships, "users") == ["u1"]
|
||||
@@ -0,0 +1 @@
|
||||
"""Tests for the Prowler App MCP tools."""
|
||||
@@ -0,0 +1,347 @@
|
||||
"""Tests for the security findings tools.
|
||||
|
||||
Reference for later branches. Drive tools through an in-memory MCP client by
|
||||
default. Tool parameters are declared with pydantic ``Field(default=...)``, and
|
||||
those defaults are only resolved by FastMCP's tool wrapper -- calling the method
|
||||
directly leaves an omitted argument as a raw ``FieldInfo`` object, which is
|
||||
truthy and silently produces nonsense filters. Call the method directly only when
|
||||
passing every argument explicitly.
|
||||
|
||||
Everything here relies on ``mock_api_client`` patching the API client *in place*:
|
||||
the tool instances captured that exact object when the package was imported, so a
|
||||
freshly-constructed client would not reach them.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from fastmcp import Client
|
||||
|
||||
from tests.helpers.jsonapi import (
|
||||
jsonapi_collection,
|
||||
jsonapi_error,
|
||||
jsonapi_relationship_one,
|
||||
jsonapi_resource,
|
||||
)
|
||||
|
||||
LATEST = "/api/v1/findings/latest"
|
||||
HISTORICAL = "/api/v1/findings"
|
||||
|
||||
CHECK_METADATA = {
|
||||
"checkid": "s3_bucket_public_access",
|
||||
"checktitle": "Ensure S3 buckets block public access",
|
||||
"description": "Checks whether the bucket blocks public access.",
|
||||
"provider": "aws",
|
||||
"servicename": "s3",
|
||||
"resourcetype": "AwsS3Bucket",
|
||||
"risk": "Public buckets expose data to the internet.",
|
||||
"additionalurls": [],
|
||||
"categories": ["internet-exposed"],
|
||||
}
|
||||
|
||||
FINDING_ATTRIBUTES = {
|
||||
"uid": "prowler-aws-s3_bucket_public_access-123456789012-us-east-1-my-bucket",
|
||||
"status": "FAIL",
|
||||
"severity": "high",
|
||||
"status_extended": "S3 bucket my-bucket is publicly accessible.",
|
||||
"delta": "new",
|
||||
"muted": False,
|
||||
"muted_reason": None,
|
||||
"check_metadata": CHECK_METADATA,
|
||||
}
|
||||
|
||||
|
||||
async def test_search_without_dates_queries_the_latest_scan_endpoint(
|
||||
mcp_root_server, mock_api_client, mock_router
|
||||
):
|
||||
"""With no date range the tool targets `/findings/latest`.
|
||||
|
||||
That endpoint reads only the most recent completed scan, which is far cheaper
|
||||
than a historical query -- so picking the wrong one is a performance
|
||||
regression the response body alone would not reveal.
|
||||
"""
|
||||
mock_router.add(
|
||||
"GET",
|
||||
LATEST,
|
||||
json=jsonapi_collection(
|
||||
[jsonapi_resource("findings", "f1", FINDING_ATTRIBUTES)]
|
||||
),
|
||||
)
|
||||
|
||||
async with Client(mcp_root_server) as client:
|
||||
result = await client.call_tool("prowler_search_security_findings", {})
|
||||
|
||||
assert result.data["findings"][0]["check_id"] == "s3_bucket_public_access"
|
||||
assert mock_router.paths() == [f"GET {LATEST}"]
|
||||
|
||||
|
||||
async def test_search_defaults_to_failed_findings_only(
|
||||
mcp_root_server, mock_api_client, mock_router
|
||||
):
|
||||
"""The default filter is FAIL, so an unqualified search surfaces real issues.
|
||||
|
||||
Also pins the sort order and field selection, which together keep the
|
||||
response small and severity-first.
|
||||
"""
|
||||
mock_router.add("GET", LATEST, json=jsonapi_collection([]))
|
||||
|
||||
async with Client(mcp_root_server) as client:
|
||||
await client.call_tool("prowler_search_security_findings", {})
|
||||
|
||||
params = mock_router.query_params("GET", LATEST)
|
||||
assert params["filter[status__in]"] == "FAIL"
|
||||
assert params["sort"] == "severity,-inserted_at"
|
||||
assert params["page[size]"] == "50"
|
||||
|
||||
|
||||
async def test_search_with_dates_switches_to_the_historical_endpoint(
|
||||
mcp_root_server, mock_api_client, mock_router
|
||||
):
|
||||
"""A date range moves the query to `/findings` with an inserted_at window.
|
||||
|
||||
Supplying only `date_from` auto-completes the other boundary, so the caller
|
||||
cannot accidentally request an unbounded historical scan.
|
||||
"""
|
||||
mock_router.add("GET", HISTORICAL, json=jsonapi_collection([]))
|
||||
|
||||
async with Client(mcp_root_server) as client:
|
||||
await client.call_tool(
|
||||
"prowler_search_security_findings", {"date_from": "2025-01-15"}
|
||||
)
|
||||
|
||||
params = mock_router.query_params("GET", HISTORICAL)
|
||||
assert params["filter[inserted_at__gte]"] == "2025-01-15"
|
||||
assert params["filter[inserted_at__lte]"] == "2025-01-16"
|
||||
|
||||
|
||||
async def test_search_rejects_a_date_range_wider_than_the_api_allows(
|
||||
mcp_root_server, mock_api_client, mock_router
|
||||
):
|
||||
"""The API caps historical queries at two days; reject before the round trip."""
|
||||
async with Client(mcp_root_server) as client:
|
||||
with pytest.raises(Exception, match="Date range cannot exceed 2 days"):
|
||||
await client.call_tool(
|
||||
"prowler_search_security_findings",
|
||||
{"date_from": "2025-01-01", "date_to": "2025-01-10"},
|
||||
)
|
||||
|
||||
assert mock_router.requests == []
|
||||
|
||||
|
||||
async def test_search_encodes_list_filters_as_comma_separated_values(
|
||||
mcp_root_server, mock_api_client, mock_router
|
||||
):
|
||||
"""Multi-value filters reach the API as CSV, not as repeated query keys."""
|
||||
mock_router.add("GET", LATEST, json=jsonapi_collection([]))
|
||||
|
||||
async with Client(mcp_root_server) as client:
|
||||
await client.call_tool(
|
||||
"prowler_search_security_findings",
|
||||
{"severity": ["critical", "high"], "service": ["s3", "ec2"]},
|
||||
)
|
||||
|
||||
params = mock_router.query_params("GET", LATEST)
|
||||
assert params["filter[severity__in]"] == "critical,high"
|
||||
assert params["filter[service__in]"] == "s3,ec2"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("argument", "value", "expected_key", "expected_value"),
|
||||
[
|
||||
("provider_type", ["aws", "gcp"], "filter[provider_type__in]", "aws,gcp"),
|
||||
("provider_alias", "prod", "filter[provider_alias__icontains]", "prod"),
|
||||
("region", ["us-east-1"], "filter[region__in]", "us-east-1"),
|
||||
("resource_type", ["AwsS3Bucket"], "filter[resource_type__in]", "AwsS3Bucket"),
|
||||
(
|
||||
"check_id",
|
||||
["s3_bucket_public_access"],
|
||||
"filter[check_id__in]",
|
||||
"s3_bucket_public_access",
|
||||
),
|
||||
("delta", ["new"], "filter[delta__in]", "new"),
|
||||
("search", "bucket", "filter[search]", "bucket"),
|
||||
],
|
||||
)
|
||||
async def test_search_maps_each_argument_onto_its_api_filter(
|
||||
mcp_root_server,
|
||||
mock_api_client,
|
||||
mock_router,
|
||||
argument,
|
||||
value,
|
||||
expected_key,
|
||||
expected_value,
|
||||
):
|
||||
"""Every search argument maps to a specific API filter key.
|
||||
|
||||
A mistyped filter key is not an error the API reports -- it is simply ignored,
|
||||
so the tool returns unfiltered results while appearing to work. Pinning the
|
||||
exact key per argument is the only thing that catches that.
|
||||
"""
|
||||
mock_router.add("GET", LATEST, json=jsonapi_collection([]))
|
||||
|
||||
async with Client(mcp_root_server) as client:
|
||||
await client.call_tool("prowler_search_security_findings", {argument: value})
|
||||
|
||||
assert mock_router.query_params("GET", LATEST)[expected_key] == expected_value
|
||||
|
||||
|
||||
async def test_overview_can_be_scoped_to_a_provider(
|
||||
mcp_root_server, mock_api_client, mock_router
|
||||
):
|
||||
"""The aggregate report accepts the same provider filter as the search tool."""
|
||||
mock_router.add(
|
||||
"GET",
|
||||
"/api/v1/overviews/findings",
|
||||
json={
|
||||
"data": jsonapi_resource(
|
||||
"findings-overview",
|
||||
"overview",
|
||||
dict.fromkeys(
|
||||
[
|
||||
"total",
|
||||
"fail",
|
||||
"pass",
|
||||
"muted",
|
||||
"new",
|
||||
"changed",
|
||||
"fail_new",
|
||||
"fail_changed",
|
||||
"pass_new",
|
||||
"pass_changed",
|
||||
"muted_new",
|
||||
"muted_changed",
|
||||
],
|
||||
0,
|
||||
),
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
async with Client(mcp_root_server) as client:
|
||||
await client.call_tool(
|
||||
"prowler_get_findings_overview", {"provider_type": ["aws"]}
|
||||
)
|
||||
|
||||
params = mock_router.query_params("GET", "/api/v1/overviews/findings")
|
||||
assert params["filter[provider_type__in]"] == "aws"
|
||||
|
||||
|
||||
async def test_search_normalises_a_string_muted_flag_to_a_boolean(
|
||||
mcp_root_server, mock_api_client, mock_router
|
||||
):
|
||||
"""`muted` accepts a string because some MCP clients send booleans as text.
|
||||
|
||||
It still has to reach the API as a lowercase boolean, otherwise the filter is
|
||||
silently ignored and the agent gets muted findings it asked to exclude.
|
||||
"""
|
||||
mock_router.add("GET", LATEST, json=jsonapi_collection([]))
|
||||
|
||||
async with Client(mcp_root_server) as client:
|
||||
await client.call_tool("prowler_search_security_findings", {"muted": "true"})
|
||||
|
||||
assert mock_router.query_params("GET", LATEST)["filter[muted]"] == "true"
|
||||
|
||||
|
||||
async def test_search_rejects_an_out_of_range_page_size(
|
||||
mcp_root_server, mock_api_client, mock_router
|
||||
):
|
||||
"""Page size is validated locally, saving a round trip on an obvious mistake."""
|
||||
async with Client(mcp_root_server) as client:
|
||||
with pytest.raises(Exception, match="Must be between 1 and 1000"):
|
||||
await client.call_tool(
|
||||
"prowler_search_security_findings", {"page_size": 5000}
|
||||
)
|
||||
|
||||
assert mock_router.requests == []
|
||||
|
||||
|
||||
async def test_get_finding_details_requests_its_relationships(
|
||||
mcp_root_server, mock_api_client, mock_router
|
||||
):
|
||||
"""Details are only useful with the scan and resources included.
|
||||
|
||||
Dropping the `include` would leave `scan_id` and `resource_ids` empty and the
|
||||
agent unable to pivot from a finding to the resource it concerns.
|
||||
"""
|
||||
attributes = {
|
||||
**FINDING_ATTRIBUTES,
|
||||
"inserted_at": "2025-01-15T10:00:00Z",
|
||||
"updated_at": "2025-01-15T10:00:00Z",
|
||||
}
|
||||
mock_router.add(
|
||||
"GET",
|
||||
f"{HISTORICAL}/f1",
|
||||
json={
|
||||
"data": jsonapi_resource(
|
||||
"findings",
|
||||
"f1",
|
||||
attributes,
|
||||
relationships={"scan": jsonapi_relationship_one("scans", "s1")},
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
async with Client(mcp_root_server) as client:
|
||||
result = await client.call_tool(
|
||||
"prowler_get_finding_details", {"finding_id": "f1"}
|
||||
)
|
||||
|
||||
assert result.data["scan_id"] == "s1"
|
||||
assert mock_router.query_params("GET", f"{HISTORICAL}/f1")["include"] == (
|
||||
"scan,resources"
|
||||
)
|
||||
|
||||
|
||||
async def test_get_finding_details_surfaces_the_api_error_detail(
|
||||
mcp_root_server, mock_api_client, mock_router
|
||||
):
|
||||
"""A missing finding surfaces the API's message rather than an opaque failure."""
|
||||
mock_router.add(
|
||||
"GET", f"{HISTORICAL}/nope", status=404, json=jsonapi_error(404, "Not found.")
|
||||
)
|
||||
|
||||
async with Client(mcp_root_server) as client:
|
||||
with pytest.raises(Exception, match="Not found."):
|
||||
await client.call_tool(
|
||||
"prowler_get_finding_details", {"finding_id": "nope"}
|
||||
)
|
||||
|
||||
|
||||
async def test_overview_renders_a_markdown_report_with_percentages(
|
||||
mcp_root_server, mock_api_client, mock_router
|
||||
):
|
||||
"""The overview returns prose, not a model, so the arithmetic is the contract.
|
||||
|
||||
Percentages are derived here rather than by the API, which makes them the one
|
||||
part of this tool that can silently go wrong.
|
||||
"""
|
||||
mock_router.add(
|
||||
"GET",
|
||||
"/api/v1/overviews/findings",
|
||||
json={
|
||||
"data": jsonapi_resource(
|
||||
"findings-overview",
|
||||
"overview",
|
||||
{
|
||||
"total": 200,
|
||||
"fail": 50,
|
||||
"pass": 130,
|
||||
"muted": 20,
|
||||
"new": 10,
|
||||
"changed": 4,
|
||||
"fail_new": 6,
|
||||
"fail_changed": 2,
|
||||
"pass_new": 3,
|
||||
"pass_changed": 1,
|
||||
"muted_new": 1,
|
||||
"muted_changed": 1,
|
||||
},
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
async with Client(mcp_root_server) as client:
|
||||
result = await client.call_tool("prowler_get_findings_overview", {})
|
||||
|
||||
report = result.data["report"]
|
||||
assert "**Total Findings**: 200" in report
|
||||
assert "**Failed Checks**: 50 (25.0%)" in report
|
||||
assert "**Unchanged**: 186" in report
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1 @@
|
||||
"""Tests for the Prowler App shared utilities."""
|
||||
@@ -0,0 +1,83 @@
|
||||
"""Tests for the shared Prowler API client.
|
||||
|
||||
Reference for later branches: drive the client through ``mock_api_client`` +
|
||||
``mock_router`` and assert on the recorded request, so the real URL joining,
|
||||
query encoding and header assembly stay covered.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
from tests.helpers.jsonapi import jsonapi_collection, jsonapi_error, jsonapi_resource
|
||||
from tests.helpers.tokens import FAKE_API_KEY
|
||||
|
||||
|
||||
async def test_get_sends_an_authenticated_jsonapi_request(mock_api_client, mock_router):
|
||||
"""A GET carries the API key and the JSON:API content negotiation headers."""
|
||||
mock_router.add(
|
||||
"GET",
|
||||
"/api/v1/findings",
|
||||
json=jsonapi_collection(
|
||||
[jsonapi_resource("findings", "f1", {"severity": "high"})]
|
||||
),
|
||||
)
|
||||
|
||||
await mock_api_client.get("/findings")
|
||||
|
||||
request = mock_router.request_for("GET", "/api/v1/findings")
|
||||
assert request.headers["authorization"] == f"Api-Key {FAKE_API_KEY}"
|
||||
assert request.headers["accept"] == "application/vnd.api+json"
|
||||
assert request.headers["user-agent"].startswith("prowler-mcp-server/")
|
||||
|
||||
|
||||
async def test_get_forwards_query_parameters(mock_api_client, mock_router):
|
||||
"""Filter parameters reach the wire with their JSON:API bracket syntax intact."""
|
||||
mock_router.add("GET", "/api/v1/findings", json=jsonapi_collection([]))
|
||||
|
||||
await mock_api_client.get(
|
||||
"/findings", params={"page[size]": 5, "filter[severity__in]": "critical"}
|
||||
)
|
||||
|
||||
assert mock_router.query_params("GET", "/api/v1/findings") == {
|
||||
"page[size]": "5",
|
||||
"filter[severity__in]": "critical",
|
||||
}
|
||||
|
||||
|
||||
async def test_error_response_surfaces_the_jsonapi_detail(mock_api_client, mock_router):
|
||||
"""A failed request is raised with the API's own `errors[].detail` message.
|
||||
|
||||
Tools relay this text straight to the model, so losing it turns an actionable
|
||||
error into an opaque one.
|
||||
"""
|
||||
mock_router.add(
|
||||
"GET",
|
||||
"/api/v1/findings/nope",
|
||||
status=404,
|
||||
json=jsonapi_error(404, "Not found."),
|
||||
)
|
||||
|
||||
with pytest.raises(Exception, match=r"API request failed: 404 - Not found\."):
|
||||
await mock_api_client.get("/findings/nope")
|
||||
|
||||
|
||||
def test_build_filter_params_normalises_types_for_the_api(mock_api_client):
|
||||
"""Booleans become lowercase strings, sequences become CSV, `None` is dropped."""
|
||||
result = mock_api_client.build_filter_params(
|
||||
{
|
||||
"filter[muted]": True,
|
||||
"filter[severity__in]": ["high", "critical"],
|
||||
"filter[status]": None,
|
||||
"page[size]": 50,
|
||||
}
|
||||
)
|
||||
|
||||
assert result == {
|
||||
"filter[muted]": "true",
|
||||
"filter[severity__in]": "high,critical",
|
||||
"page[size]": 50,
|
||||
}
|
||||
|
||||
|
||||
def test_the_api_client_is_a_singleton(isolated_api_client):
|
||||
"""Every tool must share one client so the HTTP connection pool is shared."""
|
||||
assert isolated_api_client() is isolated_api_client()
|
||||
@@ -0,0 +1,62 @@
|
||||
"""Tests for Prowler API authentication.
|
||||
|
||||
Reference for later branches: ``ProwlerAppAuth`` resolves its ``mode`` and
|
||||
``base_url`` in default arguments, which Python evaluates once at module import.
|
||||
``monkeypatch.setenv`` therefore has no effect on them -- always pass ``mode=``
|
||||
and ``base_url=`` explicitly, as these tests do.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
from prowler_mcp_server.prowler_app.utils.auth import ProwlerAppAuth
|
||||
from tests.helpers.tokens import FAKE_API_KEY, MALFORMED_API_KEY, fake_jwt
|
||||
|
||||
|
||||
async def test_stdio_mode_reads_the_api_key_from_the_environment():
|
||||
"""In STDIO transport the key comes from the process environment."""
|
||||
auth = ProwlerAppAuth(mode="stdio")
|
||||
|
||||
assert await auth.get_valid_token() == FAKE_API_KEY
|
||||
|
||||
|
||||
def test_stdio_mode_rejects_a_key_without_the_prowler_prefix(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
):
|
||||
"""A key that is not `pk_`-prefixed is refused at construction.
|
||||
|
||||
Failing here rather than on the first API call is what turns a
|
||||
misconfiguration into an immediate, readable startup error.
|
||||
"""
|
||||
monkeypatch.setenv("PROWLER_API_KEY", MALFORMED_API_KEY)
|
||||
|
||||
with pytest.raises(ValueError, match="Prowler API key format is incorrect"):
|
||||
ProwlerAppAuth(mode="stdio")
|
||||
|
||||
|
||||
async def test_http_mode_accepts_a_bearer_api_key(http_request_headers):
|
||||
"""In HTTP transport the token comes from the request's Authorization header."""
|
||||
http_request_headers(authorization=f"Bearer {FAKE_API_KEY}")
|
||||
|
||||
auth = ProwlerAppAuth(mode="http")
|
||||
|
||||
assert await auth.get_valid_token() == FAKE_API_KEY
|
||||
|
||||
|
||||
async def test_http_mode_rejects_an_expired_jwt(http_request_headers):
|
||||
"""An expired JWT is refused locally instead of being forwarded to the API."""
|
||||
http_request_headers(authorization=f"Bearer {fake_jwt(expires_in=-60)}")
|
||||
|
||||
auth = ProwlerAppAuth(mode="http")
|
||||
|
||||
with pytest.raises(ValueError, match="Token has expired"):
|
||||
await auth.get_valid_token()
|
||||
|
||||
|
||||
def test_api_keys_and_jwts_use_different_authorization_schemes():
|
||||
"""Prowler API keys authenticate with `Api-Key`, JWTs with `Bearer`."""
|
||||
auth = ProwlerAppAuth(mode="stdio")
|
||||
|
||||
assert auth.get_headers(FAKE_API_KEY)["Authorization"] == f"Api-Key {FAKE_API_KEY}"
|
||||
|
||||
jwt = fake_jwt()
|
||||
assert auth.get_headers(jwt)["Authorization"] == f"Bearer {jwt}"
|
||||
@@ -0,0 +1 @@
|
||||
"""Tests for the Prowler Documentation sub-server."""
|
||||
@@ -0,0 +1 @@
|
||||
"""Tests for the Prowler Hub sub-server."""
|
||||
@@ -1,16 +1,11 @@
|
||||
"""Tests for the Prowler MCP Server health endpoint."""
|
||||
|
||||
from starlette.testclient import TestClient
|
||||
|
||||
from prowler_mcp_server import __version__
|
||||
from prowler_mcp_server.server import app
|
||||
|
||||
|
||||
def test_health_returns_ietf_pass_response():
|
||||
def test_health_returns_ietf_pass_response(health_client):
|
||||
"""GET /health returns 200 with the IETF health-check body and headers."""
|
||||
client = TestClient(app)
|
||||
|
||||
response = client.get("/health")
|
||||
response = health_client.get("/health")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.headers["content-type"] == "application/health+json"
|
||||
@@ -24,23 +19,19 @@ def test_health_returns_ietf_pass_response():
|
||||
}
|
||||
|
||||
|
||||
def test_health_release_id_matches_package_version():
|
||||
def test_health_release_id_matches_package_version(health_client):
|
||||
"""The endpoint must surface the current package __version__ as releaseId.
|
||||
|
||||
Drift between the response and the installed package would mislead any
|
||||
monitoring tool that uses releaseId to identify the running build.
|
||||
"""
|
||||
client = TestClient(app)
|
||||
|
||||
response = client.get("/health")
|
||||
response = health_client.get("/health")
|
||||
|
||||
assert response.json()["releaseId"] == __version__
|
||||
|
||||
|
||||
def test_health_rejects_non_get_methods():
|
||||
def test_health_rejects_non_get_methods(health_client):
|
||||
"""The endpoint only exposes GET; other verbs return 405."""
|
||||
client = TestClient(app)
|
||||
|
||||
response = client.post("/health")
|
||||
response = health_client.post("/health")
|
||||
|
||||
assert response.status_code == 405
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
"""Tests for the mounted root MCP server.
|
||||
|
||||
Reference for later branches: open the client inline with
|
||||
``async with Client(mcp_root_server)``. FastMCP warns against holding a client in
|
||||
a fixture because it causes hard-to-diagnose event-loop problems.
|
||||
"""
|
||||
|
||||
from fastmcp import Client
|
||||
|
||||
from tests.helpers.assertions import (
|
||||
assert_namespaced,
|
||||
assert_tool_contract,
|
||||
tools_in_namespace,
|
||||
)
|
||||
|
||||
|
||||
async def test_every_sub_server_contributes_tools(mcp_root_server):
|
||||
"""Each of the three mounts must expose tools under its own namespace.
|
||||
|
||||
This is the guard against a silent startup failure. ``setup_main_server()``
|
||||
wraps each mount in try/except and ``load_all_tools`` swallows per-tool
|
||||
construction errors, so a sub-server that registers nothing is still logged as
|
||||
"successfully mounted". The `prowler_*` namespace in particular collapses to
|
||||
zero tools whenever the API key is missing when the module is first imported.
|
||||
"""
|
||||
async with Client(mcp_root_server) as client:
|
||||
tools = await client.list_tools()
|
||||
|
||||
assert tools_in_namespace(tools, "prowler_hub_"), "Prowler Hub registered no tools"
|
||||
assert tools_in_namespace(tools, "prowler_docs_"), (
|
||||
"Prowler Docs registered no tools"
|
||||
)
|
||||
assert tools_in_namespace(tools, "prowler_"), "Prowler App registered no tools"
|
||||
|
||||
|
||||
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:
|
||||
tools = await client.list_tools()
|
||||
|
||||
for tool in tools:
|
||||
assert_namespaced(tool)
|
||||
|
||||
|
||||
async def test_every_tool_and_parameter_is_described(mcp_root_server):
|
||||
"""Descriptions are the contract a model reads before calling a tool."""
|
||||
async with Client(mcp_root_server) as client:
|
||||
tools = await client.list_tools()
|
||||
|
||||
for tool in tools:
|
||||
assert_tool_contract(tool)
|
||||
Generated
+191
-62
@@ -1,6 +1,20 @@
|
||||
version = 1
|
||||
revision = 3
|
||||
requires-python = ">=3.12"
|
||||
resolution-markers = [
|
||||
"python_full_version >= '3.14' and sys_platform == 'win32'",
|
||||
"python_full_version >= '3.14' and sys_platform != 'win32'",
|
||||
"python_full_version < '3.14' and sys_platform == 'win32'",
|
||||
"python_full_version < '3.14' and sys_platform != 'win32'",
|
||||
]
|
||||
|
||||
[manifest]
|
||||
constraints = [
|
||||
{ name = "cryptography", specifier = "==48.0.1" },
|
||||
{ name = "joserfc", specifier = "==1.6.8" },
|
||||
{ name = "mcp", specifier = "==1.28.1" },
|
||||
{ name = "python-multipart", specifier = "==0.0.30" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "aiofile"
|
||||
@@ -199,57 +213,126 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "coverage"
|
||||
version = "7.15.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/76/d0/55fe630f4cf94e3fcba868240fad8c8cdd1f764e2a932f8926347e6ec4cd/coverage-7.15.2.tar.gz", hash = "sha256:3df60dc267f0a2ca23cb7a9ab1109c62b9335ffbf519fcfe167157c28c09b81d", size = 927741, upload-time = "2026-07-15T18:56:19.558Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/6a/50/eb5bf42e531611a9f8d272556b1ed4de503f84a91413584094487cf69f8f/coverage-7.15.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:1adac78e5abc7c5438f7a209c9ca69d06542f0bf481d728b6989ea80b813fdf9", size = 221587, upload-time = "2026-07-15T18:54:18.439Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/06/d1/da99af464c335d4e023a6efcd7ec30f63b88a43c93745154ab74ffb31cea/coverage-7.15.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b868acc62aa5de3be7a9d05c2333bf8359ca987e43f9cb30ff8fbda6a024ab73", size = 221943, upload-time = "2026-07-15T18:54:20.062Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5b/8a/13c42723d61ca447eafa18732e8141dd6a63f2732e1c7e1502c182dd88d7/coverage-7.15.2-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:6f6966fc30e6f06ca8f98fb0ce51eda6b111b3ee8d066a8b1ec9e77fa06ab55d", size = 253450, upload-time = "2026-07-15T18:54:21.765Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d7/29/99021303f98fbdcb63504b4d07bea4cc025b9b2dd907c4f07c85d50a0dab/coverage-7.15.2-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:68af907f595ab01a78f794932ff3bdf929c316d3000810d38dbc247129e26f8b", size = 256187, upload-time = "2026-07-15T18:54:23.4Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f9/a8/fd503715ed6ca9c5d742923aa5209257340b367a867b2ced0c7d4ba8a0b9/coverage-7.15.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:afa29e2eff3d5729267e2cb2fd4ce9d61c952932fb2694e34ccb5d9540c6a296", size = 257301, upload-time = "2026-07-15T18:54:25.183Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/da/40/3f4b8fb409810036ebc2857d36adc0498c6e957b5df0290c5036b2e143f1/coverage-7.15.2-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:bbf44513ceb1589e31948e20eafbde9deaface90e1a1afa5f5f77b4423d17ce6", size = 259562, upload-time = "2026-07-15T18:54:27.204Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0b/8a/9bdffbef47db77cce3d6b02a28f7e919b19f0106c4b080c2c2246040f885/coverage-7.15.2-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9deddf09eecb717b7f980414b43d90a5b22ff3967d2949ab29cb0aa83d9e9098", size = 253841, upload-time = "2026-07-15T18:54:29.134Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1b/1e/9031efde019d31a06646261fce6dfc5c3c74e951e27a71e5c9a424563178/coverage-7.15.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ae901f7e55ba405c84ee1cab3d3e962e4e871e4a2bcb9c90911adbd69b42ac5a", size = 255221, upload-time = "2026-07-15T18:54:31.142Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/56/db/787acde872389fc84a9ef9d8cd1ccc658e391ab4cb5b28092a714426a394/coverage-7.15.2-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:a0f47002c6eeb7c280228467a4cb0cc15ca2103a8421b986b2d3ec04a0f9bd8b", size = 253366, upload-time = "2026-07-15T18:54:32.886Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2f/9b/6f57bc4b93c842eef1695f8cdaf2318e35e7ba54f5ba80d84be213ab7858/coverage-7.15.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:1cd7a5beb7af3e864a13b1f0fb26efd3695da43ef0daf71e586adfffaf34d5b2", size = 257434, upload-time = "2026-07-15T18:54:34.7Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/88/26/b3186a21b2acc83e451118978905c81c7072c3333707804db09a78c096a2/coverage-7.15.2-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:97a5c5457a9fb1d6c4e06cfb5dc835871fbfb6a6a51addc9e925bdeff5ef7440", size = 252935, upload-time = "2026-07-15T18:54:36.548Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/20/c2/c9f3376b2e717ea69ed7a6e9a5fcab968fb0b290db6cf4bd9a1fc7541b75/coverage-7.15.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:0901cfe6c13bcd2302da4f83e884555d2a22bda6e4c476f09ef204ba20ca536e", size = 254807, upload-time = "2026-07-15T18:54:38.296Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f0/e1/dfc15401f4a8aaeb486e1ba3e9e3c40522a6e38bd0ecf0b3f29cb8082957/coverage-7.15.2-cp312-cp312-win32.whl", hash = "sha256:b171bdd71cb7ff792bf32e376173b0ace7e7963e7e57c58dfc42063a6a7174cd", size = 223641, upload-time = "2026-07-15T18:54:40.103Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/91/40/81b6d809d320cd366ec5bdf8176575e897dcb8efe7fb4b489ef9e93e4d13/coverage-7.15.2-cp312-cp312-win_amd64.whl", hash = "sha256:582edc45c2040543fef83341be23c43024a3ab3ae0c2d8bc498a06282905ad40", size = 224172, upload-time = "2026-07-15T18:54:41.882Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ef/28/9f14ec438149f7de557f45518f09b4a7917b795cc37083aa7db482693f8c/coverage-7.15.2-cp312-cp312-win_arm64.whl", hash = "sha256:a638db90c61cd219aeee65e83a24fdaa57269a741ae0cf773309208ac862cee3", size = 223556, upload-time = "2026-07-15T18:54:43.674Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fc/d5/f8c838e6b7282976f7c918884b792df7a0c42c5bba5d99c60ad2d221d56d/coverage-7.15.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:1121caa19159a38b5463eaae4b1e1fde81e525b15ecc5e000cd5b1a108f743a8", size = 221606, upload-time = "2026-07-15T18:54:45.448Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bf/37/97c926376364f66298cc44893b89cdf17b8bc406376497c4061ae4b8a8ff/coverage-7.15.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a300c6934e0989c327b9e8a1e110329da4641149f872bbe9f70168be66da76c1", size = 221982, upload-time = "2026-07-15T18:54:47.341Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b7/30/a36050a6e83c2135ee0776f452ca3948224befc6d7f26acecc082d0c106a/coverage-7.15.2-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:2617f8799d268fabdeef42a7e89ac3a23e1deee9025427db2df970f99a89a578", size = 252972, upload-time = "2026-07-15T18:54:49.2Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/31/d3/06b5f1daf95f0f15ab05bd75f26ba5f3c8b33d0bb72f3aaa3cf41d1bad3a/coverage-7.15.2-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:7dc2950a2992cd676d35c20ae63522836deeb034f08874699d14068710af3dc1", size = 255569, upload-time = "2026-07-15T18:54:51.098Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/81/1c/9afb3f8de2b8d36960391c48559a2e3ff96594b58099f115921549ea8d0d/coverage-7.15.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9e36686f7a442185db2400b3df171aac520869faf9deb59df687d28659eda2a6", size = 256806, upload-time = "2026-07-15T18:54:53.145Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/64/d8/b989f96061a5e32d82fddd1b1b9ff48a7c8f8ae7606f0e80fd9de54b1e33/coverage-7.15.2-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7d29ca7bd67af6e12e74632d65f026eabc1364da5c254494cd914446a28a3ef7", size = 258936, upload-time = "2026-07-15T18:54:55.015Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b8/fa/f99771f5110457c7b511c1935ca49ddf288218eaa84322e028b9334146ae/coverage-7.15.2-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:db9c8438057e5b0f6a22a0af99c0c1d26b57fbbdbd1be5861ddb8f897fcc3a2d", size = 253178, upload-time = "2026-07-15T18:54:57.527Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f6/96/c098a6044d119c751ceede7be91035fa8310170ec24a6523aff72f0a5793/coverage-7.15.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:63022c4c8dec1d0342f05c3ede99842fe3d007689acc45e86f123a1746e4a026", size = 254934, upload-time = "2026-07-15T18:54:59.41Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b2/a2/1457b3a7a50c8d77500103b97a046db863e2f59a1cf6d2f814595f349885/coverage-7.15.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:6c0be82b4d4aa5b2704e08518e2252f3e3d110164bcca826816801052e48a7aa", size = 252898, upload-time = "2026-07-15T18:55:01.338Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6c/0e/76958874c471ecfcdde0d2b2747bb2c61bdbf34a40636f4ce9db9923e643/coverage-7.15.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:4510fb9cdf6bb02dfa6af0be4a534b8102d086e22e4a33f8836df663da3d660d", size = 257056, upload-time = "2026-07-15T18:55:03.243Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7c/7c/3d7c4e3bf58baa40327dc7edc2272b17cf02299366d52763db1b0ca1556a/coverage-7.15.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:42ec3d989421b174a2ab607c1539f24127ad362757b7f1c0c0d7a2993f7eb37b", size = 252718, upload-time = "2026-07-15T18:55:05.029Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c8/b8/1cecffed9ce14fb25be9ba42d37b6bb61485c9a3ddd43cd3dde36b6087d8/coverage-7.15.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e8f91bce78e32343af184c3b7fa28fcf5a9e2641f4b6623d392038f804939188", size = 254490, upload-time = "2026-07-15T18:55:06.889Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6c/2c/42984561bc7f4c045dca67516a0c50ee5ef8d84352dbeb5559dc86c4823e/coverage-7.15.2-cp313-cp313-win32.whl", hash = "sha256:434e68d531858205895eb0d74b73d20b84260de426387d53c422a5acda2cf050", size = 223647, upload-time = "2026-07-15T18:55:08.941Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/41/9f/39c7c9245efc583beddf89a87683574e663ed93637f3afb6cd7b88405676/coverage-7.15.2-cp313-cp313-win_amd64.whl", hash = "sha256:26c3b04a6377fd7c09800921fa934e3a17c0020439cd59df73e73ae1d4b6a78c", size = 224190, upload-time = "2026-07-15T18:55:10.789Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c7/de/3a2883cf8a213659280ef4b403059e17a9acaeb7fc7fd4105e1226ff2e6d/coverage-7.15.2-cp313-cp313-win_arm64.whl", hash = "sha256:3ed010aa1b69cda8e827aabfca9866216c980e2dca82ab9a78c5f83689964c8b", size = 223583, upload-time = "2026-07-15T18:55:12.678Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/81/5f/aed265fd7a3551a394f36dfe41868aee709b7f95db4052205b4ad1563ac3/coverage-7.15.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:40f633c5c5fc783732f6312280122e859538fa24461235597c13d803ea9a108a", size = 221650, upload-time = "2026-07-15T18:55:14.527Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6b/2c/222ba12a545189017120f8eddfc1a0bd4616b47d5d4a8d99421edb2fe4c6/coverage-7.15.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:075560438765b7a2ef43bf7aa7758661b53d889df47f062a31bda6c1ade553a2", size = 221988, upload-time = "2026-07-15T18:55:16.674Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/aa/38/304b5877ab46e6c290b4292cfcf3fe28245f0e5597cad7f6acc91fc7e0a4/coverage-7.15.2-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:25fd15dd40a0a2c51a500d664ca29053c09c3259d998407bf982b6e114696138", size = 253029, upload-time = "2026-07-15T18:55:18.856Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6c/58/821b533b8db9e44cf1d8a97bd525149ced40dde1d0093da02cb78e715244/coverage-7.15.2-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b9a6367e4aff723e8ee8190836836124284e8fcd4265e307c844010cfa074f3f", size = 255536, upload-time = "2026-07-15T18:55:21.027Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f1/f2/7aa06604c389d32ea7f0a6a988359a7eafc3cd3f8e7bc2e88cd2fdf0b877/coverage-7.15.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9854ca62c152874b2060772503535be2e8f53f70b8aaa7686b094888d872f984", size = 256881, upload-time = "2026-07-15T18:55:23.125Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a2/4f/1ef342339c7916d0096bc5888cc0f653882cc7bc8f897d5cb89143287c9b/coverage-7.15.2-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:913b6c56e110da40e035bbd168353bf7aaa2544a5eaccea5d98a4629aac156c7", size = 259196, upload-time = "2026-07-15T18:55:25.099Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fe/f4/7ed055d7a9c5ec13b161773a115a5ccc6b0081d568c31fad830806306cc7/coverage-7.15.2-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:aaccad4129d735a8a4d526f26929894c9a4e8ef7034566f210b176749d6906e3", size = 253036, upload-time = "2026-07-15T18:55:27.018Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/14/79/ea82cca18c242a3a38b6c017da39726aa62dcb64aa635abf79b92009975c/coverage-7.15.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a164b50081fc7357331c4024ef4d17b78ba325f8380d05f5a69599a7e05257ee", size = 254887, upload-time = "2026-07-15T18:55:29.084Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a4/ba/a136db3c0d9562b00e10b72540dbf3a33cd3bc5b95060c9308e247494623/coverage-7.15.2-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:bfd341ccf78128e72c094bc70cc25b3ef309c33c7c2c66ba3ed4309549e02de1", size = 252852, upload-time = "2026-07-15T18:55:31.184Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/17/17/ea334246b16b7d059953fad6fdefa11e33c68efbd3fe37b1098120a1fac2/coverage-7.15.2-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:1473b3ba8e7ee0f076117b1a72c23f579a2b9e2bb742f48a8d86ea27ca93f91a", size = 257128, upload-time = "2026-07-15T18:55:33.163Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ed/c3/074fb66d46d607855f710876b117cbda562c5ab08363528e78820449f937/coverage-7.15.2-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:17c432b5f73ad52ef46fb06019f6fa7c66ce381961cf0f7dfd1d3a4bd3a98145", size = 252668, upload-time = "2026-07-15T18:55:35.063Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e1/c1/f620850ada9b36435921c9a3a8057013422b1d964eb4bf37fe138724d192/coverage-7.15.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:77f0ef5011df53a4bd1b35211ab122287f8d9b8d7aa1c4553e5c2deb24b1d446", size = 254325, upload-time = "2026-07-15T18:55:37.125Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cc/31/a729ca3689404493af82ef8e6ff70bd88bdda8da89aeef6ca9b387aeb2b4/coverage-7.15.2-cp314-cp314-win32.whl", hash = "sha256:f653e5d7248c1191ec988a85c72edeab46c3ff44f90639a4ed4874ec0be90243", size = 223844, upload-time = "2026-07-15T18:55:39.078Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c6/83/5d809dc808fb1698c671f3e372259bb9158e64b7ea526fc6ab7de64de9fe/coverage-7.15.2-cp314-cp314-win_amd64.whl", hash = "sha256:9911f31aad8906abe337c271343485cf20df5e70df5d2f57f9f136e7b55f26bc", size = 224331, upload-time = "2026-07-15T18:55:41.346Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/16/4e/35e488548e952795829e129995c4174df33bf432b591d1aa42c8d9e4e7ad/coverage-7.15.2-cp314-cp314-win_arm64.whl", hash = "sha256:e38def96ad59853824c97953fdcd2c320a84ba3ce99b417db78af8bb6c3db635", size = 223760, upload-time = "2026-07-15T18:55:43.518Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ed/49/dd2c86cd6374038f6e415fb5bfb86db5218553209c081384a020369dee79/coverage-7.15.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:835ec4e20b45f0a7f63ed78f94065aca00de033403df8377bfe8b9c6abc0a7be", size = 222384, upload-time = "2026-07-15T18:55:45.569Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d3/74/173ff17a1c0808e5a438f549f6f145d5ac7528f2791310b63523e3200ac7/coverage-7.15.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7466cc7ab6dc0db871d264bf99e8779f0917ee63d40730af0552f71535a6e072", size = 222647, upload-time = "2026-07-15T18:55:47.544Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/84/f8/b8cba872162356fb44ac79c10309d987206a4461e32072fc29228dad7331/coverage-7.15.2-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:e370c12133095ff18432de8c044962be85a5a96d90c6fcbce8e17e76236d2328", size = 264013, upload-time = "2026-07-15T18:55:49.768Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ee/67/a807a7586d0b8cae485308ddd55756f0806c92f8e0b411bacbf23c48edf3/coverage-7.15.2-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:fe41909c9515c3bfdb5f02c4d1f857dba322d9a9a1178069b91eea77889df63a", size = 266135, upload-time = "2026-07-15T18:55:51.941Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ce/67/cd78771dc985f7e4ebdcc82b1a96d9a932af9e806f01f2f91a89f4c72e80/coverage-7.15.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6aa28cfb6488e5453b5b762d65f73aa586380f6693a04d58078ce228a29b06c0", size = 268555, upload-time = "2026-07-15T18:55:54.065Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/18/3e/10134cf81275188c58568f324fc74aedff32c63ca4d5bbc513a91944a6f0/coverage-7.15.2-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:bcc0aae933921d03096f53b0b03eeb702129fd406dee59f08d2efacc68681fa5", size = 269674, upload-time = "2026-07-15T18:55:56.066Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/75/4a/771b77de446cba985dc414bbc5844bd21604da05dbc044286df8318a48a7/coverage-7.15.2-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7c63387e21ab21f512c69c9756a8c7dadd322c7275edb064064433c9a09c3743", size = 263101, upload-time = "2026-07-15T18:55:58.107Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5f/b5/70a7011da15f4071943361183aefa27847f3e3aec4fd335f1cb3d3a622b1/coverage-7.15.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:0e55510bc98ae943cece9e667a6c0fe94c6a92913720dea34243657a17993d0c", size = 266007, upload-time = "2026-07-15T18:56:00.468Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b4/0d/f9547e804ce7ad49646ffeffac26699510efbe6c0f751b66fdc960c4e825/coverage-7.15.2-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:2ff08701be2d1556fc78b326c80a3e8042da09352ecb3819105f8e386c8a3071", size = 263611, upload-time = "2026-07-15T18:56:02.615Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ac/59/f576a396659c0efd351f5c1544f67c3560e89c7761cabf7f65e412beeda5/coverage-7.15.2-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:38c9518b7103826c403a461544e3c2e77151e8676d06eaed85911a97e962584a", size = 267344, upload-time = "2026-07-15T18:56:04.622Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7c/5d/c2e4fce3579c0cb635024293f1a32bbe26df101b3e3a69f22243d1352b6c/coverage-7.15.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:dee88b1ed88587abd8c0269a1fc1f4cc77f7750d1dfde2869e2a123af420e67d", size = 262456, upload-time = "2026-07-15T18:56:06.641Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bb/dd/956287d69436b66094bc4b57ac2da71e43bfd2a5524e958900b9f582fcf8/coverage-7.15.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:2fbeeeecea279727f8ac16c8e1133ddfeee793e985c86ae343d6a5ce744eef8c", size = 264771, upload-time = "2026-07-15T18:56:08.795Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2c/5a/6f979530c2734c575de77cf58f5f28d51f7123a94b5030fd9156fe5f363c/coverage-7.15.2-cp314-cp314t-win32.whl", hash = "sha256:cb0fddaa6884be6aae36ced9544b5e90f7d5f03845a2853bf47a14953a4e8688", size = 224151, upload-time = "2026-07-15T18:56:10.856Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/54/7e/27f6b2a74d484742f4017553e710b01e396b23d809df3e95ca0bb9a2824b/coverage-7.15.2-cp314-cp314t-win_amd64.whl", hash = "sha256:77f091ea3a9cc611cd29f433565476bc1936c084ac8eee00ea0e7e70c27e4199", size = 224981, upload-time = "2026-07-15T18:56:12.928Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b1/48/284863423aa474240f6842bd00d680da22f4e6ea2e466618ef7c9c9e69a9/coverage-7.15.2-cp314-cp314t-win_arm64.whl", hash = "sha256:6fc448c377d6eeb00a47c673494bd9bae29280ca53987e1869e67ebedfe20658", size = 224294, upload-time = "2026-07-15T18:56:15.156Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ec/82/32e3bd191d498e64f6f911ad55d14006a0861e54869d2d32452326399e65/coverage-7.15.2-py3-none-any.whl", hash = "sha256:eb6bcae8d1a9d305351ecb108232441d11c5cfe9de840a04388ba5d2db8d735c", size = 213375, upload-time = "2026-07-15T18:56:17.305Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cryptography"
|
||||
version = "48.0.0"
|
||||
version = "48.0.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "cffi", marker = "platform_python_implementation != 'PyPy'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/9f/a9/db8f313fdcd85d767d4973515e1db101f9c71f95fced83233de224673757/cryptography-48.0.0.tar.gz", hash = "sha256:5c3932f4436d1cccb036cb0eaef46e6e2db91035166f1ad6505c3c9d5a635920", size = 832984, upload-time = "2026-05-04T22:59:38.133Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/12/45/870e7f4bef50e5f53b9f51d4428aee5290eedf58ba443f16b1ebb7ab8e66/cryptography-48.0.1.tar.gz", hash = "sha256:266f4ee051abb2f725b74ef8072b521ce1feacf685a3364fa6a6b45548db791a", size = 832989, upload-time = "2026-06-09T22:32:31.8Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/df/3d/01f6dd9190170a5a241e0e98c2d04be3664a9e6f5b9b872cde63aff1c3dd/cryptography-48.0.0-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:0c558d2cdffd8f4bbb30fc7134c74d2ca9a476f830bb053074498fbc86f41ed6", size = 8001587, upload-time = "2026-05-04T22:57:36.803Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b2/6e/e90527eef33f309beb811cf7c982c3aeffcce8e3edb178baa4ca3ae4a6fa/cryptography-48.0.0-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f5333311663ea94f75dd408665686aaf426563556bb5283554a3539177e03b8c", size = 4690433, upload-time = "2026-05-04T22:57:40.373Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/90/04/673510ed51ddff56575f306cf1617d80411ee76831ccd3097599140efdfe/cryptography-48.0.0-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7995ef305d7165c3f11ae07f2517e5a4f1d5c18da1376a0a9ed496336b69e5f3", size = 4710620, upload-time = "2026-05-04T22:57:42.935Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/14/d5/e9c4ef932c8d800490c34d8bd589d64a31d5890e27ec9e9ad532be893294/cryptography-48.0.0-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:40ba1f85eaa6959837b1d51c9767e230e14612eea4ef110ee8854ada22da1bf5", size = 4696283, upload-time = "2026-05-04T22:57:45.294Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0c/29/174b9dfb60b12d59ecfc6cfa04bc88c21b42a54f01b8aae09bb6e51e4c7f/cryptography-48.0.0-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:369a6348999f94bbd53435c894377b20ab95f25a9065c283570e70150d8abc3c", size = 5296573, upload-time = "2026-05-04T22:57:47.933Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/95/38/0d29a6fd7d0d1373f0c0c88a04ba20e359b257753ac497564cd660fc1d55/cryptography-48.0.0-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:a0e692c683f4df67815a2d258b324e66f4738bd7a96a218c826dce4f4bd05d8f", size = 4743677, upload-time = "2026-05-04T22:57:50.067Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/30/be/eef653013d5c63b6a490529e0316f9ac14a37602965d4903efed1399f32b/cryptography-48.0.0-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:18349bbc56f4743c8b12dc32e2bccb2cf83ee8b69a3bba74ef8ae857e26b3d25", size = 4330808, upload-time = "2026-05-04T22:57:52.301Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/84/9e/500463e87abb7a0a0f9f256ec21123ecde0a7b5541a15e840ea54551fd81/cryptography-48.0.0-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:7e8eac43dfca5c4cccc6dad9a80504436fca53bb9bc3100a2386d730fbe6b602", size = 4695941, upload-time = "2026-05-04T22:57:54.603Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e3/dc/7303087450c2ec9e7fbb750e17c2abfbc658f23cbd0e54009509b7cc4091/cryptography-48.0.0-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:9ccdac7d40688ecb5a3b4a604b8a88c8002e3442d6c60aead1db2a89a041560c", size = 5252579, upload-time = "2026-05-04T22:57:57.207Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d0/c0/7101d3b7215edcdc90c45da544961fd8ed2d6448f77577460fa75a8443f7/cryptography-48.0.0-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:bd72e68b06bb1e96913f97dd4901119bc17f39d4586a5adf2d3e47bc2b9d58b5", size = 4743326, upload-time = "2026-05-04T22:57:59.535Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ac/d8/5b833bad13016f562ab9d063d68199a4bd121d18458e439515601d3357ec/cryptography-48.0.0-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:59baa2cb386c4f0b9905bd6eb4c2a79a69a128408fd31d32ca4d7102d4156321", size = 4826672, upload-time = "2026-05-04T22:58:01.996Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/98/e1/7074eb8bf3c135558c73fc2bcf0f5633f912e6fb87e868a55c454080ef09/cryptography-48.0.0-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:9249e3cd978541d665967ac2cb2787fd6a62bddf1e75b3e347a594d7dacf4f74", size = 4972574, upload-time = "2026-05-04T22:58:03.968Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/04/70/e5a1b41d325f797f39427aa44ef8baf0be500065ab6d8e10369d850d4a4f/cryptography-48.0.0-cp311-abi3-win32.whl", hash = "sha256:9c459db21422be75e2809370b829a87eb37f74cd785fc4aa9ea1e5f43b47cda4", size = 3294868, upload-time = "2026-05-04T22:58:06.467Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f4/ac/8ac51b4a5fc5932eb7ee5c517ba7dc8cd834f0048962b6b352f00f41ebf9/cryptography-48.0.0-cp311-abi3-win_amd64.whl", hash = "sha256:5b012212e08b8dd5edc78ef54da83dd9892fd9105323b3993eff6bea65dc21d7", size = 3817107, upload-time = "2026-05-04T22:58:08.845Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6b/84/70e3feea9feea87fd7cbe77efb2712ae1e3e6edf10749dc6e95f4e60e455/cryptography-48.0.0-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:3cb07a3ed6431663cd321ea8a000a1314c74211f823e4177fefa2255e057d1ec", size = 7986556, upload-time = "2026-05-04T22:58:11.172Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/89/6e/18e07a618bb5442ba10cf4df16e99c071365528aa570dfcb8c02e25a303b/cryptography-48.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8c7378637d7d88016fa6791c159f698b3d3eed28ebf844ac36b9dc04a14dae18", size = 4684776, upload-time = "2026-05-04T22:58:13.712Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/be/6a/4ea3b4c6c6759794d5ee2103c304a5076dc4b19ae1f9fe47dba439e159e9/cryptography-48.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:cc90c0b39b2e3c65ef52c804b72e3c58f8a04ab2a1871272798e5f9572c17d20", size = 4698121, upload-time = "2026-05-04T22:58:16.448Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2f/59/6ff6ad6cae03bb887da2a5860b2c9805f8dac969ef01ce563336c49bd1d1/cryptography-48.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:76341972e1eff8b4bea859f09c0d3e64b96ce931b084f9b9b7db8ef364c30eff", size = 4690042, upload-time = "2026-05-04T22:58:18.544Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ca/b4/fc334ed8cfd705aca282fe4d8f5ae64a8e0f74932e9feecb344610cf6e4d/cryptography-48.0.0-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:55b7718303bf06a5753dcdccf2f3945cf18ad7bffde41b61226e4db31ab89a9c", size = 5282526, upload-time = "2026-05-04T22:58:20.75Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/11/08/9f8c5386cc4cd90d8255c7cdd0f5baf459a08502a09de30dc51f553d38dc/cryptography-48.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:a64697c641c7b1b2178e573cbc31c7c6684cd56883a478d75143dbb7118036db", size = 4733116, upload-time = "2026-05-04T22:58:23.627Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b8/77/99307d7574045699f8805aa500fa0fb83422d115b5400a064ddd306d7750/cryptography-48.0.0-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:561215ea3879cb1cbbf272867e2efda62476f240fb58c64de6b393ae19246741", size = 4316030, upload-time = "2026-05-04T22:58:25.581Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fd/36/a608b98337af3cb2aff4818e406649d30572b7031918b04c87d979495348/cryptography-48.0.0-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:ad64688338ed4bc1a6618076ba75fd7194a5f1797ac60b47afe926285adb3166", size = 4689640, upload-time = "2026-05-04T22:58:27.747Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/dd/a6/825010a291b4438aecc1f568bc428189fc1175515223632477c07dc0a6df/cryptography-48.0.0-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:906cbf0670286c6e0044156bc7d4af9cbb0ef6db9f73e52c3ec56ba6bdde5336", size = 5237657, upload-time = "2026-05-04T22:58:29.848Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b9/09/4e76a09b4caa29aad535ddc806f5d4c5d01885bd978bd984fbc6ca032cae/cryptography-48.0.0-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:ea8990436d914540a40ab24b6a77c0969695ed52f4a4874c5137ccf7045a7057", size = 4732362, upload-time = "2026-05-04T22:58:32.009Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/18/78/444fa04a77d0cb95f417dda20d450e13c56ba8e5220fc892a1658f44f882/cryptography-48.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c18684a7f0cc9a3cb60328f496b8e3372def7c5d2df39ac267878b05565aaaae", size = 4819580, upload-time = "2026-05-04T22:58:34.254Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/38/85/ea67067c70a1fd4be2c63d35eeed82658023021affccc7b17705f8527dd2/cryptography-48.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:9be5aafa5736574f8f15f262adc81b2a9869e2cfe9014d52a44633905b40d52c", size = 4963283, upload-time = "2026-05-04T22:58:36.376Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/75/54/cc6d0f3deac3e81c7f847e8a189a12b6cdd65059b43dad25d4316abd849a/cryptography-48.0.0-cp314-cp314t-win32.whl", hash = "sha256:c17dfe85494deaeddc5ce251aebd1d60bbe6afc8b62071bb0b469431a000124f", size = 3270954, upload-time = "2026-05-04T22:58:38.791Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/49/67/cc947e288c0758a4e5473d1dcb743037ab7785541265a969240b8885441a/cryptography-48.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:27241b1dc9962e056062a8eef1991d02c3a24569c95975bd2322a8a52c6e5e12", size = 3797313, upload-time = "2026-05-04T22:58:40.746Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f2/63/61d4a4e1c6b6bab6ce1e213cd36a24c415d90e76d78c5eb8577c5541d2e8/cryptography-48.0.0-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:58d00498e8933e4a194f3076aee1b4a97dfec1a6da444535755822fe5d8b0b86", size = 7983482, upload-time = "2026-05-04T22:58:43.769Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d5/ac/f5b5995b87770c693e2596559ffafe195b4033a57f14a82268a2842953f3/cryptography-48.0.0-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:614d0949f4790582d2cc25553abd09dd723025f0c0e7c67376a1d77196743d6e", size = 4683266, upload-time = "2026-05-04T22:58:46.064Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ec/c6/8b14f67e18338fbc4adb76f66c001f5c3610b3e2d1837f268f47a347dbbb/cryptography-48.0.0-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7ce4bfae76319a532a2dc68f82cc32f5676ee792a983187dac07183690e5c66f", size = 4696228, upload-time = "2026-05-04T22:58:48.22Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ea/73/f808fbae9514bd91b47875b003f13e284c8c6bdfd904b7944e803937eec1/cryptography-48.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:2eb992bbd4661238c5a397594c83f5b4dc2bc5b848c365c8f991b6780efcc5c7", size = 4689097, upload-time = "2026-05-04T22:58:50.9Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/93/01/d86632d7d28db8ae83221995752eeb6639ffb374c2d22955648cf8d52797/cryptography-48.0.0-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:22a5cb272895dce158b2cacdfdc3debd299019659f42947dbdac6f32d68fe832", size = 5283582, upload-time = "2026-05-04T22:58:53.017Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/02/e1/50edc7a50334807cc4791fc4a0ce7468b4a1416d9138eab358bfc9a3d70b/cryptography-48.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:2b4d59804e8408e2fea7d1fbaf218e5ec984325221db76e6a241a9abd6cdd95c", size = 4730479, upload-time = "2026-05-04T22:58:55.611Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6f/af/99a582b1b1641ff5911ac559beb45097cf79efd4ead4657f578ef1af2d47/cryptography-48.0.0-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:984a20b0f62a26f48a3396c72e4bc34c66e356d356bf370053066b3b6d54634a", size = 4326481, upload-time = "2026-05-04T22:58:57.607Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/90/ee/89aa26a06ef0a7d7611788ffd571a7c50e368cc6a4d5eef8b4884e866edb/cryptography-48.0.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:5a5ed8fde7a1d09376ca0b40e68cd59c69fe23b1f9768bd5824f54681626032a", size = 4688713, upload-time = "2026-05-04T22:59:00.077Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/70/ba/bcb1b0bb7a33d4c7c0c4d4c7874b4a62ae4f56113a5f4baefa362dfb1f0f/cryptography-48.0.0-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:8cd666227ef7af430aa5914a9910e0ddd703e75f039cef0825cd0da71b6b711a", size = 5238165, upload-time = "2026-05-04T22:59:02.317Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c9/70/ca4003b1ce5ca3dc3186ada51908c8a9b9ff7d5cab83cc0d43ee14ec144f/cryptography-48.0.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:9071196d81abc88b3516ac8cdfad32e2b66dd4a5393a8e68a961e9161ddc6239", size = 4729947, upload-time = "2026-05-04T22:59:05.255Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/44/a0/4ec7cf774207905aef1a8d11c3750d5a1db805eb380ee4e16df317870128/cryptography-48.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:1e2d54c8be6152856a36f0882ab231e70f8ec7f14e93cf87db8a2ed056bf160c", size = 4822059, upload-time = "2026-05-04T22:59:07.802Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1e/75/a2e55f99c16fcac7b5d6c1eb19ad8e00799854d6be5ca845f9259eae1681/cryptography-48.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:a5da777e32ffed6f85a7b2b3f7c5cbc88c146bfcd0a1d7baf5fcc6c52ee35dd4", size = 4960575, upload-time = "2026-05-04T22:59:09.851Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b8/23/6e6f32143ab5d8b36ca848a502c4bcd477ae75b9e1677e3530d669062578/cryptography-48.0.0-cp39-abi3-win32.whl", hash = "sha256:77a2ccbbe917f6710e05ba9adaa25fb5075620bf3ea6fb751997875aff4ae4bd", size = 3279117, upload-time = "2026-05-04T22:59:12.019Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9d/9a/0fea98a70cf1749d41d738836f6349d97945f7c89433a259a6c2642eefeb/cryptography-48.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:16cd65b9330583e4619939b3a3843eec1e6e789744bb01e7c7e2e62e33c239c8", size = 3792100, upload-time = "2026-05-04T22:59:14.884Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1b/bc/ee4137cbbe105652c0ee4252792b78fc8e7afa4b8e61d9d5dc05a7f45731/cryptography-48.0.1-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:3e4a1a3232eef2e6c732827d5722db29a0cc8b27af2a4d865b094cf954be9ca1", size = 8008324, upload-time = "2026-06-09T22:31:00.702Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d5/85/6379d42181bfc713094f081360fc5784d6c816b599d45e7f082502d173ce/cryptography-48.0.1-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:32143b24adb918f078134e1e230f1eb8cc04886b92c28b5f0041aaf3e5699225", size = 4696243, upload-time = "2026-06-09T22:32:33.446Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9c/87/c85d147b53323c7eb4d850920c8901377323c2a0ff8d79c262d4fee89aa2/cryptography-48.0.1-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f0d27a5696721ef7a672b8c810f6aded391058e0b9486e63e6d93baf765da691", size = 4713235, upload-time = "2026-06-09T22:31:40.141Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/79/58/67cbf8cf1ee7c54b439ca07bbecf8362c07afc11a3724fea70f745784add/cryptography-48.0.1-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:eb86ce1af36fe65041b6db9a8bb064ee621a7e5fded0f80d475ec243477cd242", size = 4702323, upload-time = "2026-06-09T22:31:42.191Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/89/c6/24266ac10c47f6cd2a865f4446062b466da1d1f10b27189eac00e61bf0c9/cryptography-48.0.1-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:b024e784ad6c077ee0147b35ea9cbfc1e34e1fd4c1dcca214c2794d73a12df08", size = 5300085, upload-time = "2026-06-09T22:31:58.703Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d2/bb/cc4b78784f97efc8c5874c2a9743708d172be6663024b34a0467885ae0c8/cryptography-48.0.1-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:3752f2dbc8f07a30aad2932c986cea495b03bb554887828225da104f732852b6", size = 4746137, upload-time = "2026-06-09T22:31:31.01Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1f/52/0c44de3f5267f8fbe8e835138017522a333436166e406f0db9b9e6e3033f/cryptography-48.0.1-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:bd81490cd5801d755cf97bb68ac191f14b708470b1c7cf4580f669b9c9264cd8", size = 4333867, upload-time = "2026-06-09T22:32:28.096Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9a/2e/772d7adbfa931537bc401640b7cac9976bff689bda187833e5d63b428e49/cryptography-48.0.1-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:66fd0771e7b9c6dcd44cf1120690d2338d16d72795cf40cae2786a39eba65429", size = 4701805, upload-time = "2026-06-09T22:31:38.284Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f8/a3/b06844f303873493c963caf581c04df31c7035e0c1b0f02c4814d319ec80/cryptography-48.0.1-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:3fd2ca57062b241c856670b073487d2e86c4637937ca5601e48f97bf8e11fc8f", size = 5258461, upload-time = "2026-06-09T22:31:04.187Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9f/13/8b765e2e12b07c74941caadb9d1c8fdc006c4dfbf2b8f2d610519758954d/cryptography-48.0.1-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:0ee6ea481db1ab889cba043ec1eda17bb9c1ea79db6722f779c3667f9f70322f", size = 4745488, upload-time = "2026-06-09T22:32:30.07Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2e/aa/48972bce55049b32a94f4907eda4d75fa385aad8a39506cc2fc72196ecf0/cryptography-48.0.1-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:f2ceef93cb096aa3c4cc4b5c94ca6131f9196d28c64d6111533402a9b2054d41", size = 4830256, upload-time = "2026-06-09T22:31:43.868Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/47/a2/e5079a032fb85cf6005046ca92bbd78b0c82dad2b5751ab8c311659da06f/cryptography-48.0.1-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:9bd3f92d76217892b15df84ca256c2c113d386fdda7a7d8691aeeced976507c6", size = 4979117, upload-time = "2026-06-09T22:31:05.845Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b7/a0/8f50cae9c74e718ed769d63ed5c74bd0ea830c9550a74629cebd1b9c7bc7/cryptography-48.0.1-cp311-abi3-win32.whl", hash = "sha256:b9a32b876490d66c8bcc9963ef220199569748434ab01a9d6aaeabf88e7f5158", size = 3304154, upload-time = "2026-06-09T22:32:16.845Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c5/69/0572c77dbace6fef72f33755bd52ea399c71367250d366237f8691826b9e/cryptography-48.0.1-cp311-abi3-win_amd64.whl", hash = "sha256:39489bfca54c7a1f6b297efcd8bc608ab92d16c4ca631b0cad4da46724588b24", size = 3817138, upload-time = "2026-06-09T22:32:00.388Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/42/06/3e768b4c3bc78201583fa35a0e18f640dd782ff41afba88f8545481a8874/cryptography-48.0.1-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:f817adc181390bd54f2f700107a7419040fb7c1bdf2fc26f36551a06a68c3345", size = 7989830, upload-time = "2026-06-09T22:31:07.8Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8a/13/6476736484b94041110c8340a3eb63962fea4975baea8cb4a512adb44d4d/cryptography-48.0.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d5d30989c6917b478b5817902e85fddaea2261efa8648383d965381ccb9e1ac4", size = 4689201, upload-time = "2026-06-09T22:31:09.745Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/79/62/65a87f34d2a431546e2509b85d55e8c90df86d668f6731da64d538512ac2/cryptography-48.0.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:df637c05205ea7c1d7fbcbe54bbfea648a52951155f997af13d895d0ecc96991", size = 4702822, upload-time = "2026-06-09T22:32:24.409Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7f/59/810b5204b0a9b10f4b6bc06bd551a8b609803cd931806bc3b71884b225e5/cryptography-48.0.1-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:869c3b8a53bfe27147832df48b32adadf558249d50e76cb3769d40e986b13265", size = 4694875, upload-time = "2026-06-09T22:32:08.737Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/24/dc/d8ca05ffea724eec6d232ea6f18e74c269eb6bdfdcc9bfba689790d1325f/cryptography-48.0.1-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:e361afba8918070d376df76f408a4f67fec0ee9cff81a99e48fe9a233ef59e17", size = 5290385, upload-time = "2026-06-09T22:31:15.212Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/03/8c/3be6cb4da181f5bb6c19cf560c2359d60644a6b5fc5b57854e528f47b296/cryptography-48.0.1-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:d069066deead00ac7f090be101be875a06855908f7ec004c27b8fefb4acfb411", size = 4737082, upload-time = "2026-06-09T22:32:22.66Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/aa/f6/d5f60a5a1434dbfd949e227fd0065d194c7e6b6ac526b17f5c06152b8231/cryptography-48.0.1-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:09f73a725d582cef64b91281a322cd798d14a33b2b6f2b7ad9531dc336d84c02", size = 4325328, upload-time = "2026-06-09T22:32:10.777Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/17/b7/ba75dd947a14b6ad907b01ae8f6b5b348cdd1b48142f0063dee9e20c1d9d/cryptography-48.0.1-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:15254441469dd6bf027039453288e2072124f8b6603563f5d759e1c9b69273fa", size = 4694530, upload-time = "2026-06-09T22:31:53.105Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/62/29/50d6b9e8aff12d8b67afaeb3569335e32dc83a5723e3bbded24fdac9f809/cryptography-48.0.1-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:8ace4507d1e6533c125f4fac754f8bb8b6a74c08e92179dabd7e16571a3efbf3", size = 5245046, upload-time = "2026-06-09T22:31:25.774Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9f/04/618f4115cfc0add0838c82507aa18a346089428da8653ad38b3ff36f5cb3/cryptography-48.0.1-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:b4e391975f038e66432328639620a4aff2d307513b004f1ca06d6225bced815c", size = 4736660, upload-time = "2026-06-09T22:32:12.676Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/24/9c/06e062462a0de28a3b3911322eded4c16deb9f441b1b7575d3dc59488ab5/cryptography-48.0.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:42fcd8e26fe555d9b3577a135f5091fefa0aa4e99129c23fb56787a1bd4ada72", size = 4822229, upload-time = "2026-06-09T22:31:17.062Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f4/be/0561971eaaee4b8a0e7d5113c536921063ab91aaf23278ac374eaf881e11/cryptography-48.0.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:c1400da5e32a43253392277eac7490a60e497d810a63dd5608d71bbd7af507c9", size = 4966364, upload-time = "2026-06-09T22:31:32.842Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a4/27/728c77876f12b000820b69ae490f3c4083775e79e07827e9e60be07ad209/cryptography-48.0.1-cp314-cp314t-win32.whl", hash = "sha256:0df56b056bc17c1b7d6821dfa65216e62bd232d8ab05eb3db44e71d235651471", size = 3278498, upload-time = "2026-06-09T22:31:29.154Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/06/e3/79a612c6d7b1e6ee0edd43633d53035bec2cfb78c82b76f7864f39e36f34/cryptography-48.0.1-cp314-cp314t-win_amd64.whl", hash = "sha256:9de21387aa95e2a895823d0745b430bed4f33503ba9ab5e0b5311f33e37d66d2", size = 3798790, upload-time = "2026-06-09T22:31:56.697Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ca/6c/00fa2a95997164c8b2072ce327c23d4ab20809ccc323ea5fab91e53a4bba/cryptography-48.0.1-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:4fdc69f8e4316bcf0c8c8ec1f26f285d12e8142d88d96c876a59a03be3f6ae67", size = 7987408, upload-time = "2026-06-09T22:32:20.777Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b0/d9/45f309a7e4e5f3f8f121d6d3be9e94024a7726ec598d6e08ae04edb2f04d/cryptography-48.0.1-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:48fe40804d4caa2288f24e70ca8c64c42dd826da0ad7e4f1b41b2128d679e6c8", size = 4690196, upload-time = "2026-06-09T22:31:54.74Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5f/9f/a1bc8bcc798811b8527eb374bbccf30a3f3e806829d967118222bf1125eb/cryptography-48.0.1-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:86be3b1b0b6bf09482fb50a979c508d2950ed95f5621ec77f4e385962006b83a", size = 4696782, upload-time = "2026-06-09T22:31:45.615Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/66/c2/81a4fb4e4373c500bb526bc337ac5719dd31dd15b970b84a238168c6aa08/cryptography-48.0.1-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:4ab0a343c807bbcd90c971cd1ecf072937cd01847a9e002bef88fb47ac6be577", size = 4696618, upload-time = "2026-06-09T22:31:11.564Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e5/0b/aa68b221dde92d09cb29a024ede17550ee21e77a404e59fc093c82bb51e1/cryptography-48.0.1-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:9621de99d2da096006b629979efd8ae7eb2d8b822488d0c89ee4000c306c59b1", size = 5289970, upload-time = "2026-06-09T22:31:20.368Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/78/13/fba657f958d2af66ea959a4ba01212632089249d34af1ae48054136344d7/cryptography-48.0.1-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:88c852a0ae366e262e5a1744b685e6a433dc8788dd2a277e418bf4904203609d", size = 4731873, upload-time = "2026-06-09T22:31:22.253Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4c/4c/9a964756d24a26b3e34dfcb16f961b89838786e6700b635b0d1e3adff4b6/cryptography-48.0.1-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:43c5835e2cb98c8733d86f57d6fc879b613f5c3478607281c3e36daffc6dd8a6", size = 4330804, upload-time = "2026-06-09T22:31:36.56Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4b/0f/a10f3a6eb12950a10e3a874070283aa2dd5875b2bfd15fad8a3e17b3f13e/cryptography-48.0.1-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:fe0180af5bf9236518a087e35bf2d9a347d5f5f51e63c579d683ddff424e3d46", size = 4696217, upload-time = "2026-06-09T22:31:13.351Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f3/6f/5cd12f951165ea73ef85266775d97e4c763b2474ccfd816dd69d3a18d6f8/cryptography-48.0.1-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:b7a2d1a937a738a881737cec135a38bb61470589b17515b9f73f571d0ae10401", size = 5245252, upload-time = "2026-06-09T22:32:02.193Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/68/ab/8aaa12e4516ec4464033ab79b6f3b592bd5a92102467c4ace8a0d970203f/cryptography-48.0.1-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:b74ca3b8e5ecdd833bf6a002ca41b4793bb27fb8f1c06ffaf2643c9e9140e31b", size = 4731388, upload-time = "2026-06-09T22:32:04.019Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1b/24/50027ea4dca85ec1f40688f3c24fb32ccacd520583c9592c3cc95628e6fb/cryptography-48.0.1-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:2c37f2461406063b417837f5f3daab668652acd82423efcd7f0a9f04be972de1", size = 4824186, upload-time = "2026-06-09T22:32:18.707Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/52/41/04cb5eb17085ade6f50cc611fb657df6a0f5885350de8764ece89c050197/cryptography-48.0.1-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:86fe77abb1bd87afb251d4d02ada7ecf53a32cee9b67d976abb2e45a13297475", size = 4964539, upload-time = "2026-06-09T22:31:18.793Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/36/bf/ed70785c496e89d7e73b7cda2d21f2447fd6d4e821714b8d04ff217fed92/cryptography-48.0.1-cp39-abi3-win32.whl", hash = "sha256:6b2c0c3e6ccf3ade7750f836ef3ee36eea250cc467d45c256895573ac08cc6f1", size = 3282307, upload-time = "2026-06-09T22:30:53.162Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b3/ff/371ea7d252656ee1eb6d83eeeef3d1d0c6baf1d6497687d081ea03814670/cryptography-48.0.1-cp39-abi3-win_amd64.whl", hash = "sha256:9a49ca6c81417f6a5edb50375a60cccdd70fa0a91a5211829dbea74eba94d2ac", size = 3793408, upload-time = "2026-06-09T22:32:15.191Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -321,19 +404,19 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "fastmcp"
|
||||
version = "3.4.4"
|
||||
version = "3.4.5"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "fastmcp-slim", extra = ["client", "server"] },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/9c/f7/5188565d1b93ad611cbd80bf473e7ad669d1f3b689c4bedcd304e1ec3472/fastmcp-3.4.4.tar.gz", hash = "sha256:378202e26ec15b23819d9a1c0d1b0ebda096bc712720532010a0b82a45c2b1df", size = 28796458, upload-time = "2026-07-09T00:32:41.352Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/23/14/c1ffb91b7d1fece86c81e1f9df5474f30fd97e4cdaa398814bbbeee88568/fastmcp-3.4.5.tar.gz", hash = "sha256:a95f2bc876bef42e8b50f7872f24f3f2fe3b1d37408c734e8b9d9e03014b72d3", size = 28800521, upload-time = "2026-07-27T19:20:01.231Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/5f/67/3cef84ba38a23dca1e1e776bfda8a35ab3c7a6c94a8ca81d0715de6dd3c5/fastmcp-3.4.4-py3-none-any.whl", hash = "sha256:f86f208713212260068cf55c32936839eee856fefc7808e18a032f31eb0f718e", size = 8019, upload-time = "2026-07-09T00:32:39.411Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c6/4f/73450a436c963c0382d15a882fc5d08f15aadc329194df1b54495a7c8383/fastmcp-3.4.5-py3-none-any.whl", hash = "sha256:5d3d438eb2917e63e6faf53e8cb8fe26d887ec3232f848093a4eecad7fa34861", size = 8017, upload-time = "2026-07-27T19:19:57.942Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fastmcp-slim"
|
||||
version = "3.4.4"
|
||||
version = "3.4.5"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "platformdirs" },
|
||||
@@ -343,9 +426,9 @@ dependencies = [
|
||||
{ name = "rich" },
|
||||
{ name = "typing-extensions" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/45/79/f35661c6a1d76dfbe17a079f912d96fffcfdd40fad5a9144bb9e7dfb1fdf/fastmcp_slim-3.4.4.tar.gz", hash = "sha256:dcaa3e0be2127d7eacdce592c2ef0039204923dc0ec396454615cb4a3275b078", size = 590203, upload-time = "2026-07-09T00:32:20.531Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/81/1d/f3e271fbcd01ce01a4cf623b336d8e1305c192aa5d5e8e0223b7167462e9/fastmcp_slim-3.4.5.tar.gz", hash = "sha256:5badc3bceee61f61297eeb9494f499325f3ce1cafabf4611b31f6c3e9d7dff59", size = 591622, upload-time = "2026-07-27T19:15:19.455Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/16/91/321e0b2e9ed70d0628b17ddaec76fc7b09f3e1d5d290f70bf101a2890142/fastmcp_slim-3.4.4-py3-none-any.whl", hash = "sha256:9d3a6327b9ee835188eb7323fc3b5d4cd061631b48da8ece56794bb538972505", size = 765158, upload-time = "2026-07-09T00:32:19.11Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/43/3b/16d8aa8224094519f30b078138e725b8a731bf0a13f1f850e58b5f9b3cc4/fastmcp_slim-3.4.5-py3-none-any.whl", hash = "sha256:bc31217827c4999812543c83ee95ed9a47f3ed1e3fd0bd4f64371e375b748eca", size = 766478, upload-time = "2026-07-27T19:15:18.015Z" },
|
||||
]
|
||||
|
||||
[package.optional-dependencies]
|
||||
@@ -511,14 +594,14 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "joserfc"
|
||||
version = "1.6.5"
|
||||
version = "1.6.8"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "cryptography" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/3b/dc/5f768c2e391e9afabe5d18e3221346deb5fb6338565f1ccc9e7c6d7befdd/joserfc-1.6.5.tar.gz", hash = "sha256:1482a7db78fb4602e44ed89e51b599d052e091288c7c532c5b694e20149dec48", size = 231881, upload-time = "2026-05-06T04:58:13.408Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/5d/ac/d4fd5b30f82900eac60d765f179f0ba005825ac462cc8ced6e13ec685ab3/joserfc-1.6.8.tar.gz", hash = "sha256:878620c553a6ebdd76ccdc356782fee3f735f21a356d079a546b42a4670ace5f", size = 232930, upload-time = "2026-05-27T03:22:37.819Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/54/3b/ad1cb22e75c963b1f07c8a2329bf47227ce7e4361df5eb2fb101b2ce33ef/joserfc-1.6.5-py3-none-any.whl", hash = "sha256:e9878a0f8243fe7b95e11fdda81374ca9f7a689e302751579d3dfdeec559675e", size = 70464, upload-time = "2026-05-06T04:58:11.668Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/98/8c/5cdce2cf3ce8155849baf9a5e2ce77e89dc87ec3bdb38259e5d85fbc45bd/joserfc-1.6.8-py3-none-any.whl", hash = "sha256:22fb31a69094a5e6f44632002a9df2c30c941fc6c8ce1b037e92c03de954cf9f", size = 70927, upload-time = "2026-05-27T03:22:35.796Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -602,7 +685,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "mcp"
|
||||
version = "1.27.1"
|
||||
version = "1.28.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "anyio" },
|
||||
@@ -620,9 +703,9 @@ dependencies = [
|
||||
{ name = "typing-inspection" },
|
||||
{ name = "uvicorn", marker = "sys_platform != 'emscripten'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/38/83/d1efe7c2980d8a3afa476f4e3d42d53dd54c0ab94c27bee5d755b45c8b73/mcp-1.27.1.tar.gz", hash = "sha256:0f47e1820f8f8f941466b39749eb1d1839a04caddca2bc60e9d46e8a99914924", size = 608458, upload-time = "2026-05-08T16:50:12.601Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/6e/77/9450b8f251a13affb6281997d0523c4615f8a8b35d0b21ff30db3a5aac9d/mcp-1.28.1.tar.gz", hash = "sha256:d51e36a5f5644faea4f85ea649bfffa6bc6c26770d42798ad6a3de3d2ba69683", size = 638501, upload-time = "2026-06-26T12:57:29.093Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/fd/73/42d9596facebdb533b7f0b86c1b0364ef350d1f8ba78b1052e8a58b48b65/mcp-1.27.1-py3-none-any.whl", hash = "sha256:1af3c4203b329430fde7a87b4fcb6392a041f5cb851fd68fc674016ab4e7c06f", size = 216260, upload-time = "2026-05-08T16:50:10.547Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e2/5e/d118fce19f87a2e7d8101c35c8ae0ec289098a4df0ff244cec23e415aca0/mcp-1.28.1-py3-none-any.whl", hash = "sha256:2726bca5e7193f61c5dde8b12500a6de2d9acf6d1a1c0be9e8c2e706437991df", size = 222620, upload-time = "2026-06-26T12:57:27.218Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -716,21 +799,29 @@ dependencies = [
|
||||
[package.dev-dependencies]
|
||||
dev = [
|
||||
{ name = "bandit" },
|
||||
{ name = "coverage" },
|
||||
{ name = "pytest" },
|
||||
{ name = "pytest-asyncio" },
|
||||
{ name = "pytest-cov" },
|
||||
{ name = "pytest-env" },
|
||||
{ name = "ruff" },
|
||||
{ name = "vulture" },
|
||||
]
|
||||
|
||||
[package.metadata]
|
||||
requires-dist = [
|
||||
{ name = "fastmcp", specifier = "==3.4.4" },
|
||||
{ name = "fastmcp", specifier = "==3.4.5" },
|
||||
{ name = "httpx", specifier = "==0.28.1" },
|
||||
]
|
||||
|
||||
[package.metadata.requires-dev]
|
||||
dev = [
|
||||
{ name = "bandit", specifier = "==1.8.3" },
|
||||
{ name = "coverage", specifier = "==7.15.2" },
|
||||
{ name = "pytest", specifier = "==9.0.3" },
|
||||
{ name = "pytest-asyncio", specifier = "==1.4.0" },
|
||||
{ name = "pytest-cov", specifier = "==6.0.0" },
|
||||
{ name = "pytest-env", specifier = "==1.1.5" },
|
||||
{ name = "ruff", specifier = "==0.15.11" },
|
||||
{ name = "vulture", specifier = "==2.14" },
|
||||
]
|
||||
@@ -926,6 +1017,44 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249, upload-time = "2026-04-07T17:16:16.13Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pytest-asyncio"
|
||||
version = "1.4.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "pytest" },
|
||||
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/43/7c/d36d04db312ecf4298932ef77e6e4a9e8ad017906e24e34f0b0c361a2473/pytest_asyncio-1.4.0.tar.gz", hash = "sha256:c6c0d2259945122819f171a32ecea2c349ead889ee28176caaf492143424be42", size = 58514, upload-time = "2026-05-26T09:56:04.083Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/03/e2/08a497ef684b88559c9cc5f4ad53a37e7b99e727094a86d6ea32536d5d3c/pytest_asyncio-1.4.0-py3-none-any.whl", hash = "sha256:933ca923a23075a87fb7070c0ec272a6848489824d887c85c812670932835aa1", size = 16930, upload-time = "2026-05-26T09:56:02.576Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pytest-cov"
|
||||
version = "6.0.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "coverage" },
|
||||
{ name = "pytest" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/be/45/9b538de8cef30e17c7b45ef42f538a94889ed6a16f2387a6c89e73220651/pytest-cov-6.0.0.tar.gz", hash = "sha256:fde0b595ca248bb8e2d76f020b465f3b107c9632e6a1d1705f17834c89dcadc0", size = 66945, upload-time = "2024-10-29T20:13:35.363Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/36/3b/48e79f2cd6a61dbbd4807b4ed46cb564b4fd50a76166b1c4ea5c1d9e2371/pytest_cov-6.0.0-py3-none-any.whl", hash = "sha256:eee6f1b9e61008bd34975a4d5bab25801eb31898b032dd55addc93e96fcaaa35", size = 22949, upload-time = "2024-10-29T20:13:33.215Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pytest-env"
|
||||
version = "1.1.5"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "pytest" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/1f/31/27f28431a16b83cab7a636dce59cf397517807d247caa38ee67d65e71ef8/pytest_env-1.1.5.tar.gz", hash = "sha256:91209840aa0e43385073ac464a554ad2947cc2fd663a9debf88d03b01e0cc1cf", size = 8911, upload-time = "2024-09-17T22:39:18.566Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/de/b8/87cfb16045c9d4092cfcf526135d73b88101aac83bc1adcf82dfb5fd3833/pytest_env-1.1.5-py3-none-any.whl", hash = "sha256:ce90cf8772878515c24b31cd97c7fa1f4481cd68d588419fd45f10ecaee6bc30", size = 6141, upload-time = "2024-09-17T22:39:16.942Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "python-dotenv"
|
||||
version = "1.2.2"
|
||||
@@ -937,11 +1066,11 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "python-multipart"
|
||||
version = "0.0.28"
|
||||
version = "0.0.30"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/82/54/a85eb421fbdd5007bc5af39d0f4ed9fa609e0fedbfdc2adcf0b34526870e/python_multipart-0.0.28.tar.gz", hash = "sha256:8550da197eac0f7ab748961fc9509b999fa2662ea25cef857f05249f6893c0f8", size = 45314, upload-time = "2026-05-10T11:05:16.596Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/4b/82/c8cd43a6e0719bf5a3b034f6726dd701f75829c08944c83d4b95d02ed0e8/python_multipart-0.0.30.tar.gz", hash = "sha256:0edfe0475c1f46ddd3ff7785a626f6118af32bdcf359bb21260367313bb32118", size = 46316, upload-time = "2026-05-31T19:24:55.198Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/f3/a2/43bbc5860b5034e2af4ef99a0e04d726ff329c43e192ef3abaa8d7ecfce5/python_multipart-0.0.28-py3-none-any.whl", hash = "sha256:10faac07eb966c3f48dc415f9dee46c04cb10d58d30a35677db8027c825ed9b6", size = 29438, upload-time = "2026-05-10T11:05:15.052Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1c/fd/0318007beb234790993d3ec5afd051d1dbceb733e81e3afe2b981ece3f37/python_multipart-0.0.30-py3-none-any.whl", hash = "sha256:830964def8c90607ac5daa00514e3987815865713ade8d20febc9177ac0c3c5b", size = 29730, upload-time = "2026-05-31T19:24:53.814Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1166,8 +1295,8 @@ name = "secretstorage"
|
||||
version = "3.5.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "cryptography" },
|
||||
{ name = "jeepney" },
|
||||
{ name = "cryptography", marker = "sys_platform != 'win32'" },
|
||||
{ name = "jeepney", marker = "sys_platform != 'win32'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/1c/03/e834bcd866f2f8a49a85eaff47340affa3bfa391ee9912a952a1faa68c7b/secretstorage-3.5.0.tar.gz", hash = "sha256:f04b8e4689cbce351744d5537bf6b1329c6fc68f91fa666f60a380edddcd11be", size = 19884, upload-time = "2025-11-23T19:02:53.191Z" }
|
||||
wheels = [
|
||||
|
||||
@@ -4,6 +4,29 @@ All notable changes to the **Prowler SDK** are documented in this file.
|
||||
|
||||
<!-- changelog: release notes start -->
|
||||
|
||||
## [5.37.1] (Prowler v5.37.1)
|
||||
|
||||
### 🔄 Changed
|
||||
|
||||
- Huawei Cloud exception codes moved from `19000`-`19007` to `20000`-`20007`, resolving a collision with E2E Networks which reserves `19000`-`19999` [(#12306)](https://github.com/prowler-cloud/prowler/pull/12306)
|
||||
|
||||
### 🐞 Fixed
|
||||
|
||||
- Checks registered through the `prowler.checks.<provider>` entry-point group can now run against built-in providers. The built-in probe in `_resolve_check_module` used a bare `find_spec`, which imports the parent package to search it and so raised `ModuleNotFoundError` for a plug-in check instead of returning `None`, aborting the lookup before the entry points were consulted. Such a check was discovered, listed and selected for execution, then silently produced no findings. [(#12312)](https://github.com/prowler-cloud/prowler/pull/12312)
|
||||
- Entra Conditional Access guest-user checks no longer report false FAILs: microsoft-kiota packages bumped to 1.9.10 so `guestOrExternalUserTypes` (a flags enum Graph serializes as a comma-separated string) deserializes correctly instead of returning an empty list [(#12315)](https://github.com/prowler-cloud/prowler/pull/12315)
|
||||
|
||||
### 🔐 Security
|
||||
|
||||
- Bumped the Compose `postgres` and `valkey` images, clearing 10 critical CVEs [(#12307)](https://github.com/prowler-cloud/prowler/pull/12307)
|
||||
- Bumped PowerShell, Trivy, uv and `joserfc` in the container images, clearing 14 high-severity CVEs from the SDK and API images [(#12307)](https://github.com/prowler-cloud/prowler/pull/12307)
|
||||
- Bumped `httplib2` to 0.32.0 and `pyasn1` to 0.6.4 to resolve known CVEs [(#12307)](https://github.com/prowler-cloud/prowler/pull/12307)
|
||||
- The SDK container image now builds on Debian 13 (trixie), clearing the unfixable `libsqlite3-0` and `zlib1g` criticals [(#12307)](https://github.com/prowler-cloud/prowler/pull/12307)
|
||||
- Bumped `cryptography` to 48.0.1 to resolve GHSA-537c-gmf6-5ccf, along with the `oci`, `alibabacloud-tea-openapi`, `darabonba-core` and `py-ocsf-models` bumps it requires [(#12307)](https://github.com/prowler-cloud/prowler/pull/12307)
|
||||
- Removed `pip` from the SDK container image, clearing two high-severity CVEs in the vendored copies of `setuptools` and `msgpack` [(#12307)](https://github.com/prowler-cloud/prowler/pull/12307)
|
||||
- Removed `wget`, `gnupg` and `apt-transport-https` from the SDK runtime image [(#12307)](https://github.com/prowler-cloud/prowler/pull/12307)
|
||||
|
||||
---
|
||||
|
||||
## [5.37.0] (Prowler v5.37.0)
|
||||
|
||||
### 🚀 Added
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
Bumped the Compose DozerDB image from 5.26.3.0 to 5.26.27.0, which moves it off Debian 11 and onto Debian 13
|
||||
@@ -0,0 +1 @@
|
||||
Spurious error log output from `Get-ApplicationAccessPolicy` on M365 tenants without application access policies
|
||||
@@ -0,0 +1 @@
|
||||
`admincenter_shared_bookings_disabled` check for M365 provider, covering CIS Microsoft 365 Foundations Benchmark v7.0.0 control 1.3.9
|
||||
@@ -0,0 +1 @@
|
||||
`defender_priority_account_protection_enabled` and `defender_strict_preset_security_policy_enabled` checks for M365 provider, covering CIS Microsoft 365 Foundations Benchmark v7.0.0 controls 2.4.1 and 2.4.2
|
||||
@@ -0,0 +1 @@
|
||||
`exchange_owa_mailbox_policy_personal_accounts_disabled` and `exchange_organization_reject_direct_send_enabled` checks for M365 provider, covering CIS Microsoft 365 Foundations Benchmark v7.0.0 controls 6.3.2 and 6.5.5
|
||||
@@ -0,0 +1 @@
|
||||
The IAM privilege-escalation check now detects 22 additional pathfinding.cloud escalation paths across AWS Batch, Braket, CodeDeploy, Cognito Identity, ECS, EMR, EMR Serverless, GameLift, Glue, EC2 Image Builder, Kinesis Analytics, HealthOmics, EventBridge Scheduler, SSM Automation, Step Functions, IAM permissions boundaries, and IAM Identity Center (SSO)
|
||||
@@ -0,0 +1 @@
|
||||
Moved the Trivy suppressions from the classic `.trivyignore` to `.trivyignore.yaml`, so each entry is scoped to the package it names instead of suppressing its CVE across the whole image
|
||||
@@ -323,7 +323,9 @@
|
||||
{
|
||||
"Id": "1.3.9",
|
||||
"Description": "Shared Bookings allows you to invite your team members and create booking pages and let your customers book time with you and your team. It contains various settings to define services, manage staff members, configure schedules and availability, business hours and customize how appointments are scheduled. These pages can be customized to fit the diverse needs of your organization. It is an extension of Person Bookings. The recommended state is to restrict the OwaMailboxPolicy-Default policy or disable at the organization level.",
|
||||
"Checks": [],
|
||||
"Checks": [
|
||||
"admincenter_shared_bookings_disabled"
|
||||
],
|
||||
"Attributes": [
|
||||
{
|
||||
"Section": "1 Microsoft 365 admin center",
|
||||
@@ -768,7 +770,9 @@
|
||||
{
|
||||
"Id": "2.4.1",
|
||||
"Description": "Identify priority accounts to utilize Microsoft 365's advanced custom security features. This is an essential tool to bolster protection for users who are frequently targeted due to their critical positions, such as executives, leaders, managers, or others who have access to sensitive, confidential, financial, or high-priority information. Once these accounts are identified, several services and features can be enabled, including threat policies, enhanced sign-in protection through conditional access policies, and alert policies, enabling faster response times for incident response teams.",
|
||||
"Checks": [],
|
||||
"Checks": [
|
||||
"defender_priority_account_protection_enabled"
|
||||
],
|
||||
"Attributes": [
|
||||
{
|
||||
"Section": "2 Microsoft Defender",
|
||||
@@ -789,7 +793,9 @@
|
||||
{
|
||||
"Id": "2.4.2",
|
||||
"Description": "Preset security policies have been established by Microsoft, utilizing observations and experiences within datacenters to strike a balance between the exclusion of malicious content from users and limiting unwarranted disruptions. These policies can apply to all, or select users and encompass recommendations for addressing spam, malware, and phishing threats. The policy parameters are pre-determined and non-adjustable. Strict protection has the most aggressive protection of the 3 presets. - EOP: Anti-spam, Anti-malware and Anti-phishing - Defender: Spoof protection, Impersonation protection and Advanced phishing - Defender: Safe Links and Safe Attachments NOTE: The preset security polices cannot target Priority account TAGS currently, groups should be used instead.",
|
||||
"Checks": [],
|
||||
"Checks": [
|
||||
"defender_strict_preset_security_policy_enabled"
|
||||
],
|
||||
"Attributes": [
|
||||
{
|
||||
"Section": "2 Microsoft Defender",
|
||||
@@ -2571,7 +2577,9 @@
|
||||
{
|
||||
"Id": "6.3.2",
|
||||
"Description": "Outlook on the web (OWA) mailbox policies include two settings that control personal account integration in Outlook. PersonalAccountsEnabled controls whether users can add personal email accounts (e.g., Outlook.com, Gmail, Yahoo) in the new Outlook for Windows. PersonalAccountCalendarsEnabled controls whether users can connect personal Outlook.com or Google calendars in Outlook on the web. Neither setting applies to classic Outlook for Windows, Outlook for Mac, or Outlook mobile apps. The recommended state for the default OWA Mailbox Policy is: - PersonalAccountsEnabled is set to False - PersonalAccountCalendarsEnabled is set to False",
|
||||
"Checks": [],
|
||||
"Checks": [
|
||||
"exchange_owa_mailbox_policy_personal_accounts_disabled"
|
||||
],
|
||||
"Attributes": [
|
||||
{
|
||||
"Section": "6 Exchange admin center",
|
||||
@@ -2692,7 +2700,9 @@
|
||||
{
|
||||
"Id": "6.5.5",
|
||||
"Description": "Direct Send is a method used to send emails directly to an Exchange Online customer's hosted mailboxes from on-premises devices, applications, or third-party cloud services using the customer's own accepted domain. This method does not require any form of authentication because, by its nature, it mimics incoming anonymous emails from the internet, apart from the sender domain. The recommended state is to configure RejectDirectSend to True.",
|
||||
"Checks": [],
|
||||
"Checks": [
|
||||
"exchange_organization_reject_direct_send_enabled"
|
||||
],
|
||||
"Attributes": [
|
||||
{
|
||||
"Section": "6 Exchange admin center",
|
||||
|
||||
@@ -49,7 +49,7 @@ class _MutableTimestamp:
|
||||
|
||||
timestamp = _MutableTimestamp(datetime.today())
|
||||
timestamp_utc = _MutableTimestamp(datetime.now(timezone.utc))
|
||||
prowler_version = "5.37.0"
|
||||
prowler_version = "5.38.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"
|
||||
|
||||
+20
-14
@@ -21,7 +21,11 @@ from prowler.lib.check.utils import recover_checks_from_provider
|
||||
from prowler.lib.logger import logger
|
||||
from prowler.lib.outputs.outputs import report
|
||||
from prowler.lib.utils.utils import open_file, parse_json_file, print_boxes
|
||||
from prowler.providers.common.builtin import is_builtin_provider
|
||||
from prowler.providers.common.builtin import (
|
||||
builtin_check_module,
|
||||
is_builtin_check,
|
||||
is_builtin_provider,
|
||||
)
|
||||
from prowler.providers.common.models import Audit_Metadata
|
||||
|
||||
|
||||
@@ -401,21 +405,23 @@ def _resolve_check_module(
|
||||
when a plug-in tries to override, so the user knows their plug-in
|
||||
duplicate is being ignored and can rename it.
|
||||
|
||||
Gates the built-in branch on `is_builtin_provider(provider_type)` —
|
||||
calling `find_spec` on `prowler.providers.{provider_type}.services...`
|
||||
directly would propagate `ModuleNotFoundError` for external providers
|
||||
(their parent package `prowler.providers.{provider_type}` does not
|
||||
exist) instead of returning None. The leaf helper encapsulates the
|
||||
safe lookup, so external providers go straight to entry points. For
|
||||
built-ins we still use `find_spec` to distinguish "check doesn't
|
||||
exist" from "check exists but failed to import" (broken transitive
|
||||
dep, etc.).
|
||||
Both probes are gated on leaf helpers rather than a raw `find_spec`,
|
||||
because `find_spec` imports the parent package in order to search it and
|
||||
so propagates `ModuleNotFoundError` instead of returning None whenever
|
||||
that parent is absent. That happens on both axes: for an external
|
||||
provider (no `prowler.providers.{provider_type}` package) and, on a
|
||||
built-in provider, for an external check (no
|
||||
`prowler.providers.{provider_type}.services.{service}.{check_name}`
|
||||
package). Either one, probed naively, aborts the lookup before the entry
|
||||
points are ever consulted. `is_builtin_check` still distinguishes "check
|
||||
doesn't exist" from "check exists but failed to import" (broken
|
||||
transitive dep, etc.), which a blanket except would flatten.
|
||||
"""
|
||||
# Built-in first — built-in wins on CheckID collision
|
||||
if is_builtin_provider(provider_type):
|
||||
builtin_path = f"prowler.providers.{provider_type}.services.{service}.{check_name}.{check_name}"
|
||||
if importlib.util.find_spec(builtin_path) is not None:
|
||||
return import_check(builtin_path)
|
||||
if is_builtin_provider(provider_type) and is_builtin_check(
|
||||
provider_type, service, check_name
|
||||
):
|
||||
return import_check(builtin_check_module(provider_type, service, check_name))
|
||||
|
||||
# Entry point lookup — only consulted when the built-in truly doesn't exist
|
||||
for ep in importlib.metadata.entry_points(group=f"prowler.checks.{provider_type}"):
|
||||
|
||||
@@ -342,6 +342,122 @@ privilege_escalation_policies_combination = {
|
||||
"bedrock-agentcore:StartBrowserSession",
|
||||
"bedrock-agentcore:ConnectBrowserAutomationStream",
|
||||
},
|
||||
# Batch-based privilege escalation patterns (pathfinding.cloud BATCH-001/002)
|
||||
"PassRole+BatchRegisterJobDef+SubmitJob": {
|
||||
"iam:PassRole",
|
||||
"batch:RegisterJobDefinition",
|
||||
"batch:SubmitJob",
|
||||
},
|
||||
# Prerequisite: Existing Batch job definition with admin role
|
||||
"BatchSubmitJob": {"batch:SubmitJob"},
|
||||
# Braket-based privilege escalation patterns (pathfinding.cloud BRAKET-001)
|
||||
"PassRole+BraketCreateJob": {
|
||||
"iam:PassRole",
|
||||
"braket:CreateJob",
|
||||
},
|
||||
# CodeDeploy-based privilege escalation patterns (pathfinding.cloud CODEDEPLOY-001)
|
||||
# Prerequisite: Existing CodeDeploy application and deployment group with admin role
|
||||
"CodeDeployCreateDeployment": {
|
||||
"codedeploy:CreateDeployment",
|
||||
"codedeploy:RegisterApplicationRevision",
|
||||
"codedeploy:GetDeploymentConfig",
|
||||
},
|
||||
# Cognito Identity-based privilege escalation patterns (pathfinding.cloud COGNITOIDENTITY-001)
|
||||
"PassRole+CognitoSetIdentityPoolRoles": {
|
||||
"iam:PassRole",
|
||||
"cognito-identity:SetIdentityPoolRoles",
|
||||
},
|
||||
# ECS StartTask on an existing cluster (pathfinding.cloud ECS-009)
|
||||
"PassRole+ECSStartTaskExistingCluster": {
|
||||
"iam:PassRole",
|
||||
"ecs:StartTask",
|
||||
},
|
||||
# EMR-based privilege escalation patterns (pathfinding.cloud EMR-001)
|
||||
"PassRole+EMRRunJobFlow": {
|
||||
"iam:PassRole",
|
||||
"elasticmapreduce:RunJobFlow",
|
||||
},
|
||||
# EMR Serverless-based privilege escalation patterns (pathfinding.cloud EMRSERVERLESS-001)
|
||||
"PassRole+EMRServerlessCreateApp+StartJobRun": {
|
||||
"iam:PassRole",
|
||||
"emr-serverless:CreateApplication",
|
||||
"emr-serverless:StartJobRun",
|
||||
},
|
||||
# GameLift-based privilege escalation patterns (pathfinding.cloud GAMELIFT-001)
|
||||
"PassRole+GameLiftCreateBuild+CreateFleet": {
|
||||
"iam:PassRole",
|
||||
"gamelift:CreateBuild",
|
||||
"gamelift:CreateFleet",
|
||||
"gamelift:RequestUploadCredentials",
|
||||
},
|
||||
# Glue interactive session-based privilege escalation patterns (pathfinding.cloud GLUE-007)
|
||||
"PassRole+GlueCreateSession+RunStatement": {
|
||||
"iam:PassRole",
|
||||
"glue:CreateSession",
|
||||
"glue:RunStatement",
|
||||
},
|
||||
# EC2 Image Builder-based privilege escalation patterns (pathfinding.cloud IMAGEBUILDER-001)
|
||||
"PassRole+ImageBuilderCreateComponent+CreateImage": {
|
||||
"iam:PassRole",
|
||||
"imagebuilder:CreateComponent",
|
||||
"imagebuilder:CreateImageRecipe",
|
||||
"imagebuilder:CreateInfrastructureConfiguration",
|
||||
"imagebuilder:CreateImage",
|
||||
},
|
||||
# Kinesis Data Analytics-based privilege escalation patterns (pathfinding.cloud KINESISANALYTICS-001)
|
||||
"PassRole+KinesisAnalyticsCreateApp+StartApp": {
|
||||
"iam:PassRole",
|
||||
"kinesisanalytics:CreateApplication",
|
||||
"kinesisanalytics:StartApplication",
|
||||
},
|
||||
# HealthOmics-based privilege escalation patterns (pathfinding.cloud OMICS-001)
|
||||
"PassRole+OmicsCreateWorkflow+StartRun": {
|
||||
"iam:PassRole",
|
||||
"omics:CreateWorkflow",
|
||||
"omics:StartRun",
|
||||
"s3:GetObject",
|
||||
},
|
||||
# EventBridge Scheduler-based privilege escalation patterns (pathfinding.cloud SCHEDULER-001)
|
||||
"PassRole+SchedulerCreateSchedule": {
|
||||
"iam:PassRole",
|
||||
"scheduler:CreateSchedule",
|
||||
},
|
||||
# SSM Automation document-based privilege escalation patterns (pathfinding.cloud SSM-003)
|
||||
"PassRole+SSMCreateDocument+StartAutomation": {
|
||||
"iam:PassRole",
|
||||
"ssm:CreateDocument",
|
||||
"ssm:StartAutomationExecution",
|
||||
},
|
||||
# Step Functions-based privilege escalation patterns (pathfinding.cloud STEPFUNCTIONS-001)
|
||||
"PassRole+StepFunctionsCreateStateMachine+StartExecution": {
|
||||
"iam:PassRole",
|
||||
"states:CreateStateMachine",
|
||||
"states:StartExecution",
|
||||
},
|
||||
# Prerequisite: Existing Step Functions state machine with admin role (pathfinding.cloud STEPFUNCTIONS-002)
|
||||
"StepFunctionsUpdateStateMachine+StartExecution": {
|
||||
"states:UpdateStateMachine",
|
||||
"states:StartExecution",
|
||||
},
|
||||
# IAM permissions boundary removal self-escalation (pathfinding.cloud IAM-022)
|
||||
"iam:DeleteUserPermissionsBoundary": {"iam:DeleteUserPermissionsBoundary"},
|
||||
# Role permissions boundary removal plus role assumption (pathfinding.cloud IAM-023)
|
||||
"AssumeRole+DeleteRolePermissionsBoundary": {
|
||||
"sts:AssumeRole",
|
||||
"iam:DeleteRolePermissionsBoundary",
|
||||
},
|
||||
# IAM Identity Center (SSO)-based privilege escalation patterns (pathfinding.cloud SSO-001)
|
||||
"SSOCreatePermissionSet+CreateAccountAssignment+AttachManagedPolicy": {
|
||||
"sso:CreatePermissionSet",
|
||||
"sso:CreateAccountAssignment",
|
||||
"sso:AttachManagedPolicyToPermissionSet",
|
||||
},
|
||||
# Prerequisite: Existing permission set assigned to the attacker (pathfinding.cloud SSO-002)
|
||||
"sso:AttachManagedPolicyToPermissionSet": {
|
||||
"sso:AttachManagedPolicyToPermissionSet"
|
||||
},
|
||||
# Prerequisite: Existing permission set assigned to the attacker (pathfinding.cloud SSO-003)
|
||||
"sso:PutInlinePolicyToPermissionSet": {"sso:PutInlinePolicyToPermissionSet"},
|
||||
# TO-DO: We have to handle AssumeRole just if the resource is * and without conditions
|
||||
# "sts:AssumeRole": {"sts:AssumeRole"},
|
||||
}
|
||||
|
||||
@@ -27,3 +27,44 @@ def is_builtin_provider(provider: str) -> bool:
|
||||
return spec is not None
|
||||
except (ImportError, ValueError):
|
||||
return False
|
||||
|
||||
|
||||
def builtin_check_module(provider: str, service: str, check_name: str) -> str:
|
||||
"""Return the module path a built-in check would live at."""
|
||||
return f"prowler.providers.{provider}.services.{service}.{check_name}.{check_name}"
|
||||
|
||||
|
||||
def is_builtin_check(provider: str, service: str, check_name: str) -> bool:
|
||||
"""Return True if the check's module ships with the SDK.
|
||||
|
||||
Sibling of `is_builtin_provider`, and unsafe for the same reason if probed
|
||||
naively: `find_spec` imports the parent package in order to search it, so
|
||||
asking about a check that lives in a plug-in raises `ModuleNotFoundError`
|
||||
rather than returning `None`. A check registered through
|
||||
`prowler.checks.{provider}` never has a parent under
|
||||
`prowler.providers.{provider}.services.{service}`, so the naive probe makes
|
||||
every external check on a built-in provider unresolvable.
|
||||
|
||||
Unlike its sibling this one narrows the exception instead of swallowing
|
||||
every `ImportError`. A provider either ships with the SDK or it does not,
|
||||
but callers rely on this probe to tell "the check is not built-in" apart
|
||||
from "the check is built-in and its imports are broken". Reporting the
|
||||
second as the first would turn a broken dependency into a silent
|
||||
"check not found".
|
||||
"""
|
||||
module = builtin_check_module(provider, service, check_name)
|
||||
try:
|
||||
return importlib.util.find_spec(module) is not None
|
||||
except ModuleNotFoundError as error:
|
||||
# Only absorb "this check is simply not here". `error.name` is the
|
||||
# module that could not be imported; when it is the check's own path
|
||||
# (or a prefix of it) the check does not ship with the SDK. Anything
|
||||
# else — a missing third-party dependency, say — belongs to a built-in
|
||||
# check that does exist and must stay loud.
|
||||
if error.name is None or (
|
||||
error.name != module and not module.startswith(f"{error.name}.")
|
||||
):
|
||||
raise
|
||||
return False
|
||||
except ValueError:
|
||||
return False
|
||||
|
||||
@@ -1,40 +1,40 @@
|
||||
from prowler.exceptions.exceptions import ProwlerException
|
||||
|
||||
|
||||
# Exceptions codes from 19000 to 19099 are reserved for Huawei Cloud exceptions
|
||||
# Exceptions codes from 20000 to 20999 are reserved for Huawei Cloud exceptions
|
||||
class HuaweiCloudBaseException(ProwlerException):
|
||||
"""Base class for Huawei Cloud errors."""
|
||||
|
||||
HUAWEICLOUD_ERROR_CODES = {
|
||||
(19000, "HuaweiCloudCredentialsError"): {
|
||||
(20000, "HuaweiCloudCredentialsError"): {
|
||||
"message": "Huawei Cloud credentials not found or invalid",
|
||||
"remediation": "Provide valid Huawei Cloud credentials via the HUAWEICLOUD_ACCESS_KEY_ID and HUAWEICLOUD_SECRET_ACCESS_KEY environment variables.",
|
||||
},
|
||||
(19001, "HuaweiCloudAuthenticationError"): {
|
||||
(20001, "HuaweiCloudAuthenticationError"): {
|
||||
"message": "Huawei Cloud authentication failed",
|
||||
"remediation": "Verify the Access Key ID, Secret Access Key and Project/Domain ID, and ensure the credentials have the required IAM read permissions.",
|
||||
},
|
||||
(19002, "HuaweiCloudSetUpSessionError"): {
|
||||
(20002, "HuaweiCloudSetUpSessionError"): {
|
||||
"message": "Huawei Cloud session setup failed",
|
||||
"remediation": "Review the Huawei Cloud SDK initialization parameters and credentials.",
|
||||
},
|
||||
(19003, "HuaweiCloudIdentityError"): {
|
||||
(20003, "HuaweiCloudIdentityError"): {
|
||||
"message": "Unable to retrieve Huawei Cloud identity or account information",
|
||||
"remediation": "Ensure the credentials allow access to the IAM Keystone APIs (list auth domains/projects and show user).",
|
||||
},
|
||||
(19004, "HuaweiCloudInvalidRegionError"): {
|
||||
(20004, "HuaweiCloudInvalidRegionError"): {
|
||||
"message": "One or more requested Huawei Cloud regions are invalid",
|
||||
"remediation": "Pass a valid Huawei Cloud region id to --region. See https://developer.huaweicloud.com/intl/en-us/endpoint for the current list.",
|
||||
},
|
||||
(19005, "HuaweiCloudInvalidProviderIdError"): {
|
||||
(20005, "HuaweiCloudInvalidProviderIdError"): {
|
||||
"message": "The provided Huawei Cloud account id does not match the authenticated account",
|
||||
"remediation": "Ensure the credentials belong to the expected Huawei Cloud account id.",
|
||||
},
|
||||
(19006, "HuaweiCloudServiceError"): {
|
||||
(20006, "HuaweiCloudServiceError"): {
|
||||
"message": "Huawei Cloud service error",
|
||||
"remediation": "Review the requested service and region, and check the Huawei Cloud API documentation for more details.",
|
||||
},
|
||||
(19007, "HuaweiCloudAssumeRoleError"): {
|
||||
(20007, "HuaweiCloudAssumeRoleError"): {
|
||||
"message": "Failed to assume the Huawei Cloud agency",
|
||||
"remediation": "Verify HUAWEICLOUD_AGENCY_NAME and the target account (HUAWEICLOUD_ASSUME_DOMAIN_ID or HUAWEICLOUD_ASSUME_DOMAIN_NAME), and ensure the agency delegates the required permissions to the authenticated account.",
|
||||
},
|
||||
@@ -65,7 +65,7 @@ class HuaweiCloudCredentialsError(HuaweiCloudBaseException):
|
||||
|
||||
def __init__(self, file=None, original_exception=None, message=None):
|
||||
super().__init__(
|
||||
19000, file=file, original_exception=original_exception, message=message
|
||||
20000, file=file, original_exception=original_exception, message=message
|
||||
)
|
||||
|
||||
|
||||
@@ -74,7 +74,7 @@ class HuaweiCloudAuthenticationError(HuaweiCloudBaseException):
|
||||
|
||||
def __init__(self, file=None, original_exception=None, message=None):
|
||||
super().__init__(
|
||||
19001, file=file, original_exception=original_exception, message=message
|
||||
20001, file=file, original_exception=original_exception, message=message
|
||||
)
|
||||
|
||||
|
||||
@@ -83,7 +83,7 @@ class HuaweiCloudSetUpSessionError(HuaweiCloudBaseException):
|
||||
|
||||
def __init__(self, file=None, original_exception=None, message=None):
|
||||
super().__init__(
|
||||
19002, file=file, original_exception=original_exception, message=message
|
||||
20002, file=file, original_exception=original_exception, message=message
|
||||
)
|
||||
|
||||
|
||||
@@ -92,7 +92,7 @@ class HuaweiCloudIdentityError(HuaweiCloudBaseException):
|
||||
|
||||
def __init__(self, file=None, original_exception=None, message=None):
|
||||
super().__init__(
|
||||
19003, file=file, original_exception=original_exception, message=message
|
||||
20003, file=file, original_exception=original_exception, message=message
|
||||
)
|
||||
|
||||
|
||||
@@ -101,7 +101,7 @@ class HuaweiCloudInvalidRegionError(HuaweiCloudBaseException):
|
||||
|
||||
def __init__(self, file=None, original_exception=None, message=None):
|
||||
super().__init__(
|
||||
19004, file=file, original_exception=original_exception, message=message
|
||||
20004, file=file, original_exception=original_exception, message=message
|
||||
)
|
||||
|
||||
|
||||
@@ -110,7 +110,7 @@ class HuaweiCloudInvalidProviderIdError(HuaweiCloudBaseException):
|
||||
|
||||
def __init__(self, file=None, original_exception=None, message=None):
|
||||
super().__init__(
|
||||
19005, file=file, original_exception=original_exception, message=message
|
||||
20005, file=file, original_exception=original_exception, message=message
|
||||
)
|
||||
|
||||
|
||||
@@ -119,7 +119,7 @@ class HuaweiCloudServiceError(HuaweiCloudBaseException):
|
||||
|
||||
def __init__(self, file=None, original_exception=None, message=None):
|
||||
super().__init__(
|
||||
19006, file=file, original_exception=original_exception, message=message
|
||||
20006, file=file, original_exception=original_exception, message=message
|
||||
)
|
||||
|
||||
|
||||
@@ -128,5 +128,5 @@ class HuaweiCloudAssumeRoleError(HuaweiCloudBaseException):
|
||||
|
||||
def __init__(self, file=None, original_exception=None, message=None):
|
||||
super().__init__(
|
||||
19007, file=file, original_exception=original_exception, message=message
|
||||
20007, file=file, original_exception=original_exception, message=message
|
||||
)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
from prowler.exceptions.exceptions import ProwlerException
|
||||
|
||||
|
||||
# Exceptions codes from 5000 to 5999 are reserved for M365 exceptions
|
||||
# Exceptions codes from 6000 to 6999 are reserved for M365 exceptions
|
||||
class M365BaseException(ProwlerException):
|
||||
"""Base class for M365 Errors."""
|
||||
|
||||
|
||||
@@ -402,6 +402,39 @@ class M365PowerShell(PowerShellSession):
|
||||
"Get-MalwareFilterPolicy | ConvertTo-Json -Depth 10", json_parse=True
|
||||
)
|
||||
|
||||
def get_eop_protection_policy_rule(self) -> dict:
|
||||
"""
|
||||
Get Exchange Online Protection (EOP) preset security policy rules.
|
||||
|
||||
Returns:
|
||||
dict: EOP protection policy rules in JSON format.
|
||||
"""
|
||||
return self.execute(
|
||||
"Get-EOPProtectionPolicyRule | ConvertTo-Json -Depth 10", json_parse=True
|
||||
)
|
||||
|
||||
def get_atp_protection_policy_rule(self) -> dict:
|
||||
"""
|
||||
Get Defender for Office 365 (ATP) preset security policy rules.
|
||||
|
||||
Returns:
|
||||
dict: ATP protection policy rules in JSON format.
|
||||
"""
|
||||
return self.execute(
|
||||
"Get-ATPProtectionPolicyRule | ConvertTo-Json -Depth 10", json_parse=True
|
||||
)
|
||||
|
||||
def get_email_tenant_settings(self) -> dict:
|
||||
"""
|
||||
Get Defender email tenant settings.
|
||||
|
||||
Returns:
|
||||
dict: Email tenant settings (e.g. EnablePriorityAccountProtection).
|
||||
"""
|
||||
return self.execute(
|
||||
"Get-EmailTenantSettings | ConvertTo-Json -Depth 10", json_parse=True
|
||||
)
|
||||
|
||||
def get_malware_filter_rule(self) -> dict:
|
||||
"""
|
||||
Get Defender Malware Filter Rule.
|
||||
@@ -1022,8 +1055,11 @@ class M365PowerShell(PowerShellSession):
|
||||
}
|
||||
]
|
||||
"""
|
||||
# -ErrorAction SilentlyContinue: tenants with no application access
|
||||
# policies raise a localized "object not found" error instead of
|
||||
# returning an empty result; the error output never carries data.
|
||||
return self.execute(
|
||||
"Get-ApplicationAccessPolicy | ConvertTo-Json -Depth 10",
|
||||
"Get-ApplicationAccessPolicy -ErrorAction SilentlyContinue | ConvertTo-Json -Depth 10",
|
||||
json_parse=True,
|
||||
)
|
||||
|
||||
|
||||
@@ -14,10 +14,12 @@ class AdminCenter(M365Service):
|
||||
|
||||
self.organization_config = None
|
||||
self.sharing_policy = None
|
||||
self.mailbox_policies = []
|
||||
if self.powershell:
|
||||
if self.powershell.connect_exchange_online():
|
||||
self.organization_config = self._get_organization_config()
|
||||
self.sharing_policy = self._get_sharing_policy()
|
||||
self.mailbox_policies = self._get_mailbox_policy()
|
||||
self.powershell.close()
|
||||
|
||||
created_loop = False
|
||||
@@ -69,6 +71,9 @@ class AdminCenter(M365Service):
|
||||
customer_lockbox_enabled=organization_configuration.get(
|
||||
"CustomerLockboxEnabled", False
|
||||
),
|
||||
bookings_enabled=organization_configuration.get(
|
||||
"BookingsEnabled", True
|
||||
),
|
||||
)
|
||||
except Exception as error:
|
||||
logger.error(
|
||||
@@ -76,6 +81,39 @@ class AdminCenter(M365Service):
|
||||
)
|
||||
return organization_config
|
||||
|
||||
def _get_mailbox_policy(self):
|
||||
"""Retrieve the OWA mailbox policies via Exchange Online PowerShell.
|
||||
|
||||
Reads the OWA mailbox policy configuration and captures each policy's
|
||||
default flag and Bookings mailbox creation setting.
|
||||
|
||||
Returns:
|
||||
List[OwaMailboxPolicy]: The parsed OWA mailbox policies, empty on error.
|
||||
"""
|
||||
logger.info("Microsoft365 - Getting OWA mailbox policy configuration...")
|
||||
mailbox_policies = []
|
||||
try:
|
||||
policies_data = self.powershell.get_mailbox_policy()
|
||||
if policies_data:
|
||||
if isinstance(policies_data, dict):
|
||||
policies_data = [policies_data]
|
||||
for policy in policies_data:
|
||||
if policy:
|
||||
mailbox_policies.append(
|
||||
OwaMailboxPolicy(
|
||||
id=policy.get("Id", ""),
|
||||
is_default=policy.get("IsDefault", False),
|
||||
bookings_mailbox_creation_enabled=policy.get(
|
||||
"BookingsMailboxCreationEnabled", True
|
||||
),
|
||||
)
|
||||
)
|
||||
except Exception as error:
|
||||
logger.error(
|
||||
f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
|
||||
)
|
||||
return mailbox_policies
|
||||
|
||||
def _get_sharing_policy(self):
|
||||
logger.info("M365 - Getting sharing policy...")
|
||||
sharing_policy = None
|
||||
@@ -255,6 +293,22 @@ class Organization(BaseModel):
|
||||
name: str
|
||||
guid: str
|
||||
customer_lockbox_enabled: bool
|
||||
bookings_enabled: bool = True
|
||||
|
||||
|
||||
class OwaMailboxPolicy(BaseModel):
|
||||
"""Represents an Outlook on the web (OWA) mailbox policy.
|
||||
|
||||
Attributes:
|
||||
id: The mailbox policy identifier.
|
||||
is_default: Whether the policy is the default OWA mailbox policy.
|
||||
bookings_mailbox_creation_enabled: Whether users can create Bookings
|
||||
mailboxes under this policy.
|
||||
"""
|
||||
|
||||
id: str
|
||||
is_default: bool = False
|
||||
bookings_mailbox_creation_enabled: bool = True
|
||||
|
||||
|
||||
class SharingPolicy(BaseModel):
|
||||
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
{
|
||||
"Provider": "m365",
|
||||
"CheckID": "admincenter_shared_bookings_disabled",
|
||||
"CheckTitle": "Microsoft Shared Bookings is disabled",
|
||||
"CheckType": [],
|
||||
"ServiceName": "admincenter",
|
||||
"SubServiceName": "",
|
||||
"ResourceIdTemplate": "",
|
||||
"Severity": "medium",
|
||||
"ResourceType": "NotDefined",
|
||||
"ResourceGroup": "collaboration",
|
||||
"Description": "Microsoft Bookings (Shared Bookings) should be turned off unless the organization has a business need for it. Bookings lets users create public booking pages so customers can schedule time with staff. It is considered compliant when it is disabled at the tenant level (**BookingsEnabled**) or when the default OWA mailbox policy blocks Bookings mailbox creation (**BookingsMailboxCreationEnabled**).",
|
||||
"Risk": "**Bookings** pages are internet-facing and can expose staff names, email addresses, and availability, and may allow uncontrolled creation of Bookings mailboxes. Leaving Bookings enabled without a business need increases the organization's external attack surface and information disclosure risk.",
|
||||
"RelatedUrl": "",
|
||||
"AdditionalURLs": [
|
||||
"https://learn.microsoft.com/en-us/microsoft-365/bookings/turn-bookings-on-or-off"
|
||||
],
|
||||
"Remediation": {
|
||||
"Code": {
|
||||
"CLI": "Set-OrganizationConfig -BookingsEnabled $false",
|
||||
"NativeIaC": "",
|
||||
"Other": "1. Connect to Exchange Online PowerShell using Connect-ExchangeOnline\n2. To disable at the tenant level, run: Set-OrganizationConfig -BookingsEnabled $false\n3. Alternatively, disable in the default OWA mailbox policy: Set-OwaMailboxPolicy -Identity OwaMailboxPolicy-Default -BookingsMailboxCreationEnabled $false",
|
||||
"Terraform": ""
|
||||
},
|
||||
"Recommendation": {
|
||||
"Text": "Turn off Shared Bookings at the tenant level or in the default OWA mailbox policy unless there is a documented business need, in which case restrict who can create Bookings mailboxes.",
|
||||
"Url": "https://hub.prowler.com/check/admincenter_shared_bookings_disabled"
|
||||
}
|
||||
},
|
||||
"Categories": [
|
||||
"internet-exposed",
|
||||
"e3"
|
||||
],
|
||||
"DependsOn": [],
|
||||
"RelatedTo": [],
|
||||
"Notes": ""
|
||||
}
|
||||
+77
@@ -0,0 +1,77 @@
|
||||
from typing import List
|
||||
|
||||
from prowler.lib.check.models import Check, CheckReportM365
|
||||
from prowler.providers.m365.services.admincenter.admincenter_client import (
|
||||
admincenter_client,
|
||||
)
|
||||
|
||||
|
||||
class admincenter_shared_bookings_disabled(Check):
|
||||
"""Check if Microsoft Bookings (Shared Bookings) is disabled.
|
||||
|
||||
Bookings is considered disabled and compliant when either it is turned off at the
|
||||
tenant level (OrganizationConfig BookingsEnabled) or the default OWA mailbox
|
||||
policy prevents creation of Bookings mailboxes (BookingsMailboxCreationEnabled).
|
||||
|
||||
- PASS: Bookings is disabled at the tenant level or in the default OWA mailbox
|
||||
policy.
|
||||
- FAIL: Bookings is enabled at the tenant level and allowed by the default OWA
|
||||
mailbox policy.
|
||||
"""
|
||||
|
||||
def execute(self) -> List[CheckReportM365]:
|
||||
"""Execute the check for Shared Bookings.
|
||||
|
||||
Returns:
|
||||
List[CheckReportM365]: A list of reports containing the result of the check.
|
||||
"""
|
||||
findings = []
|
||||
organization_config = admincenter_client.organization_config
|
||||
if not organization_config:
|
||||
return findings
|
||||
|
||||
default_policy = next(
|
||||
(
|
||||
policy
|
||||
for policy in admincenter_client.mailbox_policies
|
||||
if policy and policy.is_default
|
||||
),
|
||||
None,
|
||||
)
|
||||
|
||||
report = CheckReportM365(
|
||||
metadata=self.metadata(),
|
||||
resource=organization_config,
|
||||
resource_name=organization_config.name,
|
||||
resource_id=organization_config.guid,
|
||||
)
|
||||
report.status = "FAIL"
|
||||
if default_policy:
|
||||
report.status_extended = (
|
||||
"Shared Bookings is enabled at the tenant level and the default OWA "
|
||||
"mailbox policy allows Bookings mailbox creation."
|
||||
)
|
||||
else:
|
||||
report.status_extended = (
|
||||
"Shared Bookings is enabled at the tenant level and no default OWA "
|
||||
"mailbox policy was found."
|
||||
)
|
||||
|
||||
tenant_disabled = not organization_config.bookings_enabled
|
||||
policy_disabled = bool(
|
||||
default_policy and not default_policy.bookings_mailbox_creation_enabled
|
||||
)
|
||||
|
||||
if tenant_disabled or policy_disabled:
|
||||
report.status = "PASS"
|
||||
if tenant_disabled:
|
||||
report.status_extended = (
|
||||
"Shared Bookings is disabled at the tenant level."
|
||||
)
|
||||
else:
|
||||
report.status_extended = (
|
||||
"Shared Bookings is disabled in the default OWA mailbox policy."
|
||||
)
|
||||
|
||||
findings.append(report)
|
||||
return findings
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
{
|
||||
"Provider": "m365",
|
||||
"CheckID": "defender_priority_account_protection_enabled",
|
||||
"CheckTitle": "Priority account protection is enabled",
|
||||
"CheckType": [],
|
||||
"ServiceName": "defender",
|
||||
"SubServiceName": "",
|
||||
"ResourceIdTemplate": "",
|
||||
"Severity": "medium",
|
||||
"ResourceType": "NotDefined",
|
||||
"ResourceGroup": "security",
|
||||
"Description": "Priority account protection applies enhanced monitoring and protection to high-value accounts. The tenant-level flag **EnablePriorityAccountProtection** (from Get-EmailTenantSettings) should be enabled. This check evaluates the tenant-level enablement flag; tagging priority accounts and configuring alert policies must be verified separately.",
|
||||
"Risk": "Without **priority account** protection, high-value targets such as executives receive the same protection as standard users, despite being far more likely to be targeted by **phishing** and **business email compromise**.",
|
||||
"RelatedUrl": "",
|
||||
"AdditionalURLs": [
|
||||
"https://learn.microsoft.com/en-us/defender-office-365/priority-accounts-security-recommendations"
|
||||
],
|
||||
"Remediation": {
|
||||
"Code": {
|
||||
"CLI": "",
|
||||
"NativeIaC": "",
|
||||
"Other": "1. Navigate to Microsoft Defender at https://security.microsoft.com/\n2. Go to **System** > **Settings** > **Email & collaboration** > **Priority account protection**\n3. Set **Priority account protection** to **On**\n4. Tag priority accounts and configure the associated alert policies",
|
||||
"Terraform": ""
|
||||
},
|
||||
"Recommendation": {
|
||||
"Text": "Enable priority account protection, tag your high-value accounts as priority accounts, and configure the associated alert policies for enhanced monitoring.",
|
||||
"Url": "https://hub.prowler.com/check/defender_priority_account_protection_enabled"
|
||||
}
|
||||
},
|
||||
"Categories": [
|
||||
"email-security",
|
||||
"e5"
|
||||
],
|
||||
"DependsOn": [],
|
||||
"RelatedTo": [],
|
||||
"Notes": "Covers the tenant-level EnablePriorityAccountProtection flag only; alert-policy verification (Get-ProtectionAlert) requires a Security & Compliance PowerShell session that Prowler does not currently establish."
|
||||
}
|
||||
+56
@@ -0,0 +1,56 @@
|
||||
from typing import List
|
||||
|
||||
from prowler.lib.check.models import Check, CheckReportM365
|
||||
from prowler.providers.m365.services.defender.defender_client import defender_client
|
||||
|
||||
|
||||
class defender_priority_account_protection_enabled(Check):
|
||||
"""Check if priority account protection is enabled.
|
||||
|
||||
Priority account protection applies enhanced monitoring and protection to
|
||||
high-value accounts. Its tenant-level flag ``EnablePriorityAccountProtection``
|
||||
(from Get-EmailTenantSettings) should be enabled.
|
||||
|
||||
Note: This check covers the tenant-level enablement flag only. The full control
|
||||
also requires priority accounts to be tagged and alert policies to be configured,
|
||||
which must be verified manually.
|
||||
|
||||
- PASS: Priority account protection is enabled at the tenant level.
|
||||
- FAIL: Priority account protection is disabled at the tenant level.
|
||||
"""
|
||||
|
||||
def execute(self) -> List[CheckReportM365]:
|
||||
"""Execute the priority account protection check.
|
||||
|
||||
Evaluates the tenant-level ``EnablePriorityAccountProtection`` flag from
|
||||
the Defender email tenant settings, producing PASS when enabled and FAIL
|
||||
when disabled. Returns no findings when settings are unavailable.
|
||||
|
||||
Returns:
|
||||
List[CheckReportM365]: A list with the check report, or empty when no
|
||||
email tenant settings are available.
|
||||
"""
|
||||
findings = []
|
||||
settings = defender_client.email_tenant_settings
|
||||
if not settings:
|
||||
return findings
|
||||
|
||||
report = CheckReportM365(
|
||||
metadata=self.metadata(),
|
||||
resource=settings,
|
||||
resource_name="Email Tenant Settings",
|
||||
resource_id="emailTenantSettings",
|
||||
)
|
||||
report.status = "FAIL"
|
||||
report.status_extended = (
|
||||
"Priority account protection is not enabled at the tenant level."
|
||||
)
|
||||
|
||||
if settings.priority_account_protection_enabled:
|
||||
report.status = "PASS"
|
||||
report.status_extended = (
|
||||
"Priority account protection is enabled at the tenant level."
|
||||
)
|
||||
|
||||
findings.append(report)
|
||||
return findings
|
||||
@@ -59,6 +59,9 @@ class Defender(M365Service):
|
||||
self.safe_links_policies = {}
|
||||
self.safe_links_rules = {}
|
||||
self.teams_protection_policy = None
|
||||
self.eop_protection_policy_rules = None
|
||||
self.atp_protection_policy_rules = None
|
||||
self.email_tenant_settings = None
|
||||
if self.powershell:
|
||||
if self.powershell.connect_exchange_online():
|
||||
self.malware_policies = self._get_malware_filter_policy()
|
||||
@@ -80,8 +83,102 @@ class Defender(M365Service):
|
||||
self.safe_links_policies = self._get_safe_links_policy()
|
||||
self.safe_links_rules = self._get_safe_links_rule()
|
||||
self.teams_protection_policy = self._get_teams_protection_policy()
|
||||
self.eop_protection_policy_rules = (
|
||||
self._get_eop_protection_policy_rules()
|
||||
)
|
||||
self.atp_protection_policy_rules = (
|
||||
self._get_atp_protection_policy_rules()
|
||||
)
|
||||
self.email_tenant_settings = self._get_email_tenant_settings()
|
||||
self.powershell.close()
|
||||
|
||||
def _parse_protection_policy_rules(self, rules_data):
|
||||
"""Parse preset security policy rules into PresetSecurityPolicyRule models."""
|
||||
rules = []
|
||||
if not rules_data:
|
||||
return rules
|
||||
if isinstance(rules_data, dict):
|
||||
rules_data = [rules_data]
|
||||
for rule in rules_data:
|
||||
if rule:
|
||||
rules.append(
|
||||
PresetSecurityPolicyRule(
|
||||
name=rule.get("Name", rule.get("Identity", "")),
|
||||
state=rule.get("State", ""),
|
||||
sent_to=self._normalize_list(rule.get("SentTo")),
|
||||
sent_to_member_of=self._normalize_list(
|
||||
rule.get("SentToMemberOf")
|
||||
),
|
||||
recipient_domain_is=self._normalize_list(
|
||||
rule.get("RecipientDomainIs")
|
||||
),
|
||||
)
|
||||
)
|
||||
return rules
|
||||
|
||||
@staticmethod
|
||||
def _normalize_list(value):
|
||||
"""Normalize a PowerShell scalar/list/None value into a list."""
|
||||
if value is None:
|
||||
return []
|
||||
if isinstance(value, list):
|
||||
return value
|
||||
return [value]
|
||||
|
||||
def _get_eop_protection_policy_rules(self):
|
||||
"""Retrieve the EOP preset security policy rules.
|
||||
|
||||
Returns:
|
||||
Optional[List[PresetSecurityPolicyRule]]: The parsed rules (empty when
|
||||
the tenant has none), or None on error so checks can skip instead of
|
||||
reporting on missing data.
|
||||
"""
|
||||
logger.info("M365 - Getting Defender EOP protection policy rules...")
|
||||
try:
|
||||
return self._parse_protection_policy_rules(
|
||||
self.powershell.get_eop_protection_policy_rule()
|
||||
)
|
||||
except Exception as error:
|
||||
logger.error(
|
||||
f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
|
||||
)
|
||||
return None
|
||||
|
||||
def _get_atp_protection_policy_rules(self):
|
||||
"""Retrieve the Defender for Office 365 (ATP) preset security policy rules.
|
||||
|
||||
Returns:
|
||||
Optional[List[PresetSecurityPolicyRule]]: The parsed rules (empty when
|
||||
the tenant has none), or None on error so checks can skip instead of
|
||||
reporting on missing data.
|
||||
"""
|
||||
logger.info("M365 - Getting Defender ATP protection policy rules...")
|
||||
try:
|
||||
return self._parse_protection_policy_rules(
|
||||
self.powershell.get_atp_protection_policy_rule()
|
||||
)
|
||||
except Exception as error:
|
||||
logger.error(
|
||||
f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
|
||||
)
|
||||
return None
|
||||
|
||||
def _get_email_tenant_settings(self):
|
||||
logger.info("M365 - Getting Defender email tenant settings...")
|
||||
try:
|
||||
data = self.powershell.get_email_tenant_settings()
|
||||
if data:
|
||||
return EmailTenantSettings(
|
||||
priority_account_protection_enabled=data.get(
|
||||
"EnablePriorityAccountProtection", False
|
||||
),
|
||||
)
|
||||
except Exception as error:
|
||||
logger.error(
|
||||
f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
|
||||
)
|
||||
return None
|
||||
|
||||
def _get_malware_filter_policy(self):
|
||||
logger.info("M365 - Getting Defender malware filter policy...")
|
||||
malware_policies = []
|
||||
@@ -826,3 +923,22 @@ class TeamsProtectionPolicy(BaseModel):
|
||||
|
||||
identity: str
|
||||
zap_enabled: bool
|
||||
|
||||
|
||||
class PresetSecurityPolicyRule(BaseModel):
|
||||
"""Model for a preset security policy rule (EOP or ATP).
|
||||
|
||||
Empty recipient conditions mean the rule applies to all recipients.
|
||||
"""
|
||||
|
||||
name: str = ""
|
||||
state: str = ""
|
||||
sent_to: list = []
|
||||
sent_to_member_of: list = []
|
||||
recipient_domain_is: list = []
|
||||
|
||||
|
||||
class EmailTenantSettings(BaseModel):
|
||||
"""Model for Defender email tenant settings."""
|
||||
|
||||
priority_account_protection_enabled: bool = False
|
||||
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
{
|
||||
"Provider": "m365",
|
||||
"CheckID": "defender_strict_preset_security_policy_enabled",
|
||||
"CheckTitle": "Strict Preset Security Policy is enabled",
|
||||
"CheckType": [],
|
||||
"ServiceName": "defender",
|
||||
"SubServiceName": "",
|
||||
"ResourceIdTemplate": "",
|
||||
"Severity": "medium",
|
||||
"ResourceType": "NotDefined",
|
||||
"ResourceGroup": "security",
|
||||
"Description": "The **Strict Preset Security Policy** applies Microsoft's recommended strict protection settings and should be enabled for both **Exchange Online Protection** (anti-phishing, anti-spam, anti-malware) and **Defender for Office 365** (Safe Attachments, Safe Links). The rules are exposed via Get-EOPProtectionPolicyRule and Get-ATPProtectionPolicyRule.",
|
||||
"Risk": "Without the **Strict Preset Security Policy** enabled, mailboxes rely on weaker default or custom protection settings, increasing exposure to **phishing**, malware, and malicious links and attachments.",
|
||||
"RelatedUrl": "",
|
||||
"AdditionalURLs": [
|
||||
"https://learn.microsoft.com/en-us/defender-office-365/preset-security-policies"
|
||||
],
|
||||
"Remediation": {
|
||||
"Code": {
|
||||
"CLI": "",
|
||||
"NativeIaC": "",
|
||||
"Other": "1. Navigate to Microsoft Defender at https://security.microsoft.com/\n2. Go to **Email & collaboration** > **Policies & rules** > **Threat policies** > **Preset security policies**\n3. Turn on the **Strict protection** preset and assign it to the appropriate users, groups, or domains (including priority accounts)",
|
||||
"Terraform": ""
|
||||
},
|
||||
"Recommendation": {
|
||||
"Text": "Enable the Strict Preset Security Policy for both Exchange Online Protection and Defender for Office 365 and assign it to your users and priority accounts.",
|
||||
"Url": "https://hub.prowler.com/check/defender_strict_preset_security_policy_enabled"
|
||||
}
|
||||
},
|
||||
"Categories": [
|
||||
"email-security",
|
||||
"e5"
|
||||
],
|
||||
"DependsOn": [],
|
||||
"RelatedTo": [],
|
||||
"Notes": ""
|
||||
}
|
||||
+91
@@ -0,0 +1,91 @@
|
||||
from typing import List
|
||||
|
||||
from prowler.lib.check.models import Check, CheckReportM365
|
||||
from prowler.providers.m365.services.defender.defender_client import defender_client
|
||||
|
||||
STRICT_PRESET_NAME = "Strict Preset Security Policy"
|
||||
|
||||
|
||||
class defender_strict_preset_security_policy_enabled(Check):
|
||||
"""Check if the Strict Preset Security Policy is enabled for EOP and Defender.
|
||||
|
||||
The Strict Preset Security Policy applies Microsoft's recommended strict
|
||||
protection settings. It should be enabled for both Exchange Online Protection
|
||||
(anti-phishing, anti-spam, anti-malware) and Defender for Office 365 (Safe
|
||||
Attachments, Safe Links). A rule with no recipient conditions applies to all
|
||||
recipients.
|
||||
|
||||
- PASS: The Strict Preset Security Policy is enabled for both EOP and Defender.
|
||||
- FAIL: The Strict Preset Security Policy is not enabled for EOP and/or Defender.
|
||||
"""
|
||||
|
||||
def _has_enabled_strict_preset(self, rules) -> bool:
|
||||
"""Check whether any rule enables the Strict Preset Security Policy.
|
||||
|
||||
A rule qualifies when it is named the Strict Preset Security Policy and is
|
||||
in the ``Enabled`` state. Recipient conditions are not evaluated because
|
||||
empty conditions mean the rule applies to all recipients.
|
||||
|
||||
Args:
|
||||
rules: Iterable of preset security policy rules (EOP or ATP).
|
||||
|
||||
Returns:
|
||||
bool: True if at least one rule enables the Strict Preset Security
|
||||
Policy, False otherwise.
|
||||
"""
|
||||
return any(
|
||||
rule.name == STRICT_PRESET_NAME and rule.state == "Enabled"
|
||||
for rule in rules
|
||||
)
|
||||
|
||||
def execute(self) -> List[CheckReportM365]:
|
||||
"""Execute the Strict Preset Security Policy check.
|
||||
|
||||
Evaluates whether the Strict Preset Security Policy is enabled for both
|
||||
Exchange Online Protection (EOP) and Defender for Office 365 (ATP),
|
||||
producing PASS only when both are enabled. Returns no findings when the
|
||||
policy rules could not be collected.
|
||||
|
||||
Returns:
|
||||
List[CheckReportM365]: A list with the check report, or empty when the
|
||||
preset policy rules are unavailable.
|
||||
"""
|
||||
findings = []
|
||||
eop_rules = defender_client.eop_protection_policy_rules
|
||||
atp_rules = defender_client.atp_protection_policy_rules
|
||||
if eop_rules is None or atp_rules is None:
|
||||
return findings
|
||||
|
||||
report = CheckReportM365(
|
||||
metadata=self.metadata(),
|
||||
resource={
|
||||
"eop": [rule.dict() for rule in eop_rules],
|
||||
"atp": [rule.dict() for rule in atp_rules],
|
||||
},
|
||||
resource_name="Strict Preset Security Policy",
|
||||
resource_id="strictPresetSecurityPolicy",
|
||||
)
|
||||
|
||||
eop_enabled = self._has_enabled_strict_preset(eop_rules)
|
||||
atp_enabled = self._has_enabled_strict_preset(atp_rules)
|
||||
|
||||
if eop_enabled and atp_enabled:
|
||||
report.status = "PASS"
|
||||
report.status_extended = (
|
||||
"The Strict Preset Security Policy is enabled for both Exchange "
|
||||
"Online Protection and Defender for Office 365."
|
||||
)
|
||||
else:
|
||||
missing = []
|
||||
if not eop_enabled:
|
||||
missing.append("Exchange Online Protection")
|
||||
if not atp_enabled:
|
||||
missing.append("Defender for Office 365")
|
||||
report.status = "FAIL"
|
||||
report.status_extended = (
|
||||
"The Strict Preset Security Policy is not enabled for "
|
||||
f"{' or '.join(missing)}."
|
||||
)
|
||||
|
||||
findings.append(report)
|
||||
return findings
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
{
|
||||
"Provider": "m365",
|
||||
"CheckID": "exchange_organization_reject_direct_send_enabled",
|
||||
"CheckTitle": "Direct Send is rejected for the Exchange Online organization",
|
||||
"CheckType": [],
|
||||
"ServiceName": "exchange",
|
||||
"SubServiceName": "",
|
||||
"ResourceIdTemplate": "",
|
||||
"Severity": "medium",
|
||||
"ResourceType": "NotDefined",
|
||||
"ResourceGroup": "collaboration",
|
||||
"Description": "The Exchange Online organization configuration should have **RejectDirectSend** enabled. Direct Send allows on-premises devices, applications, or third-party cloud services to send email to the tenant's hosted mailboxes using one of the tenant's accepted domains without authentication, which can be abused to spoof internal senders.",
|
||||
"Risk": "When **Direct Send** is allowed, anyone who knows a valid recipient and accepted domain can send unauthenticated email that appears to originate from inside the organization, enabling **phishing** and **business email compromise** against employees.",
|
||||
"RelatedUrl": "",
|
||||
"AdditionalURLs": [
|
||||
"https://learn.microsoft.com/en-us/exchange/mail-flow-best-practices/how-to-set-up-a-multifunction-device-or-application-to-send-email-using-microsoft-365-or-office-365#option-3-configure-a-connector-to-send-mail-using-microsoft-365-or-office-365-smtp-relay"
|
||||
],
|
||||
"Remediation": {
|
||||
"Code": {
|
||||
"CLI": "Set-OrganizationConfig -RejectDirectSend $true",
|
||||
"NativeIaC": "",
|
||||
"Other": "1. Connect to Exchange Online PowerShell using Connect-ExchangeOnline\n2. Run: Set-OrganizationConfig -RejectDirectSend $true\n3. Ensure legitimate senders use an authenticated connector or SMTP AUTH before enabling",
|
||||
"Terraform": ""
|
||||
},
|
||||
"Recommendation": {
|
||||
"Text": "Enable RejectDirectSend so that unauthenticated Direct Send email to the tenant's accepted domains is rejected. Migrate any legitimate on-premises or application senders to authenticated connectors first.",
|
||||
"Url": "https://hub.prowler.com/check/exchange_organization_reject_direct_send_enabled"
|
||||
}
|
||||
},
|
||||
"Categories": [
|
||||
"trust-boundaries",
|
||||
"e3"
|
||||
],
|
||||
"DependsOn": [],
|
||||
"RelatedTo": [],
|
||||
"Notes": ""
|
||||
}
|
||||
+47
@@ -0,0 +1,47 @@
|
||||
from typing import List
|
||||
|
||||
from prowler.lib.check.models import Check, CheckReportM365
|
||||
from prowler.providers.m365.services.exchange.exchange_client import exchange_client
|
||||
|
||||
|
||||
class exchange_organization_reject_direct_send_enabled(Check):
|
||||
"""Check if Direct Send is rejected in the Exchange Online organization.
|
||||
|
||||
Direct Send lets on-premises devices, applications, or third-party services
|
||||
send email to the tenant's hosted mailboxes using an accepted domain without
|
||||
authentication. Rejecting Direct Send reduces the risk of spoofed internal
|
||||
email.
|
||||
|
||||
- PASS: RejectDirectSend is enabled for the organization.
|
||||
- FAIL: RejectDirectSend is disabled for the organization.
|
||||
"""
|
||||
|
||||
def execute(self) -> List[CheckReportM365]:
|
||||
"""Execute the check for the Direct Send organization setting.
|
||||
|
||||
Returns:
|
||||
List[CheckReportM365]: A list of reports containing the result of the check.
|
||||
"""
|
||||
findings = []
|
||||
organization_config = exchange_client.organization_config
|
||||
if organization_config:
|
||||
report = CheckReportM365(
|
||||
metadata=self.metadata(),
|
||||
resource=organization_config,
|
||||
resource_name=organization_config.name,
|
||||
resource_id=organization_config.guid,
|
||||
)
|
||||
report.status = "FAIL"
|
||||
report.status_extended = (
|
||||
"Direct Send is not rejected for the Exchange Online organization."
|
||||
)
|
||||
|
||||
if organization_config.reject_direct_send:
|
||||
report.status = "PASS"
|
||||
report.status_extended = (
|
||||
"Direct Send is rejected for the Exchange Online organization."
|
||||
)
|
||||
|
||||
findings.append(report)
|
||||
|
||||
return findings
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
{
|
||||
"Provider": "m365",
|
||||
"CheckID": "exchange_owa_mailbox_policy_personal_accounts_disabled",
|
||||
"CheckTitle": "Default OWA mailbox policy disables personal account integration",
|
||||
"CheckType": [],
|
||||
"ServiceName": "exchange",
|
||||
"SubServiceName": "",
|
||||
"ResourceIdTemplate": "",
|
||||
"Severity": "medium",
|
||||
"ResourceType": "NotDefined",
|
||||
"ResourceGroup": "collaboration",
|
||||
"Description": "The default Outlook on the web (OWA) mailbox policy should have **PersonalAccountsEnabled** and **PersonalAccountCalendarsEnabled** set to false. The former controls adding personal email accounts (e.g., Outlook.com, Gmail) in the new Outlook for Windows; the latter controls connecting personal Outlook.com or Google calendars in Outlook on the web.",
|
||||
"Risk": "Allowing personal account integration lets corporate and personal data mix within the same client, increasing the risk of data leakage and reducing the organization's ability to govern and audit access to corporate mail and calendars.",
|
||||
"RelatedUrl": "",
|
||||
"AdditionalURLs": [
|
||||
"https://learn.microsoft.com/en-us/powershell/module/exchange/set-owamailboxpolicy"
|
||||
],
|
||||
"Remediation": {
|
||||
"Code": {
|
||||
"CLI": "Set-OwaMailboxPolicy -Identity OwaMailboxPolicy-Default -PersonalAccountsEnabled $false -PersonalAccountCalendarsEnabled $false",
|
||||
"NativeIaC": "",
|
||||
"Other": "1. Connect to Exchange Online PowerShell using Connect-ExchangeOnline\n2. Run: Set-OwaMailboxPolicy -Identity OwaMailboxPolicy-Default -PersonalAccountsEnabled $false -PersonalAccountCalendarsEnabled $false",
|
||||
"Terraform": ""
|
||||
},
|
||||
"Recommendation": {
|
||||
"Text": "Disable PersonalAccountsEnabled and PersonalAccountCalendarsEnabled on the default OWA mailbox policy to prevent users from adding personal email accounts in the new Outlook for Windows and connecting personal calendars in Outlook on the web.",
|
||||
"Url": "https://hub.prowler.com/check/exchange_owa_mailbox_policy_personal_accounts_disabled"
|
||||
}
|
||||
},
|
||||
"Categories": [
|
||||
"trust-boundaries",
|
||||
"e3"
|
||||
],
|
||||
"DependsOn": [],
|
||||
"RelatedTo": [],
|
||||
"Notes": ""
|
||||
}
|
||||
+54
@@ -0,0 +1,54 @@
|
||||
from typing import List
|
||||
|
||||
from prowler.lib.check.models import Check, CheckReportM365
|
||||
from prowler.providers.m365.services.exchange.exchange_client import exchange_client
|
||||
|
||||
|
||||
class exchange_owa_mailbox_policy_personal_accounts_disabled(Check):
|
||||
"""Check if the default OWA mailbox policy disables personal account integration.
|
||||
|
||||
Outlook on the web mailbox policies expose PersonalAccountsEnabled, which
|
||||
controls whether users can add personal email accounts in the new Outlook for
|
||||
Windows, and PersonalAccountCalendarsEnabled, which controls whether users can
|
||||
connect personal Outlook.com or Google calendars in Outlook on the web. Only
|
||||
the default OWA mailbox policy is required for compliance with this control.
|
||||
|
||||
- PASS: The default OWA mailbox policy disables personal accounts and personal
|
||||
account calendars.
|
||||
- FAIL: The default OWA mailbox policy allows personal accounts and/or personal
|
||||
account calendars.
|
||||
"""
|
||||
|
||||
def execute(self) -> List[CheckReportM365]:
|
||||
"""Execute the check for the default OWA mailbox policy personal account settings.
|
||||
|
||||
Returns:
|
||||
List[CheckReportM365]: A list of reports containing the result of the check.
|
||||
"""
|
||||
findings = []
|
||||
for mailbox_policy in exchange_client.mailbox_policies:
|
||||
if not mailbox_policy or not mailbox_policy.is_default:
|
||||
continue
|
||||
|
||||
report = CheckReportM365(
|
||||
metadata=self.metadata(),
|
||||
resource=mailbox_policy,
|
||||
resource_name=f"Exchange Mailbox Policy - {mailbox_policy.id}",
|
||||
resource_id=mailbox_policy.id,
|
||||
)
|
||||
report.status = "PASS"
|
||||
report.status_extended = f"Default OWA mailbox policy '{mailbox_policy.id}' disables personal account integration."
|
||||
|
||||
allowed_settings = []
|
||||
if mailbox_policy.personal_accounts_enabled:
|
||||
allowed_settings.append("personal accounts")
|
||||
if mailbox_policy.personal_account_calendars_enabled:
|
||||
allowed_settings.append("personal account calendars")
|
||||
|
||||
if allowed_settings:
|
||||
report.status = "FAIL"
|
||||
report.status_extended = f"Default OWA mailbox policy '{mailbox_policy.id}' allows {' and '.join(allowed_settings)}."
|
||||
|
||||
findings.append(report)
|
||||
|
||||
return findings
|
||||
@@ -108,6 +108,16 @@ class Exchange(M365Service):
|
||||
return None
|
||||
|
||||
def _get_organization_config(self):
|
||||
"""Retrieve the Exchange Online organization configuration.
|
||||
|
||||
Reads Get-OrganizationConfig via Exchange Online PowerShell. Boolean
|
||||
properties that can come back null (never configured) are normalized to
|
||||
their platform defaults, e.g. RejectDirectSend to False.
|
||||
|
||||
Returns:
|
||||
Optional[Organization]: The parsed organization configuration, or
|
||||
None when unavailable or on error.
|
||||
"""
|
||||
logger.info("Microsoft365 - Getting Exchange Organization configuration...")
|
||||
organization_config = None
|
||||
try:
|
||||
@@ -137,6 +147,12 @@ class Exchange(M365Service):
|
||||
delayed_delicensing_enabled=organization_configuration.get(
|
||||
"DelayedDelicensingEnabled", False
|
||||
),
|
||||
# Can be null on tenants where the setting was never
|
||||
# configured; null keeps the platform default (disabled).
|
||||
reject_direct_send=organization_configuration.get(
|
||||
"RejectDirectSend"
|
||||
)
|
||||
is True,
|
||||
)
|
||||
except Exception as error:
|
||||
logger.error(
|
||||
@@ -241,6 +257,15 @@ class Exchange(M365Service):
|
||||
return transport_config
|
||||
|
||||
def _get_mailbox_policy(self):
|
||||
"""Retrieve the OWA mailbox policies.
|
||||
|
||||
Reads Get-OwaMailboxPolicy via Exchange Online PowerShell. The personal
|
||||
account properties can come back null (never configured) and are
|
||||
normalized to their platform defaults (enabled).
|
||||
|
||||
Returns:
|
||||
List[MailboxPolicy]: The parsed OWA mailbox policies, empty on error.
|
||||
"""
|
||||
logger.info("Microsoft365 - Getting mailbox policy configuration...")
|
||||
mailbox_policies = []
|
||||
try:
|
||||
@@ -256,6 +281,18 @@ class Exchange(M365Service):
|
||||
additional_storage_enabled=policy.get(
|
||||
"AdditionalStorageProvidersAvailable", True
|
||||
),
|
||||
# These properties can be null on tenants where the
|
||||
# setting was never configured; null keeps the
|
||||
# platform default.
|
||||
is_default=policy.get("IsDefault") is True,
|
||||
personal_accounts_enabled=policy.get(
|
||||
"PersonalAccountsEnabled"
|
||||
)
|
||||
is not False,
|
||||
personal_account_calendars_enabled=policy.get(
|
||||
"PersonalAccountCalendarsEnabled"
|
||||
)
|
||||
is not False,
|
||||
)
|
||||
)
|
||||
except Exception as error:
|
||||
@@ -489,6 +526,7 @@ class Organization(BaseModel):
|
||||
mailtips_large_audience_threshold: int
|
||||
delayed_delicensing_enabled: bool = False
|
||||
total_paid_licenses: Optional[int] = None
|
||||
reject_direct_send: bool = False
|
||||
|
||||
|
||||
class MailboxAuditConfig(BaseModel):
|
||||
@@ -516,6 +554,9 @@ class TransportConfig(BaseModel):
|
||||
class MailboxPolicy(BaseModel):
|
||||
id: str
|
||||
additional_storage_enabled: bool
|
||||
is_default: bool = False
|
||||
personal_accounts_enabled: bool = True
|
||||
personal_account_calendars_enabled: bool = True
|
||||
|
||||
|
||||
class RoleAssignmentPolicy(BaseModel):
|
||||
|
||||
+17
-17
@@ -68,7 +68,7 @@ dependencies = [
|
||||
"boto3==1.40.61",
|
||||
"botocore==1.40.61",
|
||||
"colorama==0.4.6",
|
||||
"cryptography==46.0.7",
|
||||
"cryptography==48.0.1",
|
||||
"dash==3.1.1",
|
||||
"dash-bootstrap-components==2.0.3",
|
||||
"defusedxml==0.7.1",
|
||||
@@ -80,13 +80,13 @@ dependencies = [
|
||||
"kubernetes==32.0.1",
|
||||
"linode-api4==5.45.0",
|
||||
"markdown==3.10.2",
|
||||
"microsoft-kiota-abstractions==1.9.9",
|
||||
"microsoft-kiota-abstractions==1.9.10",
|
||||
"numpy==2.2.6",
|
||||
"msgraph-sdk==1.55.0",
|
||||
"okta==3.4.2",
|
||||
"openstacksdk==4.2.0",
|
||||
"pandas==2.2.3",
|
||||
"py-ocsf-models==0.8.1",
|
||||
"py-ocsf-models==0.10.0",
|
||||
"pydantic==2.12.5",
|
||||
"pygithub==2.8.0",
|
||||
"python-dateutil==2.9.0.post0",
|
||||
@@ -103,10 +103,10 @@ dependencies = [
|
||||
"uuid6==2024.7.10",
|
||||
"py-iam-expand==0.3.0",
|
||||
"h2==4.3.0",
|
||||
"oci==2.169.0",
|
||||
"oci==2.183.0",
|
||||
"alibabacloud_credentials==1.0.3",
|
||||
"alibabacloud_ram20150501==1.2.0",
|
||||
"alibabacloud_tea_openapi==0.4.4",
|
||||
"alibabacloud_tea_openapi==0.4.5",
|
||||
"alibabacloud_sts20150401==1.1.6",
|
||||
"alibabacloud_vpc20160428==6.13.0",
|
||||
"alibabacloud_ecs20140526==7.2.5",
|
||||
@@ -136,7 +136,7 @@ maintainers = [{name = "Prowler Engineering", email = "engineering@prowler.com"}
|
||||
name = "prowler"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10,<3.14"
|
||||
version = "5.37.0"
|
||||
version = "5.38.0"
|
||||
|
||||
[project.scripts]
|
||||
prowler = "prowler.__main__:prowler"
|
||||
@@ -199,7 +199,7 @@ constraint-dependencies = [
|
||||
"alibabacloud-sas20181203==6.1.0",
|
||||
"alibabacloud-sts20150401==1.1.6",
|
||||
"alibabacloud-tea==0.4.3",
|
||||
"alibabacloud-tea-openapi==0.4.4",
|
||||
"alibabacloud-tea-openapi==0.4.5",
|
||||
"alibabacloud-tea-util==0.3.14",
|
||||
"alibabacloud-tea-xml==0.0.3",
|
||||
"alibabacloud-vpc20160428==6.13.0",
|
||||
@@ -228,7 +228,7 @@ constraint-dependencies = [
|
||||
"click-plugins==1.1.1.2",
|
||||
"contextlib2==21.6.0",
|
||||
"coverage==7.6.12",
|
||||
"darabonba-core==1.0.5",
|
||||
"darabonba-core==1.0.8",
|
||||
"decorator==5.2.1",
|
||||
"deprecated==1.3.1",
|
||||
"dill==0.4.1",
|
||||
@@ -253,7 +253,7 @@ constraint-dependencies = [
|
||||
"h11==0.16.0",
|
||||
"hpack==4.1.0",
|
||||
"httpcore==1.0.9",
|
||||
"httplib2==0.31.2",
|
||||
"httplib2==0.32.0",
|
||||
"httpx==0.28.1",
|
||||
"huaweicloudsdkcore==3.1.204",
|
||||
"huaweicloudsdkcts==3.1.204",
|
||||
@@ -277,7 +277,7 @@ constraint-dependencies = [
|
||||
"itsdangerous==2.2.0",
|
||||
"jinja2==3.1.6",
|
||||
"jmespath==1.1.0",
|
||||
"joserfc==1.6.5",
|
||||
"joserfc==1.6.8",
|
||||
"jsonpatch==1.33",
|
||||
"jsonpath-ng==1.8.0",
|
||||
"jsonpointer==3.1.1",
|
||||
@@ -291,12 +291,12 @@ constraint-dependencies = [
|
||||
"markupsafe==3.0.3",
|
||||
"mccabe==0.7.0",
|
||||
"mdurl==0.1.2",
|
||||
"microsoft-kiota-authentication-azure==1.9.9",
|
||||
"microsoft-kiota-http==1.9.9",
|
||||
"microsoft-kiota-serialization-form==1.9.9",
|
||||
"microsoft-kiota-serialization-json==1.9.9",
|
||||
"microsoft-kiota-serialization-multipart==1.9.9",
|
||||
"microsoft-kiota-serialization-text==1.9.9",
|
||||
"microsoft-kiota-authentication-azure==1.9.10",
|
||||
"microsoft-kiota-http==1.9.10",
|
||||
"microsoft-kiota-serialization-form==1.9.10",
|
||||
"microsoft-kiota-serialization-json==1.9.10",
|
||||
"microsoft-kiota-serialization-multipart==1.9.10",
|
||||
"microsoft-kiota-serialization-text==1.9.10",
|
||||
"mock==5.2.0",
|
||||
"moto==5.1.11",
|
||||
"mpmath==1.3.0",
|
||||
@@ -331,7 +331,7 @@ constraint-dependencies = [
|
||||
"protobuf==7.34.1",
|
||||
"psutil==7.2.2",
|
||||
"py-partiql-parser==0.6.1",
|
||||
"pyasn1==0.6.3",
|
||||
"pyasn1==0.6.4",
|
||||
"pyasn1-modules==0.4.2",
|
||||
"pycodestyle==2.12.1",
|
||||
"pycparser==3.0",
|
||||
|
||||
@@ -72,10 +72,12 @@ Use `@mcp.tool()` decorator directly—no BaseTool or models required.
|
||||
- [ ] Error handling returns `{"error": str, "status": "failed"}`
|
||||
- [ ] Parameters use `Field()` with descriptions
|
||||
- [ ] No hardcoded secrets
|
||||
- [ ] Tests added under `mcp_server/tests/`
|
||||
|
||||
---
|
||||
|
||||
## Resources
|
||||
|
||||
- **Full Guide**: [docs/developer-guide/mcp-server.mdx](../../../docs/developer-guide/mcp-server.mdx)
|
||||
- **Full Guide**: [docs/developer-guide/mcp-server.mdx](../../docs/developer-guide/mcp-server.mdx)
|
||||
- **Templates**: See [assets/](assets/) for tool and model templates
|
||||
- **Testing**: See [prowler-test-mcp](../prowler-test-mcp/SKILL.md) for fixtures and test patterns
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user