Compare commits

...
67 Commits
Author SHA1 Message Date
Prowler Botandprowler-bot b21ddad32c chore(api): Update prowler dependency to v5.38 for release 5.38.0 (#12374)
Co-authored-by: prowler-bot <179230569+prowler-bot@users.noreply.github.com>
2026-08-06 13:36:54 +02:00
Prowler Botandprowler-bot 226504982b chore(changelog): v5.38.0 (#12373)
Co-authored-by: prowler-bot <179230569+prowler-bot@users.noreply.github.com>
2026-08-06 13:25:12 +02:00
César Arroba a700865340 ci: always report from actionlint, skipping the work when nothing changed (#12371) 2026-08-06 12:53:43 +02:00
César Arroba 07faddd64e ci: fix the remaining shellcheck findings and enable the check (#12367) 2026-08-06 12:35:55 +02:00
César Arroba 1b9a44b164 fix(ci): keep the cloud sync dispatch payload within the 10-property limit (#12370) 2026-08-06 12:31:37 +02:00
Alan Buscagliaandalejandrobailo ff0ee666e3 fix(ui): prevent feedback widget overlaps (#12282)
Co-authored-by: alejandrobailo <alejandrobailo94@gmail.com>
2026-08-06 12:28:32 +02:00
César Arroba b684ad06f3 ci: forward stacked PR metadata in the cloud sync dispatch (#12368) 2026-08-06 12:24:48 +02:00
Alan Buscagliaandalejandrobailo cd4693168a fix(ui): align overview metric cards (#12323)
Co-authored-by: alejandrobailo <alejandrobailo94@gmail.com>
2026-08-06 12:21:14 +02:00
Alejandro Bailo f727f1bb50 fix(ui): handle pending compliance overview responses (#12358) 2026-08-06 12:00:30 +02:00
César Arroba 76d7a2882c ci: quote the unquoted shell expansions in workflows (#12365) 2026-08-06 11:59:40 +02:00
Josema Camacho c7adc14729 fix(ui): correct GCP organization credential labels (#12362) 2026-08-06 11:28:22 +02:00
César Arroba 31d8faccfa ci: check GitHub Actions schemas with actionlint (#12361) 2026-08-06 11:01:53 +02:00
Hugo Pereira Brito d0da56f352 fix(alibabacloud): retry STS connection failures (#12353) 2026-08-06 09:44:00 +01:00
Adrián Peña 5e41b2054d docs: document multiple SAML domains (#12351) 2026-08-06 10:07:41 +02:00
Josema Camacho 058db7bcc9 fix(ui): launch organization scans through the bulk endpoint (#12350) 2026-08-06 09:48:53 +02:00
César Arroba d1a37039fd fix(deps): upgrade cryptography to 50.0.0 (#12356) 2026-08-05 19:16:11 +02:00
Rubén De la Torre VicoandClaude Opus 5 97c342ad80 test(mcp): cover the integrations tools and models (#12343)
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-05 16:47:11 +02:00
César Arroba f3c602a5ac feat(container): ship an SBOM and provenance with the published images (#12352) 2026-08-05 16:19:42 +02:00
Josema Camacho d7816f1179 feat(ui): show sign-in methods in the Cloud users table (#12268) 2026-08-05 14:41:59 +02:00
Daniel Barranquero dd61c417b7 feat(attack-paths): add outcome to query metadata (#12344) 2026-08-05 14:13:24 +02:00
Pablo Fernandez Guerra (PFE) 2fdd46336c fix(ui): keep page-size selector when table fits one page (#12299) 2026-08-05 14:13:11 +02:00
Pablo Fernandez Guerra (PFE) ae8c86ecb5 fix(ui): always show Scans page so imported scans are visible (#12025) 2026-08-05 14:12:17 +02:00
Pedro MartínandDaniel Barranquero aaa29d3528 feat(m365): add CIS M365 v7.0.0 entra device registration checks (#12152)
Co-authored-by: Daniel Barranquero <danielbo2001@gmail.com>
2026-08-05 13:51:29 +02:00
Pepe Fagoaga 06799dcaa8 docs(compliance): watchlist review (#12348) 2026-08-05 13:44:03 +02:00
Pepe Fagoaga b53c5a4e70 docs(compliance): watchlist (#12347) 2026-08-05 13:29:45 +02:00
César Arroba af757a4d69 ci(container): pin Trivy to v0.72.0 across the estate (#12346) 2026-08-05 13:12:24 +02:00
Pedro MartínandDaniel Barranquero f87522e423 feat(m365): add CIS M365 v7.0.0 teams external access check (#12151)
Co-authored-by: Daniel Barranquero <danielbo2001@gmail.com>
2026-08-05 12:24:44 +02:00
Adrián Peña d3524d50fb feat(ui): support multiple SAML domains in Cloud (#12332) 2026-08-05 11:49:59 +02:00
Pedro Martín 5b1bd146be docs(m365): highlight key terms in check Risk descriptions (#12156) 2026-08-05 10:49:53 +01:00
Pablo Fernandez Guerra (PFE) 34431b5b88 test(ui): stabilize browser-mode test harnesses (#12301) 2026-08-05 11:37:58 +02:00
Pedro Martínandalejandrobailo 5285d25cfd feat(ui): add compliance watchlist (#12300)
Co-authored-by: alejandrobailo <alejandrobailo94@gmail.com>
2026-08-05 11:18:45 +02:00
Pedro MartínandAlan Buscaglia a6d5dbacd9 fix(api): log comp report output dir failures with exc_info (#12142)
Co-authored-by: Alan Buscaglia <gentlemanprogramming@gmail.com>
2026-08-05 11:12:35 +02:00
César Arroba 3b577907e4 ci(container): gate Grype only on fixable findings, and comment results on the PR (#12341) 2026-08-05 10:23:29 +02:00
Pedro Martín 8bf788ea95 fix(aws): delegated administrator lookup and reporting (#12319) 2026-08-05 09:48:00 +02:00
César Arroba 87bc1eceae ci(container): scan images with Grype alongside Trivy, blocking on critical and high (#12340) 2026-08-04 18:49:53 +02:00
César Arroba 635e451d9b fix(container): verify the checksum of downloaded third-party binaries (#12334) 2026-08-04 18:07:46 +02:00
Pedro MartínandAlan Buscaglia bd6aec8c20 fix(sdk): only report JDBC strings with embedded credentials (#12288)
Co-authored-by: Alan Buscaglia <gentlemanprogramming@gmail.com>
2026-08-04 17:22:31 +02:00
Pedro Martín 3c14df7e5b fix(oci): handle null fields in identity service (#12327) 2026-08-04 17:11:26 +02:00
César Arroba 531f61df2f chore(docs): add the Prowler logo to the Docker Hub overview (#12336) 2026-08-04 17:10:31 +02:00
César Arroba 765a1596f9 chore(docs): sync Docker Hub repository overviews from a single source (#12333) 2026-08-04 16:53:59 +02:00
Rubén De la Torre Vico 138d643119 test(mcp): add test foundation for the MCP server (#12291) 2026-08-04 16:19:10 +02:00
Alejandro Bailo c74eac1369 refactor(ui): type overview action results as ApiResult (#12287) 2026-08-04 13:41:44 +02:00
Alejandro Bailo f9dbb0eee9 fix(ui): harden Lighthouse context compiler against invalid items and budget pressure (#12286) 2026-08-04 13:41:20 +02:00
Alejandro Bailo ab13d111c2 feat(ui): capture Lighthouse context on tenant admin pages (#12285) 2026-08-04 13:40:34 +02:00
Alejandro Bailo f0d2972969 fix(ui): guard Overview Lighthouse context against API error responses (#12284) 2026-08-04 13:37:47 +02:00
Pedro MartínandDaniel Barranquero 90905dcc9f feat(m365): add CIS M365 v7.0.0 exchange checks (#12149)
Co-authored-by: Daniel Barranquero <danielbo2001@gmail.com>
2026-08-04 13:37:28 +02:00
César Arroba 5cf49805a2 fix(ci): make the YAML Trivy suppressions actually apply (#12326) 2026-08-04 13:05:46 +02:00
c610d9ac31 chore(changelog): v5.37.1 forward-sync to master (#12322)
Co-authored-by: prowler-bot <179230569+prowler-bot@users.noreply.github.com>
Co-authored-by: César Arroba <cesar@prowler.com>
2026-08-04 12:12:15 +02:00
fb9d989be8 fix(ui): deep-link wizard docs link to provider authentication guide (#12218)
Co-authored-by: Daniel Barranquero <danielbo2001@gmail.com>
Co-authored-by: alejandrobailo <alejandrobailo94@gmail.com>
2026-08-04 11:57:56 +02:00
César Arroba 64c0cf900f fix(compose): bump DozerDB to 5.26.27.0 (PROWLER-2308) (#12320) 2026-08-04 11:41:23 +02:00
Pedro MartínandDaniel Barranquero 0f39665ece feat(m365): add CIS M365 v7.0.0 defender preset policy checks (#12148)
Co-authored-by: Daniel Barranquero <danielbo2001@gmail.com>
2026-08-04 11:31:50 +02:00
Alan Buscaglia 162c6560d9 fix(ui): stabilize overview responsive layout (#12317) 2026-08-04 11:18:10 +02:00
César Arroba 681be7537d chore(security): migrate suppressions to .trivyignore.yaml (PROWLER-2327) (#12314) 2026-08-04 11:13:16 +02:00
César Arroba 94254555a4 fix(ui): drop apk upgrade and move the base digest forward (PROWLER-2323) (#12313) 2026-08-04 11:07:34 +02:00
Bruno FerreiraCursorcoderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>Utwo
2646068e7e fix(app): correct the worker KEDA PostgreSQL scaler (#12055)
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
Co-authored-by: Utwo <mihai.legat@gmail.com>
2026-08-04 10:47:02 +02:00
Daniel Barranquero caf27de6ee fix(m365): bump microsoft-kiota packages to 1.9.10 so guest-user CA checks parse guestOrExternalUserTypes (#12280) 2026-08-04 10:37:04 +02:00
César Arroba aab8154139 ci(workflows): align container build-push workflows across api, ui, mcp and sdk (#12310) 2026-08-04 10:25:31 +02:00
StylusFrostcoderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>Daniel Barranquero
1de779c978 fix(sdk): resolve entry-point checks on built-in providers (#12294)
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
Co-authored-by: Daniel Barranquero <danielbo2001@gmail.com>
2026-08-04 10:24:55 +02:00
César Arroba f4d6cd8609 fix(deps): restore the SDK dependency to master (PROWLER-2328) (#12309) 2026-08-04 09:59:21 +02:00
Pedro MartínandDaniel Barranquero b3d174d0c1 feat(m365): add CIS M365 v7.0.0 admincenter shared bookings check (#12147)
Co-authored-by: Daniel Barranquero <danielbo2001@gmail.com>
2026-08-04 09:46:47 +02:00
César Arroba 8ebb4a1ee7 fix(container): clear the Private Cloud image vulnerabilities (PROWLER-2291) (#12258) 2026-08-04 09:41:47 +02:00
Maringanti Vasist Acharya abf660ce06 fix(sdk): renumber Huawei Cloud off E2E Networks' range, and correct the M365 header comment (#12296) 2026-08-04 09:36:46 +02:00
Daniel Barranquero a19fd70001 feat(aws): add pathfinding.cloud privilege-escalation coverage (#12237) 2026-08-04 08:59:52 +02:00
Pepe Fagoaga 9b6a239abe fix(docs): v5.37.0 changelog date (#12304) 2026-08-03 18:31:30 +02:00
8c0fbf5073 docs: v5.37.0 changelog highlights (#12264)
Co-authored-by: Rubén De la Torre Vico <ruben@prowler.com>
Co-authored-by: Adrián Jesús Peña Rodríguez <adrianjpr@gmail.com>
Co-authored-by: Pepe Fagoaga <pepe@prowler.com>
2026-08-03 18:24:44 +02:00
Prowler Botandprowler-bot ce77eb7f41 chore(release): Bump versions to v5.38.0 (#12302)
Co-authored-by: prowler-bot <179230569+prowler-bot@users.noreply.github.com>
2026-08-03 18:20:09 +02:00
Copilotcopilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>Pepe Fagoaga
e15f6970ef fix(ci): use PROWLER_BOT_ACCESS_TOKEN for release freeze (#12295)
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: Pepe Fagoaga <pepe@prowler.com>
2026-08-03 14:45:23 +02:00
398 changed files with 25043 additions and 3443 deletions
+1 -1
View File
@@ -158,7 +158,7 @@ SENTRY_RELEASE=local
# REO_DEV_CLIENT_ID=
#### Prowler release version ####
NEXT_PUBLIC_PROWLER_RELEASE_VERSION=v5.37.0
NEXT_PUBLIC_PROWLER_RELEASE_VERSION=v5.38.0
# Social login credentials
SOCIAL_GOOGLE_OAUTH_CALLBACK_URL="${AUTH_URL}/api/auth/callback/google"
+6
View File
@@ -0,0 +1,6 @@
# Generated by gh-aw and marked DO NOT EDIT. It uses concurrency options newer than
# actionlint knows, so the findings are about actionlint's schema, not our workflows.
paths:
.github/workflows/**/*.lock.yml:
ignore:
- '.*'
+179
View File
@@ -0,0 +1,179 @@
name: 'Container Security Scan with Grype'
description: 'Scans container images for vulnerabilities using Grype and reports results'
author: 'Prowler'
inputs:
image-name:
description: 'Container image name to scan'
required: true
image-tag:
description: 'Container image tag to scan'
required: true
default: ${{ github.sha }}
fail-on-severity:
description: 'Fail the build on findings at this severity or above: critical, high, or none'
required: false
default: 'high'
upload-sarif:
description: 'Upload results to GitHub Security tab'
required: false
default: 'true'
create-pr-comment:
description: 'Create a comment on the PR with scan results'
required: false
default: 'true'
artifact-retention-days:
description: 'Days to retain the Grype report artifact'
required: false
default: '2'
outputs:
critical-count:
description: 'Number of critical vulnerabilities found'
value: ${{ steps.security-check.outputs.critical }}
high-count:
description: 'Number of high vulnerabilities found'
value: ${{ steps.security-check.outputs.high }}
total-count:
description: 'Total number of vulnerabilities found'
value: ${{ steps.security-check.outputs.total }}
runs:
using: 'composite'
steps:
- name: Run Grype vulnerability scan (JSON)
uses: anchore/scan-action@e1165082ffb1fe366ebaf02d8526e7c4989ea9d2 # v7.4.0
with:
image: ${{ inputs.image-name }}:${{ inputs.image-tag }}
output-format: 'json'
output-file: 'grype-report.json'
fail-build: 'false'
by-cve: 'true' # Report CVE ids rather than GHSA, so findings line up with Trivy's
only-fixed: 'true' # A finding with no available fix is not actionable, so it must not gate
cache-db: 'true'
grype-version: 'v0.116.1'
- name: Run Grype vulnerability scan (SARIF)
if: inputs.upload-sarif == 'true' && github.event_name == 'push'
uses: anchore/scan-action@e1165082ffb1fe366ebaf02d8526e7c4989ea9d2 # v7.4.0
with:
image: ${{ inputs.image-name }}:${{ inputs.image-tag }}
output-format: 'sarif'
output-file: 'grype-results.sarif'
fail-build: 'false'
severity-cutoff: 'high'
by-cve: 'true'
only-fixed: 'true' # A finding with no available fix is not actionable, so it must not gate
cache-db: 'true'
grype-version: 'v0.116.1'
- name: Upload Grype results to GitHub Security tab
if: inputs.upload-sarif == 'true' && github.event_name == 'push'
uses: github/codeql-action/upload-sarif@3599b3baa15b485a2e49ef411a7a4bb2452e7f93 # v3.30.5
with:
sarif_file: 'grype-results.sarif'
category: 'grype-container'
- name: Upload Grype report artifact
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
if: always()
with:
name: grype-scan-report-${{ inputs.image-name }}-${{ inputs.image-tag }}
path: grype-report.json
retention-days: ${{ inputs.artifact-retention-days }}
- name: Generate security summary
id: security-check
shell: bash
run: |
CRITICAL=$(jq '[.matches[]? | select(.vulnerability.severity=="Critical")] | length' grype-report.json)
HIGH=$(jq '[.matches[]? | select(.vulnerability.severity=="High")] | length' grype-report.json)
TOTAL=$(jq '[.matches[]?] | length' grype-report.json)
echo "critical=$CRITICAL" >> $GITHUB_OUTPUT
echo "high=$HIGH" >> $GITHUB_OUTPUT
echo "total=$TOTAL" >> $GITHUB_OUTPUT
echo "### 🔎 Container Security Scan (Grype)" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "**Image:** \`${INPUTS_IMAGE_NAME}:${INPUTS_IMAGE_TAG}\`" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "- 🔴 Critical: $CRITICAL" >> $GITHUB_STEP_SUMMARY
echo "- 🟠 High: $HIGH" >> $GITHUB_STEP_SUMMARY
echo "- **Total**: $TOTAL" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "Reported alongside Trivy, not instead of it. Counts differ by design." >> $GITHUB_STEP_SUMMARY
env:
INPUTS_IMAGE_NAME: ${{ inputs.image-name }}
INPUTS_IMAGE_TAG: ${{ inputs.image-tag }}
# Before the gate, so the comment is there to explain a failure rather than absent because of it
- name: Comment scan results on PR
if: >-
inputs.create-pr-comment == 'true'
&& github.event_name == 'pull_request'
&& github.event.pull_request.head.repo.full_name == github.repository
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
env:
IMAGE_NAME: ${{ inputs.image-name }}
GITHUB_SHA: ${{ inputs.image-tag }}
CUTOFF: ${{ inputs.fail-on-severity }}
with:
script: |
const comment = require('./.github/scripts/grype-pr-comment.js');
// Unique identifier to find our comment
const marker = `<!-- grype-scan-comment:${process.env.IMAGE_NAME} -->`;
const body = marker + '\n' + comment;
const { data: comments } = await github.rest.issues.listComments({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
});
const existingComment = comments.find(c => c.body?.includes(marker));
if (existingComment) {
await github.rest.issues.updateComment({
owner: context.repo.owner,
repo: context.repo.repo,
comment_id: existingComment.id,
body: body
});
console.log('✅ Updated existing Grype scan comment');
} else {
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
body: body
});
console.log('✅ Created new Grype scan comment');
}
- name: Check for blocking vulnerabilities
if: inputs.fail-on-severity != 'none'
shell: bash
run: |
if [ "$CUTOFF" = "critical" ]; then
BLOCKING=$CRITICAL
SEVERITIES='["Critical"]'
else
BLOCKING=$((CRITICAL + HIGH))
SEVERITIES='["Critical","High"]'
fi
if [ "$BLOCKING" -gt 0 ]; then
echo "::error::Found $BLOCKING vulnerabilities at severity ${CUTOFF} or above ($CRITICAL critical, $HIGH high)"
echo "::warning::Update the package, or add it to .grype.yaml with a reason if nothing can be done"
jq -r --argjson severities "$SEVERITIES" \
'.matches[] | select(.vulnerability.severity | IN($severities[]))
| " \(.vulnerability.severity)\t\(.vulnerability.id)\t\(.artifact.name) \(.artifact.version)"' \
grype-report.json | sort -u
exit 1
fi
env:
CUTOFF: ${{ inputs.fail-on-severity }}
CRITICAL: ${{ steps.security-check.outputs.critical }}
HIGH: ${{ steps.security-check.outputs.high }}
+34 -11
View File
@@ -14,10 +14,10 @@ inputs:
description: 'Severities to scan for (comma-separated)'
required: false
default: 'CRITICAL,HIGH,MEDIUM,LOW'
fail-on-critical:
description: 'Fail the build if critical vulnerabilities are found'
fail-on-severity:
description: 'Fail the build on findings at this severity or above: critical, high, or none'
required: false
default: 'false'
default: 'high'
upload-sarif:
description: 'Upload results to GitHub Security tab'
required: false
@@ -62,8 +62,12 @@ runs:
severity: ${{ inputs.severity }}
exit-code: '0'
scanners: 'vuln'
ignore-unfixed: 'true' # A finding with no available fix is not actionable, so it must not gate
timeout: '5m'
version: 'v0.71.2'
version: 'v0.72.0'
# 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'
@@ -75,8 +79,12 @@ runs:
severity: 'CRITICAL,HIGH'
exit-code: '0'
scanners: 'vuln'
ignore-unfixed: 'true' # A finding with no available fix is not actionable, so it must not gate
timeout: '5m'
version: 'v0.71.2'
version: 'v0.72.0'
# 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'
@@ -163,13 +171,28 @@ runs:
console.log('✅ Created new Trivy scan comment');
}
- name: Check for critical vulnerabilities
if: inputs.fail-on-critical == 'true' && steps.security-check.outputs.critical != '0'
- name: Check for blocking vulnerabilities
if: inputs.fail-on-severity != 'none'
shell: bash
run: |
echo "::error::Found ${STEPS_SECURITY_CHECK_OUTPUTS_CRITICAL} critical vulnerabilities"
echo "::warning::Please update packages or use a different base image"
exit 1
if [ "$CUTOFF" = "critical" ]; then
BLOCKING=$CRITICAL
SEVERITIES='["CRITICAL"]'
else
BLOCKING=$((CRITICAL + HIGH))
SEVERITIES='["CRITICAL","HIGH"]'
fi
if [ "$BLOCKING" -gt 0 ]; then
echo "::error::Found $BLOCKING vulnerabilities at severity ${CUTOFF} or above ($CRITICAL critical, $HIGH high)"
echo "::warning::Update the package, or add it to .trivyignore.yaml with a reason if nothing can be done"
jq -r --argjson severities "$SEVERITIES" \
'.Results[]?.Vulnerabilities[]? | select(.Severity | IN($severities[]))
| " \(.Severity)\t\(.VulnerabilityID)\t\(.PkgName) \(.InstalledVersion)"' \
trivy-report.json | sort -u
exit 1
fi
env:
STEPS_SECURITY_CHECK_OUTPUTS_CRITICAL: ${{ steps.security-check.outputs.critical }}
CUTOFF: ${{ inputs.fail-on-severity }}
CRITICAL: ${{ steps.security-check.outputs.critical }}
HIGH: ${{ steps.security-check.outputs.high }}
+100
View File
@@ -0,0 +1,100 @@
const fs = require('fs');
// Configuration from environment variables
const REPORT_FILE = process.env.GRYPE_REPORT_FILE || 'grype-report.json';
const IMAGE_NAME = process.env.IMAGE_NAME || 'container-image';
const GITHUB_SHA = process.env.GITHUB_SHA || 'unknown';
const GITHUB_REPOSITORY = process.env.GITHUB_REPOSITORY || '';
const GITHUB_RUN_ID = process.env.GITHUB_RUN_ID || '';
const CUTOFF = process.env.CUTOFF || 'high';
// A cutoff of 'critical' blocks only on critical; anything else blocks on high and above
const blocking = CUTOFF === 'critical' ? ['Critical'] : ['Critical', 'High'];
const report = JSON.parse(fs.readFileSync(REPORT_FILE, 'utf-8'));
const matches = Array.isArray(report.matches) ? report.matches : [];
const ignored = Array.isArray(report.ignoredMatches) ? report.ignoredMatches : [];
const counts = { Critical: 0, High: 0, Medium: 0, Low: 0, Negligible: 0, Unknown: 0 };
const blockers = new Map();
for (const match of matches) {
const severity = match.vulnerability.severity;
if (counts[severity] !== undefined) {
counts[severity]++;
}
if (blocking.includes(severity)) {
const artifact = match.artifact;
const fixedIn = (match.vulnerability.fix && match.vulnerability.fix.versions || []).join(', ');
// Same CVE can match several install paths of one package; collapse them
blockers.set(`${match.vulnerability.id}|${artifact.name}`, {
id: match.vulnerability.id,
severity,
name: artifact.name,
version: artifact.version,
fixedIn
});
}
}
const ignoredBlocking = ignored.filter(m => blocking.includes(m.vulnerability.severity)).length;
const shortSha = GITHUB_SHA.substring(0, 7);
const timestamp = new Date().toISOString().replace('T', ' ').substring(0, 19) + ' UTC';
const severityConfig = {
Critical: { icon: '🔴', label: 'Critical' },
High: { icon: '🟠', label: 'High' },
Medium: { icon: '🟡', label: 'Medium' },
Low: { icon: '🔵', label: 'Low' }
};
let comment = '## 🔎 Container Security Scan (Grype)\n\n';
comment += `**Image:** \`${IMAGE_NAME}:${shortSha}\`\n`;
comment += `**Last scan:** ${timestamp}\n\n`;
if (blockers.size === 0) {
comment += '### ✅ Nothing Blocking\n\n';
comment += `No findings at **${blocking.join(' or ').toLowerCase()}** severity.\n`;
} else {
comment += `### ⚠️ ${blockers.size} Finding(s) Blocking This PR\n\n`;
comment += '| Severity | CVE | Package | Installed | Fixed in |\n';
comment += '|---|---|---|---|---|\n';
const order = { Critical: 0, High: 1 };
const rows = [...blockers.values()].sort((a, b) =>
(order[a.severity] - order[b.severity]) || a.name.localeCompare(b.name));
for (const row of rows) {
const config = severityConfig[row.severity];
comment += `| ${config.icon} ${config.label} | \`${row.id}\` | \`${row.name}\` | ${row.version} | ${row.fixedIn || '—'} |\n`;
}
comment += '\n**What to do:**\n';
comment += '- Upgrade the package to the version in the "Fixed in" column.\n';
comment += '- If it is pinned by another dependency, or the fix is otherwise out of reach, add it to `.grype.yaml` **with the reason**.\n';
comment += '- Findings with no published fix never appear here: the scan runs with `only-fixed`, so it reports only what can actually be acted on.\n';
}
const otherCounts = Object.entries(counts)
.filter(([severity, count]) => !blocking.includes(severity) && count > 0)
.map(([severity, count]) => `${severity.toLowerCase()}: ${count}`);
if (otherCounts.length > 0) {
comment += `\nNot blocking at this cutoff — ${otherCounts.join(', ')}.\n`;
}
if (ignoredBlocking > 0) {
comment += `\n${ignoredBlocking} finding(s) excluded by \`.grype.yaml\`, each with a documented reason.\n`;
}
comment += '\n---\n';
comment += '📋 **Resources:**\n';
if (GITHUB_REPOSITORY && GITHUB_RUN_ID) {
comment += `- [Download full report](https://github.com/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}) (see artifacts)\n`;
}
comment += '- [View in Security tab](https://github.com/' + (GITHUB_REPOSITORY || 'repository') + '/security/code-scanning)\n';
comment += '- Scanned with [Grype](https://github.com/anchore/grype), alongside Trivy\n';
module.exports = comment;
+26 -12
View File
@@ -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')
@@ -159,9 +162,20 @@ jobs:
with:
context: ${{ env.WORKING_DIRECTORY }}
push: true
sbom: true
# max, not the default min: min records little beyond the build ref.
provenance: mode=max
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 +193,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:
@@ -196,9 +210,9 @@ jobs:
run: |
docker buildx imagetools create \
-t ${{ env.PROWLERCLOUD_DOCKERHUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:${{ env.LATEST_TAG }} \
-t ${{ env.PROWLERCLOUD_DOCKERHUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:${NEEDS_SETUP_OUTPUTS_SHORT_SHA} \
${{ env.PROWLERCLOUD_DOCKERHUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:${NEEDS_SETUP_OUTPUTS_SHORT_SHA}-amd64 \
${{ env.PROWLERCLOUD_DOCKERHUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:${NEEDS_SETUP_OUTPUTS_SHORT_SHA}-arm64
-t "${{ env.PROWLERCLOUD_DOCKERHUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:${NEEDS_SETUP_OUTPUTS_SHORT_SHA}" \
"${{ env.PROWLERCLOUD_DOCKERHUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:${NEEDS_SETUP_OUTPUTS_SHORT_SHA}-amd64" \
"${{ env.PROWLERCLOUD_DOCKERHUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:${NEEDS_SETUP_OUTPUTS_SHORT_SHA}-arm64"
env:
NEEDS_SETUP_OUTPUTS_SHORT_SHA: ${{ needs.setup.outputs.short-sha }}
@@ -206,10 +220,10 @@ jobs:
if: github.event_name == 'release' || github.event_name == 'workflow_dispatch'
run: |
docker buildx imagetools create \
-t ${{ env.PROWLERCLOUD_DOCKERHUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:${RELEASE_TAG} \
-t "${{ env.PROWLERCLOUD_DOCKERHUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:${RELEASE_TAG}" \
-t ${{ env.PROWLERCLOUD_DOCKERHUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:${{ env.STABLE_TAG }} \
${{ env.PROWLERCLOUD_DOCKERHUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:${NEEDS_SETUP_OUTPUTS_SHORT_SHA}-amd64 \
${{ env.PROWLERCLOUD_DOCKERHUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:${NEEDS_SETUP_OUTPUTS_SHORT_SHA}-arm64
"${{ env.PROWLERCLOUD_DOCKERHUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:${NEEDS_SETUP_OUTPUTS_SHORT_SHA}-amd64" \
"${{ env.PROWLERCLOUD_DOCKERHUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:${NEEDS_SETUP_OUTPUTS_SHORT_SHA}-arm64"
env:
NEEDS_SETUP_OUTPUTS_SHORT_SHA: ${{ needs.setup.outputs.short-sha }}
@@ -249,9 +263,9 @@ jobs:
id: outcome
run: |
if [[ "${NEEDS_CONTAINER_BUILD_PUSH_RESULT}" == "success" && "${NEEDS_CREATE_MANIFEST_RESULT}" == "success" ]]; then
echo "outcome=success" >> $GITHUB_OUTPUT
echo "outcome=success" >> "$GITHUB_OUTPUT"
else
echo "outcome=failure" >> $GITHUB_OUTPUT
echo "outcome=failure" >> "$GITHUB_OUTPUT"
fi
env:
NEEDS_CONTAINER_BUILD_PUSH_RESULT: ${{ needs.container-build-push.result }}
+17 -2
View File
@@ -81,6 +81,10 @@ jobs:
auth.docker.io:443
production.cloudflare.docker.com:443
production.cloudfront.docker.com:443
raw.githubusercontent.com:443
objects.githubusercontent.com:443
grype.anchore.io:443
get.anchore.io:443
debian.map.fastlydns.net:80
release-assets.githubusercontent.com:443
objects.githubusercontent.com:443
@@ -108,6 +112,9 @@ jobs:
files: |
api/**
.github/actions/trivy-scan/**
.github/actions/grype-scan/**
.grype.yaml
.github/scripts/grype-pr-comment.js
files_ignore: |
api/docs/**
api/README.md
@@ -145,5 +152,13 @@ jobs:
with:
image-name: ${{ env.IMAGE_NAME }}
image-tag: ${{ github.sha }}
fail-on-critical: 'true'
severity: 'CRITICAL'
fail-on-severity: 'high'
severity: 'CRITICAL,HIGH'
- name: Scan container with Grype
if: steps.check-changes.outputs.any_changed == 'true'
uses: ./.github/actions/grype-scan
with:
image-name: ${{ env.IMAGE_NAME }}
image-tag: ${{ github.sha }}
fail-on-severity: 'high'
+63
View File
@@ -0,0 +1,63 @@
name: 'CI: Actionlint'
on:
push:
branches:
- 'master'
pull_request:
branches:
- 'master'
schedule:
- cron: '45 06 * * *'
workflow_dispatch:
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
permissions: {}
jobs:
actionlint:
if: github.repository == 'prowler-cloud/prowler'
name: GitHub Actions Schema Check
runs-on: ubuntu-latest
timeout-minutes: 10
permissions:
contents: read
steps:
- name: Harden Runner
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
with:
egress-policy: block
allowed-endpoints: >
github.com:443
api.github.com:443
auth.docker.io:443
registry-1.docker.io:443
production.cloudflare.docker.com:443
production.cloudfront.docker.com:443
- name: Checkout repository
uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0
with:
# zizmor: ignore[artipacked]
persist-credentials: true # Required by tj-actions/changed-files to fetch PR branch
# Always runs so it always reports; the lint is skipped when nothing changed.
- name: Check for workflow changes
id: check-changes
uses: tj-actions/changed-files@9426d40962ed5378910ee2e21d5f8c6fcbf2dd96 # v47.0.6
with:
files: .github/**
# SC2129 is style only: it suggests grouping consecutive redirects.
- name: Run actionlint
if: steps.check-changes.outputs.any_changed == 'true'
env:
SHELLCHECK_OPTS: '-e SC2129'
run: |
docker run --rm -v "$PWD:/repo" --workdir /repo -e SHELLCHECK_OPTS \
rhysd/actionlint:1.7.12@sha256:b1934ee5f1c509618f2508e6eb47ee0d3520686341fec936f3b79331f9315667 \
-color
+2 -2
View File
@@ -32,7 +32,7 @@ jobs:
ISSUE_NUMBER: ${{ github.event.issue.number }}
run: |
echo "Removing 'status/awaiting-response' label from #$ISSUE_NUMBER"
gh api /repos/${{ github.repository }}/issues/$ISSUE_NUMBER/labels/status%2Fawaiting-response \
gh api "/repos/${{ github.repository }}/issues/$ISSUE_NUMBER/labels/status%2Fawaiting-response" \
-X DELETE
- name: Add 'status/waiting-for-revision' label
@@ -41,6 +41,6 @@ jobs:
ISSUE_NUMBER: ${{ github.event.issue.number }}
run: |
echo "Adding 'status/waiting-for-revision' label to #$ISSUE_NUMBER"
gh api /repos/${{ github.repository }}/issues/$ISSUE_NUMBER/labels \
gh api "/repos/${{ github.repository }}/issues/$ISSUE_NUMBER/labels" \
-X POST \
-f labels[]='status/waiting-for-revision'
@@ -0,0 +1,92 @@
name: 'Tools: Sync Docker Hub Descriptions'
on:
push:
branches:
- 'master'
paths:
- 'docs/dockerhub/README.md'
- '.github/workflows/dockerhub-descriptions.yml'
workflow_dispatch:
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: false
env:
OVERVIEW_FILE: docs/dockerhub/README.md
permissions: {}
jobs:
prowlercloud:
if: github.repository == 'prowler-cloud/prowler' && github.ref == 'refs/heads/master'
runs-on: ubuntu-latest
timeout-minutes: 10
permissions:
contents: read
strategy:
fail-fast: false
matrix:
include:
- repository: prowlercloud/prowler
short_description: 'Prowler CLI: the Open Cloud Security tool for AWS, Azure, Google Cloud, Kubernetes, M365 and GitHub'
- repository: prowlercloud/prowler-api
short_description: 'Prowler Local Server - API: the JSON API and Task Runner components of Prowler'
- repository: prowlercloud/prowler-ui
short_description: 'Prowler Local Server - UI: the web interface to run Prowler scans and explore findings'
- repository: prowlercloud/prowler-mcp
short_description: 'Prowler MCP: the interface for agents, including IDE plugins and agent integrations'
steps:
- name: Harden Runner
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
with:
egress-policy: block
allowed-endpoints: >
github.com:443
hub.docker.com:443
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Update Docker Hub description for ${{ matrix.repository }}
uses: peter-evans/dockerhub-description@1b9a80c056b620d92cedb9d9b5a223409c68ddfa # v5.0.0
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
repository: ${{ matrix.repository }}
short-description: ${{ matrix.short_description }}
readme-filepath: ${{ env.OVERVIEW_FILE }}
toniblyx:
if: github.repository == 'prowler-cloud/prowler' && github.ref == 'refs/heads/master'
runs-on: ubuntu-latest
timeout-minutes: 10
permissions:
contents: read
steps:
- name: Harden Runner
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
with:
egress-policy: block
allowed-endpoints: >
github.com:443
hub.docker.com:443
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Update Docker Hub description for toniblyx/prowler
uses: peter-evans/dockerhub-description@1b9a80c056b620d92cedb9d9b5a223409c68ddfa # v5.0.0
with:
username: ${{ secrets.TONIBLYX_DOCKERHUB_USERNAME }}
password: ${{ secrets.TONIBLYX_DOCKERHUB_PASSWORD }}
repository: toniblyx/prowler
short-description: 'Prowler CLI (legacy repository, mirrors prowlercloud/prowler)'
readme-filepath: ${{ env.OVERVIEW_FILE }}
+1 -1
View File
@@ -50,7 +50,7 @@ jobs:
GITHUB_EVENT_RELEASE_TAG_NAME: ${{ github.event.release.tag_name }}
- name: Login to GHCR
run: echo "${{ secrets.GITHUB_TOKEN }}" | helm registry login ghcr.io -u ${GITHUB_ACTOR} --password-stdin
run: echo "${{ secrets.GITHUB_TOKEN }}" | helm registry login ghcr.io -u "${GITHUB_ACTOR}" --password-stdin
- name: Update chart dependencies
run: helm dependency update ${{ env.CHART_PATH }}
+2 -2
View File
@@ -85,10 +85,10 @@ jobs:
# Check if author is in the org members list
if printf '%s\n' "${ORG_MEMBERS[@]}" | grep -q "^${AUTHOR}$"; then
echo "is_member=true" >> $GITHUB_OUTPUT
echo "is_member=true" >> "$GITHUB_OUTPUT"
echo "$AUTHOR is an organization member"
else
echo "is_member=false" >> $GITHUB_OUTPUT
echo "is_member=false" >> "$GITHUB_OUTPUT"
echo "$AUTHOR is not an organization member"
fi
+25 -19
View File
@@ -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
@@ -141,17 +144,20 @@ jobs:
with:
context: ${{ env.WORKING_DIRECTORY }}
push: true
sbom: true
# max, not the default min: min records little beyond the build ref.
provenance: mode=max
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 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 +175,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
@@ -187,9 +193,9 @@ jobs:
run: |
docker buildx imagetools create \
-t ${{ env.PROWLERCLOUD_DOCKERHUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:${{ env.LATEST_TAG }} \
-t ${{ env.PROWLERCLOUD_DOCKERHUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:${NEEDS_SETUP_OUTPUTS_SHORT_SHA} \
${{ env.PROWLERCLOUD_DOCKERHUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:${NEEDS_SETUP_OUTPUTS_SHORT_SHA}-amd64 \
${{ env.PROWLERCLOUD_DOCKERHUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:${NEEDS_SETUP_OUTPUTS_SHORT_SHA}-arm64
-t "${{ env.PROWLERCLOUD_DOCKERHUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:${NEEDS_SETUP_OUTPUTS_SHORT_SHA}" \
"${{ env.PROWLERCLOUD_DOCKERHUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:${NEEDS_SETUP_OUTPUTS_SHORT_SHA}-amd64" \
"${{ env.PROWLERCLOUD_DOCKERHUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:${NEEDS_SETUP_OUTPUTS_SHORT_SHA}-arm64"
env:
NEEDS_SETUP_OUTPUTS_SHORT_SHA: ${{ needs.setup.outputs.short-sha }}
@@ -197,10 +203,10 @@ jobs:
if: github.event_name == 'release' || github.event_name == 'workflow_dispatch'
run: |
docker buildx imagetools create \
-t ${{ env.PROWLERCLOUD_DOCKERHUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:${RELEASE_TAG} \
-t "${{ env.PROWLERCLOUD_DOCKERHUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:${RELEASE_TAG}" \
-t ${{ env.PROWLERCLOUD_DOCKERHUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:${{ env.STABLE_TAG }} \
${{ env.PROWLERCLOUD_DOCKERHUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:${NEEDS_SETUP_OUTPUTS_SHORT_SHA}-amd64 \
${{ env.PROWLERCLOUD_DOCKERHUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:${NEEDS_SETUP_OUTPUTS_SHORT_SHA}-arm64
"${{ env.PROWLERCLOUD_DOCKERHUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:${NEEDS_SETUP_OUTPUTS_SHORT_SHA}-amd64" \
"${{ env.PROWLERCLOUD_DOCKERHUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:${NEEDS_SETUP_OUTPUTS_SHORT_SHA}-arm64"
env:
NEEDS_SETUP_OUTPUTS_SHORT_SHA: ${{ needs.setup.outputs.short-sha }}
@@ -240,9 +246,9 @@ jobs:
id: outcome
run: |
if [[ "${NEEDS_CONTAINER_BUILD_PUSH_RESULT}" == "success" && "${NEEDS_CREATE_MANIFEST_RESULT}" == "success" ]]; then
echo "outcome=success" >> $GITHUB_OUTPUT
echo "outcome=success" >> "$GITHUB_OUTPUT"
else
echo "outcome=failure" >> $GITHUB_OUTPUT
echo "outcome=failure" >> "$GITHUB_OUTPUT"
fi
env:
NEEDS_CONTAINER_BUILD_PUSH_RESULT: ${{ needs.container-build-push.result }}
+16 -2
View File
@@ -87,6 +87,9 @@ jobs:
get.trivy.dev:443
release-assets.githubusercontent.com:443
objects.githubusercontent.com:443
raw.githubusercontent.com:443
grype.anchore.io:443
get.anchore.io:443
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
@@ -101,6 +104,9 @@ jobs:
files: |
mcp_server/**
.github/actions/trivy-scan/**
.github/actions/grype-scan/**
.grype.yaml
.github/scripts/grype-pr-comment.js
files_ignore: |
mcp_server/README.md
mcp_server/CHANGELOG.md
@@ -127,5 +133,13 @@ jobs:
with:
image-name: ${{ env.IMAGE_NAME }}
image-tag: ${{ github.sha }}
fail-on-critical: 'true'
severity: 'CRITICAL'
fail-on-severity: 'high'
severity: 'CRITICAL,HIGH'
- name: Scan MCP container with Grype
if: steps.check-changes.outputs.any_changed == 'true'
uses: ./.github/actions/grype-scan
with:
image-name: ${{ env.IMAGE_NAME }}
image-tag: ${{ github.sha }}
fail-on-severity: 'high'
+99
View File
@@ -0,0 +1,99 @@
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
- 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
-1
View File
@@ -129,7 +129,6 @@ jobs:
handwritten_changelogs=""
all_changed=$(echo "${STEPS_CHANGED_FILES_OUTPUTS_ALL_CHANGED_FILES}" | tr ' ' '\n')
added=$(echo "${STEPS_CHANGED_FILES_OUTPUTS_ADDED_FILES}" | tr ' ' '\n')
added_or_renamed=$(printf '%s\n%s' "${STEPS_CHANGED_FILES_OUTPUTS_ADDED_FILES}" "${STEPS_CHANGED_FILES_OUTPUTS_RENAMED_FILES}" | tr ' ' '\n')
added_modified_or_renamed=$(printf '%s\n%s\n%s' "${STEPS_CHANGED_FILES_OUTPUTS_ADDED_FILES}" "${STEPS_CHANGED_FILES_OUTPUTS_MODIFIED_FILES}" "${STEPS_CHANGED_FILES_OUTPUTS_RENAMED_FILES}" | tr ' ' '\n')
@@ -111,7 +111,7 @@ jobs:
done
if [ -n "$found_in" ]; then
found_in=$(echo "$found_in" | sed 's/, $//')
found_in="${found_in%, }"
MAPPED="${MAPPED}- \`${check_id}\` (\`${provider}\`): ${found_in}"$'\n'
else
UNMAPPED="${UNMAPPED}- \`${check_id}\` (\`${provider}\`)"$'\n'
+3 -3
View File
@@ -74,15 +74,15 @@ jobs:
done <<< "$STEPS_CHANGED_FILES_OUTPUTS_ALL_CHANGED_FILES"
if [ "$HAS_CONFLICTS" = true ]; then
echo "has_conflicts=true" >> $GITHUB_OUTPUT
echo "has_conflicts=true" >> "$GITHUB_OUTPUT"
{
echo "conflict_files<<EOF"
echo "$CONFLICT_FILES"
echo "EOF"
} >> $GITHUB_OUTPUT
} >> "$GITHUB_OUTPUT"
echo "Conflict markers detected"
else
echo "has_conflicts=false" >> $GITHUB_OUTPUT
echo "has_conflicts=false" >> "$GITHUB_OUTPUT"
echo "No conflict markers found in changed files"
fi
env:
+5 -4
View File
@@ -36,7 +36,7 @@ jobs:
id: vars
run: |
SHORT_SHA="${GITHUB_EVENT_PULL_REQUEST_MERGE_COMMIT_SHA}"
echo "short_sha=${SHORT_SHA::7}" >> $GITHUB_OUTPUT
echo "short_sha=${SHORT_SHA::7}" >> "$GITHUB_OUTPUT"
env:
GITHUB_EVENT_PULL_REQUEST_MERGE_COMMIT_SHA: ${{ github.event.pull_request.merge_commit_sha }}
@@ -46,6 +46,7 @@ jobs:
token: ${{ secrets.PROWLER_BOT_ACCESS_TOKEN }}
repository: ${{ secrets.CLOUD_DISPATCH }}
event-type: prowler-pull-request-merged
# repository_dispatch caps client_payload at 10 properties; this is exactly at the cap.
client-payload: |
{
"PROWLER_COMMIT_SHA": "${{ github.event.pull_request.merge_commit_sha }}",
@@ -54,8 +55,8 @@ jobs:
"PROWLER_PR_TITLE": ${{ toJson(github.event.pull_request.title) }},
"PROWLER_PR_LABELS": ${{ toJson(github.event.pull_request.labels.*.name) }},
"PROWLER_PR_BODY": ${{ toJson(github.event.pull_request.body) }},
"PROWLER_PR_URL": ${{ toJson(github.event.pull_request.html_url) }},
"PROWLER_PR_MERGED_BY": "${{ github.event.pull_request.merged_by.login }}",
"PROWLER_PR_BASE_BRANCH": ${{ toJson(github.event.pull_request.base.ref) }},
"PROWLER_PR_HEAD_BRANCH": ${{ toJson(github.event.pull_request.head.ref) }}
"PROWLER_PR_STACK_NUMBER": "${{ github.event.pull_request.stack.number }}",
"PROWLER_PR_STACK_POSITION": "${{ github.event.pull_request.stack.position }}",
"PROWLER_PR_STACK_SIZE": "${{ github.event.pull_request.stack.size }}"
}
+14 -12
View File
@@ -36,7 +36,7 @@ jobs:
- name: Enable release freeze
env:
GH_TOKEN: ${{ github.token }}
GH_TOKEN: ${{ secrets.PROWLER_BOT_ACCESS_TOKEN }}
run: |
gh variable set RELEASE_FREEZE --body true --repo "${GITHUB_REPOSITORY}"
@@ -77,7 +77,7 @@ jobs:
echo "Prowler version: $PROWLER_VERSION"
echo "Branch name: $BRANCH_NAME"
echo "Is minor release: $([ $PATCH_VERSION -eq 0 ] && echo 'true' || echo 'false')"
echo "Is minor release: $([ "$PATCH_VERSION" -eq 0 ] && echo 'true' || echo 'false')"
else
echo "Invalid version syntax: '$PROWLER_VERSION' (must be N.N.N)" >&2
exit 1
@@ -107,7 +107,8 @@ jobs:
if [ -f "$changelog_file" ]; then
# Extract version that matches this Prowler release
# Format: ## [version] (Prowler X.Y.Z) or ## [vversion] (Prowler vX.Y.Z)
local version=$(grep '^## \[' "$changelog_file" | grep "(Prowler v\?${prowler_version})" | head -1 | sed 's/^## \[\(.*\)\].*/\1/' | sed 's/^v//' | tr -d '[:space:]')
local version
version=$(grep '^## \[' "$changelog_file" | grep "(Prowler v\?${prowler_version})" | head -1 | sed 's/^## \[\(.*\)\].*/\1/' | sed 's/^v//' | tr -d '[:space:]')
echo "$version"
else
echo ""
@@ -178,55 +179,55 @@ jobs:
# Determine if components have changes for this specific release
if [ -n "$SDK_VERSION" ]; then
echo "HAS_SDK_CHANGES=true" >> $GITHUB_ENV
echo "HAS_SDK_CHANGES=true" >> "$GITHUB_ENV"
HAS_SDK_CHANGES="true"
echo "✓ SDK changes detected - version: $SDK_VERSION"
extract_changelog "prowler/CHANGELOG.md" "$SDK_VERSION" "prowler_changelog.md"
else
echo "HAS_SDK_CHANGES=false" >> $GITHUB_ENV
echo "HAS_SDK_CHANGES=false" >> "$GITHUB_ENV"
HAS_SDK_CHANGES="false"
echo " No SDK changes for this release"
touch "prowler_changelog.md"
fi
if [ -n "$API_VERSION" ]; then
echo "HAS_API_CHANGES=true" >> $GITHUB_ENV
echo "HAS_API_CHANGES=true" >> "$GITHUB_ENV"
HAS_API_CHANGES="true"
echo "✓ API changes detected - version: $API_VERSION"
extract_changelog "api/CHANGELOG.md" "$API_VERSION" "api_changelog.md"
else
echo "HAS_API_CHANGES=false" >> $GITHUB_ENV
echo "HAS_API_CHANGES=false" >> "$GITHUB_ENV"
HAS_API_CHANGES="false"
echo " No API changes for this release"
touch "api_changelog.md"
fi
if [ -n "$UI_VERSION" ]; then
echo "HAS_UI_CHANGES=true" >> $GITHUB_ENV
echo "HAS_UI_CHANGES=true" >> "$GITHUB_ENV"
HAS_UI_CHANGES="true"
echo "✓ UI changes detected - version: $UI_VERSION"
extract_changelog "ui/CHANGELOG.md" "$UI_VERSION" "ui_changelog.md"
else
echo "HAS_UI_CHANGES=false" >> $GITHUB_ENV
echo "HAS_UI_CHANGES=false" >> "$GITHUB_ENV"
HAS_UI_CHANGES="false"
echo " No UI changes for this release"
touch "ui_changelog.md"
fi
if [ -n "$MCP_VERSION" ]; then
echo "HAS_MCP_CHANGES=true" >> $GITHUB_ENV
echo "HAS_MCP_CHANGES=true" >> "$GITHUB_ENV"
HAS_MCP_CHANGES="true"
echo "✓ MCP changes detected - version: $MCP_VERSION"
extract_changelog "mcp_server/CHANGELOG.md" "$MCP_VERSION" "mcp_changelog.md"
else
echo "HAS_MCP_CHANGES=false" >> $GITHUB_ENV
echo "HAS_MCP_CHANGES=false" >> "$GITHUB_ENV"
HAS_MCP_CHANGES="false"
echo " No MCP changes for this release"
touch "mcp_changelog.md"
fi
# Combine changelogs in order: UI, API, SDK, MCP
> combined_changelog.md
: > combined_changelog.md
if [ "$HAS_UI_CHANGES" = "true" ] && [ -s "ui_changelog.md" ]; then
echo "## UI" >> combined_changelog.md
@@ -389,3 +390,4 @@ jobs:
if: always()
run: |
rm -f prowler_changelog.md api_changelog.md ui_changelog.md mcp_changelog.md combined_changelog.md
+44 -31
View File
@@ -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
@@ -198,9 +200,20 @@ jobs:
context: .
file: ${{ env.DOCKERFILE_PATH }}
push: true
sbom: true
# max, not the default min: min records little beyond the build ref.
provenance: mode=max
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 +232,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
@@ -252,10 +265,10 @@ jobs:
if: github.event_name == 'push'
run: |
docker buildx imagetools create \
-t ${{ env.PROWLERCLOUD_DOCKERHUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:${NEEDS_SETUP_OUTPUTS_LATEST_TAG} \
-t ${{ secrets.PUBLIC_ECR_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:${NEEDS_SETUP_OUTPUTS_LATEST_TAG} \
${{ env.PROWLERCLOUD_DOCKERHUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:${NEEDS_SETUP_OUTPUTS_LATEST_TAG}-amd64 \
${{ env.PROWLERCLOUD_DOCKERHUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:${NEEDS_SETUP_OUTPUTS_LATEST_TAG}-arm64
-t "${{ env.PROWLERCLOUD_DOCKERHUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:${NEEDS_SETUP_OUTPUTS_LATEST_TAG}" \
-t "${{ secrets.PUBLIC_ECR_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:${NEEDS_SETUP_OUTPUTS_LATEST_TAG}" \
"${{ env.PROWLERCLOUD_DOCKERHUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:${NEEDS_SETUP_OUTPUTS_LATEST_TAG}-amd64" \
"${{ env.PROWLERCLOUD_DOCKERHUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:${NEEDS_SETUP_OUTPUTS_LATEST_TAG}-arm64"
env:
NEEDS_SETUP_OUTPUTS_LATEST_TAG: ${{ needs.setup.outputs.latest_tag }}
@@ -263,12 +276,12 @@ jobs:
if: github.event_name == 'release' || github.event_name == 'workflow_dispatch'
run: |
docker buildx imagetools create \
-t ${{ env.PROWLERCLOUD_DOCKERHUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:${NEEDS_SETUP_OUTPUTS_PROWLER_VERSION} \
-t ${{ env.PROWLERCLOUD_DOCKERHUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:${NEEDS_SETUP_OUTPUTS_STABLE_TAG} \
-t ${{ secrets.PUBLIC_ECR_REPOSITORY }}/${{ env.IMAGE_NAME }}:${NEEDS_SETUP_OUTPUTS_PROWLER_VERSION} \
-t ${{ secrets.PUBLIC_ECR_REPOSITORY }}/${{ env.IMAGE_NAME }}:${NEEDS_SETUP_OUTPUTS_STABLE_TAG} \
${{ env.PROWLERCLOUD_DOCKERHUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:${NEEDS_SETUP_OUTPUTS_LATEST_TAG}-amd64 \
${{ env.PROWLERCLOUD_DOCKERHUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:${NEEDS_SETUP_OUTPUTS_LATEST_TAG}-arm64
-t "${{ env.PROWLERCLOUD_DOCKERHUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:${NEEDS_SETUP_OUTPUTS_PROWLER_VERSION}" \
-t "${{ env.PROWLERCLOUD_DOCKERHUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:${NEEDS_SETUP_OUTPUTS_STABLE_TAG}" \
-t "${{ secrets.PUBLIC_ECR_REPOSITORY }}/${{ env.IMAGE_NAME }}:${NEEDS_SETUP_OUTPUTS_PROWLER_VERSION}" \
-t "${{ secrets.PUBLIC_ECR_REPOSITORY }}/${{ env.IMAGE_NAME }}:${NEEDS_SETUP_OUTPUTS_STABLE_TAG}" \
"${{ env.PROWLERCLOUD_DOCKERHUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:${NEEDS_SETUP_OUTPUTS_LATEST_TAG}-amd64" \
"${{ env.PROWLERCLOUD_DOCKERHUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:${NEEDS_SETUP_OUTPUTS_LATEST_TAG}-arm64"
env:
NEEDS_SETUP_OUTPUTS_PROWLER_VERSION: ${{ needs.setup.outputs.prowler_version }}
NEEDS_SETUP_OUTPUTS_STABLE_TAG: ${{ needs.setup.outputs.stable_tag }}
@@ -293,7 +306,7 @@ jobs:
if: needs.setup.outputs.latest_tag == 'latest' && (github.event_name == 'release' || github.event_name == 'workflow_dispatch')
run: |
docker buildx imagetools create \
-t ${{ env.TONIBLYX_DOCKERHUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:${NEEDS_SETUP_OUTPUTS_PROWLER_VERSION} \
-t "${{ env.TONIBLYX_DOCKERHUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:${NEEDS_SETUP_OUTPUTS_PROWLER_VERSION}" \
-t ${{ env.TONIBLYX_DOCKERHUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:stable \
${{ env.PROWLERCLOUD_DOCKERHUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:stable
env:
@@ -343,9 +356,9 @@ jobs:
id: outcome
run: |
if [[ "${NEEDS_CONTAINER_BUILD_PUSH_RESULT}" == "success" && "${NEEDS_CREATE_MANIFEST_RESULT}" == "success" ]]; then
echo "outcome=success" >> $GITHUB_OUTPUT
echo "outcome=success" >> "$GITHUB_OUTPUT"
else
echo "outcome=failure" >> $GITHUB_OUTPUT
echo "outcome=failure" >> "$GITHUB_OUTPUT"
fi
env:
NEEDS_CONTAINER_BUILD_PUSH_RESULT: ${{ needs.container-build-push.result }}
+17 -2
View File
@@ -83,6 +83,10 @@ jobs:
api.github.com:443
mirror.gcr.io:443
check.trivy.dev:443
raw.githubusercontent.com:443
objects.githubusercontent.com:443
grype.anchore.io:443
get.anchore.io:443
debian.map.fastlydns.net:80
release-assets.githubusercontent.com:443
objects.githubusercontent.com:443
@@ -114,6 +118,9 @@ jobs:
uv.lock
.github/workflows/sdk-container-checks.yml
.github/actions/trivy-scan/**
.github/actions/grype-scan/**
.grype.yaml
.github/scripts/grype-pr-comment.js
files_ignore: |
prowler/CHANGELOG.md
prowler/changelog.d/**
@@ -140,5 +147,13 @@ jobs:
with:
image-name: ${{ env.IMAGE_NAME }}
image-tag: ${{ github.sha }}
fail-on-critical: 'true'
severity: 'CRITICAL'
fail-on-severity: 'high'
severity: 'CRITICAL,HIGH'
- name: Scan SDK container with Grype
if: steps.check-changes.outputs.any_changed == 'true'
uses: ./.github/actions/grype-scan
with:
image-name: ${{ env.IMAGE_NAME }}
image-tag: ${{ github.sha }}
fail-on-severity: 'high'
+2 -1
View File
@@ -216,7 +216,8 @@ jobs:
elif [ -z "${STEPS_AWS_SERVICES_OUTPUTS_SERVICE_PATHS}" ]; then
echo "No AWS service paths detected; skipping AWS tests."
else
uv run pytest -n auto --cov=./prowler/providers/aws --cov-report=xml:aws_coverage.xml ${STEPS_AWS_SERVICES_OUTPUTS_SERVICE_PATHS}
read -ra service_paths <<< "${STEPS_AWS_SERVICES_OUTPUTS_SERVICE_PATHS}"
uv run pytest -n auto --cov=./prowler/providers/aws --cov-report=xml:aws_coverage.xml "${service_paths[@]}"
fi
env:
STEPS_AWS_SERVICES_OUTPUTS_RUN_ALL: ${{ steps.aws-services.outputs.run_all }}
+20 -19
View File
@@ -84,7 +84,8 @@ jobs:
echo "Changed files:"
echo "${STEPS_CHANGED_FILES_OUTPUTS_ALL_CHANGED_FILES}" | tr ' ' '\n'
echo ""
python .github/scripts/test-impact.py ${STEPS_CHANGED_FILES_OUTPUTS_ALL_CHANGED_FILES}
read -ra changed <<< "${STEPS_CHANGED_FILES_OUTPUTS_ALL_CHANGED_FILES}"
python .github/scripts/test-impact.py "${changed[@]}"
env:
STEPS_CHANGED_FILES_OUTPUTS_ALL_CHANGED_FILES: ${{ steps.changed-files.outputs.all_changed_files }}
@@ -92,21 +93,21 @@ jobs:
id: set-flags
run: |
if [[ -n "${STEPS_IMPACT_OUTPUTS_SDK_TESTS}" ]]; then
echo "has-sdk-tests=true" >> $GITHUB_OUTPUT
echo "has-sdk-tests=true" >> "$GITHUB_OUTPUT"
else
echo "has-sdk-tests=false" >> $GITHUB_OUTPUT
echo "has-sdk-tests=false" >> "$GITHUB_OUTPUT"
fi
if [[ -n "${STEPS_IMPACT_OUTPUTS_API_TESTS}" ]]; then
echo "has-api-tests=true" >> $GITHUB_OUTPUT
echo "has-api-tests=true" >> "$GITHUB_OUTPUT"
else
echo "has-api-tests=false" >> $GITHUB_OUTPUT
echo "has-api-tests=false" >> "$GITHUB_OUTPUT"
fi
if [[ -n "${STEPS_IMPACT_OUTPUTS_UI_E2E}" ]]; then
echo "has-ui-e2e=true" >> $GITHUB_OUTPUT
echo "has-ui-e2e=true" >> "$GITHUB_OUTPUT"
else
echo "has-ui-e2e=false" >> $GITHUB_OUTPUT
echo "has-ui-e2e=false" >> "$GITHUB_OUTPUT"
fi
env:
STEPS_IMPACT_OUTPUTS_SDK_TESTS: ${{ steps.impact.outputs.sdk-tests }}
@@ -115,22 +116,22 @@ jobs:
- name: Summary
run: |
echo "## Test Impact Analysis" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "## Test Impact Analysis" >> "$GITHUB_STEP_SUMMARY"
echo "" >> "$GITHUB_STEP_SUMMARY"
if [[ "${STEPS_IMPACT_OUTPUTS_RUN_ALL}" == "true" ]]; then
echo "🚨 **Critical path changed - running ALL tests**" >> $GITHUB_STEP_SUMMARY
echo "🚨 **Critical path changed - running ALL tests**" >> "$GITHUB_STEP_SUMMARY"
else
echo "### Affected Modules" >> $GITHUB_STEP_SUMMARY
echo "\`${STEPS_IMPACT_OUTPUTS_MODULES}\`" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "### Affected Modules" >> "$GITHUB_STEP_SUMMARY"
echo "\`${STEPS_IMPACT_OUTPUTS_MODULES}\`" >> "$GITHUB_STEP_SUMMARY"
echo "" >> "$GITHUB_STEP_SUMMARY"
echo "### Tests to Run" >> $GITHUB_STEP_SUMMARY
echo "| Category | Paths |" >> $GITHUB_STEP_SUMMARY
echo "|----------|-------|" >> $GITHUB_STEP_SUMMARY
echo "| SDK Tests | \`${STEPS_IMPACT_OUTPUTS_SDK_TESTS:-none}\` |" >> $GITHUB_STEP_SUMMARY
echo "| API Tests | \`${STEPS_IMPACT_OUTPUTS_API_TESTS:-none}\` |" >> $GITHUB_STEP_SUMMARY
echo "| UI E2E | \`${STEPS_IMPACT_OUTPUTS_UI_E2E:-none}\` |" >> $GITHUB_STEP_SUMMARY
echo "### Tests to Run" >> "$GITHUB_STEP_SUMMARY"
echo "| Category | Paths |" >> "$GITHUB_STEP_SUMMARY"
echo "|----------|-------|" >> "$GITHUB_STEP_SUMMARY"
echo "| SDK Tests | \`${STEPS_IMPACT_OUTPUTS_SDK_TESTS:-none}\` |" >> "$GITHUB_STEP_SUMMARY"
echo "| API Tests | \`${STEPS_IMPACT_OUTPUTS_API_TESTS:-none}\` |" >> "$GITHUB_STEP_SUMMARY"
echo "| UI E2E | \`${STEPS_IMPACT_OUTPUTS_UI_E2E:-none}\` |" >> "$GITHUB_STEP_SUMMARY"
fi
env:
+32 -18
View File
@@ -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
@@ -144,9 +147,20 @@ jobs:
build-args: |
NEXT_PUBLIC_PROWLER_RELEASE_VERSION=${{ (github.event_name == 'release' || github.event_name == 'workflow_dispatch') && format('v{0}', env.RELEASE_TAG) || needs.setup.outputs.short-sha }}
push: true
sbom: true
# max, not the default min: min records little beyond the build ref.
provenance: mode=max
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 +178,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
@@ -182,9 +196,9 @@ jobs:
run: |
docker buildx imagetools create \
-t ${{ env.PROWLERCLOUD_DOCKERHUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:${{ env.LATEST_TAG }} \
-t ${{ env.PROWLERCLOUD_DOCKERHUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:${NEEDS_SETUP_OUTPUTS_SHORT_SHA} \
${{ env.PROWLERCLOUD_DOCKERHUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:${NEEDS_SETUP_OUTPUTS_SHORT_SHA}-amd64 \
${{ env.PROWLERCLOUD_DOCKERHUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:${NEEDS_SETUP_OUTPUTS_SHORT_SHA}-arm64
-t "${{ env.PROWLERCLOUD_DOCKERHUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:${NEEDS_SETUP_OUTPUTS_SHORT_SHA}" \
"${{ env.PROWLERCLOUD_DOCKERHUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:${NEEDS_SETUP_OUTPUTS_SHORT_SHA}-amd64" \
"${{ env.PROWLERCLOUD_DOCKERHUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:${NEEDS_SETUP_OUTPUTS_SHORT_SHA}-arm64"
env:
NEEDS_SETUP_OUTPUTS_SHORT_SHA: ${{ needs.setup.outputs.short-sha }}
@@ -192,10 +206,10 @@ jobs:
if: github.event_name == 'release' || github.event_name == 'workflow_dispatch'
run: |
docker buildx imagetools create \
-t ${{ env.PROWLERCLOUD_DOCKERHUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:${RELEASE_TAG} \
-t "${{ env.PROWLERCLOUD_DOCKERHUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:${RELEASE_TAG}" \
-t ${{ env.PROWLERCLOUD_DOCKERHUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:${{ env.STABLE_TAG }} \
${{ env.PROWLERCLOUD_DOCKERHUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:${NEEDS_SETUP_OUTPUTS_SHORT_SHA}-amd64 \
${{ env.PROWLERCLOUD_DOCKERHUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:${NEEDS_SETUP_OUTPUTS_SHORT_SHA}-arm64
"${{ env.PROWLERCLOUD_DOCKERHUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:${NEEDS_SETUP_OUTPUTS_SHORT_SHA}-amd64" \
"${{ env.PROWLERCLOUD_DOCKERHUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:${NEEDS_SETUP_OUTPUTS_SHORT_SHA}-arm64"
env:
NEEDS_SETUP_OUTPUTS_SHORT_SHA: ${{ needs.setup.outputs.short-sha }}
@@ -235,9 +249,9 @@ jobs:
id: outcome
run: |
if [[ "${NEEDS_CONTAINER_BUILD_PUSH_RESULT}" == "success" && "${NEEDS_CREATE_MANIFEST_RESULT}" == "success" ]]; then
echo "outcome=success" >> $GITHUB_OUTPUT
echo "outcome=success" >> "$GITHUB_OUTPUT"
else
echo "outcome=failure" >> $GITHUB_OUTPUT
echo "outcome=failure" >> "$GITHUB_OUTPUT"
fi
env:
NEEDS_CONTAINER_BUILD_PUSH_RESULT: ${{ needs.container-build-push.result }}
+16 -2
View File
@@ -88,6 +88,9 @@ jobs:
get.trivy.dev:443
release-assets.githubusercontent.com:443
objects.githubusercontent.com:443
raw.githubusercontent.com:443
grype.anchore.io:443
get.anchore.io:443
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
@@ -102,6 +105,9 @@ jobs:
files: |
ui/**
.github/actions/trivy-scan/**
.github/actions/grype-scan/**
.grype.yaml
.github/scripts/grype-pr-comment.js
files_ignore: |
ui/CHANGELOG.md
ui/changelog.d/**
@@ -132,5 +138,13 @@ jobs:
with:
image-name: ${{ env.IMAGE_NAME }}
image-tag: ${{ github.sha }}
fail-on-critical: 'true'
severity: 'CRITICAL'
fail-on-severity: 'high'
severity: 'CRITICAL,HIGH'
- name: Scan UI container with Grype
if: steps.check-changes.outputs.any_changed == 'true'
uses: ./.github/actions/grype-scan
with:
image-name: ${{ env.IMAGE_NAME }}
image-tag: ${{ github.sha }}
fail-on-severity: 'high'
+16 -15
View File
@@ -108,14 +108,14 @@ jobs:
- name: Show test scope
run: |
echo "## E2E Test Scope" >> $GITHUB_STEP_SUMMARY
echo "## E2E Test Scope" >> "$GITHUB_STEP_SUMMARY"
if [[ "${RUN_ALL_TESTS}" == "true" ]]; then
echo "Running **ALL** E2E tests (critical path changed)" >> $GITHUB_STEP_SUMMARY
echo "Running **ALL** E2E tests (critical path changed)" >> "$GITHUB_STEP_SUMMARY"
else
echo "Running tests matching: \`${E2E_TEST_PATHS}\`" >> $GITHUB_STEP_SUMMARY
echo "Running tests matching: \`${E2E_TEST_PATHS}\`" >> "$GITHUB_STEP_SUMMARY"
fi
echo ""
echo "Affected modules: \`${NEEDS_IMPACT_ANALYSIS_OUTPUTS_MODULES}\`" >> $GITHUB_STEP_SUMMARY
echo "Affected modules: \`${NEEDS_IMPACT_ANALYSIS_OUTPUTS_MODULES}\`" >> "$GITHUB_STEP_SUMMARY"
env:
NEEDS_IMPACT_ANALYSIS_OUTPUTS_MODULES: ${{ needs.impact-analysis.outputs.modules }}
@@ -233,7 +233,7 @@ jobs:
yq -i '.services.worker.networks = ["kind","default"]' docker-compose.yml
- name: Fix API data directory permissions
run: docker run --rm -v $(pwd)/_data/api:/data alpine chown -R 1000:1000 /data
run: docker run --rm -v "$(pwd)/_data/api:/data" alpine chown -R 1000:1000 /data
- name: Add AWS credentials for testing
run: |
@@ -267,7 +267,7 @@ jobs:
timeout=150
elapsed=0
while [ $elapsed -lt $timeout ]; do
if curl -s ${UI_API_BASE_URL}/docs >/dev/null 2>&1; then
if curl -s "${UI_API_BASE_URL}/docs" >/dev/null 2>&1; then
echo "Prowler API is ready!"
exit 0
fi
@@ -301,7 +301,7 @@ jobs:
run_install: false
- name: Get pnpm store directory
run: echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV
run: echo "STORE_PATH=$(pnpm store path --silent)" >> "$GITHUB_ENV"
- name: Setup pnpm and Next.js cache
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
@@ -387,7 +387,8 @@ jobs:
fi
TEST_PATHS=$(echo "$VALID_PATHS" | tr '\n' ' ')
echo "Resolved test paths: $TEST_PATHS"
pnpm exec playwright test $TEST_PATHS
read -ra test_paths <<< "$TEST_PATHS"
pnpm exec playwright test "${test_paths[@]}"
fi
- name: Upload test reports
@@ -444,12 +445,12 @@ jobs:
- name: No E2E tests needed
run: |
echo "## E2E Tests Skipped" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "No UI E2E tests needed for this change." >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "Affected modules: \`${NEEDS_IMPACT_ANALYSIS_OUTPUTS_MODULES}\`" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "To run all tests, modify a file in a critical path (e.g., \`ui/lib/**\`)." >> $GITHUB_STEP_SUMMARY
echo "## E2E Tests Skipped" >> "$GITHUB_STEP_SUMMARY"
echo "" >> "$GITHUB_STEP_SUMMARY"
echo "No UI E2E tests needed for this change." >> "$GITHUB_STEP_SUMMARY"
echo "" >> "$GITHUB_STEP_SUMMARY"
echo "Affected modules: \`${NEEDS_IMPACT_ANALYSIS_OUTPUTS_MODULES}\`" >> "$GITHUB_STEP_SUMMARY"
echo "" >> "$GITHUB_STEP_SUMMARY"
echo "To run all tests, modify a file in a critical path (e.g., \`ui/lib/**\`)." >> "$GITHUB_STEP_SUMMARY"
env:
NEEDS_IMPACT_ANALYSIS_OUTPUTS_MODULES: ${{ needs.impact-analysis.outputs.modules }}
+3 -2
View File
@@ -113,7 +113,7 @@ jobs:
- name: Get pnpm store directory
if: steps.check-changes.outputs.any_changed == 'true'
shell: bash
run: echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV
run: echo "STORE_PATH=$(pnpm store path --silent)" >> "$GITHUB_ENV"
- name: Setup pnpm and Next.js cache
if: steps.check-changes.outputs.any_changed == 'true'
@@ -157,7 +157,8 @@ jobs:
echo "${STEPS_CHANGED_SOURCE_OUTPUTS_ALL_CHANGED_FILES}"
# Convert space-separated to vitest related format (remove ui/ prefix for relative paths)
CHANGED_FILES=$(echo "${STEPS_CHANGED_SOURCE_OUTPUTS_ALL_CHANGED_FILES}" | tr ' ' '\n' | sed 's|^ui/||' | tr '\n' ' ')
pnpm exec vitest related $CHANGED_FILES --run --project unit
read -ra changed <<< "$CHANGED_FILES"
pnpm exec vitest related "${changed[@]}" --run --project unit
env:
STEPS_CHANGED_SOURCE_OUTPUTS_ALL_CHANGED_FILES: ${{ steps.changed-source.outputs.all_changed_files }}
+62
View File
@@ -0,0 +1,62 @@
# Findings excluded from the Grype gate, each with a reason.
# Anything not listed here blocks the pull request at critical or high severity.
# Pairs are explicit: a new CVE against an already-listed package still blocks.
#
# Every entry below has a published fix we cannot take. Findings with no fix at all are
# not listed: the scan runs with only-fixed, so they never reach the gate.
ignore:
# Modules compiled into the Trivy binary we ship.
# Only a Trivy rebuild by its vendor can change these; the version is pinned in our Dockerfile.
- vulnerability: CVE-2026-56852
package:
name: golang.org/x/text
- vulnerability: GHSA-hrxh-6v49-42gf
package:
name: google.golang.org/grpc
- vulnerability: CVE-2026-50151
package:
name: oras.land/oras-go/v2
# Shipped inside the PowerShell tarball, in its bundled MicrosoftTeams module.
# Not a dependency we declare, and not one we can upgrade independently.
- vulnerability: CVE-2026-26127
package:
name: Microsoft.Bcl.Memory
# The CPython interpreter, compiled into the official base image.
# TEMPORARY, unlike the entries above: moving to Python 3.13 clears seven of these, and
# that is a runtime upgrade pending its own evaluation. The remaining three need 3.15 and
# are unfixable either way -- the MCP image already runs 3.13.14 and still reports them.
- vulnerability: CVE-2026-11940
package:
name: python
- vulnerability: CVE-2026-11972
package:
name: python
- vulnerability: CVE-2026-15308
package:
name: python
- vulnerability: CVE-2026-3298
package:
name: python
- vulnerability: CVE-2026-3644
package:
name: python
- vulnerability: CVE-2026-4224
package:
name: python
- vulnerability: CVE-2026-4786
package:
name: python
- vulnerability: CVE-2026-6100
package:
name: python
- vulnerability: CVE-2026-7210
package:
name: python
- vulnerability: CVE-2026-9669
package:
name: python
+8
View File
@@ -47,6 +47,14 @@ repos:
priority: 20
## GITHUB ACTIONS
- repo: https://github.com/rhysd/actionlint
rev: v1.7.12
hooks:
- id: actionlint
# SC2129 only suggests grouping consecutive redirects; not worth restructuring for.
args: ['-shellcheck=-e SC2129']
priority: 30
- repo: https://github.com/zizmorcore/zizmor-pre-commit
rev: v1.24.1
hooks:
-123
View File
@@ -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
+143
View File
@@ -0,0 +1,143 @@
# 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.
#
# The four entries below are currently redundant: the scan runs with ignore-unfixed,
# and none of them has a published fix, so they never reach the gate either way. They
# are kept because the reasoning is what justifies accepting them, and because they
# apply again the moment any of them gains a fix we do not take.
#
# 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
# Declared in the SPDX manifest that ships inside PowerShell's MicrosoftTeams module
# (Modules/MicrosoftTeams/7.9.0/_manifest/spdx_2.2/manifest.spdx.json). Trivy reads that
# SBOM and reports what it declares, which is not the same as what the image contains:
# there is no Node runtime and no node_modules anywhere in the image, and the .NET
# assemblies target net472, a Windows-only framework. Nothing here is reachable, and none
# of it is a dependency we declare -- only Microsoft can change the module's contents.
- id: CVE-2020-0606
purls:
- "pkg:nuget/Microsoft.WindowsDesktop.App.Ref"
expired_at: 2027-01-31
- id: CVE-2019-0820
purls:
- "pkg:nuget/System.Text.RegularExpressions"
expired_at: 2027-01-31
- id: CVE-2026-47302
purls:
- "pkg:nuget/System.Security.Cryptography.Xml"
expired_at: 2027-01-31
- id: CVE-2026-47304
purls:
- "pkg:nuget/System.Security.Cryptography.Xml"
expired_at: 2027-01-31
- id: CVE-2026-50525
purls:
- "pkg:nuget/System.Security.Cryptography.Xml"
expired_at: 2027-01-31
- id: CVE-2026-50527
purls:
- "pkg:nuget/System.Security.Cryptography.Xml"
expired_at: 2027-01-31
- id: CVE-2026-50648
purls:
- "pkg:nuget/System.Security.Cryptography.Xml"
expired_at: 2027-01-31
- id: CVE-2026-13676
purls:
- "pkg:npm/fast-uri"
expired_at: 2027-01-31
- id: CVE-2026-16221
purls:
- "pkg:npm/fast-uri"
expired_at: 2027-01-31
- id: CVE-2026-18446
purls:
- "pkg:npm/fast-uri"
expired_at: 2027-01-31
- id: CVE-2026-69192
purls:
- "pkg:npm/ip-address"
expired_at: 2027-01-31
# Modules compiled into the Trivy binary the images ship. The binary is pinned by version
# and verified by checksum in the Dockerfile; only a rebuild by its vendor moves these.
- id: CVE-2026-56852
purls:
- "pkg:golang/golang.org/x/text"
expired_at: 2026-12-31
- id: GHSA-hrxh-6v49-42gf
purls:
- "pkg:golang/google.golang.org/grpc"
expired_at: 2026-12-31
- id: CVE-2026-50151
purls:
- "pkg:golang/oras.land/oras-go/v2"
expired_at: 2026-12-31
- id: CVE-2026-50163
purls:
- "pkg:golang/oras.land/oras-go/v2"
expired_at: 2026-12-31
- id: CVE-2026-39822
purls:
- "pkg:golang/stdlib"
expired_at: 2026-12-31
+35 -5
View File
@@ -1,22 +1,30 @@
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
ENV ZIZMOR_VERSION=${ZIZMOR_VERSION}
# Pinned here, not fetched with the artefact: a compromised release ships its own checksum.
ARG TRIVY_SHA256_AMD64=bbb64b9695866ce4a7a8f5c9592002c5961cab378577fa3f8a040df362b9b2ea
ARG TRIVY_SHA256_ARM64=2ca2c023109c2db6b2b77366b6717291452d4531167377d95c79547f0c8e3467
ARG POWERSHELL_SHA256_AMD64=492ff26bb958336bf61e597ce19e07648b4003bd2a08659e02f0e3e0446ebfe0
ARG POWERSHELL_SHA256_ARM64=2503b71da3e83635592b092df59a0aca4c3606b4d9b068217bb00be989cb0d56
ARG ZIZMOR_SHA256_AMD64=a8000f3c683319a523d3b20df0e75457ba591f049cfcbfa98966631b56733c03
ARG ZIZMOR_SHA256_ARM64=d66e37ef8a375fb07939c630ebf9709a6e0f20242bdc3faf672a7ed97e0b768d
# 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/*
@@ -29,6 +37,9 @@ RUN ARCH=$(uname -m) && \
else \
echo "Unsupported architecture: $ARCH" && exit 1 ; \
fi && \
if [ "$ARCH" = "x86_64" ]; then EXPECT="$POWERSHELL_SHA256_AMD64" ; else EXPECT="$POWERSHELL_SHA256_ARM64" ; fi && \
echo "$EXPECT /tmp/powershell.tar.gz" > /tmp/powershell.sha256 && \
sha256sum -c /tmp/powershell.sha256 && rm /tmp/powershell.sha256 && \
mkdir -p /opt/microsoft/powershell/7 && \
tar zxf /tmp/powershell.tar.gz -C /opt/microsoft/powershell/7 && \
chmod +x /opt/microsoft/powershell/7/pwsh && \
@@ -45,6 +56,9 @@ RUN ARCH=$(uname -m) && \
echo "Unsupported architecture for Trivy: $ARCH" && exit 1 ; \
fi && \
wget --progress=dot:giga "https://github.com/aquasecurity/trivy/releases/download/v${TRIVY_VERSION}/trivy_${TRIVY_VERSION}_${TRIVY_ARCH}.tar.gz" -O /tmp/trivy.tar.gz && \
if [ "$ARCH" = "x86_64" ]; then EXPECT="$TRIVY_SHA256_AMD64" ; else EXPECT="$TRIVY_SHA256_ARM64" ; fi && \
echo "$EXPECT /tmp/trivy.tar.gz" > /tmp/trivy.sha256 && \
sha256sum -c /tmp/trivy.sha256 && rm /tmp/trivy.sha256 && \
tar zxf /tmp/trivy.tar.gz -C /tmp && \
mv /tmp/trivy /usr/local/bin/trivy && \
chmod +x /usr/local/bin/trivy && \
@@ -63,6 +77,9 @@ RUN ARCH=$(uname -m) && \
echo "Unsupported architecture for zizmor: $ARCH" && exit 1 ; \
fi && \
wget --progress=dot:giga "https://github.com/zizmorcore/zizmor/releases/download/v${ZIZMOR_VERSION}/zizmor-${ZIZMOR_ARCH}.tar.gz" -O /tmp/zizmor.tar.gz && \
if [ "$ARCH" = "x86_64" ]; then EXPECT="$ZIZMOR_SHA256_AMD64" ; else EXPECT="$ZIZMOR_SHA256_ARM64" ; fi && \
echo "$EXPECT /tmp/zizmor.tar.gz" > /tmp/zizmor.sha256 && \
sha256sum -c /tmp/zizmor.sha256 && rm /tmp/zizmor.sha256 && \
mkdir -p /tmp/zizmor-extract && \
tar zxf /tmp/zizmor.tar.gz -C /tmp/zizmor-extract && \
mv /tmp/zizmor-extract/zizmor /usr/local/bin/zizmor && \
@@ -89,7 +106,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 +122,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 +133,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"]
+3
View File
@@ -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 && \
+43
View File
@@ -4,6 +4,49 @@ All notable changes to the **Prowler API** are documented in this file.
<!-- changelog: release notes start -->
## [1.39.0] (Prowler v5.38.0)
### 🚀 Added
- 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 [(#12237)](https://github.com/prowler-cloud/prowler/pull/12237)
- Attack Paths query metadata now carries an outcome (Code execution, Privilege escalation, Public exposure, or Resource inventory), exposed on the queries endpoint so the graph can show a terminal outcome node [(#12344)](https://github.com/prowler-cloud/prowler/pull/12344)
- Container images now ship an SBOM and build provenance as OCI attestations [(#12352)](https://github.com/prowler-cloud/prowler/pull/12352)
### 🔄 Changed
- Pin the container vulnerability scanner to Trivy v0.72.0, matching prowler-registry and partner-portal [(#12346)](https://github.com/prowler-cloud/prowler/pull/12346)
### 🐞 Fixed
- Compliance report output directory failures are now logged with the exception attached and fingerprinted by `errno` in Sentry, so `ENOSPC`, `ENOENT` and `EACCES` no longer share a single issue [(#12142)](https://github.com/prowler-cloud/prowler/pull/12142)
- 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 [(#12309)](https://github.com/prowler-cloud/prowler/pull/12309)
### 🔐 Security
- The API container image now verifies the checksum of every third-party binary it downloads (PowerShell, Trivy, zizmor) before installing it [(#12334)](https://github.com/prowler-cloud/prowler/pull/12334)
- Upgrade aiohttp to 3.14.3 to pick up the fix for CVE-2026-69244 [(#12340)](https://github.com/prowler-cloud/prowler/pull/12340)
- Upgrade cryptography to 50.0.0, closing CVE-2026-69247 and CVE-2026-69249 [(#12356)](https://github.com/prowler-cloud/prowler/pull/12356)
---
## [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
+37 -6
View File
@@ -1,23 +1,31 @@
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
ENV ZIZMOR_VERSION=${ZIZMOR_VERSION}
# Pinned here, not fetched with the artefact: a compromised release ships its own checksum.
ARG TRIVY_SHA256_AMD64=bbb64b9695866ce4a7a8f5c9592002c5961cab378577fa3f8a040df362b9b2ea
ARG TRIVY_SHA256_ARM64=2ca2c023109c2db6b2b77366b6717291452d4531167377d95c79547f0c8e3467
ARG POWERSHELL_SHA256_AMD64=492ff26bb958336bf61e597ce19e07648b4003bd2a08659e02f0e3e0446ebfe0
ARG POWERSHELL_SHA256_ARM64=2503b71da3e83635592b092df59a0aca4c3606b4d9b068217bb00be989cb0d56
ARG ZIZMOR_SHA256_AMD64=a8000f3c683319a523d3b20df0e75457ba591f049cfcbfa98966631b56733c03
ARG ZIZMOR_SHA256_ARM64=d66e37ef8a375fb07939c630ebf9709a6e0f20242bdc3faf672a7ed97e0b768d
# hadolint ignore=DL3008
RUN apt-get update && apt-get install -y --no-install-recommends \
wget \
git \
libicu72 \
libicu76 \
gcc \
g++ \
make \
@@ -28,7 +36,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
@@ -40,6 +47,9 @@ RUN ARCH=$(uname -m) && \
else \
echo "Unsupported architecture: $ARCH" && exit 1 ; \
fi && \
if [ "$ARCH" = "x86_64" ]; then EXPECT="$POWERSHELL_SHA256_AMD64" ; else EXPECT="$POWERSHELL_SHA256_ARM64" ; fi && \
echo "$EXPECT /tmp/powershell.tar.gz" > /tmp/powershell.sha256 && \
sha256sum -c /tmp/powershell.sha256 && rm /tmp/powershell.sha256 && \
mkdir -p /opt/microsoft/powershell/7 && \
tar zxf /tmp/powershell.tar.gz -C /opt/microsoft/powershell/7 && \
chmod +x /opt/microsoft/powershell/7/pwsh && \
@@ -56,6 +66,9 @@ RUN ARCH=$(uname -m) && \
echo "Unsupported architecture for Trivy: $ARCH" && exit 1 ; \
fi && \
wget --progress=dot:giga "https://github.com/aquasecurity/trivy/releases/download/v${TRIVY_VERSION}/trivy_${TRIVY_VERSION}_${TRIVY_ARCH}.tar.gz" -O /tmp/trivy.tar.gz && \
if [ "$ARCH" = "x86_64" ]; then EXPECT="$TRIVY_SHA256_AMD64" ; else EXPECT="$TRIVY_SHA256_ARM64" ; fi && \
echo "$EXPECT /tmp/trivy.tar.gz" > /tmp/trivy.sha256 && \
sha256sum -c /tmp/trivy.sha256 && rm /tmp/trivy.sha256 && \
tar zxf /tmp/trivy.tar.gz -C /tmp && \
mv /tmp/trivy /usr/local/bin/trivy && \
chmod +x /usr/local/bin/trivy && \
@@ -74,6 +87,9 @@ RUN ARCH=$(uname -m) && \
echo "Unsupported architecture for zizmor: $ARCH" && exit 1 ; \
fi && \
wget --progress=dot:giga "https://github.com/zizmorcore/zizmor/releases/download/v${ZIZMOR_VERSION}/zizmor-${ZIZMOR_ARCH}.tar.gz" -O /tmp/zizmor.tar.gz && \
if [ "$ARCH" = "x86_64" ]; then EXPECT="$ZIZMOR_SHA256_AMD64" ; else EXPECT="$ZIZMOR_SHA256_ARM64" ; fi && \
echo "$EXPECT /tmp/zizmor.tar.gz" > /tmp/zizmor.sha256 && \
sha256sum -c /tmp/zizmor.sha256 && rm /tmp/zizmor.sha256 && \
mkdir -p /tmp/zizmor-extract && \
tar zxf /tmp/zizmor.tar.gz -C /tmp/zizmor-extract && \
mv /tmp/zizmor-extract/zizmor /usr/local/bin/zizmor && \
@@ -94,7 +110,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 +125,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/
+39 -26
View File
@@ -45,7 +45,7 @@ dependencies = [
"gunicorn==26.0.0",
"uvloop==0.22.1",
"lxml==6.1.0",
"prowler @ git+https://github.com/prowler-cloud/prowler.git@master",
"prowler @ git+https://github.com/prowler-cloud/prowler.git@v5.38",
"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",
@@ -99,7 +101,7 @@ constraint-dependencies = [
"aiobotocore==2.25.1",
"aiofiles==24.1.0",
"aiohappyeyeballs==2.6.1",
"aiohttp==3.14.0",
"aiohttp==3.14.3",
"aioitertools==0.13.0",
"aiosignal==1.4.0",
"alibabacloud-actiontrail20200706==2.4.1",
@@ -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==50.0.0",
"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
@@ -480,8 +485,16 @@ constraint-dependencies = [
# that request pyjwt[crypto] and leave cryptography (needed for RS256) only transitive.
override-dependencies = [
"okta==3.4.2",
# alibabacloud-tea-openapi 0.4.5 caps cryptography below 49 and is the latest release.
"cryptography==50.0.0",
"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"
]
@@ -1,5 +1,6 @@
from api.attack_paths.queries import (
AttackPathsQueryDefinition,
AttackPathsQueryOutcome,
AttackPathsQueryParameterDefinition,
get_queries_for_provider,
get_query_by_id,
@@ -7,6 +8,7 @@ from api.attack_paths.queries import (
__all__ = [
"AttackPathsQueryDefinition",
"AttackPathsQueryOutcome",
"AttackPathsQueryParameterDefinition",
"get_queries_for_provider",
"get_query_by_id",
@@ -4,11 +4,13 @@ from api.attack_paths.queries.registry import (
)
from api.attack_paths.queries.types import (
AttackPathsQueryDefinition,
AttackPathsQueryOutcome,
AttackPathsQueryParameterDefinition,
)
__all__ = [
"AttackPathsQueryDefinition",
"AttackPathsQueryOutcome",
"AttackPathsQueryParameterDefinition",
"get_queries_for_provider",
"get_query_by_id",
File diff suppressed because it is too large Load Diff
@@ -1,4 +1,38 @@
from dataclasses import dataclass, field
from enum import Enum
@dataclass(frozen=True)
class AttackPathsQueryOutcomeMeta:
"""Display metadata for an outcome kind.
`label` and `partial` are properties of the outcome *kind*, not of an
individual query, so they live here once and every query just references a
kind. `partial` marks a latent/posture outcome (e.g. inventory) that the UI
renders as a marker rather than a full realized outcome.
"""
kind: str
label: str
partial: bool = False
class AttackPathsQueryOutcome(Enum):
"""The terminal impact an attack-path query leads to.
Set per query and exposed by the API so the UI can render the graph's
terminal outcome node. The taxonomy is shared with Prowler Hub's attack-path
diagram (whose terminal labels match these values).
"""
CODE_EXECUTION = AttackPathsQueryOutcomeMeta("code_execution", "Code execution")
PRIVILEGE_ESCALATION = AttackPathsQueryOutcomeMeta(
"privilege_escalation", "Privilege escalation"
)
PUBLIC_EXPOSURE = AttackPathsQueryOutcomeMeta("public_exposure", "Public exposure")
RESOURCE_INVENTORY = AttackPathsQueryOutcomeMeta(
"resource_inventory", "Resource inventory", partial=True
)
@dataclass
@@ -36,4 +70,5 @@ class AttackPathsQueryDefinition:
provider: str
cypher: str
attribution: AttackPathsQueryAttribution | None = None
outcome: AttackPathsQueryOutcome | None = None
parameters: list[AttackPathsQueryParameterDefinition] = field(default_factory=list)
+1 -1
View File
@@ -1,7 +1,7 @@
openapi: 3.0.3
info:
title: Prowler API
version: 1.38.0
version: 1.39.0
description: |-
Prowler API specification.
@@ -22,7 +22,10 @@ from api.attack_paths.queries.aws import (
AWS_STS_PRIVESC_CROSS_ACCOUNT_TRUST,
AWS_STS_PRIVESC_WILDCARD_TRUST,
)
from api.attack_paths.queries.types import AttackPathsQueryDefinition
from api.attack_paths.queries.types import (
AttackPathsQueryDefinition,
AttackPathsQueryOutcome,
)
# The pathfinding.cloud privilege-escalation queries added for PROWLER-2278.
NEW_PATHFINDING_QUERIES = [
@@ -238,6 +241,50 @@ class TestAllQueriesUniqueIds:
assert not duplicates, f"Duplicate query IDs found: {duplicates}"
class TestQueryOutcomes:
"""Every query carries a valid outcome (the graph's terminal impact)."""
def test_every_query_has_an_outcome(self):
# Completeness guard: a new query must be given an outcome, so the UI can
# always render a terminal outcome node.
missing = [q.id for q in AWS_QUERIES if q.outcome is None]
assert not missing, f"Queries without an outcome: {missing}"
def test_every_outcome_is_a_valid_member(self):
for query in AWS_QUERIES:
assert isinstance(query.outcome, AttackPathsQueryOutcome)
assert query.outcome.value.kind
assert query.outcome.value.label
@pytest.mark.parametrize(
"query, expected",
[
(
AWS_STS_PRIVESC_CROSS_ACCOUNT_TRUST,
AttackPathsQueryOutcome.PRIVILEGE_ESCALATION,
),
(
AWS_IAM_PRIVESC_DELETE_USER_PERMISSIONS_BOUNDARY,
AttackPathsQueryOutcome.PRIVILEGE_ESCALATION,
),
],
ids=lambda v: getattr(v, "id", getattr(v, "name", "")),
)
def test_representative_outcomes(self, query, expected):
assert query.outcome is expected
def test_inventory_outcome_is_partial(self):
assert AttackPathsQueryOutcome.RESOURCE_INVENTORY.value.partial is True
def test_realized_outcomes_are_not_partial(self):
for outcome in (
AttackPathsQueryOutcome.CODE_EXECUTION,
AttackPathsQueryOutcome.PRIVILEGE_ESCALATION,
AttackPathsQueryOutcome.PUBLIC_EXPOSURE,
):
assert outcome.value.partial is False
def _strip_comment_lines(cypher: str) -> str:
"""Drop `//` comment lines so keyword scans ignore prose in comments."""
return "\n".join(
@@ -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
)
+164 -1
View File
@@ -1,9 +1,10 @@
import errno
import logging
from unittest.mock import MagicMock, patch
import pytest
from config.settings import sentry as sentry_settings
from config.settings.sentry import before_send
from config.settings.sentry import before_send, errno_fingerprint
def test_initialize_sentry_skips_without_dsn():
@@ -188,3 +189,165 @@ def test_before_send_passes_non_defunct_neo4j_log():
event = MagicMock()
assert before_send(event, hint) == event
def _filesystem_hint(exception, msg="Error generating output directory"):
"""Build the hint the logging integration sends for a filesystem failure."""
exc_info = (type(exception), exception, exception.__traceback__)
log_record = _make_log_record(msg)
log_record.exc_info = exc_info
setattr(
log_record,
sentry_settings.ERROR_CATEGORY_ATTRIBUTE,
sentry_settings.FILESYSTEM_ERROR_CATEGORY,
)
return {"log_record": log_record, "exc_info": exc_info}
@pytest.mark.parametrize(
("error_number", "message", "expected_suffix"),
[
(errno.ENOSPC, "No space left on device", "errno:ENOSPC"),
(errno.ENOENT, "No such file or directory", "errno:ENOENT"),
(errno.EACCES, "Permission denied", "errno:EACCES"),
],
)
def test_before_send_fingerprints_oserror_by_errno(
error_number, message, expected_suffix
):
"""Filesystem failures raised from the same call site must not be merged."""
event = {}
result = before_send(event, _filesystem_hint(OSError(error_number, message)))
assert result is event
assert event["fingerprint"] == ["{{ default }}", expected_suffix]
def test_before_send_fingerprints_differ_per_errno():
"""ENOSPC and ENOENT from the same call site produce different issues."""
enospc_event = {}
enoent_event = {}
before_send(
enospc_event, _filesystem_hint(OSError(errno.ENOSPC, "No space left on device"))
)
before_send(
enoent_event,
_filesystem_hint(OSError(errno.ENOENT, "No such file or directory")),
)
assert enospc_event["fingerprint"] != enoent_event["fingerprint"]
def test_before_send_fingerprints_wrapped_oserror():
"""The errno is found even when the OSError is wrapped by another error."""
try:
try:
raise OSError(errno.ENOSPC, "No space left on device")
except OSError as os_error:
raise RuntimeError("Error generating output directory") from os_error
except RuntimeError as wrapper:
event = {}
before_send(event, _filesystem_hint(wrapper))
assert event["fingerprint"] == ["{{ default }}", "errno:ENOSPC"]
def test_before_send_does_not_fingerprint_non_oserror():
"""Non-filesystem exceptions keep Sentry's default grouping."""
event = {}
result = before_send(event, _filesystem_hint(ValueError("boom")))
assert result is event
assert "fingerprint" not in event
def test_before_send_does_not_fingerprint_unrelated_oserror_log():
"""Only records declaring the filesystem category opt into the errno grouping."""
exception = OSError(errno.ENOSPC, "No space left on device")
log_record = _make_log_record("Unrelated failure")
exc_info = (OSError, exception, None)
log_record.exc_info = exc_info
event = {}
result = before_send(event, {"log_record": log_record, "exc_info": exc_info})
assert result is event
assert "fingerprint" not in event
def test_before_send_does_not_fingerprint_exception_events():
"""Exception events without a log record keep Sentry's default grouping."""
event = {}
result = before_send(
event,
{"exc_info": (OSError, OSError(errno.ENOSPC, "No space left on device"), None)},
)
assert result is event
assert "fingerprint" not in event
@pytest.mark.parametrize("fingerprint", [["scope-fingerprint"], []])
def test_before_send_keeps_existing_fingerprint(fingerprint):
"""A fingerprint set by a scope or an integration is never overwritten."""
expected_fingerprint = fingerprint.copy()
event = {"fingerprint": fingerprint}
before_send(
event, _filesystem_hint(OSError(errno.ENOSPC, "No space left on device"))
)
assert event["fingerprint"] == expected_fingerprint
def test_before_send_ignores_suppressed_context():
"""`raise ... from None` hides the context, so it must not group the event."""
try:
try:
raise OSError(errno.ENOSPC, "No space left on device")
except OSError:
raise RuntimeError("Error generating output directory") from None
except RuntimeError as wrapper:
event = {}
before_send(event, _filesystem_hint(wrapper))
assert "fingerprint" not in event
def test_errno_fingerprint_follows_implicit_context():
"""An implicit `raise` during handling still exposes the original errno."""
try:
try:
raise OSError(errno.EACCES, "Permission denied")
except OSError:
raise RuntimeError("Error generating output directory")
except RuntimeError as wrapper:
assert errno_fingerprint(wrapper) == "errno:EACCES"
def test_before_send_does_not_fingerprint_oserror_without_errno():
"""An OSError without errno has nothing to split the issue by."""
event = {}
before_send(event, _filesystem_hint(OSError("no errno here")))
assert "fingerprint" not in event
def test_errno_fingerprint_uses_raw_number_for_unknown_errno():
"""Unmapped errno values still split the issue instead of being dropped."""
assert errno_fingerprint(OSError(9999, "unknown")) == "errno:9999"
def test_errno_fingerprint_stops_on_self_referencing_chain():
"""A cyclic exception chain must not hang the fingerprint lookup."""
first = ValueError("first")
second = ValueError("second")
first.__cause__ = second
second.__cause__ = first
assert errno_fingerprint(first) is None
+67
View File
@@ -19,6 +19,7 @@ from allauth.account.models import EmailAddress
from allauth.socialaccount.models import SocialAccount, SocialApp
from api.attack_paths import (
AttackPathsQueryDefinition,
AttackPathsQueryOutcome,
AttackPathsQueryParameterDefinition,
)
from api.compliance import get_compliance_frameworks
@@ -5388,6 +5389,72 @@ class TestAttackPathsScanViewSet:
assert payload[0]["attributes"]["name"] == "RDS inventory"
assert payload[0]["attributes"]["parameters"][0]["name"] == "ip"
def test_attack_paths_queries_expose_outcome(
self,
authenticated_client,
aws_provider,
scans_fixture,
create_attack_paths_scan,
):
provider = aws_provider
attack_paths_scan = create_attack_paths_scan(
provider,
scan=scans_fixture[0],
)
definitions = [
AttackPathsQueryDefinition(
id="aws-lambda-passrole",
name="Lambda passrole",
short_description="Pass a role to a new Lambda function.",
description="Pass a role to a new Lambda function and run code as it.",
provider=provider.provider,
cypher="MATCH (n) RETURN n",
outcome=AttackPathsQueryOutcome.CODE_EXECUTION,
),
AttackPathsQueryDefinition(
id="aws-rds-inventory",
name="RDS inventory",
short_description="List account RDS assets.",
description="List account RDS assets.",
provider=provider.provider,
cypher="MATCH (n) RETURN n",
outcome=AttackPathsQueryOutcome.RESOURCE_INVENTORY,
),
AttackPathsQueryDefinition(
id="aws-no-outcome",
name="No outcome",
short_description="A query without an outcome.",
description="A query without an outcome.",
provider=provider.provider,
cypher="MATCH (n) RETURN n",
),
]
with patch("api.v1.views.get_queries_for_provider", return_value=definitions):
response = authenticated_client.get(
reverse(
"attack-paths-scans-queries", kwargs={"pk": attack_paths_scan.id}
)
)
assert response.status_code == status.HTTP_200_OK
outcomes = {
item["id"]: item["attributes"]["outcome"]
for item in response.json()["data"]
}
assert outcomes["aws-lambda-passrole"] == {
"kind": "code_execution",
"label": "Code execution",
"partial": False,
}
assert outcomes["aws-rds-inventory"] == {
"kind": "resource_inventory",
"label": "Resource inventory",
"partial": True,
}
assert outcomes["aws-no-outcome"] is None
def test_attack_paths_queries_returns_404_when_catalog_missing(
self,
authenticated_client,
+22
View File
@@ -1315,6 +1315,28 @@ class AttackPathsQuerySerializer(BaseSerializerV1):
attribution = AttackPathsQueryAttributionSerializer(allow_null=True, required=False)
provider = serializers.CharField()
parameters = AttackPathsQueryParameterSerializer(many=True)
# The terminal impact the query leads to (e.g. {"kind": "code_execution",
# "label": "Code execution"}), or null if the query has none. The UI renders
# this as the graph's terminal outcome node.
outcome = serializers.SerializerMethodField()
@extend_schema_field(
{
"type": "object",
"nullable": True,
"properties": {
"kind": {"type": "string"},
"label": {"type": "string"},
"partial": {"type": "boolean"},
},
}
)
def get_outcome(self, definition):
outcome = getattr(definition, "outcome", None)
if outcome is None:
return None
meta = outcome.value
return {"kind": meta.kind, "label": meta.label, "partial": meta.partial}
class JSONAPIMeta:
resource_name = "attack-paths-queries"
+65 -1
View File
@@ -1,6 +1,20 @@
from errno import errorcode
import sentry_sdk
from config.env import env
# How many links of the __cause__/__context__ chain are inspected when looking
# for the OSError that actually caused the event.
MAX_EXCEPTION_CHAIN_DEPTH = 10
# LogRecord attribute describing what kind of failure the record reports, set by
# the caller through `logger.exception(..., extra={"error_category": ...})`.
ERROR_CATEGORY_ATTRIBUTE = "error_category"
# Category of records whose events are grouped by the errno of the underlying
# OSError. Only records that declare it opt into the errno fingerprint.
FILESYSTEM_ERROR_CATEGORY = "filesystem"
IGNORED_EXCEPTIONS = [
# Provider is not connected due to credentials errors
"is not connected",
@@ -80,6 +94,38 @@ IGNORED_EXCEPTIONS = [
]
def errno_fingerprint(exception):
"""
Return an errno-based fingerprint suffix for OSError-like exceptions.
Filesystem failures such as ENOSPC (disk full), ENOENT (missing mount point)
or EACCES (wrong permissions) are all OSError raised from the same call
site, so Sentry's default grouping merges them into a single issue even
when the exception is attached to the event. Appending the errno keeps the
default grouping and splits the issue per failure cause.
Only the part of the chain Sentry itself displays is inspected: a
`raise ... from None` sets __suppress_context__, so the implicit
__context__ is dropped from the event and must not group it either.
Returns None when no OSError with an errno is found in the exception chain.
"""
seen = set()
for _ in range(MAX_EXCEPTION_CHAIN_DEPTH):
if exception is None or id(exception) in seen:
break
seen.add(id(exception))
if isinstance(exception, OSError) and exception.errno is not None:
return f"errno:{errorcode.get(exception.errno, exception.errno)}"
if exception.__cause__ is not None:
exception = exception.__cause__
elif exception.__suppress_context__:
break
else:
exception = exception.__context__
return None
def before_send(event, hint):
"""
before_send handles the Sentry events in order to send them or not
@@ -115,10 +161,28 @@ def before_send(event, hint):
# Ignore exceptions with the ignored_exceptions
if "exc_info" in hint and hint["exc_info"]:
exc_value = str(hint["exc_info"][1])
exception = hint["exc_info"][1]
exc_value = str(exception)
if any(ignored in exc_value for ignored in IGNORED_EXCEPTIONS):
return None # Explicitly return None to drop the event
# Split filesystem issues per errno instead of grouping every failure raised
# from the same call site under a single issue. Only records that declare
# themselves as filesystem failures opt in, and a fingerprint already set by
# a scope or an integration always wins.
log_record = hint.get("log_record")
exc_info = hint.get("exc_info")
if (
log_record is not None
and exc_info
and getattr(log_record, ERROR_CATEGORY_ATTRIBUTE, None)
== FILESYSTEM_ERROR_CATEGORY
and "fingerprint" not in event
):
fingerprint_suffix = errno_fingerprint(exc_info[1])
if fingerprint_suffix:
event["fingerprint"] = ["{{ default }}", fingerprint_suffix]
return event
+10 -1
View File
@@ -13,6 +13,7 @@ from api.db_utils import rls_transaction
from api.models import Provider, Scan, ScanSummary, StateChoices, ThreatScoreSnapshot
from celery.utils.log import get_task_logger
from config.django.base import DJANGO_TMP_OUTPUT_DIRECTORY
from config.settings.sentry import ERROR_CATEGORY_ATTRIBUTE, FILESYSTEM_ERROR_CATEGORY
from prowler.lib.check.compliance_models import (
Compliance,
get_bulk_compliance_frameworks_universal,
@@ -960,7 +961,15 @@ def generate_compliance_reports(
first_output_path = next(iter(output_paths.values()))
out_dir = str(Path(first_output_path).parent.parent)
except Exception as e:
logger.error("Error generating output directory: %s", e)
# logger.exception attaches the exception (and its traceback) to the
# Sentry event and the filesystem category opts that event into the
# errno fingerprint, so ENOSPC, ENOENT and EACCES raised from this same
# call site land on separate issues.
logger.exception(
"Error generating output directory: %s",
e,
extra={ERROR_CATEGORY_ATTRIBUTE: FILESYSTEM_ERROR_CATEGORY},
)
error_dict = {"error": str(e), "upload": False, "path": ""}
if generate_threatscore:
results["threatscore"] = error_dict.copy()
@@ -1,3 +1,5 @@
import errno
import logging
import os
import time
import uuid
@@ -14,6 +16,11 @@ from api.models import (
StateChoices,
StatusChoices,
)
from config.settings.sentry import (
ERROR_CATEGORY_ATTRIBUTE,
FILESYSTEM_ERROR_CATEGORY,
before_send,
)
from prowler.lib.check.models import Severity
from reportlab.lib import colors
from tasks.jobs.report import (
@@ -1676,6 +1683,78 @@ class TestGenerateComplianceReportsCIS:
assert result["cis"]["upload"] is False
assert result["cis"]["error"] == "dir boom"
@patch("tasks.jobs.report._aggregate_requirement_statistics_from_database")
@patch("tasks.jobs.report._generate_compliance_output_directory")
@patch("tasks.jobs.report.Compliance.get_bulk")
def test_output_directory_failures_are_grouped_per_errno(
self,
mock_get_bulk,
mock_generate_output_dir,
mock_stats,
monkeypatch,
caplog,
tenants_fixture,
scans_fixture,
aws_provider,
):
"""A full disk and a missing mount point must not share a Sentry issue.
Both are OSError raised from the same ``os.makedirs`` call, so they only
stay apart if the exception reaches ``before_send``, which fingerprints
it by errno.
"""
tenant = tenants_fixture[0]
scan = scans_fixture[0]
provider = aws_provider
self._force_scan_has_findings(monkeypatch)
mock_stats.return_value = {}
mock_get_bulk.return_value = {"cis_5.0_aws": Mock()}
fingerprints = []
for error_number, message in (
(errno.ENOSPC, "No space left on device: '/tmp/prowler_api_output'"),
(errno.ENOENT, "No such file or directory: '/mnt/output'"),
):
mock_generate_output_dir.side_effect = OSError(error_number, message)
caplog.clear()
with caplog.at_level(logging.ERROR, logger="tasks.jobs.report"):
generate_compliance_reports(
tenant_id=str(tenant.id),
scan_id=str(scan.id),
provider_id=str(provider.id),
generate_threatscore=False,
generate_ens=False,
generate_nis2=False,
generate_csa=False,
generate_cis=True,
)
record = next(
record
for record in caplog.records
if "Error generating output directory" in record.getMessage()
)
# Without exc_info the Sentry event carries no exception at all and
# nothing can tell the two failures apart.
assert record.exc_info is not None
# The category is what scopes the errno fingerprint to this record.
assert (
getattr(record, ERROR_CATEGORY_ATTRIBUTE, None)
== FILESYSTEM_ERROR_CATEGORY
)
# Same hint the Sentry logging integration builds for this record.
event = {}
before_send(event, {"log_record": record, "exc_info": record.exc_info})
fingerprints.append(event["fingerprint"])
assert fingerprints == [
["{{ default }}", "errno:ENOSPC"],
["{{ default }}", "errno:ENOENT"],
]
class TestPickLatestCisVariant:
"""Unit tests for `_pick_latest_cis_variant` helper."""
Generated
+419 -173
View File
@@ -16,7 +16,7 @@ constraints = [
{ name = "aiobotocore", specifier = "==2.25.1" },
{ name = "aiofiles", specifier = "==24.1.0" },
{ name = "aiohappyeyeballs", specifier = "==2.6.1" },
{ name = "aiohttp", specifier = "==3.14.0" },
{ name = "aiohttp", specifier = "==3.14.3" },
{ name = "aioitertools", specifier = "==0.13.0" },
{ name = "aiosignal", specifier = "==1.4.0" },
{ name = "alibabacloud-actiontrail20200706", specifier = "==2.4.1" },
@@ -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 = "==50.0.0" },
{ 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" },
@@ -377,8 +377,15 @@ constraints = [
]
overrides = [
{ name = "azure-mgmt-containerservice", specifier = "==34.1.0" },
{ name = "cryptography", specifier = "==50.0.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" },
]
@@ -472,7 +479,7 @@ wheels = [
[[package]]
name = "aiohttp"
version = "3.14.0"
version = "3.14.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "aiohappyeyeballs" },
@@ -484,44 +491,44 @@ dependencies = [
{ name = "typing-extensions" },
{ name = "yarl" },
]
sdist = { url = "https://files.pythonhosted.org/packages/ee/ab/93ce242f899b68c51b0578c027aafa791ab3614cb9345fa5d37b5f5c8e3e/aiohttp-3.14.0.tar.gz", hash = "sha256:2882de819734c715fd1b9c11c97e09fa020d14438203d1d354d8ed1702791c9b", size = 7940674, upload-time = "2026-06-01T19:41:02.763Z" }
sdist = { url = "https://files.pythonhosted.org/packages/58/d9/22ce5786ac0c1653ae8b6c23bded02c1686d11f0dbb45b31ce128e0df985/aiohttp-3.14.3.tar.gz", hash = "sha256:9491196535a88924a60afd5b5f434b5b203b6cc616250878dbdb223a8f7844bc", size = 7971213, upload-time = "2026-07-23T01:57:27.037Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/67/47/7727bfe8db93f8835a001bd4359d8480cc68d1259b8bce334668f8be97bd/aiohttp-3.14.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:54bf3522d6f7351e55f89a62d5c2bf138ad557b031670266c5df604ae88e0b5a", size = 759147, upload-time = "2026-06-01T19:37:12.918Z" },
{ url = "https://files.pythonhosted.org/packages/eb/f2/cd3fedff6fade73d71df9ec908c210cec518ef90fd00289250684b90aecf/aiohttp-3.14.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:0746d9fb0ac4fdef643a84494efe3f06d50335dd8c7a530228b86448aae0a803", size = 513705, upload-time = "2026-06-01T19:37:14.633Z" },
{ url = "https://files.pythonhosted.org/packages/5a/fe/49746b6b610144a06323bebd8e1211a390310d8c69b98dd6d52df341bc3e/aiohttp-3.14.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:9f3a96b6d39a4872222beee72e1df41d2ff886ae96152cf3e757ef8c5673ef0e", size = 509627, upload-time = "2026-06-01T19:37:16.385Z" },
{ url = "https://files.pythonhosted.org/packages/4c/3f/28f2f6cf3d5c0e7b01b27140d0e7873fd11fb341169ad3ce78ad04aba628/aiohttp-3.14.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d336820adbb914debbc90a1d8c1bfc4bea55996aecf64866a989d35d1f9fd903", size = 1769293, upload-time = "2026-06-01T19:37:18.067Z" },
{ url = "https://files.pythonhosted.org/packages/97/6f/2e5f1b525d5474b12b3c60abf733a755845f3bceff21542081ada515f837/aiohttp-3.14.0-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:71b2604c9bfc1b115547d63a094d5244b3f02799833513a99a68aaa7b167c4cb", size = 1732363, upload-time = "2026-06-01T19:37:20.138Z" },
{ url = "https://files.pythonhosted.org/packages/a8/ce/596120faa85ca7b19cd061e3f2f3be23aa8f11a0aedf9191db9e0da1bd76/aiohttp-3.14.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:610d68800435903e303ca0542b9d3e4eb72a12ff33a6d471a070c1d81eebd3c2", size = 1840375, upload-time = "2026-06-01T19:37:22.104Z" },
{ url = "https://files.pythonhosted.org/packages/72/3c/a7ffe05a757a4a7867643da69357ec41f506879fbd1b231d2ed90af246b2/aiohttp-3.14.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:514db9a79337068981ee2137310283a07b4b885c584991097a91a4da419bcb81", size = 1921484, upload-time = "2026-06-01T19:37:24.068Z" },
{ url = "https://files.pythonhosted.org/packages/93/fa/2c861170bbd4a491de93a69e081db1d971092569e0d593a98ef62c384dc1/aiohttp-3.14.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c452d17eeb95d563fc8b936f3050301dbd1d268126c4632d8b70ede9696202ee", size = 1774153, upload-time = "2026-06-01T19:37:26.256Z" },
{ url = "https://files.pythonhosted.org/packages/9d/da/1d2f5a165f47ec9b1f69d37b8b977fdc4d501aa72ffb7930db27bb9e49ea/aiohttp-3.14.0-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ed94a81506e3d1bdbad5108f497a58f2a2354aedb4ca314d5326f07d1fd1ac2d", size = 1632569, upload-time = "2026-06-01T19:37:28.192Z" },
{ url = "https://files.pythonhosted.org/packages/46/1d/7a6e295c4257252f70f69e90864fdad74b6a1293054fb3f9e65a15de6d63/aiohttp-3.14.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1394dce36e0f0d260ac0b555a654de19cb989f3c1b8bdd24f505314dfea18a00", size = 1740325, upload-time = "2026-06-01T19:37:30.08Z" },
{ url = "https://files.pythonhosted.org/packages/f1/7e/e1899b1ca3ec62f1eab2a5cbde14039b97493f7f53eb88d9b668562ffa8d/aiohttp-3.14.0-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:d1467d1e7b48a73ca7237e0ee4335f3d02b923dbc27b82fd254bc301c97d4026", size = 1748691, upload-time = "2026-06-01T19:37:32.211Z" },
{ url = "https://files.pythonhosted.org/packages/ec/54/4e6b61c1fe7d3433f82bcc6bd7e4d7c683a742a10c9b12a025fd3695c047/aiohttp-3.14.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:6a5f3532125233c261cf61f32df4059cfcf482eb793c7d3db8452e3142028b86", size = 1814477, upload-time = "2026-06-01T19:37:34.173Z" },
{ url = "https://files.pythonhosted.org/packages/9c/38/86fd51be2e08d8e45c83d879d255f10391903cd9fe2a16512f7591a15873/aiohttp-3.14.0-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:3ea81eb518a2ecb319d8ec6d1424a37c773f6634bd87d6985eb606b2faac419f", size = 1623393, upload-time = "2026-06-01T19:37:36.281Z" },
{ url = "https://files.pythonhosted.org/packages/78/49/466e947a42a88ee23c486d036e7e5d1b097f1bafd8084ad9c9a0a92f0f43/aiohttp-3.14.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:32e735c3182de7b64f6941a4ede48b38c7f47d9437bd615dd30b5bda8fa1bc93", size = 1824097, upload-time = "2026-06-01T19:37:38.421Z" },
{ url = "https://files.pythonhosted.org/packages/f3/89/35f3410bc284682338a1be6b6ea0c5abfa05f063942cfaa9256608440434/aiohttp-3.14.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:c21ca9a1c63d4509158f478aeb9d02914dcc52adc68d1bc9dee2452284ee5996", size = 1764790, upload-time = "2026-06-01T19:37:40.755Z" },
{ url = "https://files.pythonhosted.org/packages/42/80/2d4291bd5724d3d17e5951aff5a3e02281483fb47295f0788276ee66cd73/aiohttp-3.14.0-cp311-cp311-win32.whl", hash = "sha256:19ca5fc84130675ba11c6ca5c7da5cb65f7bf8a32cdd2b616bf49cd334688aae", size = 454176, upload-time = "2026-06-01T19:37:42.837Z" },
{ url = "https://files.pythonhosted.org/packages/59/ed/41d0ad4f6ececffc32bdf1f7b494e5498f7ca5c849ea2e3cc9bbd1668251/aiohttp-3.14.0-cp311-cp311-win_amd64.whl", hash = "sha256:d488e6e9d3bb8ba5ae7066d5be885ae9670eba021b8c6ccb9a3a568e6b19d6e5", size = 479334, upload-time = "2026-06-01T19:37:44.776Z" },
{ url = "https://files.pythonhosted.org/packages/d1/86/c0b5e305c770053f8c3d069bb52b8196917ba91949d1962d52eb307fb0d2/aiohttp-3.14.0-cp311-cp311-win_arm64.whl", hash = "sha256:8b93618102caf12801638a01a2b478a55410ddd71bd41cfaf6f707953a49ac43", size = 450262, upload-time = "2026-06-01T19:37:46.461Z" },
{ url = "https://files.pythonhosted.org/packages/89/97/2b6889bfb6b6847520d50d95eb8c4307a45e28aaca39faf4a9454b3d1b2f/aiohttp-3.14.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:b29518c9c2ec7e373e68259206a137c7f4f5439c58baaec4b5ab3ab799850a4e", size = 750194, upload-time = "2026-06-01T19:37:48.164Z" },
{ url = "https://files.pythonhosted.org/packages/21/e2/62634b7fff918ed98c3c6b2f0e70d520f7f28846cb412d451b04354c6459/aiohttp-3.14.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:dbec68ce61b64cb73cab4d33df9433427b1713c8bcccb181dce695c1b6f8e87c", size = 506966, upload-time = "2026-06-01T19:37:50.014Z" },
{ url = "https://files.pythonhosted.org/packages/dd/fb/5ce075150828c797a5106f1c2fb26034e709d4289b9d2bf8b07f1e59fac6/aiohttp-3.14.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:3cdf534aa455593e589302990c5097aa5c92c06c4262a20da22934f9186a5fff", size = 507527, upload-time = "2026-06-01T19:37:51.96Z" },
{ url = "https://files.pythonhosted.org/packages/01/d5/405a0ae4e6b081754a3609c1c97c63a950e000a2def16046f1e736933a0e/aiohttp-3.14.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cb6c657104393b5fbff01a5f59b2023db74058a8077d94475d6c25d03882a108", size = 1762420, upload-time = "2026-06-01T19:37:53.839Z" },
{ url = "https://files.pythonhosted.org/packages/ae/1d/e05a7c896b15a6bc6fb8fc5319eb437861c2c49c34559ef928add6590315/aiohttp-3.14.0-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:46fbbec4e4fab7428d4396a3823f9320e4560aa3113b89eeebce712c27c9ed5a", size = 1733672, upload-time = "2026-06-01T19:37:55.791Z" },
{ url = "https://files.pythonhosted.org/packages/cc/22/a72f7c459e195fa41bf4f7abd1f925b91fe91f8097e51c654229ba144a33/aiohttp-3.14.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2c2c7e05dd5335b298085abf45ddf98673934c3ee1c083d0b9ea13d4186ad500", size = 1805064, upload-time = "2026-06-01T19:37:57.931Z" },
{ url = "https://files.pythonhosted.org/packages/80/50/e85bdaba0be59ca4838005ebfef4048fcdd5f35a02b07057a9a123394440/aiohttp-3.14.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3c7139100fbaae76515b73051d8f0aa3a3ff02e415eec8a8eee8e2223d9ba955", size = 1902125, upload-time = "2026-06-01T19:38:00.225Z" },
{ url = "https://files.pythonhosted.org/packages/19/d8/51de5c6b971c27bb1ef620293b8d1ca611ec78736b34b3f6ccf68e4c8785/aiohttp-3.14.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:78d6f9286a629ce52728430afe18f8ed2b6c39a1fddb3802d7244b9983910ad2", size = 1783112, upload-time = "2026-06-01T19:38:02.641Z" },
{ url = "https://files.pythonhosted.org/packages/73/ae/b4402bfde77e43dfb1b6ccff83c7b7ab63ed06b50c4754f0c5423fb374fe/aiohttp-3.14.0-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cc3c3e12cdaeb92d7dcf13db00e9f6b1956b910e47256e696df1cfa946d02159", size = 1586356, upload-time = "2026-06-01T19:38:04.637Z" },
{ url = "https://files.pythonhosted.org/packages/bc/05/750a3265ca4dc54a460bd0cb1121a8f2ce9171fce4a135fb47ea7fd594d2/aiohttp-3.14.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:4d6a998191f5ebe3b8c28463ff72bc030250008b3193c402464efadd08b5ca02", size = 1723119, upload-time = "2026-06-01T19:38:06.713Z" },
{ url = "https://files.pythonhosted.org/packages/37/01/8c0812c50b3b1b1c37b323bf170d6be8847a8f234060485b7d1e71953f60/aiohttp-3.14.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:0fc2b75ae8d169d853be2862d960be8550da6c5c65711d5476407eb3fdb006bd", size = 1757216, upload-time = "2026-06-01T19:38:08.736Z" },
{ url = "https://files.pythonhosted.org/packages/47/2a/50fb98028a26887cbe48dcc1df92a90825615bc73b5584301304090cded8/aiohttp-3.14.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:16eee56bcc72d04600bc56c1759982c2385ec0b41d3fd3521f836bf64a0957ef", size = 1770500, upload-time = "2026-06-01T19:38:11.111Z" },
{ url = "https://files.pythonhosted.org/packages/bd/32/0ffd598a2fa2b9a423daf242e700cfdabda35d6e602394ad9ae58972c1c7/aiohttp-3.14.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:5a2e7ca615c3ddc15b82687e05a624e5f5cba3f1d6c20cb81172d70ea498451e", size = 1576224, upload-time = "2026-06-01T19:38:13.391Z" },
{ url = "https://files.pythonhosted.org/packages/0b/f9/b9fc381dd9b66afb33f2634c40e229d106467be0afcabe79648631ab6712/aiohttp-3.14.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:f0b7b8bbbec3ce9467ee0ebe334622fd90624f593edd3136c567811453fc4fae", size = 1794252, upload-time = "2026-06-01T19:38:15.498Z" },
{ url = "https://files.pythonhosted.org/packages/a8/fb/05d9214c975f23225a8cd5c439325e338c7c377b315480ef3871db51f54e/aiohttp-3.14.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5ba10966d4f03dd96a14365be4b8e37c327c76f11c3ca867116966cdd9f98066", size = 1760193, upload-time = "2026-06-01T19:38:17.624Z" },
{ url = "https://files.pythonhosted.org/packages/d9/4b/02992fc4fb9e1b6673ee3f888a8e587a6447afda1f6f4aca776c148c2876/aiohttp-3.14.0-cp312-cp312-win32.whl", hash = "sha256:101df7779c80c0636014a6b2c6642acd3efb5b355d48347c9d7dfb720aee9430", size = 448650, upload-time = "2026-06-01T19:38:19.545Z" },
{ url = "https://files.pythonhosted.org/packages/39/e9/246532214c3abda518477cbaaf16d420295ad8effa5233844cbb38f299ab/aiohttp-3.14.0-cp312-cp312-win_amd64.whl", hash = "sha256:b0a5747586d4467efd1f932710b269131c9717a872dce082cd92a00c1c13123a", size = 476145, upload-time = "2026-06-01T19:38:21.505Z" },
{ url = "https://files.pythonhosted.org/packages/2b/c3/63f8c20090048915711598b0adf475b149216d736157961de06480a45b15/aiohttp-3.14.0-cp312-cp312-win_arm64.whl", hash = "sha256:5f1c5be60add78fabb4aacd13c5a348ae79d2fcbfc7fa78da8f1eb192273b370", size = 444250, upload-time = "2026-06-01T19:38:24.027Z" },
{ url = "https://files.pythonhosted.org/packages/f8/5c/b3e4ff8ad43a8afef9602c5e90285936da1beaea8b029016b793891f03c3/aiohttp-3.14.3-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:e568e14940c09955aa51f4e645b6daa18a581c5dcfcd73744dcc86a856e3ced3", size = 764250, upload-time = "2026-07-23T01:52:48.525Z" },
{ url = "https://files.pythonhosted.org/packages/0e/da/f1b384465e51449d844056b75070461da03a9a23e6c1747003695bf4172a/aiohttp-3.14.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:54cfcdee2770dac994417cbb0ee1f3eb0e7cb6b30c79bf44f2c02ff79ec5124a", size = 516281, upload-time = "2026-07-23T01:52:51.047Z" },
{ url = "https://files.pythonhosted.org/packages/b9/3f/01264f820ee2e3712a827892b1cd6ff80f3300c1fcbffbb45714a915d47a/aiohttp-3.14.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:21c016079415ed3fd676963e9793700a566d85dbbd6bfc564b9b2d209147dcc8", size = 514742, upload-time = "2026-07-23T01:52:53.779Z" },
{ url = "https://files.pythonhosted.org/packages/9e/8d/a71c6f2db52ac1ed142b133f7feddaa6b70539c3f4de24d7e226c95b794c/aiohttp-3.14.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d6088ec9894113802bddb3c09e974929aed2c7b3a8c456219b8aab4481f1a239", size = 1780613, upload-time = "2026-07-23T01:52:56.948Z" },
{ url = "https://files.pythonhosted.org/packages/a5/11/3dd9b3fb3a170f6ec9011b5291d876a6fab4086714c9e158600edf01b4fd/aiohttp-3.14.3-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:16ea7e24c309fb7c0bbd505d149abe4fe4dccfb8db911db7dbec0921bc889a6f", size = 1737688, upload-time = "2026-07-23T01:52:59.294Z" },
{ url = "https://files.pythonhosted.org/packages/6d/3e/834c26918be7d88068822b40e0db30fca50b5f4fe79104aa16a93f1d74e6/aiohttp-3.14.3-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:56f355e79f71aef2a85c80305cc915f894b170dba76de5fe84f6351939b83c06", size = 1845742, upload-time = "2026-07-23T01:53:01.641Z" },
{ url = "https://files.pythonhosted.org/packages/cc/c9/49ab8572df7d66bc13d11e31f781292badb04180dd87ba98733066c6aed7/aiohttp-3.14.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:18c441d0a8fca6de8d1f546849b9f0ab20d435993e2c5b59562b2fae6be2f929", size = 1928412, upload-time = "2026-07-23T01:53:04.018Z" },
{ url = "https://files.pythonhosted.org/packages/a5/b9/2b8f0c0ce09c87a1daf80fd483431b56b1435d3f62789bc86f572e1245de/aiohttp-3.14.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:53e7b4ce82b54a8bcc71b3b67a5cbd177ca1d7f592cbc92cd38b7349f73482db", size = 1786220, upload-time = "2026-07-23T01:53:06.481Z" },
{ url = "https://files.pythonhosted.org/packages/85/00/9c45f81de11710460edfa1dc81317b6e882703b160926c879a9d20da9fcc/aiohttp-3.14.3-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f55119f7bf25f49ed210f6096090715da24f2943c62102448915fde3c62877ce", size = 1637231, upload-time = "2026-07-23T01:53:10.258Z" },
{ url = "https://files.pythonhosted.org/packages/19/ce/967d628e910756f3539c6107cb7844a1b69440dcb3029a5ee7871b09ab63/aiohttp-3.14.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:9aa6e61fdf20105c4144e755bd586008ff450791d67b1c8146fdc15959c4d51c", size = 1753161, upload-time = "2026-07-23T01:53:13.817Z" },
{ url = "https://files.pythonhosted.org/packages/11/b2/0c3d4114f0aee4f580f5b3b4eb71b24d7a23b834ea506a4dfebe76513f35/aiohttp-3.14.3-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:ccd4893707b3e2a13e39c90d43cf80edf2e4d0457935bcc103bf2346214c3f15", size = 1756356, upload-time = "2026-07-23T01:53:16.211Z" },
{ url = "https://files.pythonhosted.org/packages/63/5d/99e7d91c82f1399d1ae2a854e080bd1493fbc31e5e959dbc4ec33dac3bec/aiohttp-3.14.3-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:b2466434105a4e03113c36ec775cc2ebe6676b62eae326fa670bb607ef788c1c", size = 1819846, upload-time = "2026-07-23T01:53:18.289Z" },
{ url = "https://files.pythonhosted.org/packages/ad/05/d5e1cb6480eeffd3f901d40a2c5e2d1e7effdc797837da3b490272699f13/aiohttp-3.14.3-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:ba59d59aba08ac02fc03b0c8983ccd5ee39a199d0552ce9e6d2b4845b34d59ae", size = 1628531, upload-time = "2026-07-23T01:53:23.86Z" },
{ url = "https://files.pythonhosted.org/packages/c9/90/b934682bcaefae18a9e04f3dff5b68522ba810906358ae5029b68110ea3b/aiohttp-3.14.3-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:ed099d105449c4f9e84f24af203cd131349d4761d8813fa7e02c32e7128cd910", size = 1832712, upload-time = "2026-07-23T01:53:27.551Z" },
{ url = "https://files.pythonhosted.org/packages/21/df/6061679faaf81fac746e7307c7adb71e858071a5d34c27583afefc64f543/aiohttp-3.14.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:152516815ef926786a0b6ae2b8f1fd2e0c71582dee0b435636865316fd4891b7", size = 1775014, upload-time = "2026-07-23T01:53:30.223Z" },
{ url = "https://files.pythonhosted.org/packages/8a/1d/f854878bbc69b88faefe924b619a34a6f59ec05fd387c77690667eaa75eb/aiohttp-3.14.3-cp311-cp311-win32.whl", hash = "sha256:a4af35c443e0b1a1bd6a8af3f3485d7fda15c142751a00f3ff8090f0b93346fa", size = 456006, upload-time = "2026-07-23T01:53:34.97Z" },
{ url = "https://files.pythonhosted.org/packages/73/0c/2af9d1674baccd1dbd47282a93d660a22e57ef6167c856deb24b4214fbab/aiohttp-3.14.3-cp311-cp311-win_amd64.whl", hash = "sha256:e1e74298bab6ee0d6e749ed4fd1901c7e604bdda32c03d787a2cc71c46d0433d", size = 481069, upload-time = "2026-07-23T01:53:39.673Z" },
{ url = "https://files.pythonhosted.org/packages/8e/76/88401ff3fc95e85c5fc38d588f36f55e61ecb64343b2bc8d69326f453cc0/aiohttp-3.14.3-cp311-cp311-win_arm64.whl", hash = "sha256:03cd2bde3d7f085b64e549c985f4bb928cad7e8ecf5323bfca320db548d81b39", size = 453021, upload-time = "2026-07-23T01:53:43.749Z" },
{ url = "https://files.pythonhosted.org/packages/18/d4/eb96299230e20acf2efae207cb8d69051f1f68e357e5ea5e479bf6fb097a/aiohttp-3.14.3-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:39aded8c7f3b935b54aab1d8d73c70ec0ee2d3ec3b943e0e86611bc150ba47f5", size = 754690, upload-time = "2026-07-23T01:53:47.332Z" },
{ url = "https://files.pythonhosted.org/packages/88/11/e7a70a209eb9a067c0d3212b518a0134e3484f5178c7533878b6b514d469/aiohttp-3.14.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:5bcb6ff3fdab1258a192679ff1a05d44f59626430aa05cd1a9d2447423599228", size = 509484, upload-time = "2026-07-23T01:53:51.159Z" },
{ url = "https://files.pythonhosted.org/packages/30/07/4bbc222cc8dbe31d4c3e8a5baad2286e4d42026ac0c570027b89afce6344/aiohttp-3.14.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:617105e2c3018ee38d0c8ce5ee3c84f621a6d8b9f723202aacaff28449ca91ee", size = 511949, upload-time = "2026-07-23T01:53:55.083Z" },
{ url = "https://files.pythonhosted.org/packages/54/b9/42e74c46b7b7c794b995bbc1f573fb48950c38b19d8600c62a6804ee2d67/aiohttp-3.14.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f631fe87a6f30df5fbe6d79640b25e4cffb38c31c7fb6f10871517b84b0f8c1a", size = 1765282, upload-time = "2026-07-23T01:53:59.662Z" },
{ url = "https://files.pythonhosted.org/packages/6b/ed/62bc4d74363ad346d518e0720363a949f63e2e23439a79eb5813d4d29bb3/aiohttp-3.14.3-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a94dbaae5ae27bd849c93570669bff91e0510f33a80805738e3de72a7be0447b", size = 1741511, upload-time = "2026-07-23T01:54:04.063Z" },
{ url = "https://files.pythonhosted.org/packages/d0/9f/181e8a8bc79e47d13c7fc4540bd7a3b729d9505609c61f392a8dd2fbfe55/aiohttp-3.14.3-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8f2f1c4c032c7cedd7d8da6f54c97b70266c6570c3108d3fdffee7188bb70529", size = 1810680, upload-time = "2026-07-23T01:54:09.882Z" },
{ url = "https://files.pythonhosted.org/packages/5c/9a/dec94d6ad694552fe3424e3f1928d7a606a5d9d9433a04e7ecdd9d38ae7f/aiohttp-3.14.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ea05e1f97ceea523942d9b2a7d7c0359d781d683d6b043f5943a602b14da4787", size = 1905646, upload-time = "2026-07-23T01:54:13.475Z" },
{ url = "https://files.pythonhosted.org/packages/52/b7/7cd31f29d6055bd711ae6e669367fba6f5ae9de463910a793e30556a8db7/aiohttp-3.14.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:543906c127fb1d929b95076db19b83fa2d46751006ff1e23b093aa5ac4d8db42", size = 1792122, upload-time = "2026-07-23T01:54:15.752Z" },
{ url = "https://files.pythonhosted.org/packages/66/73/10b1ef93afa61f4963c746257b70ced619cf31a4798671de5fdb2608501d/aiohttp-3.14.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0a5ff2dfbb9ce645fa5b8ef3e02c6c0b9cc3f6030ff863d0c51fffc50cb5541b", size = 1591127, upload-time = "2026-07-23T01:54:19.489Z" },
{ url = "https://files.pythonhosted.org/packages/49/ed/3b203fa6de1b338c14acdc06bf6ca9b043b7944f005966958c2ced932cde/aiohttp-3.14.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:041badb8f84396357c4d3ad26de6afd7a32b112f43d3c63045c0c8278cfd2043", size = 1725210, upload-time = "2026-07-23T01:54:24.129Z" },
{ url = "https://files.pythonhosted.org/packages/28/b7/1c2aab8c706436dcc28598452488ac9cd7c409da815237c28c27d58993e6/aiohttp-3.14.3-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:530125ee1163c4219af35dc3aa1206e541e7b31b6efc1a3f93b70a136f65d427", size = 1764848, upload-time = "2026-07-23T01:54:27.973Z" },
{ url = "https://files.pythonhosted.org/packages/54/50/94c28f08b131c4bf10984ea2c7a536c9920608bb2d6e7f95642c30cc87b7/aiohttp-3.14.3-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:c8653fd547c93a61aadc612007790f5555cdd18946fa48cf45e26d8ea4ea473d", size = 1777102, upload-time = "2026-07-23T01:54:31.775Z" },
{ url = "https://files.pythonhosted.org/packages/13/d4/e7d09ba7d345fb2d74440fd2fa033c5e079fac05552927705986f41a364f/aiohttp-3.14.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:89176250f686cb9853c0fb7ead90e639e915b84a6f43eedc2a4e7ec21f1037f0", size = 1580205, upload-time = "2026-07-23T01:54:34.518Z" },
{ url = "https://files.pythonhosted.org/packages/a3/84/072a91d68e1e1eb587985b54baab94221277f877e8ef274fc213a0ceae28/aiohttp-3.14.3-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:3a26434dafe408229ff3403458ca58de24fb51936504decac49ce6755f77e59d", size = 1797219, upload-time = "2026-07-23T01:54:36.995Z" },
{ url = "https://files.pythonhosted.org/packages/e0/eb/aad34e897e668424d6e995da5dff8a4a09af93363d3392488772957a63aa/aiohttp-3.14.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:d1558173930a5a8d3069cee5c92fc91c87c4dbcb099debbb3622053717145a19", size = 1768629, upload-time = "2026-07-23T01:54:40.103Z" },
{ url = "https://files.pythonhosted.org/packages/b6/2b/6bb88ddba0fecd9122aa3ebcad25996cf6c083a4a7040dbb3a4f97972af6/aiohttp-3.14.3-cp312-cp312-win32.whl", hash = "sha256:16100ad3ab8d649fdfbee87602d9d2dcdca9df0b9eda8a1b5fdc0d41f96da559", size = 451481, upload-time = "2026-07-23T01:54:42.547Z" },
{ url = "https://files.pythonhosted.org/packages/76/9b/f2f8f108da17ecef2cc3efc424e8b7ad3782b1a8360f7b8eae8ced84f6ea/aiohttp-3.14.3-cp312-cp312-win_amd64.whl", hash = "sha256:33a2d7c28d33797a2e99923dffa63f83d908a19b6bf26cfe80fa790aa5e1a75a", size = 476845, upload-time = "2026-07-23T01:54:44.853Z" },
{ url = "https://files.pythonhosted.org/packages/3e/44/28dac80a8941b604f4da10ce21097614ca1bf905ce93dca28d8d7de9c1e7/aiohttp-3.14.3-cp312-cp312-win_arm64.whl", hash = "sha256:362a3fd481769cac1a824514bcd86fda51c65e8fe6e051099e008fddde6db17c", size = 448050, upload-time = "2026-07-23T01:54:47.087Z" },
]
[[package]]
@@ -853,7 +860,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 +869,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 +2095,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 +2150,45 @@ wheels = [
[[package]]
name = "cryptography"
version = "46.0.7"
version = "50.0.0"
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/de/41/6cbdcf9142d00fe82836fbb51e503e58088575cf7a0fe1dbff6695bf0840/cryptography-50.0.0.tar.gz", hash = "sha256:eeac2acb5a20ed25e0ad6d1df9891a520b78b404266b6d11778f25d5d691a6c9", size = 880201, upload-time = "2026-07-31T14:25:10.11Z" }
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/c5/5c/59086b4aac5e879d38ddbcf74e4be7ade89cebc3eb199a55da998c3bb46a/cryptography-50.0.0-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:031e2d5dd4bb9caa3ca9c82e5a197fd8ae680232cee62603d1a813f3f07e3d03", size = 4001252, upload-time = "2026-07-31T14:23:33.331Z" },
{ url = "https://files.pythonhosted.org/packages/57/ef/8f2df13c7216bcad3e1c74e07f6e193d93e998e114f524a53877c9af27ad/cryptography-50.0.0-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:fd9192b7b70c573d7f214eb1ae35e00d359f6f5e4b27c7e21e30de1fc6204645", size = 4719554, upload-time = "2026-07-31T14:23:35.611Z" },
{ url = "https://files.pythonhosted.org/packages/d9/41/029086c34d91052fc3b88bcc8056f709a7c915c7a23b235a54eb800b1c97/cryptography-50.0.0-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:06a32a980526a6ab9a4b9bf8f7385800791e2bb960903cb6b530e4817509a3b7", size = 4702130, upload-time = "2026-07-31T14:23:37.635Z" },
{ url = "https://files.pythonhosted.org/packages/7d/ff/b6ce0954962e7f7b969f850a883744197bb3910bdfd7b6da162eab7d9f68/cryptography-50.0.0-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:a1b30560f2acc95aa8b2e06e716a13dbfc97314747b80d9707e307f77b40d6b3", size = 4725244, upload-time = "2026-07-31T14:23:39.471Z" },
{ url = "https://files.pythonhosted.org/packages/06/1e/63a1027cb7fec360a182208e1b7767d5aa1fe57be3d6aa856e69a321edc0/cryptography-50.0.0-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:8d89f3976b10b4ce31118de72329025f70d2c6ead14a8217c5514dd2c6d5a78f", size = 5342265, upload-time = "2026-07-31T14:23:41.286Z" },
{ url = "https://files.pythonhosted.org/packages/6b/72/a1116d683a6d7ece94590013882515de087edf9ef0e6292aae615a44df73/cryptography-50.0.0-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:b42a28c1844fd9de8f3f7d540e36b66f3a9c83fceac7170ebc7a6a19edd9dcae", size = 4734609, upload-time = "2026-07-31T14:23:43.139Z" },
{ url = "https://files.pythonhosted.org/packages/15/37/36a9c479bbe49acea2636c7fd3360d20f7b7e079c300352011c44850b181/cryptography-50.0.0-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:900131fafd8aead39ac7dd3a7e833be754c17a95cfd91221636949fe4eb0aa8a", size = 4356517, upload-time = "2026-07-31T14:23:44.939Z" },
{ url = "https://files.pythonhosted.org/packages/32/98/8a151d64367204cbc63ec65d37502f1d9c53cf4bfc6ec3c532614dbec60d/cryptography-50.0.0-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:07949c449a1abcf60d1ee6e88956d89404c7df3c8258f46589e912988e551987", size = 4724529, upload-time = "2026-07-31T14:23:46.93Z" },
{ url = "https://files.pythonhosted.org/packages/22/f6/ec13b470172126464a86bf54d2294a46d29837fc51ba3e45d4047946fb5e/cryptography-50.0.0-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:f89831ef99dd7dd169ab06d63a831adb9e20a87aac6d380266bbda5823349169", size = 5299852, upload-time = "2026-07-31T14:23:48.851Z" },
{ url = "https://files.pythonhosted.org/packages/da/3a/f05e32c99d440c9bb891ea0e36c9091891e36be5a9a87ab2ee6ea20729f6/cryptography-50.0.0-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:82148ec5bddac30b51a5b3c1945075f896fa022cb93f8e4a01e9f6ee95292c5f", size = 4734462, upload-time = "2026-07-31T14:23:50.861Z" },
{ url = "https://files.pythonhosted.org/packages/ca/dc/bd72b26be8953f80625f63151efd38eee71c76ca6cf591c08ff34615a79e/cryptography-50.0.0-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:1489e263a8048bb8b6a8bac662eb2d402ea5d2b7b4699b72f385f1e2772db105", size = 4852708, upload-time = "2026-07-31T14:23:52.715Z" },
{ url = "https://files.pythonhosted.org/packages/27/20/c930314a2ab476d15dec966ec87e2e9637bb02b06106b12c0396c57bb603/cryptography-50.0.0-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:7cec5b856506da6defb290f30c9ee687d5f5e8cb0bd3f6459dde43b0b4fa40ef", size = 5004179, upload-time = "2026-07-31T14:23:54.887Z" },
{ url = "https://files.pythonhosted.org/packages/32/2e/c9db68a0c4bfa28e310707527c0ee3a2bd254104d2e02e68f368e197aa4c/cryptography-50.0.0-cp311-abi3-win_amd64.whl", hash = "sha256:bd1c592e4d5974f0d08d4888e432157adba757c66da0246918e43677fafa2d30", size = 3840395, upload-time = "2026-07-31T14:23:56.677Z" },
{ url = "https://files.pythonhosted.org/packages/03/37/73d005be173aff344af30e9fd2a576575cb2391a7101d9cd3842e1fa8cce/cryptography-50.0.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:ccdc4a71a4dabae05de219404f9f4abc38e3b58422177ff93d0da05967dafa07", size = 4036009, upload-time = "2026-07-31T14:24:24.122Z" },
{ url = "https://files.pythonhosted.org/packages/ff/c6/7a6202a534e32103a285b7834a120869557fe198d51d7cfe59754c8bda9c/cryptography-50.0.0-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:910e1d2668e7de9648f2bcee30e180db2a6b15c30f887d7c4c93ddf96e3992e3", size = 4745252, upload-time = "2026-07-31T14:24:26.118Z" },
{ url = "https://files.pythonhosted.org/packages/85/4f/0fa8c2f4428198f15d9ff8d63400e27afbf94ce833f6108da1eb3753f945/cryptography-50.0.0-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a91296cb61e8df6f86d0c19cc4068228da256bf59bf86049fbd821084565327f", size = 4728939, upload-time = "2026-07-31T14:24:27.994Z" },
{ url = "https://files.pythonhosted.org/packages/d1/63/54dd723490ba2dc09b299682c10b38db38f159728bcaae8c591b8af2f22d/cryptography-50.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:e722f16708d854fe924790e051061f6704a472c3bac347b6fd88033ea8dd0dc5", size = 4748483, upload-time = "2026-07-31T14:24:30.254Z" },
{ url = "https://files.pythonhosted.org/packages/1d/dd/7c77d26285cc7f6991efce64a0f5b4f9383bfa5dd8c5033003eaf7db4cdb/cryptography-50.0.0-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:d764dcf130c428ef66786f866dd750f53182bc608813489915e9fc106bb0c82f", size = 5367599, upload-time = "2026-07-31T14:24:32.457Z" },
{ url = "https://files.pythonhosted.org/packages/46/c9/f60aed34c013f317f92817b6c171c2d22a78270fa41109bd4b08af26b194/cryptography-50.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:105110f43a471dbd0060b9c9516cb8a6a79233631a04cc2ba16f28323ac6e025", size = 4762647, upload-time = "2026-07-31T14:24:34.599Z" },
{ url = "https://files.pythonhosted.org/packages/be/f3/f9a0173b139372c3a48ed98154b45cc6b9de17c789d5ab552e621c293609/cryptography-50.0.0-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:828743d939e9629bc267b8e2d08d8bb67cd4319c771a33d4b18b22dd8fb7440a", size = 4385197, upload-time = "2026-07-31T14:24:36.647Z" },
{ url = "https://files.pythonhosted.org/packages/d8/36/83bb81f6e569bc38e1e4a7bc80f29b46bb9601920bc455fc8e888f5d5742/cryptography-50.0.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:2a8183b489dc1f7f80f135780fadc1108f14b31b8a40411c7a5b17425f65f28b", size = 4748095, upload-time = "2026-07-31T14:24:39.493Z" },
{ url = "https://files.pythonhosted.org/packages/6b/16/d3008eff98c764979865834c3d386d4fd041b5f52e7f34fc29ac1a5eb515/cryptography-50.0.0-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:6e7d61120573a7f2cd94cc095f9e81f6967c61ccdf194285aa143ecec8e0b708", size = 5325948, upload-time = "2026-07-31T14:24:41.556Z" },
{ url = "https://files.pythonhosted.org/packages/9c/f8/d97f9603efda3888187bfdb893f26c41be4735c10631d05d284ee6b047c4/cryptography-50.0.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:37fdb0d0111f1e2ff07139dfb79f1b49531f8e213c46f1163dd7642979b58c47", size = 4762400, upload-time = "2026-07-31T14:24:43.636Z" },
{ url = "https://files.pythonhosted.org/packages/64/a2/4615c8f7d81a00b1d6e6afe19f694e1543582349fb5f4076f6cb5dc36485/cryptography-50.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:c87f62a3d3b9888ed0fdde100ec06aa61ca9cd44bad9057d1dff9a516b5f5bb9", size = 4878208, upload-time = "2026-07-31T14:24:45.522Z" },
{ url = "https://files.pythonhosted.org/packages/d2/1a/efcfb02f91407149a0dacffffab791f7e19bf6385f63b3666dc8b5e5c9c8/cryptography-50.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:65c2c3add92b45fd0709db8594536aea39c2a67af0e27ffcf049c498501140b7", size = 5037050, upload-time = "2026-07-31T14:24:47.697Z" },
{ url = "https://files.pythonhosted.org/packages/57/30/4a22984d4f1bdfb8c054f07a92bc176b97a3134cc1d6c4b3bffb1f3688b4/cryptography-50.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:d24fead1d4d076e1bfb006dcec392074a3cd8d7b4fc8a595aa64073b2b7a96ba", size = 3874135, upload-time = "2026-07-31T14:24:50.085Z" },
{ url = "https://files.pythonhosted.org/packages/9d/3e/e54cde8c01631a5a8226ccd617eab9e57fd5cfdad90f1a9e6bb570794631/cryptography-50.0.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:5e34edd123674534acd70147f0ca331eaa2c74e6325fb2028c886aa26ba0b68c", size = 3963170, upload-time = "2026-07-31T14:24:51.968Z" },
{ url = "https://files.pythonhosted.org/packages/01/b6/0b9e125e90f3d2dcf599a218a899cda7326a3158cfa258723f0b398b08f6/cryptography-50.0.0-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:8eb5e1172eb569ea8a872796576e6a67c276351728b6455d5beb01242b027c6a", size = 4692441, upload-time = "2026-07-31T14:24:53.743Z" },
{ url = "https://files.pythonhosted.org/packages/53/c9/a5151588710785a96d7bc4de27d4cd62f263bbbcb203cfe29df537eb6505/cryptography-50.0.0-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:910d11e1a385c654bf738bf3e6b8e6ed5de0f5610fcae2be9e5b398d8081d20e", size = 4699810, upload-time = "2026-07-31T14:24:55.746Z" },
{ url = "https://files.pythonhosted.org/packages/c7/1a/15b92b25eb6ce3089cd49377ae990a0f3ad485a510f968aed1f19dbdcdf2/cryptography-50.0.0-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:62598a8a57f815db4c6259a4e97d857dab56697e7de8e8ab02352ab74da1995d", size = 4691924, upload-time = "2026-07-31T14:24:58.082Z" },
{ url = "https://files.pythonhosted.org/packages/62/15/219075012ab13e8905f3cd572204f4acb4b111df787104346b9bc0cea789/cryptography-50.0.0-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:07479a1cb08219ab719147e742e76090c9c773321959bb94946fffdd397a6437", size = 4699593, upload-time = "2026-07-31T14:24:59.951Z" },
{ url = "https://files.pythonhosted.org/packages/8e/b5/c2c5fce26f0ee40d21bafe7f191d29a34b35a65ac4fe8a1191d1983612e9/cryptography-50.0.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:c99c003e088647b8a5b7c145d6f78c335f6348332b62e142d411c4b63d1460b9", size = 3813796, upload-time = "2026-07-31T14:25:02.298Z" },
]
[[package]]
@@ -2166,15 +2202,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 +3365,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]]
@@ -3357,6 +3395,134 @@ http2 = [
{ name = "h2" },
]
[[package]]
name = "huaweicloudsdkcore"
version = "3.1.204"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "certifi" },
{ name = "defusedxml" },
{ name = "pyasn1" },
{ name = "pymongo" },
{ name = "pyyaml" },
{ name = "requests-toolbelt" },
{ name = "simplejson" },
{ name = "six" },
]
wheels = [
{ url = "https://files.pythonhosted.org/packages/e4/f5/65e90764ea3bbfef50fb68cd5e12340acf1f51e9276b11745fbf5feb7e0e/huaweicloudsdkcore-3.1.204-py3-none-any.whl", hash = "sha256:9ae17744795ebdc8ce9291373a3a27bf72e90aa98677cfce0ea9394376875a95", size = 69578, upload-time = "2026-07-09T09:01:59.715Z" },
]
[[package]]
name = "huaweicloudsdkcts"
version = "3.1.204"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "huaweicloudsdkcore" },
]
wheels = [
{ url = "https://files.pythonhosted.org/packages/58/32/d06328e35375d4aa606719a27856cdf57b1f7fb0c49d4dfd22a9609dba19/huaweicloudsdkcts-3.1.204-py3-none-any.whl", hash = "sha256:9def561aa784a6ee13b46bfc96888cd1df5bfc42f8a89e60b42c91c608bf6d60", size = 121768, upload-time = "2026-07-09T09:02:08.16Z" },
]
[[package]]
name = "huaweicloudsdkecs"
version = "3.1.204"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "huaweicloudsdkcore" },
]
wheels = [
{ url = "https://files.pythonhosted.org/packages/88/66/f8e4a3b9ca70d3ea79c4d200f928ed9ffdf4910ac01be4864967408c8f18/huaweicloudsdkecs-3.1.204-py3-none-any.whl", hash = "sha256:dc5715d782c0260b901c793d009d5e632257acb04257b6f2c6631e415c589343", size = 765699, upload-time = "2026-07-09T09:02:39.272Z" },
]
[[package]]
name = "huaweicloudsdkelb"
version = "3.1.204"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "huaweicloudsdkcore" },
]
wheels = [
{ url = "https://files.pythonhosted.org/packages/34/4b/9bdc7e2066419d967e9b812cfacfb9c4996a8e977dc39853309a3c1ac9e2/huaweicloudsdkelb-3.1.204-py3-none-any.whl", hash = "sha256:620247c2b2a7f20e7da8b18fe9c64e29972055f015bc35270fb5b43243dc4830", size = 1292397, upload-time = "2026-07-09T09:02:45.656Z" },
]
[[package]]
name = "huaweicloudsdkevs"
version = "3.1.204"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "huaweicloudsdkcore" },
]
wheels = [
{ url = "https://files.pythonhosted.org/packages/f7/cf/531dc55fd9d0f3bbd3eef24c7e4d78c6a1ba8eb80fa506e3574d72bcc98a/huaweicloudsdkevs-3.1.204-py3-none-any.whl", hash = "sha256:9118ac4c576e54aa7eaa926949e2b6824c5f038a2274b51d9a304d37fc0d7e2f", size = 251404, upload-time = "2026-07-09T09:02:50.05Z" },
]
[[package]]
name = "huaweicloudsdkiam"
version = "3.1.204"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "huaweicloudsdkcore" },
]
wheels = [
{ url = "https://files.pythonhosted.org/packages/f7/9a/7da0fbe9b83bc7a7f6d586366b81e829ed355a57419d2184b7dd51f8c2a3/huaweicloudsdkiam-3.1.204-py3-none-any.whl", hash = "sha256:0021e204f81ceef2640017e517adb72ba56c9ced03f071a0265b10bc9759badf", size = 1251350, upload-time = "2026-07-09T09:03:05.467Z" },
]
[[package]]
name = "huaweicloudsdkkms"
version = "3.1.204"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "huaweicloudsdkcore" },
]
wheels = [
{ url = "https://files.pythonhosted.org/packages/28/3a/7392617d585cb2005f7d9ade0b0e0e493a7a88daf56e8dc39e4e219cc5d0/huaweicloudsdkkms-3.1.204-py3-none-any.whl", hash = "sha256:378986f33113ce99f445ef318d1c7dda89e361d16c008e5ef9793981d8385376", size = 275690, upload-time = "2026-07-09T09:03:29.833Z" },
]
[[package]]
name = "huaweicloudsdkobs"
version = "3.1.204"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "huaweicloudsdkcore" },
]
wheels = [
{ url = "https://files.pythonhosted.org/packages/af/49/28a09e1e33d1c039be22ee4171efaa739351653c7aa88d3a2f7a78d90217/huaweicloudsdkobs-3.1.204-py3-none-any.whl", hash = "sha256:8c5830fa30293185964d98e524887fc510c8e17ca2fadb4563dad10910f37b13", size = 235360, upload-time = "2026-07-09T09:03:52.171Z" },
]
[[package]]
name = "huaweicloudsdkrds"
version = "3.1.204"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "huaweicloudsdkcore" },
]
wheels = [
{ url = "https://files.pythonhosted.org/packages/18/7d/721f162c46e3de604a73674223bf6c6bc6cf7ade25b3751a71288f4dd122/huaweicloudsdkrds-3.1.204-py3-none-any.whl", hash = "sha256:a790b5b3c457a608e5679c101f463b4d037dd9a8a66f6e46144a9e5a4b37780f", size = 1626906, upload-time = "2026-07-09T09:04:06.936Z" },
]
[[package]]
name = "huaweicloudsdkvpc"
version = "3.1.204"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "huaweicloudsdkcore" },
]
wheels = [
{ url = "https://files.pythonhosted.org/packages/de/b5/4baa27c3a275ea92806068e35e06f249a30add8dd57c777bb45841f63406/huaweicloudsdkvpc-3.1.204-py3-none-any.whl", hash = "sha256:c57d6b6d2f70deca91e86f7956b33fc9ac4991b431f0d608c3632231119f8970", size = 1124332, upload-time = "2026-07-09T09:04:39.797Z" },
]
[[package]]
name = "huaweicloudsdkwaf"
version = "3.1.204"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "huaweicloudsdkcore" },
]
wheels = [
{ url = "https://files.pythonhosted.org/packages/9e/21/01590dce200be487756451f5e9efb99da7810688062d177e4b58d6062465/huaweicloudsdkwaf-3.1.204-py3-none-any.whl", hash = "sha256:b2355276e0029808f45e2d1bd3eb14b37d2da9417e61e642ffe0e2b748ca8283", size = 1337762, upload-time = "2026-07-09T09:04:43.688Z" },
]
[[package]]
name = "humanfriendly"
version = "10.0"
@@ -3920,21 +4086,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" },
@@ -3943,14 +4109,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"] },
@@ -3958,57 +4124,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]]
@@ -4260,20 +4426,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]]
@@ -4467,39 +4635,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]]
@@ -4673,8 +4835,8 @@ wheels = [
[[package]]
name = "prowler"
version = "5.35.0"
source = { git = "https://github.com/prowler-cloud/prowler.git?rev=master#f5ea116763aeffede9f399c8934fc280eaccd315" }
version = "5.38.0"
source = { git = "https://github.com/prowler-cloud/prowler.git?rev=v5.38#226504982b1d1fb9887f00f2773aafc9adf2a2a8" }
dependencies = [
{ name = "alibabacloud-actiontrail20200706" },
{ name = "alibabacloud-credentials" },
@@ -4729,6 +4891,17 @@ dependencies = [
{ name = "google-api-python-client" },
{ name = "google-auth-httplib2" },
{ name = "h2" },
{ name = "huaweicloudsdkcore" },
{ name = "huaweicloudsdkcts" },
{ name = "huaweicloudsdkecs" },
{ name = "huaweicloudsdkelb" },
{ name = "huaweicloudsdkevs" },
{ name = "huaweicloudsdkiam" },
{ name = "huaweicloudsdkkms" },
{ name = "huaweicloudsdkobs" },
{ name = "huaweicloudsdkrds" },
{ name = "huaweicloudsdkvpc" },
{ name = "huaweicloudsdkwaf" },
{ name = "jsonschema" },
{ name = "kingfisher-bin" },
{ name = "kubernetes" },
@@ -4762,7 +4935,7 @@ dependencies = [
[[package]]
name = "prowler-api"
version = "1.38.0"
version = "1.39.0"
source = { virtual = "." }
dependencies = [
{ name = "cartography" },
@@ -4862,7 +5035,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=master" },
{ name = "prowler", git = "https://github.com/prowler-cloud/prowler.git?rev=v5.38" },
{ name = "psycopg2-binary", specifier = "==2.9.9" },
{ name = "pytest-celery", extras = ["redis"], specifier = "==1.3.0" },
{ name = "reportlab", specifier = "==4.4.10" },
@@ -4978,25 +5151,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]]
@@ -5187,6 +5359,37 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/2e/ff/7f52c1461d8ceaefa989d2700a027f84427879bb7571145bbffdec5d5f4a/pylint-3.2.5-py3-none-any.whl", hash = "sha256:32cd6c042b5004b8e857d727708720c54a676d1e22917cf1a2df9b4d4868abd6", size = 519603, upload-time = "2024-06-28T13:10:23.526Z" },
]
[[package]]
name = "pymongo"
version = "4.15.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "dnspython" },
]
sdist = { url = "https://files.pythonhosted.org/packages/22/f5/c0c6732fbd358b75a07e17d7e588fd23d481b9812ca96ceeff90bbf879fc/pymongo-4.15.1.tar.gz", hash = "sha256:b9f379a4333dc3779a6bf7adfd077d4387404ed1561472743486a9c58286f705", size = 2470613, upload-time = "2025-09-16T16:39:47.24Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c9/da/89066930a70b4299844f1155fc23baaa7e30e77c8a0cbf62a2ae06ee34a5/pymongo-4.15.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:363445cc0e899b9e55ac9904a868c8a16a6c81f71c48dbadfd78c98e0b54de27", size = 865410, upload-time = "2025-09-16T16:38:16.279Z" },
{ url = "https://files.pythonhosted.org/packages/99/8f/a1d0402d52e5ebd14283718abefdc0c16f308cf10bee56cdff04b1f5119b/pymongo-4.15.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:da0a13f345f4b101776dbab92cec66f0b75015df0b007b47bd73bfd0305cc56a", size = 865695, upload-time = "2025-09-16T16:38:18.015Z" },
{ url = "https://files.pythonhosted.org/packages/53/38/d1ef69028923f86fd00638d9eb16400d4e60a89eabd2011fe631fd3186cf/pymongo-4.15.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9481a492851e432122a83755d4e69c06aeb087bbf8370bac9f96d112ac1303fd", size = 1434758, upload-time = "2025-09-16T16:38:20.141Z" },
{ url = "https://files.pythonhosted.org/packages/b0/eb/a8d5dff748a2dd333610b2e4c8120b623e38ea2b5e30ad190d0ce2803840/pymongo-4.15.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:625dec3e9cd7c3d336285a20728c01bfc56d37230a99ec537a6a8625af783a43", size = 1485716, upload-time = "2025-09-16T16:38:21.607Z" },
{ url = "https://files.pythonhosted.org/packages/c4/d4/17ba457a828b733182ddc01a202872fef3006eed6b54450b20dc95a2f77d/pymongo-4.15.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:26a31af455bffcc64537a7f67e2f84833a57855a82d05a085a1030c471138990", size = 1460160, upload-time = "2025-09-16T16:38:23.509Z" },
{ url = "https://files.pythonhosted.org/packages/c3/25/42b8662c09f5ca9c81d18d160f48e58842e0fa4c314ea02613c5e5d54542/pymongo-4.15.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ea4415970d2a074d5890696af10e174d84cb735f1fa7673020c7538431e1cb6e", size = 1439284, upload-time = "2025-09-16T16:38:25.248Z" },
{ url = "https://files.pythonhosted.org/packages/b3/bb/46b9d978161828eb91973bd441a3f05f73c789203e976332a8de2832d5db/pymongo-4.15.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:51ee050a2e026e2b224d2ed382830194be20a81c78e1ef98f467e469071df3ac", size = 1407933, upload-time = "2025-09-16T16:38:27.045Z" },
{ url = "https://files.pythonhosted.org/packages/4b/55/bd5af98f675001f4b06f7314b3918e45809424a7ad3510f823f6703cd8f2/pymongo-4.15.1-cp311-cp311-win32.whl", hash = "sha256:9aef07d33839f6429dc24f2ef36e4ec906979cb4f628c57a1c2676cc66625711", size = 844328, upload-time = "2025-09-16T16:38:28.513Z" },
{ url = "https://files.pythonhosted.org/packages/c3/78/90989a290dd458ed43a8a04fa561ac9c7b3391f395cdacd42e21f0f22ce4/pymongo-4.15.1-cp311-cp311-win_amd64.whl", hash = "sha256:8ea6e5ff4d6747e7b64966629a964db3089e9c1e0206d8f9cc8720c90f5a7af1", size = 858951, upload-time = "2025-09-16T16:38:30.074Z" },
{ url = "https://files.pythonhosted.org/packages/de/bb/d4d23f06e166cd773f2324cff73841a62d78a1ad16fb799cf7c5490ce32c/pymongo-4.15.1-cp311-cp311-win_arm64.whl", hash = "sha256:bb783d9001b464a6ef3ee76c30ebbb6f977caee7bbc3a9bb1bd2ff596e818c46", size = 848290, upload-time = "2025-09-16T16:38:31.741Z" },
{ url = "https://files.pythonhosted.org/packages/7e/31/bc4525312083706a59fffe6e8de868054472308230fdee8db0c452c2b831/pymongo-4.15.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:bab357c5ff36ba2340dfc94f3338ef399032089d35c3d257ce0c48630b7848b2", size = 920261, upload-time = "2025-09-16T16:38:33.614Z" },
{ url = "https://files.pythonhosted.org/packages/ae/55/4d99aec625494f21151b8b31e12e06b8ccd3b9dcff609b0dd1acf9bbbc0e/pymongo-4.15.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:46d1af3eb2c274f07815372b5a68f99ecd48750e8ab54d5c3ff36a280fb41c8e", size = 919956, upload-time = "2025-09-16T16:38:35.121Z" },
{ url = "https://files.pythonhosted.org/packages/be/60/8f1afa41521df950e13f6490ecdef48155fc63b78f926e7649045e07afd1/pymongo-4.15.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7dc31357379318881186213dc5fc49b62601c955504f65c8e72032b5048950a1", size = 1698596, upload-time = "2025-09-16T16:38:36.586Z" },
{ url = "https://files.pythonhosted.org/packages/bc/3f/e48d50ee8d6aa0a4cda7889dd73076ec2ab79a232716a5eb0b9df070ffcf/pymongo-4.15.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:12140d29da1ecbaefee2a9e65433ef15d6c2c38f97bc6dab0ff246a96f9d20cd", size = 1762833, upload-time = "2025-09-16T16:38:38.09Z" },
{ url = "https://files.pythonhosted.org/packages/63/87/db976859efc617f608754e051e1468459d9a818fe1ad5d0862e8af57720b/pymongo-4.15.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cf193d2dcd91fa1d1dfa1fd036a3b54f792915a4842d323c0548d23d30461b59", size = 1731875, upload-time = "2025-09-16T16:38:39.742Z" },
{ url = "https://files.pythonhosted.org/packages/18/59/3643ad52a5064ad3ef8c32910de6da28eb658234c25f2db5366f16bffbfb/pymongo-4.15.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a2c0bdcf4d57e4861ed323ba430b585ad98c010a83e46cb8aa3b29c248a82be1", size = 1701853, upload-time = "2025-09-16T16:38:41.333Z" },
{ url = "https://files.pythonhosted.org/packages/d8/96/441c190823f855fc6445ea574b39dca41156acf723c5e6a69ee718421700/pymongo-4.15.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:43fcfc19446e0706bbfe86f683a477d1e699b02369dd9c114ec17c7182d1fe2b", size = 1660978, upload-time = "2025-09-16T16:38:42.877Z" },
{ url = "https://files.pythonhosted.org/packages/47/49/bd7e783fb78aaf9bdaa3f88cc238449be5bc5546e930ec98845ef235f809/pymongo-4.15.1-cp312-cp312-win32.whl", hash = "sha256:e5fedea0e7b3747da836cd5f88b0fa3e2ec5a394371f9b6a6b15927cfeb5455d", size = 891175, upload-time = "2025-09-16T16:38:44.658Z" },
{ url = "https://files.pythonhosted.org/packages/2e/28/7de5858bdeaa07ea4b277f9eb06123ea358003659fe55e72e4e7c898b321/pymongo-4.15.1-cp312-cp312-win_amd64.whl", hash = "sha256:330a17c1c89e2c3bf03ed391108f928d5881298c17692199d3e0cdf097a20082", size = 910619, upload-time = "2025-09-16T16:38:46.124Z" },
{ url = "https://files.pythonhosted.org/packages/17/87/c39f4f8415e7c65f8b66413f53a9272211ff7dfe78a5128b27027bf88864/pymongo-4.15.1-cp312-cp312-win_arm64.whl", hash = "sha256:756b7a2a80ec3dd5b89cd62e9d13c573afd456452a53d05663e8ad0c5ff6632b", size = 896229, upload-time = "2025-09-16T16:38:48.563Z" },
]
[[package]]
name = "pymsalruntime"
version = "0.18.1"
@@ -5223,15 +5426,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]]
@@ -5556,6 +5759,18 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/3b/5d/63d4ae3b9daea098d5d6f5da83984853c1bbacd5dc826764b249fe119d24/requests_oauthlib-2.0.0-py2.py3-none-any.whl", hash = "sha256:7dd8a5c40426b779b0868c404bdef9768deccf22749cde15852df527e6269b36", size = 24179, upload-time = "2024-03-22T20:32:28.055Z" },
]
[[package]]
name = "requests-toolbelt"
version = "1.0.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "requests" },
]
sdist = { url = "https://files.pythonhosted.org/packages/f3/61/d7545dafb7ac2230c70d38d31cbfe4cc64f7144dc41f6e4e4b78ecd9f5bb/requests-toolbelt-1.0.0.tar.gz", hash = "sha256:7681a0a3d047012b5bdc0ee37d7f8f07ebe76ab08caeccfc3921ce23c88d5bc6", size = 206888, upload-time = "2023-05-01T04:11:33.229Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/3f/51/d4db610ef29373b879047326cbf6fa98b6c1969d6f6dc423279de2b1be2c/requests_toolbelt-1.0.0-py2.py3-none-any.whl", hash = "sha256:cccfdd665f0a24fcf4726e690f65639d272bb0637b9b92dfd91a5568ccf6bd06", size = 54481, upload-time = "2023-05-01T04:11:28.427Z" },
]
[[package]]
name = "requestsexceptions"
version = "1.4.0"
@@ -5774,6 +5989,37 @@ dependencies = [
]
sdist = { url = "https://files.pythonhosted.org/packages/c5/06/c6dcc975a1e7d89bc764fd271da8138b318e18080b48e7f1acd2ab63df28/shodan-1.31.0.tar.gz", hash = "sha256:c73275386ea02390e196c35c660706a28dd4d537c5a21eb387ab6236fac251f6", size = 57939, upload-time = "2023-12-17T01:42:02.426Z" }
[[package]]
name = "simplejson"
version = "4.1.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/0e/2a/54837395a3487c725669428d513293612a48d82b95a0642c936932e5d898/simplejson-4.1.1.tar.gz", hash = "sha256:c08eb9f7a90f77ae470e19a07472e9a79ebc0d1c2315d86a72767665bd5ba79f", size = 118860, upload-time = "2026-04-24T19:24:59.819Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/1e/25/39013ffe279d90093ec1c848565b3683c586906c10fa55d9000ec29d046b/simplejson-4.1.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:2867c64d92abd1992c15666fae198203093f593e43d6b81adf176bae530d493a", size = 111538, upload-time = "2026-04-24T19:22:49.051Z" },
{ url = "https://files.pythonhosted.org/packages/f2/ae/2c272971c8a87e2539c54a98eb6ff037bee1e2e93943c3986cf7500a4f3a/simplejson-4.1.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:4c47c46e16c8ea9e4850061e6ed5aa2b9cd2074cb2274bfd9c138cba15ce7453", size = 90594, upload-time = "2026-04-24T19:22:50.408Z" },
{ url = "https://files.pythonhosted.org/packages/4e/a2/6eebfb99dedc139f549200f61ade6d1890ac5707c5d427bdfa6fe39c9313/simplejson-4.1.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e294e33dbf316a9bbdd4030d46503c9b0f19470ae7ad6af5bae6c426bc2e869f", size = 90718, upload-time = "2026-04-24T19:22:51.694Z" },
{ url = "https://files.pythonhosted.org/packages/80/7e/c9e6c0c4ad8415e64dad0c47f619b556b02680a41631b4dbc281d55dc54d/simplejson-4.1.1-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:7ce252b28fddbdd83db5bd7d93dad2a8a591d7ada098afec9c1b23d6b722a7a4", size = 180901, upload-time = "2026-04-24T19:22:53.025Z" },
{ url = "https://files.pythonhosted.org/packages/34/09/69e331e3994b1ed9be6ce9ace4ade704e7ed503edf869929ca7bb404eda8/simplejson-4.1.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4c44ef6b02a4eb67ed17a72342341792149b3ff46f15426c26e970e49addf327", size = 178133, upload-time = "2026-04-24T19:22:54.574Z" },
{ url = "https://files.pythonhosted.org/packages/5d/40/ed806f24afef295c1032448f5ff6f6f2979392d5645ddb9f4fed7f38194d/simplejson-4.1.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:82bfca2b85a34178c25829c703f0a9e9f113a5af7539285bd3efb583a0bf1ba3", size = 188155, upload-time = "2026-04-24T19:22:56.044Z" },
{ url = "https://files.pythonhosted.org/packages/38/94/8d6f515b827b0f7881a49c8c1ac6920b7ae9428939ef04238c973278b42a/simplejson-4.1.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:0e4b23f71dd781f8830f1663dc01a4944d3dbf87a1f93d78fba1cf64722d0ccf", size = 176225, upload-time = "2026-04-24T19:22:57.981Z" },
{ url = "https://files.pythonhosted.org/packages/c9/fd/6dffb4956563d48bbe46b91ff341adae34920e94008fd6b8d728072abfc7/simplejson-4.1.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:82fee635d7b73ad801030b05a75fbd34a098da0c2ecf600667a03636d09e1e42", size = 185535, upload-time = "2026-04-24T19:22:59.618Z" },
{ url = "https://files.pythonhosted.org/packages/de/d2/a509ee37763e79aec75d68f8521db1440306edeba3b8b4064ab4ee8bf1d9/simplejson-4.1.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:68e62eda21192c5ea9bb92d571ca46a4477fef48762f50d433de2b4253051551", size = 179302, upload-time = "2026-04-24T19:23:01.324Z" },
{ url = "https://files.pythonhosted.org/packages/d8/23/5b343bfd2a79d3b6818e4db3586c405a001a090d4c89d336e31273ce7177/simplejson-4.1.1-cp311-cp311-win32.whl", hash = "sha256:ffd3d82294b47f5ec64050021ace95fd62628a0c1cc8bbf4d06d2d1fb697e055", size = 88408, upload-time = "2026-04-24T19:23:02.808Z" },
{ url = "https://files.pythonhosted.org/packages/38/04/df9b37aedbd524dca20840d25ebe01d6ae486b89792aeff5d15b9c4114f7/simplejson-4.1.1-cp311-cp311-win_amd64.whl", hash = "sha256:78a3fe0995be42bed62a26aa78e0e0b4d87c6545785346b9cc898f3389569a35", size = 90526, upload-time = "2026-04-24T19:23:04.408Z" },
{ url = "https://files.pythonhosted.org/packages/60/25/e90998fe8e480eb43b966c09e835379887d427567ebd496563d3b1e16b19/simplejson-4.1.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:19040a17154dc03d289bab68d73ce0a6a0be01de30c584bbdd93490bead14b22", size = 112414, upload-time = "2026-04-24T19:23:06.084Z" },
{ url = "https://files.pythonhosted.org/packages/9c/a0/abd4785f36c3400f1fbb21f517be39295a750a714f04b7ee175adf6ef580/simplejson-4.1.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a94ebaecdbaa80d9551a3ec6bf0c9302fc8b53ab6c1b2bfd498a1df4cb28158d", size = 91120, upload-time = "2026-04-24T19:23:07.877Z" },
{ url = "https://files.pythonhosted.org/packages/b8/78/fc060d2e3b13c6ec59288574b8efac64075e316b2afba4396a56b2422f78/simplejson-4.1.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:67341c95c0a168ab4a6d1e807e50463f1c8da932c3286d81e201266c427061fa", size = 91055, upload-time = "2026-04-24T19:23:09.264Z" },
{ url = "https://files.pythonhosted.org/packages/0c/b6/156a8de1e1b47694f0e7de6675866936608d45dc68388fd017d36f8693be/simplejson-4.1.1-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:45ec18e337fec538b7e902d489505c450b2454653d1290f3f50385e6fd8aa607", size = 190297, upload-time = "2026-04-24T19:23:11.226Z" },
{ url = "https://files.pythonhosted.org/packages/86/1c/e4d0eab695be3eb21d0f46bce820752031f03e7113f9c80a9b3c73ee7157/simplejson-4.1.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:820c69a4710400e9b248d5670647d60be58824369282d3925e516b3ff1a7cd82", size = 187002, upload-time = "2026-04-24T19:23:12.982Z" },
{ url = "https://files.pythonhosted.org/packages/76/0e/7f5a59d29426b062d5928fb88b403c3f797129d53be7102f955dbe51aa44/simplejson-4.1.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2e708d373a10e4378ef2d59f8361850c7150fd907ed49efe49bc5492160476d1", size = 195146, upload-time = "2026-04-24T19:23:14.517Z" },
{ url = "https://files.pythonhosted.org/packages/78/18/9943db224dd4d5fa3c090c3e56a94c37b254338c83995ec5680285111c40/simplejson-4.1.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:980fc33353f81fd12d8c49d44f8c2760d1dc8192285e627c5180d141035b228a", size = 183931, upload-time = "2026-04-24T19:23:16.742Z" },
{ url = "https://files.pythonhosted.org/packages/c2/08/9a690da9a766161c06c627d805362cf159f1abe480969372b2897649b955/simplejson-4.1.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:de2ed102fff88dacf543699f53ee3a533cc11539a39baa176b7e09dd783069d6", size = 192228, upload-time = "2026-04-24T19:23:18.33Z" },
{ url = "https://files.pythonhosted.org/packages/05/88/bd8aad36b451ffb0e0a3f721d695a88befa6d1ac7d1e02ae788ca7ff4029/simplejson-4.1.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2785ff8edc0e28bf773a32543a6bbed46351453c997b3f6709c744e3c2f7eabb", size = 187808, upload-time = "2026-04-24T19:23:21.165Z" },
{ url = "https://files.pythonhosted.org/packages/04/ee/14f91db0d1f481533b651dafbf8cd0da088d9817f7af30c68f7f19f9c847/simplejson-4.1.1-cp312-cp312-win32.whl", hash = "sha256:2e0d5ead6d14610467ec356ec1f6b5d8a56aa216abaad8d41c8b873b16cf313f", size = 88512, upload-time = "2026-04-24T19:23:22.764Z" },
{ url = "https://files.pythonhosted.org/packages/b9/c4/90de06b2d8737c68c05ff9274113f854dbf6a5f28b7a955212111672cb57/simplejson-4.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:63a5451f557d6be48a231bae932458655c620902b868170b2f1c8afed496f6b4", size = 90748, upload-time = "2026-04-24T19:23:24.494Z" },
{ url = "https://files.pythonhosted.org/packages/ce/6a/8b74c52ffd33dbbde00fe7251fee6a0acdc8cea33f7a43805aed258fb79b/simplejson-4.1.1-py3-none-any.whl", hash = "sha256:2ce92b3748f02423e26d2bfb636fb9d7a8f67c8f5854dcae69d350d123b2eee2", size = 69195, upload-time = "2026-04-24T19:24:57.962Z" },
]
[[package]]
name = "six"
version = "1.17.0"
@@ -6203,16 +6449,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]]
+7
View File
@@ -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 }}
+54 -3
View File
@@ -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/
+3 -3
View File
@@ -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
View File
@@ -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
+151
View File
@@ -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 AI answering "explain this finding" from the side panel, with the page context chip highlighted in the composer](/images/prowler-app/lighthouse/prowler-cloud/side-panel-context-aware.png)
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.
![Across providers compliance section](/images/compliance/prowler-app-across-providers-expanded.png)
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.
![Cross-provider compliance detail across providers](/images/compliance/prowler-app-across-providers-detail.png)
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
+144
View File
@@ -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}>
+123
View File
@@ -0,0 +1,123 @@
<p align="center">
<img src="https://raw.githubusercontent.com/prowler-cloud/prowler/master/docs/dockerhub/prowler-logo.svg" width="252" alt="Prowler">
</p>
<p align="center">
<b>Prowler</b> is the Open Cloud Security platform trusted by thousands to automate security and compliance in any cloud environment — AWS, Azure, Google Cloud, Kubernetes, M365, GitHub and more.
</p>
<p align="center">
<b>Learn more at <a href="https://prowler.com">prowler.com</a> · <a href="https://goto.prowler.com/slack">Join our Slack community</a></b>
</p>
<p align="center">
<a href="https://github.com/prowler-cloud/prowler"><img alt="GitHub" src="https://img.shields.io/github/stars/prowler-cloud/prowler?style=social"></a>
<a href="https://github.com/prowler-cloud/prowler/releases"><img alt="Version" src="https://img.shields.io/github/v/release/prowler-cloud/prowler"></a>
<a href="https://pypi.org/project/prowler/"><img alt="PyPI" src="https://img.shields.io/pypi/v/prowler.svg"></a>
<a href="https://github.com/prowler-cloud/prowler"><img alt="License" src="https://img.shields.io/github/license/prowler-cloud/prowler"></a>
</p>
---
# Prowler container images
All Prowler images are built from a single repository — [github.com/prowler-cloud/prowler](https://github.com/prowler-cloud/prowler) — and published together on every release.
| Image | What it is | Dockerfile |
|---|---|---|
| [`prowlercloud/prowler`](https://hub.docker.com/r/prowlercloud/prowler) | **Prowler CLI.** Runs scans from your terminal, a CI job, a Kubernetes Job or any container platform. | [`Dockerfile`](https://github.com/prowler-cloud/prowler/blob/master/Dockerfile) |
| [`prowlercloud/prowler-api`](https://hub.docker.com/r/prowlercloud/prowler-api) | **Prowler Local Server — API.** Django REST backend plus the Celery worker and scheduler that run scans and store results. | [`api/Dockerfile`](https://github.com/prowler-cloud/prowler/blob/master/api/Dockerfile) |
| [`prowlercloud/prowler-ui`](https://hub.docker.com/r/prowlercloud/prowler-ui) | **Prowler Local Server — UI.** Next.js web interface for launching scans and exploring findings. | [`ui/Dockerfile`](https://github.com/prowler-cloud/prowler/blob/master/ui/Dockerfile) |
| [`prowlercloud/prowler-mcp`](https://hub.docker.com/r/prowlercloud/prowler-mcp) | **Prowler MCP.** Gives AI assistants access to the Prowler ecosystem over the Model Context Protocol. | [`mcp_server/Dockerfile`](https://github.com/prowler-cloud/prowler/blob/master/mcp_server/Dockerfile) |
| [`toniblyx/prowler`](https://hub.docker.com/r/toniblyx/prowler) | **Legacy home of the Prowler CLI image.** Still mirrored on every release for backwards compatibility. New deployments should use `prowlercloud/prowler`. | [`Dockerfile`](https://github.com/prowler-cloud/prowler/blob/master/Dockerfile) |
All images are published for `linux/amd64` and `linux/arm64`.
## Tags
| Tag | Meaning |
|---|---|
| `stable` | Always points to the latest stable release. **Recommended for production.** |
| `<x.y.z>` | A specific release, e.g. `5.14.0`. Immutable. |
| `latest` | Built from the `master` branch on every merge. Not a stable version. |
| `<short-sha>` | A specific `master` commit (`prowler-api`, `prowler-ui` and `prowler-mcp` only). |
`v3-*` and `v4-*` tags on `prowlercloud/prowler` are frozen historical artifacts of Prowler v3/v4 and no longer receive updates.
## Other registries
The Prowler CLI image is also available on AWS Public ECR: [`public.ecr.aws/prowler-cloud/prowler`](https://gallery.ecr.aws/prowler-cloud/prowler).
---
# Quick start
## Prowler Local Server (UI + API)
```console
curl -LO https://raw.githubusercontent.com/prowler-cloud/prowler/refs/heads/master/docker-compose.yml
curl -LO https://raw.githubusercontent.com/prowler-cloud/prowler/refs/heads/master/.env
docker compose up -d
```
Then open http://localhost:3000 and sign up with your email and password.
Full guide: [Prowler Local Server installation](https://docs.prowler.com/getting-started/installation/prowler-app)
## Prowler CLI
```console
docker run -ti --rm \
-v /your/local/dir/prowler-output:/home/prowler/output \
--name prowler \
--env AWS_ACCESS_KEY_ID \
--env AWS_SECRET_ACCESS_KEY \
--env AWS_SESSION_TOKEN \
prowlercloud/prowler:stable aws
```
Swap `aws` for `azure`, `gcp`, `kubernetes`, `m365` or `github` to scan another provider. The CLI is also on PyPI: `pip install prowler`.
Full guide: [Prowler CLI installation](https://docs.prowler.com/getting-started/installation/prowler-cli)
## Prowler MCP
```console
# STDIO mode (for local MCP clients)
docker run --rm -i prowlercloud/prowler-mcp
# HTTP mode (for remote access)
docker run --rm -p 8000:8000 prowlercloud/prowler-mcp \
--transport http --host 0.0.0.0 --port 8000
```
Full guide: [Prowler MCP installation](https://docs.prowler.com/getting-started/installation/prowler-mcp)
> **Note on architecture:** if your workstation's architecture is incompatible, set `DOCKER_DEFAULT_PLATFORM=linux/amd64` or pass `--platform linux/amd64` to your Docker command.
---
# What Prowler covers
Hundreds of built-in checks mapped to the frameworks you get audited against — CIS, NIST 800 / CSF, CISA, PCI-DSS, GDPR, HIPAA, FFIEC, SOC2, GXP, FedRAMP, RBI, AWS Well-Architected (Security Pillar), AWS FTR, ENS — plus your own custom frameworks.
For live check, service, framework and category counts, see [**Prowler Hub**](https://hub.prowler.com).
List what's available for any provider:
```console
prowler <provider> --list-checks
prowler <provider> --list-services
prowler <provider> --list-compliance
prowler <provider> --list-categories
```
# Documentation and support
- **Documentation:** [docs.prowler.com](https://docs.prowler.com/)
- **Source:** [github.com/prowler-cloud/prowler](https://github.com/prowler-cloud/prowler)
- **Issues:** [github.com/prowler-cloud/prowler/issues](https://github.com/prowler-cloud/prowler/issues)
- **Community:** [Prowler Slack](https://goto.prowler.com/slack)
- **Troubleshooting:** [docs.prowler.com/troubleshooting](https://docs.prowler.com/troubleshooting)
# License
Prowler is licensed under the Apache License 2.0. A copy is available at http://www.apache.org/licenses/LICENSE-2.0.
+7
View File
@@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" width="252" height="78.2" viewBox="0 0 252 78.2" role="img" aria-label="Prowler">
<title>Prowler</title>
<rect width="252" height="78.2" rx="10" fill="#0C0A09"/>
<g transform="translate(26 22.5) scale(0.1621179084)" fill="#ffffff">
<path d="M1169.38,132.04c20.76-12.21,34.44-34.9,34.44-59.79,0-38.18-31.06-69.25-69.25-69.25l-216.9.23v145.17h-64.8V3h-79.95s-47.14,95.97-47.14,95.97V3h-52.09s-47.14,95.97-47.14,95.97V3h-53.48v69.6C560.37,30.64,521.34,0,475.28,0c-42.63,0-79.24,26.25-94.54,63.43-4.35-34.03-33.48-60.43-68.67-60.43h-100.01v47.43C202.9,22.91,176.91,3,146.35,3H0s46.34,46.33,46.34,46.33v151.64h53.47v-76.68l17.21,17.21h29.33c30.56,0,56.54-19.91,65.71-47.43v106.91h53.48v-81.51l76.01,81.51h69.62l-64.29-68.94c11.14-6.56,20.22-16.15,26.26-27.46,1.27,55.26,46.58,99.82,102.14,99.82,46.06,0,85.09-30.64,97.81-72.6v69.18h60.88l38.34-78.06v78.06h60.88l66.2-134.78v135.69h95.41l22.86-22.86v22.86h95.05l21.84-21.84v20.93h53.48v-81.5l76.01,81.5h69.62l-64.29-68.94ZM146.35,88.02h-46.54v-31.54h46.54c8.7,0,15.77,7.07,15.77,15.77s-7.07,15.77-15.77,15.77ZM312.07,88.02l-46.54-.18v-31.36h46.54c8.7,0,15.77,7.07,15.77,15.77s-7.08,15.77-15.77,15.77ZM475.28,150.92c-26.86,0-48.72-21.86-48.72-48.72s21.86-48.72,48.72-48.72,48.72,21.86,48.72,48.72-21.86,48.72-48.72,48.72ZM1034.56,148.41h-63.41v-20.35h42.91v-50.88h-42.91v-20.46h63.41v91.69ZM1134.57,88.02l-46.54-.18v-31.36h46.54c8.7,0,15.77,7.07,15.77,15.77s-7.07,15.77-15.77,15.77Z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

@@ -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: 235 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 155 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 146 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 137 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 364 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 238 KiB

After

Width:  |  Height:  |  Size: 145 KiB

+33 -15
View File
@@ -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
@@ -1,10 +1,11 @@
---
title: 'Compliance'
sidebarTitle: 'Overview'
description: 'Run security checks against compliance frameworks, review posture across providers, and download CSV or PDF reports from Prowler Cloud and Prowler Local Server, or CSV reports from Prowler CLI.'
description: 'Run security checks against compliance frameworks, review posture across providers, use Compliance Watchlist to pin frameworks, and download CSV or PDF reports from Prowler Cloud and Prowler Local Server, or CSV reports from Prowler CLI.'
---
import { SubscriptionBanner } from "/snippets/subscription-banner.mdx"
import { VersionBadge } from "/snippets/version-badge.mdx"
Prowler maps every security check to one or more industry-standard compliance frameworks, so a single scan produces both technical findings and framework-aligned evidence. The same evaluation runs identically whether scans are launched from Prowler Cloud, Prowler Local Server, or Prowler CLI.
@@ -102,6 +103,49 @@ Select any card to open the framework detail page.
Score color coding follows three thresholds: red for severely low compliance, amber for partial compliance, and green for healthy posture. Hover over the score for the exact percentage.
</Note>
### Tracking Frameworks With the Compliance Watchlist
<VersionBadge version="5.38.0" />
<SubscriptionBanner label="Compliance Watchlist" />
The compliance catalog lists dozens of frameworks, while an organization usually tracks a handful. In Prowler Cloud, Compliance Watchlist keeps that handful in front: pin the frameworks that matter and narrow every compliance surface down to them. The watchlist is shared by the whole organization: one list per tenant, not a per-user bookmark, so every member sees the same pinned frameworks.
#### Pinning a Framework
Every framework card carries a pin button in its top-right corner. Select the pin to add the framework to the watchlist, and select it again to remove it. Pinning is available on the three compliance surfaces:
* **Single Scan:** The framework grid of the selected scan.
* **Across provider types:** The universal framework cards in the **Multiple Scans** tab.
* **Across providers:** The framework cards inside each provider type group in the **Multiple Scans** tab.
<img src="/images/compliance/prowler-app-compliance-single-scan-pins.png" alt="Single Scan framework grid where every card carries a pin button, with the pinned frameworks showing a filled pin" width="900" />
<Note>
Universal frameworks (CSA CCM, CIS Controls, DORA) are a single watchlist entry. Pinning one of them from any surface shows it as pinned on the others.
</Note>
#### Filtering With the Watchlist
Two controls sit above the tabs, because both tabs read the same watchlist:
* **Show only watchlist:** A toggle that hides every framework not in the watchlist, on both tabs at once. When the filter leaves a section with nothing to show, the section explains that no pinned framework matches and offers to clear the filter.
* **Watchlist selector:** A searchable multi-select over the full framework catalog, grouped by provider. Use it to pin or unpin several frameworks in one place instead of visiting each card.
<img src="/images/compliance/prowler-app-compliance-watchlist-editor.png" alt="Watchlist selector open above the compliance tabs, showing the searchable framework catalog grouped by provider with the pinned frameworks selected" width="900" />
In Prowler Cloud, the compliance framework chips in the finding details panel follow the watchlist as well, so triage points to the same frameworks the organization tracks.
#### Reviewing the Watchlist on the Overview Page
The **Compliance Watchlist** card on the Overview page lists exactly the pinned frameworks with their current score, computed from the latest completed scan per provider. Selecting an entry opens the framework detail page. Until a framework is pinned, the card is empty and prompts to start pinning from the Compliance section.
<img src="/images/compliance/prowler-app-overview-compliance-watchlist.png" alt="Compliance Watchlist card on the Overview page listing the six pinned frameworks with their scores" width="312" />
<Note>
In Prowler Local Server, where the watchlist is not available, the card keeps its previous behavior and ranks every framework with scan data.
</Note>
### Working With the Framework Detail Page
The detail page provides everything needed to evaluate a single framework: aggregate metrics, top failure sections, and a requirement-by-requirement view.
@@ -51,6 +51,14 @@ Framework cards in this section carry no score: they enumerate which frameworks
<img src="/images/compliance/prowler-app-across-providers-expanded.png" alt="Across providers section with the AWS group expanded, showing one card per single-provider framework with its View across providers link and provider count" width="900" />
## Pinning Frameworks to the Watchlist
<VersionBadge version="5.38.0" />
Framework cards inside each provider type group carry a pin button that adds the framework to the organization's [Compliance Watchlist](/user-guide/compliance/tutorials/compliance#tracking-frameworks-with-the-compliance-watchlist). With the **Show only watchlist** toggle enabled, each group lists only its pinned frameworks, and a group whose frameworks are all filtered out explains that no pinned framework matches instead of expanding into an empty accordion.
<img src="/images/compliance/prowler-app-compliance-watchlist-filtered.png" alt="Multiple Scans tab with Show only watchlist enabled, where the AWS group of the Across providers section lists only its pinned framework" width="900" />
## Which Providers Are Listed and Which Contribute
The **Across providers** section and the detail page count different things, so their numbers often differ:
@@ -80,6 +80,14 @@ Each **framework card** includes:
Select any card to open the framework detail page.
### Pinning Universal Frameworks to the Watchlist
<VersionBadge version="5.38.0" />
Each universal framework card carries a pin button that adds the framework to the organization's [Compliance Watchlist](/user-guide/compliance/tutorials/compliance#tracking-frameworks-with-the-compliance-watchlist). A universal framework is a single watchlist entry, so pinning it here also shows it as pinned on the Single Scan grid of every compatible provider. The **Show only watchlist** toggle above the tabs narrows both sections of the Multiple Scans tab to the pinned frameworks.
<img src="/images/compliance/prowler-app-compliance-watchlist-pins.png" alt="Multiple Scans tab with the watchlist controls above the tabs and a filled pin on each universal framework card" width="900" />
### Filtering the Roll-Up
The filters bar controls which providers feed every card and detail view. Cross-Provider Type Compliance supports three filters:
+29 -1
View File
@@ -5,6 +5,7 @@ sidebarTitle: 'SAML SSO'
import { VersionBadge } from "/snippets/version-badge.mdx"
import { AppliesTo } from "/snippets/applies-to.mdx"
import { SubscriptionBanner } from "/snippets/subscription-banner.mdx"
<VersionBadge version="5.9.0" />
@@ -197,7 +198,7 @@ To complete the Prowler Cloud configuration:
1. Return to the Prowler SAML configuration page.
2. Enter the **email domain** for the organization (e.g., `mycompany.com`). Prowler Cloud uses this to identify users who should authenticate via SAML.
2. Enter the **primary email domain** for the organization (e.g., `mycompany.com`). Prowler Cloud uses this domain to generate the Assertion Consumer Service (ACS) URL. Every configured domain can identify users who authenticate through this SAML configuration.
3. Upload the **metadata XML file** downloaded from the IdP.
@@ -209,6 +210,33 @@ Click the "Save" button to complete the setup. The "SAML SSO Integration" card w
![Verify Integration Status](/images/prowler-app/saml/saml-sso-enabled.png)
---
### Add Multiple SAML Domains
<VersionBadge version="5.38.0" />
<SubscriptionBanner />
Prowler Cloud supports one primary domain and up to 19 additional verified email domains in the same SAML configuration. Users from every configured domain authenticate through the same Identity Provider (IdP), so separate SAML applications are not required for each domain.
<Note>
A SAML configuration supports up to 20 email domains in total. One domain is required as the primary domain, leaving 19 slots for additional domains. Each subdomain counts as a separate additional domain. For example, `partners.example.com` counts separately from `example.com`.
</Note>
The ACS URL always uses the primary domain. Configure this single ACS URL in the IdP even when the SAML configuration includes additional domains.
To add domains to a new or existing SAML configuration:
1. Enter the domain in **Additional Email Domains**.
2. Click **Add**. Each additional domain must be unique and must differ from the primary domain.
3. Repeat these steps for every domain that must share the configuration.
4. Click **Save** for a new configuration or **Update** for an existing configuration.
![Prowler Cloud SAML configuration with multiple additional email domains](/images/prowler-app/saml/saml-multiple-domains.png)
To remove an additional domain, click the remove button next to the domain, then click **Update**. Users from a removed domain can no longer start SAML authentication through this configuration.
### Remove SAML Configuration
SAML SSO can be disabled by removing the existing configuration from the integration panel.
![Remove SAML configuration](/images/prowler-app/saml/saml-sso-remove.png)
+25 -6
View File
@@ -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
+27
View File
@@ -4,6 +4,33 @@ All notable changes to the **Prowler MCP Server** are documented in this file.
<!-- changelog: release notes start -->
## [0.10.0] (Prowler v5.38.0)
### 🚀 Added
- Test foundation for the MCP server with shared fixtures, JSON:API builders, mocked HTTP transports and CI coverage reporting [(#12291)](https://github.com/prowler-cloud/prowler/pull/12291)
- Test coverage for the integrations tools and models, pinning the connection-check choreography and the Jira dispatch retry safety [(#12343)](https://github.com/prowler-cloud/prowler/pull/12343)
- Container images now ship an SBOM and build provenance as OCI attestations [(#12352)](https://github.com/prowler-cloud/prowler/pull/12352)
### 🔄 Changed
- `prowler_send_findings_to_jira` now reports `safe_to_retry` on every outcome, true only when Prowler knows no Jira work item was created: a dispatch the API refused is retryable, one that failed on the server or got no answer is not [(#12343)](https://github.com/prowler-cloud/prowler/pull/12343)
- `prowler_list_integrations` no longer requests the `configuration` it discards, now that the API tolerates a sparse fieldset without it [(#12343)](https://github.com/prowler-cloud/prowler/pull/12343)
### 🔐 Security
- Upgrade cryptography to 50.0.0, closing CVE-2026-69247 and CVE-2026-69249 [(#12356)](https://github.com/prowler-cloud/prowler/pull/12356)
---
## [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
@@ -261,20 +261,20 @@ class JiraDispatchResult(MinimalSerializerMixin, BaseModel):
model_config = ConfigDict(frozen=True)
status: Literal["completed", "in_progress", "unknown"] = Field(
description="Outcome of the dispatch: 'completed' when Prowler finished creating the work items, 'in_progress' when the background task is still running, 'unknown' when the task stopped before reporting a result and Prowler cannot tell how many work items it had already created"
status: Literal["completed", "in_progress", "unknown", "failed"] = Field(
description="Outcome of the dispatch: 'completed' when Prowler finished creating the work items, 'in_progress' when the background task is still running, 'failed' when the dispatch was rejected before it started so nothing was created, 'unknown' when the dispatch stopped before reporting a result and Prowler cannot tell how many work items it had already created"
)
safe_to_retry: bool = Field(
description="True only when Prowler is certain that no Jira work item was created. When False the dispatch must NOT be sent again: some work items may already exist and retrying would duplicate them. Report the outcome to the user and let them check Jira instead"
)
created_count: int | None = Field(
default=None,
description="Number of Jira work items successfully created, absent when the outcome is unknown",
description="Number of Jira work items successfully created, absent unless the dispatch completed",
ge=0,
)
failed_count: int | None = Field(
default=None,
description="Number of findings that could not be sent to Jira, absent when the outcome is unknown",
description="Number of findings that could not be sent to Jira, absent unless the dispatch completed",
ge=0,
)
error: str | None = Field(
@@ -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,15 +17,15 @@ from prowler_mcp_server.prowler_app.models.integrations import (
IntegrationsListResponse,
JiraDispatchResult,
JiraIssueTypes,
SimplifiedIntegration,
)
from prowler_mcp_server.prowler_app.tools.base import BaseTool
from prowler_mcp_server.prowler_app.utils.api_client import ProwlerAPIError
# The configuration is deliberately left out of the list view, it belongs to the
# 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 +36,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 +495,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 +528,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 +547,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}")
@@ -706,13 +725,16 @@ class IntegrationsTools(BaseTool):
The result includes:
- status: 'completed' when Prowler finished the dispatch, 'in_progress' when the task
is still running, 'unknown' when the task stopped without reporting a result
is still running, 'failed' when the dispatch was rejected before it started,
'unknown' when the task stopped without reporting a result
- safe_to_retry: whether the dispatch can be sent again. It is only true when no work
item was created. NEVER call this tool again for the same findings when it is false,
the work items already created would be duplicated. Report the outcome to the user
and let them check Jira instead
- created_count: number of work items created in Jira, absent when status='unknown'
- failed_count: number of findings that could not be sent, absent when status='unknown'
item was created, which is the case when the dispatch was rejected before it
started. NEVER call this tool again for the same findings when it is false, the
work items already created would be duplicated. Report the outcome to the user and
let them check Jira instead
- created_count: number of work items created in Jira, absent unless status='completed'
- failed_count: number of findings that could not be sent, absent unless
status='completed'
Workflow:
1. Use prowler_search_security_findings to select the findings to escalate
@@ -748,10 +770,36 @@ class IntegrationsTools(BaseTool):
params=params,
json_data=dispatch_body,
)
except ValueError as e:
# Refused here, so the request never went out
self.logger.error(f"Jira dispatch was refused before the request: {e}")
return self._jira_dispatch_rejected(str(e))
except ProwlerAPIError as e:
# Only a client error is a refusal: the API validates the dispatch and
# then queues the background task before serializing its answer, so a
# server error may well come back with work items already being created
if e.status_code >= 500:
self.logger.error(f"Jira dispatch failed on the server: {e}")
return self._jira_dispatch_unknown(
task_id=None,
error=(
f"the request that starts the dispatch failed on the server: {e} "
"It may have been queued anyway."
),
)
self.logger.error(f"Jira dispatch was rejected by Prowler: {e}")
return self._jira_dispatch_rejected(str(e))
except Exception as e:
# Nothing was dispatched yet, so this failure is safe to act on
# No answer came back, so the request may still have been accepted
self.logger.error(f"Jira dispatch could not be started: {e}")
return {"error": str(e), "status": "failed"}
return self._jira_dispatch_unknown(
task_id=None,
error=(
f"the request that starts the dispatch got no answer: {e} "
"It may have been accepted anyway."
),
)
task_id = task_response.get("data", {}).get("id")
if not task_id:
@@ -769,14 +817,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 +872,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 +962,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
@@ -1047,6 +1068,17 @@ class IntegrationsTools(BaseTool):
task_id=task_id,
).model_dump()
def _jira_dispatch_rejected(self, error: str) -> dict[str, Any]:
"""Report a dispatch that was refused before any work item could be created.
This is the only outcome safe to retry, and it is reserved for the failures
that prove nothing was queued: a validation error raised here, or a client
error from the API, which rejects the dispatch before starting its task.
"""
return JiraDispatchResult(
status="failed", safe_to_retry=True, error=error
).model_dump()
def _jira_dispatch_unknown(self, task_id: str | None, error: str) -> dict[str, Any]:
"""Report a dispatch whose outcome Prowler cannot determine.
@@ -15,6 +15,20 @@ from prowler_mcp_server.prowler_app.utils.auth import ProwlerAppAuth
ALLOWED_EXTERNAL_DOMAINS: frozenset[str] = frozenset({"raw.githubusercontent.com"})
class ProwlerAPIError(Exception):
"""An error response returned by the Prowler API.
Raised only when the API answered with an error status, which tells a caller
something no plain exception can: the request reached Prowler and was
rejected, so it changed nothing. A timeout or a dropped connection stays a
bare exception because the request may well have been processed.
"""
def __init__(self, message: str, status_code: int) -> None:
super().__init__(message)
self.status_code: int = status_code
class HTTPMethod(StrEnum):
"""HTTP methods enum."""
@@ -73,7 +87,8 @@ class ProwlerAPIClient(metaclass=SingletonMeta):
API response as dictionary
Raises:
Exception: If API request fails
ProwlerAPIError: If the API answered with an error status
Exception: If the request could not be completed
"""
try:
token: str = await self.auth_manager.get_valid_token()
@@ -105,8 +120,9 @@ class ProwlerAPIClient(metaclass=SingletonMeta):
except Exception:
error_detail = e.response.text
raise Exception(
f"API request failed: {e.response.status_code} - {error_detail}"
raise ProwlerAPIError(
f"API request failed: {e.response.status_code} - {error_detail}",
e.response.status_code,
)
except Exception as e:
logger.error(f"Error during {method.value} {path}: {e}")
+37 -1
View File
@@ -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==50.0.0",
"joserfc==1.6.8",
"mcp==1.28.1",
"python-multipart==0.0.30"
]
+264
View File
@@ -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
+49
View File
@@ -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",
]
+66
View File
@@ -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)
]
+118
View File
@@ -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]
+112
View File
@@ -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))
+34
View File
@@ -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"
+1
View File
@@ -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,108 @@
"""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 httpx
import pytest
from prowler_mcp_server.prowler_app.utils.api_client import ProwlerAPIError
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(
ProwlerAPIError, match=r"API request failed: 404 - Not found\."
) as raised:
await mock_api_client.get("/findings/nope")
assert raised.value.status_code == 404
async def test_a_request_that_got_no_answer_is_not_an_api_error(
mock_api_client, mock_router
):
"""`ProwlerAPIError` means the API answered, and callers act on that.
A write tool tells a rejected request -- which changed nothing -- from one
that may have been processed by the type of the failure, so a timeout must
not be dressed up as a rejection.
"""
def timed_out(request):
raise httpx.ReadTimeout("Timed out reading the response", request=request)
mock_router.add_handler("GET", "/api/v1/findings", timed_out)
with pytest.raises(httpx.ReadTimeout):
await mock_api_client.get("/findings")
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}"

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