Compare commits

...
Author SHA1 Message Date
Daniel Barranquero f251b86e61 fix(outputs): redact detected secrets from finding resource metadata
Secret-scanning checks embed the scanned resource verbatim in their finding,
and that resource is serialized into every output (OCSF, CSV, JSON) and any
uploaded findings, so a plaintext secret was written out even though the finding
message only names the offending field.

Add a central redactor in the output pipeline: for FAIL findings from a
secrets-category check, Finding.generate_output re-scans the resource metadata's
string values and masks any value flagged as a secret, preserving non-sensitive
context. This covers the whole secrets-check family in one place, so no
individual check has to sanitize its own resource. Best-effort and offline: on
scan error the metadata is left unchanged.
2026-07-30 16:32:25 +02:00
0b98a34687 feat(aws): add glue_catalog_connection_no_secrets check (#11963)
Signed-off-by: Alex Chen <l46983284@gmail.com>
Co-authored-by: Daniel Barranquero <danielbo2001@gmail.com>
Co-authored-by: Rishi943 <84287593+Rishi943@users.noreply.github.com>
Co-authored-by: Utkarsh <udaydeepak1928@gmail.com>
2026-07-30 15:12:37 +02:00
Siddhant JadhavandDaniel Barranquero fc0204a40d feat(codecommit): add codecommit service and codecommit_repository_no_secrets check (#11846)
Co-authored-by: Daniel Barranquero <danielbo2001@gmail.com>
2026-07-30 15:05:27 +02:00
Alan Buscaglia 7a62926a09 fix(ui): stabilize cloud e2e prerequisites (#12024) 2026-07-30 14:54:26 +02:00
7a6a35afec feat(sagemaker): add sagemaker_endpoint_config_kms_encryption_enabled check (#12118)
Co-authored-by: Daniel Barranquero <danielbo2001@gmail.com>
Co-authored-by: Alex Chen <l46983284@gmail.com>
2026-07-30 14:29:45 +02:00
b7281a5221 fix(sdk): align secret scan source line indexing (#12141)
Co-authored-by: jbchief-dev <285331266+jbchief-dev@users.noreply.github.com>
Co-authored-by: Daniel Barranquero <danielbo2001@gmail.com>
2026-07-30 12:44:20 +02:00
Daniel Barranquero 5c4b0ba1fe fix(api): scope Attack Paths predefined queries with provider label (#12167) 2026-07-30 11:55:24 +02:00
César ArrobaandRubén De la Torre Vico f3b8ac1dbb fix(mcp): run streamable HTTP stateless to stop session memory leak (#12235)
Co-authored-by: Rubén De la Torre Vico <ruben@prowler.com>
2026-07-30 11:40:55 +02:00
Adrián Peña 8dac2a7ccf fix(api): assign fallback role to SAML users (#12223) 2026-07-30 11:17:25 +02:00
Rubén De la Torre Vico 6192b8ac32 feat(mcp): add integrations tools (#12138) 2026-07-30 11:11:09 +02:00
Adrián Peña e49babb9e7 fix(ui): stabilize SAML ACS URL field (#12236) 2026-07-30 11:05:24 +02:00
Josema Camacho f8be9afa7c fix(api): safely decode stored Celery task arguments (#12165) 2026-07-30 10:30:19 +02:00
Adrián Peña ecf7ec8e85 fix(api): respect provider group scope in provider actions (#12216) 2026-07-30 10:06:57 +02:00
stepsecurity-app[bot]andstepsecurity-app[bot] <188008098+stepsecurity-app[bot]@users.noreply.github.com> a57a507cee feat(security): security best practices from StepSecurity (#12232)
Signed-off-by: StepSecurity Bot <bot@stepsecurity.io>
Co-authored-by: stepsecurity-app[bot] <188008098+stepsecurity-app[bot]@users.noreply.github.com>
2026-07-30 09:24:44 +02:00
Pedro Martín 9b565586da docs(compliance): improve cross-provider compliance guide (#12162) 2026-07-30 07:59:55 +02:00
Alejandro Bailo 17f726f816 feat(ui): enrich Lighthouse context on Overview and remaining pages (#12220) 2026-07-29 18:12:40 +02:00
Alan Buscaglia 3fd748994a docs(msp): align guides with current portal behavior (#12105) 2026-07-29 17:21:16 +02:00
Pedro Martín 976220dbf5 fix(api): reject API keys whose owning user was deleted (#12210) 2026-07-29 17:18:09 +02:00
Hugo Pereira Brito a77e56b5a0 fix(ocsf): avoid missing MITRE catalog errors (#12222) 2026-07-29 15:27:37 +01:00
Hugo Pereira Brito 34e4d25576 fix(ocsf): use provider MITRE catalog for attacks (#12157) 2026-07-29 13:05:47 +01:00
Daniel Barranquero d4a33c0d1c feat(ui): link every Attack Paths query to its Prowler Hub page (#12145) 2026-07-29 13:31:42 +02:00
Alejandro Bailo 4c3017e2ed fix(ui): wrap long Lighthouse chat messages (#12215) 2026-07-29 11:55:52 +02:00
Adrián Peña 03f2ab46c9 fix(api): refresh Security Hub connection status (#12212) 2026-07-29 11:17:25 +02:00
Alan BuscagliaandPablo F.G 7f1cdb82ae feat(ui): add PostHog-backed in-app feedback survey (#12116)
Co-authored-by: Pablo F.G <pablo.fernandez@prowler.com>
2026-07-29 11:12:38 +02:00
Hugo Pereira Brito 1bb3fc9bda ci: add release freeze gate (#11621) 2026-07-29 10:08:15 +01:00
Hugo Pereira Brito 463e8309f7 fix(sdk): render inline code with valid ADF marks (#12158) 2026-07-29 09:47:14 +01:00
Alejandro Bailo 59a7d30a3e fix(ui): restore finding delta colors and shorten update labels (#12160) 2026-07-29 10:31:22 +02:00
Alejandro Bailo 2ae1062e76 feat(ui): add contextual Lighthouse page UX (#12069) 2026-07-29 10:06:02 +02:00
Daniel Barranquero 1218b0920f docs: restructure Attack Paths docs and add query developer guide (#12144) 2026-07-28 18:28:17 +02:00
Pepe Fagoaga 06ea61ffbf docs(saml): update missing images (#12166) 2026-07-28 18:24:08 +02:00
Rubén De la Torre Vico 8eeb37aea4 feat(mcp): add users and roles tools to the Prowler MCP Server (#12088) 2026-07-28 17:16:56 +02:00
Pepe Fagoaga 7ba96ab2e2 docs: update images (#12159) 2026-07-28 16:47:38 +02:00
Stefano BaldoandHugo P.Brito 4987d8a08e fix(gcp): make gen2 Cloud Functions IAM policy query thread-safe (#12107)
Co-authored-by: Hugo P.Brito <hugopbrit@gmail.com>
2026-07-28 15:40:19 +01:00
Pepe Fagoaga 2bd89ceb97 docs: update images (#12146) 2026-07-28 14:56:22 +02:00
Pedro Martínandalejandrobailo 0d4a21b5a4 feat(ui): add cross-account compliance view (#12086)
Co-authored-by: alejandrobailo <alejandrobailo94@gmail.com>
2026-07-28 14:03:58 +02:00
Alejandro Bailo 1460f7b188 feat(ui): add Lighthouse contextual transport core (#12068) 2026-07-28 12:12:19 +02:00
Daniel Barranquero e9bbde2f01 fix(api): remove cartesian product in Attack Paths IAM privesc queries (#12136) 2026-07-28 11:12:10 +02:00
Oleksandr_SaninandHugo P.Brito 76239b7076 feat(ocsf): populate analytic and attacks fields in OCSF detection finding output (#11492)
Signed-off-by: Oleksandr Sanin <alexaaander.sanin@gmail.com>
Co-authored-by: Hugo P.Brito <hugopbrit@gmail.com>
2026-07-28 08:19:23 +01:00
César Arroba 0c0f150cd4 chore(ui): drop the unused deployment-mode variable (#12131) 2026-07-27 18:44:00 +02:00
rayair250-droidandHugo P.Brito 43de7709cb fix(gcp): detect SSH/RDP exposure when the port is not first in a multi-port firewall rule (#12115)
Co-authored-by: Hugo P.Brito <hugopbrit@gmail.com>
2026-07-27 16:44:35 +01:00
Pepe Fagoaga bb18dcb882 docs(readme): agentic cloud defender and link to Prowler Cloud (#12135) 2026-07-27 17:21:17 +02:00
Bruno FerreiraCursorcoderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
f5fe9c7b40 ci(helm): publish immutable chart versions on release (#12056)
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
2026-07-27 16:58:59 +02:00
Hugo Pereira Brito 60c81f6549 docs: document Jira dispatch for Finding Groups (#12130) 2026-07-27 15:56:54 +01:00
Alan Buscaglia f18c2841a8 fix(ui): classify attack path findings exactly (#11244) 2026-07-27 14:48:01 +02:00
Bruno FerreiraandCursor 6b21e31a28 fix(app): cap Celery worker concurrency in the Helm chart (#12054)
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-27 12:53:03 +02:00
Pepe Fagoaga 294e665d9e docs: changelog section (#12126) 2026-07-27 12:44:56 +02:00
mintlify[bot]andmintlify[bot] <109931778+mintlify[bot]@users.noreply.github.com> 94e14660da docs: apply brand tone and writing style fixes (#12125)
Co-authored-by: mintlify[bot] <109931778+mintlify[bot]@users.noreply.github.com>
2026-07-27 09:25:28 +02:00
Nithin ReddyandDaniel Barranquero 339930ef13 feat(ec2): add ec2_instance_stopped_older_than_specific_days check (#12076)
Co-authored-by: Daniel Barranquero <danielbo2001@gmail.com>
2026-07-27 09:13:18 +02:00
d933a8ecab feat(providers): add Huawei Cloud provider with CIS 1.0 benchmark (#11950)
Co-authored-by: tomitobio <tomitobio@users.noreply.github.com>
Co-authored-by: Daniel Barranquero <danielbo2001@gmail.com>
Co-authored-by: Lydia Vilchez <lydiavilchezlopez@gmail.com>
2026-07-27 08:52:58 +02:00
Prowler Botandprowler-bot da09ad9813 chore(release): Bump versions to v5.37.0 (#12113)
Co-authored-by: prowler-bot <179230569+prowler-bot@users.noreply.github.com>
2026-07-24 14:56:54 +02:00
Prowler Botandprowler-bot 2298d4a3f8 chore(changelog): v5.36.0 (#12109)
Co-authored-by: prowler-bot <179230569+prowler-bot@users.noreply.github.com>
2026-07-24 12:22:40 +02:00
Alejandro Bailo 066d53467a fix(ui): bump next-auth to 5.0.0-beta.32 to patch critical advisories (#12108) 2026-07-24 10:46:56 +02:00
Pedro Martín cf433128ed fix(api): duplicate finding rows in outputs on tasks re-run (#12097) 2026-07-24 10:18:18 +02:00
Hugo Pereira BritoandPablo F.G 0b782fcb8c fix(kubernetes): block kubeconfig command auth bypass (#12091)
Co-authored-by: Pablo F.G <pablo.fernandez@prowler.com>
2026-07-24 08:40:23 +01:00
StylusFrost b80e3a7bfb docs(msp): add Prowler for MSPs and MSSPs documentation (#12101) 2026-07-23 16:20:25 +02:00
Daniel Barranquero 885555e080 fix(ui): enable grouped Jira dispatch for Cloud users (#12100) 2026-07-23 14:30:27 +01:00
Alan Buscaglia 641c418816 fix(ui): refresh permissions after tenant switch (#12087) 2026-07-23 13:26:54 +02:00
Rubén De la Torre Vico 10d173f8da docs: update Image provider interface to include UI (#12098) 2026-07-23 12:50:53 +02:00
Prowler Botandprowler-bot 34de660755 feat(aws): Update regions for AWS services (#11716)
Co-authored-by: prowler-bot <179230569+prowler-bot@users.noreply.github.com>
2026-07-23 12:33:13 +02:00
Daniel Barranquero fb75146e34 feat(ui): filter empty Attack Paths queries from the selector in Cloud (#12010) 2026-07-23 10:36:33 +02:00
Pablo Fernandez Guerra (PFE)andPablo F.G 9f5ef80e69 test(ui): await recent-chats render in lighthouse panel chat test (#12096)
Co-authored-by: Pablo F.G <pablo.fernandez@prowler.com>
2026-07-23 09:51:34 +02:00
Alejandro Bailo 2f6aedf291 fix(ui): update Next.js to 16.2.11 (#12093) 2026-07-23 09:42:53 +02:00
Alejandro Bailo dcf2736e8d refactor(ui): centralize Jira dispatch flow (#12092) 2026-07-22 20:23:13 +02:00
7f0dc9b7da feat(ui): add AI agents banner to overview (#12074)
Co-authored-by: alejandrobailo <alejandrobailo94@gmail.com>
Co-authored-by: César Arroba <19954079+cesararroba@users.noreply.github.com>
2026-07-22 16:00:52 +02:00
Hugo Pereira Britoandalejandrobailo ff45f46047 feat(ui): add Jira dispatch choices for finding selections (#12001)
Co-authored-by: alejandrobailo <alejandrobailo94@gmail.com>
2026-07-22 12:58:12 +01:00
Hugo Pereira Brito 3bd13d173d fix(api): recover missing scan resources (#12002) 2026-07-22 12:24:45 +01:00
César Arroba 2b7f7e7dc0 fix(api): invoke m365 module without a hardcoded python version path (#12085) 2026-07-22 12:19:13 +02:00
César Arroba 98015dafef ci(api): scan the SDK pin that ships, not the committed lock (#12084) 2026-07-22 12:08:40 +02:00
Pedro Martín d70a7e3d02 fix(api): scope integrations to role provider visibility (#12060) 2026-07-22 11:50:04 +02:00
7d2a22c45a feat(ui): make the Cloud flag a runtime variable (UI_CLOUD_ENABLED) (#12061)
Co-authored-by: Pablo F.G <pablo.fernandez@prowler.com>
Co-authored-by: César Arroba <19954079+cesararroba@users.noreply.github.com>
2026-07-22 11:31:22 +02:00
Alan Buscaglia bf82e9ff3d fix(ui): prevent cloud upgrade modal flash on close (#12067) 2026-07-22 11:30:58 +02:00
Pedro Martín eece938350 fix(ci): ignore unfixed Perl Storable CVE-2026-57433 (#12081) 2026-07-22 10:32:00 +02:00
Rubén De la Torre Vico 2b0e34818c docs: add per-agent MCP configuration guides (#12064) 2026-07-22 10:23:42 +02:00
César Arroba f587dbf419 fix(ui): bump vitest to 4.1.10 to resolve @vitest/browser file-access bypass (#12077) 2026-07-22 09:56:49 +02:00
Hugo Pereira Brito 2d684c1996 fix(ui): adjust sidebar logo top spacing (#12066) 2026-07-21 15:54:41 +01:00
Pedro Martín 7f9d64a996 fix(ui): show AWS Organizations deployment hint in error color (#12063) 2026-07-21 16:50:48 +02:00
César Arroba e943ded978 fix(ui): remove unused npm from container to drop tar CVE-2026-59873 (#12065) 2026-07-21 15:32:32 +02:00
Alan Buscaglia bb20f69a63 fix(ui): prevent findings timeline axis overflow (#11545) 2026-07-21 11:22:19 +02:00
kiranrajsgandDaniel Barranquero 97233189c3 feat(sagemaker): add sagemaker_notebook_instance_no_secrets check (#11843)
Co-authored-by: Daniel Barranquero <danielbo2001@gmail.com>
2026-07-21 10:44:41 +02:00
Pedro Martín b624818b8e chore(skills): improve compliance coverage, validation & docs (#12062) 2026-07-21 10:24:25 +02:00
cb31856025 chore(ui): migrate ESLint to flat eslint.config.ts with typescript-eslint and import-x (#11352)
Co-authored-by: Pablo F.G <pablo.fernandez@prowler.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-21 10:17:54 +02:00
Alan Buscaglia 13a9caa803 fix(ui): reduce sentry alert noise (#11665) 2026-07-20 16:34:24 +02:00
1015 changed files with 47095 additions and 5397 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.36.0
NEXT_PUBLIC_PROWLER_RELEASE_VERSION=v5.37.0
# Social login credentials
SOCIAL_GOOGLE_OAUTH_CALLBACK_URL="${AUTH_URL}/api/auth/callback/google"
+11
View File
@@ -249,6 +249,7 @@ modules:
- ui/tests/profile/**
- ui/tests/lighthouse/**
- ui/tests/home/**
- ui/tests/navigation/**
- ui/tests/attack-paths/**
- name: api-serializers
@@ -275,6 +276,7 @@ modules:
- ui/tests/profile/**
- ui/tests/lighthouse/**
- ui/tests/home/**
- ui/tests/navigation/**
- ui/tests/attack-paths/**
- name: api-filters
@@ -432,6 +434,14 @@ modules:
e2e:
- ui/tests/lighthouse/**
- name: ui-navigation
match:
- ui/components/layout/**
- ui/tests/navigation/**
tests: []
e2e:
- ui/tests/navigation/**
- name: ui-overview
match:
- ui/components/overview/**
@@ -464,6 +474,7 @@ modules:
- ui/tests/profile/**
- ui/tests/lighthouse/**
- ui/tests/home/**
- ui/tests/navigation/**
- ui/tests/attack-paths/**
- name: ui-attack-paths
@@ -113,6 +113,15 @@ jobs:
api/changelog.d/**
api/AGENTS.md
# api-container-build-push.yml resolves the SDK pin to the branch tip
# before building, so match it here and scan what ships. Push only: PRs
# stay deterministic against the committed lock.
- name: Refresh prowler SDK pin to current branch tip
if: steps.check-changes.outputs.any_changed == 'true' && github.event_name == 'push'
run: |
pip install --no-cache-dir "uv==0.11.14"
(cd api && uv lock --upgrade-package prowler)
- name: Set up Docker Buildx
if: steps.check-changes.outputs.any_changed == 'true'
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
+1
View File
@@ -107,6 +107,7 @@ jobs:
files: |
api/**
.github/workflows/api-tests.yml
codecov.yml
files_ignore: |
api/docs/**
api/README.md
+7 -4
View File
@@ -38,11 +38,14 @@ jobs:
- name: Set up Helm
uses: azure/setup-helm@dda3372f752e03dde6b3237bc9431cdc2f7a02a2 # v5.0.0
- name: Set appVersion from release tag
- name: Set chart version and appVersion from release tag
run: |
RELEASE_TAG="${GITHUB_EVENT_RELEASE_TAG_NAME}"
echo "Setting appVersion to ${RELEASE_TAG}"
sed -i "s/^appVersion:.*/appVersion: \"${RELEASE_TAG}\"/" ${{ env.CHART_PATH }}/Chart.yaml
# Strip any leading "v" so the chart version is valid SemVer 2.
RELEASE_TAG="${GITHUB_EVENT_RELEASE_TAG_NAME#v}"
echo "Setting chart version and appVersion to ${RELEASE_TAG}"
# Publish an immutable chart version per release instead of the static
# 0.0.1 in source, so every release is a distinct, addressable artifact.
yq -i ".version = \"${RELEASE_TAG}\" | .appVersion = \"${RELEASE_TAG}\"" ${{ env.CHART_PATH }}/Chart.yaml
env:
GITHUB_EVENT_RELEASE_TAG_NAME: ${{ github.event.release.tag_name }}
+7
View File
@@ -25,6 +25,7 @@ jobs:
runs-on: ubuntu-latest
timeout-minutes: 30
permissions:
actions: write
contents: write
pull-requests: write
steps:
@@ -33,6 +34,12 @@ jobs:
with:
egress-policy: audit
- name: Enable release freeze
env:
GH_TOKEN: ${{ github.token }}
run: |
gh variable set RELEASE_FREEZE --body true --repo "${GITHUB_REPOSITORY}"
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
+45
View File
@@ -0,0 +1,45 @@
name: 'Tools: Release Freeze Gate'
on:
pull_request:
branches:
- 'master'
types:
- opened
- synchronize
- reopened
- ready_for_review
merge_group:
branches:
- 'master'
types:
- checks_requested
workflow_dispatch:
permissions: {}
jobs:
release-freeze-gate:
name: release-freeze-gate
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
with:
egress-policy: audit
- name: Check release freeze status
env:
RELEASE_FREEZE: ${{ vars.RELEASE_FREEZE }}
run: |
case "${RELEASE_FREEZE}" in
true|TRUE|True)
echo "::error::Release freeze is active. Merges to master are temporarily blocked."
echo "Set the RELEASE_FREEZE repository variable to false when the release is complete."
exit 1
;;
*)
echo "Release freeze is not active."
;;
esac
+117
View File
@@ -36,6 +36,7 @@ jobs:
needs: impact-analysis
if: |
github.repository == 'prowler-cloud/prowler' &&
(github.event_name != 'pull_request' || github.event.pull_request.head.repo.fork == false) &&
(needs.impact-analysis.outputs.has-ui-e2e == 'true' || needs.impact-analysis.outputs.run-all == 'true')
runs-on: ubuntu-latest
env:
@@ -118,6 +119,104 @@ jobs:
env:
NEEDS_IMPACT_ANALYSIS_OUTPUTS_MODULES: ${{ needs.impact-analysis.outputs.modules }}
- name: Validate E2E prerequisites
shell: bash
run: |
declare -A required=()
suite_selected() {
[[ "${RUN_ALL_TESTS}" == "true" ]] ||
[[ " ${E2E_TEST_PATHS} " == *"ui/tests/$1/"* ]]
}
require_vars() {
local variable
for variable in "$@"; do
required["${variable}"]=1
done
}
if suite_selected auth || suite_selected providers ||
suite_selected invitations || suite_selected scans ||
suite_selected navigation; then
require_vars E2E_ADMIN_USER E2E_ADMIN_PASSWORD
fi
if suite_selected sign-up; then
require_vars E2E_NEW_USER_PASSWORD
fi
if suite_selected invitations; then
require_vars E2E_NEW_USER_PASSWORD E2E_ORGANIZATION_ID
fi
if suite_selected scans; then
require_vars \
E2E_AWS_PROVIDER_ACCOUNT_ID \
E2E_AWS_PROVIDER_ACCESS_KEY \
E2E_AWS_PROVIDER_SECRET_KEY
fi
if suite_selected providers; then
require_vars \
E2E_AWS_PROVIDER_ACCOUNT_ID \
E2E_AWS_PROVIDER_ACCESS_KEY \
E2E_AWS_PROVIDER_SECRET_KEY \
E2E_AWS_PROVIDER_ROLE_ARN \
E2E_AZURE_SUBSCRIPTION_ID \
E2E_AZURE_CLIENT_ID \
E2E_AZURE_SECRET_ID \
E2E_AZURE_TENANT_ID \
E2E_M365_DOMAIN_ID \
E2E_M365_CLIENT_ID \
E2E_M365_SECRET_ID \
E2E_M365_TENANT_ID \
E2E_M365_CERTIFICATE_CONTENT \
E2E_GCP_BASE64_SERVICE_ACCOUNT_KEY \
E2E_GCP_PROJECT_ID \
E2E_GITHUB_APP_ID \
E2E_GITHUB_BASE64_APP_PRIVATE_KEY \
E2E_GITHUB_USERNAME \
E2E_GITHUB_PERSONAL_ACCESS_TOKEN \
E2E_GITHUB_ORGANIZATION \
E2E_GITHUB_ORGANIZATION_ACCESS_TOKEN \
E2E_OCI_TENANCY_ID \
E2E_OCI_USER_ID \
E2E_OCI_FINGERPRINT \
E2E_OCI_KEY_CONTENT \
E2E_ALIBABACLOUD_ACCOUNT_ID \
E2E_ALIBABACLOUD_ACCESS_KEY_ID \
E2E_ALIBABACLOUD_ACCESS_KEY_SECRET \
E2E_ALIBABACLOUD_ROLE_ARN \
E2E_OKTA_DOMAIN \
E2E_OKTA_CLIENT_ID \
E2E_OKTA_BASE64_PRIVATE_KEY \
E2E_GOOGLEWORKSPACE_CUSTOMER_ID \
E2E_GOOGLEWORKSPACE_SERVICE_ACCOUNT_JSON \
E2E_GOOGLEWORKSPACE_DELEGATED_USER \
E2E_VERCEL_TEAM_ID \
E2E_VERCEL_API_TOKEN
fi
missing=()
if (( ${#required[@]} > 0 )); then
while IFS= read -r variable; do
[[ -z "${!variable:-}" ]] && missing+=("${variable}")
done < <(printf '%s\n' "${!required[@]}" | sort)
fi
if (( ${#missing[@]} > 0 )); then
echo "Missing required E2E variables:"
printf ' - %s\n' "${missing[@]}"
{
echo "## Missing E2E prerequisites"
printf -- "- \`%s\`\n" "${missing[@]}"
} >> "${GITHUB_STEP_SUMMARY}"
exit 1
fi
echo "E2E prerequisite preflight passed."
- name: Create k8s Kind Cluster
uses: helm/kind-action@ef37e7f390d99f746eb8b610417061a60e82a6cc # v1
with:
@@ -304,6 +403,24 @@ jobs:
run: |
docker compose down -v || true
# Fork pull requests cannot access the secrets required by the E2E suites.
fork-e2e-unavailable:
needs: impact-analysis
if: |
github.repository == 'prowler-cloud/prowler' &&
github.event_name == 'pull_request' &&
github.event.pull_request.head.repo.fork == true &&
(needs.impact-analysis.outputs.has-ui-e2e == 'true' || needs.impact-analysis.outputs.run-all == 'true')
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- name: Report unavailable E2E tests
run: |
echo "## E2E Tests Skipped" >> "$GITHUB_STEP_SUMMARY"
echo "" >> "$GITHUB_STEP_SUMMARY"
echo "UI E2E tests require repository secrets and cannot run for fork pull requests." >> "$GITHUB_STEP_SUMMARY"
# Skip job - provides clear feedback when no E2E tests needed
skip-e2e:
needs: impact-analysis
+2
View File
@@ -173,3 +173,5 @@ GEMINI.md
# Docker
docker-compose.override.yml
docker-compose-dev.override.yml
# Local Pi runtime state
.atl/
+14
View File
@@ -35,6 +35,20 @@ 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
+2
View File
@@ -62,6 +62,7 @@ When performing these actions, ALWAYS invoke the corresponding skill FIRST:
| Action | Skill |
|--------|-------|
| Add changelog entry for a PR or feature | `prowler-changelog` |
| Adding ConfigRequirements guardrails to compliance requirements | `prowler-compliance` |
| Adding DRF pagination or permissions | `django-drf` |
| Adding a compliance output formatter (per-provider class + table dispatcher) | `prowler-compliance` |
| Adding indexes or constraints to database tables | `django-migration-psql` |
@@ -84,6 +85,7 @@ When performing these actions, ALWAYS invoke the corresponding skill FIRST:
| Creating ViewSets, serializers, or filters in api/ | `django-drf` |
| Creating Zod schemas | `zod-4` |
| Creating a git commit | `prowler-commit` |
| Creating a universal (multi-provider) compliance framework | `prowler-compliance` |
| Creating new checks | `prowler-sdk-check` |
| Creating new skills | `skill-creator` |
| Creating or reviewing Django migrations | `django-migration-psql` |
+7 -3
View File
@@ -6,7 +6,10 @@
<b><i>Prowler</b> is the Open Cloud Security Platform trusted by thousands to automate security and compliance in any cloud environment. With thousands of ready-to-use checks and compliance frameworks, Prowler delivers real-time, customizable monitoring and seamless integrations, making cloud security simple, scalable, and cost-effective for organizations of any size.
</p>
<p align="center">
<b>Secure ANY cloud at AI Speed at <a href="https://prowler.com">prowler.com</i></b>
<b>The Agentic Cloud Defender</i></b>
</p>
<p align="center">
<a href="https://cloud.prowler.com/sign-up">Try Prowler Cloud</a>
</p>
<p align="center">
@@ -56,7 +59,7 @@ Prowler includes hundreds of built-in controls to ensure compliance with standar
## Prowler Cloud & Prowler Local Server
[Prowler Cloud](https://cloud.prowler.com/) and Prowler Local Server, its self-hosted open-source version, are web applications that simplify running Prowler across your cloud provider accounts. They provide a user-friendly interface to visualize the results and streamline your security assessments.
[Prowler Cloud](https://cloud.prowler.com/sign-up) and Prowler Local Server, its self-hosted open-source version, are web applications that simplify running Prowler across your cloud provider accounts. They provide a user-friendly interface to visualize the results and streamline your security assessments.
![Prowler Cloud](docs/images/products/overview.png)
![Risk Pipeline](docs/images/products/risk-pipeline.png)
@@ -135,12 +138,13 @@ Every AWS provider scan will enqueue an Attack Paths ingestion job automatically
| IaC | [See `trivy` docs.](https://trivy.dev/latest/docs/coverage/iac/) | N/A | N/A | N/A | Official | UI, API, CLI |
| MongoDB Atlas | 10 | 3 | 1 | 8 | Official | UI, API, CLI |
| LLM | [See `promptfoo` docs.](https://www.promptfoo.dev/docs/red-team/plugins/) | N/A | N/A | N/A | Official | CLI |
| Image | N/A | N/A | N/A | N/A | Official | CLI, API |
| Image | N/A | N/A | N/A | N/A | Official | UI, API, CLI |
| Google Workspace | 65 | 11 | 3 | 6 | Official | UI, API, CLI |
| OpenStack | 34 | 5 | 1 | 9 | Official | UI, API, CLI |
| Vercel | 26 | 6 | 1 | 8 | Official | UI, API, CLI |
| Okta | 29 | 8 | 2 | 2 | Official | UI, API, CLI |
| Linode [Contact us](https://prowler.com/contact) | 10 | 3 | 1 | 4 | Unofficial | CLI |
| Huawei Cloud [Contact us](https://prowler.com/contact) | 25 | 10 | 1 | 6 | Unofficial | CLI |
| E2E Networks [Contact us](https://prowler.com/contact) | 27 | 6 | 0 | 2 | Unofficial | CLI |
| Scaleway [Contact us](https://prowler.com/contact) | 1 | 1 | 1 | 1 | Unofficial | CLI |
| StackIT [Contact us](https://prowler.com/contact) | 7 | 2 | 1 | 3 | Unofficial | CLI |
+22
View File
@@ -4,6 +4,28 @@ All notable changes to the **Prowler API** are documented in this file.
<!-- changelog: release notes start -->
## [1.37.0] (Prowler v5.36.0)
### 🔄 Changed
- OCI provider secrets no longer require `region`; legacy `region` input is accepted for backwards compatibility but ignored before storing or scanning [(#11741)](https://github.com/prowler-cloud/prowler/pull/11741)
- Compliance overview ingest now runs in a single transaction per scan with a configurable `COPY` batch size (`DJANGO_COMPLIANCE_COPY_BATCH_SIZE`, default 2000), reducing write pressure on the database [(#11875)](https://github.com/prowler-cloud/prowler/pull/11875)
### 🐞 Fixed
- Scan findings now recover resources missing from the in-memory cache after resource pre-resolution, preventing valid findings from being skipped [(#12002)](https://github.com/prowler-cloud/prowler/pull/12002)
- Tenant-wide integrations that are not attached to any provider, such as Jira, are now visible and manageable by roles with `manage_integrations` and without unlimited visibility [(#12060)](https://github.com/prowler-cloud/prowler/pull/12060)
- Output generation now removes the scan's temporary output directory before writing, so a re-run of the task for the same scan (e.g. broker redelivery after a worker is killed mid-run) no longer appends to the previous run's files and duplicates finding rows in the exported CSV and other outputs [(#12097)](https://github.com/prowler-cloud/prowler/pull/12097)
### 🔐 Security
- Integration responses no longer disclose providers outside the visibility of the role, including the resources sideloaded through `?include=providers` [(#12060)](https://github.com/prowler-cloud/prowler/pull/12060)
- Integration connection checks, Jira issue type lookups and Jira dispatches now resolve the integration through the provider visibility of the role instead of the whole tenant [(#12060)](https://github.com/prowler-cloud/prowler/pull/12060)
- Roles without unlimited visibility can no longer attach an integration to providers they cannot see, nor edit or delete an integration bound to them [(#12060)](https://github.com/prowler-cloud/prowler/pull/12060)
- Kubernetes kubeconfig validation now rejects legacy `auth-provider.config.cmd-path` command authentication in Prowler Cloud/API [(#12091)](https://github.com/prowler-cloud/prowler/pull/12091)
---
## [1.36.0] (Prowler v5.35.0)
### 🐞 Fixed
+3 -1
View File
@@ -102,7 +102,9 @@ ENV PATH="/home/prowler/.local/bin:$PATH"
RUN uv sync --locked --no-install-project && \
rm -rf ~/.cache/uv
RUN .venv/bin/python .venv/lib/python3.12/site-packages/prowler/providers/m365/lib/powershell/m365_powershell.py
# Invoked as a module so the base image's Python minor version is not baked
# into a site-packages path.
RUN .venv/bin/python -m prowler.providers.m365.lib.powershell.m365_powershell
USER root
+1
View File
@@ -0,0 +1 @@
Authentication with an API key whose owning user was deleted now returns `401` instead of an unhandled `AttributeError`, and user deletion now revokes the user's API keys across all their tenants
@@ -0,0 +1 @@
Attack Paths IAM privilege-escalation queries no longer build an all-nodes × all-resource-items cartesian product, fixing runtime errors and timeouts on accounts with many IAM roles, users, or groups
@@ -0,0 +1 @@
Attack Paths predefined queries on migrated graphs are now scoped with the provider label, letting the graph database seed from its label index instead of a global label scan and preventing query timeouts on Neptune
@@ -0,0 +1 @@
`task_args` serialization no longer returns HTTP 500 errors when Celery truncates stored task keyword arguments
@@ -1 +0,0 @@
Compliance overview ingest now runs in a single transaction per scan with a configurable `COPY` batch size (`DJANGO_COMPLIANCE_COPY_BATCH_SIZE`, default 2000), reducing write pressure on the database
@@ -1 +0,0 @@
OCI provider secrets no longer require `region`; legacy `region` input is accepted for backwards compatibility but ignored before storing or scanning
@@ -0,0 +1 @@
Provider deletion and connection checks, scan creation, provider secrets, provider groups, and daily schedules now respect role provider-group visibility
@@ -0,0 +1 @@
SAML users without a `userType` attribute and without an existing role in the SAML tenant now receive a least-privilege `read_only` fallback role; a numeric suffix is used when that name belongs to a role with different permissions
@@ -0,0 +1 @@
AWS Security Hub integrations now persist successful connection checks during finding delivery so their connection status and last checked timestamp stay current
+1 -1
View File
@@ -71,7 +71,7 @@ name = "prowler-api"
package-mode = false
# Needed for the SDK compatibility
requires-python = ">=3.11,<3.13"
version = "1.37.0"
version = "1.38.0"
# Shared ruff baseline (kept in sync with mcp_server/pyproject.toml).
# target-version tracks this project's lowest supported Python.
+169 -74
View File
@@ -1927,12 +1927,20 @@ AWS_IAM_PRIVESC_CREATE_ACCESS_KEY = AttackPathsQueryDefinition(
OR act.value = '*'
WITH DISTINCT aws, principal, stmt, path_principal
// Find target users that the principal can create access keys for
MATCH path_target = (aws)--(target_user:AWSUser)
// Pre-aggregate this statement's resource values into a list so the user
// match below is evaluated once per user (in-memory `any`) instead of
// building an (all-users x all-resource-items) cartesian product.
MATCH (stmt)-[:HAS_RESOURCE]->(res:AWSPolicyStatementResourceItem)
WHERE res.value = '*'
OR res.value CONTAINS target_user.name
OR target_user.arn CONTAINS res.value
WITH aws, path_principal, collect(DISTINCT res.value) AS res_values
WITH aws, path_principal, res_values, ('*' IN res_values) AS res_wildcard
// Find target users that the principal can create access keys for.
// Bind name/arn once so the `any` predicate reads locals.
MATCH path_target = (aws)--(target_user:AWSUser)
WITH path_principal, path_target, res_values, res_wildcard,
target_user.name AS uname, target_user.arn AS uarn
WHERE res_wildcard
OR size([rv IN res_values WHERE rv CONTAINS uname OR uarn CONTAINS rv]) > 0
WITH DISTINCT path_principal, path_target
WITH collect(path_principal) + collect(path_target) AS paths
@@ -1975,16 +1983,24 @@ AWS_IAM_PRIVESC_DELETE_CREATE_ACCESS_KEY = AttackPathsQueryDefinition(
OR act2.value = '*'
WITH DISTINCT aws, principal, stmt, stmt2, path_principal
// Find target users that the principal can rotate access keys for
MATCH path_target = (aws)--(target_user:AWSUser)
// Pre-aggregate both statements' resource values into lists so the user
// match below is evaluated once per user (in-memory `any`) instead of
// building an (all-users x res x res2) cartesian product.
MATCH (stmt)-[:HAS_RESOURCE]->(res:AWSPolicyStatementResourceItem)
WHERE res.value = '*'
OR res.value CONTAINS target_user.name
OR target_user.arn CONTAINS res.value
WITH aws, stmt2, path_principal, collect(DISTINCT res.value) AS res_values
MATCH (stmt2)-[:HAS_RESOURCE]->(res2:AWSPolicyStatementResourceItem)
WHERE res2.value = '*'
OR res2.value CONTAINS target_user.name
OR target_user.arn CONTAINS res2.value
WITH aws, path_principal, res_values, collect(DISTINCT res2.value) AS res2_values
WITH aws, path_principal, res_values, res2_values,
('*' IN res_values) AS res_wildcard,
('*' IN res2_values) AS res2_wildcard
// Find target users that the principal can rotate access keys for.
// Bind name/arn once so the `any` predicates read locals.
MATCH path_target = (aws)--(target_user:AWSUser)
WITH path_principal, path_target, res_values, res_wildcard, res2_values, res2_wildcard,
target_user.name AS uname, target_user.arn AS uarn
WHERE (res_wildcard OR size([rv IN res_values WHERE rv CONTAINS uname OR uarn CONTAINS rv]) > 0)
AND (res2_wildcard OR size([rv IN res2_values WHERE rv CONTAINS uname OR uarn CONTAINS rv]) > 0)
WITH DISTINCT path_principal, path_target
WITH collect(path_principal) + collect(path_target) AS paths
@@ -2019,12 +2035,20 @@ AWS_IAM_PRIVESC_CREATE_LOGIN_PROFILE = AttackPathsQueryDefinition(
OR act.value = '*'
WITH DISTINCT aws, principal, stmt, path_principal
// Find target users that the principal can create login profiles for
MATCH path_target = (aws)--(target_user:AWSUser)
// Pre-aggregate this statement's resource values into a list so the user
// match below is evaluated once per user (in-memory `any`) instead of
// building an (all-users x all-resource-items) cartesian product.
MATCH (stmt)-[:HAS_RESOURCE]->(res:AWSPolicyStatementResourceItem)
WHERE res.value = '*'
OR res.value CONTAINS target_user.name
OR target_user.arn CONTAINS res.value
WITH aws, path_principal, collect(DISTINCT res.value) AS res_values
WITH aws, path_principal, res_values, ('*' IN res_values) AS res_wildcard
// Find target users that the principal can create login profiles for.
// Bind name/arn once so the `any` predicate reads locals.
MATCH path_target = (aws)--(target_user:AWSUser)
WITH path_principal, path_target, res_values, res_wildcard,
target_user.name AS uname, target_user.arn AS uarn
WHERE res_wildcard
OR size([rv IN res_values WHERE rv CONTAINS uname OR uarn CONTAINS rv]) > 0
WITH DISTINCT path_principal, path_target
WITH collect(path_principal) + collect(path_target) AS paths
@@ -2097,12 +2121,20 @@ AWS_IAM_PRIVESC_UPDATE_LOGIN_PROFILE = AttackPathsQueryDefinition(
OR act.value = '*'
WITH DISTINCT aws, principal, stmt, path_principal
// Find target users that the principal can update login profiles for
MATCH path_target = (aws)--(target_user:AWSUser)
// Pre-aggregate this statement's resource values into a list so the user
// match below is evaluated once per user (in-memory `any`) instead of
// building an (all-users x all-resource-items) cartesian product.
MATCH (stmt)-[:HAS_RESOURCE]->(res:AWSPolicyStatementResourceItem)
WHERE res.value = '*'
OR res.value CONTAINS target_user.name
OR target_user.arn CONTAINS res.value
WITH aws, path_principal, collect(DISTINCT res.value) AS res_values
WITH aws, path_principal, res_values, ('*' IN res_values) AS res_wildcard
// Find target users that the principal can update login profiles for.
// Bind name/arn once so the `any` predicate reads locals.
MATCH path_target = (aws)--(target_user:AWSUser)
WITH path_principal, path_target, res_values, res_wildcard,
target_user.name AS uname, target_user.arn AS uarn
WHERE res_wildcard
OR size([rv IN res_values WHERE rv CONTAINS uname OR uarn CONTAINS rv]) > 0
WITH DISTINCT path_principal, path_target
WITH collect(path_principal) + collect(path_target) AS paths
@@ -2333,12 +2365,21 @@ AWS_IAM_PRIVESC_UPDATE_ASSUME_ROLE_POLICY = AttackPathsQueryDefinition(
// Collapse the action-item fan-out: one row per (statement chain), not per matching action
WITH DISTINCT aws, stmt, path_principal
// Find target roles whose trust policy this statement's resource can target
MATCH path_target = (aws)--(target_role:AWSRole)
// Pre-aggregate this statement's resource values into a list so the role
// match below is evaluated once per role (in-memory `any`) instead of
// building an (all-roles x all-resource-items) cartesian product.
MATCH (stmt)-[:HAS_RESOURCE]->(res:AWSPolicyStatementResourceItem)
WHERE res.value = '*'
OR res.value CONTAINS target_role.name
OR target_role.arn CONTAINS res.value
WITH aws, path_principal, collect(DISTINCT res.value) AS res_values
WITH aws, path_principal, res_values, ('*' IN res_values) AS res_wildcard
// Find target roles whose trust policy this statement's resource can target.
// Bind the role's name/arn once so the `any` predicate reads them from a
// local variable instead of re-reading the property store per resource.
MATCH path_target = (aws)--(target_role:AWSRole)
WITH path_principal, path_target, res_values, res_wildcard,
target_role.name AS rname, target_role.arn AS rarn
WHERE res_wildcard
OR size([rv IN res_values WHERE rv CONTAINS rname OR rarn CONTAINS rv]) > 0
WITH DISTINCT path_principal, path_target
WITH collect(path_principal) + collect(path_target) AS paths
@@ -2373,12 +2414,20 @@ AWS_IAM_PRIVESC_ADD_USER_TO_GROUP = AttackPathsQueryDefinition(
OR act.value = '*'
WITH DISTINCT aws, principal, stmt, path_principal
// Find target groups the principal can add users to
MATCH path_target = (aws)--(target_group:AWSGroup)
// Pre-aggregate this statement's resource values into a list so the group
// match below is evaluated once per group (in-memory `any`) instead of
// building an (all-groups x all-resource-items) cartesian product.
MATCH (stmt)-[:HAS_RESOURCE]->(res:AWSPolicyStatementResourceItem)
WHERE res.value = '*'
OR res.value CONTAINS target_group.name
OR target_group.arn CONTAINS res.value
WITH aws, path_principal, collect(DISTINCT res.value) AS res_values
WITH aws, path_principal, res_values, ('*' IN res_values) AS res_wildcard
// Find target groups the principal can add users to.
// Bind name/arn once so the `any` predicate reads locals.
MATCH path_target = (aws)--(target_group:AWSGroup)
WITH path_principal, path_target, res_values, res_wildcard,
target_group.name AS gname, target_group.arn AS garn
WHERE res_wildcard
OR size([rv IN res_values WHERE rv CONTAINS gname OR garn CONTAINS rv]) > 0
WITH DISTINCT path_principal, path_target
WITH collect(path_principal) + collect(path_target) AS paths
@@ -2462,16 +2511,24 @@ AWS_IAM_PRIVESC_ATTACH_USER_POLICY_CREATE_ACCESS_KEY = AttackPathsQueryDefinitio
OR act2.value = '*'
WITH DISTINCT aws, principal, stmt, stmt2, path_principal
// Find target users the principal can attach policies to and create keys for
MATCH path_target = (aws)--(target_user:AWSUser)
// Pre-aggregate both statements' resource values into lists so the user
// match below is evaluated once per user (in-memory `any`) instead of
// building an (all-users x res x res2) cartesian product.
MATCH (stmt)-[:HAS_RESOURCE]->(res:AWSPolicyStatementResourceItem)
WHERE res.value = '*'
OR res.value CONTAINS target_user.name
OR target_user.arn CONTAINS res.value
WITH aws, stmt2, path_principal, collect(DISTINCT res.value) AS res_values
MATCH (stmt2)-[:HAS_RESOURCE]->(res2:AWSPolicyStatementResourceItem)
WHERE res2.value = '*'
OR res2.value CONTAINS target_user.name
OR target_user.arn CONTAINS res2.value
WITH aws, path_principal, res_values, collect(DISTINCT res2.value) AS res2_values
WITH aws, path_principal, res_values, res2_values,
('*' IN res_values) AS res_wildcard,
('*' IN res2_values) AS res2_wildcard
// Find target users the principal can attach policies to and create keys for.
// Bind name/arn once so the `any` predicates read locals.
MATCH path_target = (aws)--(target_user:AWSUser)
WITH path_principal, path_target, res_values, res_wildcard, res2_values, res2_wildcard,
target_user.name AS uname, target_user.arn AS uarn
WHERE (res_wildcard OR size([rv IN res_values WHERE rv CONTAINS uname OR uarn CONTAINS rv]) > 0)
AND (res2_wildcard OR size([rv IN res2_values WHERE rv CONTAINS uname OR uarn CONTAINS rv]) > 0)
WITH DISTINCT path_principal, path_target
WITH collect(path_principal) + collect(path_target) AS paths
@@ -2596,16 +2653,24 @@ AWS_IAM_PRIVESC_PUT_USER_POLICY_CREATE_ACCESS_KEY = AttackPathsQueryDefinition(
OR act2.value = '*'
WITH DISTINCT aws, principal, stmt, stmt2, path_principal
// Find target users the principal can put policies on and create keys for
MATCH path_target = (aws)--(target_user:AWSUser)
// Pre-aggregate both statements' resource values into lists so the user
// match below is evaluated once per user (in-memory `any`) instead of
// building an (all-users x res x res2) cartesian product.
MATCH (stmt)-[:HAS_RESOURCE]->(res:AWSPolicyStatementResourceItem)
WHERE res.value = '*'
OR res.value CONTAINS target_user.name
OR target_user.arn CONTAINS res.value
WITH aws, stmt2, path_principal, collect(DISTINCT res.value) AS res_values
MATCH (stmt2)-[:HAS_RESOURCE]->(res2:AWSPolicyStatementResourceItem)
WHERE res2.value = '*'
OR res2.value CONTAINS target_user.name
OR target_user.arn CONTAINS res2.value
WITH aws, path_principal, res_values, collect(DISTINCT res2.value) AS res2_values
WITH aws, path_principal, res_values, res2_values,
('*' IN res_values) AS res_wildcard,
('*' IN res2_values) AS res2_wildcard
// Find target users the principal can put policies on and create keys for.
// Bind name/arn once so the `any` predicates read locals.
MATCH path_target = (aws)--(target_user:AWSUser)
WITH path_principal, path_target, res_values, res_wildcard, res2_values, res2_wildcard,
target_user.name AS uname, target_user.arn AS uarn
WHERE (res_wildcard OR size([rv IN res_values WHERE rv CONTAINS uname OR uarn CONTAINS rv]) > 0)
AND (res2_wildcard OR size([rv IN res2_values WHERE rv CONTAINS uname OR uarn CONTAINS rv]) > 0)
WITH DISTINCT path_principal, path_target
WITH collect(path_principal) + collect(path_target) AS paths
@@ -2647,16 +2712,24 @@ AWS_IAM_PRIVESC_ATTACH_ROLE_POLICY_UPDATE_ASSUME_ROLE = AttackPathsQueryDefiniti
OR act2.value = '*'
WITH DISTINCT aws, principal, stmt, stmt2, path_principal
// Find target roles the principal can attach policies to and update trust policy for
MATCH path_target = (aws)--(target_role:AWSRole)
// Pre-aggregate both statements' resource values into lists so the role
// match below is evaluated once per role (in-memory `any`) instead of
// building an (all-roles x res x res2) cartesian product.
MATCH (stmt)-[:HAS_RESOURCE]->(res:AWSPolicyStatementResourceItem)
WHERE res.value = '*'
OR res.value CONTAINS target_role.name
OR target_role.arn CONTAINS res.value
WITH aws, stmt2, path_principal, collect(DISTINCT res.value) AS res_values
MATCH (stmt2)-[:HAS_RESOURCE]->(res2:AWSPolicyStatementResourceItem)
WHERE res2.value = '*'
OR res2.value CONTAINS target_role.name
OR target_role.arn CONTAINS res2.value
WITH aws, path_principal, res_values, collect(DISTINCT res2.value) AS res2_values
WITH aws, path_principal, res_values, res2_values,
('*' IN res_values) AS res_wildcard,
('*' IN res2_values) AS res2_wildcard
// Find target roles the principal can attach policies to and update trust
// policy for. Bind name/arn once so the `any` predicates read locals.
MATCH path_target = (aws)--(target_role:AWSRole)
WITH path_principal, path_target, res_values, res_wildcard, res2_values, res2_wildcard,
target_role.name AS rname, target_role.arn AS rarn
WHERE (res_wildcard OR size([rv IN res_values WHERE rv CONTAINS rname OR rarn CONTAINS rv]) > 0)
AND (res2_wildcard OR size([rv IN res2_values WHERE rv CONTAINS rname OR rarn CONTAINS rv]) > 0)
WITH DISTINCT path_principal, path_target
WITH collect(path_principal) + collect(path_target) AS paths
@@ -2698,17 +2771,31 @@ AWS_IAM_PRIVESC_CREATE_POLICY_VERSION_UPDATE_ASSUME_ROLE = AttackPathsQueryDefin
OR act2.value = '*'
WITH DISTINCT aws, principal, stmt, stmt2, path_principal
// Find target roles with customer-managed policies the principal can modify and update trust policy for
MATCH path_target = (aws)--(target_role:AWSRole)
// Pre-aggregate both statements' resource values into lists so the role
// and policy matches below are evaluated with an in-memory `any` instead
// of building an (all-roles x res2) x (policies x res) cartesian product.
MATCH (stmt2)-[:HAS_RESOURCE]->(res2:AWSPolicyStatementResourceItem)
WHERE res2.value = '*'
OR res2.value CONTAINS target_role.name
OR target_role.arn CONTAINS res2.value
MATCH (target_role)-[:POLICY]->(target_policy:AWSPolicy)
WHERE target_policy.arn CONTAINS $provider_uid
WITH aws, stmt, path_principal, collect(DISTINCT res2.value) AS res2_values
MATCH (stmt)-[:HAS_RESOURCE]->(res:AWSPolicyStatementResourceItem)
WHERE res.value = '*'
OR target_policy.arn CONTAINS res.value
WITH aws, path_principal, res2_values, collect(DISTINCT res.value) AS res_values
WITH aws, path_principal, res2_values, res_values,
('*' IN res2_values) AS res2_wildcard,
('*' IN res_values) AS res_wildcard
// Find target roles with customer-managed policies the principal can
// modify and update trust policy for. Bind name/arn once so the `any`
// predicates read locals instead of re-reading the property store.
MATCH path_target = (aws)--(target_role:AWSRole)
WITH path_principal, path_target, target_role, res_values, res_wildcard,
res2_values, res2_wildcard,
target_role.name AS rname, target_role.arn AS rarn
WHERE res2_wildcard
OR size([rv IN res2_values WHERE rv CONTAINS rname OR rarn CONTAINS rv]) > 0
MATCH (target_role)-[:POLICY]->(target_policy:AWSPolicy)
WITH path_principal, path_target, res_values, res_wildcard,
target_policy.arn AS parn
WHERE parn CONTAINS $provider_uid
AND (res_wildcard OR size([rv IN res_values WHERE parn CONTAINS rv]) > 0)
WITH DISTINCT path_principal, path_target
WITH collect(path_principal) + collect(path_target) AS paths
@@ -2750,16 +2837,24 @@ AWS_IAM_PRIVESC_PUT_ROLE_POLICY_UPDATE_ASSUME_ROLE = AttackPathsQueryDefinition(
OR act2.value = '*'
WITH DISTINCT aws, principal, stmt, stmt2, path_principal
// Find target roles the principal can put inline policies on and update trust policy for
MATCH path_target = (aws)--(target_role:AWSRole)
// Pre-aggregate both statements' resource values into lists so the role
// match below is evaluated once per role (in-memory `any`) instead of
// building an (all-roles x res x res2) cartesian product.
MATCH (stmt)-[:HAS_RESOURCE]->(res:AWSPolicyStatementResourceItem)
WHERE res.value = '*'
OR res.value CONTAINS target_role.name
OR target_role.arn CONTAINS res.value
WITH aws, stmt2, path_principal, collect(DISTINCT res.value) AS res_values
MATCH (stmt2)-[:HAS_RESOURCE]->(res2:AWSPolicyStatementResourceItem)
WHERE res2.value = '*'
OR res2.value CONTAINS target_role.name
OR target_role.arn CONTAINS res2.value
WITH aws, path_principal, res_values, collect(DISTINCT res2.value) AS res2_values
WITH aws, path_principal, res_values, res2_values,
('*' IN res_values) AS res_wildcard,
('*' IN res2_values) AS res2_wildcard
// Find target roles the principal can put inline policies on and update
// trust policy for. Bind name/arn once so the `any` predicates read locals.
MATCH path_target = (aws)--(target_role:AWSRole)
WITH path_principal, path_target, res_values, res_wildcard, res2_values, res2_wildcard,
target_role.name AS rname, target_role.arn AS rarn
WHERE (res_wildcard OR size([rv IN res_values WHERE rv CONTAINS rname OR rarn CONTAINS rv]) > 0)
AND (res2_wildcard OR size([rv IN res2_values WHERE rv CONTAINS rname OR rarn CONTAINS rv]) > 0)
WITH DISTINCT path_principal, path_target
WITH collect(path_principal) + collect(path_target) AS paths
@@ -115,7 +115,26 @@ def execute_query(
# TODO: drop after Neptune cutover
# Route reads by the scan row's recorded sink, not by current settings.
backend = sink_module.get_backend_for_scan(scan)
graph = backend.execute_read_query(database_name, definition.cypher, parameters)
cypher = definition.cypher
# Every synced node carries a `_Provider_{uuid}` isolation label (the
# sync labels the whole provider subgraph). Injecting it into the
# predefined query's node patterns gives the planner a selective label
# index to seed from instead of a global label scan (`:AWSRole` across
# every tenant), which on Neptune is the difference between a sub-second
# plan and a query that times out. The custom-query path relies on this
# same injection.
#
# Restrict it to migrated scans: that catalog runs on the Neptune sink
# where the plan blowup happens, while the pre-cutover legacy catalog
# runs on the old sink and is dropped after the cutover, so leave it
# byte-for-byte unchanged. This only affects the query plan, not
# isolation - `_serialize_graph` already label-filters both catalogs.
# TODO: drop the is_migrated guard after Neptune cutover
if scan.is_migrated:
cypher = inject_provider_label(cypher, provider_id)
graph = backend.execute_read_query(database_name, cypher, parameters)
return _serialize_graph(graph, provider_id)
except graph_database.WriteQueryNotAllowedException:
+63 -30
View File
@@ -1,3 +1,4 @@
import logging
from math import isfinite
from uuid import UUID
@@ -5,6 +6,7 @@ from api.db_router import MainRouter
from api.models import TenantAPIKey, TenantAPIKeyManager
from cryptography.fernet import InvalidToken
from django.core.exceptions import ObjectDoesNotExist
from django.db import transaction
from django.utils import timezone
from drf_simple_apikey.backends import APIKeyAuthentication as BaseAPIKeyAuth
from drf_simple_apikey.crypto import get_crypto
@@ -14,6 +16,16 @@ from rest_framework.exceptions import AuthenticationFailed
from rest_framework.request import Request
from rest_framework_simplejwt.authentication import JWTAuthentication
logger = logging.getLogger(__name__)
class OrphanedAPIKeyError(Exception):
"""Raised when an API key outlived the user that owns it.
Handled by `authenticate`, which commits the revocation written while detecting it
and then rejects the request with `AuthenticationFailed`.
"""
class TenantAPIKeyAuthentication(BaseAPIKeyAuth):
model = TenantAPIKey
@@ -24,10 +36,13 @@ class TenantAPIKeyAuthentication(BaseAPIKeyAuth):
def _authenticate_credentials(self, request, key):
"""
Override to use admin connection, bypassing RLS during authentication.
Returns the validated API key row, locked with `select_for_update`, so callers
must run inside `transaction.atomic(using=MainRouter.admin_db)`.
"""
try:
payload = self.key_crypto.decrypt(key)
except ValueError:
except (ValueError, InvalidToken):
raise AuthenticationFailed("Invalid API Key.")
if not isinstance(payload, dict):
@@ -52,13 +67,33 @@ class TenantAPIKeyAuthentication(BaseAPIKeyAuth):
raise AuthenticationFailed("API Key has already expired.")
try:
api_key = self.model.objects.using(MainRouter.admin_db).get(id=api_key_pk)
api_key = (
self.model.objects.using(MainRouter.admin_db)
.select_for_update()
.get(id=api_key_pk)
)
except ObjectDoesNotExist:
raise AuthenticationFailed("No entity matching this api key.")
if api_key.revoked:
raise AuthenticationFailed("This API Key has been revoked.")
# `entity` is nullable and `on_delete=SET_NULL` leaves the key behind when its
# owner is deleted, so a key can outlive its user. Reject it here: further down
# the authentication would return `None` as the authenticated user, which blows
# up while building the auth dict and surfaces as a 500 instead of a 401.
# Revoke it as well, so it stops showing up as active and later attempts fail
# the `revoked` check above like any other revoked key.
if api_key.entity_id is None:
api_key.revoked = True
api_key.save(update_fields=["revoked"], using=MainRouter.admin_db)
logger.warning(
"Revoked orphaned API key: prefix=%s tenant=%s",
api_key.prefix,
api_key.tenant_id,
)
raise OrphanedAPIKeyError
client_ip = request.META.get(package_settings.IP_ADDRESS_HEADER)
if api_key.blacklisted_ips and client_ip in api_key.blacklisted_ips:
raise AuthenticationFailed("Access denied from blacklisted IP.")
@@ -66,7 +101,7 @@ class TenantAPIKeyAuthentication(BaseAPIKeyAuth):
if api_key.whitelisted_ips and client_ip not in api_key.whitelisted_ips:
raise AuthenticationFailed("Access restricted to specific IP addresses.")
return api_key.entity, key
return api_key
def authenticate(self, request: Request):
prefixed_key = self.get_key(request)
@@ -77,36 +112,34 @@ class TenantAPIKeyAuthentication(BaseAPIKeyAuth):
except ValueError:
raise AuthenticationFailed("Invalid API Key.")
try:
entity, _ = self._authenticate_credentials(request, key)
except InvalidToken:
raise AuthenticationFailed("Invalid API Key.")
# Validation, the `last_used_at` update and the auth claims all read the same
# row, locked until the transaction ends. Looking the key up a second time to
# build the claims used to leave a window where a key revoked or orphaned right
# after passing validation still authenticated.
with transaction.atomic(using=MainRouter.admin_db):
try:
api_key = self._authenticate_credentials(request, key)
except OrphanedAPIKeyError:
# Rejected below instead of here: leaving the block normally commits
# the revocation `_authenticate_credentials` wrote, while raising from
# inside would roll it back.
pass
else:
# The prefix used to be checked by the second lookup
if api_key.prefix != prefix:
raise AuthenticationFailed("Invalid API Key.")
# Get the API key instance to update last_used_at and retrieve tenant info
# We need to decrypt again to get the pk (already validated by _authenticate_credentials)
payload = self.key_crypto.decrypt(key)
api_key_pk = payload["_pk"]
api_key.last_used_at = timezone.now()
api_key.save(update_fields=["last_used_at"], using=MainRouter.admin_db)
# Convert string UUID back to UUID object for lookup
if isinstance(api_key_pk, str):
api_key_pk = UUID(api_key_pk)
entity = api_key.entity
return entity, {
"tenant_id": str(api_key.tenant_id),
"sub": str(entity.id),
"api_key_prefix": api_key.prefix,
}
try:
api_key_instance = TenantAPIKey.objects.using(MainRouter.admin_db).get(
id=api_key_pk, prefix=prefix
)
except TenantAPIKey.DoesNotExist:
raise AuthenticationFailed("Invalid API Key.")
# Update last_used_at
api_key_instance.last_used_at = timezone.now()
api_key_instance.save(update_fields=["last_used_at"], using=MainRouter.admin_db)
return entity, {
"tenant_id": str(api_key_instance.tenant_id),
"sub": str(api_key_instance.entity.id),
"api_key_prefix": prefix,
}
raise AuthenticationFailed("No entity matching this api key.")
class CombinedJWTOrAPIKeyAuthentication(BaseAuthentication):
+7 -1
View File
@@ -3,9 +3,10 @@ from api.db_router import MainRouter, reset_read_db_alias, set_read_db_alias
from api.db_utils import POSTGRES_USER_VAR, rls_transaction
from api.filters import CustomDjangoFilterBackend
from api.models import Role, UserRoleRelationship
from api.rbac.permissions import HasPermissions
from api.rbac.permissions import HasPermissions, get_role
from django.conf import settings
from django.db import transaction
from django.utils.functional import cached_property
from rest_framework import permissions
from rest_framework.exceptions import NotAuthenticated
from rest_framework.filters import SearchFilter
@@ -100,6 +101,11 @@ class BaseRLSViewSet(BaseViewSet):
context["tenant_id"] = self.request.tenant_id
return context
@cached_property
def user_role(self):
"""Role of the requesting user in the active tenant, resolved once per request."""
return get_role(self.request.user, self.request.tenant_id)
class BaseTenantViewset(BaseViewSet):
def dispatch(self, request, *args, **kwargs):
+40
View File
@@ -0,0 +1,40 @@
import ast
import json
from typing import Any
_UNPARSED = object()
def decode_celery_field(value: Any, default: Any) -> Any:
"""Decode a Celery result field and require JSON-serializable output."""
decoded = value
for _ in range(2):
if not isinstance(decoded, str):
break
text = decoded.strip()
if not text:
decoded = default
break
parsed = _UNPARSED
for parser in (json.loads, ast.literal_eval):
try:
parsed = parser(text)
break
except (TypeError, ValueError, SyntaxError):
continue
if parsed is _UNPARSED:
raise ValueError("Unable to decode Celery result field")
decoded = parsed
decoded = default if decoded is None else decoded
try:
json.dumps(decoded, allow_nan=False)
except (TypeError, ValueError) as error:
raise ValueError(
"Decoded Celery result field is not JSON serializable"
) from error
return decoded
+31 -2
View File
@@ -1,8 +1,8 @@
from enum import Enum
from api.db_router import MainRouter
from api.models import Provider, Role, User
from django.db.models import QuerySet
from api.models import Integration, Provider, Role, User
from django.db.models import Q, QuerySet
from rest_framework.exceptions import PermissionDenied
from rest_framework.permissions import BasePermission
@@ -83,3 +83,32 @@ def get_providers(role: Role) -> QuerySet[Provider]:
return Provider.objects.filter(
tenant_id=tenant_id, provider_groups__in=provider_groups
).distinct()
def get_integrations(
role: Role, providers: QuerySet[Provider] | None = None
) -> QuerySet[Integration]:
"""
Return a distinct queryset of Integrations visible to the given role.
Integrations with no providers attached are tenant-wide, as is always the case for
Jira, and stay visible regardless of the provider visibility of the role. Integrations
attached to providers are only visible when the role can access at least one of them.
Args:
role: A Role instance.
providers: Optional queryset of the providers accessible by the role, to reuse
an already resolved `get_providers(role)` result within the same request.
Returns:
A QuerySet of Integration objects visible to the role.
"""
queryset = Integration.objects.filter(tenant_id=role.tenant_id)
if role.unlimited_visibility:
return queryset
if providers is None:
providers = get_providers(role)
return queryset.filter(
Q(providers__isnull=True) | Q(providers__in=providers)
).distinct()
+14 -2
View File
@@ -1,3 +1,4 @@
from api.db_router import MainRouter
from api.db_utils import delete_related_daily_task
from api.models import (
LighthouseProviderConfiguration,
@@ -47,8 +48,15 @@ def revoke_user_api_keys(sender, instance, **kwargs): # noqa: F841
The entity field will be set to NULL by on_delete=SET_NULL,
but we explicitly revoke the keys to prevent further use.
The update runs on the admin connection because `api_keys` is RLS protected and its
policy denies every row when `api.tenant_id` is unset. Users are deleted through the
admin connection and may belong to several tenants, so going through the default
connection would silently revoke nothing, or only the keys of the active tenant.
"""
TenantAPIKey.objects.filter(entity=instance).update(revoked=True)
TenantAPIKey.objects.using(MainRouter.admin_db).filter(entity=instance).update(
revoked=True
)
@receiver(post_delete, sender=Membership)
@@ -58,8 +66,12 @@ def revoke_membership_api_keys(sender, instance, **kwargs): # noqa: F841
When a membership is deleted, all API keys created by that user
in that tenant should be revoked to prevent further access.
Uses the admin connection for the same reason as `revoke_user_api_keys`: the RLS
policy on `api_keys` denies every row when `api.tenant_id` is unset, which is the
case when the membership is removed as a cascade of a user deletion.
"""
TenantAPIKey.objects.filter(
TenantAPIKey.objects.using(MainRouter.admin_db).filter(
entity_id=instance.user_id, tenant_id=instance.tenant_id
).update(revoked=True)
+19 -10
View File
@@ -1,7 +1,7 @@
openapi: 3.0.3
info:
title: Prowler API
version: 1.37.0
version: 1.38.0
description: |-
Prowler API specification.
@@ -6629,8 +6629,10 @@ paths:
/api/v1/integrations:
get:
operationId: api_v1_integrations_list
description: Retrieve a list of all configured integrations with options for
filtering by various criteria.
description: |-
Retrieve a list of all configured integrations with options for filtering by various criteria.
Integrations attached to one or more providers are only returned when the role can access at least one of those providers, and each integration lists only the providers visible to the role. Integrations not attached to any provider, such as Jira, are tenant-wide and are returned for every role.
summary: List all integrations
parameters:
- in: query
@@ -6781,7 +6783,8 @@ paths:
post:
operationId: api_v1_integrations_create
description: Register a new integration with the system, providing necessary
configuration details.
configuration details. Only providers visible to the role can be attached
to the integration.
summary: Create a new integration
tags:
- Integration
@@ -6810,7 +6813,7 @@ paths:
post:
operationId: api_v1_integrations_jira_dispatches_create
description: |-
Send a set of filtered findings to the given integration. At least one finding filter must be provided.
Send a set of filtered findings to the given integration. At least one finding filter must be provided. Jira integrations are tenant-wide and do not require unlimited visibility, while the findings sent are limited to the providers the role can access.
## Known Limitations
@@ -6883,7 +6886,8 @@ paths:
get:
operationId: api_v1_integrations_jira_issue_types_retrieve
description: Fetch the available issue types from Jira for a given project key
and update the integration configuration.
and update the integration configuration. Jira integrations are tenant-wide
and do not require unlimited visibility.
summary: Get available issue types for a Jira project
parameters:
- in: query
@@ -6924,7 +6928,8 @@ paths:
get:
operationId: api_v1_integrations_retrieve
description: Fetch detailed information about a specific integration by its
ID.
ID. Integrations outside the provider visibility of the role are reported
the same way as one that does not exist.
summary: Retrieve integration details
parameters:
- in: query
@@ -6978,7 +6983,8 @@ paths:
patch:
operationId: api_v1_integrations_partial_update
description: Modify certain fields of an existing integration without affecting
other settings.
other settings. Integrations attached to providers outside the visibility
of the role cannot be modified by it.
summary: Partially update an integration
parameters:
- in: path
@@ -7013,7 +7019,8 @@ paths:
description: ''
delete:
operationId: api_v1_integrations_destroy
description: Remove an integration from the system by its ID.
description: Remove an integration from the system by its ID. Integrations attached
to providers outside the visibility of the role cannot be deleted by it.
summary: Delete an integration
parameters:
- in: path
@@ -7033,7 +7040,9 @@ paths:
/api/v1/integrations/{id}/connection:
post:
operationId: api_v1_integrations_connection_create
description: Try to verify integration connection
description: Try to verify integration connection. Integrations outside the
provider visibility of the role are reported the same way as one that does
not exist.
summary: Check integration connection
parameters:
- in: path
@@ -4,8 +4,11 @@ from datetime import UTC, datetime, timedelta
from uuid import uuid4
import pytest
from api.db_router import MainRouter
from api.models import Membership, Role, TenantAPIKey, User, UserRoleRelationship
from api.signals import revoke_membership_api_keys, revoke_user_api_keys
from conftest import TEST_PASSWORD, get_api_tokens, get_authorization_header
from django.db.utils import ConnectionDoesNotExist
from django.urls import reverse
from drf_simple_apikey.crypto import get_crypto
from rest_framework.test import APIClient
@@ -625,6 +628,34 @@ class TestAPIKeyErrors:
assert response.status_code == 401
assert "API Key has been revoked." in response.json()["errors"][0]["detail"]
def test_orphaned_api_key_rejected(
self, create_test_user, tenants_fixture, api_keys_fixture
):
"""Key whose owning user was deleted returns 401 instead of 500."""
client = APIClient()
api_key = api_keys_fixture[0]
# `on_delete=SET_NULL` leaves the key behind with no entity when the owner goes
TenantAPIKey.objects.filter(id=api_key.id).update(entity=None)
api_key_headers = get_api_key_header(api_key._raw_key)
response = client.get(reverse("provider-list"), headers=api_key_headers)
assert response.status_code == 401
assert (
"No entity matching this api key." in response.json()["errors"][0]["detail"]
)
# The orphaned key is revoked on use; retries fail the regular revoked check
api_key.refresh_from_db()
assert api_key.revoked is True
retry_response = client.get(reverse("provider-list"), headers=api_key_headers)
assert retry_response.status_code == 401
assert (
"API Key has been revoked." in retry_response.json()["errors"][0]["detail"]
)
def test_non_existent_api_key(self, create_test_user, tenants_fixture):
"""Key UUID doesn't exist in database."""
client = APIClient()
@@ -817,6 +848,93 @@ class TestAPIKeyTenantIsolation:
error_detail = response_json["errors"][0]["detail"]
assert "revoked" in error_detail.lower()
def test_deleting_user_revokes_api_keys_in_every_tenant(self, tenants_fixture):
"""Deleting a user revokes their keys in all their tenants, not just one."""
first_tenant, second_tenant = tenants_fixture[0], tenants_fixture[1]
test_user = User.objects.create_user(
name="multi_tenant_user",
email="multi_tenant_user@prowler.com",
password=TEST_PASSWORD,
)
for tenant in (first_tenant, second_tenant):
Membership.objects.create(
user=test_user, tenant=tenant, role=Membership.RoleChoices.OWNER
)
first_key, _ = TenantAPIKey.objects.create_api_key(
name="Key in first tenant", tenant_id=first_tenant.id, entity=test_user
)
second_key, _ = TenantAPIKey.objects.create_api_key(
name="Key in second tenant", tenant_id=second_tenant.id, entity=test_user
)
test_user.delete()
first_key.refresh_from_db()
second_key.refresh_from_db()
assert first_key.revoked is True
assert second_key.revoked is True
# `on_delete=SET_NULL` orphans the keys, so revoking them is what keeps them
# from authenticating
assert first_key.entity_id is None
assert second_key.entity_id is None
def test_revoke_user_api_keys_uses_the_admin_connection(
self, monkeypatch, tenants_fixture
):
"""The revocation must not go through the default connection.
`api_keys` is RLS protected and its policy denies every row when `api.tenant_id`
is unset, which is the case while a user is deleted through the admin
connection: the update would silently revoke nothing and leave usable orphaned
keys behind.
Pointing `admin_db` at a missing alias is the only way to assert the connection
here, because the test suite runs on a single superuser database with
`MainRouter.admin_db` patched to "default" (see `conftest.py`), so RLS never
applies and both connections are otherwise indistinguishable.
"""
test_user = User.objects.create_user(
name="admin_connection_user",
email="admin_connection_user@prowler.com",
password=TEST_PASSWORD,
)
Membership.objects.create(user=test_user, tenant=tenants_fixture[0])
TenantAPIKey.objects.create_api_key(
name="Key for admin connection check",
tenant_id=tenants_fixture[0].id,
entity=test_user,
)
monkeypatch.setattr(MainRouter, "admin_db", "missing_admin_alias")
with pytest.raises(ConnectionDoesNotExist):
revoke_user_api_keys(sender=User, instance=test_user)
def test_revoke_membership_api_keys_uses_the_admin_connection(
self, monkeypatch, tenants_fixture
):
"""Same as the user deletion case: this receiver also runs as its cascade."""
test_user = User.objects.create_user(
name="admin_connection_membership_user",
email="admin_connection_membership_user@prowler.com",
password=TEST_PASSWORD,
)
membership = Membership.objects.create(
user=test_user, tenant=tenants_fixture[0]
)
TenantAPIKey.objects.create_api_key(
name="Key for membership admin connection check",
tenant_id=tenants_fixture[0].id,
entity=test_user,
)
monkeypatch.setattr(MainRouter, "admin_db", "missing_admin_alias")
with pytest.raises(ConnectionDoesNotExist):
revoke_membership_api_keys(sender=Membership, instance=membership)
@pytest.mark.django_db
class TestAPIKeyLifecycle:
@@ -154,6 +154,88 @@ def test_execute_query_serializes_graph(
assert result["relationships"][0]["label"] == "OWNS"
def test_execute_query_injects_provider_label_when_migrated(
attack_paths_query_definition_factory,
sink_backend_stub,
):
# On migrated graphs the predefined cypher must be scoped with the
# provider label so the planner seeds from the label index instead of a
# global label scan (the Neptune cartesian/timeout fix).
definition = attack_paths_query_definition_factory(
id="aws-iam",
name="IAM",
short_description="Short desc",
description="",
cypher="MATCH (aws:AWSAccount)--(target_role:AWSRole) RETURN target_role",
parameters=[],
)
provider_id = "test-provider-123"
plabel = get_provider_label(provider_id)
parameters = {"provider_uid": "123"}
graph_result = MagicMock()
graph_result.nodes = []
graph_result.relationships = []
sink_backend_stub.execute_read_query.return_value = graph_result
# Injection is gated on `is_migrated`, not the sink (it is a pure string
# transform), so `neo4j` exercises the same code path as Neptune here.
views_helpers.execute_query(
"db-tenant-test",
definition,
parameters,
provider_id=provider_id,
scan=MagicMock(is_migrated=True, sink_backend="neo4j"),
)
executed_cypher = sink_backend_stub.execute_read_query.call_args[0][1]
assert executed_cypher != definition.cypher
# Both node patterns are scoped - not just one. Asserting the exact rewrite
# (rather than `f":{plabel}" in executed_cypher`, which a partial injection
# would still satisfy) proves every node got the label and that injection
# inserted labels and nothing else.
assert executed_cypher == (
f"MATCH (aws:AWSAccount:{plabel})--(target_role:AWSRole:{plabel}) "
"RETURN target_role"
)
# Parameters are passed through untouched.
assert sink_backend_stub.execute_read_query.call_args[0][2] == parameters
def test_execute_query_does_not_inject_label_when_deprecated(
attack_paths_query_definition_factory,
sink_backend_stub,
):
# The pre-cutover legacy catalog runs on the old sink and is removed after
# the Neptune cutover, so it must run verbatim (no injection).
definition = attack_paths_query_definition_factory(
id="aws-iam",
name="IAM",
short_description="Short desc",
description="",
cypher="MATCH (aws:AWSAccount)--(target_role:AWSRole) RETURN target_role",
parameters=[],
)
parameters = {"provider_uid": "123"}
graph_result = MagicMock()
graph_result.nodes = []
graph_result.relationships = []
sink_backend_stub.execute_read_query.return_value = graph_result
views_helpers.execute_query(
"db-tenant-test",
definition,
parameters,
provider_id="test-provider-123",
scan=MagicMock(is_migrated=False, sink_backend="neo4j"),
)
sink_backend_stub.execute_read_query.assert_called_once_with(
"db-tenant-test", definition.cypher, parameters
)
def test_execute_query_wraps_graph_errors(
attack_paths_query_definition_factory,
sink_backend_stub,
@@ -4,11 +4,17 @@ from unittest.mock import MagicMock, patch
from uuid import uuid4
import pytest
from api.authentication import SSEAuthentication, TenantAPIKeyAuthentication
from api.authentication import (
OrphanedAPIKeyError,
SSEAuthentication,
TenantAPIKeyAuthentication,
)
from api.db_router import MainRouter
from api.models import TenantAPIKey
from django.db import connections
from django.db.models.query import QuerySet
from django.test import RequestFactory
from django.test.utils import CaptureQueriesContext
from rest_framework.exceptions import AuthenticationFailed
@@ -38,13 +44,12 @@ class TestTenantAPIKeyAuthentication:
request = request_factory.get("/")
# Call the method
entity, auth_dict = auth_backend._authenticate_credentials(
request, encrypted_key
)
validated_key = auth_backend._authenticate_credentials(request, encrypted_key)
# Verify that the entity is the user associated with the API key
assert entity == api_key.entity
assert entity.id == api_key.entity.id
assert validated_key.id == api_key.id
assert validated_key.entity == api_key.entity
assert validated_key.entity.id == api_key.entity.id
def test_authenticate_credentials_restores_manager_on_success(
self, auth_backend, api_keys_fixture, request_factory
@@ -231,6 +236,120 @@ class TestTenantAPIKeyAuthentication:
assert str(exc_info.value.detail) == "This API Key has been revoked."
def test_authenticate_credentials_orphaned_api_key(
self, auth_backend, api_keys_fixture, request_factory
):
"""Test credential validation fails when the owning user no longer exists."""
api_key = api_keys_fixture[0]
_, encrypted_key = api_key._raw_key.split(TenantAPIKey.objects.separator, 1)
# `entity` is what `on_delete=SET_NULL` leaves behind when the owner is deleted
TenantAPIKey.objects.filter(id=api_key.id).update(entity=None)
request = request_factory.get("/")
with pytest.raises(OrphanedAPIKeyError):
auth_backend._authenticate_credentials(request, encrypted_key)
# The orphaned key is revoked on use, so it stops showing up as active
api_key.refresh_from_db()
assert api_key.revoked is True
def test_authenticate_orphaned_api_key(
self, auth_backend, api_keys_fixture, request_factory
):
"""Test authentication fails with a key whose owning user was deleted.
Regression test: this used to raise `AttributeError: 'NoneType' object has no
attribute 'id'` while building the auth dict, which DRF re-raises as
`WrappedAttributeError` and turns into a 500 instead of a 401.
"""
api_key = api_keys_fixture[0]
raw_key = api_key._raw_key
TenantAPIKey.objects.filter(id=api_key.id).update(entity=None)
request = request_factory.get("/")
request.META["HTTP_AUTHORIZATION"] = f"Api-Key {raw_key}"
with pytest.raises(AuthenticationFailed) as exc_info:
auth_backend.authenticate(request)
assert str(exc_info.value.detail) == "No entity matching this api key."
# The orphaned key is revoked on use; retries fail the regular revoked check
api_key.refresh_from_db()
assert api_key.revoked is True
with pytest.raises(AuthenticationFailed) as exc_info:
auth_backend.authenticate(request)
assert str(exc_info.value.detail) == "This API Key has been revoked."
def test_authenticate_reads_the_api_key_once_under_a_row_lock(
self, auth_backend, api_keys_fixture, request_factory
):
"""Test the API key is read a single time and the row is locked.
Validation, the `last_used_at` update and the claims must all come from the
same authoritative row: a second, unlocked lookup would reopen the window
where a key revoked in between still authenticates.
"""
api_key = api_keys_fixture[0]
request = request_factory.get("/")
request.META["HTTP_AUTHORIZATION"] = f"Api-Key {api_key._raw_key}"
with CaptureQueriesContext(connections[MainRouter.admin_db]) as captured:
auth_backend.authenticate(request)
api_key_selects = [
query["sql"]
for query in captured.captured_queries
if query["sql"].startswith("SELECT") and '"api_keys"' in query["sql"]
]
assert len(api_key_selects) == 1
assert "FOR UPDATE" in api_key_selects[0]
def test_authenticate_ignores_revocation_after_the_locked_read(
self, auth_backend, api_keys_fixture, request_factory
):
"""Test the claims describe the row that was validated, not a later state.
Regression test: the key used to be looked up again to build the auth dict,
without rechecking `revoked` or `entity`. A key revoked or orphaned between
both reads still authenticated, and the claims came from that stale row. With
a single locked read the write below cannot land mid-authentication, and the
revocation only takes effect on the next request.
"""
api_key = api_keys_fixture[0]
entity_at_validation = api_key.entity
original_save = TenantAPIKey.save
def revoke_and_orphan_before_saving(instance, *args, **kwargs):
# Runs after validation, right before the claims are built: the exact
# window a concurrent revocation or user deletion used to slip into
TenantAPIKey.objects.filter(id=api_key.id).update(revoked=True, entity=None)
return original_save(instance, *args, **kwargs)
request = request_factory.get("/")
request.META["HTTP_AUTHORIZATION"] = f"Api-Key {api_key._raw_key}"
with patch.object(TenantAPIKey, "save", revoke_and_orphan_before_saving):
entity, auth_dict = auth_backend.authenticate(request)
assert entity == entity_at_validation
assert auth_dict["sub"] == str(entity_at_validation.id)
assert auth_dict["tenant_id"] == str(api_key.tenant_id)
assert auth_dict["api_key_prefix"] == api_key.prefix
# The revoked key is rejected from the next request on
with pytest.raises(AuthenticationFailed) as exc_info:
auth_backend.authenticate(request)
assert str(exc_info.value.detail) == "This API Key has been revoked."
def test_authenticate_expired_api_key(
self, auth_backend, create_test_user, tenants_fixture, request_factory
):
@@ -1,5 +1,6 @@
"""Unit tests for the Cypher sanitizer (validation + provider-label injection)."""
import re
from unittest.mock import patch
import pytest
@@ -22,6 +23,38 @@ def _inject(cypher: str) -> str:
return inject_provider_label(cypher, PROVIDER_ID)
# String literals and line comments can contain parentheses that look like node
# patterns; strip them first. Implemented here independently of the sanitizer so
# the node count is an oracle for the injector rather than a copy of its regexes.
_STRING_OR_COMMENT_RE = re.compile(r"'(?:[^'\\]|\\.)*'|\"(?:[^\"\\]|\\.)*\"|//[^\n]*")
# A node pattern is `(`, not preceded by a word char (which would make it a
# function call), wrapping an optional variable, zero or more `:Label`s and an
# optional `{property map}` - and nothing else, which excludes parenthesized
# expressions such as `(a OR b)` in a WHERE clause.
_NODE_PATTERN_RE = re.compile(
r"(?<![\w`])\("
r"\s*(?:[a-zA-Z_]\w*)?"
r"(?:\s*:\s*(?:`[^`]*`|[a-zA-Z_]\w*))*"
r"(?:\s*\{[^{}]*\})?"
r"\s*\)"
)
def _count_node_patterns(cypher: str) -> int:
"""Count node patterns in a query, independently of the injector.
Injection appends exactly one provider label per node pattern, so the
number of injected labels must equal this count - proving *every* node is
scoped, not just one."""
stripped = _STRING_OR_COMMENT_RE.sub("", cypher)
return sum(
1
for match in _NODE_PATTERN_RE.finditer(stripped)
if match.group(0)[1:-1].strip()
)
def test_generic_inject_label_reuses_provider_injection_pipeline():
result = inject_label("MATCH (n:AWSRole)--(m) RETURN n, m", "_Tenant_test")
@@ -427,3 +460,66 @@ class TestValidation:
)
def test_allows_clean_queries(self, cypher):
validate_custom_query(cypher)
# ---------------------------------------------------------------------------
# Predefined-catalog injection (Option 1: label-scoped predefined queries)
# ---------------------------------------------------------------------------
def _all_predefined_queries():
"""Every predefined query in the migrated catalog, as (id, cypher)."""
from api.attack_paths.queries.registry import _QUERY_DEFINITIONS
return [
(definition.id, definition.cypher)
for definitions in _QUERY_DEFINITIONS.values()
for definition in definitions
]
_PREDEFINED_QUERIES = _all_predefined_queries()
class TestPredefinedCatalogInjection:
"""`execute_query` injects the provider label into predefined queries on
migrated graphs. The injection must be *lossless* for every catalog query:
it may only insert `:_Provider_{uuid}` tokens and must not otherwise alter
the cypher (which would corrupt a hand-authored query). This runs over the
whole catalog so a regex regression is caught for all queries at once.
Injection is a pure string transform, so it is sink-independent (the same
result is sent to Neo4j and Neptune)."""
def test_catalog_is_not_empty(self):
# Guard against the parametrized tests silently covering nothing.
assert len(_PREDEFINED_QUERIES) > 0
@pytest.mark.parametrize(
"cypher",
[cypher for _, cypher in _PREDEFINED_QUERIES],
ids=[query_id for query_id, _ in _PREDEFINED_QUERIES],
)
def test_injection_is_lossless(self, cypher):
injected = _inject(cypher)
# Every node pattern is scoped - not just one. A partial-injection
# regression that missed some nodes would still satisfy a bare
# `f":{LABEL}" in injected` check, so assert the label count matches the
# number of node patterns.
assert injected.count(f":{LABEL}") == _count_node_patterns(cypher)
# Stripping the injected tokens restores the query verbatim, proving
# injection changed nothing but the labels.
assert injected.replace(f":{LABEL}", "") == cypher
@pytest.mark.parametrize(
"cypher",
[cypher for _, cypher in _PREDEFINED_QUERIES],
ids=[query_id for query_id, _ in _PREDEFINED_QUERIES],
)
def test_injection_preserves_parameter_placeholders(self, cypher):
# Label injection must never touch `$param` bindings.
original_params = sorted(set(re.findall(r"\$\w+", cypher)))
injected_params = sorted(set(re.findall(r"\$\w+", _inject(cypher))))
assert injected_params == original_params
+967
View File
@@ -3,11 +3,15 @@ from unittest.mock import ANY, Mock, patch
import pytest
from api.models import (
Integration,
IntegrationProviderRelationship,
Membership,
ProviderGroup,
ProviderGroupMembership,
ProviderSecret,
Role,
RoleProviderGroupRelationship,
Scan,
User,
UserRoleRelationship,
)
@@ -664,6 +668,612 @@ class TestLimitedVisibility:
limited_admin_user, tenants_fixture[0]
)
@pytest.fixture
def hidden_provider_secret(self, aws_provider_pair):
hidden_provider = aws_provider_pair[1]
return ProviderSecret.objects.create(
tenant_id=hidden_provider.tenant_id,
provider=hidden_provider,
secret_type=ProviderSecret.TypeChoices.STATIC,
secret={
"aws_access_key_id": "hidden-key",
"aws_secret_access_key": "hidden-secret",
},
name="Hidden provider secret",
)
@pytest.fixture
def limited_provider_group(self, limited_admin_user):
return ProviderGroup.objects.get(name="limited_visibility_group")
@patch("api.v1.views.enqueue_scan_execution_on_commit")
def test_scan_create_out_of_scope_provider_is_rejected(
self,
mock_enqueue_scan,
authenticated_client_rbac_limited,
aws_provider_pair,
):
hidden_provider = aws_provider_pair[1]
response = authenticated_client_rbac_limited.post(
reverse("scan-list"),
data=json.dumps(
{
"data": {
"type": "scans",
"attributes": {"name": "Out of scope scan"},
"relationships": {
"provider": {
"data": {
"type": "providers",
"id": str(hidden_provider.id),
}
}
},
}
}
),
content_type="application/vnd.api+json",
)
assert response.status_code == status.HTTP_400_BAD_REQUEST
assert not Scan.objects.filter(
provider=hidden_provider, name="Out of scope scan"
).exists()
mock_enqueue_scan.assert_not_called()
@patch("api.v1.views.enqueue_scan_execution_on_commit")
def test_scan_create_in_scope_provider_is_accepted(
self,
mock_enqueue_scan,
authenticated_client_rbac_limited,
aws_provider,
):
response = authenticated_client_rbac_limited.post(
reverse("scan-list"),
data=json.dumps(
{
"data": {
"type": "scans",
"attributes": {"name": "In scope scan"},
"relationships": {
"provider": {
"data": {
"type": "providers",
"id": str(aws_provider.id),
}
}
},
}
}
),
content_type="application/vnd.api+json",
)
assert response.status_code == status.HTTP_202_ACCEPTED
assert Scan.objects.filter(provider=aws_provider, name="In scope scan").exists()
mock_enqueue_scan.assert_called_once()
def test_provider_secret_retrieve_out_of_scope_returns_404(
self,
authenticated_client_rbac_limited,
hidden_provider_secret,
):
response = authenticated_client_rbac_limited.get(
reverse(
"providersecret-detail",
kwargs={"pk": hidden_provider_secret.id},
)
)
assert response.status_code == status.HTTP_404_NOT_FOUND
def test_provider_secret_list_excludes_out_of_scope_provider(
self,
authenticated_client_rbac_limited,
hidden_provider_secret,
):
response = authenticated_client_rbac_limited.get(reverse("providersecret-list"))
assert response.status_code == status.HTTP_200_OK
assert str(hidden_provider_secret.id) not in {
item["id"] for item in response.json()["data"]
}
def test_provider_secret_create_out_of_scope_provider_is_rejected(
self,
authenticated_client_rbac_limited,
aws_provider_pair,
):
hidden_provider = aws_provider_pair[1]
response = authenticated_client_rbac_limited.post(
reverse("providersecret-list"),
data=json.dumps(
{
"data": {
"type": "provider-secrets",
"attributes": {
"name": "Out of scope secret",
"secret_type": ProviderSecret.TypeChoices.STATIC,
"secret": {
"aws_access_key_id": "hidden-key",
"aws_secret_access_key": "hidden-secret",
},
},
"relationships": {
"provider": {
"data": {
"type": "providers",
"id": str(hidden_provider.id),
}
}
},
}
}
),
content_type="application/vnd.api+json",
)
assert response.status_code == status.HTTP_400_BAD_REQUEST
assert not ProviderSecret.objects.filter(provider=hidden_provider).exists()
def test_provider_secret_create_in_scope_provider_is_accepted(
self,
authenticated_client_rbac_limited,
aws_provider,
):
response = authenticated_client_rbac_limited.post(
reverse("providersecret-list"),
data=json.dumps(
{
"data": {
"type": "provider-secrets",
"attributes": {
"name": "In scope secret",
"secret_type": ProviderSecret.TypeChoices.STATIC,
"secret": {
"aws_access_key_id": "visible-key",
"aws_secret_access_key": "visible-secret",
},
},
"relationships": {
"provider": {
"data": {
"type": "providers",
"id": str(aws_provider.id),
}
}
},
}
}
),
content_type="application/vnd.api+json",
)
assert response.status_code == status.HTTP_201_CREATED
assert ProviderSecret.objects.filter(provider=aws_provider).exists()
def test_provider_secret_update_out_of_scope_returns_404(
self,
authenticated_client_rbac_limited,
hidden_provider_secret,
):
response = authenticated_client_rbac_limited.patch(
reverse(
"providersecret-detail",
kwargs={"pk": hidden_provider_secret.id},
),
data=json.dumps(
{
"data": {
"type": "provider-secrets",
"id": str(hidden_provider_secret.id),
"attributes": {"name": "Updated hidden secret"},
}
}
),
content_type="application/vnd.api+json",
)
assert response.status_code == status.HTTP_404_NOT_FOUND
hidden_provider_secret.refresh_from_db()
assert hidden_provider_secret.name == "Hidden provider secret"
def test_provider_secret_delete_out_of_scope_returns_404(
self,
authenticated_client_rbac_limited,
hidden_provider_secret,
):
response = authenticated_client_rbac_limited.delete(
reverse(
"providersecret-detail",
kwargs={"pk": hidden_provider_secret.id},
)
)
assert response.status_code == status.HTTP_404_NOT_FOUND
assert ProviderSecret.objects.filter(id=hidden_provider_secret.id).exists()
def test_provider_group_create_out_of_scope_provider_is_rejected(
self,
authenticated_client_rbac_limited,
aws_provider_pair,
):
hidden_provider = aws_provider_pair[1]
response = authenticated_client_rbac_limited.post(
reverse("providergroup-list"),
data=json.dumps(
{
"data": {
"type": "provider-groups",
"attributes": {"name": "Out of scope group"},
"relationships": {
"providers": {
"data": [
{
"type": "providers",
"id": str(hidden_provider.id),
}
]
}
},
}
}
),
content_type="application/vnd.api+json",
)
assert response.status_code == status.HTTP_400_BAD_REQUEST
assert not ProviderGroup.objects.filter(name="Out of scope group").exists()
def test_provider_group_create_in_scope_provider_is_accepted(
self,
authenticated_client_rbac_limited,
aws_provider,
):
response = authenticated_client_rbac_limited.post(
reverse("providergroup-list"),
data=json.dumps(
{
"data": {
"type": "provider-groups",
"attributes": {"name": "In scope group"},
"relationships": {
"providers": {
"data": [
{
"type": "providers",
"id": str(aws_provider.id),
}
]
}
},
}
}
),
content_type="application/vnd.api+json",
)
assert response.status_code == status.HTTP_201_CREATED
provider_group = ProviderGroup.objects.get(name="In scope group")
assert set(provider_group.providers.all()) == {aws_provider}
def test_provider_group_update_out_of_scope_provider_is_rejected(
self,
authenticated_client_rbac_limited,
limited_provider_group,
aws_provider_pair,
):
visible_provider, hidden_provider = aws_provider_pair
response = authenticated_client_rbac_limited.patch(
reverse(
"providergroup-detail",
kwargs={"pk": limited_provider_group.id},
),
data=json.dumps(
{
"data": {
"type": "provider-groups",
"id": str(limited_provider_group.id),
"relationships": {
"providers": {
"data": [
{
"type": "providers",
"id": str(hidden_provider.id),
}
]
}
},
}
}
),
content_type="application/vnd.api+json",
)
assert response.status_code == status.HTTP_400_BAD_REQUEST
assert set(limited_provider_group.providers.all()) == {visible_provider}
def test_provider_group_relationship_create_out_of_scope_provider_is_rejected(
self,
authenticated_client_rbac_limited,
limited_provider_group,
aws_provider_pair,
):
hidden_provider = aws_provider_pair[1]
response = authenticated_client_rbac_limited.post(
reverse(
"provider_group-providers-relationship",
kwargs={"pk": limited_provider_group.id},
),
data={
"data": [
{"type": "providers", "id": str(hidden_provider.id)},
]
},
content_type="application/vnd.api+json",
)
assert response.status_code == status.HTTP_400_BAD_REQUEST
assert not ProviderGroupMembership.objects.filter(
provider_group=limited_provider_group,
provider=hidden_provider,
).exists()
def test_provider_group_relationship_update_out_of_scope_provider_is_rejected(
self,
authenticated_client_rbac_limited,
limited_provider_group,
aws_provider_pair,
):
visible_provider, hidden_provider = aws_provider_pair
response = authenticated_client_rbac_limited.patch(
reverse(
"provider_group-providers-relationship",
kwargs={"pk": limited_provider_group.id},
),
data={
"data": [
{"type": "providers", "id": str(hidden_provider.id)},
]
},
content_type="application/vnd.api+json",
)
assert response.status_code == status.HTTP_400_BAD_REQUEST
assert set(limited_provider_group.providers.all()) == {visible_provider}
def test_provider_group_relationship_create_in_scope_provider_is_accepted(
self,
authenticated_client_rbac_limited,
limited_provider_group,
aws_provider_pair,
):
additional_provider = aws_provider_pair[1]
additional_group = ProviderGroup.objects.create(
tenant_id=additional_provider.tenant_id,
name="Additional visible group",
)
ProviderGroupMembership.objects.create(
tenant_id=additional_provider.tenant_id,
provider_group=additional_group,
provider=additional_provider,
)
RoleProviderGroupRelationship.objects.create(
tenant_id=additional_provider.tenant_id,
role=limited_provider_group.roles.get(),
provider_group=additional_group,
)
response = authenticated_client_rbac_limited.post(
reverse(
"provider_group-providers-relationship",
kwargs={"pk": limited_provider_group.id},
),
data={
"data": [
{"type": "providers", "id": str(additional_provider.id)},
]
},
content_type="application/vnd.api+json",
)
assert response.status_code == status.HTTP_204_NO_CONTENT
assert ProviderGroupMembership.objects.filter(
provider_group=limited_provider_group,
provider=additional_provider,
).exists()
def test_provider_group_relationship_delete_out_of_scope_group_returns_404(
self,
authenticated_client_rbac_limited,
aws_provider_pair,
):
hidden_provider = aws_provider_pair[1]
hidden_group = ProviderGroup.objects.create(
tenant_id=hidden_provider.tenant_id,
name="Unassigned provider group",
)
ProviderGroupMembership.objects.create(
tenant_id=hidden_provider.tenant_id,
provider_group=hidden_group,
provider=hidden_provider,
)
response = authenticated_client_rbac_limited.delete(
reverse(
"provider_group-providers-relationship",
kwargs={"pk": hidden_group.id},
)
)
assert response.status_code == status.HTTP_404_NOT_FOUND
assert ProviderGroupMembership.objects.filter(
provider_group=hidden_group,
provider=hidden_provider,
).exists()
@patch("api.v1.views.Task.objects.get")
@patch("api.v1.views.delete_provider_task.delay")
def test_provider_delete_out_of_scope_returns_404(
self,
mock_delete_task,
mock_task_get,
authenticated_client_rbac_limited,
aws_provider_pair,
tasks_fixture,
):
hidden_provider = aws_provider_pair[1]
prowler_task = tasks_fixture[0]
mock_delete_task.return_value.id = prowler_task.id
mock_task_get.return_value = prowler_task
response = authenticated_client_rbac_limited.delete(
reverse("provider-detail", kwargs={"pk": hidden_provider.id})
)
assert response.status_code == status.HTTP_404_NOT_FOUND
hidden_provider.refresh_from_db()
assert hidden_provider.is_deleted is False
mock_delete_task.assert_not_called()
mock_task_get.assert_not_called()
@patch("api.v1.views.Task.objects.get")
@patch("api.v1.views.delete_provider_task.delay")
def test_provider_delete_in_scope_returns_202(
self,
mock_delete_task,
mock_task_get,
authenticated_client_rbac_limited,
aws_provider,
tasks_fixture,
):
prowler_task = tasks_fixture[0]
mock_delete_task.return_value.id = prowler_task.id
mock_task_get.return_value = prowler_task
response = authenticated_client_rbac_limited.delete(
reverse("provider-detail", kwargs={"pk": aws_provider.id})
)
assert response.status_code == status.HTTP_202_ACCEPTED
mock_delete_task.assert_called_once_with(
provider_id=str(aws_provider.id), tenant_id=ANY
)
mock_task_get.assert_called_once_with(id=prowler_task.id)
@patch("api.v1.views.Task.objects.get")
@patch("api.v1.views.check_provider_connection_task.delay")
def test_provider_connection_out_of_scope_returns_404(
self,
mock_provider_connection,
mock_task_get,
authenticated_client_rbac_limited,
aws_provider_pair,
tasks_fixture,
):
hidden_provider = aws_provider_pair[1]
prowler_task = tasks_fixture[0]
mock_provider_connection.return_value.id = prowler_task.id
mock_task_get.return_value = prowler_task
response = authenticated_client_rbac_limited.post(
reverse("provider-connection", kwargs={"pk": hidden_provider.id})
)
assert response.status_code == status.HTTP_404_NOT_FOUND
mock_provider_connection.assert_not_called()
mock_task_get.assert_not_called()
@patch("api.v1.views.Task.objects.get")
@patch("api.v1.views.check_provider_connection_task.delay")
def test_provider_connection_in_scope_returns_202(
self,
mock_provider_connection,
mock_task_get,
authenticated_client_rbac_limited,
aws_provider,
tasks_fixture,
):
prowler_task = tasks_fixture[0]
mock_provider_connection.return_value.id = prowler_task.id
mock_task_get.return_value = prowler_task
response = authenticated_client_rbac_limited.post(
reverse("provider-connection", kwargs={"pk": aws_provider.id})
)
assert response.status_code == status.HTTP_202_ACCEPTED
mock_provider_connection.assert_called_once_with(
provider_id=str(aws_provider.id), tenant_id=ANY
)
mock_task_get.assert_called_once_with(id=prowler_task.id)
@patch("api.v1.views.Task.objects.get")
@patch("api.v1.views.schedule_provider_scan")
def test_schedule_daily_out_of_scope_returns_404(
self,
mock_schedule_scan,
mock_task_get,
authenticated_client_rbac_limited,
aws_provider_pair,
tasks_fixture,
):
hidden_provider = aws_provider_pair[1]
prowler_task = tasks_fixture[0]
mock_schedule_scan.return_value.id = prowler_task.id
mock_task_get.return_value = prowler_task
response = authenticated_client_rbac_limited.post(
reverse("schedule-daily"),
data=json.dumps(
{
"data": {
"type": "daily-schedules",
"attributes": {"provider_id": str(hidden_provider.id)},
}
}
),
content_type="application/vnd.api+json",
)
assert response.wsgi_request.content_type == "application/vnd.api+json"
assert response.status_code == status.HTTP_404_NOT_FOUND
mock_schedule_scan.assert_not_called()
mock_task_get.assert_not_called()
@patch("api.v1.views.Task.objects.get")
@patch("api.v1.views.schedule_provider_scan")
def test_schedule_daily_in_scope_returns_202(
self,
mock_schedule_scan,
mock_task_get,
authenticated_client_rbac_limited,
aws_provider,
tasks_fixture,
):
prowler_task = tasks_fixture[0]
mock_schedule_scan.return_value.id = prowler_task.id
mock_task_get.return_value = prowler_task
response = authenticated_client_rbac_limited.post(
reverse("schedule-daily"),
data=json.dumps(
{
"data": {
"type": "daily-schedules",
"attributes": {"provider_id": str(aws_provider.id)},
}
}
),
content_type="application/vnd.api+json",
)
assert response.wsgi_request.content_type == "application/vnd.api+json"
assert response.status_code == status.HTTP_202_ACCEPTED
mock_schedule_scan.assert_called_once_with(aws_provider)
mock_task_get.assert_called_once_with(id=prowler_task.id)
def test_integrations(
self, authenticated_client_rbac_limited, integrations_fixture
):
@@ -681,6 +1291,363 @@ class TestLimitedVisibility:
response.json()["data"]["relationships"]["providers"]["meta"]["count"] == 1
)
@pytest.fixture
def jira_integration(self, tenants_fixture):
# Jira is a tenant-wide integration: it is not attached to any provider
return Integration.objects.create(
tenant_id=tenants_fixture[0].id,
enabled=True,
connected=True,
integration_type=Integration.IntegrationChoices.JIRA,
configuration={"projects": {"TEST": "Test project"}},
credentials={
"domain": "test",
"user_mail": "a@b.com",
"api_token": "token",
},
)
@pytest.fixture
def out_of_scope_integration(self, tenants_fixture, provider_factory):
tenant_id = tenants_fixture[0].id
integration = Integration.objects.create(
tenant_id=tenant_id,
enabled=True,
connected=True,
integration_type=Integration.IntegrationChoices.AMAZON_S3,
configuration={
"bucket_name": "bucket",
"output_directory": "output",
},
credentials={"aws_access_key_id": "key"},
)
IntegrationProviderRelationship.objects.create(
tenant_id=tenant_id,
integration=integration,
provider=provider_factory(),
)
return integration
def test_integrations_list_includes_tenant_wide_integration(
self,
authenticated_client_rbac_limited,
integrations_fixture,
jira_integration,
aws_provider_pair,
):
# Integration 2 is attached to both providers, so make both visible to the role
# to assert the provider join does not duplicate it in the listing
ProviderGroupMembership.objects.create(
tenant_id=aws_provider_pair[1].tenant_id,
provider=aws_provider_pair[1],
provider_group=ProviderGroup.objects.get(name="limited_visibility_group"),
)
response = authenticated_client_rbac_limited.get(reverse("integration-list"))
assert response.status_code == status.HTTP_200_OK
integration_ids = [item["id"] for item in response.json()["data"]]
# The tenant-wide Jira integration is visible without unlimited visibility
assert str(jira_integration.id) in integration_ids
# Integrations attached to more than one visible provider are not duplicated
assert integration_ids.count(str(integrations_fixture[1].id)) == 1
assert response.json()["meta"]["pagination"]["count"] == len(integration_ids)
def test_integrations_list_without_provider_groups_keeps_tenant_wide_integration(
self, authenticated_client_rbac_limited, integrations_fixture, jira_integration
):
# A role with no provider group at all sees no provider, but still needs Jira
RoleProviderGroupRelationship.objects.all().delete()
response = authenticated_client_rbac_limited.get(reverse("integration-list"))
assert response.status_code == status.HTTP_200_OK
integration_ids = [item["id"] for item in response.json()["data"]]
assert integration_ids == [str(jira_integration.id)]
def test_integrations_include_providers_hides_out_of_scope_providers(
self, authenticated_client_rbac_limited, integrations_fixture, aws_provider_pair
):
# Integration 2 is related to provider1 (visible) and provider2 (not visible)
hidden_provider = aws_provider_pair[1]
response = authenticated_client_rbac_limited.get(
reverse("integration-list"), {"include": "providers"}
)
assert response.status_code == status.HTTP_200_OK
included_ids = {item["id"] for item in response.json().get("included", [])}
assert str(aws_provider_pair[0].id) in included_ids
# Sideloaded resources must not disclose the provider the role cannot see
assert str(hidden_provider.id) not in included_ids
def test_integrations_list_with_sparse_fields(
self, authenticated_client_rbac_limited, integrations_fixture
):
response = authenticated_client_rbac_limited.get(
reverse("integration-list"), {"fields[integrations]": "enabled"}
)
assert response.status_code == status.HTTP_200_OK
assert all(
list(item["attributes"].keys()) == ["enabled"]
for item in response.json()["data"]
)
def test_integrations_list_excludes_out_of_scope_integration(
self, authenticated_client_rbac_limited, out_of_scope_integration
):
response = authenticated_client_rbac_limited.get(reverse("integration-list"))
assert response.status_code == status.HTTP_200_OK
integration_ids = [item["id"] for item in response.json()["data"]]
assert str(out_of_scope_integration.id) not in integration_ids
def test_integration_detail_out_of_scope_returns_404(
self, authenticated_client_rbac_limited, out_of_scope_integration
):
response = authenticated_client_rbac_limited.get(
reverse("integration-detail", kwargs={"pk": out_of_scope_integration.id})
)
assert response.status_code == status.HTTP_404_NOT_FOUND
def test_integration_connection_out_of_scope_returns_404(
self, authenticated_client_rbac_limited, out_of_scope_integration
):
response = authenticated_client_rbac_limited.post(
reverse(
"integration-connection", kwargs={"pk": out_of_scope_integration.id}
)
)
assert response.status_code == status.HTTP_404_NOT_FOUND
def test_integration_update_allowed_when_fully_visible(
self, authenticated_client_rbac_limited, integrations_fixture, jira_integration
):
# Integration 1 is only related to provider1, which the role can access
integration = integrations_fixture[0]
payload = {
"data": {
"type": "integrations",
"id": str(integration.id),
"attributes": {
"enabled": False,
# integration_type is `amazon_s3`
"credentials": {"aws_access_key_id": "new_value"},
"configuration": {
"bucket_name": "new_bucket_name",
"output_directory": "new_output_directory",
},
},
}
}
response = authenticated_client_rbac_limited.patch(
reverse("integration-detail", kwargs={"pk": integration.id}),
data=json.dumps(payload),
content_type="application/vnd.api+json",
)
assert response.status_code == status.HTTP_200_OK
integration.refresh_from_db()
assert integration.enabled is False
# Tenant-wide integrations have no provider restricting the role
payload = {
"data": {
"type": "integrations",
"id": str(jira_integration.id),
"attributes": {"enabled": False},
}
}
response = authenticated_client_rbac_limited.patch(
reverse("integration-detail", kwargs={"pk": jira_integration.id}),
data=json.dumps(payload),
content_type="application/vnd.api+json",
)
assert response.status_code == status.HTTP_200_OK
jira_integration.refresh_from_db()
assert jira_integration.enabled is False
def test_integration_create_rejects_out_of_scope_provider(
self, authenticated_client_rbac_limited, aws_provider_pair
):
# provider2 is not in any provider group assigned to the role
payload = {
"data": {
"type": "integrations",
"attributes": {
"integration_type": "amazon_s3",
"configuration": {
"bucket_name": "attacker_bucket",
"output_directory": "output",
},
"credentials": {"aws_access_key_id": "key"},
},
"relationships": {
"providers": {
"data": [
{"type": "providers", "id": str(aws_provider_pair[1].id)}
]
}
},
}
}
response = authenticated_client_rbac_limited.post(
reverse("integration-list"),
data=json.dumps(payload),
content_type="application/vnd.api+json",
)
assert response.status_code == status.HTTP_400_BAD_REQUEST
assert not Integration.objects.filter(
integrationproviderrelationship__provider=aws_provider_pair[1],
configuration__bucket_name="attacker_bucket",
).exists()
@pytest.mark.parametrize("submitted_providers", [True, False])
def test_integration_update_denied_when_shared_with_hidden_provider(
self,
authenticated_client_rbac_limited,
integrations_fixture,
aws_provider_pair,
submitted_providers,
):
# Integration 2 is related to provider1 (visible) and provider2 (not visible).
# Editing it would reach beyond the visibility of the role, just like deleting
# it, so both are rejected consistently
integration = integrations_fixture[1]
visible_provider, hidden_provider = aws_provider_pair
payload = {
"data": {
"type": "integrations",
"id": str(integration.id),
"attributes": {
"enabled": False,
# integration_type is `amazon_s3`
"credentials": {"aws_access_key_id": "new_value"},
"configuration": {
"bucket_name": "new_bucket_name",
"output_directory": "new_output_directory",
},
},
}
}
if submitted_providers:
payload["data"]["relationships"] = {
"providers": {
"data": [{"type": "providers", "id": str(visible_provider.id)}]
}
}
response = authenticated_client_rbac_limited.patch(
reverse("integration-detail", kwargs={"pk": integration.id}),
data=json.dumps(payload),
content_type="application/vnd.api+json",
)
assert response.status_code == status.HTTP_403_FORBIDDEN
integration.refresh_from_db()
assert integration.enabled is True
assert integration.providers.filter(id=hidden_provider.id).exists()
assert integration.providers.filter(id=visible_provider.id).exists()
def test_integration_delete_denied_when_shared_with_hidden_provider(
self, authenticated_client_rbac_limited, integrations_fixture
):
# Integration 2 is related to provider1 (visible) and provider2 (not visible)
integration = integrations_fixture[1]
response = authenticated_client_rbac_limited.delete(
reverse("integration-detail", kwargs={"pk": integration.id})
)
assert response.status_code == status.HTTP_403_FORBIDDEN
assert Integration.objects.filter(id=integration.id).exists()
def test_integration_delete_allowed_when_fully_visible(
self, authenticated_client_rbac_limited, integrations_fixture, jira_integration
):
# Integration 1 is only related to provider1, which the role can access
integration = integrations_fixture[0]
response = authenticated_client_rbac_limited.delete(
reverse("integration-detail", kwargs={"pk": integration.id})
)
assert response.status_code == status.HTTP_204_NO_CONTENT
assert not Integration.objects.filter(id=integration.id).exists()
# Tenant-wide integrations have no provider restricting the role
response = authenticated_client_rbac_limited.delete(
reverse("integration-detail", kwargs={"pk": jira_integration.id})
)
assert response.status_code == status.HTTP_204_NO_CONTENT
def test_jira_issue_types_allowed_without_unlimited_visibility(
self, authenticated_client_rbac_limited, jira_integration
):
with patch("api.v1.views.initialize_prowler_integration") as mock_jira:
mock_jira.return_value.get_available_issue_types.return_value = ["Task"]
response = authenticated_client_rbac_limited.get(
reverse(
"integration-jira-issue-types",
kwargs={"integration_pk": jira_integration.id},
),
{"project_key": "TEST"},
)
assert response.status_code == status.HTTP_200_OK
assert response.json()["data"]["attributes"]["issue_types"] == ["Task"]
def test_jira_issue_types_out_of_scope_returns_404(
self, authenticated_client_rbac_limited, out_of_scope_integration
):
response = authenticated_client_rbac_limited.get(
reverse(
"integration-jira-issue-types",
kwargs={"integration_pk": out_of_scope_integration.id},
),
{"project_key": "TEST"},
)
assert response.status_code == status.HTTP_404_NOT_FOUND
def test_jira_dispatches_out_of_scope_returns_404(
self, authenticated_client_rbac_limited, out_of_scope_integration
):
response = authenticated_client_rbac_limited.post(
reverse(
"integration-jira-dispatches",
kwargs={"integration_pk": out_of_scope_integration.id},
),
data=json.dumps({}),
content_type="application/vnd.api+json",
)
assert response.status_code == status.HTTP_404_NOT_FOUND
def test_jira_dispatches_allowed_without_unlimited_visibility(
self, authenticated_client_rbac_limited, jira_integration
):
response = authenticated_client_rbac_limited.post(
reverse(
"integration-jira-dispatches",
kwargs={"integration_pk": jira_integration.id},
),
data=json.dumps({}),
content_type="application/vnd.api+json",
)
# The integration is reachable: the request fails on payload validation, not RBAC
assert response.status_code == status.HTTP_400_BAD_REQUEST
@pytest.mark.usefixtures("scan_summaries_fixture")
def test_overviews_providers(
self,
@@ -309,6 +309,36 @@ current-context: test-context
assert not serializer.is_valid()
assert "kubeconfig_content" in serializer.errors
def test_kubeconfig_with_auth_provider_cmd_path_is_rejected(self):
kubeconfig_content = """
apiVersion: v1
kind: Config
clusters:
- name: test-cluster
cluster:
server: https://kubernetes.example.test
users:
- name: test-user
user:
auth-provider:
name: gcp
config:
cmd-path: /bin/sh
contexts:
- name: test-context
context:
cluster: test-cluster
user: test-user
current-context: test-context
"""
serializer = KubernetesProviderSecret(
data={"kubeconfig_content": kubeconfig_content}
)
assert not serializer.is_valid()
assert "kubeconfig_content" in serializer.errors
def test_malformed_kubeconfig_is_rejected(self):
serializer = KubernetesProviderSecret(
data={"kubeconfig_content": "apiVersion: ["}
+309 -8
View File
@@ -3,9 +3,11 @@ import io
import json
import os
import tempfile
from concurrent.futures import ThreadPoolExecutor
from datetime import UTC, date, datetime, timedelta
from decimal import Decimal
from pathlib import Path
from threading import Event, Lock
from types import SimpleNamespace
from unittest.mock import ANY, MagicMock, Mock, patch
from urllib.parse import parse_qs, urlparse
@@ -65,6 +67,7 @@ from api.v1.views import (
)
from botocore.exceptions import ClientError, NoCredentialsError
from celery import states
from celery.utils.saferepr import saferepr
from conftest import (
API_JSON_CONTENT_TYPE,
TEST_PASSWORD,
@@ -73,7 +76,7 @@ from conftest import (
today_after_n_days,
)
from django.conf import settings
from django.db import connection
from django.db import close_old_connections, connection
from django.db.models import Count
from django.http import JsonResponse
from django.test import RequestFactory
@@ -5043,11 +5046,60 @@ class TestTaskViewSet:
reverse("task-detail", kwargs={"pk": task1.id}),
)
assert response.status_code == status.HTTP_200_OK
assert response.json()["data"]["attributes"]["task_args"] == {
"kwarg1": "value1"
}
assert (
response.json()["data"]["attributes"]["name"]
== task1.task_runner_task.task_name
)
def test_tasks_retrieve_hides_tenant_id(
self, authenticated_client, tasks_fixture, tenants_fixture
):
task, *_ = tasks_fixture
task.task_runner_task.task_kwargs = json.dumps(
repr(
{
"tenant_id": str(tenants_fixture[0].id),
"enabled": True,
"scan_id": None,
"label": "True North",
}
)
)
task.task_runner_task.save(update_fields=["task_kwargs"])
response = authenticated_client.get(
reverse("task-detail", kwargs={"pk": task.id}),
)
assert response.status_code == status.HTTP_200_OK
assert response.json()["data"]["attributes"]["task_args"] == {
"enabled": True,
"scan_id": None,
"label": "True North",
}
def test_tasks_retrieve_with_truncated_kwargs_returns_empty_task_args(
self, authenticated_client, tasks_fixture
):
task, *_ = tasks_fixture
kwargs_repr = saferepr(
{"finding_ids": [str(uuid4()) for _ in range(30)]}, maxlen=1024
)
assert "..." in kwargs_repr
task.task_runner_task.task_kwargs = json.dumps(kwargs_repr)
task.task_runner_task.save(update_fields=["task_kwargs"])
response = authenticated_client.get(
reverse("task-detail", kwargs={"pk": task.id}),
)
assert response.status_code == status.HTTP_200_OK
assert response.headers["Content-Type"] == API_JSON_CONTENT_TYPE
assert response.json()["data"]["attributes"]["task_args"] == {}
def test_tasks_invalid_retrieve(self, authenticated_client):
response = authenticated_client.get(
reverse("task-detail", kwargs={"pk": "invalid_id"})
@@ -14793,19 +14845,94 @@ class TestTenantFinishACSView:
# Verify no new role was created
assert Role.objects.using(MainRouter.admin_db).count() == roles_before
def test_dispatch_assigns_no_role_to_new_user_when_usertype_missing(
@pytest.mark.parametrize(
(
"existing_role_attributes",
"existing_suffixes",
"expected_role_name",
"expected_role_created",
),
[
(None, (), "read_only", True),
({"unlimited_visibility": True}, (), "read_only", False),
(
{"manage_users": True, "unlimited_visibility": True},
(
("read_only_0", {"unlimited_visibility": True}),
("read_only_1", {"unlimited_visibility": True}),
),
"read_only_0",
False,
),
(
{"manage_users": True, "unlimited_visibility": True},
(
(
"read_only_0",
{"manage_users": True, "unlimited_visibility": True},
),
("read_only_1", {"unlimited_visibility": True}),
),
"read_only_1",
False,
),
({"unlimited_visibility": False}, (), "read_only_0", True),
],
ids=[
"creates-role",
"reuses-safe-role",
"reuses-first-safe-suffixed-role",
"skips-unsafe-suffixed-role",
"avoids-restricted-visibility",
],
)
def test_dispatch_assigns_read_only_role_when_usertype_missing(
self,
create_test_user,
tenants_fixture,
saml_setup,
settings,
monkeypatch,
existing_role_attributes,
existing_suffixes,
expected_role_name,
expected_role_created,
):
"""Test that a user without roles gets none assigned when userType is missing"""
"""Test safe fallback role assignment when userType is missing"""
monkeypatch.setenv("SAML_SSO_CALLBACK_URL", "http://localhost/sso-complete")
user = create_test_user
tenant = tenants_fixture[0]
roles_before = Role.objects.using(MainRouter.admin_db).count()
other_tenant = tenants_fixture[1]
other_tenant_role = Role.objects.using(MainRouter.admin_db).create(
name="read_only",
tenant=other_tenant,
unlimited_visibility=True,
)
other_tenant_relationship = UserRoleRelationship.objects.using(
MainRouter.admin_db
).create(
user=user,
role=other_tenant_role,
tenant=other_tenant,
)
existing_role = None
if existing_role_attributes is not None:
existing_role = Role.objects.using(MainRouter.admin_db).create(
name="read_only",
tenant=tenant,
**existing_role_attributes,
)
for role_name, role_attributes in existing_suffixes:
Role.objects.using(MainRouter.admin_db).create(
name=role_name,
tenant=tenant,
**role_attributes,
)
roles_before = (
Role.objects.using(MainRouter.admin_db).filter(tenant=tenant).count()
)
social_account = SocialAccount(
user=user,
@@ -14858,12 +14985,44 @@ class TestTenantFinishACSView:
assert response.status_code == 302
# Verify no role was created or assigned
assert Role.objects.using(MainRouter.admin_db).count() == roles_before
assert not (
# Verify the fallback role was created or reused with read-only access
expected_role_count = roles_before + expected_role_created
assert (
Role.objects.using(MainRouter.admin_db).filter(tenant=tenant).count()
== expected_role_count
)
role = Role.objects.using(MainRouter.admin_db).get(
name=expected_role_name, tenant=tenant
)
if existing_role is not None and expected_role_name == "read_only":
assert role == existing_role
assert not role.manage_users
assert not role.manage_account
assert not role.manage_billing
assert not role.manage_providers
assert not role.manage_integrations
assert not role.manage_scans
assert role.unlimited_visibility
assert (
UserRoleRelationship.objects.using(MainRouter.admin_db)
.filter(user=user, role=role, tenant_id=tenant.id)
.exists()
)
assert (
UserRoleRelationship.objects.using(MainRouter.admin_db)
.filter(
id=other_tenant_relationship.id,
user=user,
role=other_tenant_role,
tenant_id=other_tenant.id,
)
.exists()
)
assert (
UserRoleRelationship.objects.using(MainRouter.admin_db)
.filter(user=user, tenant_id=tenant.id)
.exists()
.count()
== 1
)
# Membership is still created so the user belongs to the tenant
@@ -14873,6 +15032,131 @@ class TestTenantFinishACSView:
.exists()
)
@pytest.mark.django_db(transaction=True)
def test_dispatch_serializes_concurrent_fallback_role_assignment(
self,
create_test_user,
tenants_fixture,
saml_setup,
monkeypatch,
):
"""Test concurrent callbacks assign only one fallback role"""
monkeypatch.setenv("SAML_SSO_CALLBACK_URL", "http://localhost/sso-complete")
user = create_test_user
tenant = tenants_fixture[0]
Role.objects.using(MainRouter.admin_db).create(
name="read_only",
tenant=tenant,
manage_users=True,
unlimited_visibility=True,
)
social_account = SocialAccount(
user=user,
provider="saml",
extra_data={
"firstName": ["John"],
"lastName": ["Doe"],
"organization": ["testing_company"],
},
)
# Without the user lock, both callbacks reach this query before either
# creates a fallback. With the lock, the first callback times out here
# while the second waits for the transaction to finish.
second_role_check_reached = Event()
concurrent_role_checks_detected = Event()
role_check_count_lock = Lock()
role_check_count = 0
original_role_check = TenantFinishACSView._user_has_tenant_role
def synchronize_role_checks(user_id, tenant_id):
nonlocal role_check_count
with role_check_count_lock:
role_check_count += 1
is_first_role_check = role_check_count == 1
if role_check_count == 2:
second_role_check_reached.set()
if is_first_role_check and second_role_check_reached.wait(timeout=1):
concurrent_role_checks_detected.set()
return original_role_check(user_id, tenant_id)
def dispatch_callback():
close_old_connections()
try:
thread_user = User.objects.using(MainRouter.admin_db).get(pk=user.pk)
request = RequestFactory().get(
reverse(
"saml_finish_acs",
kwargs={"organization_slug": saml_setup["domain"]},
)
)
request.user = thread_user
request.session = {}
response = TenantFinishACSView.as_view()(
request, organization_slug=saml_setup["domain"]
)
return response
finally:
close_old_connections()
with (
patch(
"allauth.socialaccount.providers.saml.views.get_app_or_404"
) as mock_get_app_or_404,
patch(
"allauth.socialaccount.models.SocialApp.objects.get"
) as mock_socialapp_get,
patch(
"allauth.socialaccount.models.SocialAccount.objects.get"
) as mock_sa_get,
patch("api.models.SAMLDomainIndex.objects.get") as mock_saml_domain_get,
patch("api.models.SAMLConfiguration.objects.get") as mock_saml_config_get,
patch("api.models.User.objects.get") as mock_user_get,
patch.object(
TenantFinishACSView,
"_user_has_tenant_role",
side_effect=synchronize_role_checks,
),
):
mock_get_app_or_404.return_value = MagicMock(
provider="saml",
client_id=saml_setup["domain"],
name="Test App",
settings={},
)
mock_sa_get.return_value = social_account
mock_socialapp_get.return_value = MagicMock(provider_id="saml")
mock_saml_domain_get.return_value = SimpleNamespace(tenant_id=tenant.id)
mock_saml_config_get.return_value = SimpleNamespace(
email_domain=saml_setup["domain"], tenant=tenant
)
mock_user_get.side_effect = lambda *_args, **_kwargs: User.objects.using(
MainRouter.admin_db
).get(pk=user.pk)
with ThreadPoolExecutor(max_workers=2) as executor:
responses = list(executor.map(lambda _: dispatch_callback(), range(2)))
assert role_check_count == 2
assert not concurrent_role_checks_detected.is_set()
for response in responses:
assert response.status_code == status.HTTP_302_FOUND
parsed_redirect = urlparse(response.url)
assert parsed_redirect.path == "/sso-complete"
assert set(parse_qs(parsed_redirect.query)) == {"id"}
relationships = UserRoleRelationship.objects.using(MainRouter.admin_db).filter(
user=user, tenant_id=tenant.id
)
assert relationships.count() == 1
assert relationships.get().role.name == "read_only_0"
assert (
Role.objects.using(MainRouter.admin_db)
.filter(tenant=tenant, name__startswith="read_only_")
.count()
== 1
)
def test_dispatch_skips_role_mapping_when_last_manage_account_user_maps_to_new_role(
self,
create_test_user,
@@ -15748,6 +16032,23 @@ class TestTenantApiKeyViewSet:
data = response.json()["data"]
assert len(data) == len(api_keys_fixture)
def test_api_keys_list_with_orphaned_key(
self, authenticated_client, api_keys_fixture
):
"""Test listing keys whose owner was deleted: `entity` is serialized as null."""
orphaned_key = api_keys_fixture[0]
TenantAPIKey.objects.filter(id=orphaned_key.id).update(entity=None)
response = authenticated_client.get(reverse("api-key-list"))
assert response.status_code == status.HTTP_200_OK
data = response.json()["data"]
assert len(data) == len(api_keys_fixture)
serialized_key = next(
item for item in data if item["id"] == str(orphaned_key.id)
)
assert serialized_key["relationships"]["entity"]["data"] is None
def test_api_keys_list_empty(self, authenticated_client, tenants_fixture):
"""Test listing API keys when none exist returns empty list."""
response = authenticated_client.get(reverse("api-key-list"))
+18
View File
@@ -6,9 +6,11 @@ from api.exceptions import (
TaskNotFoundException,
)
from api.models import Provider, StateChoices, Task
from api.rbac.permissions import get_providers
from api.v1.serializers import TaskSerializer
from django.http import QueryDict
from django.urls import reverse
from django.utils.functional import cached_property
from django_celery_results.models import TaskResult
from rest_framework import status
from rest_framework.exceptions import ValidationError
@@ -33,6 +35,22 @@ class DisablePaginationMixin:
return super().paginate_queryset(queryset)
class ProviderVisibilityMixin:
@cached_property
def provider_queryset(self):
if self.user_role.unlimited_visibility:
return Provider.objects.filter(tenant_id=self.request.tenant_id)
return get_providers(self.user_role)
def get_provider_queryset(self):
return self.provider_queryset
def get_serializer_context(self):
context = super().get_serializer_context()
context["provider_queryset"] = self.get_provider_queryset()
return context
class PaginateByPkMixin:
"""
Mixin to paginate on a list of PKs (cheaper than heavy JOINs),
@@ -1,7 +1,9 @@
import os
import re
from api.models import Integration, IntegrationProviderRelationship, Provider
from api.v1.serializer_utils.base import BaseValidateSerializer
from django.db import transaction
from drf_spectacular.utils import extend_schema_field
from rest_framework_json_api import serializers
@@ -10,6 +12,24 @@ ATLASSIAN_SITE_NAME_REGEX = re.compile(
)
def replace_integration_providers(
integration: Integration, providers: list[Provider], tenant_id: str
) -> None:
"""Replace the provider relationships of an integration with the given set."""
# Atomic on its own, so callers without an ambient transaction cannot leave the
# integration with no relationships if the recreation fails halfway
with transaction.atomic():
IntegrationProviderRelationship.objects.filter(integration=integration).delete()
IntegrationProviderRelationship.objects.bulk_create(
[
IntegrationProviderRelationship(
integration=integration, provider=provider, tenant_id=tenant_id
)
for provider in providers
]
)
class S3ConfigSerializer(BaseValidateSerializer):
bucket_name = serializers.CharField()
output_directory = serializers.CharField(allow_blank=True)
@@ -214,7 +214,7 @@ from rest_framework_json_api import serializers
"kubeconfig_content": {
"type": "string",
"description": "The content of the Kubernetes kubeconfig file, encoded as a string. "
"Kubeconfig exec authentication is not supported in Prowler Cloud for security reasons.",
"Kubeconfig command-based authentication is not supported in Prowler Cloud for security reasons.",
}
},
"required": ["kubeconfig_content"],
+142 -51
View File
@@ -1,8 +1,10 @@
import base64
import json
import logging
from datetime import UTC, datetime, timedelta
import yaml
from api.celery_utils import decode_celery_field
from api.db_router import MainRouter
from api.exceptions import ConflictException
from api.models import (
@@ -47,6 +49,7 @@ from api.v1.serializer_utils.integrations import (
JiraCredentialSerializer,
S3ConfigSerializer,
SecurityHubConfigSerializer,
replace_integration_providers,
)
from api.v1.serializer_utils.lighthouse import (
BedrockCredentialsSerializer,
@@ -58,6 +61,7 @@ from api.v1.serializer_utils.lighthouse import (
from api.v1.serializer_utils.processors import ProcessorConfigField
from api.v1.serializer_utils.providers import ProviderSecretField
from api.validators import validate_lighthouse_openai_compatible_base_url
from config.custom_logging import BackendLogger
from django.conf import settings
from django.contrib.auth import authenticate
from django.contrib.auth.models import update_last_login
@@ -78,6 +82,8 @@ from rest_framework_simplejwt.settings import api_settings
from rest_framework_simplejwt.tokens import RefreshToken
from rest_framework_simplejwt.utils import get_md5_hash_password
logger = logging.getLogger(BackendLogger.API)
# Base
@@ -123,6 +129,20 @@ class RLSSerializer(BaseModelSerializerV1):
return super().create(validated_data)
class ScopedProviderFieldMixin:
provider_field_name = "provider"
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
provider_queryset = self.context.get("provider_queryset")
provider_field = self.fields.get(self.provider_field_name)
if provider_queryset is None or provider_field is None:
return
related_field = getattr(provider_field, "child_relation", provider_field)
related_field.queryset = provider_queryset
class StateEnumSerializerField(serializers.ChoiceField):
def __init__(self, **kwargs):
kwargs["choices"] = StateChoices.choices
@@ -606,13 +626,24 @@ class TaskSerializer(RLSSerializer, TaskBase):
@extend_schema_field(serializers.JSONField())
def get_task_args(self, obj):
task_args = self.get_json_field(obj, "task_kwargs")
# Celery task_kwargs are stored as a double string JSON in the database when not empty
if isinstance(task_args, str):
task_args = json.loads(task_args.replace("'", '"').replace("None", "null"))
# Remove tenant_id from task_kwargs if present
task_args.pop("tenant_id", None)
task_kwargs = (
getattr(obj.task_runner_task, "task_kwargs", None)
if obj.task_runner_task
else None
)
try:
task_args = decode_celery_field(task_kwargs, {})
if not isinstance(task_args, dict):
raise ValueError("Decoded task kwargs must be a dictionary")
except ValueError:
logger.warning(
"Unable to decode task kwargs for task %s; returning empty task_args.",
obj.id,
)
return {}
task_args = task_args.copy()
task_args.pop("tenant_id", None)
return task_args
@staticmethod
@@ -692,7 +723,10 @@ class MembershipIncludeSerializer(serializers.ModelSerializer):
# Provider Groups
class ProviderGroupSerializer(RLSSerializer, BaseWriteSerializer):
class ProviderGroupSerializer(
ScopedProviderFieldMixin, RLSSerializer, BaseWriteSerializer
):
provider_field_name = "providers"
providers = serializers.ResourceRelatedField(
queryset=Provider.objects.all(), many=True, required=False
)
@@ -850,9 +884,27 @@ class ProviderGroupMembershipSerializer(RLSSerializer, BaseWriteSerializer):
help_text="List of resource identifier objects representing providers.",
)
def get_providers(self, validated_data):
provider_ids = {item["id"] for item in validated_data["providers"]}
provider_queryset = self.context.get("provider_queryset")
if provider_queryset is None:
provider_queryset = Provider.objects.filter(
tenant_id=self.context.get("tenant_id")
)
providers = list(provider_queryset.filter(id__in=provider_ids))
if {provider.id for provider in providers} != provider_ids:
raise serializers.ValidationError(
{
"providers": (
"One or more providers do not exist or are not accessible."
)
}
)
return providers
def create(self, validated_data):
provider_ids = [item["id"] for item in validated_data["providers"]]
providers = Provider.objects.filter(id__in=provider_ids)
providers = self.get_providers(validated_data)
tenant_id = self.context.get("tenant_id")
new_relationships = [
@@ -868,8 +920,7 @@ class ProviderGroupMembershipSerializer(RLSSerializer, BaseWriteSerializer):
return self.context.get("provider_group")
def update(self, instance, validated_data):
provider_ids = [item["id"] for item in validated_data["providers"]]
providers = Provider.objects.filter(id__in=provider_ids)
providers = self.get_providers(validated_data)
tenant_id = self.context.get("tenant_id")
instance.providers.clear()
@@ -1108,7 +1159,9 @@ class ScanIncludeSerializer(RLSSerializer):
}
class ScanCreateSerializer(RLSSerializer, BaseWriteSerializer):
class ScanCreateSerializer(
ScopedProviderFieldMixin, RLSSerializer, BaseWriteSerializer
):
class Meta:
model = Scan
# TODO: add mutelist when implemented
@@ -1568,14 +1621,14 @@ class FindingMetadataSerializer(BaseSerializerV1):
# Provider secrets
KUBERNETES_KUBECONFIG_EXEC_ERROR = (
"Kubernetes kubeconfig exec authentication is not supported in Prowler Cloud "
"for security reasons."
KUBERNETES_KUBECONFIG_UNSUPPORTED_COMMAND_AUTH_ERROR = (
"Kubernetes kubeconfig command-based authentication is not supported in "
"Prowler Cloud for security reasons."
)
KUBERNETES_KUBECONFIG_INVALID_ERROR = "Invalid Kubernetes kubeconfig content."
def kubeconfig_contains_exec_auth(kubeconfig: dict) -> bool:
def kubeconfig_contains_unsupported_command_auth(kubeconfig: dict) -> bool:
users = kubeconfig.get("users", [])
if not isinstance(users, list):
raise ValidationError(KUBERNETES_KUBECONFIG_INVALID_ERROR)
@@ -1591,6 +1644,17 @@ def kubeconfig_contains_exec_auth(kubeconfig: dict) -> bool:
if "exec" in user:
return True
auth_provider = user.get("auth-provider", {})
if not isinstance(auth_provider, dict):
continue
auth_provider_config = auth_provider.get("config", {})
if not isinstance(auth_provider_config, dict):
continue
if "cmd-path" in auth_provider_config:
return True
return False
@@ -1787,8 +1851,10 @@ class KubernetesProviderSecret(serializers.Serializer):
if not isinstance(kubeconfig, dict):
raise serializers.ValidationError(KUBERNETES_KUBECONFIG_INVALID_ERROR)
if kubeconfig_contains_exec_auth(kubeconfig):
raise serializers.ValidationError(KUBERNETES_KUBECONFIG_EXEC_ERROR)
if kubeconfig_contains_unsupported_command_auth(kubeconfig):
raise serializers.ValidationError(
KUBERNETES_KUBECONFIG_UNSUPPORTED_COMMAND_AUTH_ERROR
)
return kubeconfig_content
@@ -1960,7 +2026,9 @@ class ProviderSecretSerializer(RLSSerializer):
]
class ProviderSecretCreateSerializer(RLSSerializer, BaseWriteProviderSecretSerializer):
class ProviderSecretCreateSerializer(
ScopedProviderFieldMixin, RLSSerializer, BaseWriteProviderSecretSerializer
):
secret = ProviderSecretField(write_only=True)
class Meta:
@@ -2743,6 +2811,37 @@ class ScheduleDailyCreateSerializer(BaseSerializerV1):
# Integrations
class IntegrationProviderVisibilityMixin:
"""
Keep the `providers` relationship within the provider visibility of the role.
The view injects `allowed_providers` in the serializer context: `None` when the role
has unlimited visibility, and the queryset of visible providers otherwise. Roles with
limited visibility can neither attach providers they cannot see nor discover, through
the serialized output, the ones already attached.
"""
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
allowed_providers = self.context.get("allowed_providers")
if allowed_providers is not None:
self.fields["providers"].child_relation.queryset = allowed_providers
def hide_restricted_providers(self, representation: dict) -> dict:
allowed_providers = self.context.get("allowed_providers")
# `providers` is missing when the request asks for a subset of the fields
if allowed_providers is None or "providers" not in representation:
return representation
allowed_provider_ids = {str(provider.id) for provider in allowed_providers}
representation["providers"] = [
provider
for provider in representation["providers"]
if provider["id"] in allowed_provider_ids
]
return representation
class BaseWriteIntegrationSerializer(BaseWriteSerializer):
def validate(self, attrs):
integration_type = attrs.get("integration_type")
@@ -2875,7 +2974,7 @@ class BaseWriteIntegrationSerializer(BaseWriteSerializer):
)
class IntegrationSerializer(RLSSerializer):
class IntegrationSerializer(IntegrationProviderVisibilityMixin, RLSSerializer):
"""
Serializer for the Integration model.
"""
@@ -2904,15 +3003,9 @@ class IntegrationSerializer(RLSSerializer):
}
def to_representation(self, instance):
representation = super().to_representation(instance)
allowed_providers = self.context.get("allowed_providers")
if allowed_providers:
allowed_provider_ids = {str(provider.id) for provider in allowed_providers}
representation["providers"] = [
provider
for provider in representation["providers"]
if provider["id"] in allowed_provider_ids
]
representation = self.hide_restricted_providers(
super().to_representation(instance)
)
if instance.integration_type == Integration.IntegrationChoices.JIRA:
representation["configuration"].update(
{"domain": instance.credentials.get("domain")}
@@ -2920,7 +3013,9 @@ class IntegrationSerializer(RLSSerializer):
return representation
class IntegrationCreateSerializer(BaseWriteIntegrationSerializer):
class IntegrationCreateSerializer(
IntegrationProviderVisibilityMixin, BaseWriteIntegrationSerializer
):
credentials = IntegrationCredentialField(write_only=True)
configuration = IntegrationConfigField()
providers = serializers.ResourceRelatedField(
@@ -2971,22 +3066,18 @@ class IntegrationCreateSerializer(BaseWriteIntegrationSerializer):
tenant_id = self.context.get("tenant_id")
providers = validated_data.pop("providers", [])
integration = Integration.objects.create(tenant_id=tenant_id, **validated_data)
through_model_instances = [
IntegrationProviderRelationship(
integration=integration,
provider=provider,
tenant_id=tenant_id,
with transaction.atomic():
integration = Integration.objects.create(
tenant_id=tenant_id, **validated_data
)
for provider in providers
]
IntegrationProviderRelationship.objects.bulk_create(through_model_instances)
replace_integration_providers(integration, providers, tenant_id)
return integration
class IntegrationUpdateSerializer(BaseWriteIntegrationSerializer):
class IntegrationUpdateSerializer(
IntegrationProviderVisibilityMixin, BaseWriteIntegrationSerializer
):
credentials = IntegrationCredentialField(write_only=True, required=False)
configuration = IntegrationConfigField(required=False)
providers = serializers.ResourceRelatedField(
@@ -3031,15 +3122,13 @@ class IntegrationUpdateSerializer(BaseWriteIntegrationSerializer):
def update(self, instance, validated_data):
tenant_id = self.context.get("tenant_id")
if validated_data.get("providers") is not None:
instance.providers.clear()
new_relationships = [
IntegrationProviderRelationship(
integration=instance, provider=provider, tenant_id=tenant_id
)
for provider in validated_data["providers"]
]
IntegrationProviderRelationship.objects.bulk_create(new_relationships)
# Relationships are replaced here, so they are kept out of the default
# `ModelSerializer.update()`, which would otherwise reset them all. The view
# rejects updates on integrations shared with providers hidden to the role, so
# every existing relationship is visible to the requester at this point
providers = validated_data.pop("providers", None)
if providers is not None:
replace_integration_providers(instance, providers, tenant_id)
# Preserve regions field for Security Hub integrations
if instance.integration_type == Integration.IntegrationChoices.AWS_SECURITY_HUB:
@@ -3051,7 +3140,9 @@ class IntegrationUpdateSerializer(BaseWriteIntegrationSerializer):
return super().update(instance, validated_data)
def to_representation(self, instance):
representation = super().to_representation(instance)
representation = self.hide_restricted_providers(
super().to_representation(instance)
)
# Ensure JIRA integrations show updated domain in configuration from credentials
if instance.integration_type == Integration.IntegrationChoices.JIRA:
representation["configuration"].update(
+186 -67
View File
@@ -124,7 +124,12 @@ from api.models import (
UserRoleRelationship,
)
from api.pagination import ComplianceOverviewPagination
from api.rbac.permissions import Permissions, get_providers, get_role
from api.rbac.permissions import (
Permissions,
get_integrations,
get_providers,
get_role,
)
from api.renderers import APIJSONRenderer, PlainTextRenderer
from api.rls import Tenant
from api.utils import (
@@ -141,6 +146,7 @@ from api.v1.mixins import (
JsonApiFilterMixin,
PaginateByPkMixin,
ProviderFilterParamsMixin,
ProviderVisibilityMixin,
TaskManagementMixin,
)
from api.v1.serializers import (
@@ -281,6 +287,7 @@ from django.shortcuts import redirect
from django.urls import reverse
from django.utils.dateparse import parse_date
from django.utils.decorators import method_decorator
from django.utils.functional import cached_property
from django.views.decorators.cache import cache_control
from django_celery_beat.models import PeriodicTask
from drf_spectacular.settings import spectacular_settings
@@ -807,6 +814,21 @@ class TenantFinishACSView(FinishACSView):
User.objects.using(MainRouter.admin_db).filter(id=saml_user_id).delete()
request.session.pop("saml_user_created", None)
@staticmethod
def _user_has_tenant_role(user_id, tenant_id):
return (
UserRoleRelationship.objects.using(MainRouter.admin_db)
.filter(user_id=user_id, tenant_id=tenant_id)
.exists()
)
@staticmethod
def _is_read_only_fallback_role(role):
return (
not any(getattr(role, permission) for permission in Role.PERMISSION_FIELDS)
and role.unlimited_visibility
)
def dispatch(self, request, organization_slug):
try:
super().dispatch(request, organization_slug)
@@ -872,11 +894,56 @@ class TenantFinishACSView(FinishACSView):
user.name = "N/A"
user.save()
# Only remap roles when the IdP provides a userType attribute.
# Without it, the user's current roles are left untouched.
# Only remap existing roles when the IdP provides a userType attribute.
# Without it, preserve current roles or assign a read-only fallback.
role_name = (
extra.get("userType", [""])[0].strip() if extra.get("userType") else ""
)
if not role_name:
with rls_transaction(str(tenant.id), using=MainRouter.admin_db):
with transaction.atomic(using=MainRouter.admin_db):
# Serialize concurrent ACS callbacks for the same user.
(
User.objects.using(MainRouter.admin_db)
.select_for_update()
.only("id")
.get(pk=user_id)
)
user_has_roles = self._user_has_tenant_role(user_id, tenant.id)
if not user_has_roles:
read_only_defaults = dict.fromkeys(
Role.PERMISSION_FIELDS, False
)
read_only_defaults["unlimited_visibility"] = True
role, role_created = Role.objects.using(
MainRouter.admin_db
).get_or_create(
name="read_only",
tenant=tenant,
defaults=read_only_defaults,
)
role_is_read_only = self._is_read_only_fallback_role(role)
if not role_created and not role_is_read_only:
suffix = 0
while not role_created and not role_is_read_only:
role, role_created = Role.objects.using(
MainRouter.admin_db
).get_or_create(
name=f"read_only_{suffix}",
tenant=tenant,
defaults=read_only_defaults,
)
role_is_read_only = self._is_read_only_fallback_role(
role
)
suffix += 1
UserRoleRelationship.objects.using(
MainRouter.admin_db
).get_or_create(
user=user,
role=role,
defaults={"tenant": tenant},
)
if role_name:
with transaction.atomic(using=MainRouter.admin_db):
role = (
@@ -1626,7 +1693,7 @@ class TenantMembersViewSet(BaseTenantViewset):
),
update=extend_schema(exclude=True),
)
class ProviderGroupViewSet(BaseRLSViewSet):
class ProviderGroupViewSet(ProviderVisibilityMixin, BaseRLSViewSet):
queryset = ProviderGroup.objects.all()
serializer_class = ProviderGroupSerializer
filterset_class = ProviderGroupFilter
@@ -1647,14 +1714,13 @@ class ProviderGroupViewSet(BaseRLSViewSet):
self.required_permissions = [Permissions.MANAGE_PROVIDERS]
def get_queryset(self):
user_roles = get_role(self.request.user, self.request.tenant_id)
# Check if any of the user's roles have UNLIMITED_VISIBILITY
if user_roles.unlimited_visibility:
# User has unlimited visibility, return all provider groups
return ProviderGroup.objects.prefetch_related("providers", "roles")
# Collect provider groups associated with the user's roles
return user_roles.provider_groups.all().prefetch_related("providers", "roles")
if self.user_role.unlimited_visibility:
queryset = ProviderGroup.objects.filter(tenant_id=self.request.tenant_id)
else:
queryset = self.user_role.provider_groups.filter(
tenant_id=self.request.tenant_id
)
return queryset.prefetch_related("providers", "roles")
def get_serializer_class(self):
if self.action == "create":
@@ -1695,7 +1761,9 @@ class ProviderGroupViewSet(BaseRLSViewSet):
},
),
)
class ProviderGroupProvidersRelationshipView(RelationshipView, BaseRLSViewSet):
class ProviderGroupProvidersRelationshipView(
ProviderVisibilityMixin, RelationshipView, BaseRLSViewSet
):
queryset = ProviderGroup.objects.all()
serializer_class = ProviderGroupMembershipSerializer
resource_name = "providers"
@@ -1705,7 +1773,9 @@ class ProviderGroupProvidersRelationshipView(RelationshipView, BaseRLSViewSet):
required_permissions = [Permissions.MANAGE_PROVIDERS]
def get_queryset(self):
return ProviderGroup.objects.filter(tenant_id=self.request.tenant_id)
if self.user_role.unlimited_visibility:
return ProviderGroup.objects.filter(tenant_id=self.request.tenant_id)
return self.user_role.provider_groups.filter(tenant_id=self.request.tenant_id)
def create(self, request, *args, **kwargs):
provider_group = self.get_object()
@@ -1727,6 +1797,7 @@ class ProviderGroupProvidersRelationshipView(RelationshipView, BaseRLSViewSet):
data={"providers": request.data},
context={
"provider_group": provider_group,
"provider_queryset": self.get_provider_queryset(),
"tenant_id": self.request.tenant_id,
"request": request,
},
@@ -1741,7 +1812,11 @@ class ProviderGroupProvidersRelationshipView(RelationshipView, BaseRLSViewSet):
serializer = self.get_serializer(
instance=provider_group,
data={"providers": request.data},
context={"tenant_id": self.request.tenant_id, "request": request},
context={
"provider_queryset": self.get_provider_queryset(),
"tenant_id": self.request.tenant_id,
"request": request,
},
)
serializer.is_valid(raise_exception=True)
serializer.save()
@@ -1858,7 +1933,7 @@ class ProviderViewSet(DisablePaginationMixin, BaseRLSViewSet):
)
@action(detail=True, methods=["post"], url_name="connection")
def connection(self, request, pk=None):
get_object_or_404(Provider, pk=pk)
self.get_object()
with transaction.atomic():
task = check_provider_connection_task.delay(
provider_id=pk, tenant_id=self.request.tenant_id
@@ -1876,7 +1951,7 @@ class ProviderViewSet(DisablePaginationMixin, BaseRLSViewSet):
)
def destroy(self, request, *args, pk=None, **kwargs):
provider = get_object_or_404(Provider, pk=pk)
provider = self.get_object()
provider.is_deleted = True
provider.save()
task_name = f"scan-perform-scheduled-{pk}"
@@ -2098,7 +2173,7 @@ class ProviderViewSet(DisablePaginationMixin, BaseRLSViewSet):
)
@method_decorator(CACHE_DECORATOR, name="list")
@method_decorator(CACHE_DECORATOR, name="retrieve")
class ScanViewSet(BaseRLSViewSet):
class ScanViewSet(ProviderVisibilityMixin, BaseRLSViewSet):
queryset = Scan.objects.all()
serializer_class = ScanSerializer
http_method_names = ["get", "post", "patch"]
@@ -2127,13 +2202,7 @@ class ScanViewSet(BaseRLSViewSet):
self.required_permissions = [Permissions.MANAGE_SCANS]
def get_queryset(self):
user_roles = get_role(self.request.user, self.request.tenant_id)
if user_roles.unlimited_visibility:
# User has unlimited visibility, return all scans
queryset = Scan.objects.filter(tenant_id=self.request.tenant_id)
else:
# User lacks permission, filter providers based on provider groups associated with the role
queryset = Scan.objects.filter(provider__in=get_providers(user_roles))
queryset = Scan.objects.filter(provider__in=self.get_provider_queryset())
return queryset.select_related("provider", "task")
def get_serializer_class(self):
@@ -2731,6 +2800,7 @@ class ScanViewSet(BaseRLSViewSet):
provider = Provider.objects.select_for_update().get(
id=provider.id,
tenant_id=self.request.tenant_id,
id__in=self.get_provider_queryset().values("id"),
)
active_scan = get_active_provider_scan(
self.request.tenant_id, provider.id
@@ -4305,7 +4375,7 @@ class FindingViewSet(PaginateByPkMixin, BaseRLSViewSet):
)
@method_decorator(CACHE_DECORATOR, name="list")
@method_decorator(CACHE_DECORATOR, name="retrieve")
class ProviderSecretViewSet(BaseRLSViewSet):
class ProviderSecretViewSet(ProviderVisibilityMixin, BaseRLSViewSet):
queryset = ProviderSecret.objects.all()
serializer_class = ProviderSecretSerializer
filterset_class = ProviderSecretFilter
@@ -4321,7 +4391,7 @@ class ProviderSecretViewSet(BaseRLSViewSet):
required_permissions = [Permissions.MANAGE_PROVIDERS]
def get_queryset(self):
return ProviderSecret.objects.filter(tenant_id=self.request.tenant_id)
return ProviderSecret.objects.filter(provider__in=self.get_provider_queryset())
def get_serializer_class(self):
if self.action == "create":
@@ -6602,7 +6672,7 @@ class OverviewViewSet(ProviderFilterParamsMixin, BaseRLSViewSet):
responses={202: OpenApiResponse(response=TaskSerializer)},
)
)
class ScheduleViewSet(BaseRLSViewSet):
class ScheduleViewSet(ProviderVisibilityMixin, BaseRLSViewSet):
# TODO: change to Schedule when implemented
queryset = Task.objects.none()
http_method_names = ["post"]
@@ -6629,7 +6699,9 @@ class ScheduleViewSet(BaseRLSViewSet):
serializer.is_valid(raise_exception=True)
provider_id = serializer.validated_data["provider_id"]
provider_instance = get_object_or_404(Provider, pk=provider_id)
provider_instance = get_object_or_404(
self.get_provider_queryset(), pk=provider_id
)
with transaction.atomic():
task = schedule_provider_scan(provider_instance)
@@ -6652,27 +6724,34 @@ class ScheduleViewSet(BaseRLSViewSet):
list=extend_schema(
tags=["Integration"],
summary="List all integrations",
description="Retrieve a list of all configured integrations with options for filtering by various criteria.",
description="Retrieve a list of all configured integrations with options for filtering by various criteria.\n\n"
"Integrations attached to one or more providers are only returned when the role can access at least one of "
"those providers, and each integration lists only the providers visible to the role. Integrations not "
"attached to any provider, such as Jira, are tenant-wide and are returned for every role.",
),
retrieve=extend_schema(
tags=["Integration"],
summary="Retrieve integration details",
description="Fetch detailed information about a specific integration by its ID.",
description="Fetch detailed information about a specific integration by its ID. Integrations outside the "
"provider visibility of the role are reported the same way as one that does not exist.",
),
create=extend_schema(
tags=["Integration"],
summary="Create a new integration",
description="Register a new integration with the system, providing necessary configuration details.",
description="Register a new integration with the system, providing necessary configuration details. Only "
"providers visible to the role can be attached to the integration.",
),
partial_update=extend_schema(
tags=["Integration"],
summary="Partially update an integration",
description="Modify certain fields of an existing integration without affecting other settings.",
description="Modify certain fields of an existing integration without affecting other settings. Integrations "
"attached to providers outside the visibility of the role cannot be modified by it.",
),
destroy=extend_schema(
tags=["Integration"],
summary="Delete an integration",
description="Remove an integration from the system by its ID.",
description="Remove an integration from the system by its ID. Integrations attached to providers outside "
"the visibility of the role cannot be deleted by it.",
),
)
@method_decorator(CACHE_DECORATOR, name="list")
@@ -6685,18 +6764,27 @@ class IntegrationViewSet(BaseRLSViewSet):
ordering = ["integration_type", "-inserted_at"]
# RBAC required permissions
required_permissions = [Permissions.MANAGE_INTEGRATIONS]
allowed_providers = None
@cached_property
def allowed_providers(self):
"""
Providers the role can access, or None when it has unlimited visibility.
Resolved per request and independently of the action, so that writes are scoped
as tightly as reads.
"""
if self.user_role.unlimited_visibility:
return None
return get_providers(self.user_role)
def get_queryset(self):
user_roles = get_role(self.request.user, self.request.tenant_id)
if user_roles.unlimited_visibility:
# User has unlimited visibility, return all integrations
queryset = Integration.objects.filter(tenant_id=self.request.tenant_id)
else:
# User lacks permission, filter providers based on provider groups associated with the role
allowed_providers = get_providers(user_roles)
queryset = Integration.objects.filter(providers__in=allowed_providers)
self.allowed_providers = allowed_providers
queryset = get_integrations(self.user_role, providers=self.allowed_providers)
if self.allowed_providers is not None and self.action in ("list", "retrieve"):
# Restrict the relationship itself, so that the providers hidden to the role
# are left out of the sideloaded resources of `?include=providers` too
queryset = queryset.prefetch_related(
Prefetch("providers", queryset=self.allowed_providers)
)
return queryset
def get_serializer_class(self):
@@ -6711,16 +6799,33 @@ class IntegrationViewSet(BaseRLSViewSet):
context["allowed_providers"] = self.allowed_providers
return context
def get_object(self):
instance = super().get_object()
# Writes on an integration shared with providers hidden to the role would reach
# beyond its visibility, so both editing and deleting are rejected consistently
if (
self.action in ("partial_update", "destroy")
and self.allowed_providers is not None
and instance.providers.exclude(
id__in=self.allowed_providers.values("id")
).exists()
):
raise PermissionDenied(
"The integration is attached to providers outside the visibility of your role."
)
return instance
@extend_schema(
tags=["Integration"],
summary="Check integration connection",
description="Try to verify integration connection",
description="Try to verify integration connection. Integrations outside the provider visibility of the role "
"are reported the same way as one that does not exist.",
request=None,
responses={202: OpenApiResponse(response=TaskSerializer)},
)
@action(detail=True, methods=["post"], url_name="connection")
def connection(self, request, pk=None):
get_object_or_404(Integration, pk=pk)
get_object_or_404(self.get_queryset(), pk=pk)
with transaction.atomic():
task = check_integration_connection_task.delay(
integration_id=pk, tenant_id=self.request.tenant_id
@@ -6743,7 +6848,8 @@ class IntegrationViewSet(BaseRLSViewSet):
tags=["Integration"],
summary="Send findings to a Jira integration",
description="Send a set of filtered findings to the given integration. At least one finding filter must be "
"provided.\n\n"
"provided. Jira integrations are tenant-wide and do not require unlimited visibility, while the findings "
"sent are limited to the providers the role can access.\n\n"
"## Known Limitations\n\n"
"### Issue Types with Required Custom Fields\n\n"
"Certain Jira issue types (such as Epic) may require mandatory custom fields that Prowler does not "
@@ -6787,24 +6893,37 @@ class IntegrationJiraViewSet(BaseRLSViewSet):
return []
return super().get_filter_backends()
def get_queryset(self):
tenant_id = self.request.tenant_id
user_roles = get_role(self.request.user, self.request.tenant_id)
if user_roles.unlimited_visibility:
# User has unlimited visibility, return all findings
queryset = Finding.all_objects.filter(tenant_id=tenant_id)
else:
# User lacks permission, filter findings based on provider groups associated with the role
queryset = Finding.all_objects.filter(
scan__provider__in=get_providers(user_roles)
)
@cached_property
def allowed_providers(self):
"""
Providers the role can access, or None when it has unlimited visibility.
return queryset
Resolved once per request and shared between the findings queryset and the
integration lookup.
"""
if self.user_role.unlimited_visibility:
return None
return get_providers(self.user_role)
def get_queryset(self):
if self.allowed_providers is None:
# User has unlimited visibility, return all findings
return Finding.all_objects.filter(tenant_id=self.request.tenant_id)
# Findings are limited to the providers the role can access
return Finding.all_objects.filter(scan__provider__in=self.allowed_providers)
def get_integration(self, integration_pk):
"""Retrieve the integration, honoring the provider visibility of the user's role."""
return get_object_or_404(
get_integrations(self.user_role, providers=self.allowed_providers),
pk=integration_pk,
)
@extend_schema(
tags=["Integration"],
summary="Get available issue types for a Jira project",
description="Fetch the available issue types from Jira for a given project key and update the integration configuration.",
description="Fetch the available issue types from Jira for a given project key and update the integration "
"configuration. Jira integrations are tenant-wide and do not require unlimited visibility.",
parameters=[
OpenApiParameter(
name="project_key",
@@ -6817,7 +6936,7 @@ class IntegrationJiraViewSet(BaseRLSViewSet):
)
@action(detail=False, methods=["get"], url_name="issue-types")
def issue_types(self, request, integration_pk=None):
integration = get_object_or_404(Integration, pk=integration_pk)
integration = self.get_integration(integration_pk)
project_key = request.query_params.get("project_key")
if not project_key:
@@ -6862,23 +6981,23 @@ class IntegrationJiraViewSet(BaseRLSViewSet):
@action(detail=False, methods=["post"], url_name="dispatches")
def dispatches(self, request, integration_pk=None):
get_object_or_404(Integration, pk=integration_pk)
self.get_integration(integration_pk)
serializer = self.get_serializer(
data=request.data, context={"integration_id": integration_pk}
)
serializer.is_valid(raise_exception=True)
if self.filter_queryset(self.get_queryset()).count() == 0:
raise ValidationError(
{"findings": "No findings match the provided filters"}
)
finding_ids = [
str(finding_id)
for finding_id in self.filter_queryset(self.get_queryset()).values_list(
"id", flat=True
)
]
if not finding_ids:
raise ValidationError(
{"findings": "No findings match the provided filters"}
)
project_key = serializer.validated_data["project_key"]
issue_type = serializer.validated_data["issue_type"]
+4 -1
View File
@@ -1,5 +1,6 @@
import os
import time
from datetime import UTC, datetime
from glob import glob
from api.db_router import READ_REPLICA_ALIAS, MainRouter
@@ -214,8 +215,10 @@ def get_security_hub_client_from_integration(
for region in set(all_security_hub_regions):
regions_status[region] = region in connection.enabled_regions
# Save regions information in the integration configuration
# Persist the successful connection check and regions information
with rls_transaction(tenant_id, using=MainRouter.default_db):
integration.connected = True
integration.connection_last_checked_at = datetime.now(tz=UTC)
integration.configuration["regions"] = regions_status
integration.save()
+7 -34
View File
@@ -18,12 +18,11 @@ This is the shared engine behind both the periodic Beat watchdog and the
`reconcile_orphan_tasks` management command.
"""
import ast
import json
from contextlib import contextmanager
from datetime import UTC, datetime, timedelta
from uuid import uuid4
from api.celery_utils import decode_celery_field
from celery import current_app, states
from celery.utils.log import get_task_logger
from django.db import connections
@@ -138,34 +137,6 @@ def revoke_task(task_result, terminate: bool = True) -> None:
logger.exception(f"Failed to revoke task {task_result.task_id}")
def _decode_celery_field(value, default):
"""Decode django-celery-results' stored task_args/task_kwargs to a Python object.
The backend stores them as a (sometimes double-encoded) repr/JSON string. An
empty or missing field returns ``default``; a non-empty value that cannot be
decoded raises ``ValueError`` so the caller can avoid re-enqueuing a task with
the wrong arguments.
"""
obj = value
for _ in range(2): # values can be double-encoded (a string holding a repr)
if not isinstance(obj, str):
break
text = obj.strip()
if not text:
return default
parsed = None
for parser in (ast.literal_eval, json.loads):
try:
parsed = parser(text)
break
except (ValueError, SyntaxError, TypeError):
continue
if parsed is None:
raise ValueError(f"undecodable celery field: {text[:120]!r}")
obj = parsed
return default if obj is None else obj
def reconcile_orphans(
grace_minutes: int = 2,
max_attempts: int = 3,
@@ -313,8 +284,10 @@ def _recover_task(task_result, max_attempts: int, window_hours: int) -> str:
return "failed"
try:
args = _decode_celery_field(args_repr, [])
kwargs = _decode_celery_field(kwargs_repr, {})
args = decode_celery_field(args_repr, [])
kwargs = decode_celery_field(kwargs_repr, {})
if not isinstance(args, (list, tuple)) or not isinstance(kwargs, dict):
raise ValueError("Stored task arguments have invalid types")
except ValueError:
logger.error(
"Orphan %s (%s): could not decode stored args/kwargs, not re-enqueuing",
@@ -324,8 +297,8 @@ def _recover_task(task_result, max_attempts: int, window_hours: int) -> str:
return "failed"
new_task_id = str(uuid4())
task_obj.apply_async(
args=list(args) if isinstance(args, (list, tuple)) else [],
kwargs=kwargs if isinstance(kwargs, dict) else {},
args=list(args),
kwargs=kwargs,
task_id=new_task_id,
)
logger.info(
+150 -20
View File
@@ -1,3 +1,4 @@
import copy
import csv
import io
import json
@@ -695,6 +696,45 @@ def _process_finding_micro_batch(
scan_resource_groups_cache: Dict tracking resource group counts {(resource_group, severity): {"total", "failed", "new_failed"}}.
group_resources_cache: Dict tracking unique resources per group {resource_group: set(resource_uids)}.
"""
def build_resource_defaults_from_finding(finding: ProwlerFinding) -> dict[str, Any]:
check_metadata = finding.get_metadata()
group = check_metadata.get("resourcegroup") or None
return {
"tenant_id": tenant_id,
"provider": provider_instance,
"uid": finding.resource_uid,
"region": finding.region,
"service": finding.service_name,
"type": finding.resource_type,
"name": finding.resource_name,
"groups": [group] if group else None,
}
def recover_resource_after_cache_miss(finding: ProwlerFinding) -> Resource:
resource_uid = finding.resource_uid
resource_instance = Resource.objects.filter(
tenant_id=tenant_id,
provider_id=provider_instance.id,
uid=resource_uid,
).first()
if resource_instance is None:
try:
with transaction.atomic():
resource_instance = Resource.objects.create(
**build_resource_defaults_from_finding(finding)
)
except IntegrityError:
resource_instance = Resource.objects.filter(
tenant_id=tenant_id,
provider_id=provider_instance.id,
uid=resource_uid,
).first()
if resource_instance is None:
raise
return cache_resource(resource_uid, resource_instance)
# Accumulate objects for bulk operations
findings_to_create = []
dirty_resources = {}
@@ -733,7 +773,103 @@ def _process_finding_micro_batch(
# All DB writes for this micro-batch run inside ONE rls_transaction,
# with deadlock-retry at micro-batch granularity instead of per-finding.
missing_cache_value = object()
for attempt in range(CELERY_DEADLOCK_ATTEMPTS):
resource_cache_originals: dict[str, Resource | object] = {}
failed_count_originals: dict[str, int | None] = {}
resource_field_originals: dict[str, dict[str, Any]] = {}
tag_cache_original = dict(tag_cache)
scan_resource_cache_original = set(scan_resource_cache)
scan_categories_cache_original = {
key: value.copy() for key, value in scan_categories_cache.items()
}
scan_resource_groups_cache_original = {
key: value.copy() for key, value in scan_resource_groups_cache.items()
}
group_resources_cache_original = {
key: set(value) for key, value in group_resources_cache.items()
}
def cache_resource(resource_uid: str, resource_instance: Resource) -> Resource:
if resource_uid not in resource_cache_originals:
resource_cache_originals[resource_uid] = resource_cache.get(
resource_uid, missing_cache_value
)
resource_cache[resource_uid] = resource_instance
if resource_uid not in resource_failed_findings_cache:
failed_count_originals[resource_uid] = None
resource_failed_findings_cache[resource_uid] = 0
return resource_instance
def snapshot_failed_count(resource_uid: str) -> None:
if resource_uid not in failed_count_originals:
failed_count_originals[resource_uid] = (
resource_failed_findings_cache.get(resource_uid)
)
def snapshot_resource_fields(
resource_uid: str, resource_instance: Resource
) -> None:
if resource_uid in resource_field_originals:
return
resource_field_originals[resource_uid] = {
field: copy.deepcopy(getattr(resource_instance, field))
for field in (
"name",
"metadata",
"details",
"partition",
"region",
"service",
"type",
"groups",
"updated_at",
)
}
def restore_attempt_caches() -> None:
for resource_uid, original_fields in resource_field_originals.items():
resource_instance = resource_cache.get(resource_uid)
if resource_instance is None:
continue
for field, value in original_fields.items():
setattr(resource_instance, field, value)
for resource_uid, original_resource in resource_cache_originals.items():
if original_resource is missing_cache_value:
resource_cache.pop(resource_uid, None)
else:
resource_cache[resource_uid] = original_resource
for resource_uid, original_count in failed_count_originals.items():
if original_count is None:
resource_failed_findings_cache.pop(resource_uid, None)
else:
resource_failed_findings_cache[resource_uid] = original_count
tag_cache.clear()
tag_cache.update(tag_cache_original)
scan_resource_cache.clear()
scan_resource_cache.update(scan_resource_cache_original)
scan_categories_cache.clear()
scan_categories_cache.update(
{
key: value.copy()
for key, value in scan_categories_cache_original.items()
}
)
scan_resource_groups_cache.clear()
scan_resource_groups_cache.update(
{
key: value.copy()
for key, value in scan_resource_groups_cache_original.items()
}
)
group_resources_cache.clear()
group_resources_cache.update(
{
key: set(value)
for key, value in group_resources_cache_original.items()
}
)
try:
with rls_transaction(tenant_id):
# 1) Pre-resolve Resources in bulk
@@ -768,19 +904,8 @@ def _process_finding_micro_batch(
resources_to_create = []
for uid in missing_uids:
f = first_finding_per_uid[uid]
check_metadata = f.get_metadata()
group = check_metadata.get("resourcegroup") or None
resources_to_create.append(
Resource(
tenant_id=tenant_id,
provider=provider_instance,
uid=uid,
region=f.region,
service=f.service_name,
type=f.resource_type,
name=f.resource_name,
groups=[group] if group else None,
)
Resource(**build_resource_defaults_from_finding(f))
)
Resource.objects.bulk_create(
resources_to_create,
@@ -801,8 +926,7 @@ def _process_finding_micro_batch(
}
)
for uid, r in existing_resources.items():
resource_cache[uid] = r
resource_failed_findings_cache.setdefault(uid, 0)
cache_resource(uid, r)
# 2) Pre-resolve ResourceTags in bulk
batch_tag_kv: set[tuple[str, str]] = set()
@@ -848,47 +972,50 @@ def _process_finding_micro_batch(
resource_uid = finding.resource_uid
resource_instance = resource_cache.get(resource_uid)
if resource_instance is None:
# Should be unreachable after the pre-resolve step. Defensive log.
logger.error(
f"Resource {resource_uid} missing from cache after pre-resolve "
f"on scan {scan_instance.id}; skipping finding."
)
continue
resource_instance = recover_resource_after_cache_miss(finding)
# Detect resource field changes (defer save until end-of-batch bulk_update).
check_metadata = finding.get_metadata()
group = check_metadata.get("resourcegroup") or None
updated = False
if finding.region and resource_instance.region != finding.region:
snapshot_resource_fields(resource_uid, resource_instance)
resource_instance.region = finding.region
updated = True
if (
finding.resource_name
and resource_instance.name != finding.resource_name
):
snapshot_resource_fields(resource_uid, resource_instance)
resource_instance.name = finding.resource_name
updated = True
if resource_instance.service != finding.service_name:
snapshot_resource_fields(resource_uid, resource_instance)
resource_instance.service = finding.service_name
updated = True
if resource_instance.type != finding.resource_type:
snapshot_resource_fields(resource_uid, resource_instance)
resource_instance.type = finding.resource_type
updated = True
if resource_instance.metadata != finding.resource_metadata:
snapshot_resource_fields(resource_uid, resource_instance)
resource_instance.metadata = json.dumps(
finding.resource_metadata, cls=CustomEncoder
)
updated = True
if resource_instance.details != finding.resource_details:
snapshot_resource_fields(resource_uid, resource_instance)
resource_instance.details = finding.resource_details
updated = True
if resource_instance.partition != finding.partition:
snapshot_resource_fields(resource_uid, resource_instance)
resource_instance.partition = finding.partition
updated = True
if group and (
not resource_instance.groups
or group not in resource_instance.groups
):
snapshot_resource_fields(resource_uid, resource_instance)
resource_instance.groups = (resource_instance.groups or []) + [
group
]
@@ -950,6 +1077,7 @@ def _process_finding_micro_batch(
muted_reason = mute_rules_cache[finding_uid]
if status == FindingStatus.FAIL and not is_muted:
snapshot_failed_count(resource_uid)
resource_failed_findings_cache[resource_uid] += 1
check_metadata["compliance"] = finding.compliance
@@ -1107,6 +1235,7 @@ def _process_finding_micro_batch(
if r is None:
continue
# Manually bump updated_at since bulk_update bypasses auto_now.
snapshot_resource_fields(uid, r)
r.updated_at = now_utc
resources_to_bulk_update.append(r)
if resources_to_bulk_update:
@@ -1128,6 +1257,7 @@ def _process_finding_micro_batch(
# Successful execution: leave deadlock retry loop.
break
except (OperationalError, IntegrityError) as db_err:
restore_attempt_caches()
if attempt < CELERY_DEADLOCK_ATTEMPTS - 1:
logger.warning(
f"{'Deadlock error' if isinstance(db_err, OperationalError) else 'Integrity error'} "
+23 -1
View File
@@ -797,12 +797,34 @@ def generate_outputs_task(scan_id: str, provider_id: str, tenant_id: str):
if name not in frameworks_bulk and universal_bulk[name].outputs
}
frameworks_avail = get_compliance_frameworks(provider_type)
# Idempotency: a previous run of this task for the same scan may have left
# output files behind (e.g. broker redelivery after a worker was killed
# mid-run with task_acks_late, or a successful run on a deployment without
# S3 where the tmp dir is not removed). Output writers open files in append
# mode with a deterministic path (derived from scan.started_at), so reusing
# them would append every finding row again and duplicate the CSV/output
# rows. Start from a clean slate before (re)generating.
scan_tmp_dir = _scan_tmp_output_directory(tenant_id, scan_id)
if os.path.exists(scan_tmp_dir):
rmtree(scan_tmp_dir, ignore_errors=True)
# The writers below open output files in append mode with deterministic
# paths (derived from scan.started_at). Any stale file that survives the
# cleanup would get every finding row appended again, which is the exact
# duplication this guards against. Continuing is therefore unsafe: abort
# so `ScanReportRLSTask.on_failure` removes the tmp dir and the retry
# starts from a clean slate instead of publishing duplicated rows.
if os.path.exists(scan_tmp_dir):
raise RuntimeError(
"Could not remove stale output directory for scan "
f"{scan_id} before generating outputs; aborting to avoid "
"duplicated rows in appended outputs."
)
out_dir, comp_dir = _generate_output_directory(
DJANGO_TMP_OUTPUT_DIRECTORY, provider_uid, tenant_id, scan_id
)
# Removed on success here and on failure by ScanReportRLSTask.on_failure,
# so partial artifacts do not accumulate and fill the disk (ENOSPC).
scan_tmp_dir = _scan_tmp_output_directory(tenant_id, scan_id)
def get_writer(writer_map, name, factory, is_last):
"""
@@ -1,3 +1,4 @@
from datetime import UTC, datetime
from unittest.mock import MagicMock, patch
import pytest
@@ -671,6 +672,8 @@ class TestSecurityHubIntegrationUploads:
mock_integration = MagicMock()
mock_integration.configuration = {"send_only_fails": True}
mock_integration.credentials = {} # Empty credentials, use provider
mock_integration.connected = False
mock_integration.connection_last_checked_at = None
# Mock tenant_id
tenant_id = "550e8400-e29b-41d4-a716-446655440000" # Valid UUID
@@ -723,12 +726,22 @@ class TestSecurityHubIntegrationUploads:
# Configure the test_connection to return our mock_connection
mock_security_hub_class.test_connection = mock_test_connection
checked_at_before = datetime.now(tz=UTC)
connected, security_hub = get_security_hub_client_from_integration(
mock_integration, tenant_id, mock_findings
)
checked_at_after = datetime.now(tz=UTC)
assert connected is True
assert security_hub == mock_security_hub
assert mock_integration.connected is True
assert mock_integration.connection_last_checked_at.tzinfo is UTC
assert (
checked_at_before
<= mock_integration.connection_last_checked_at
<= checked_at_after
)
mock_integration.save.assert_called_once()
# Verify SecurityHub was called once to create the client
assert mock_security_hub_class.call_count == 1
@@ -3,12 +3,13 @@ from unittest.mock import MagicMock, patch
from uuid import uuid4
import pytest
from api.celery_utils import decode_celery_field
from celery import states
from celery.utils.saferepr import saferepr
from django.test import override_settings
from django_celery_results.models import TaskResult
from tasks.jobs.orphan_recovery import (
_SKIP_RECOVERY,
_decode_celery_field,
_reconcile_task_results,
_recovery_attempt_count,
advisory_lock,
@@ -36,24 +37,77 @@ def _orphan_result(*, name, kwargs, worker, created_minutes_ago, status=states.S
return tr
@pytest.mark.django_db
class TestDecodeCeleryField:
def test_decodes_strict_json(self):
assert decode_celery_field('{"enabled": true, "scan_id": null}', {}) == {
"enabled": True,
"scan_id": None,
}
def test_decodes_single_encoded_repr(self):
assert _decode_celery_field("{'tenant_id': 'abc'}", {}) == {"tenant_id": "abc"}
assert decode_celery_field("{'tenant_id': 'abc'}", {}) == {"tenant_id": "abc"}
def test_decodes_double_encoded(self):
import json
stored = json.dumps(repr({"tenant_id": "abc", "scan_id": "s1"}))
assert _decode_celery_field(stored, {}) == {"tenant_id": "abc", "scan_id": "s1"}
assert decode_celery_field(stored, {}) == {
"tenant_id": "abc",
"scan_id": "s1",
}
def test_python_words_inside_strings_are_preserved(self):
stored = repr(
{
"enabled": True,
"scan_id": None,
"label": "True North",
"note": "None",
}
)
assert decode_celery_field(stored, {}) == {
"enabled": True,
"scan_id": None,
"label": "True North",
"note": "None",
}
def test_empty_returns_default(self):
assert _decode_celery_field(None, {}) == {}
assert _decode_celery_field("", []) == []
assert decode_celery_field(None, {}) == {}
assert decode_celery_field("", []) == []
assert decode_celery_field("null", {}) == {}
assert decode_celery_field("None", []) == []
def test_empty_validates_default(self):
with pytest.raises(ValueError):
decode_celery_field("", {"value": ...})
def test_unparseable_raises(self):
with pytest.raises(ValueError):
_decode_celery_field("<<not a literal>>", {})
decode_celery_field("<<not a literal>>", {})
@pytest.mark.parametrize(
"value",
(
"{'value': ...}",
"{'value': {1, 2}}",
"{'value': b'bytes'}",
'{"value": NaN}',
),
)
def test_non_json_values_raise(self, value):
with pytest.raises(ValueError):
decode_celery_field(value, {})
def test_truncated_repr_raises(self):
kwargs_repr = saferepr(
{"finding_ids": [str(uuid4()) for _ in range(30)]}, maxlen=1024
)
assert "..." in kwargs_repr
with pytest.raises(ValueError):
decode_celery_field(kwargs_repr, {})
@pytest.mark.django_db
@@ -98,6 +152,58 @@ class TestReconcileTaskResults:
assert call["kwargs"] == {"tenant_id": str(tenant.id)}
assert call["task_id"] != tr.task_id # fresh task id
def test_truncated_kwargs_are_not_reenqueued(self, tenants_fixture):
tenant = tenants_fixture[0]
tr = _orphan_result(
name="tenant-deletion",
kwargs={"tenant_id": str(tenant.id)},
worker="dead@gone",
created_minutes_ago=60,
)
tr.task_kwargs = saferepr(
{"finding_ids": [str(uuid4()) for _ in range(30)]}, maxlen=1024
)
assert "..." in tr.task_kwargs
tr.save(update_fields=["task_kwargs"])
p_alive, p_revoke, p_app, mock_task = self._patches(alive=False)
with (
p_alive,
p_revoke,
p_app,
patch("tasks.jobs.orphan_recovery._recovery_attempt_count", return_value=1),
):
result = _reconcile_task_results(
grace_minutes=2, max_attempts=3, window_hours=6, dry_run=False
)
assert tr.task_id in result["failed"]
mock_task.apply_async.assert_not_called()
def test_wrong_kwargs_shape_is_not_reenqueued(self, tenants_fixture):
tr = _orphan_result(
name="tenant-deletion",
kwargs={"tenant_id": str(tenants_fixture[0].id)},
worker="dead@gone",
created_minutes_ago=60,
)
tr.task_kwargs = "[]"
tr.save(update_fields=["task_kwargs"])
p_alive, p_revoke, p_app, mock_task = self._patches(alive=False)
with (
p_alive,
p_revoke,
p_app,
patch("tasks.jobs.orphan_recovery._recovery_attempt_count", return_value=1),
):
result = _reconcile_task_results(
grace_minutes=2, max_attempts=3, window_hours=6, dry_run=False
)
assert tr.task_id in result["failed"]
mock_task.apply_async.assert_not_called()
def test_external_integration_task_is_not_reenqueued_by_default(
self, tenants_fixture
):
+646 -22
View File
@@ -2,6 +2,7 @@ import csv
import json
import re
import uuid
from collections.abc import MutableMapping
from contextlib import contextmanager
from datetime import UTC, datetime
from io import StringIO
@@ -15,13 +16,16 @@ from api.models import (
MuteRule,
Provider,
Resource,
ResourceFindingMapping,
ResourceScanSummary,
ResourceTag,
ResourceTagMapping,
Scan,
ScanSummary,
StateChoices,
StatusChoices,
)
from django.db import IntegrityError, OperationalError
from django.db import IntegrityError, OperationalError, transaction
from prowler.lib.check.models import Severity
from prowler.lib.outputs.finding import Status
from tasks.jobs.scan import (
@@ -53,6 +57,12 @@ def noop_rls_transaction(*args, **kwargs):
yield
@contextmanager
def atomic_rls_transaction(*args, **kwargs):
with transaction.atomic():
yield
class FakeFinding:
def __init__(self, **attrs):
self.metadata = attrs.pop("metadata", {})
@@ -71,6 +81,32 @@ class FakeFinding:
return self.metadata
class CacheMissAfterPreResolve(MutableMapping):
def __init__(self, missing_uid):
self._cache = {}
self.missing_uid = missing_uid
def __contains__(self, key):
if key == self.missing_uid:
return True
return key in self._cache
def __getitem__(self, key):
return self._cache[key]
def __setitem__(self, key, value):
self._cache[key] = value
def __delitem__(self, key):
del self._cache[key]
def __iter__(self):
return iter(self._cache)
def __len__(self):
return len(self._cache)
@pytest.mark.django_db
class TestPerformScan:
def test_perform_prowler_scan_success(
@@ -1055,8 +1091,12 @@ class TestPerformScan:
perform_prowler_scan(tenant_id, scan_id, provider_id, [])
# Verify findings are muted with correct reason
fail_finding_db = Finding.objects.get(uid=finding_uid_1)
pass_finding_db = Finding.objects.get(uid=finding_uid_2)
fail_finding_db = Finding.objects.get(
tenant_id=tenant.id, scan_id=scan.id, uid=finding_uid_1
)
pass_finding_db = Finding.objects.get(
tenant_id=tenant.id, scan_id=scan.id, uid=finding_uid_2
)
assert fail_finding_db.muted
assert fail_finding_db.muted_reason == mute_rule_reason
@@ -1067,7 +1107,9 @@ class TestPerformScan:
assert pass_finding_db.muted_at is not None
# Verify failed_findings_count is 0 for muted FAIL finding
resource_1 = Resource.objects.get(uid="resource_uid_1")
resource_1 = Resource.objects.get(
tenant_id=tenant.id, provider_id=provider.id, uid="resource_uid_1"
)
assert resource_1.failed_findings_count == 0
def test_perform_prowler_scan_with_inactive_mute_rules(
@@ -1147,13 +1189,17 @@ class TestPerformScan:
perform_prowler_scan(tenant_id, scan_id, provider_id, [])
# Verify finding is NOT muted
finding_db = Finding.objects.get(uid=finding_uid)
finding_db = Finding.objects.get(
tenant_id=tenant.id, scan_id=scan.id, uid=finding_uid
)
assert not finding_db.muted
assert finding_db.muted_reason is None
assert finding_db.muted_at is None
# Verify failed_findings_count increments for FAIL finding
resource = Resource.objects.get(uid="resource_uid_inactive")
resource = Resource.objects.get(
tenant_id=tenant.id, provider_id=provider.id, uid="resource_uid_inactive"
)
assert resource.failed_findings_count == 1
def test_perform_prowler_scan_mutelist_overrides_mute_rules(
@@ -1233,13 +1279,17 @@ class TestPerformScan:
perform_prowler_scan(tenant_id, scan_id, provider_id, [])
# Verify mutelist reason takes precedence
finding_db = Finding.objects.get(uid=finding_uid)
finding_db = Finding.objects.get(
tenant_id=tenant.id, scan_id=scan.id, uid=finding_uid
)
assert finding_db.muted
assert finding_db.muted_reason == "Muted by mutelist"
assert finding_db.muted_at is not None
# Verify failed_findings_count is 0
resource = Resource.objects.get(uid="resource_both")
resource = Resource.objects.get(
tenant_id=tenant.id, provider_id=provider.id, uid="resource_both"
)
assert resource.failed_findings_count == 0
def test_perform_prowler_scan_mute_rules_multiple_findings(
@@ -1331,14 +1381,20 @@ class TestPerformScan:
# Verify all findings are muted with same reason
for uid in finding_uids:
finding_db = Finding.objects.get(uid=uid)
finding_db = Finding.objects.get(
tenant_id=tenant.id, scan_id=scan.id, uid=uid
)
assert finding_db.muted
assert finding_db.muted_reason == mute_rule_reason
assert finding_db.muted_at is not None
# Verify all resources have failed_findings_count = 0
for i in range(len(finding_uids)):
resource = Resource.objects.get(uid=f"resource_bulk_{i}")
resource = Resource.objects.get(
tenant_id=tenant.id,
provider_id=provider.id,
uid=f"resource_bulk_{i}",
)
assert resource.failed_findings_count == 0
def test_perform_prowler_scan_mute_rules_error_handling(
@@ -1416,12 +1472,18 @@ class TestPerformScan:
assert scan.state == StateChoices.COMPLETED
# Verify finding is not muted (mute_rules_cache was empty dict)
finding_db = Finding.objects.get(uid="finding_error_handling")
finding_db = Finding.objects.get(
tenant_id=tenant.id,
scan_id=scan.id,
uid="finding_error_handling",
)
assert not finding_db.muted
assert finding_db.muted_reason is None
# Verify failed_findings_count increments
resource = Resource.objects.get(uid="resource_error")
resource = Resource.objects.get(
tenant_id=tenant.id, provider_id=provider.id, uid="resource_error"
)
assert resource.failed_findings_count == 1
def test_perform_prowler_scan_muted_at_timestamp(
@@ -1503,7 +1565,9 @@ class TestPerformScan:
after_scan = datetime.now(UTC)
# Verify muted_at is within the scan time window
finding_db = Finding.objects.get(uid=finding_uid)
finding_db = Finding.objects.get(
tenant_id=tenant.id, scan_id=scan.id, uid=finding_uid
)
assert finding_db.muted
assert finding_db.muted_at is not None
assert before_scan <= finding_db.muted_at <= after_scan
@@ -1514,6 +1578,548 @@ class TestPerformScan:
@pytest.mark.django_db
class TestProcessFindingMicroBatch:
def _process_one_finding_micro_batch(
self,
tenant,
scan,
provider,
finding,
resource_cache=None,
resource_failed_findings_cache=None,
):
resource_cache = resource_cache if resource_cache is not None else {}
resource_failed_findings_cache = (
resource_failed_findings_cache
if resource_failed_findings_cache is not None
else {}
)
caches = {
"resource_cache": resource_cache,
"tag_cache": {},
"last_status_cache": {},
"resource_failed_findings_cache": resource_failed_findings_cache,
"unique_resources": set(),
"scan_resource_cache": set(),
"mute_rules_cache": {},
"scan_categories_cache": {},
"scan_resource_groups_cache": {},
"group_resources_cache": {},
}
with (
patch("tasks.jobs.scan.rls_transaction", new=noop_rls_transaction),
patch("api.db_utils.rls_transaction", new=noop_rls_transaction),
):
_process_finding_micro_batch(
str(tenant.id),
[finding],
scan,
provider,
caches["resource_cache"],
caches["tag_cache"],
caches["last_status_cache"],
caches["resource_failed_findings_cache"],
caches["unique_resources"],
caches["scan_resource_cache"],
caches["mute_rules_cache"],
caches["scan_categories_cache"],
caches["scan_resource_groups_cache"],
caches["group_resources_cache"],
)
return caches
def test_process_finding_micro_batch_fallback_creates_resource_after_cache_miss(
self, tenants_fixture, scans_fixture
):
tenant = tenants_fixture[0]
scan = scans_fixture[0]
provider = scan.provider
resource_uid = "arn:aws:accessanalyzer:us-east-1:123456789012:analyzer/unknown"
finding = FakeFinding(
uid="finding-cache-miss-create",
status=StatusChoices.FAIL,
status_extended="missing analyzer",
severity=Severity.medium,
check_id="accessanalyzer_enabled",
resource_uid=resource_uid,
resource_name="analyzer/unknown",
region="us-east-1",
service_name="accessanalyzer",
resource_type="analyzer",
resource_tags={},
resource_metadata={},
resource_details={},
partition="aws",
raw={},
compliance={},
metadata={"resourcegroup": "identity"},
muted=False,
)
caches = self._process_one_finding_micro_batch(
tenant,
scan,
provider,
finding,
resource_cache=CacheMissAfterPreResolve(resource_uid),
)
resource = Resource.objects.get(
tenant_id=tenant.id, provider_id=provider.id, uid=resource_uid
)
created_finding = Finding.objects.get(
tenant_id=tenant.id, scan_id=scan.id, uid=finding.uid
)
assert created_finding.scan_id == scan.id
assert resource.provider_id == provider.id
assert resource.region == finding.region
assert resource.service == finding.service_name
assert resource.type == finding.resource_type
assert resource.name == finding.resource_name
assert resource.groups == ["identity"]
assert resource.findings.filter(
tenant_id=tenant.id, scan_id=scan.id, uid=finding.uid
).exists()
assert caches["resource_cache"][resource_uid].id == resource.id
assert caches["resource_failed_findings_cache"][resource_uid] == 1
def test_process_finding_micro_batch_fallback_recovers_existing_resource_after_cache_miss(
self, tenants_fixture, scans_fixture
):
tenant = tenants_fixture[0]
scan = scans_fixture[0]
provider = scan.provider
resource_uid = "arn:aws:guardduty:us-east-1:123456789012:detector/unknown"
existing_resource = Resource.objects.create(
tenant_id=tenant.id,
provider=provider,
uid=resource_uid,
name="detector/unknown",
region="us-east-1",
service="guardduty",
type="detector",
)
finding = FakeFinding(
uid="finding-cache-miss-existing",
status=StatusChoices.FAIL,
status_extended="missing detector",
severity=Severity.high,
check_id="guardduty_enabled",
resource_uid=resource_uid,
resource_name=existing_resource.name,
region=existing_resource.region,
service_name=existing_resource.service,
resource_type=existing_resource.type,
resource_tags={},
resource_metadata={},
resource_details={},
partition="aws",
raw={},
compliance={},
metadata={},
muted=False,
)
caches = self._process_one_finding_micro_batch(
tenant,
scan,
provider,
finding,
resource_cache=CacheMissAfterPreResolve(resource_uid),
)
assert (
Resource.objects.filter(
tenant_id=tenant.id, provider_id=provider.id, uid=resource_uid
).count()
== 1
)
created_finding = Finding.objects.get(
tenant_id=tenant.id, scan_id=scan.id, uid=finding.uid
)
existing_resource.refresh_from_db()
assert created_finding.scan_id == scan.id
assert existing_resource.findings.filter(
tenant_id=tenant.id, scan_id=scan.id, uid=finding.uid
).exists()
assert caches["resource_cache"][resource_uid].id == existing_resource.id
assert caches["resource_failed_findings_cache"][resource_uid] == 1
def test_process_finding_micro_batch_fallback_recovers_after_create_race(
self, tenants_fixture, scans_fixture
):
tenant = tenants_fixture[0]
scan = scans_fixture[0]
provider = scan.provider
resource_uid = "arn:aws:securityhub:us-east-1:123456789012:hub/unknown"
raced_resource = Resource.objects.create(
tenant_id=tenant.id,
provider=provider,
uid=resource_uid,
name="hub/unknown",
region="us-east-1",
service="securityhub",
type="hub",
)
finding = FakeFinding(
uid="finding-cache-miss-failure",
status=StatusChoices.FAIL,
status_extended="missing hub",
severity=Severity.high,
check_id="securityhub_enabled",
resource_uid=resource_uid,
resource_name="hub/unknown",
region="us-east-1",
service_name="securityhub",
resource_type="hub",
resource_tags={},
resource_metadata={},
resource_details={},
partition="aws",
raw={},
compliance={},
metadata={},
muted=False,
)
resource_filter_result = MagicMock()
resource_filter_result.first.side_effect = [None, raced_resource]
with (
patch.object(
Resource.objects,
"filter",
return_value=resource_filter_result,
),
patch.object(
Resource.objects,
"create",
side_effect=IntegrityError("duplicate resource"),
),
):
caches = self._process_one_finding_micro_batch(
tenant,
scan,
provider,
finding,
resource_cache=CacheMissAfterPreResolve(resource_uid),
)
assert (
Resource.objects.filter(
tenant_id=tenant.id, provider_id=provider.id, uid=resource_uid
).count()
== 1
)
created_finding = Finding.objects.get(
tenant_id=tenant.id, scan_id=scan.id, uid=finding.uid
)
raced_resource.refresh_from_db()
assert created_finding.scan_id == scan.id
assert raced_resource.findings.filter(
tenant_id=tenant.id, scan_id=scan.id, uid=finding.uid
).exists()
assert caches["resource_cache"][resource_uid].id == raced_resource.id
assert caches["resource_failed_findings_cache"][resource_uid] == 1
def test_process_finding_micro_batch_cache_miss_retry_drops_rolled_back_resource(
self, tenants_fixture, scans_fixture
):
tenant = tenants_fixture[0]
scan = scans_fixture[0]
provider = scan.provider
resource_uid = "generic-resource-cache-miss-retry"
cached_resource = Resource.objects.create(
tenant_id=tenant.id,
provider=provider,
uid="generic-cached-resource-retry",
name="old-cached-resource",
region="us-west-2",
service="old-service",
type="old-type",
)
finding = FakeFinding(
uid="finding-cache-miss-retry-clean-resource-cache",
status=StatusChoices.FAIL,
status_extended="missing resource",
severity=Severity.high,
check_id="generic_resource_check",
resource_uid=resource_uid,
resource_name="generic-resource",
region="us-east-1",
service_name="generic-service",
resource_type="generic-type",
resource_tags={"team": "platform"},
resource_metadata={"owner": "security"},
resource_details={"id": "generic-resource"},
partition="aws",
raw={},
compliance={},
metadata={"categories": ["security"], "resourcegroup": "identity"},
muted=False,
)
cached_resource_finding = FakeFinding(
uid="finding-cache-miss-retry-restores-dirty-resource",
status=StatusChoices.FAIL,
status_extended="cached resource changed",
severity=Severity.high,
check_id="generic_cached_resource_check",
resource_uid=cached_resource.uid,
resource_name="new-cached-resource",
region="eu-west-1",
service_name="new-service",
resource_type="new-type",
resource_tags={},
resource_metadata={"owner": "platform"},
resource_details={"id": "cached-resource"},
partition="aws",
raw={},
compliance={},
metadata={"categories": ["security"], "resourcegroup": "identity"},
muted=False,
)
resource_cache = CacheMissAfterPreResolve(resource_uid)
resource_cache[cached_resource.uid] = cached_resource
tag_cache = {}
resource_failed_findings_cache = {cached_resource.uid: 0}
scan_resource_cache: set[tuple[str, str, str, str]] = set()
scan_categories_cache: dict[tuple[str, str], dict[str, int]] = {}
scan_resource_groups_cache: dict[tuple[str, str], dict[str, int]] = {}
group_resources_cache: dict[str, set] = {}
original_bulk_create = ResourceFindingMapping.objects.bulk_create
original_tag_mapping_bulk_create = ResourceTagMapping.objects.bulk_create
mapping_bulk_create_calls = []
tag_mapping_bulk_create_calls = []
def fail_once_then_bulk_create(objects, *args, **kwargs):
mapping_bulk_create_calls.append([str(obj.resource_id) for obj in objects])
if len(mapping_bulk_create_calls) == 1:
raise IntegrityError("rollback after fallback resource creation")
return original_bulk_create(objects, *args, **kwargs)
def track_tag_mappings_bulk_create(objects, *args, **kwargs):
tag_mapping_bulk_create_calls.append([str(obj.tag_id) for obj in objects])
return original_tag_mapping_bulk_create(objects, *args, **kwargs)
with (
patch("tasks.jobs.scan.CELERY_DEADLOCK_ATTEMPTS", 2),
patch("tasks.jobs.scan.rls_transaction", new=atomic_rls_transaction),
patch("api.db_utils.rls_transaction", new=atomic_rls_transaction),
patch.object(
ResourceTagMapping.objects,
"bulk_create",
side_effect=track_tag_mappings_bulk_create,
),
patch.object(
ResourceFindingMapping.objects,
"bulk_create",
side_effect=fail_once_then_bulk_create,
),
):
_process_finding_micro_batch(
str(tenant.id),
[finding, cached_resource_finding],
scan,
provider,
resource_cache,
tag_cache,
{},
resource_failed_findings_cache,
set(),
scan_resource_cache,
{},
scan_categories_cache,
scan_resource_groups_cache,
group_resources_cache,
)
resource = Resource.objects.get(
tenant_id=tenant.id,
provider_id=provider.id,
uid=resource_uid,
)
created_finding = Finding.objects.get(
tenant_id=tenant.id,
scan_id=scan.id,
uid=finding.uid,
)
cached_resource.refresh_from_db()
assert len(mapping_bulk_create_calls) == 2
assert mapping_bulk_create_calls[0] != mapping_bulk_create_calls[1]
assert len(tag_mapping_bulk_create_calls) == 2
assert tag_mapping_bulk_create_calls[0] != tag_mapping_bulk_create_calls[1]
assert created_finding.scan_id == scan.id
assert resource.findings.filter(
tenant_id=tenant.id,
scan_id=scan.id,
uid=finding.uid,
).exists()
assert cached_resource.findings.filter(
tenant_id=tenant.id,
scan_id=scan.id,
uid=cached_resource_finding.uid,
).exists()
assert cached_resource.name == cached_resource_finding.resource_name
assert cached_resource.region == cached_resource_finding.region
assert cached_resource.service == cached_resource_finding.service_name
assert cached_resource.type == cached_resource_finding.resource_type
assert resource_cache[resource_uid].id == resource.id
assert resource_failed_findings_cache[resource_uid] == 1
assert resource_failed_findings_cache[cached_resource.uid] == 1
assert scan_resource_cache == {
(
str(resource.id),
finding.service_name,
finding.region,
finding.resource_type,
),
(
str(cached_resource.id),
cached_resource_finding.service_name,
cached_resource_finding.region,
cached_resource_finding.resource_type,
),
}
assert (
tag_cache[("team", "platform")].id
== ResourceTag.objects.get(
tenant_id=tenant.id,
key="team",
value="platform",
).id
)
assert scan_categories_cache == {
("security", "high"): {"total": 2, "failed": 2, "new_failed": 2}
}
assert scan_resource_groups_cache == {
("identity", "high"): {"total": 2, "failed": 2, "new_failed": 2}
}
assert group_resources_cache == {
"identity": {resource_uid, cached_resource.uid}
}
def test_process_finding_micro_batch_propagates_retryable_cache_miss_db_errors(
self, tenants_fixture, scans_fixture
):
tenant = tenants_fixture[0]
scan = scans_fixture[0]
provider = scan.provider
resource_uid = "arn:aws:securityhub:us-east-1:123456789012:hub/retryable"
finding = FakeFinding(
uid="finding-cache-miss-retryable-error",
status=StatusChoices.FAIL,
status_extended="missing hub",
severity=Severity.high,
check_id="securityhub_enabled",
resource_uid=resource_uid,
resource_name="hub/retryable",
region="us-east-1",
service_name="securityhub",
resource_type="hub",
resource_tags={},
resource_metadata={},
resource_details={},
partition="aws",
raw={},
compliance={},
metadata={},
muted=False,
)
with (
patch("tasks.jobs.scan.CELERY_DEADLOCK_ATTEMPTS", 1),
patch.object(
Resource.objects,
"create",
side_effect=OperationalError("deadlock detected"),
),
):
with pytest.raises(OperationalError, match="deadlock detected"):
self._process_one_finding_micro_batch(
tenant,
scan,
provider,
finding,
resource_cache=CacheMissAfterPreResolve(resource_uid),
)
assert not Finding.objects.filter(
tenant_id=tenant.id, scan_id=scan.id, uid=finding.uid
).exists()
def test_process_finding_micro_batch_propagates_unrecovered_cache_miss_integrity_error(
self, tenants_fixture, scans_fixture
):
tenant = tenants_fixture[0]
scan = scans_fixture[0]
provider = scan.provider
resource_uid = "arn:aws:securityhub:us-east-1:123456789012:hub/unrecovered"
finding = FakeFinding(
uid="finding-cache-miss-unrecovered-integrity-error",
status=StatusChoices.FAIL,
status_extended="missing hub",
severity=Severity.high,
check_id="securityhub_enabled",
resource_uid=resource_uid,
resource_name="hub/unrecovered",
region="us-east-1",
service_name="securityhub",
resource_type="hub",
resource_tags={},
resource_metadata={},
resource_details={},
partition="aws",
raw={},
compliance={},
metadata={},
muted=False,
)
original_resource_filter = Resource.objects.filter
resource_filter_result = MagicMock()
resource_filter_result.first.side_effect = [None, None]
def resource_filter_side_effect(*args, **kwargs):
if kwargs.get("uid") == resource_uid:
return resource_filter_result
return original_resource_filter(*args, **kwargs)
with (
patch("tasks.jobs.scan.CELERY_DEADLOCK_ATTEMPTS", 1),
patch.object(
Resource.objects,
"filter",
side_effect=resource_filter_side_effect,
),
patch.object(
Resource.objects,
"create",
side_effect=IntegrityError("constraint violation"),
),
):
with pytest.raises(IntegrityError, match="constraint violation"):
self._process_one_finding_micro_batch(
tenant,
scan,
provider,
finding,
resource_cache=CacheMissAfterPreResolve(resource_uid),
)
assert not Finding.objects.filter(
tenant_id=tenant.id, scan_id=scan.id, uid=finding.uid
).exists()
def test_process_finding_micro_batch_creates_records_and_updates_caches(
self, tenants_fixture, scans_fixture
):
@@ -1574,8 +2180,12 @@ class TestProcessFindingMicroBatch:
group_resources_cache,
)
created_finding = Finding.objects.get(uid=finding.uid)
resource = Resource.objects.get(uid=finding.resource_uid)
created_finding = Finding.objects.get(
tenant_id=tenant.id, scan_id=scan.id, uid=finding.uid
)
resource = Resource.objects.get(
tenant_id=tenant.id, provider_id=provider.id, uid=finding.resource_uid
)
assert created_finding.scan_id == scan.id
assert created_finding.status == StatusChoices.PASS
@@ -1603,7 +2213,9 @@ class TestProcessFindingMicroBatch:
assert set(resource.tags.values_list("key", "value")) == set(
finding.resource_tags.items()
)
assert resource.findings.filter(uid=finding.uid).exists()
assert resource.findings.filter(
tenant_id=tenant.id, scan_id=scan.id, uid=finding.uid
).exists()
assert resource_cache[finding.resource_uid].id == resource.id
assert resource_failed_findings_cache[finding.resource_uid] == 0
@@ -1692,7 +2304,9 @@ class TestProcessFindingMicroBatch:
)
existing_resource.refresh_from_db()
created_finding = Finding.objects.get(uid=finding.uid)
created_finding = Finding.objects.get(
tenant_id=tenant.id, scan_id=scan.id, uid=finding.uid
)
assert created_finding.delta == Finding.DeltaChoices.CHANGED
assert created_finding.status == StatusChoices.FAIL
@@ -1726,7 +2340,9 @@ class TestProcessFindingMicroBatch:
assert set(existing_resource.tags.values_list("key", "value")) == {
("team", "devsec")
}
assert existing_resource.findings.filter(uid=finding.uid).exists()
assert existing_resource.findings.filter(
tenant_id=tenant.id, scan_id=scan.id, uid=finding.uid
).exists()
assert resource_cache[finding.resource_uid].region == finding.region
assert resource_cache[finding.resource_uid].service == finding.service_name
@@ -1892,10 +2508,14 @@ class TestProcessFindingMicroBatch:
)
# Verify the long UID finding was NOT created
assert not Finding.objects.filter(uid=long_uid).exists()
assert not Finding.objects.filter(
tenant_id=tenant.id, scan_id=scan.id, uid=long_uid
).exists()
# Verify the normal finding WAS created
assert Finding.objects.filter(uid=normal_finding.uid).exists()
assert Finding.objects.filter(
tenant_id=tenant.id, scan_id=scan.id, uid=normal_finding.uid
).exists()
# Verify logging was called for skipped finding
assert mock_logger.warning.called
@@ -2020,8 +2640,12 @@ class TestProcessFindingMicroBatch:
"new_failed": 1,
}
created_finding1 = Finding.objects.get(uid="finding-cat-1")
created_finding2 = Finding.objects.get(uid="finding-cat-2")
created_finding1 = Finding.objects.get(
tenant_id=tenant.id, scan_id=scan.id, uid="finding-cat-1"
)
created_finding2 = Finding.objects.get(
tenant_id=tenant.id, scan_id=scan.id, uid="finding-cat-2"
)
assert set(created_finding1.categories) == {"gen-ai", "security"}
assert set(created_finding2.categories) == {"security", "iam"}
+118
View File
@@ -420,6 +420,124 @@ class TestGenerateOutputs:
assert result == {"upload": False}
mock_scan_update.return_value.update.assert_called_once()
def test_generate_outputs_removes_previous_run_artifacts(self):
"""Regression for PROWLER-2266.
Output writers open files in append mode with a deterministic path
(derived from scan.started_at). If this task runs again for the same
scan (e.g. broker redelivery after a worker is killed mid-run with
task_acks_late), reusing the leftover files appends every finding row
again, duplicating rows in the CSV/output while the API console keeps
showing a single finding. The task must start from a clean slate by
removing the scan's tmp output directory before (re)generating.
"""
import tempfile
from pathlib import Path
with tempfile.TemporaryDirectory() as tmp_root:
# Simulate artifacts left behind by a previous run of the same scan.
scan_tmp_dir = Path(tmp_root) / self.tenant_id / self.scan_id
scan_tmp_dir.mkdir(parents=True)
stale_artifact = scan_tmp_dir / "prowler-output-aws-20260723120000.csv"
stale_artifact.write_text("HEADER\nold-finding-row\n")
with (
patch("tasks.tasks.DJANGO_TMP_OUTPUT_DIRECTORY", tmp_root),
patch("tasks.tasks.ScanSummary.objects.filter") as mock_filter,
patch("tasks.tasks.Provider.objects.get"),
patch("tasks.tasks.initialize_prowler_provider"),
patch("tasks.tasks.Compliance.get_bulk"),
patch("tasks.tasks.get_compliance_frameworks"),
patch("tasks.tasks.get_prowler_provider_compliance", return_value={}),
patch("tasks.tasks.Finding.all_objects.filter") as mock_findings,
patch(
"tasks.tasks._generate_output_directory",
return_value=("/tmp/test/out", "/tmp/test/comp"),
),
patch("tasks.tasks.FindingOutput._transform_findings_stats"),
patch("tasks.tasks.FindingOutput.transform_api_finding"),
patch(
"tasks.tasks.OUTPUT_FORMATS_MAPPING",
{
"json": {
"class": MagicMock(name="Writer"),
"suffix": ".json",
"kwargs": {},
}
},
),
patch("tasks.tasks.COMPLIANCE_CLASS_MAP", {"aws": []}),
patch(
"tasks.tasks._compress_output_files", return_value="/tmp/compressed"
),
patch("tasks.tasks._upload_to_s3", return_value=None),
patch("tasks.tasks.Scan.all_objects.filter"),
):
mock_filter.return_value.exists.return_value = True
mock_findings.return_value.order_by.return_value.iterator.return_value = [
[MagicMock()],
True,
]
generate_outputs_task(
scan_id=self.scan_id,
provider_id=self.provider_id,
tenant_id=self.tenant_id,
)
# The stale artifacts from the previous run must be gone, so the
# append-mode writers cannot duplicate rows onto them.
assert not stale_artifact.exists()
assert not scan_tmp_dir.exists()
def test_generate_outputs_aborts_when_stale_cleanup_fails(self):
"""Regression for PROWLER-2266.
If the stale output directory cannot be removed (e.g. permission error),
the leftover files would be reopened in append mode and every finding
row would be duplicated. The task must abort instead of continuing and
publishing duplicated rows, so the retry can start from a clean slate.
"""
import tempfile
from pathlib import Path
with tempfile.TemporaryDirectory() as tmp_root:
scan_tmp_dir = Path(tmp_root) / self.tenant_id / self.scan_id
scan_tmp_dir.mkdir(parents=True)
stale_artifact = scan_tmp_dir / "prowler-output-aws-20260723120000.csv"
stale_artifact.write_text("HEADER\nold-finding-row\n")
with (
patch("tasks.tasks.DJANGO_TMP_OUTPUT_DIRECTORY", tmp_root),
patch("tasks.tasks.ScanSummary.objects.filter") as mock_filter,
patch("tasks.tasks.Provider.objects.get"),
patch("tasks.tasks.initialize_prowler_provider"),
patch("tasks.tasks.Compliance.get_bulk"),
patch("tasks.tasks.get_compliance_frameworks"),
patch("tasks.tasks.get_prowler_provider_compliance", return_value={}),
# `rmtree(ignore_errors=True)` swallows the failure and leaves the
# directory behind; simulate that with a no-op so the guard fires.
patch("tasks.tasks.rmtree"),
patch("tasks.tasks._generate_output_directory") as mock_gen_dir,
patch("tasks.tasks._compress_output_files") as mock_compress,
patch("tasks.tasks._upload_to_s3") as mock_upload,
patch("tasks.tasks.Scan.all_objects.filter") as mock_scan_update,
):
mock_filter.return_value.exists.return_value = True
with pytest.raises(RuntimeError, match="stale output directory"):
generate_outputs_task(
scan_id=self.scan_id,
provider_id=self.provider_id,
tenant_id=self.tenant_id,
)
# The task must abort before generating/publishing any output.
mock_gen_dir.assert_not_called()
mock_compress.assert_not_called()
mock_upload.assert_not_called()
mock_scan_update.assert_not_called()
def test_generate_outputs_triggers_html_extra_update(self):
mock_finding_output = MagicMock()
mock_finding_output.compliance = {"cis": ["requirement-1", "requirement-2"]}
Generated
+1 -1
View File
@@ -4762,7 +4762,7 @@ dependencies = [
[[package]]
name = "prowler-api"
version = "1.37.0"
version = "1.38.0"
source = { virtual = "." }
dependencies = [
{ name = "cartography" },
+6
View File
@@ -7,5 +7,11 @@ component_management:
paths:
- "api/**"
flags:
api:
paths:
- "api/**"
carryforward: true
comment:
layout: "header, diff, flags, components"
+5
View File
@@ -189,6 +189,11 @@ api:
DJANGO_STALE_WHILE_REVALIDATE: "60"
DJANGO_MANAGE_DB_PARTITIONS: "True"
DJANGO_BROKER_VISIBILITY_TIMEOUT: "86400"
# Caps the Celery prefork pool size on the worker pods. Without it, Celery
# sizes the pool from the number of visible CPUs, so on large nodes the
# worker spawns one child per CPU, each loading the full Prowler SDK, and
# OOMKills under memory pressure. Raise it on bigger workers.
DJANGO_CELERY_WORKER_CONCURRENCY: "2"
# Secret names to be used as env vars for api, worker, and worker_beat.
secrets: []
+652
View File
@@ -0,0 +1,652 @@
---
title: "Changelog"
description: "New features and improvements in each Prowler release"
rss: true
---
<Update label="v5.36.0" description="July 24, 2026">
### 🎫 Finding Groups - Jira
<Note>
This feature is available exclusively in **Prowler Cloud** and **Prowler Private Cloud** with a [subscription](https://prowler.com/pricing).
</Note>
Selected Findings, Finding Groups, and mixed selections can now be sent to Jira. When you select multiple findings, choose between one grouped issue or separate issues. Generated issues keep their Prowler context with deep links and filter details, while the UI provides clear dispatch and failure feedback.
![Send findings to Jira](/images/changelog/v5.36.0-finding-groups-jira.png)
Read more in the [Jira integration documentation](/user-guide/tutorials/prowler-app-jira-integration).
### 🕸️ Attack Paths - Queries
<Note>
This feature is available exclusively in **Prowler Cloud** and **Prowler Private Cloud** with a [subscription](https://prowler.com/pricing).
</Note>
Prowler Cloud now records which built-in Attack Paths queries returned data at the end of each scan. The query selector hides confirmed-empty queries for the selected scan, so you can focus on paths that exist without opening blank graph views. Errored, unknown, and parameterized queries remain available when they still require investigation or input.
All Attack Paths queries are now published on [Prowler Hub](https://hub.prowler.com), where you can browse the full catalog.
![Attack Paths query selector](/images/changelog/v5.36.0-attack-paths-queries.png)
Read more in the [Attack Paths documentation](/user-guide/tutorials/prowler-app-attack-paths).
### 🧑‍🏫 New Tutorials: Connect Your AI Agents to Prowler Cloud
<Note>
This feature needs a Prowler Cloud API key, so it is available exclusively in **Prowler Cloud** and **Prowler Private Cloud** with a [subscription](https://prowler.com/pricing).
</Note>
New tutorials walk you through connecting your own AI agents to Prowler Cloud, so they can query your security posture and act on it programmatically.
Read more in the [AI agents documentation](/user-guide/ai-agents/index).
### ☁️ Region-less Oracle Cloud Infrastructure Setup
Oracle Cloud Infrastructure (OCI) provider credentials no longer require a region. Existing clients can still send the legacy `region` field for compatibility, but the API ignores it before storing credentials or starting a scan. This removes an unnecessary step from OCI onboarding.
Read more in the [OCI documentation](/user-guide/providers/oci/getting-started-oci).
### 🔍 Checks
#### AWS
- `sagemaker_notebook_instance_no_secrets` scans the `OnCreate` and `OnStart` lifecycle scripts of SageMaker notebook instances for hardcoded API keys, passwords, tokens, connection strings, and other secrets. Thanks to @kiranrajsg!
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).
### 🔐 Security
- Integration responses and operations now respect provider visibility, preventing hidden-provider disclosure and blocking unauthorized attachment, connection checks, Jira dispatches, edits, and deletion.
- Next.js was updated from 16.2.9 to 16.2.11, patching four high-severity and five medium-severity vulnerabilities.
- The unused `npm` CLI was removed from the UI container image, eliminating the bundled `node-tar` CVE-2026-59873 and reducing exposure to future bundled npm vulnerabilities.
- Vitest and its browser packages were updated from 4.1.8 to 4.1.10, resolving the critical `@vitest/browser` file-access permission bypass. These are development dependencies and have no runtime impact.
- Kubernetes kubeconfig validation now blocks legacy `auth-provider.config.cmd-path` command authentication, closing a command-execution bypass.
- `next-auth` was updated from 5.0.0-beta.30 to 5.0.0-beta.32, patching two critical Auth.js advisories: existence-based authorization checks that could fail open when a provider is misconfigured, and a homoglyph `@` bypass in email address normalization. The bump also pulls in the patched `@auth/core` 0.41.3 transitively.
### 🙌 External Contributors
Thank you to our community contributors for this release!
- @kiranrajsg: AWS `sagemaker_notebook_instance_no_secrets` check ([#11843](https://github.com/prowler-cloud/prowler/pull/11843))
- @owenchenxy: Alibaba Cloud SSH and RDP security group checks now handle capitalized `Policy="Accept"` values correctly ([#12049](https://github.com/prowler-cloud/prowler/pull/12049))
- @rsaladra: S3 bucket name validation no longer raises an invalid escape sequence `SyntaxWarning` at startup ([#12041](https://github.com/prowler-cloud/prowler/pull/12041))
- @SujayKulkarni-2211: Updated the AWS check count in the README ([#12011](https://github.com/prowler-cloud/prowler/pull/12011))
See the [full release notes on GitHub](https://github.com/prowler-cloud/prowler/releases/tag/5.36.0) for the complete list of changes.
</Update>
<Update label="v5.35.0" description="July 17, 2026">
### 💬 Lighthouse AI - Side Chat
<Note>
This feature is available exclusively in **Prowler Cloud** and **Prowler Private Cloud** with a [subscription](https://prowler.com/pricing).
</Note>
Lighthouse AI now lives in a side panel you can open from anywhere in the app. Ask about the findings you are looking at without leaving the page, and expand to the full-page chat at any time: your draft, messages, and streaming response come along. Finding and resource details share the same panel, with tabs to switch between Details and Lighthouse AI.
![Lighthouse AI side chat](/images/changelog/v5.35.0-lighthouse-ai-side-chat.png)
Read more in the [Lighthouse AI documentation](/getting-started/products/prowler-cloud-lighthouse#side-panel).
### 🤖 Lighthouse AI - Take Action
<Note>
This feature is available exclusively in **Prowler Cloud** and **Prowler Private Cloud** with a [subscription](https://prowler.com/pricing).
</Note>
Lighthouse AI is no longer read-only. Ask it to do things and it will: connect or remove providers, trigger a scan, schedule daily scans, update scan settings, and manage your mutelist and mute rules, straight from the chat. Every action is gated by RBAC: Lighthouse can only do what the user asking could do themselves.
Read more in the [Lighthouse AI capabilities](/getting-started/products/prowler-cloud-lighthouse#capabilities).
### ☁️ One-step AWS Organizations 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 AWS Organization is now a single step. One CloudFormation quick-create link deploys the management account role and a service-managed StackSet that rolls the role out to every member account, replacing the manual StackSet console setup. Target the whole organization or a specific Organizational Unit or Root ID, and deploy from the management account or a delegated administrator. The S3 integration quick-create link also pre-fills the bucket owner account ID, preventing a stack validation error.
![AWS Organizations onboarding wizard](/images/changelog/v5.35.0-aws-orgs-wizard.png)
Built on the full-organization CloudFormation template contributed by @jchrisfarris — thanks!
Read more in the [AWS Organizations documentation](/user-guide/tutorials/prowler-cloud-aws-organizations).
### 🎯 Scan configurations: exclude checks and services
<Note>
This feature is available exclusively in **Prowler Cloud** and **Prowler Private Cloud** with a [subscription](https://prowler.com/pricing).
</Note>
Scan configurations now accept `excluded_checks` and `excluded_services` to narrow the execution scope. Skip individual checks or entire services per provider, and the scan does not run them at all: less noise, faster scans, and no findings you would mute anyway.
Read more in the [Scan Configuration documentation](/user-guide/tutorials/prowler-app-scan-configuration#limiting-the-scan-scope).
### 🧭 Redesigned sidebar navigation
The sidebar was redesigned around how you actually work: grouped sections for security, settings, and help, a Home/Chat switch at the top, collapsible configuration entries, clearer active states, and a responsive mobile overlay.
![Redesigned sidebar](/images/changelog/v5.35.0-new-menu.png)
### 🔌 Prowler MCP tools renamed to `prowler_*`
Core Prowler tools in Prowler MCP moved from the `prowler_app_*` prefix to the shorter `prowler_*` namespace, and the MCP documentation was restructured around it. Legacy `prowler_app_*` names keep working in Lighthouse AI, so existing setups are not broken.
Read more in the [Prowler MCP tools reference](/getting-started/basic-usage/prowler-mcp-tools).
### 🔐 Security
- Jira integration credentials now only accept bare Atlassian site names (letters, numbers, and hyphens), and Jira tenant information requests validate site names and no longer follow redirects.
- Social account linking now requires a verified matching email from both the identity provider and the existing user account, and account connection notification emails are disabled.
- 13 advisories reported by `pnpm audit` on the UI (3 high, 9 moderate, 1 low) are resolved with patched versions of `hono`, `ws`, `vite`, `dompurify`, `js-yaml`, `@opentelemetry/core`, and `@babel/core`, including `hono` CVE-2026-59896.
### 🙌 External Contributors
No external contributors in this release.
Special mention to @jchrisfarris, whose full-organization CloudFormation template from v5.34.0 powers the new one-step AWS Organizations onboarding ([#10403](https://github.com/prowler-cloud/prowler/pull/10403)).
See the [full release notes on GitHub](https://github.com/prowler-cloud/prowler/releases/tag/5.35.0) for the complete list of changes.
</Update>
<Update label="v5.34.0" description="July 15, 2026">
### 🏷️ New product names
The Prowler family has grown, and the names now say what each product is. Same products, clearer names:
**Prowler products:**
- **Prowler Cloud** — the managed cloud security platform operated by the Prowler team.
- **Prowler Private Cloud** (formerly *Prowler Enterprise*) — the self-hosted deployment of Prowler Cloud in your own environment.
- **Prowler Hub** — the free public library of versioned checks, cloud service artifacts, and compliance frameworks.
- **Prowler Lighthouse AI** — The Agentic Cloud Defender in Prowler Cloud and Prowler Private Cloud.
- **Prowler MCP** — the MCP server that connects AI assistants and agents to Prowler, including the IDE plugins.
**Open source projects:**
- **Prowler CLI** — the command-line scanner for all supported providers.
- **Prowler Local Server** (formerly *Prowler App*) — the self-hosted web application and API to run scans, visualize findings, and manage providers.
- **Prowler Local Dashboard** — the web dashboard for visualizing Prowler CLI scan results, distributed with the CLI.
- **Prowler SDK** — the Python library behind Prowler CLI and Prowler Local Server.
See the full family in the [Prowler products documentation](/getting-started/products).
### 🧭 Cross-Provider Compliance
<Note>
This feature is available exclusively in **Prowler Cloud** and **Prowler Private Cloud** with a [subscription](https://prowler.com/pricing).
</Note>
One framework, every cloud, a single answer. The new **Cross-provider** tab in Compliance takes the most recent completed scan of every compatible provider and rolls them up into a single compliance posture per framework, with a per-provider breakdown and a combined executive PDF report. Requirement status follows strict precedence (FAIL over PASS over MANUAL), so one failing provider is enough to flag a requirement across your whole estate.
![Cross-provider compliance overview](/images/changelog/v5.34.0-cross-provider-compliance-overview.png)
Three universal frameworks support it today:
- **CIS Controls 8.1** — AWS, Azure, Google Cloud, Microsoft 365, Kubernetes, GitHub, Google Workspace, Okta, Oracle Cloud, Alibaba Cloud, Cloudflare, MongoDB Atlas, OpenStack, and Vercel.
- **CSA CCM 4.0** — AWS, Azure, Google Cloud, Alibaba Cloud, and Oracle Cloud.
- **DORA 2022/2554** — AWS, Azure, Google Cloud, Alibaba Cloud, and Cloudflare.
Filter by provider type, account, or provider group, drill into each framework's requirements, and export the combined PDF.
![Cross-provider compliance detail](/images/changelog/v5.34.0-cross-provider-compliance-detail.png)
Read more in the [Cross-Provider Compliance documentation](/user-guide/compliance/tutorials/cross-provider-compliance).
### 🏢 New Provider — E2E Networks
Prowler now scans [**E2E Networks**](https://www.e2enetworks.com/), with **27 checks** spanning compute nodes, networking, security groups, load balancers, block and file storage, and managed databases. Thanks to @deepak7093 for their 1st provider in Prowler!
Available in the Prowler CLI:
```bash
export E2E_NETWORKS_API_KEY="your-api-key"
export E2E_NETWORKS_AUTH_TOKEN="your-auth-token"
export E2E_NETWORKS_PROJECT_ID="your-project-id"
prowler e2enetworks
```
Read more in the [E2E Networks documentation](/user-guide/providers/e2enetworks/getting-started-e2enetworks). Explore all E2E Networks checks at [Prowler Hub](https://hub.prowler.com/check?provider=e2enetworks).
### 🔐 Security
User role relationship updates in the API are now limited to the active tenant, preserving the role assignments the same user holds in other tenants.
### 🔍 Checks
#### AWS
- `ec2_ami_account_block_public_access` — verifies AMI block public access is enabled at the account level in each Region, so AMIs cannot be shared publicly. Thanks to @goutham-hari!
- `datapipeline_pipeline_no_secrets_in_definition` — scans Data Pipeline object fields, parameter objects, and parameter values for hardcoded secrets with Kingfisher. Thanks to @YinkaMetrics!
- `elbv2_listener_pqc_tls_enabled` — verifies ELBv2 HTTPS/TLS listeners use post-quantum TLS security policies with TLS 1.2 or higher, helping reduce harvest-now-decrypt-later exposure.
- `amplify_app_no_secrets_in_environment` — scans Amplify app and branch environment variables and build settings (buildSpec) for hardcoded secrets with Kingfisher. Thanks to @Deep070203!
#### Azure
- `app_function_ensure_http_is_redirected_to_https` — verifies that Function Apps enforce HTTPS-only traffic. Thanks to @amandalal007!
#### Kubernetes
- `core_minimize_hostpath_volume_mounts` — detects Pods that use `hostPath` volumes. Thanks to @0xTaoZ!
- `core_readonly_root_filesystem_enabled` — verifies that every container in each Pod explicitly sets `readOnlyRootFilesystem: true` in its security context. Thanks to @Weedle02!
#### STACKIT
- `iaas_server_public_ip_attached` — flags IaaS servers that have a public IP address directly attached to a network interface. Thanks to @johannes-engler-mw!
Explore all checks at [Prowler Hub](https://hub.prowler.com/check).
### 🙌 External Contributors
Thank you to our community contributors for this release!
- @jchrisfarris — Deploy AWS Organizations with the CloudFormation template in one step ([#10403](https://github.com/prowler-cloud/prowler/pull/10403))
- @deepak7093 — New E2E Networks provider: 27 checks across compute nodes, networking, security groups, load balancers, block/file storage, and managed databases ([#11654](https://github.com/prowler-cloud/prowler/pull/11654))
- @goutham-hari — AWS `ec2_ami_account_block_public_access` check ([#11828](https://github.com/prowler-cloud/prowler/pull/11828))
- @YinkaMetrics — AWS `datapipeline_pipeline_no_secrets_in_definition` check ([#11821](https://github.com/prowler-cloud/prowler/pull/11821))
- @amandalal007 — Azure `app_function_ensure_http_is_redirected_to_https` check ([#11929](https://github.com/prowler-cloud/prowler/pull/11929))
- @0xTaoZ — Kubernetes `core_minimize_hostpath_volume_mounts` check ([#11837](https://github.com/prowler-cloud/prowler/pull/11837))
- @Weedle02 — Kubernetes `core_readonly_root_filesystem_enabled` check ([#11835](https://github.com/prowler-cloud/prowler/pull/11835))
- @johannes-engler-mw — STACKIT `iaas_server_public_ip_attached` check ([#11549](https://github.com/prowler-cloud/prowler/pull/11549))
- @janderik — Trailing newlines added to compliance, region, and fixture data files for POSIX compliance ([#11765](https://github.com/prowler-cloud/prowler/pull/11765))
- @Deep070203 — AWS `amplify_app_no_secrets_in_environment` check ([#11825](https://github.com/prowler-cloud/prowler/pull/11825))
See the [full release notes on GitHub](https://github.com/prowler-cloud/prowler/releases/tag/5.34.0) for the complete list of changes.
</Update>
<Update label="v5.33.0" description="July 7, 2026">
### 🤖 Lighthouse AI — The Agentic Cloud Defender
<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 a full agentic assistant wired to the Prowler Cloud backend. Ask it about your findings, your compliance posture, or your riskiest resources, and watch it work: the agent discovers and runs the Prowler tools it needs to answer, with every tool call visible in the new agentic view. It reads your security data through read-only tools, so it can never touch secrets or modify your tenant.
![Lighthouse AI agentic view](/images/changelog/v5.33.0-lighthouse-ai-1.webp)
The chat experience is rebuilt around **persistent sessions**: conversations stream in real time, stay in your session history, can be archived, and a **sidebar chat mode** lets you ask questions from any page in the app without losing your place.
![Lighthouse AI sessions](/images/changelog/v5.33.0-lighthouse-ai-2.webp)
You control the brain behind it. Configure one or more LLM providers — **OpenAI**, **Amazon Bedrock**, or any **OpenAI-compatible** endpoint (OpenRouter, Ollama) — with connection testing built into the setup and per-provider model selection. Add a shared **business context** (your security goals, compliance needs, organizational priorities) and every session uses it to give answers that fit your environment.
![Lighthouse AI LLM providers](/images/changelog/v5.33.0-lighthouse-ai-3.webp)
Read more in the [Lighthouse AI documentation](/getting-started/products/prowler-cloud-lighthouse) and the [multiple LLM providers guide](/user-guide/tutorials/prowler-cloud-lighthouse-multi-llm).
### 📄 Compliance PDF Reports Without Credentials
Compliance PDF reports no longer require the provider's credentials to be present. Findings are now enriched from the provider metadata stored in the database, so a report still generates even after the provider secret has been deleted or its credentials have become invalid.
Read more in the [compliance documentation](/user-guide/compliance/tutorials/compliance).
### ⏳ Scan Queueing
Overlapping scans for the same provider now queue behind the active one instead of dispatching concurrent scan workers. Launch a manual scan while a scheduled one is running and it waits its turn. No more duplicated work or racing scans.
### 🔐 Security
The Kubernetes provider credentials now reject kubeconfigs using `exec` authentication in Prowler Cloud, at the API and in the credential form, preventing user-supplied commands from running on Cloud workers.
Read more in the [Kubernetes provider authentication documentation](/user-guide/providers/kubernetes/getting-started-k8s#step-2-configure-kubernetes-authentication).
### 🙌 External Contributors
Thank you to our community contributors for this release!
- @kratos0718 — Azure `postgresql_flexible_server_log_retention_days_greater_3` Flexible Server log retention fix ([#11761](https://github.com/prowler-cloud/prowler/pull/11761))
- @Sanjays2402 — `KeyError: 'MANUAL'` crash fix in the compliance summary table, shipped early in v5.32.1 ([#11823](https://github.com/prowler-cloud/prowler/pull/11823))
See the [full release notes on GitHub](https://github.com/prowler-cloud/prowler/releases/tag/5.33.0) for the complete list of changes.
</Update>
<Update label="v5.32.0" description="July 2, 2026">
### 🔎 Findings Triage
<Note>
This feature is available exclusively in **Prowler Cloud** and **Prowler Private Cloud** with a [subscription](https://prowler.com/pricing).
</Note>
Triage findings straight from the Findings view. Each finding gets a triage status you can move through its lifecycle:
**Open → Under Review → Remediating → Risk Accepted → False Positive → Resolved**
Add a triage note to record the decision, mute a finding, all from the row's actions menu. The current status shows inline on every finding row, so you keep track of what has been reviewed and stop re-checking the same issues scan after scan.
![Findings triage statuses](/images/changelog/v5.32.0-triage-1.png)
The status also follows the finding automatically across scans: when a finding flips from `FAIL` to `PASS` on the next scan it moves to **Resolved**, and when it flips from `PASS` back to `FAIL` it moves to **Reopened**. You always know whether an issue is genuinely fixed or has regressed, without touching it by hand.
![Findings triage lifecycle](/images/changelog/v5.32.0-triage-2.png)
Read more in the [Findings Triage documentation](/user-guide/tutorials/prowler-app-findings-triage).
### ⚙️ Scan Configuration
<Note>
This feature is available exclusively in **Prowler Cloud** and **Prowler Private Cloud** with a [subscription](https://prowler.com/pricing).
</Note>
Create named, reusable scan configurations from a dedicated **Scans / Configuration** page. Each configuration is YAML that follows the structure of [`prowler/config/config.yaml`](https://github.com/prowler-cloud/prowler/blob/master/prowler/config/config.yaml), so you only include the keys you want to override; the rest fall back to the built-in defaults. Values are validated on save against a per-provider, type-safe configuration schema that range-checks each field and rejects unknown keys, so a malformed config is caught before it ever reaches a scan. Attach a configuration to one or more providers so it applies on their next scan, or save it now and attach providers later.
![Scan configuration editor](/images/changelog/v5.32.0-config-1.png)
From the Providers view you can pick which configuration a provider uses (`Default` or any of your saved ones) without leaving the page. No more passing config files around by hand.
![Scan configuration per provider](/images/changelog/v5.32.0-config-2.png)
Read more in the [Scan Configuration documentation](/user-guide/tutorials/prowler-app-scan-configuration).
### ✅ Per-Requirement Configuration Validation
<Note>
This feature is available exclusively in **Prowler Cloud** and **Prowler Private Cloud** with a [subscription](https://prowler.com/pricing).
</Note>
Compliance frameworks can now declare `ConfigRequirements` on a requirement, so it's reported as **FAIL** when its mapped checks ran under a configuration too loose to satisfy it. Even if every individual finding PASSed. This applies across all compliance outputs: CSV, OCSF, and console tables, and is the engine behind Scan Configuration's "marked as FAIL" behavior described above.
![Per-requirement configuration validation](/images/changelog/v5.32.0-per-requirement-validation.png)
Read more in the [Configuration File documentation](/user-guide/cli/tutorials/configuration_file).
### ⏱️ Okta — Request Throttling & Retries
Prowler now proactively throttles Okta API requests to stay under rate limits, with reactive retries on HTTP 429 as a safety net. Both are set in the scan configuration (or their equivalent CLI flags):
- `okta_requests_per_second` (config file) / `--okta-requests-per-second` (CLI) — cap the request rate. Default: 4 req/s.
- `okta_max_retries` (config file) / `--okta-retries-max-attempts` (CLI) — bound retry attempts. Default: 5.
This makes large Okta scans more reliable and less likely to be rate-limited.
Read more in the [Okta rate limit documentation](/user-guide/providers/okta/retry-configuration#request-throttling-requests-per-second).
### 📉 AWS — Cap Resources Scanned per Service
Large AWS accounts can now cap how many resources Prowler analyzes for the highest-volume services, keeping scan time and cost under control. Set a global limit with `max_scanned_resources_per_service`, or override it per service:
- EBS snapshots (`max_ebs_snapshots`)
- Backup recovery points (`max_backup_recovery_points`)
- CloudWatch log groups (`max_cloudwatch_log_groups`)
- Lambda functions (`max_lambda_functions`)
- ECS task definitions (`max_ecs_task_definitions`)
- CodeArtifact packages (`max_codeartifact_packages`)
Limits are **disabled by default** (`0` = unlimited); only positive values cap the analyzed resources.
<Warning>
When a positive limit is set, compliance results reflect only the sampled resources, not every matching resource in the account.
</Warning>
Read more in the [configuration file documentation](/user-guide/cli/tutorials/configuration_file#supported-aws-resource-limits).
### 🏷️ Azure — Filter by Resource Group
Azure scans can now be scoped to one or more resource groups with the new `--azure-resource-group` / `--azure-resource-groups` option. This lets you run focused assessments against specific environments, teams, or workloads instead of scanning every accessible resource in the subscription. Thanks to @Legin-ML for contributing this feature!
```bash
# Single resource group
prowler azure --az-cli-auth --azure-resource-group rg-prod
# Multiple resource groups
prowler azure --az-cli-auth --azure-resource-group rg-prod1 rg-prod2
```
Read more in the [Azure Resource Groups documentation](/user-guide/providers/azure/resource-groups).
### 🧭 Provider Group Filter
Filter the **Overview, Findings, Resources, Scans, and Providers** views by provider group. Scope the whole app to a team, an environment, or a business unit in one click instead of filtering provider by provider.
![Provider group filter](/images/changelog/v5.32.0-provider-group-filter.png)
Read more about managing provider groups in the [RBAC documentation](/user-guide/tutorials/prowler-app-rbac).
### 🔬 API — Timestamp Precision in Findings Filters
The `/api/v1/findings` endpoint now accepts full timestamps on the `inserted_at` and `updated_at` filters (`filter[inserted_at__gte]`, `filter[inserted_at__lte]`, and the `updated_at` variants), so you can query narrow time windows instead of whole days. Date-only filtering keeps working, so existing integrations are unaffected.
```bash
# Findings inserted within a precise timestamp window
curl --globoff \
'http://localhost:8080/api/v1/findings?filter[inserted_at__gte]=2026-07-01T06:12:18Z&filter[inserted_at__lte]=2026-07-02T19:25:55Z' \
-H 'Authorization: Bearer <YOUR_TOKEN>' \
-H 'Accept: application/vnd.api+json'
```
### 🕸️ Attack Paths — Neptune as a persistent sink
Attack Paths can now persist its graph in **AWS Neptune** in addition to Neo4j, selectable via `ATTACK_PATHS_SINK_DATABASE=neptune` (default `neo4j`). Cartography's per-scan ingest database stays on Neo4j. The scan task preflights the ingest database and the configured sink before ingestion, and provider graph cleanup now deletes relationships in directed batches before deleting nodes.
This is the groundwork for scale: a managed graph database lets Attack Paths hold much larger graphs, extend coverage to more providers, and link resources across them so an attack path can cross provider boundaries instead of stopping at one cloud's edge.
Read more in the [Attack Paths documentation](/user-guide/tutorials/prowler-app-attack-paths).
### 🔐 New Secret-Scanning Engine — Kingfisher
Prowler's secret-scanning checks now run on [Kingfisher](https://github.com/mongodb/kingfisher) instead of `detect-secrets`. Scans run **fully offline by default**, and obvious placeholder values (e.g. `password123`, `changeme`) are no longer reported, cutting down false positives.
Opt in to **live validation** with the new `--scan-secrets-validate` flag (or the `aws.secrets_validate` config option): Prowler checks discovered secrets against the provider APIs, and any secret confirmed to be **live is reported as critical**, so you can prioritize the credentials that actually work.
<Note>
The `detect_secrets_plugins` configuration option has been removed, as it is no longer used by the new engine.
</Note>
Read more in the [secret detection documentation](/user-guide/cli/tutorials/pentesting#detect-secrets).
### 🔍 Checks
#### AWS
- `stepfunctions_statemachine_encrypted_with_cmk` — Step Functions state machines use a customer-managed KMS key for encryption at rest instead of the default AWS-owned key. Thanks to @Sid-0602!
- `waf_regional_webacl_logging_enabled` — AWS WAF Classic Regional Web ACLs have logging enabled to a Kinesis Data Firehose stream. Thanks to @Sid-0602!
- **IAM privilege escalation** — the privesc checks now cover **AWS Bedrock AgentCore** paths across Runtime, Harness, Code Interpreter, and Custom Browser. Thanks to @MrCloudSec!
- `apigateway_restapi_no_secrets_in_stage_variables` — scans API Gateway REST API stage variables for hardcoded passwords, API keys, and tokens. Thanks to @chirag1206!
- `awslambda_function_no_secrets_in_code` — this check now supports a `secrets_ignore_files` audit-config option to skip files inside the deployment package by glob pattern (e.g. `*.deps.json`), suppressing .NET dependency-manifest false positives without masking real secrets.
- `s3_bucket_object_public` — spot-checks a configurable sample of object ACLs in each bucket and flags objects granted to the `AllUsers` or `AuthenticatedUsers` groups. Disabled by default; opt in via the `s3_bucket_object_public_enabled` configuration option. Thanks to @Synchx00!
#### Microsoft 365
New **Conditional Access** hardening checks:
- `entra_conditional_access_policy_explicitly_targets_azure_devops` — at least one enabled policy explicitly includes the Azure DevOps cloud application, rather than relying on a broad "All cloud apps" policy. Thanks to @mzl2233!
- `entra_conditional_access_policy_no_exclusion_gaps` — every user, group, role, or application excluded from an enabled policy stays in scope of another enabled policy. Thanks to @UTKARSH698 with @arieleli01212 as co-author!
- `entra_conditional_access_policy_groups_management_restricted` — every security group referenced by an enabled or report-only policy is management-restricted or role-assignable. Thanks to @SAMurai-16!
- `exchange_application_access_policy_restricts_mailbox_apps` — every service principal with Microsoft Graph application-level Exchange mailbox permissions is restricted by an Exchange Online Application Access Policy. Thanks to @VasistAcharya!
### 📚 Compliance
#### CIS Benchmark Refresh — Six New Versions
Prowler ships a coordinated refresh of the CIS Benchmarks across six providers:
- **AWS** — CIS Amazon Web Services Foundations Benchmark v7.0.0, adding the new Organizations section (2.1.1-2.1.6), resource policy (2.21), web front-end access logging (4.10), and VPC Endpoints (6.8) recommendations.
- **Azure** — CIS Microsoft Azure Foundations Benchmark v6.0.0.
- **GCP** — CIS Google Cloud Platform Foundation Benchmark v5.0.0.
- **Kubernetes** — CIS Kubernetes Benchmark v2.0.1.
- **GitHub** — CIS GitHub Benchmark v1.2.0.
- **Microsoft 365** — CIS Microsoft 365 Foundations Benchmark v7.0.0.
#### CIS Controls v8.1 — Universal Framework
A new **universal** (cross-provider) compliance framework mapping existing checks across 18 providers — AWS, Azure, GCP, Kubernetes, M365, GitHub, AlibabaCloud, OracleCloud, GoogleWorkspace, Okta, Cloudflare, Vercel, MongoDB Atlas, OpenStack, Linode, StackIT, NHN, and Scaleway — to the 18 CIS Critical Security Controls and their Safeguards. Ships with a dedicated detail view and report mapping in the UI.
Read more in the [compliance documentation](/user-guide/compliance/tutorials/compliance). Explore the full compliance catalog at [Prowler Hub](https://hub.prowler.com/compliance).
### 🙌 External Contributors
Thank you to our community contributors for this release!
- @chirag1206 — `apigateway_restapi_no_secrets_in_stage_variables` check ([#11188](https://github.com/prowler-cloud/prowler/pull/11188))
- @MrCloudSec — AWS Bedrock AgentCore privilege escalation paths in the IAM privesc checks ([#11726](https://github.com/prowler-cloud/prowler/pull/11726))
- @Sid-0602 — `stepfunctions_statemachine_encrypted_with_cmk` ([#11538](https://github.com/prowler-cloud/prowler/pull/11538)) and `waf_regional_webacl_logging_enabled` ([#11539](https://github.com/prowler-cloud/prowler/pull/11539)) checks
- @mzl2233 — `entra_conditional_access_policy_explicitly_targets_azure_devops` check ([#11182](https://github.com/prowler-cloud/prowler/pull/11182))
- @UTKARSH698 with @arieleli01212 as co-author — `entra_conditional_access_policy_no_exclusion_gaps` check ([#11577](https://github.com/prowler-cloud/prowler/pull/11577))
- @SAMurai-16 — `entra_conditional_access_policy_groups_management_restricted` check ([#11342](https://github.com/prowler-cloud/prowler/pull/11342))
- @vahidg — Azure PostgreSQL flexible server collection resilience fix ([#11595](https://github.com/prowler-cloud/prowler/pull/11595))
- @davletd — Azure `keyvault_logging_enabled` `AuditEvent` category fix ([#11660](https://github.com/prowler-cloud/prowler/pull/11660))
- @VasistAcharya — `exchange_application_access_policy_restricts_mailbox_apps` ([#11247](https://github.com/prowler-cloud/prowler/pull/11247))
- @Legin-ML — Filter scans at Resource Group level ([#10657](https://github.com/prowler-cloud/prowler/pull/10657))
- @Synchx00 — `s3_bucket_object_public` check ([#9517](https://github.com/prowler-cloud/prowler/pull/9517))
See the [full release notes on GitHub](https://github.com/prowler-cloud/prowler/releases/tag/5.32.0) for the complete list of changes.
</Update>
<Update label="v5.31.0" description="June 23, 2026">
### 🗓️ Flexible Scan Scheduling
<Note>
Available exclusively in **Prowler Cloud**. Prowler Local Server supports daily scans only.
</Note>
![Scan scheduling from the Providers page](/images/changelog/v5.31.0-schedule-1.png)
You can now set a per-provider scan schedule from the Providers page. Pick a **scan time** and a **repeat cadence**: Daily, Every 48 hours, Weekly (with a day-of-week selector), or Monthly. Schedules can be edited or removed at any time, and a new scan never interrupts access to existing data.
![Schedule editor](/images/changelog/v5.31.0-schedule-2.png)
All schedules are listed in one place under the **Scheduled** tab in **Scan Jobs**, showing each provider's cadence, next scan, and last scan at a glance.
![Scheduled tab in Scan Jobs](/images/changelog/v5.31.0-schedule-3.png)
Read more in the [scan scheduling documentation](/user-guide/tutorials/prowler-scan-scheduling).
### 📚 DORA — Expanded Provider Coverage
Prowler extends [**DORA**](https://www.eiopa.europa.eu/digital-operational-resilience-act-dora_en) (Digital Operational Resilience Act, Regulation (EU) 2022/2554) coverage to **Azure**, **GCP**, **Cloudflare**, and **Alibaba Cloud**, mapping each provider's existing checks across the five DORA pillars.
![DORA compliance for Alibaba Cloud](/images/changelog/v5.31.0-dora-alibaba.png)
<Note>
The framework follows the `<name>_<version>` naming convention as `DORA_2022_2554`.
</Note>
Read more in the [compliance documentation](/user-guide/compliance/tutorials/compliance).
### 🚀 Guided Onboarding
<Note>
Available exclusively in **Prowler Cloud**.
</Note>
New accounts now get a guided first-run experience. The Overview greets you with an **"Add your first provider"** prompt: connect a provider so Prowler has something to scan and assess, then get started in one click (or skip for now).
![Guided onboarding prompt](/images/changelog/v5.31.0-onboarding-1.png)
From there, contextual empty states across the product point you to the next action rather than leaving you stuck. Attack Paths, for example, explains that you need a completed scan before it can build a graph and links straight to **Scan Jobs**, with a **"See how it works"** affordance for first-timers.
![Contextual empty states](/images/changelog/v5.31.0-onboarding-2.png)
### 🔐 Optional SAML SSO `userType`
The SAML `userType` attribute is now optional. If your IdP does not send it, or sends it blank, Prowler keeps the user's existing roles unchanged instead of replacing them with a fallback role.
When `userType` is provided, Prowler still maps the user to the matching role. If that role does not exist yet, Prowler creates it with read-only access: visibility over all providers, with no management permissions.
Read more in the [SAML SSO documentation](/user-guide/tutorials/prowler-app-sso).
### 🏢 New Provider — Linode
Prowler now scans [**Linode**](https://www.linode.com/) (Akamai Cloud), covering its administration, compute, and networking services. Thanks to @varunmamillapalli for their 1st provider in Prowler!
<Note>
Linode is not officially supported. For more information, [contact us](https://prowler.com/contact).
</Note>
Read more in the [Linode documentation](/user-guide/providers/linode/getting-started-linode). Explore all Linode checks at [Prowler Hub](https://hub.prowler.com/check?provider=linode).
### 🔍 Checks
#### AWS
**Post-Quantum Cryptography readiness** — get ahead of the migration to quantum-resistant cryptography:
- `cloudfront_distributions_pqc_tls_enabled` — CloudFront distributions enforce a post-quantum TLS 1.3 security policy.
- `apigateway_domain_name_pqc_tls_enabled` — API Gateway custom domain names use a post-quantum TLS security policy.
- `transfer_server_pqc_ssh_kex_enabled` — Transfer Family servers use a post-quantum hybrid SSH key exchange.
- `acmpca_certificate_authority_pqc_key_algorithm` — Private CA authorities use a post-quantum (ML-DSA) key algorithm (new `acmpca` service).
- `rolesanywhere_trust_anchor_pqc_pki` — IAM Roles Anywhere trust anchors are backed by a post-quantum (ML-DSA) PKI (new `rolesanywhere` service).
**Organization-wide governance:**
- `securityhub_delegated_admin_enabled_all_regions` — Security Hub has a delegated administrator, active in all opted-in regions, with organization auto-enable on. Thanks to @ernestprovo23!
- `config_delegated_admin_and_org_aggregator_all_regions` — AWS Config has a delegated administrator and an organization aggregator covering all regions. Thanks to @ernestprovo23!
**Machine learning:**
- `sagemaker_clarify_exists` — verifies at least one SageMaker Clarify processing job exists per scanned region, so bias-detection and model-explainability controls are in place. Thanks to @AlexanderSanin!
#### Azure
A large batch of new Azure checks spanning data, compute, identity, and networking:
- **Cosmos DB** — automatic failover, continuous backup policy, minimum TLS 1.2, and public network access disabled.
- **MySQL & PostgreSQL Flexible Servers** — geo-redundant backup and high availability.
- **AKS** — auto-upgrade, Azure Monitor (Container Insights), local accounts disabled, and Microsoft Defender enabled.
- **Databricks** — public network access disabled and secure cluster connectivity (no public IP).
- **Defender** — CSPM on the Standard tier.
- **Networking** — NSG association on subnets and DDoS Network Protection on VNets.
- **Entra ID** — app registration credential expiry, users with recent sign-in and strong authentication enforcement.
- **Recovery Services** — vaults with at least one protected backup item and vaults with adequate backup policy.
Thanks to @s1ns3nz0 for all these contributions!
#### GCP
New coverage for high availability and public-exposure detection:
- `cloudsql_instance_high_availability_enabled` — Cloud SQL primary instances use `REGIONAL` availability for automatic zone failover.
- `cloudfunction_function_inside_vpc` — Cloud Functions use a Serverless VPC Access connector for private egress.
- `cloudfunction_function_not_publicly_accessible` — detects `allUsers` / `allAuthenticatedUsers` IAM invocation bindings.
- `secretmanager_secret_not_publicly_accessible` — detects Secret Manager secrets with public IAM bindings.
- `secretmanager_secret_rotation_enabled` — verifies Secret Manager secrets have automatic rotation configured with a period of 90 days or less and no missed rotation.
Thanks to @s1ns3nz0 for all these contributions!
#### Kubernetes
New core checks for container resource governance and reliability: CPU limits, CPU requests, memory limits, memory requests, fixed image tags, liveness probes, and readiness probes. Thanks to @Nikhilkumar2311 for all these contributions!
#### Microsoft 365
- `entra_directory_sync_object_takeover_blocked` — hybrid Entra tenants block cloud object takeover through soft-match and hard-match directory synchronization. Thanks to @PrettyFox0 and @omobolajiadeyan!
- `entra_conditional_access_policy_no_deleted_object_references` — flags Conditional Access policies that reference user, group, or role objects that no longer resolve in the directory. Thanks to @ernestprovo23!
#### Oracle Cloud Infrastructure
- `identity_storage_service_level_admins_scoped` — CIS 3.1 control 1.15, ensuring storage service-level administrators exclude delete permissions.
Explore all checks at [Prowler Hub](https://hub.prowler.com/check).
### 🐍 Python 3.13 Support
The Prowler SDK now supports **Python 3.13**. Thanks to @branchv!
### 🔐 Security Updates
- **SDK** — `pytest` 8.3.5 → 9.0.3, `black` 25.1.0 → 26.3.1, `microsoft-kiota-*` → 1.9.9, and `aiohttp` → 3.14.0, patching known CVEs.
- **API** — `aiohttp` → 3.14.0 and `idna` → 3.15, patching known CVEs.
- **UI** — bumped vulnerable `Next.js`, React, AI SDK, `postcss`, `hono`, `qs`, `esbuild`, and Alpine OpenSSL packages; `dompurify` 3.4.2 → 3.4.10, patching XSS sanitization bypass advisories.
- **Containers** — base image bumped to `python:3.12.13-slim-bookworm` (patches `libgnutls30` CVE-2026-33845 and CVE-2026-42010) and `trivy` to 0.71.0 (patches embedded `golang.org/x/crypto` and Go stdlib CVEs).
### 🙌 External Contributors
Thank you to our community contributors for this release!
- @varunmamillapalli — New Linode provider: administration, compute, and networking services ([#11633](https://github.com/prowler-cloud/prowler/pull/11633))
- @s1ns3nz0 — 20+ Azure & GCP checks across Cosmos DB, AKS, Databricks, Flexible Servers, Entra, networking, and GCP public-exposure
- @Nikhilkumar2311 — Kubernetes resource limits, requests, image tag, and probe checks ([#11373](https://github.com/prowler-cloud/prowler/pull/11373))
- @ernestprovo23 — AWS Security Hub/Config org-wide delegated admin checks ([#11259](https://github.com/prowler-cloud/prowler/pull/11259)) and M365 conditional access check ([#11236](https://github.com/prowler-cloud/prowler/pull/11236))
- @AlexanderSanin — `sagemaker_clarify_exists` check ([#11211](https://github.com/prowler-cloud/prowler/pull/11211))
- @PrettyFox0 with @omobolajiadeyan as co-author — M365 directory sync object takeover check ([#11098](https://github.com/prowler-cloud/prowler/pull/11098))
- @branchv — Python 3.13 support ([#9293](https://github.com/prowler-cloud/prowler/pull/9293))
- @alinealfa — GCP audit-filtered aggregated sinks fix ([#11575](https://github.com/prowler-cloud/prowler/pull/11575))
- @b-abderrahmane — Configurable Celery worker concurrency ([#11075](https://github.com/prowler-cloud/prowler/pull/11075))
See the [full release notes on GitHub](https://github.com/prowler-cloud/prowler/releases/tag/5.31.0) for the complete list of changes.
</Update>
<Update label="Earlier releases">
Release notes for v5.30.0 and earlier, along with every patch release, are on [GitHub Releases](https://github.com/prowler-cloud/prowler/releases).
</Update>
@@ -0,0 +1,467 @@
---
title: "Attack Paths Queries"
---
This guide explains how to write and maintain Prowler Attack Paths queries: the read-only openCypher queries that traverse the Cartography-ingested cloud graph to detect privilege escalation chains, network exposure, and other graph-shaped security risks.
<Info>
**New to Attack Paths?** Start with the user documentation:
- [Attack Paths](/user-guide/tutorials/prowler-app-attack-paths) - What Attack Paths detects, how to run built-in queries, and how to explore the resulting graph.
- [Writing Custom openCypher Queries](/user-guide/tutorials/prowler-app-attack-paths#writing-custom-opencypher-queries) - Run ad-hoc read-only queries from the Prowler App.
</Info>
## Introduction
Attack Paths queries run against a property graph populated by [Cartography](https://github.com/cartography-cncf/cartography), an open-source graph ingestion framework, and enriched with Prowler findings. Every query is read-only openCypher (Version 9) so it runs on both the Neo4j and Amazon Neptune sinks.
Two categories of query exist, each with a different isolation model:
| | Predefined queries | Custom queries |
| ------------------ | ----------------------------------------------------------- | --------------------------------------------------------------------- |
| Where they live | `api/src/backend/api/attack_paths/queries/{provider}.py` | User-supplied through the custom query API endpoint |
| Provider isolation | `AWSAccount {id: $provider_uid}` anchor plus path connectivity | Automatic `_Provider_{uuid}` label injection by `cypher_sanitizer.py` |
| What to write | Chain every `MATCH` from the `aws` variable | Plain Cypher, no isolation boilerplate |
| Internal labels | Never use | Never use (system-injected) |
For **predefined queries**, every node must be reachable from the `AWSAccount` root through graph traversal. That reachability is the isolation boundary.
For **custom queries**, the runner injects a `_Provider_{uuid}` label into every node pattern, and a post-query filter handles edge cases, so query authors write natural Cypher without isolation boilerplate.
The rest of this guide focuses on predefined queries, though the graph model, list-property handling, and compatibility rules apply to both.
## The Graph Model
### Cartography Schema
Node labels, relationship types, and properties follow the upstream Cartography schema for each provider. Do not guess them, fetch the schema for the pinned Cartography version:
```bash
grep cartography api/pyproject.toml
```
Then read the schema for that exact tag:
```text
# Git pin (prowler-cloud/cartography@<TAG>):
https://raw.githubusercontent.com/prowler-cloud/cartography/refs/tags/<TAG>/docs/root/modules/{provider}/schema.md
# PyPI pin (cartography==<TAG>):
https://raw.githubusercontent.com/cartography-cncf/cartography/refs/tags/<TAG>/docs/root/modules/{provider}/schema.md
```
The public schema reference for AWS is available at [Cartography AWS Schema](https://cartography-cncf.github.io/cartography/modules/aws/schema.html).
### Prowler-Specific Additions
The Prowler sync task enriches the Cartography graph with the following labels and relationships. These are not part of the upstream schema:
| Label / Relationship | Description |
| ---------------------- | ----------------------------------------------------------- |
| `ProwlerFinding` | Finding node (`status`, `severity`, `check_id`) |
| `Internet` | Internet sentinel node used to model public exposure |
| `CAN_ACCESS` | `(Internet)-[:CAN_ACCESS]->(resource)` exposure edge |
| `HAS_FINDING` | `(resource)-[:HAS_FINDING]->(:ProwlerFinding)` finding link |
| `TRUSTS_AWS_PRINCIPAL` | Role trust relationship |
| `STS_ASSUMEROLE_ALLOW` | Principal can assume a role |
### Internal Isolation Labels
The sync layer also adds internal labels used only for tenant and provider isolation: `_ProviderResource`, `_AWSResource`, `_Tenant_*`, and `_Provider_*`. These must never appear in query text, predefined or custom. The runner applies isolation automatically.
## Query Structure
### Provider Scoping Parameter
| Parameter | Property | Used on | Purpose |
| --------------- | -------- | ------------ | -------------------------------------- |
| `$provider_uid` | `id` | `AWSAccount` | Scopes the query to a specific account |
The runner binds `$provider_uid` automatically. Every other node is isolated by path connectivity from the `AWSAccount` anchor.
### Imports
```python
from api.attack_paths.queries.types import (
AttackPathsQueryAttribution,
AttackPathsQueryDefinition,
AttackPathsQueryParameterDefinition,
)
from tasks.jobs.attack_paths.config import PROWLER_FINDING_LABEL
```
Always reference `PROWLER_FINDING_LABEL` through f-string interpolation, never hardcode `"ProwlerFinding"`.
### Definition Fields
- **id**: kebab-case `{provider}-{category}-{description}`, e.g. `aws-ec2-privesc-passrole-iam`.
- **name**: short, human-friendly label. Sourced queries append the reference ID: `"EC2 Instance Launch with Privileged Role (EC2-001)"`.
- **short_description**: one sentence, no technical permissions.
- **description**: full technical explanation, plain text.
- **provider**: `aws`, `azure`, `gcp`, `kubernetes`, or `github`.
- **cypher**: f-string Cypher body. Literal `{` and `}` are escaped as `{{` and `}}`.
- **parameters**: `parameters=[]` when the query takes no input.
- **attribution**: optional `AttackPathsQueryAttribution(text, link)` for sourced queries. The `link` uses the lowercase ID.
Append the constant to the `{PROVIDER}_QUERIES` list at the bottom of the provider file.
## The Predefined Query Template
The canonical shape combines a principal walk, an optional target walk, deduplicated nodes, and a typed finding overlay:
```python
AWS_QUERY_NAME = AttackPathsQueryDefinition(
id="aws-kebab-case-name",
name="Label (REFERENCE_ID)",
short_description="One sentence.",
description="Full technical explanation.",
attribution=AttackPathsQueryAttribution(
text="pathfinding.cloud - REFERENCE_ID - permission",
link="https://pathfinding.cloud/paths/reference_id_lowercase",
),
provider="aws",
cypher=f"""
// Find principals with the source permission
MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)-[:POLICY]->(policy:AWSPolicy)-[:STATEMENT]->(stmt:AWSPolicyStatement {{effect: 'Allow'}})
MATCH (stmt)-[:HAS_ACTION]->(act:AWSPolicyStatementActionItem)
WHERE toLower(act.value) IN ['permission_lowercase', 'service:*']
OR act.value = '*'
WITH DISTINCT aws, principal, stmt, path_principal
// Pre-aggregate the statement's resource values (see "Avoiding Cartesian Products")
MATCH (stmt)-[:HAS_RESOURCE]->(res:AWSPolicyStatementResourceItem)
WITH aws, path_principal, collect(DISTINCT res.value) AS res_values
WITH aws, path_principal, res_values, ('*' IN res_values) AS res_wildcard
// Match each target once against the in-memory resource list
MATCH path_target = (aws)--(target_role:AWSRole)
WITH path_principal, path_target, res_values, res_wildcard,
target_role.name AS rname, target_role.arn AS rarn
WHERE res_wildcard
OR size([rv IN res_values WHERE rv CONTAINS rname OR rarn CONTAINS rv]) > 0
WITH DISTINCT path_principal, path_target
WITH collect(path_principal) + collect(path_target) AS paths
UNWIND paths AS p
UNWIND nodes(p) AS n
WITH paths, collect(DISTINCT n) AS unique_nodes
UNWIND unique_nodes AS n
OPTIONAL MATCH (n)-[pfr:HAS_FINDING]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}})
RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr
""",
parameters=[],
)
```
Key points:
- The principal walk types the `POLICY` and `STATEMENT` hops. Both are low-fan-out (each principal has a handful of policies; each policy a handful of statements), so the typed edge lets the planner cost a cheap inline filter.
- The `(aws)--` hub hops stay anonymous. `AWSAccount` is a high-degree node that fans out to every principal, role, policy, and resource in the account; typing those edges forces the planner to enumerate from the hub and collapses performance on multi-tenant Neptune.
- Other relationship types appear only where the file's existing queries already use one (`TRUSTS_AWS_PRINCIPAL`, `STS_ASSUMEROLE_ALLOW`, `MEMBER_AWS_GROUP`, `HAS_EXECUTION_ROLE`).
- The finding probe is typed `:HAS_FINDING` and left undirected. The type lets Neptune apply an inline edge filter; the missing direction matches the convention of the rest of the file.
- Collapse duplicate rows after each permission gate with `WITH DISTINCT`, carrying only the variables needed by later clauses.
- The `RETURN` shape `paths, dpf, dpfr` is the contract the serializer and visualizer depend on. Do not change it.
## Avoiding Cartesian Products
The most common performance defect in Attack Paths queries is a Cartesian product between a target set and a policy statement's resource items. When the two are written as independent `MATCH` clauses, the planner pairs every target with every resource item before any filter runs. On accounts with many IAM principals, that multiplies into hundreds of thousands of rows and the query errors or times out.
### The Pattern That Causes It
```cypher
// One row per (target_role x resource_item): a Cartesian product
MATCH path_target = (aws)--(target_role:AWSRole)
MATCH (stmt)-[:HAS_RESOURCE]->(res:AWSPolicyStatementResourceItem)
WHERE res.value = '*'
OR res.value CONTAINS target_role.name
OR target_role.arn CONTAINS res.value
```
The two `MATCH` clauses share no relationship, so the engine enumerates all targets multiplied by all resource items, applies a non-indexable `CONTAINS` to each pair, then expands the finding overlay for every surviving row. Cost grows with `targets × resources`, and a second constrained statement (`stmt2`) multiplies it again.
### The Pattern That Avoids It
Collect the statement's resource values into a list once, then match each target a single time against that in-memory list:
```cypher
// Pre-aggregate the statement's resource values into a list
MATCH (stmt)-[:HAS_RESOURCE]->(res:AWSPolicyStatementResourceItem)
WITH aws, path_principal, collect(DISTINCT res.value) AS res_values
WITH aws, path_principal, res_values, ('*' IN res_values) AS res_wildcard
// Match each target once; bind name/arn to locals so the predicate reads them once
MATCH path_target = (aws)--(target_role:AWSRole)
WITH path_principal, path_target, res_values, res_wildcard,
target_role.name AS rname, target_role.arn AS rarn
WHERE res_wildcard
OR size([rv IN res_values WHERE rv CONTAINS rname OR rarn CONTAINS rv]) > 0
```
Cost now grows with `targets + resources` (linear) rather than `targets × resources`. The rewrite is a pure algebraic identity: the result set is unchanged.
Guidelines:
- **Aggregate resources before matching targets, not after.** `collect(DISTINCT res.value)` reduces the resource items to a single list per statement.
- **Short-circuit the wildcard grant** with `('*' IN res_values)`. When a statement grants `*`, every target matches, so the list scan is skipped entirely.
- **Bind `target.name` and `target.arn` to local variables** in a `WITH` before the predicate. The list comprehension then reads each once per target instead of re-reading the property store once per resource value.
- **Use `size([... ]) > 0`, not `any(...)`.** The `any()`, `all()`, and `none()` predicate functions are not part of the openCypher specification and fail on Amazon Neptune. See [openCypher Compatibility](#opencypher-compatibility).
- **For two-statement queries**, aggregate each statement's resources into its own list (`res_values`, `res2_values`) and combine the two `size([... ]) > 0` checks with `AND`.
Every IAM privilege escalation query in `aws.py` uses this pattern. The lateral-movement variants that constrain the target with a relationship (`STS_ASSUMEROLE_ALLOW`, `TRUSTS_AWS_PRINCIPAL`) already limit the target set before the resource filter, which keeps them efficient without further aggregation.
## Privilege Escalation Sub-Patterns
Four `path_target` shapes cover the common escalation types. Each shares the canonical template's `path_principal`, the resource pre-aggregation, the deduplication tail, and the `RETURN`; only the `path_target` `MATCH` and its resource predicate differ.
| Sub-pattern | Target | `path_target` shape | Example |
| ------------------- | ------------------------ | ------------------------------------------------------------------------------------------------------- | ------- |
| Self-escalation | Principal's own policies | `(aws)--(target_policy:AWSPolicy)--(principal)` | IAM-001 |
| Lateral to user | Other IAM users | `(aws)--(target_user:AWSUser)` | IAM-002 |
| Assume-role lateral | Assumable roles | `(aws)--(target_role:AWSRole)-[:STS_ASSUMEROLE_ALLOW]-(principal)` | IAM-014 |
| PassRole plus service | Service-trusting roles | `(aws)--(target_role:AWSRole)-[:TRUSTS_AWS_PRINCIPAL]-(:AWSPrincipal {arn: '{service}.amazonaws.com'})` | EC2-001 |
**Multi-permission queries** (for example PassRole plus a service-create action) add permission gates before `path_target`. Reuse the per-query counter for new variables (`act2`, `policy2`, `stmt2`) and collapse rows after each gate:
```cypher
MATCH (principal)-[:POLICY]->(policy2:AWSPolicy)-[:STATEMENT]->(stmt2:AWSPolicyStatement {effect: 'Allow'})
MATCH (stmt2)-[:HAS_ACTION]->(act2:AWSPolicyStatementActionItem)
WHERE toLower(act2.value) IN ['service:*', 'service:createsomething']
OR act2.value = '*'
WITH DISTINCT aws, principal, stmt, stmt2, path_principal
```
When a permission is an existence-only gate whose statement resource is not checked later, keep the policy and statement anonymous and carry only the variables still needed:
```cypher
MATCH (principal)-[:POLICY]->(:AWSPolicy)-[:STATEMENT]->(:AWSPolicyStatement {effect: 'Allow'})-[:HAS_ACTION]->(act3:AWSPolicyStatementActionItem)
WHERE toLower(act3.value) IN ['service:*', 'service:othersomething']
OR act3.value = '*'
WITH DISTINCT aws, principal, stmt, path_principal
```
## Network Exposure Pattern
The Internet node is reached through `CAN_ACCESS` from an already-scoped resource, never as a standalone lookup:
```python
cypher=f"""
// Resource scoped through the account anchor
MATCH path = (aws:AWSAccount {{id: $provider_uid}})--(resource:EC2Instance)
WHERE resource.exposed_internet = true
// Internet node reached through path connectivity from the resource
OPTIONAL MATCH (internet:Internet)-[can_access:CAN_ACCESS]->(resource)
WITH collect(path) AS paths, head(collect(internet)) AS internet, collect(can_access) AS can_access
UNWIND paths AS p
UNWIND nodes(p) AS n
WITH paths, internet, can_access, collect(DISTINCT n) AS unique_nodes
UNWIND unique_nodes AS n
OPTIONAL MATCH (n)-[pfr:HAS_FINDING]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}})
RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr,
internet, can_access
"""
```
The `CAN_ACCESS` edge stays typed and directed (`-[:CAN_ACCESS]->`); that is its canonical sync-time orientation. Network-exposure queries extend the `RETURN` contract with `internet, can_access`.
## Working with List-Typed Properties
Some Cartography node properties carry a list of values: `AWSPolicyStatement.action`, `AWSPolicyStatement.resource`, `AWSPolicyStatement.notaction`, `AWSPolicyStatement.notresource`, `KMSKey.encryption_algorithms`, `CloudFrontDistribution.aliases`, the container-definition lists on `ECSContainerDefinition`, and many others. The graph models each such property as a set of child item nodes connected to the parent by a typed edge. Queries reach the values by traversing the edge; the parent does not carry the list as a single field.
### Naming Convention
For a list-typed parent property the sink stores:
- **Child label**: `<ParentLabel><PropertyPascal>Item`. Example: `AWSPolicyStatement.resource` becomes `AWSPolicyStatementResourceItem`.
- **Edge type**: `HAS_<PROPERTY_UPPER>`. Example: `resource` becomes `HAS_RESOURCE`.
- **Child property**: `value`, a single scalar string per list element. For list-of-dict properties (rare; for example `SecretsManagerSecretVersion.tags`) the child carries the original dict keys as named fields per the catalog's `field_map`.
### Variable Naming for Child-Item Matches
`aws.py` uses a per-query counter for each `HAS_*` traversal so chained matches stay unambiguous. The counter resets at the top of every query.
| Edge | First | Second | Third |
| ----------------- | ------ | ------- | ------- |
| `HAS_ACTION` | `act` | `act2` | `act3` |
| `HAS_RESOURCE` | `res` | `res2` | `res3` |
| `HAS_NOTACTION` | `nact` | `nact2` | `nact3` |
| `HAS_NOTRESOURCE` | `nres` | `nres2` | `nres3` |
### Matching an Action
To find statements that grant `iam:PassRole`, `iam:*`, or `*`, traverse the `HAS_ACTION` edge in its own `MATCH` clause and apply the predicate in the attached `WHERE`:
```cypher
MATCH (stmt:AWSPolicyStatement {effect: 'Allow'})
MATCH (stmt)-[:HAS_ACTION]->(act:AWSPolicyStatementActionItem)
WHERE toLower(act.value) IN ['iam:passrole', 'iam:*']
OR act.value = '*'
```
The literal-action list is case-folded with `toLower(act.value)` because IAM authors mix case (`iam:PassRole`, `iam:passrole`); the `*` wildcard never lower-cases.
### Matching a Resource Against a Target
To find statements whose resource can target a specific node, pre-aggregate the resource values and test the target against the list once (see [Avoiding Cartesian Products](#avoiding-cartesian-products)):
```cypher
MATCH (stmt)-[:HAS_RESOURCE]->(res:AWSPolicyStatementResourceItem)
WITH aws, path_principal, collect(DISTINCT res.value) AS res_values
WITH aws, path_principal, res_values, ('*' IN res_values) AS res_wildcard
MATCH path_target = (aws)--(target_role:AWSRole)
WITH path_principal, path_target, res_values, res_wildcard,
target_role.name AS rname, target_role.arn AS rarn
WHERE res_wildcard
OR size([rv IN res_values WHERE rv CONTAINS rname OR rarn CONTAINS rv]) > 0
```
Three predicates cover the resource cases: full wildcard (`*`), a pattern containing the target name (`arn:aws:iam::*:role/admin*`), and a pattern that is a prefix or component of the actual ARN.
### Every-Item and Any-Item Predicates on a Custom Query
Custom queries can express list predicates directly with pattern comprehensions. To check whether *every* item satisfies a predicate, count the counter-examples and require zero, together with a guard that ensures at least one item is attached:
```cypher
MATCH (stmt:AWSPolicyStatement)
WHERE size([
(stmt)-[:HAS_ACTION]->(a:AWSPolicyStatementActionItem)
WHERE NOT toLower(a.value) STARTS WITH 's3:'
| a
]) = 0
AND size([(stmt)-[:HAS_ACTION]->(a:AWSPolicyStatementActionItem) | a]) > 0
RETURN stmt
LIMIT 25
```
To return the list of values directly, collect them from the child items:
```cypher
MATCH (stmt:AWSPolicyStatement {effect: 'Allow'})
OPTIONAL MATCH (stmt)-[:HAS_ACTION]->(a:AWSPolicyStatementActionItem)
RETURN stmt, collect(a.value) AS actions
LIMIT 25
```
### Catalog of List Properties
The provider catalog lives in `api/src/backend/tasks/jobs/attack_paths/provider_config.py` (`AWS_NORMALIZED_LISTS`). Beyond policy statements it includes KMS algorithms, ECS container-definition lists (`entry_point`, `command`, `links`, `dns_servers`, and others), CloudFront aliases, Inspector finding URL and vulnerability lists, and RDS event-subscription categories. To query a list property that is not in the catalog, add an entry there first so the sync layer materializes it. Properties absent from the catalog are serialized to a comma-delimited string and emit a one-time warning during sync.
## Working with JSON-Encoded Properties
Some Cartography properties represent nested objects, most notably `condition` on `AWSPolicyStatement` and `S3PolicyStatement` nodes. To keep the schema portable across graph backends, object-typed properties are stored as JSON-encoded strings:
```
'{"StringEquals":{"aws:SourceAccount":"123456789012"}}'
```
No JSON parser is available at query time, so use `CONTAINS` for substring checks against keys or known values:
```cypher
MATCH (stmt:AWSPolicyStatement)
WHERE stmt.effect = 'Allow'
AND stmt.condition CONTAINS '"aws:SourceAccount"'
RETURN stmt
LIMIT 25
```
When a query needs to inspect the structured members of a condition (for example, to evaluate every operator and key), fetch the rows first and parse the JSON in application code. Cypher cannot navigate JSON object keys or values.
## openCypher Compatibility
Queries must run on both Neo4j and Amazon Neptune. Neptune implements a subset of Cypher, so several convenient constructs are unavailable. Avoid the following:
| Feature | Use instead |
| --------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------- |
| APOC procedures (`apoc.*`) | Real nodes and relationships in the graph |
| Neptune extensions | Standard openCypher |
| `any(x IN list ...)` | `size([x IN list WHERE pred]) > 0` |
| `all(x IN list ...)` | `size([x IN list WHERE pred]) = size(list)` |
| `none(x IN list ...)` | `size([x IN list WHERE pred]) = 0` |
| `reduce()` | `UNWIND` plus `collect()` |
| `FOREACH` | `WITH` plus `UNWIND` plus `SET` |
| Regex `=~` | `toLower()` plus exact match, or `STARTS WITH` / `CONTAINS` |
| `CALL () { UNION }` | Multi-label `OR` in `WHERE` |
| Carried value plus aggregate expression | Project the aggregate first (`WITH principal_paths, collect(...) AS target_paths`), then combine lists in the next `WITH` |
| `EXISTS { MATCH (pattern) WHERE pred }` | Standalone `MATCH (pattern)` plus `WHERE pred`; precede the downstream `collect(path...)` with `WITH DISTINCT <path-vars>` to dedupe the joins |
The carried-value-plus-aggregate rule is worth calling out because it is easy to hit. Neo4j 5.x rejects an expression that concatenates a carried list variable with an aggregate in the same projection:
```cypher
// Rejected: "Aggregation column contains implicit grouping expressions"
WITH principal_paths + collect(DISTINCT path_target) AS paths
```
Split it into two `WITH` clauses so the aggregation resolves before the concatenation:
```cypher
WITH principal_paths, collect(DISTINCT path_target) AS target_paths
WITH principal_paths + target_paths AS paths
```
For list-typed properties in the catalog (action, resource, and so on), traverse the `HAS_*` edges to the child item nodes rather than reading a single field; `split(...)` and comma-string predicates do not apply.
## Best Practices
1. **Chain every MATCH from the account anchor.** An unanchored `MATCH (role:AWSRole)` returns roles from every provider in the graph; `MATCH (aws)--(role:AWSRole)` is scoped. A second-permission `MATCH` such as `MATCH (principal)--(policy2:AWSPolicy)--(stmt2:AWSPolicyStatement)` is safe because `principal` is already bound to the account subgraph.
2. **Pre-aggregate resource lists before matching targets** to avoid Cartesian products (see [Avoiding Cartesian Products](#avoiding-cartesian-products)).
3. **Type the finding probe.** Always `OPTIONAL MATCH (n)-[pfr:HAS_FINDING]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}})`. The type lets Neptune apply an inline edge filter; an untyped probe scans every incident edge of high-degree nodes.
4. **Comment each MATCH.** One inline `// ...` line per clause explaining its role.
5. **Never use internal labels.** `_ProviderResource`, `_AWSResource`, `_Tenant_*`, and `_Provider_*` are system isolation labels and must not appear in query text.
6. **Reach the Internet node through path connectivity** with `(internet:Internet)-[:CAN_ACCESS]->(resource)`, never as a standalone match.
7. **Preserve the RETURN contract.** `paths, dpf, dpfr` for the standard shape; add `internet, can_access` for network-exposure queries. The serializer and visualizer depend on these names.
## Naming Conventions
- **ID**: kebab-case `{provider}-{category}-{description}`, e.g. `aws-ec2-privesc-passrole-iam`.
- **Constant**: `UPPER_SNAKE_CASE` `{PROVIDER}_{CATEGORY}_{DESCRIPTION}`, e.g. `AWS_EC2_PRIVESC_PASSROLE_IAM`.
## Creating a New Query
New queries come from one of two input sources: a [pathfinding.cloud](https://github.com/DataDog/pathfinding.cloud) research ID (for example `ECS-001`, `GLUE-001`) or a natural-language description from the requester. The aggregated `paths.json` is too large to fetch whole; query a single path by ID:
```bash
# Fetch a single path by ID
curl -s https://raw.githubusercontent.com/DataDog/pathfinding.cloud/main/docs/paths.json \
| jq '.[] | select(.id == "ecs-002")'
# List all path IDs and names
curl -s https://raw.githubusercontent.com/DataDog/pathfinding.cloud/main/docs/paths.json \
| jq -r '.[] | "\(.id): \(.name)"'
```
Then follow these steps:
1. **Read the queries module first** to match the existing style:
```text
api/src/backend/api/attack_paths/queries/
├── __init__.py
├── types.py # dataclass definitions
├── registry.py
└── {provider}.py
```
2. **Fetch the Cartography schema for the pinned version.** Do not guess labels, properties, or relationships. See [The Graph Model](#the-graph-model).
3. **Build the query** from the canonical template plus the appropriate sub-pattern (privilege escalation or network exposure). Pre-aggregate resource lists, traverse `HAS_*` edges for list-typed properties, and keep the `RETURN` contract.
4. **Register** the constant in the `{PROVIDER}_QUERIES` list at the bottom of the provider file.
5. **Verify compatibility** against the [openCypher Compatibility](#opencypher-compatibility) rules, and confirm the query parses and runs on Neo4j before it reaches Neptune.
<Note>
AI assistants connected through Prowler MCP Server can fetch the exact Cartography schema for the active scan with the `prowler_get_attack_paths_cartography_schema` tool, which guarantees that generated queries match the schema version pinned by the running Prowler release.
</Note>
## Reference
- **pathfinding.cloud**: [github.com/DataDog/pathfinding.cloud](https://github.com/DataDog/pathfinding.cloud) (use `curl | jq`; the aggregated `paths.json` is too large for a single fetch).
- **Cartography AWS schema**: [cartography-cncf.github.io/cartography/modules/aws/schema.html](https://cartography-cncf.github.io/cartography/modules/aws/schema.html).
- **Neptune openCypher compliance**: [docs.aws.amazon.com/neptune/latest/userguide/feature-opencypher-compliance.html](https://docs.aws.amazon.com/neptune/latest/userguide/feature-opencypher-compliance.html).
- **Neptune openCypher rewrites**: [docs.aws.amazon.com/neptune/latest/userguide/migration-opencypher-rewrites.html](https://docs.aws.amazon.com/neptune/latest/userguide/migration-opencypher-rewrites.html).
- **openCypher specification**: [github.com/opencypher/openCypher](https://github.com/opencypher/openCypher).
@@ -133,6 +133,7 @@ Only fields with a numeric range, a fixed value set, or a length cap are listed.
| `max_unused_sagemaker_access_days` | `7..180` days | |
| `max_security_group_rules` | `1..1000` | AWS hard limit is 1000 rules per security group |
| `max_ec2_instance_age_in_days` | `1..1095` days | 3 years |
| `max_ec2_instance_stopped_days` | `1..1095` days | 3 years |
| `ec2_high_risk_ports` | each port `1..65535` | port 0 is reserved |
| `max_idle_disconnect_timeout_in_seconds` | `60..1800` s | NIST AC-12: cap at 30 min |
| `max_disconnect_timeout_in_seconds` | `60..3600` s | |
@@ -36,6 +36,9 @@ The former build-time variables map to the new runtime variables as follows:
| `NEXT_PUBLIC_GOOGLE_TAG_MANAGER_ID` | `UI_GOOGLE_TAG_MANAGER_ID` |
| `NEXT_PUBLIC_SENTRY_DSN`, `SENTRY_DSN` | `UI_SENTRY_DSN` |
| `NEXT_PUBLIC_SENTRY_ENVIRONMENT`, `SENTRY_ENVIRONMENT` | `UI_SENTRY_ENVIRONMENT` |
| `NEXT_PUBLIC_IS_CLOUD_ENV` | `UI_CLOUD_ENABLED` |
`UI_CLOUD_ENABLED` is a plain runtime boolean flag that enables Prowler Cloud behavior when set to the exact string `"true"` and defaults to off; unlike the other renamed variables it has no legacy fallback, so `NEXT_PUBLIC_IS_CLOUD_ENV` is no longer read.
The build-time-only Sentry variables used for source-map upload — `SENTRY_ORG`, `SENTRY_PROJECT`, `SENTRY_AUTH_TOKEN`, and `SENTRY_RELEASE` — keep their names, as they are not part of Prowler Local Server's runtime configuration.
+3
View File
@@ -45,6 +45,9 @@ Prowler is constantly evolving. Contributions to checks, services, or integratio
<Card title="Adding New Integrations" icon="link" href="/developer-guide/integrations">
Prowler can work with other tools and platforms through integrations.
</Card>
<Card title="Adding New Attack Paths Queries" icon="diagram-project" href="/developer-guide/attack-paths-queries">
Want to detect new privilege escalation or exposure patterns? Contribute read-only openCypher queries that traverse the cloud graph.
</Card>
<Card title="Proposing or Implementing Features" icon="lightbulb" href="https://github.com/prowler-cloud/prowler/issues/new?template=feature-request.yml">
Propose brand-new features or enhancements to existing ones, or help implement community-requested improvements.
</Card>
+63 -10
View File
@@ -80,7 +80,24 @@
{
"group": "Prowler for AI Agents",
"pages": [
"getting-started/products/prowler-claude-code-plugin"
"user-guide/ai-agents/index",
"user-guide/ai-agents/claude-code",
"user-guide/ai-agents/claude-desktop",
"user-guide/ai-agents/codex",
"user-guide/ai-agents/cursor",
"user-guide/ai-agents/vscode"
]
},
{
"group": "Prowler for MSPs and MSSPs",
"pages": [
"getting-started/products/prowler-for-msps",
"user-guide/tutorials/prowler-for-msps-sign-up",
"user-guide/tutorials/prowler-for-msps-organization",
"user-guide/tutorials/prowler-for-msps-team",
"user-guide/tutorials/prowler-for-msps-customers",
"user-guide/tutorials/prowler-for-msps-billing",
"user-guide/tutorials/prowler-for-msps-branding"
]
}
]
@@ -136,10 +153,18 @@
"user-guide/tutorials/prowler-app-rbac"
]
},
{
"group": "Attack Paths",
"pages": [
"user-guide/tutorials/prowler-app-attack-paths",
"user-guide/tutorials/prowler-app-attack-paths-active-queries"
]
},
{
"group": "Compliance",
"pages": [
"user-guide/compliance/tutorials/compliance",
"user-guide/compliance/tutorials/cross-provider-type-compliance",
"user-guide/compliance/tutorials/cross-provider-compliance",
"user-guide/compliance/tutorials/threatscore"
]
@@ -148,7 +173,6 @@
"group": "Findings",
"pages": [
"user-guide/tutorials/prowler-alerts",
"user-guide/tutorials/prowler-app-attack-paths",
"user-guide/tutorials/prowler-app-finding-groups",
"user-guide/tutorials/prowler-app-findings-triage"
]
@@ -195,6 +219,17 @@
}
]
},
{
"group": "Prowler for MSPs and MSSPs",
"pages": [
"user-guide/tutorials/prowler-for-msps-sign-up",
"user-guide/tutorials/prowler-for-msps-organization",
"user-guide/tutorials/prowler-for-msps-team",
"user-guide/tutorials/prowler-for-msps-customers",
"user-guide/tutorials/prowler-for-msps-billing",
"user-guide/tutorials/prowler-for-msps-branding"
]
},
{
"group": "Prowler Lighthouse AI",
"pages": [
@@ -217,7 +252,12 @@
{
"group": "Prowler for AI Agents",
"pages": [
"getting-started/products/prowler-claude-code-plugin"
"user-guide/ai-agents/index",
"user-guide/ai-agents/claude-code",
"user-guide/ai-agents/claude-desktop",
"user-guide/ai-agents/codex",
"user-guide/ai-agents/cursor",
"user-guide/ai-agents/vscode"
]
},
{
@@ -333,6 +373,13 @@
"user-guide/providers/googleworkspace/authentication"
]
},
{
"group": "Huawei Cloud",
"pages": [
"user-guide/providers/huaweicloud/getting-started-huaweicloud",
"user-guide/providers/huaweicloud/authentication"
]
},
{
"group": "IaC",
"pages": [
@@ -455,7 +502,8 @@
"developer-guide/mcp-server",
"developer-guide/ai-skills",
"developer-guide/prowler-studio",
"developer-guide/server-sent-events"
"developer-guide/server-sent-events",
"developer-guide/attack-paths-queries"
]
},
{
@@ -525,15 +573,16 @@
"troubleshooting"
]
},
{
"tab": "Changelog",
"pages": [
"changelog"
]
},
{
"tab": "About Us",
"icon": "/favicon.ico",
"href": "https://prowler.com/about#team"
},
{
"tab": "Changelog",
"icon": "github",
"href": "https://github.com/prowler-cloud/prowler/releases"
}
],
"global": {
@@ -570,7 +619,7 @@
]
},
"banner": {
"content": "Prowler App is now Prowler Local Server, and Prowler Enterprise is now Prowler Private Cloud. See [Prowler product families](/getting-started/products).",
"content": "Prowler App is now Prowler Local Server, and Prowler Enterprise is now Prowler Private Cloud. See [Prowler product families](/getting-started/products). Check the [latest changes](/changelog).",
"dismissible": false
},
"markdown": {
@@ -660,6 +709,10 @@
{
"source": "/user-guide/tutorials/prowler-cloud-public-ips",
"destination": "/security/networking"
},
{
"source": "/getting-started/products/prowler-claude-code-plugin",
"destination": "/user-guide/ai-agents/claude-code"
}
]
}
@@ -10,7 +10,7 @@ Complete reference guide for all tools available in the Prowler MCP Server. Tool
|----------|------------|------------------------|
| Prowler Hub | 10 tools | No |
| Prowler Documentation | 2 tools | No |
| Prowler Cloud, Private Cloud & Local Server | 32 tools | Yes |
| Prowler Cloud, Private Cloud & Local Server | 42 tools | Yes |
## Tool Naming Convention
@@ -89,6 +89,29 @@ Tools for managing finding muting, including pattern-based bulk muting (mutelist
- **`prowler_update_mute_rule`** - Update a mute rule's name, reason, or enabled status
- **`prowler_delete_mute_rule`** - Delete a mute rule from the system
### Integrations Management
Tools for managing where Prowler sends its results: Amazon S3 buckets, AWS Security Hub, and Jira. Requires the **Manage Integrations** permission.
#### Integration Lifecycle
- **`prowler_list_integrations`** - List the configured integrations with their enabled and connection state, optionally filtered by integration type
- **`prowler_get_integration`** - Get an integration with its complete, type-specific configuration (bucket and output directory, Security Hub settings and enabled regions, or Jira projects and issue types)
- **`prowler_update_integration`** - Update credentials, configuration, attached providers, or enabled state. Configuration changes are merged with the current one, and the connection is re-checked automatically whenever credentials, configuration, or attached providers change
- **`prowler_delete_integration`** - Permanently remove an integration and its stored credentials
- **`prowler_test_integration_connection`** - Check an integration connection and refresh the configuration Prowler discovers from the remote system (Jira projects, Security Hub regions)
#### Integration Setup
- **`prowler_create_amazon_s3_integration`** - Export scan outputs (CSV, HTML, OCSF JSON, compliance reports) to an S3 bucket, using an IAM role or static credentials
- **`prowler_create_aws_security_hub_integration`** - Send findings to AWS Security Hub in ASFF format for a single AWS provider, reusing the provider credentials or dedicated ones
- **`prowler_create_jira_integration`** - Connect an Atlassian Jira site so findings can be turned into work items. Tenant-wide, not attached to any provider
#### Jira Operations
- **`prowler_get_jira_issue_types`** - List the issue types available in a Jira project, fetched live from Jira
- **`prowler_send_findings_to_jira`** - Create one Jira work item per finding, with its severity, resource, risk, and remediation steps
### Attack Paths Analysis
Tools for analyzing privilege escalation chains and security misconfigurations using graph-based analysis. Attack Paths maps relationships between cloud resources, permissions, and security findings to detect how privileges can be escalated and how misconfigurations can be exploited.
@@ -105,6 +128,23 @@ Tools for viewing compliance status and framework details across all cloud provi
- **`prowler_get_compliance_overview`** - Get high-level compliance status across all frameworks for a specific scan or provider, including pass/fail statistics per framework
- **`prowler_get_compliance_framework_state_details`** - Get detailed requirement-level breakdown for a specific compliance framework, including failed requirements and associated finding IDs
### User Management
Tools for viewing the users in your tenant and identifying the authenticated user.
- **`prowler_list_users`** - List the users in the tenant with their names and emails
- **`prowler_get_user`** - Get detailed information about a specific user by ID, including join date and role/membership IDs
- **`prowler_get_current_user`** - Identify which user the current credentials authenticate as
### Role Management
Tools for browsing RBAC roles and managing the role assigned to a user. A user holds exactly one role, so setting a role replaces the one they held before.
- **`prowler_list_roles`** - List the roles defined in the tenant with their permission scope
- **`prowler_get_role`** - Get detailed information about a specific role by ID, including granted capabilities, visibility scope, assigned users, and provider groups
- **`prowler_get_user_roles`** - List the roles assigned to a specific user, with the capabilities each role grants
- **`prowler_set_user_role`** - Set the role a user holds, replacing the role they had before (idempotent)
## Prowler Hub Tools
Access Prowler's security check catalog and compliance frameworks. **No authentication required.**
@@ -23,6 +23,28 @@ Most users should use the **Cloud MCP Server** — it needs no installation and
- **Cloud MCP Server (HTTP)**: the managed server at `https://mcp.prowler.com/mcp` (or your own self-hosted HTTP server).
- **Local MCP Server (STDIO)**: local installation only (runs as a subprocess of your MCP client).
### Step-by-Step Guides Per Agent
The tabs below are a quick configuration reference. For a walkthrough with screenshots, troubleshooting, and client-specific caveats, follow the dedicated guide for your agent:
<CardGroup cols={2}>
<Card title="Claude Code" icon="terminal" href="/user-guide/ai-agents/claude-code">
Plugin vs. MCP-only, and which Claude surfaces work
</Card>
<Card title="Claude Desktop App (Chat)" icon="comment" href="/user-guide/ai-agents/claude-desktop">
The Chat tab, via a local bridge
</Card>
<Card title="Codex" icon="code" href="/user-guide/ai-agents/codex">
CLI and the VS Code extension
</Card>
<Card title="Cursor" icon="arrow-pointer" href="/user-guide/ai-agents/cursor">
Global and project scopes
</Card>
<Card title="VS Code / Copilot" icon="microsoft" href="/user-guide/ai-agents/vscode">
Agent mode with secure key prompts
</Card>
</CardGroup>
## Cloud MCP Server Configuration (Recommended)
Connect to the **Cloud MCP Server** at `https://mcp.prowler.com/mcp` over HTTP. This is the recommended path — no installation, always up to date. The same configuration works for a self-hosted HTTP server: just swap the URL.
@@ -76,67 +98,6 @@ Connect to the **Cloud MCP Server** at `https://mcp.prowler.com/mcp` over HTTP.
The `mcp-remote` tool acts as a bridge for clients that don't support HTTP natively. Learn more at [mcp-remote on npm](https://www.npmjs.com/package/mcp-remote).
</Info>
</Tab>
<Tab title="Claude Desktop">
1. Open Claude Desktop settings
2. Go to "Developer" tab
3. Click in "Edit Config" button
4. Edit the `claude_desktop_config.json` file with your favorite editor
5. Install a reviewed version of `mcp-remote` in a dedicated local workspace:
```bash
mkdir -p ~/.local/share/prowler-mcp-bridge
cd ~/.local/share/prowler-mcp-bridge
npm init -y
npm install --save-exact mcp-remote@0.1.38
```
6. Add the following configuration:
```json
{
"mcpServers": {
"prowler": {
"command": "/absolute/path/to/.local/share/prowler-mcp-bridge/node_modules/.bin/mcp-remote",
"args": [
"https://mcp.prowler.com/mcp",
"--header",
"Authorization: Bearer ${PROWLER_API_KEY}"
],
"env": {
"PROWLER_API_KEY": "<your-api-key-here>"
}
}
}
}
```
</Tab>
<Tab title="Claude Code">
Run the following command:
```bash
export PROWLER_API_KEY="<your-api-key-here>"
claude mcp add --transport http prowler https://mcp.prowler.com/mcp --header "Authorization: Bearer $PROWLER_API_KEY" --scope user
```
</Tab>
<Tab title="Cursor">
1. Open Cursor settings
2. Go to "Tools & MCP"
3. Click in "New MCP Server" button
4. Add to the JSON Configuration the following:
```json
{
"mcpServers": {
"prowler": {
"url": "https://mcp.prowler.com/mcp",
"headers": {
"Authorization": "Bearer <your-api-key-here>"
}
}
}
}
```
</Tab>
</Tabs>
## Local MCP Server Configuration
@@ -128,8 +128,8 @@ To update the environment file:
Edit the `.env` file and change version values:
```env
PROWLER_UI_VERSION="5.35.0"
PROWLER_API_VERSION="5.35.0"
PROWLER_UI_VERSION="5.36.0"
PROWLER_API_VERSION="5.36.0"
```
<Note>
+1 -1
View File
@@ -18,7 +18,7 @@ Read the [public announcement of the Prowler product families](https://prowler-w
| Prowler Private Cloud | Prowler Cloud deployed in your own environment. Formerly Prowler Enterprise. See [pricing](https://prowler.com/pricing). |
| [Prowler Hub](https://hub.prowler.com) | Free public library of versioned checks, cloud service artifacts, and compliance frameworks. |
| [Prowler Lighthouse AI](/getting-started/products/prowler-cloud-lighthouse) | AI security analyst capabilities within Prowler Cloud and Prowler Private Cloud. |
| [Prowler MCP](/getting-started/products/prowler-mcp) | MCP server that connects AI assistants and agents to Prowler, including IDE plugins such as [Prowler for Claude Code](/getting-started/products/prowler-claude-code-plugin). |
| [Prowler MCP](/getting-started/products/prowler-mcp) | MCP server that connects AI assistants and agents to Prowler, including IDE plugins such as [Prowler for Claude Code](/user-guide/ai-agents/claude-code). |
{/* Unreleased products. Uncomment these rows in the Prowler Products table when announced:
| Prowler Registry | Distribution service for Prowler content such as checks and compliance frameworks. Free and paid tiers. |
@@ -1,102 +0,0 @@
---
title: 'Prowler for Claude Code'
sidebarTitle: 'Claude Code'
---
End-to-end cloud security and compliance from inside [Claude Code](https://www.claude.com/product/claude-code), powered by the [Prowler MCP server](/getting-started/products/prowler-mcp). The plugin lets Claude walk a Prowler Cloud-connected account through a compliance assessment and remediate findings until the chosen security or industry framework is compliant.
<Warning>
**Preview**: this plugin is under active development. Please report issues on [GitHub](https://github.com/prowler-cloud/prowler/issues) or join the [Slack community](https://goto.prowler.com/slack) for feedback.
</Warning>
## Requirements
<CardGroup cols={3}>
<Card title="Claude Code" icon="terminal">
Installed and signed in. See the [official install guide](https://www.claude.com/product/claude-code).
</Card>
<Card title="Prowler Cloud account" icon="cloud">
The free tier is enough to start. Sign up at [cloud.prowler.com](https://cloud.prowler.com).
</Card>
<Card title="Prowler API key" icon="key">
Create one at [cloud.prowler.com/profile](https://cloud.prowler.com/profile).
</Card>
</CardGroup>
## Installation
<Tabs>
<Tab title="From GitHub (recommended)">
Inside a Claude Code session:
```text
/plugin marketplace add prowler-cloud/prowler
/plugin install prowler@prowler-plugins
```
</Tab>
<Tab title="From a local clone">
If you already have the repository checked out:
```text
/plugin marketplace add /absolute/path/to/prowler
/plugin install prowler@prowler-plugins
```
</Tab>
</Tabs>
## Configuration
On first install, Claude Code prompts for your **Prowler API key**. The value is stored securely (macOS keychain or `~/.claude/.credentials.json`) and used to authenticate against Prowler Cloud.
<Note>
To rotate the key, uninstall and reinstall the plugin — Claude Code will prompt again.
</Note>
## Verify the installation
In a Claude Code session:
```text
/mcp → "prowler" appears as a connected server
/plugin → "prowler" enabled, skill listed as prowler:framework-compliance-triage
```
If `/mcp` reports the `prowler` server as failed, the most common cause is a rejected API key — re-issue one in Prowler Cloud and reinstall the plugin so it re-prompts.
## Usage
Open a conversation that mentions the framework you want to comply with. Examples:
- *"Make my AWS production account compliant with CIS 4.0."*
- *"Make my current Terraform project compliant with Prowler ThreatScore Compliance Framework based on the latest scan results."*
- *"Help me get to 100% on PCI-DSS for this GCP project."*
You pick a **primary tool** (Terraform, gh / az / aws CLI, web console, or mixed) and a **mode**:
<CardGroup cols={2}>
<Card title="Claude-assisted (default)" icon="hand">
Claude shows each fix — target resource, exact commands, side effects, reversibility — and waits for your go-ahead before applying.
</Card>
<Card title="Claude autonomous" icon="robot">
Claude presents a single up-front plan grouped by shared fixes, waits for one confirmation, then proceeds. It pauses mid-loop if a fix has wide blast radius or a finding is not applicable.
</Card>
</CardGroup>
Claude tracks progress in a markdown report under `.prowler/` at your project root — one file per framework × account. Open it any time to see exactly where the flow is. When all findings are addressed, Claude proposes a fresh Prowler scan to verify everything end-to-end.
## Uninstalling
```text
/plugin uninstall prowler@prowler-plugins
/plugin marketplace remove prowler-plugins
```
The stored API key is removed automatically.
## Troubleshooting
| Symptom | Likely cause | Fix |
| --- | --- | --- |
| `/mcp` shows `prowler` as failed | Rejected API key | Generate a new one in Prowler Cloud and reinstall the plugin to re-prompt. |
| Skill not invoked when expected | The skill description didn't match the prompt | Mention the framework name plus "compliance" or "compliant" in your prompt. |
| "Framework not supported" | Prowler Hub does not list the framework for that provider | Open an issue or PR at [github.com/prowler-cloud/prowler](https://github.com/prowler-cloud/prowler). |
@@ -0,0 +1,78 @@
---
title: "Prowler for MSPs and MSSPs"
sidebarTitle: "Overview"
---
Prowler for MSPs and MSSPs is a dedicated console for Managed Service Providers (MSPs), Managed Security Service Providers (MSSPs), resellers and consultants who run cloud security for other organizations. It lets a provider onboard customers, group them, manage a team, and operate each customer's Prowler Cloud tenant on their behalf.
The console is available at [partners.prowler.com](https://partners.prowler.com).
<Card title="Sign Up for Prowler for MSPs and MSSPs" icon="rocket" href="https://partners.prowler.com/sign-up" />
## What You Get
* **Customer onboarding:** provision a Prowler Cloud tenant for each customer, with a billing plan selected up front.
* **Delegated access:** open any customer's Prowler Cloud tenant from the console. Every action is attributed to you acting on behalf of that customer.
* **Team and roles:** invite team members by email and assign a role that governs what they can do.
* **Consolidated billing:** each customer carries its own plan, with month-to-date revenue reported across every customer.
* **Branding:** upload your logo and preview its intended placement in Settings.
## Core Concepts
Three objects make up the model. Getting these straight makes the rest of the documentation easy to follow.
| Object | What it is |
|---|---|
| **Partner organization** | The provider's own company. The top-level container for everything below, created at sign-up. |
| **Customer** | One of the provider's customers. Each customer maps to a Prowler Cloud tenant and carries its own billing plan. |
| **Team member** | A user in the partner organization, holding a role that governs what they can do. |
## How It Relates to Prowler Cloud
| | Prowler Cloud | Prowler for MSPs and MSSPs |
|---|---|---|
| **Audience** | End customers | MSPs, MSSPs, resellers, consultants |
| **Console** | [cloud.prowler.com](https://cloud.prowler.com) | [partners.prowler.com](https://partners.prowler.com) |
| **Scope** | One organization's own cloud accounts | Many customer organizations |
| **Billing** | Each organization pays for itself | The provider manages a plan per customer |
| **Branding** | Prowler-branded | Logo upload and placement preview in Settings |
Your customers keep signing in to Prowler Cloud with their own users. Provider-side access is **additive** — it does not replace or restrict customer-side users.
## The Console at a Glance
Signing in lands you on the **Dashboard**. The sidebar carries:
| Entry | What it does | Visible to |
|---|---|---|
| **Dashboard** | Partner Insights, a Billing Overview card and an Active Customers table | Everyone |
| **Customers** | Review customer posture and billing and open customer tenants; Superadmins can also add customers | Everyone |
| **Team** | Invite, re-invite, disable and remove team members | Roles with **Manage members** |
| **Settings** | Profile, Partner Code, branding and security | Everyone; editing requires **Manage settings** |
![Prowler for MSPs and MSSPs dashboard](/images/prowler-for-msps/dashboard.png)
**Partner Insights** is the top row: **Total Customers**, broken down into active and non-paid; **Cloud Accounts**, broken down by cloud provider; and **Monitored Resources**, with a note on organizations whose critical risk has grown. Each card carries a 30-day trend.
Below it, **Billing Overview** reports monthly expenses against the previous month and splits revenue for the period into annual, monthly and overage. **Active Customers** lists your customers with their provider count, resource count and last completed scan and, for Superadmins, carries its own **Add Customer** button.
## Getting Access
Sign-up is self-service, approval is not. Register at [partners.prowler.com/sign-up](https://partners.prowler.com/sign-up), then verify your email address — the organization sits in **Pending email verification** until you do, and the Prowler team does not review it before that. Verifying moves the organization to **Pending approval**. Once approved, you can invite your team and start onboarding customers.
## Next Steps
<Columns cols={2}>
<Card title="Sign Up and Sign In" icon="user-plus" href="/user-guide/tutorials/prowler-for-msps-sign-up">
Register, verify your email, and get approved.
</Card>
<Card title="Your Partner Organization" icon="briefcase" href="/user-guide/tutorials/prowler-for-msps-organization">
Lifecycle, settings, Partner Code and closing your organization.
</Card>
<Card title="Onboarding Customers" icon="building" href="/user-guide/tutorials/prowler-for-msps-customers">
Add customers and open their Prowler Cloud tenants.
</Card>
<Card title="Managing Your Team" icon="users" href="/user-guide/tutorials/prowler-for-msps-team">
Invite team members and assign roles.
</Card>
</Columns>
@@ -26,7 +26,7 @@ The fastest way to get started is the **Cloud MCP Server** at `https://mcp.prowl
```
<Card title="Connect Your MCP Client to the Cloud MCP Server" icon="cloud" href="/getting-started/basic-usage/prowler-mcp#cloud-mcp-server-configuration-recommended" horizontal>
Step-by-step setup for Claude Desktop, Claude Code, Cursor, and other clients.
Step-by-step setup for Claude Code, Codex, Cursor, VS Code, and other agents.
</Card>
<Note>
@@ -50,6 +50,7 @@ Full access to your Prowler deployment — Prowler Cloud, Prowler Private Cloud,
- **Resource Inventory**: Search and view detailed information about your audited resources
- **Muting Management**: Create and manage muting lists/rules to suppress non-relevant findings
- **Attack Paths Analysis**: Analyze privilege escalation chains and security misconfigurations through graph-based analysis of cloud resource relationships
- **User & Role Management**: List the users in your tenant, identify the authenticated user, browse RBAC roles, and set the role a user holds
### 2. Prowler Hub
Binary file not shown.

Before

Width:  |  Height:  |  Size: 239 KiB

After

Width:  |  Height:  |  Size: 233 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 433 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 591 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 169 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 370 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 131 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 309 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 705 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 502 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 188 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 270 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 95 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 410 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 94 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 122 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 796 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 547 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 193 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 701 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 155 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1020 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 665 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 40 KiB

After

Width:  |  Height:  |  Size: 194 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 53 KiB

After

Width:  |  Height:  |  Size: 231 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 187 KiB

After

Width:  |  Height:  |  Size: 164 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 66 KiB

After

Width:  |  Height:  |  Size: 173 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

After

Width:  |  Height:  |  Size: 194 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 274 KiB

After

Width:  |  Height:  |  Size: 215 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 191 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 236 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

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