Compare commits

...
73 Commits
Author SHA1 Message Date
Prowler BotandHugo Pereira Brito d6667ec2cb fix(html): escape provider data in reports (#12247)
Co-authored-by: Hugo Pereira Brito <101209179+HugoPBrito@users.noreply.github.com>
2026-07-30 14:36:22 +01:00
2a5a95eb6d fix(sdk): align secret scan source line indexing (#12240)
Co-authored-by: jbchief-dev <jb+dev@chief.so>
Co-authored-by: jbchief-dev <285331266+jbchief-dev@users.noreply.github.com>
Co-authored-by: Daniel Barranquero <danielbo2001@gmail.com>
2026-07-30 12:56:56 +02:00
Prowler BotandPedro Martín d8a0df82cc fix(api): reject API keys whose owning user was deleted (#12224)
Co-authored-by: Pedro Martín <pedromarting3@gmail.com>
2026-07-29 17:30:39 +02:00
Prowler BotandAdrián Peña e293d24bb9 fix(api): refresh Security Hub connection status (#12214)
Co-authored-by: Adrián Peña <adrianjpr@gmail.com>
2026-07-29 11:28:51 +02:00
Prowler BotandHugo Pereira Brito 0940eba0de fix(sdk): render inline code with valid ADF marks (#12213)
Co-authored-by: Hugo Pereira Brito <101209179+HugoPBrito@users.noreply.github.com>
2026-07-29 09:56:03 +01:00
6cc6653256 fix(gcp): make gen2 Cloud Functions IAM policy query thread-safe (#12161)
Co-authored-by: Stefano Baldo <stefanobaldo@gmail.com>
Co-authored-by: Hugo P.Brito <hugopbrit@gmail.com>
2026-07-28 15:49:32 +01:00
489e5dc7cf fix(gcp): detect SSH/RDP exposure when the port is not first in a multi-port firewall rule (#12137)
Co-authored-by: rayair250-droid <ray.air250@gmail.com>
Co-authored-by: Hugo P.Brito <hugopbrit@gmail.com>
2026-07-27 16:52:02 +01:00
Prowler Botandprowler-bot 9d82875037 chore(release): Bump versions to v5.36.1 (#12112)
Co-authored-by: prowler-bot <179230569+prowler-bot@users.noreply.github.com>
2026-07-24 14:53:35 +02:00
Prowler Botandprowler-bot 685d24dfee chore(api): Update prowler dependency to v5.36 for release 5.36.0 (#12110)
Co-authored-by: prowler-bot <179230569+prowler-bot@users.noreply.github.com>
2026-07-24 12:30:29 +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
Pedro Martín 4e22289a19 perf(api): ingest compliance overviews in a single transaction (#11875) 2026-07-20 15:15:39 +02:00
e035e0ff62 fix(alibabacloud): normalize security group policy case (#12049)
Co-authored-by: xianyao.chen <xychen@xianyaochens-MacBook-Pro.local>
Co-authored-by: Hugo P.Brito <hugopbrit@gmail.com>
2026-07-20 13:57:04 +01:00
Robert SaladraandDaniel Barranquero dd81793480 fix(aws): silence invalid escape sequence SyntaxWarning in S3 bucket name validation (#12041)
Co-authored-by: Daniel Barranquero <danielbo2001@gmail.com>
2026-07-20 14:24:22 +02:00
mintlify[bot]andmintlify[bot] <109931778+mintlify[bot]@users.noreply.github.com> 457297a5f6 fix(docs): apply brand tone and writing style fixes (#12052)
Co-authored-by: mintlify[bot] <109931778+mintlify[bot]@users.noreply.github.com>
2026-07-20 13:23:20 +01:00
mintlify[bot]andmintlify[bot] <109931778+mintlify[bot]@users.noreply.github.com> 4da5aed519 fix(docs): typos and grammar (#12053)
Co-authored-by: mintlify[bot] <109931778+mintlify[bot]@users.noreply.github.com>
2026-07-20 13:19:53 +01:00
Sujay V KulkarniandSujayKulkarni-2211 95521d26cb docs(readme): update AWS check count from 615 to 621 (#12011)
Co-authored-by: SujayKulkarni-2211 <sujayvkulkarni@gmail.com>
2026-07-20 13:34:42 +02:00
César ArrobaandPablo F.G cbe06314ca feat(ui): register Stripe publishable keys in runtime config island (#12021)
Co-authored-by: Pablo F.G <pablo.fernandez@prowler.com>
2026-07-20 11:03:51 +02:00
Alejandro Bailo 4b72cc8dd4 fix(ui): hide billing when Cloud billing is disabled (#12047) 2026-07-20 10:04:48 +02:00
Hugo Pereira Brito ce9d46065a feat(sdk): support grouped Jira issue rendering (#12035) 2026-07-20 08:26:27 +01:00
Hugo Pereira Brito 35b3ff2c8e feat(api): support regionless OCI credentials (#11741) 2026-07-17 12:35:34 +01:00
Prowler Botandprowler-bot d7d4cc4849 chore(release): Bump versions to v5.36.0 (#12042)
Co-authored-by: prowler-bot <179230569+prowler-bot@users.noreply.github.com>
2026-07-17 13:08:27 +02:00
Prowler Botandprowler-bot 1459046985 chore(changelog): v5.35.0 (#12038)
Co-authored-by: prowler-bot <179230569+prowler-bot@users.noreply.github.com>
2026-07-17 12:00:58 +02:00
Rubén De la Torre Vico 84c3c9ce0a docs(lighthouse): add side panel view and tools capabilities to Prowler Cloud Lighthouse overview (#12037) 2026-07-17 11:17:14 +02:00
Alejandro Bailo 8271659fda fix(ui): patch dependency vulnerabilities flagged by pnpm audit (#12029) 2026-07-17 10:48:03 +02:00
Adrián Peña 9916e1ac66 chore(api): update Prowler SDK lock (#12036) 2026-07-17 10:32:31 +02:00
Daniel Barranquero ccec96ac5f feat(ui): improve AWS Organizations wizard and docs (#12034) 2026-07-17 10:22:12 +02:00
Adrián Peña f5ea116763 fix(sdk): validate scan configuration exclusions (#12033) 2026-07-17 10:16:06 +02:00
Adrián Peña 99ca260855 docs: explain reduced scan scope results (#12032) 2026-07-17 08:46:28 +02:00
Josema Camacho bfc6b9e577 fix(api): resolve attack paths scan reliability issues (#12019) 2026-07-16 18:15:42 +02:00
lydiavilchez bc3c922177 feat(sdk): apply scan configuration exclusions (#12028) 2026-07-16 17:27:26 +02:00
Alejandro Bailo 0e20d2388e feat(ui): redesign public authentication pages (#12027) 2026-07-16 16:58:00 +02:00
Alejandro Bailo e0ddc0de27 feat(ui): add Lighthouse side panel (#11872) 2026-07-16 16:02:22 +02:00
Rubén De la Torre VicoandAlan Buscaglia cf840588c7 chore(mcp): rebrand prowler_app_* tools to prowler_* and restructure MCP docs (#12017)
Co-authored-by: Alan Buscaglia <gentlemanprogramming@gmail.com>
2026-07-16 15:15:55 +02:00
e1c2e9373c feat(ui): one-step AWS Organizations onboarding + S3 bucket acc (#11927)
Co-authored-by: alejandrobailo <alejandrobailo94@gmail.com>
Co-authored-by: Pablo F.G <pablo.fernandez@prowler.com>
Co-authored-by: Daniel Barranquero <danielbo2001@gmail.com>
2026-07-16 14:57:17 +02:00
Adrián Peña 641a11e4a0 fix(api): disable social account connection notifications (#12018) 2026-07-16 14:00:51 +02:00
Pablo Fernandez Guerra (PFE)andPablo F.G 59b13778e2 fix(ui): show scan in Findings filter from View Findings action (#11997)
Co-authored-by: Pablo F.G <pablo.fernandez@prowler.com>
2026-07-16 12:03:55 +02:00
Pablo Fernandez Guerra (PFE)andPablo F.G 7d8c64d35b fix(ui): allow rename and delete for dynamic providers (#11957)
Co-authored-by: Pablo F.G <pablo.fernandez@prowler.com>
2026-07-16 11:50:35 +02:00
Adrián Peña a022ad5c7a fix: harden Jira site handling (#12012) 2026-07-16 11:48:12 +02:00
Adrián Peña 0f31f1a3c2 fix(api): strengthen social account linking (#12013) 2026-07-16 11:48:06 +02:00
Alejandro Bailo 1ee71c1f20 feat(ui): redesign app sidebar navigation (#11994) 2026-07-16 11:40:08 +02:00
Josema Camacho 0dc424031b fix(api): apply long task limits to Attack Paths scans (#12009) 2026-07-16 10:54:23 +02:00
Prowler Botandprowler-bot 0d899b3076 chore(release): Bump versions to v5.35.0 (#12004)
Co-authored-by: prowler-bot <179230569+prowler-bot@users.noreply.github.com>
2026-07-15 18:16:32 +02:00
579 changed files with 27895 additions and 9051 deletions
+3 -3
View File
@@ -72,8 +72,8 @@ NEO4J_APOC_IMPORT_FILE_ENABLED=false
NEO4J_APOC_IMPORT_FILE_USE_NEO4J_CONFIG=true
NEO4J_APOC_TRIGGER_ENABLED=false
NEO4J_DBMS_CONNECTOR_BOLT_LISTEN_ADDRESS=0.0.0.0:7687
# Neo4j Prowler settings
ATTACK_PATHS_BATCH_SIZE=1000
# Attack Paths graph settings
ATTACK_PATHS_GRAPH_MUTATION_BATCH_SIZE=1000
ATTACK_PATHS_SERVICE_UNAVAILABLE_MAX_RETRIES=3
ATTACK_PATHS_READ_QUERY_TIMEOUT_SECONDS=30
ATTACK_PATHS_MAX_CUSTOM_QUERY_NODES=250
@@ -158,7 +158,7 @@ SENTRY_RELEASE=local
# REO_DEV_CLIENT_ID=
#### Prowler release version ####
NEXT_PUBLIC_PROWLER_RELEASE_VERSION=v5.34.0
NEXT_PUBLIC_PROWLER_RELEASE_VERSION=v5.36.1
# Social login credentials
SOCIAL_GOOGLE_OAUTH_CALLBACK_URL="${AUTH_URL}/api/auth/callback/google"
@@ -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
+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` |
+5 -5
View File
@@ -123,19 +123,19 @@ Every AWS provider scan will enqueue an Attack Paths ingestion job automatically
| Provider | Checks | Services | [Compliance Frameworks](https://docs.prowler.com/user-guide/compliance/tutorials/compliance) | [Categories](https://docs.prowler.com/user-guide/cli/tutorials/misc#categories) | Support | Interface |
|---|---|---|---|---|---|---|
| AWS | 615 | 86 | 47 | 19 | Official | UI, API, CLI |
| Azure | 190 | 22 | 21 | 16 | Official | UI, API, CLI |
| AWS | 621 | 86 | 47 | 19 | Official | UI, API, CLI |
| Azure | 191 | 22 | 21 | 16 | Official | UI, API, CLI |
| GCP | 109 | 20 | 19 | 12 | Official | UI, API, CLI |
| Kubernetes | 90 | 7 | 8 | 11 | Official | UI, API, CLI |
| Kubernetes | 92 | 7 | 8 | 11 | Official | UI, API, CLI |
| GitHub | 24 | 3 | 2 | 5 | Official | UI, API, CLI |
| M365 | 109 | 10 | 6 | 10 | Official | UI, API, CLI |
| M365 | 111 | 10 | 6 | 10 | Official | UI, API, CLI |
| OCI | 52 | 14 | 5 | 10 | Official | UI, API, CLI |
| Alibaba Cloud | 63 | 9 | 6 | 9 | Official | UI, API, CLI |
| Cloudflare | 29 | 3 | 2 | 5 | Official | UI, API, CLI |
| 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 |
+36
View File
@@ -4,6 +4,42 @@ 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
- `attack-paths-scan-perform` Celery tasks now use the configurable long-task time limits instead of the six-hour defaults [(#12009)](https://github.com/prowler-cloud/prowler/pull/12009)
- Attack Paths scans handle provider deletion races cleanly, detect stale tasks after 16 hours, use backend-specific graph synchronization batches, and report exhausted Neptune write retries with the original database error [(#12019)](https://github.com/prowler-cloud/prowler/pull/12019)
### 🔐 Security
- Jira integration credentials only accept bare Atlassian site names containing letters, numbers, and hyphens [(#12012)](https://github.com/prowler-cloud/prowler/pull/12012)
- Social account linking requires a verified matching email from both the identity provider and the existing user account without sending account connection notifications [(#12013)](https://github.com/prowler-cloud/prowler/pull/12013)
---
## [1.35.0] (Prowler v5.34.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 @@
AWS Security Hub integrations now persist successful connection checks during finding delivery so their connection status and last checked timestamp stay current
+2 -2
View File
@@ -45,7 +45,7 @@ dependencies = [
"gunicorn==26.0.0",
"uvloop==0.22.1",
"lxml==6.1.0",
"prowler @ git+https://github.com/prowler-cloud/prowler.git@master",
"prowler @ git+https://github.com/prowler-cloud/prowler.git@v5.36",
"psycopg2-binary==2.9.9",
"pytest-celery[redis] (==1.3.0)",
"sentry-sdk[django] (==2.56.0)",
@@ -71,7 +71,7 @@ name = "prowler-api"
package-mode = false
# Needed for the SDK compatibility
requires-python = ">=3.11,<3.13"
version = "1.35.0"
version = "1.37.1"
# Shared ruff baseline (kept in sync with mcp_server/pyproject.toml).
# target-version tracks this project's lowest supported Python.
+21 -2
View File
@@ -1,3 +1,5 @@
from allauth.account.models import EmailAddress
from allauth.core.exceptions import ImmediateHttpResponse
from allauth.socialaccount.adapter import DefaultSocialAccountAdapter
from api.db_router import MainRouter
from api.db_utils import rls_transaction
@@ -11,6 +13,7 @@ from api.models import (
)
from api.utils import accept_invitation_for_user
from django.db import transaction
from django.http import HttpResponseForbidden
class ProwlerSocialAccountAdapter(DefaultSocialAccountAdapter):
@@ -38,8 +41,13 @@ class ProwlerSocialAccountAdapter(DefaultSocialAccountAdapter):
return None
def pre_social_login(self, request, sociallogin):
# Link existing accounts with the same email address
email = sociallogin.account.extra_data.get("email")
# The provider account is already bound, so no email-based linking is needed.
if sociallogin.account.pk:
return
# Prefer the normalized email populated by allauth. GitHub can return the
# primary email separately from the profile stored in extra_data.
email = sociallogin.user.email or sociallogin.account.extra_data.get("email")
if sociallogin.provider.id == "saml":
# For SAML, the asserted NameID email cannot be trusted on its own:
# any tenant can claim any email domain in its SAML configuration. To
@@ -80,6 +88,17 @@ class ProwlerSocialAccountAdapter(DefaultSocialAccountAdapter):
if email:
existing_user = self.get_user_by_email(email)
if existing_user:
email_is_verified = EmailAddress.objects.filter(
user=existing_user,
email__iexact=email,
verified=True,
).exists()
provider_verified_email = any(
address.verified and address.email.casefold() == email.casefold()
for address in sociallogin.email_addresses
)
if not email_is_verified or not provider_verified_email:
raise ImmediateHttpResponse(HttpResponseForbidden())
sociallogin.connect(request, existing_user)
def save_user(self, request, sociallogin, form=None):
@@ -27,6 +27,7 @@ from django.conf import (
MAX_CUSTOM_QUERY_NODES = env.int("ATTACK_PATHS_MAX_CUSTOM_QUERY_NODES", default=250)
TEMP_DB_PREFIX = "db-tmp-scan-"
DATABASE_NOT_FOUND_CODE = "Neo.ClientError.Database.DatabaseNotFound"
# Exceptions
@@ -44,6 +45,10 @@ class GraphDatabaseQueryException(Exception):
return self.message
class NeptuneWriteRetryExhaustedException(GraphDatabaseQueryException):
pass
class WriteQueryNotAllowedException(GraphDatabaseQueryException):
pass
@@ -10,6 +10,28 @@ import neo4j.exceptions
logger = logging.getLogger(__name__)
class RetryExhaustedError(Exception):
def __init__(
self,
*,
retry_context: str,
method_name: str,
attempts: int,
elapsed_seconds: float,
last_error: Exception,
) -> None:
self.retry_context = retry_context
self.method_name = method_name
self.attempts = attempts
self.elapsed_seconds = elapsed_seconds
self.last_error = last_error
last_message = getattr(last_error, "message", None) or str(last_error)
super().__init__(
f"{retry_context} {method_name} failed after {attempts} attempts over "
f"{elapsed_seconds:.3f}s. Last error: {last_message}"
)
class RetryableSession:
"""Wrapper around ``neo4j.Session`` with a refreshable retry policy."""
@@ -19,11 +41,13 @@ class RetryableSession:
max_retries: int,
retry_if: Callable[[Exception], bool] | None = None,
initial_retry_delay_seconds: float = 0,
retry_context: str | None = None,
) -> None:
self._session_factory = session_factory
self._max_retries = max(0, max_retries)
self._retry_if = retry_if
self._initial_retry_delay_seconds = max(0.0, initial_retry_delay_seconds)
self._retry_context = retry_context
self._session = self._session_factory()
def close(self) -> None:
@@ -54,6 +78,7 @@ class RetryableSession:
def _call_with_retry(self, method_name: str, *args: Any, **kwargs: Any) -> Any:
attempt = 0
last_exc: Exception | None = None
started_at = time.monotonic()
while attempt <= self._max_retries:
try:
@@ -68,17 +93,38 @@ class RetryableSession:
attempt += 1
if attempt > self._max_retries:
if self._retry_context is not None:
raise RetryExhaustedError(
retry_context=self._retry_context,
method_name=method_name,
attempts=attempt,
elapsed_seconds=time.monotonic() - started_at,
last_error=exc,
) from exc
raise
delay = self._retry_delay(attempt)
logger.warning(
"Graph session %s failed with %s; retry %s/%s in %.3fs",
method_name,
type(exc).__name__,
attempt,
self._max_retries,
delay,
)
if self._retry_context is not None:
error_message = getattr(exc, "message", None) or str(exc)
logger.warning(
"%s %s failed with %s: %s; retry %s/%s in %.3fs",
self._retry_context,
method_name,
type(exc).__name__,
error_message,
attempt,
self._max_retries,
delay,
)
else:
logger.warning(
"Graph session %s failed with %s; retry %s/%s in %.3fs",
method_name,
type(exc).__name__,
attempt,
self._max_retries,
delay,
)
self._refresh_session()
if delay:
time.sleep(delay)
@@ -15,6 +15,8 @@ class SinkDatabase(Protocol):
has a single graph, and isolation is label-based).
"""
sync_batch_size: int
def init(self) -> None: ...
def close(self) -> None: ...
@@ -54,6 +54,8 @@ DATABASE_NOT_FOUND_CODE = "Neo.ClientError.Database.DatabaseNotFound"
class Neo4jSink(SinkDatabase):
"""Neo4j-backed sink. Multi-database cluster; tenant isolation is physical."""
sync_batch_size = env.int("ATTACK_PATHS_NEO4J_SYNC_BATCH_SIZE", default=1000)
def __init__(self) -> None:
self._driver: neo4j.Driver | None = None
self._lock = threading.Lock()
@@ -203,7 +205,7 @@ class Neo4jSink(SinkDatabase):
"""
from api.attack_paths.database import GraphDatabaseQueryException
from tasks.jobs.attack_paths.config import (
BATCH_SIZE,
GRAPH_MUTATION_BATCH_SIZE,
PROVIDER_RESOURCE_LABEL,
get_provider_label,
)
@@ -251,7 +253,7 @@ class Neo4jSink(SinkDatabase):
total_key="rels",
deleted_key="deleted_rels",
initial_total=deleted_relationships,
batch_size=BATCH_SIZE,
batch_size=GRAPH_MUTATION_BATCH_SIZE,
drop_t0=drop_t0,
)
relationship_batches += phase_batches
@@ -270,7 +272,7 @@ class Neo4jSink(SinkDatabase):
total_key="nodes",
deleted_key="deleted_nodes",
initial_total=0,
batch_size=BATCH_SIZE,
batch_size=GRAPH_MUTATION_BATCH_SIZE,
drop_t0=drop_t0,
)
@@ -25,7 +25,7 @@ from urllib.parse import urlsplit
import neo4j
import neo4j.exceptions
from api.attack_paths.retryable_session import RetryableSession
from api.attack_paths.retryable_session import RetryableSession, RetryExhaustedError
from api.attack_paths.sink.base import SinkDatabase
from api.attack_paths.sink.drop import (
NODE_DELETE_QUERY_TEMPLATE,
@@ -85,6 +85,8 @@ def _is_retryable_write_error(exc: Exception) -> bool:
class NeptuneSink(SinkDatabase):
"""Neptune-backed sink. Single database; isolation is label-based."""
sync_batch_size = env.int("ATTACK_PATHS_NEPTUNE_SYNC_BATCH_SIZE", default=500)
def __init__(self) -> None:
self._writer: neo4j.Driver | None = None
self._reader: neo4j.Driver | None = None
@@ -206,6 +208,7 @@ class NeptuneSink(SinkDatabase):
from api.attack_paths.database import (
ClientStatementException,
GraphDatabaseQueryException,
NeptuneWriteRetryExhaustedException,
WriteQueryNotAllowedException,
)
@@ -227,9 +230,17 @@ class NeptuneSink(SinkDatabase):
initial_retry_delay_seconds=(
NEPTUNE_WRITE_RETRY_DELAY_SECONDS if is_write_session else 0
),
retry_context="Neptune write" if is_write_session else None,
)
yield session_wrapper
except RetryExhaustedError as exc:
last_error = exc.last_error
raise NeptuneWriteRetryExhaustedException(
message=str(exc),
code=getattr(last_error, "code", None),
) from last_error
except neo4j.exceptions.Neo4jError as exc:
if (
default_access_mode == neo4j.READ_ACCESS
@@ -291,7 +302,7 @@ class NeptuneSink(SinkDatabase):
graph's branching factor.
"""
from tasks.jobs.attack_paths.config import (
BATCH_SIZE,
GRAPH_MUTATION_BATCH_SIZE,
PROVIDER_RESOURCE_LABEL,
get_provider_label,
)
@@ -330,7 +341,7 @@ class NeptuneSink(SinkDatabase):
total_key="rels",
deleted_key="deleted_rels",
initial_total=deleted_relationships,
batch_size=BATCH_SIZE,
batch_size=GRAPH_MUTATION_BATCH_SIZE,
drop_t0=drop_t0,
)
relationship_batches += phase_batches
@@ -349,7 +360,7 @@ class NeptuneSink(SinkDatabase):
total_key="nodes",
deleted_key="deleted_nodes",
initial_total=0,
batch_size=BATCH_SIZE,
batch_size=GRAPH_MUTATION_BATCH_SIZE,
drop_t0=drop_t0,
)
+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):
+22 -7
View File
@@ -1,12 +1,13 @@
import uuid
from functools import wraps
from api.attack_paths.database import GraphDatabaseQueryException
from api.db_router import READ_REPLICA_ALIAS
from api.db_utils import POSTGRES_TENANT_VAR, SET_CONFIG_QUERY, rls_transaction
from api.exceptions import ProviderDeletedException
from api.models import Provider, Scan
from api.models import Membership, Provider, Scan, Tenant
from django.core.exceptions import ObjectDoesNotExist
from django.db import DatabaseError, connection, transaction
from django.db import DEFAULT_DB_ALIAS, DatabaseError, connection, transaction
from rest_framework_json_api.serializers import ValidationError
@@ -75,9 +76,11 @@ def handle_provider_deletion(func):
"""
Decorator that raises `ProviderDeletedException` if provider was deleted during execution.
Catches `ObjectDoesNotExist` and `DatabaseError` (including `IntegrityError`), checks if
provider still exists, and raises `ProviderDeletedException` if not. Otherwise,
re-raises original exception.
Catches `ObjectDoesNotExist`, `DatabaseError` (including `IntegrityError`), and
`GraphDatabaseQueryException`, checks if provider still exists, and raises
`ProviderDeletedException` if not. Graph database errors also check whether the
tenant still exists and has memberships. Otherwise, re-raises the original
exception.
Requires `tenant_id` and `provider_id` in kwargs.
@@ -92,11 +95,16 @@ def handle_provider_deletion(func):
def wrapper(*args, **kwargs):
try:
return func(*args, **kwargs)
except (ObjectDoesNotExist, DatabaseError):
except (ObjectDoesNotExist, DatabaseError, GraphDatabaseQueryException) as exc:
tenant_id = kwargs.get("tenant_id")
provider_id = kwargs.get("provider_id")
database_alias = (
DEFAULT_DB_ALIAS
if isinstance(exc, GraphDatabaseQueryException)
else READ_REPLICA_ALIAS
)
with rls_transaction(tenant_id, using=READ_REPLICA_ALIAS):
with rls_transaction(tenant_id, using=database_alias):
if provider_id is None:
scan_id = kwargs.get("scan_id")
if scan_id is None:
@@ -113,6 +121,13 @@ def handle_provider_deletion(func):
raise ProviderDeletedException(
f"Provider '{provider_id}' was deleted during the scan"
) from None
if isinstance(exc, GraphDatabaseQueryException) and (
not Tenant.objects.filter(pk=tenant_id).exists()
or not Membership.objects.filter(tenant_id=tenant_id).exists()
):
raise ProviderDeletedException(
f"Tenant '{tenant_id}' was deleted during the scan"
) from None
raise
return wrapper
+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.35.0
version: 1.37.1
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:
+171 -15
View File
@@ -2,11 +2,18 @@ from types import SimpleNamespace
from unittest.mock import MagicMock, patch
import pytest
from allauth.socialaccount.models import SocialLogin
from allauth.account import app_settings as account_app_settings
from allauth.account.models import EmailAddress
from allauth.core import context
from allauth.core.exceptions import ImmediateHttpResponse
from allauth.socialaccount import app_settings as socialaccount_app_settings
from allauth.socialaccount.internal.flows.login import complete_login
from allauth.socialaccount.models import SocialAccount, SocialLogin
from api.adapters import ProwlerSocialAccountAdapter
from api.db_router import MainRouter
from api.models import Invitation, Membership, SAMLConfiguration, Tenant
from django.contrib.auth import get_user_model
from django.core import mail
User = get_user_model()
@@ -40,6 +47,7 @@ def _saml_request(rf, organization_slug):
def _saml_sociallogin(user):
sociallogin = MagicMock(spec=SocialLogin)
sociallogin.account = MagicMock()
sociallogin.account.pk = None
sociallogin.provider = MagicMock()
sociallogin.provider.id = "saml"
sociallogin.account.extra_data = {}
@@ -48,6 +56,59 @@ def _saml_sociallogin(user):
return sociallogin
def _oauth_sociallogin(
user,
*,
provider="google",
provider_email_verified=True,
include_extra_email=True,
):
sociallogin = MagicMock(spec=SocialLogin)
sociallogin.account = MagicMock()
sociallogin.account.pk = None
sociallogin.provider = MagicMock()
sociallogin.provider.id = provider
sociallogin.account.extra_data = (
{"email": user.email} if include_extra_email else {}
)
sociallogin.email_addresses = [
EmailAddress(
email=user.email,
verified=provider_email_verified,
primary=True,
)
]
sociallogin.user = user
sociallogin.connect = MagicMock()
return sociallogin
def _real_oauth_sociallogin(user, uid):
provider = MagicMock()
provider.id = "google"
provider.app = None
provider.get_settings.return_value = {}
return SocialLogin(
user=user,
account=SocialAccount(
provider="google",
uid=uid,
extra_data={"email": user.email},
),
email_addresses=[EmailAddress(email=user.email, verified=True, primary=True)],
provider=provider,
)
def _verify_local_email(user):
return EmailAddress.objects.create(
user=user,
email=user.email,
verified=True,
primary=True,
)
@pytest.mark.django_db
class TestProwlerSocialAccountAdapter:
def test_get_user_by_email_returns_user(self, create_test_user):
@@ -157,6 +218,7 @@ class TestProwlerSocialAccountAdapter:
sociallogin = MagicMock(spec=SocialLogin)
sociallogin.account = MagicMock()
sociallogin.account.pk = None
sociallogin.provider = MagicMock()
sociallogin.user = MagicMock()
sociallogin.user.email = ""
@@ -168,25 +230,119 @@ class TestProwlerSocialAccountAdapter:
sociallogin.connect.assert_not_called()
def test_pre_social_login_non_saml_links_by_email(self, create_test_user, rf):
"""Non-SAML providers (e.g. Google/GitHub) still link to an existing
local account by email; the tenant binding only applies to SAML."""
def test_pre_social_login_blocks_unverified_local_email(self, create_test_user, rf):
"""A verified OAuth email must not claim an unverified local account."""
adapter = ProwlerSocialAccountAdapter()
sociallogin = _oauth_sociallogin(create_test_user)
sociallogin = MagicMock(spec=SocialLogin)
sociallogin.account = MagicMock()
sociallogin.provider = MagicMock()
sociallogin.provider.id = "google"
sociallogin.account.extra_data = {"email": create_test_user.email}
sociallogin.user = create_test_user
sociallogin.connect = MagicMock()
with pytest.raises(ImmediateHttpResponse) as exc_info:
adapter.pre_social_login(rf.get("/"), sociallogin)
assert exc_info.value.response.status_code == 403
sociallogin.connect.assert_not_called()
def test_complete_oauth_login_does_not_link_unverified_local_email(
self, create_test_user, rf
):
"""Regression test for the complete pre-hijack account-linking flow."""
incoming_user = User(email=create_test_user.email)
incoming_user.set_unusable_password()
sociallogin = _real_oauth_sociallogin(
incoming_user,
uid="victim-google-account",
)
request = rf.get("/")
request.session = {}
with pytest.raises(ImmediateHttpResponse) as exc_info:
complete_login(request, sociallogin, raises=True)
assert exc_info.value.response.status_code == 403
assert not SocialAccount.objects.filter(
provider="google", uid="victim-google-account"
).exists()
def test_pre_social_login_allows_already_connected_account(
self, create_test_user, rf
):
"""Existing provider bindings do not need to relink on every login."""
adapter = ProwlerSocialAccountAdapter()
sociallogin = _oauth_sociallogin(create_test_user)
sociallogin.account.pk = "existing-social-account"
adapter.pre_social_login(rf.get("/"), sociallogin)
call_args = sociallogin.connect.call_args
assert call_args is not None
_, called_user = call_args[0]
assert called_user.email == create_test_user.email
sociallogin.connect.assert_not_called()
def test_pre_social_login_blocks_unverified_provider_email(
self, create_test_user, rf
):
"""An OAuth provider must prove ownership of the matching email."""
_verify_local_email(create_test_user)
adapter = ProwlerSocialAccountAdapter()
sociallogin = _oauth_sociallogin(
create_test_user,
provider="github",
provider_email_verified=False,
)
with pytest.raises(ImmediateHttpResponse) as exc_info:
adapter.pre_social_login(rf.get("/"), sociallogin)
assert exc_info.value.response.status_code == 403
sociallogin.connect.assert_not_called()
def test_pre_social_login_links_verified_emails(self, create_test_user, rf):
_verify_local_email(create_test_user)
adapter = ProwlerSocialAccountAdapter()
sociallogin = _oauth_sociallogin(create_test_user)
request = rf.get("/")
adapter.pre_social_login(request, sociallogin)
sociallogin.connect.assert_called_once_with(request, create_test_user)
def test_verified_social_account_link_does_not_send_notification(
self, create_test_user, rf
):
_verify_local_email(create_test_user)
sociallogin = _real_oauth_sociallogin(
create_test_user,
uid="verified-google-account",
)
request = rf.get("/")
with context.request_context(request):
ProwlerSocialAccountAdapter().pre_social_login(request, sociallogin)
assert SocialAccount.objects.filter(
provider="google",
uid="verified-google-account",
user=create_test_user,
).exists()
assert mail.outbox == []
def test_pre_social_login_uses_verified_email_missing_from_extra_data(
self, create_test_user, rf
):
"""GitHub can return its verified primary email outside extra_data."""
_verify_local_email(create_test_user)
adapter = ProwlerSocialAccountAdapter()
sociallogin = _oauth_sociallogin(
create_test_user,
provider="github",
include_extra_email=False,
)
request = rf.get("/")
adapter.pre_social_login(request, sociallogin)
sociallogin.connect.assert_called_once_with(request, create_test_user)
def test_social_account_linking_settings_are_fail_closed(self):
assert not socialaccount_app_settings.EMAIL_AUTHENTICATION
assert not socialaccount_app_settings.EMAIL_AUTHENTICATION_AUTO_CONNECT
assert not account_app_settings.EMAIL_NOTIFICATIONS
def test_save_user_social_with_invitation_joins_invited_tenant(
self, rf, create_test_user, tenants_fixture
@@ -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
):
+102 -1
View File
@@ -2,11 +2,12 @@ import uuid
from unittest.mock import call, patch
import pytest
from api.attack_paths.database import GraphDatabaseQueryException
from api.db_utils import POSTGRES_TENANT_VAR, SET_CONFIG_QUERY
from api.decorators import handle_provider_deletion, set_tenant
from api.exceptions import ProviderDeletedException
from django.core.exceptions import ObjectDoesNotExist
from django.db import DatabaseError, IntegrityError
from django.db import DEFAULT_DB_ALIAS, DatabaseError, IntegrityError
@pytest.mark.django_db
@@ -204,6 +205,106 @@ class TestHandleProviderDeletionDecorator:
with pytest.raises(DatabaseError):
task_func(tenant_id=str(tenant.id), provider_id=str(provider.id))
@patch("api.decorators.rls_transaction")
@patch("api.decorators.Provider.objects.filter")
def test_graph_database_error_provider_missing_or_soft_deleted(
self, mock_provider_filter, mock_rls, tenants_fixture
):
tenant = tenants_fixture[0]
provider_id = str(uuid.uuid4())
mock_rls.return_value.__enter__ = lambda s: None
mock_rls.return_value.__exit__ = lambda s, *args: None
mock_provider_filter.return_value.exists.return_value = False
@handle_provider_deletion
def task_func(**kwargs):
raise GraphDatabaseQueryException("Temporary database not found")
with pytest.raises(ProviderDeletedException):
task_func(tenant_id=str(tenant.id), provider_id=provider_id)
@patch("api.decorators.rls_transaction")
@patch("api.decorators.Tenant.objects.filter")
@patch("api.decorators.Provider.objects.filter")
def test_graph_database_error_tenant_missing(
self, mock_provider_filter, mock_tenant_filter, mock_rls, tenants_fixture
):
tenant = tenants_fixture[0]
provider_id = str(uuid.uuid4())
mock_rls.return_value.__enter__ = lambda s: None
mock_rls.return_value.__exit__ = lambda s, *args: None
mock_provider_filter.return_value.exists.return_value = True
mock_tenant_filter.return_value.exists.return_value = False
@handle_provider_deletion
def task_func(**kwargs):
raise GraphDatabaseQueryException("Temporary database not found")
with pytest.raises(ProviderDeletedException):
task_func(tenant_id=str(tenant.id), provider_id=provider_id)
@patch("api.decorators.rls_transaction")
@patch("api.decorators.Membership.objects.filter")
@patch("api.decorators.Tenant.objects.filter")
@patch("api.decorators.Provider.objects.filter")
def test_graph_database_error_tenant_without_memberships(
self,
mock_provider_filter,
mock_tenant_filter,
mock_membership_filter,
mock_rls,
tenants_fixture,
):
tenant = tenants_fixture[0]
provider_id = str(uuid.uuid4())
mock_rls.return_value.__enter__ = lambda s: None
mock_rls.return_value.__exit__ = lambda s, *args: None
mock_provider_filter.return_value.exists.return_value = True
mock_tenant_filter.return_value.exists.return_value = True
mock_membership_filter.return_value.exists.return_value = False
@handle_provider_deletion
def task_func(**kwargs):
raise GraphDatabaseQueryException("Temporary database not found")
with pytest.raises(ProviderDeletedException):
task_func(tenant_id=str(tenant.id), provider_id=provider_id)
@patch("api.decorators.rls_transaction")
@patch("api.decorators.Membership.objects.filter")
@patch("api.decorators.Tenant.objects.filter")
@patch("api.decorators.Provider.objects.filter")
def test_graph_database_error_active_provider_and_tenant_reraises(
self,
mock_provider_filter,
mock_tenant_filter,
mock_membership_filter,
mock_rls,
tenants_fixture,
):
tenant = tenants_fixture[0]
provider_id = str(uuid.uuid4())
graph_error = GraphDatabaseQueryException("Temporary database not found")
mock_rls.return_value.__enter__ = lambda s: None
mock_rls.return_value.__exit__ = lambda s, *args: None
mock_provider_filter.return_value.exists.return_value = True
mock_tenant_filter.return_value.exists.return_value = True
mock_membership_filter.return_value.exists.return_value = True
@handle_provider_deletion
def task_func(**kwargs):
raise graph_error
with pytest.raises(GraphDatabaseQueryException) as exc_info:
task_func(tenant_id=str(tenant.id), provider_id=provider_id)
assert exc_info.value is graph_error
mock_rls.assert_called_once_with(str(tenant.id), using=DEFAULT_DB_ALIAS)
def test_missing_provider_and_scan_raises_assertion(self, tenants_fixture):
"""Raises AssertionError when neither provider_id nor scan_id in kwargs."""
+359
View File
@@ -3,6 +3,8 @@ from unittest.mock import ANY, Mock, patch
import pytest
from api.models import (
Integration,
IntegrationProviderRelationship,
Membership,
ProviderGroup,
ProviderGroupMembership,
@@ -681,6 +683,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,
@@ -1,7 +1,7 @@
from unittest.mock import MagicMock, patch
import pytest
from api.attack_paths.retryable_session import RetryableSession
from api.attack_paths.retryable_session import RetryableSession, RetryExhaustedError
from neo4j.exceptions import ServiceUnavailable
@@ -24,6 +24,7 @@ class TestRetryableSession:
max_retries=3,
retry_if=lambda exc: exc is retryable_error,
initial_retry_delay_seconds=2,
retry_context="Neptune write",
)
assert session.execute_write(work) == "success"
@@ -54,6 +55,7 @@ class TestRetryableSession:
max_retries=3,
retry_if=lambda _: False,
initial_retry_delay_seconds=2,
retry_context="Neptune write",
)
with pytest.raises(RuntimeError) as exc_info:
@@ -83,3 +85,81 @@ class TestRetryableSession:
driver_sessions[0].close.assert_called_once_with()
driver_sessions[1].close.assert_called_once_with()
driver_sessions[2].close.assert_not_called()
def test_retry_exhaustion_with_context_reports_attempts_and_elapsed_time(self):
error = RuntimeError("still retryable")
driver_sessions = [MagicMock() for _ in range(3)]
for driver_session in driver_sessions:
driver_session.execute_write.side_effect = error
session = RetryableSession(
session_factory=MagicMock(side_effect=driver_sessions),
max_retries=2,
retry_if=lambda _: True,
retry_context="Neptune write",
)
with (
patch(
"api.attack_paths.retryable_session.time.monotonic",
side_effect=[100.0, 127.1234],
),
pytest.raises(RetryExhaustedError) as exc_info,
):
session.execute_write(MagicMock())
assert exc_info.value.method_name == "execute_write"
assert exc_info.value.attempts == 3
assert exc_info.value.elapsed_seconds == pytest.approx(27.1234)
assert exc_info.value.last_error is error
assert exc_info.value.__cause__ is error
assert str(exc_info.value) == (
"Neptune write execute_write failed after 3 attempts over 27.123s. "
"Last error: still retryable"
)
def test_retry_exhaustion_with_zero_retries_reports_one_attempt(self):
error = ServiceUnavailable("still unavailable")
driver_session = MagicMock()
driver_session.execute_write.side_effect = error
session = RetryableSession(
session_factory=MagicMock(return_value=driver_session),
max_retries=0,
retry_context="Neptune write",
)
with pytest.raises(RetryExhaustedError) as exc_info:
session.execute_write(MagicMock())
assert exc_info.value.attempts == 1
@patch("api.attack_paths.retryable_session.time.sleep")
@patch("api.attack_paths.retryable_session.random.uniform", return_value=3.0)
def test_contextual_retry_warning_includes_original_error(
self, _mock_uniform, _mock_sleep
):
error = RuntimeError("retryable detail")
first_session = MagicMock()
first_session.execute_write.side_effect = error
second_session = MagicMock()
second_session.execute_write.return_value = "success"
session = RetryableSession(
session_factory=MagicMock(side_effect=[first_session, second_session]),
max_retries=1,
retry_if=lambda _: True,
initial_retry_delay_seconds=2,
retry_context="Neptune write",
)
with patch("api.attack_paths.retryable_session.logger.warning") as mock_warning:
assert session.execute_write(MagicMock()) == "success"
mock_warning.assert_called_once_with(
"%s %s failed with %s: %s; retry %s/%s in %.3fs",
"Neptune write",
"execute_write",
"RuntimeError",
"retryable detail",
1,
1,
3.0,
)
+40
View File
@@ -1,6 +1,7 @@
import logging
from unittest.mock import MagicMock, patch
import pytest
from config.settings import sentry as sentry_settings
from config.settings.sentry import before_send
@@ -82,6 +83,45 @@ def test_before_send_passes_through_non_ignored_log():
assert result == event
def test_before_send_ignores_cartography_missing_temporary_database_log():
log_record = _make_log_record(
msg="Cartography job failed with %s for database %s",
name="cartography.graph.job",
args=(
"Neo.ClientError.Database.DatabaseNotFound",
"db-tmp-scan-12345678",
),
)
event = MagicMock()
assert before_send(event, {"log_record": log_record}) is None
@pytest.mark.parametrize(
("logger_name", "message"),
[
(
"cartography.graph.job.worker",
"Neo.ClientError.Database.DatabaseNotFound for db-tmp-scan-12345678",
),
(
"cartography.graph.job",
"DatabaseNotFound for db-tmp-scan-12345678",
),
(
"cartography.graph.job",
"Neo.ClientError.Database.DatabaseNotFound for db-tenant-12345678",
),
],
)
def test_before_send_passes_through_similar_cartography_logs(logger_name, message):
log_record = _make_log_record(msg=message, name=logger_name)
event = MagicMock()
assert before_send(event, {"log_record": log_record}) is event
def test_before_send_passes_through_non_ignored_exception():
"""Test that before_send passes through exceptions that don't contain ignored exceptions."""
exc_info = (Exception, Exception("Some other error message"), None)
+151 -2
View File
@@ -1,6 +1,14 @@
import pytest
from api.v1.serializer_utils.integrations import S3ConfigSerializer
from api.v1.serializers import ImageProviderSecret, KubernetesProviderSecret
from api.v1.serializer_utils.integrations import (
JiraCredentialSerializer,
S3ConfigSerializer,
)
from api.v1.serializer_utils.providers import ProviderSecretField
from api.v1.serializers import (
ImageProviderSecret,
KubernetesProviderSecret,
OracleCloudProviderSecret,
)
from rest_framework.exceptions import ValidationError
@@ -100,6 +108,59 @@ class TestS3ConfigSerializer:
assert "output_directory" in serializer.errors
class TestJiraCredentialSerializer:
@pytest.mark.parametrize(
"domain",
(
"a",
"prowler",
"prowler-domain",
"A1-b2-C3",
"a" * 63,
),
)
def test_valid_site_name(self, domain):
serializer = JiraCredentialSerializer(
data={
"user_mail": "testing@prowler.com",
"api_token": "fake-api-token",
"domain": domain,
}
)
assert serializer.is_valid(), serializer.errors
@pytest.mark.parametrize(
"domain",
(
"169.254.169.254#",
"internal/service",
"internal?target",
"internal\\target",
"internal:8000",
"user@internal",
"example.atlassian.net",
"-prowler",
"prowler-",
"a" * 64,
" prowler",
"prowler ",
"prowler\n",
),
)
def test_invalid_site_name(self, domain):
serializer = JiraCredentialSerializer(
data={
"user_mail": "testing@prowler.com",
"api_token": "fake-api-token",
"domain": domain,
}
)
assert not serializer.is_valid()
assert "domain" in serializer.errors
class TestImageProviderSecret:
"""Test cases for ImageProviderSecret validation."""
@@ -134,6 +195,64 @@ class TestImageProviderSecret:
assert "non_field_errors" in serializer.errors
class TestOracleCloudProviderSecret:
def valid_secret(self, **overrides):
secret = {
"user": "ocid1.user.oc1..aaaaaaaexample",
"fingerprint": "aa:bb:cc:dd:ee:ff:00:11:22:33:44:55:66:77:88:99",
"key_content": "fake-base64-key-content",
"tenancy": "ocid1.tenancy.oc1..aaaaaaaexample",
}
secret.update(overrides)
return secret
def test_accepts_regionless_secret(self):
serializer = OracleCloudProviderSecret(data=self.valid_secret())
assert serializer.is_valid(), serializer.errors
assert "region" not in serializer.validated_data
def test_accepts_and_ignores_region_field(self):
secret = self.valid_secret(region="us-phoenix-1")
serializer = OracleCloudProviderSecret(data=secret)
assert serializer.is_valid(), serializer.errors
assert "region" not in serializer.validated_data
@pytest.mark.parametrize(
"legacy_field, legacy_value",
[
("region", None),
("region", ""),
("region", {"name": "us-ashburn-1"}),
],
)
def test_accepts_and_ignores_any_legacy_region_value(
self, legacy_field, legacy_value
):
serializer = OracleCloudProviderSecret(
data=self.valid_secret(**{legacy_field: legacy_value})
)
assert serializer.is_valid(), serializer.errors
assert legacy_field not in serializer.validated_data
class TestProviderSecretFieldSchema:
def test_oraclecloud_schema_includes_legacy_region_field(self):
schema = ProviderSecretField._spectacular_annotation["field"]
oraclecloud_schema = next(
credential_schema
for credential_schema in schema["oneOf"]
if credential_schema["title"]
== "Oracle Cloud Infrastructure (OCI) API Key Credentials"
)
assert oraclecloud_schema["properties"]["region"]["deprecated"] is True
class TestKubernetesProviderSecret:
def test_valid_static_kubeconfig_is_accepted(self):
kubeconfig_content = """
@@ -190,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: ["}
+56 -1
View File
@@ -11,7 +11,11 @@ from unittest.mock import MagicMock, patch
import neo4j
import pytest
from api.attack_paths import sink as sink_module
from api.attack_paths.database import GraphDatabaseQueryException
from api.attack_paths.database import (
GraphDatabaseQueryException,
NeptuneWriteRetryExhaustedException,
)
from api.attack_paths.retryable_session import RetryExhaustedError
from api.attack_paths.sink import factory
from api.attack_paths.sink.neo4j import DATABASE_NOT_FOUND_CODE, Neo4jSink
from api.attack_paths.sink.neptune import (
@@ -123,6 +127,14 @@ class TestSinkFactory:
assert mock_driver.call_count == 1
def test_neo4j_sync_batch_size_defaults_to_1000():
assert Neo4jSink.sync_batch_size == 1000
def test_neptune_sync_batch_size_defaults_to_500():
assert NeptuneSink.sync_batch_size == 500
class TestGetBackendForScan:
"""``get_backend_for_scan`` routes by the row's recorded sink backend."""
@@ -372,6 +384,7 @@ class TestNeptuneRetryPolicy:
assert (
kwargs["initial_retry_delay_seconds"] == NEPTUNE_WRITE_RETRY_DELAY_SECONDS
)
assert kwargs["retry_context"] == "Neptune write"
@patch("api.attack_paths.sink.neptune.RetryableSession")
def test_reader_session_does_not_enable_write_retry_policy(self, retryable_session):
@@ -384,6 +397,48 @@ class TestNeptuneRetryPolicy:
kwargs = retryable_session.call_args.kwargs
assert kwargs["retry_if"] is None
assert kwargs["initial_retry_delay_seconds"] == 0
assert kwargs["retry_context"] is None
def test_writer_retry_exhaustion_preserves_neptune_error_details(self):
message = (
"Unexpected server exception 'Operation failed due to conflicting "
"concurrent operations (please retry), 0 transactions are currently "
"rolling back.'"
)
error = neo4j.exceptions.Neo4jError._hydrate_neo4j(
code="BoltProtocol.unexpectedException",
message=message,
)
retry_error = RetryExhaustedError(
retry_context="Neptune write",
method_name="execute_write",
attempts=4,
elapsed_seconds=27.1234,
last_error=error,
)
sink = NeptuneSink()
driver = MagicMock()
retryable_session = MagicMock()
retryable_session.execute_write.side_effect = retry_error
with (
patch.object(sink, "_get_writer", return_value=driver),
patch(
"api.attack_paths.sink.neptune.RetryableSession",
return_value=retryable_session,
),
pytest.raises(NeptuneWriteRetryExhaustedException) as exc_info,
):
with sink.get_session() as session:
session.execute_write(MagicMock())
assert exc_info.value.code == "BoltProtocol.unexpectedException"
assert str(exc_info.value) == (
"BoltProtocol.unexpectedException: Neptune write execute_write failed "
"after 4 attempts over 27.123s. Last error: "
f"{message}"
)
assert exc_info.value.__cause__ is error
class TestNeptuneSinkDropSubgraph:
+91 -8
View File
@@ -171,6 +171,53 @@ class TestInitializeProwlerProvider:
key="value", mutelist_content={"key": "value"}
)
@patch("api.utils.return_prowler_provider")
def test_initialize_oraclecloud_provider_removes_region_string(
self, mock_return_prowler_provider
):
provider = MagicMock()
provider.provider = Provider.ProviderChoices.ORACLECLOUD.value
provider.secret.secret = {
"user": "ocid1.user.oc1..fake",
"fingerprint": "00:11:22:33:44:55:66:77",
"key_content": "fake-base64-key-content",
"tenancy": "ocid1.tenancy.oc1..fake",
"region": "us-ashburn-1",
}
mock_return_prowler_provider.return_value = MagicMock()
initialize_prowler_provider(provider)
mock_return_prowler_provider.return_value.assert_called_once_with(
user="ocid1.user.oc1..fake",
fingerprint="00:11:22:33:44:55:66:77",
key_content="fake-base64-key-content",
tenancy="ocid1.tenancy.oc1..fake",
)
@patch("api.utils.return_prowler_provider")
def test_initialize_oraclecloud_provider_without_region_omits_scan_filter(
self, mock_return_prowler_provider
):
provider = MagicMock()
provider.provider = Provider.ProviderChoices.ORACLECLOUD.value
provider.secret.secret = {
"user": "ocid1.user.oc1..fake",
"fingerprint": "00:11:22:33:44:55:66:77",
"key_content": "fake-base64-key-content",
"tenancy": "ocid1.tenancy.oc1..fake",
}
mock_return_prowler_provider.return_value = MagicMock()
initialize_prowler_provider(provider)
mock_return_prowler_provider.return_value.assert_called_once_with(
user="ocid1.user.oc1..fake",
fingerprint="00:11:22:33:44:55:66:77",
key_content="fake-base64-key-content",
tenancy="ocid1.tenancy.oc1..fake",
)
class TestProwlerProviderConnectionTest:
@patch("api.utils.return_prowler_provider")
@@ -185,6 +232,37 @@ class TestProwlerProviderConnectionTest:
key="value", provider_id="1234567890", raise_on_exception=False
)
@patch("api.utils.return_prowler_provider")
def test_oraclecloud_connection_test_uses_direct_credentials_without_region(
self, mock_return_prowler_provider
):
provider = MagicMock()
provider.uid = "ocid1.tenancy.oc1..aaaaaaaexample"
provider.provider = Provider.ProviderChoices.ORACLECLOUD.value
provider.secret.secret = {
"user": "ocid1.user.oc1..aaaaaaaexample",
"fingerprint": "00:11:22:33:44:55:66:77",
"key_content": "fake-base64-key-content",
"tenancy": "ocid1.tenancy.oc1..aaaaaaaexample",
}
mock_return_prowler_provider.return_value = MagicMock()
prowler_provider_connection_test(provider)
mock_return_prowler_provider.return_value.test_connection.assert_called_once_with(
user="ocid1.user.oc1..aaaaaaaexample",
fingerprint="00:11:22:33:44:55:66:77",
key_content="fake-base64-key-content",
tenancy="ocid1.tenancy.oc1..aaaaaaaexample",
region=getattr(
OraclecloudProvider,
"_bootstrap_region",
OraclecloudProvider._home_region,
),
provider_id="ocid1.tenancy.oc1..aaaaaaaexample",
raise_on_exception=False,
)
@pytest.mark.django_db
@patch("api.utils.return_prowler_provider")
def test_prowler_provider_connection_test_without_secret(
@@ -356,7 +434,7 @@ class TestGetProwlerProviderKwargs:
expected_result = {**secret_dict, **expected_extra_kwargs}
assert result == expected_result
def test_get_prowler_provider_kwargs_oraclecloud_converts_region_string_to_set(
def test_get_prowler_provider_kwargs_oraclecloud_removes_region(
self,
):
secret_dict = {
@@ -377,8 +455,13 @@ class TestGetProwlerProviderKwargs:
result = get_prowler_provider_kwargs(provider)
expected_result = {**secret_dict, "region": {"us-ashburn-1"}}
assert result == expected_result
assert result == {
"user": "ocid1.user.oc1..fake",
"fingerprint": "00:11:22:33:44:55:66:77",
"key_content": "-----BEGIN PRIVATE KEY-----\nfake\n-----END PRIVATE KEY-----",
"tenancy": "ocid1.tenancy.oc1..fake",
"pass_phrase": "fake-passphrase",
}
def test_get_prowler_provider_kwargs_with_mutelist(self):
provider_uid = "provider_uid"
@@ -856,7 +939,7 @@ class TestProwlerIntegrationConnectionTest:
integration.credentials = {
"user_mail": "test@example.com",
"api_token": "test_api_token",
"domain": "example.atlassian.net",
"domain": "example",
}
integration.configuration = {}
@@ -884,7 +967,7 @@ class TestProwlerIntegrationConnectionTest:
mock_jira_class.test_connection.assert_called_once_with(
user_mail="test@example.com",
api_token="test_api_token",
domain="example.atlassian.net",
domain="example",
raise_on_exception=False,
)
@@ -917,7 +1000,7 @@ class TestProwlerIntegrationConnectionTest:
integration.credentials = {
"user_mail": "invalid@example.com",
"api_token": "invalid_token",
"domain": "invalid.atlassian.net",
"domain": "invalid",
}
integration.configuration = {}
@@ -942,7 +1025,7 @@ class TestProwlerIntegrationConnectionTest:
mock_jira_class.test_connection.assert_called_once_with(
user_mail="invalid@example.com",
api_token="invalid_token",
domain="invalid.atlassian.net",
domain="invalid",
raise_on_exception=False,
)
@@ -970,7 +1053,7 @@ class TestProwlerIntegrationConnectionTest:
integration.credentials = {
"user_mail": "test@example.com",
"api_token": "test_api_token",
"domain": "example.atlassian.net",
"domain": "example",
}
integration.configuration = {
"issue_types": {"OLD_PROJ": ["Task"]}, # Existing configuration
+244 -3
View File
@@ -2917,6 +2917,48 @@ class TestProviderGroupViewSet:
@pytest.mark.django_db
class TestProviderSecretViewSet:
@staticmethod
def _oraclecloud_secret(**overrides):
secret = {
"user": "ocid1.user.oc1..aaaaaaaakldibrbov4ubh25aqdeiroklxjngwka7u6w7no3glmdq3n5sxtkq",
"fingerprint": "aa:bb:cc:dd:ee:ff:00:11:22:33:44:55:66:77:88:99",
"key_content": "test-key-content",
"tenancy": "ocid1.tenancy.oc1..aaaaaaaa3dwoazoox4q7wrvriywpokp5grlhgnkwtyt6dmwyou7no6mdmzda",
}
secret.update(overrides)
return secret
def _create_oraclecloud_secret(
self,
authenticated_client,
oraclecloud_provider,
secret,
name="OCI Secret",
):
data = {
"data": {
"type": "provider-secrets",
"attributes": {
"name": name,
"secret_type": ProviderSecret.TypeChoices.STATIC,
"secret": secret,
},
"relationships": {
"provider": {
"data": {
"type": "providers",
"id": str(oraclecloud_provider.id),
}
}
},
}
}
return authenticated_client.post(
reverse("providersecret-list"),
data=json.dumps(data),
content_type="application/vnd.api+json",
)
def test_provider_secrets_list(self, authenticated_client, provider_secret_fixture):
response = authenticated_client.get(reverse("providersecret-list"))
assert response.status_code == status.HTTP_200_OK
@@ -3076,7 +3118,6 @@ current-context: test-context
"fingerprint": "aa:bb:cc:dd:ee:ff:00:11:22:33:44:55:66:77:88:99",
"key_content": "-----BEGIN RSA PRIVATE KEY-----\ntest-key-content\n-----END RSA PRIVATE KEY-----",
"tenancy": "ocid1.tenancy.oc1..aaaaaaaa3dwoazoox4q7wrvriywpokp5grlhgnkwtyt6dmwyou7no6mdmzda",
"region": "us-ashburn-1",
},
),
# OCI with API key credentials (with key_file)
@@ -3088,7 +3129,6 @@ current-context: test-context
"fingerprint": "aa:bb:cc:dd:ee:ff:00:11:22:33:44:55:66:77:88:99",
"key_file": "/path/to/oci_api_key.pem",
"tenancy": "ocid1.tenancy.oc1..aaaaaaaa3dwoazoox4q7wrvriywpokp5grlhgnkwtyt6dmwyou7no6mdmzda",
"region": "us-ashburn-1",
},
),
# OCI with API key credentials (with passphrase)
@@ -3100,7 +3140,6 @@ current-context: test-context
"fingerprint": "aa:bb:cc:dd:ee:ff:00:11:22:33:44:55:66:77:88:99",
"key_content": "-----BEGIN RSA PRIVATE KEY-----\ntest-encrypted-key\n-----END RSA PRIVATE KEY-----",
"tenancy": "ocid1.tenancy.oc1..aaaaaaaa3dwoazoox4q7wrvriywpokp5grlhgnkwtyt6dmwyou7no6mdmzda",
"region": "us-ashburn-1",
"pass_phrase": "my-secure-passphrase",
},
),
@@ -3258,6 +3297,103 @@ current-context: test-context
== data["data"]["relationships"]["provider"]["data"]["id"]
)
def test_provider_secrets_create_oraclecloud_without_region_stores_no_region(
self,
authenticated_client,
oraclecloud_provider,
):
response = self._create_oraclecloud_secret(
authenticated_client,
oraclecloud_provider,
self._oraclecloud_secret(),
)
assert response.status_code == status.HTTP_201_CREATED
provider_secret = ProviderSecret.objects.get()
assert "region" not in provider_secret.secret
def test_provider_secrets_create_oraclecloud_accepts_and_ignores_region(
self,
authenticated_client,
oraclecloud_provider,
):
response = self._create_oraclecloud_secret(
authenticated_client,
oraclecloud_provider,
self._oraclecloud_secret(
key_content=" test-key-content ", region=" us-ashburn-1 "
),
)
assert response.status_code == status.HTTP_201_CREATED
provider_secret = ProviderSecret.objects.get()
assert provider_secret.secret["key_content"] == "test-key-content"
assert "region" not in provider_secret.secret
def test_provider_secrets_update_oraclecloud_without_region_stores_no_region(
self,
authenticated_client,
oraclecloud_provider,
):
create_response = self._create_oraclecloud_secret(
authenticated_client,
oraclecloud_provider,
self._oraclecloud_secret(),
)
provider_secret = ProviderSecret.objects.get(
id=create_response.json()["data"]["id"]
)
data = {
"data": {
"type": "provider-secrets",
"id": str(provider_secret.id),
"attributes": {"secret": self._oraclecloud_secret()},
}
}
response = authenticated_client.patch(
reverse("providersecret-detail", kwargs={"pk": provider_secret.id}),
data=json.dumps(data),
content_type="application/vnd.api+json",
)
assert response.status_code == status.HTTP_200_OK
provider_secret.refresh_from_db()
assert "region" not in provider_secret.secret
def test_provider_secrets_update_oraclecloud_accepts_and_ignores_region(
self,
authenticated_client,
oraclecloud_provider,
):
create_response = self._create_oraclecloud_secret(
authenticated_client,
oraclecloud_provider,
self._oraclecloud_secret(),
)
provider_secret = ProviderSecret.objects.get(
id=create_response.json()["data"]["id"]
)
data = {
"data": {
"type": "provider-secrets",
"id": str(provider_secret.id),
"attributes": {
"secret": self._oraclecloud_secret(region=" us-ashburn-1 ")
},
}
}
response = authenticated_client.patch(
reverse("providersecret-detail", kwargs={"pk": provider_secret.id}),
data=json.dumps(data),
content_type="application/vnd.api+json",
)
assert response.status_code == status.HTTP_200_OK
provider_secret.refresh_from_db()
assert "region" not in provider_secret.secret
@pytest.mark.parametrize(
"attributes, error_code, error_pointer",
(
@@ -13423,6 +13559,45 @@ class TestIntegrationViewSet:
)
assert "credentials" not in response.json()["data"]["attributes"]
@pytest.mark.parametrize(
"domain",
(
"169.254.169.254#",
"internal/service",
"internal?target",
"internal\\target",
"internal:8000",
"user@internal",
),
)
def test_integrations_create_jira_rejects_invalid_domain(
self, authenticated_client, domain
):
data = {
"data": {
"type": "integrations",
"attributes": {
"integration_type": Integration.IntegrationChoices.JIRA,
"configuration": {},
"credentials": {
"domain": domain,
"api_token": "fake-api-token",
"user_mail": "testing@prowler.com",
},
"enabled": True,
},
}
}
response = authenticated_client.post(
reverse("integration-list"),
data=json.dumps(data),
content_type="application/vnd.api+json",
)
assert response.status_code == status.HTTP_400_BAD_REQUEST
assert Integration.objects.count() == 0
def test_integrations_create_valid_relationships(
self,
authenticated_client,
@@ -13963,6 +14138,55 @@ class TestIntegrationViewSet:
assert "projects" in configuration
assert "issue_types" in configuration
def test_integrations_update_jira_rejects_invalid_domain(
self, authenticated_client
):
create_data = {
"data": {
"type": "integrations",
"attributes": {
"integration_type": Integration.IntegrationChoices.JIRA,
"configuration": {},
"credentials": {
"user_mail": "test@example.com",
"api_token": "fake-api-token",
"domain": "original-domain",
},
"enabled": True,
},
}
}
create_response = authenticated_client.post(
reverse("integration-list"),
data=json.dumps(create_data),
content_type="application/vnd.api+json",
)
assert create_response.status_code == status.HTTP_201_CREATED
integration_id = create_response.json()["data"]["id"]
update_data = {
"data": {
"type": "integrations",
"id": integration_id,
"attributes": {
"credentials": {
"user_mail": "test@example.com",
"api_token": "fake-api-token",
"domain": "169.254.169.254#",
}
},
}
}
response = authenticated_client.patch(
reverse("integration-detail", kwargs={"pk": integration_id}),
data=json.dumps(update_data),
content_type="application/vnd.api+json",
)
assert response.status_code == status.HTTP_400_BAD_REQUEST
integration = Integration.objects.get(id=integration_id)
assert integration.credentials["domain"] == "original-domain"
@pytest.mark.django_db
class TestSAMLTokenValidation:
@@ -15524,6 +15748,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"))
+48 -6
View File
@@ -252,12 +252,6 @@ def get_prowler_provider_kwargs(
**prowler_provider_kwargs,
"filter_accounts": [provider.uid],
}
elif provider.provider == Provider.ProviderChoices.ORACLECLOUD.value:
if isinstance(prowler_provider_kwargs.get("region"), str):
prowler_provider_kwargs = {
**prowler_provider_kwargs,
"region": {prowler_provider_kwargs["region"]},
}
elif provider.provider == Provider.ProviderChoices.OPENSTACK.value:
# clouds_yaml_content, clouds_yaml_cloud and provider_id are validated
# in the provider itself, so it's not needed here.
@@ -288,6 +282,11 @@ def get_prowler_provider_kwargs(
**{k: v for k, v in prowler_provider_kwargs.items() if v},
}
elif provider.provider == Provider.ProviderChoices.ORACLECLOUD.value:
prowler_provider_kwargs = _normalize_oraclecloud_provider_kwargs(
prowler_provider_kwargs
)
if mutelist_processor:
mutelist_content = mutelist_processor.configuration.get("Mutelist", {})
# IaC and Image providers don't support mutelist (both use Trivy's built-in logic)
@@ -300,6 +299,40 @@ def get_prowler_provider_kwargs(
return prowler_provider_kwargs
def _normalize_oraclecloud_provider_kwargs(secret: dict) -> dict:
"""Normalize external OCI secret fields into SDK provider kwargs."""
prowler_provider_kwargs = secret.copy()
prowler_provider_kwargs.pop("region", None)
return prowler_provider_kwargs
def _normalize_oraclecloud_connection_test_kwargs(secret: dict) -> dict:
"""Normalize external OCI secret fields into test_connection kwargs."""
from prowler.providers.oraclecloud.oraclecloud_provider import OraclecloudProvider
prowler_provider_kwargs = secret.copy()
prowler_provider_kwargs.pop("region", None)
if (
prowler_provider_kwargs.get("user")
and prowler_provider_kwargs.get("fingerprint")
and prowler_provider_kwargs.get("tenancy")
and (
prowler_provider_kwargs.get("key_content")
or prowler_provider_kwargs.get("key_file")
)
):
# Connection validation needs one OCI endpoint, but scans remain unfiltered.
prowler_provider_kwargs["region"] = getattr(
OraclecloudProvider,
"_bootstrap_region",
OraclecloudProvider._home_region,
)
return prowler_provider_kwargs
def initialize_prowler_provider(
provider: Provider,
mutelist_processor: Processor | None = None,
@@ -402,6 +435,15 @@ def prowler_provider_connection_test(provider: Provider) -> Connection:
if prowler_provider_kwargs.get("registry_token"):
image_kwargs["registry_token"] = prowler_provider_kwargs["registry_token"]
return prowler_provider.test_connection(**image_kwargs)
elif provider.provider == Provider.ProviderChoices.ORACLECLOUD.value:
oraclecloud_kwargs = _normalize_oraclecloud_connection_test_kwargs(
prowler_provider_kwargs
)
return prowler_provider.test_connection(
**oraclecloud_kwargs,
provider_id=provider.uid,
raise_on_exception=False,
)
else:
return prowler_provider.test_connection(
**prowler_provider_kwargs,
@@ -1,10 +1,34 @@
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
ATLASSIAN_SITE_NAME_REGEX = re.compile(
r"\A[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\Z"
)
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()
@@ -97,7 +121,17 @@ class AWSCredentialSerializer(BaseValidateSerializer):
class JiraCredentialSerializer(BaseValidateSerializer):
user_mail = serializers.EmailField(required=True)
api_token = serializers.CharField(required=True)
domain = serializers.CharField(required=True)
domain = serializers.RegexField(
regex=ATLASSIAN_SITE_NAME_REGEX,
required=True,
trim_whitespace=False,
error_messages={
"invalid": (
"Domain must be a valid Atlassian site name containing only "
"letters, numbers, and hyphens."
)
},
)
class Meta:
resource_name = "integrations"
@@ -170,7 +204,10 @@ class JiraCredentialSerializer(BaseValidateSerializer):
},
"domain": {
"type": "string",
"description": "The JIRA domain/instance URL (e.g., 'your-domain.atlassian.net').",
"description": "The Jira site name without the '.atlassian.net' suffix (e.g., 'your-domain').",
"minLength": 1,
"maxLength": 63,
"pattern": "^[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?$",
},
},
"required": ["user_mail", "api_token", "domain"],
@@ -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"],
@@ -295,16 +295,21 @@ from rest_framework_json_api import serializers
"type": "string",
"description": "The OCID of the tenancy.",
},
"region": {
"type": "string",
"description": "The OCI region identifier (e.g., us-ashburn-1, us-phoenix-1).",
},
"pass_phrase": {
"type": "string",
"description": "The passphrase for the private key, if encrypted.",
},
"region": {
"type": "string",
"deprecated": True,
"description": "Legacy OCI region field accepted for backwards compatibility but ignored; OCI scans all regions.",
},
},
"required": ["user", "fingerprint", "tenancy", "region"],
"required": ["user", "fingerprint", "tenancy"],
"anyOf": [
{"required": ["key_file"]},
{"required": ["key_content"]},
],
},
{
"type": "object",
+105 -41
View File
@@ -47,6 +47,7 @@ from api.v1.serializer_utils.integrations import (
JiraCredentialSerializer,
S3ConfigSerializer,
SecurityHubConfigSerializer,
replace_integration_providers,
)
from api.v1.serializer_utils.lighthouse import (
BedrockCredentialsSerializer,
@@ -1568,14 +1569,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 +1592,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
@@ -1672,6 +1684,7 @@ class BaseWriteProviderSecretSerializer(BaseWriteSerializer):
validation_error.detail[f"secret/{key}"] = value
del validation_error.detail[key]
raise validation_error
return serializer.validated_data
class AwsProviderSecret(serializers.Serializer):
@@ -1786,8 +1799,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
@@ -1813,14 +1828,32 @@ class IacProviderSecret(serializers.Serializer):
resource_name = "provider-secrets"
class LegacyOCIRegionField(serializers.Field):
def to_internal_value(self, data):
return data
def to_representation(self, value):
return value
class OracleCloudProviderSecret(serializers.Serializer):
user = serializers.CharField()
fingerprint = serializers.CharField()
key_file = serializers.CharField(required=False)
key_content = serializers.CharField(required=False)
tenancy = serializers.CharField()
region = serializers.CharField()
pass_phrase = serializers.CharField(required=False)
region = LegacyOCIRegionField(required=False, allow_null=True)
def validate(self, attrs):
attrs.pop("region", None)
if "key_file" not in attrs and "key_content" not in attrs:
raise serializers.ValidationError(
{"key_file": "Either key_file or key_content must be provided."}
)
return attrs
class Meta:
resource_name = "provider-secrets"
@@ -1965,7 +1998,11 @@ class ProviderSecretCreateSerializer(RLSSerializer, BaseWriteProviderSecretSeria
secret = attrs.get("secret")
validated_attrs = super().validate(attrs)
self.validate_secret_based_on_provider(provider.provider, secret_type, secret)
validated_secret = self.validate_secret_based_on_provider(
provider.provider, secret_type, secret
)
if provider.provider == Provider.ProviderChoices.ORACLECLOUD.value:
validated_attrs["secret"] = validated_secret
return validated_attrs
@@ -1997,7 +2034,11 @@ class ProviderSecretUpdateSerializer(BaseWriteProviderSecretSerializer):
secret = attrs.get("secret")
validated_attrs = super().validate(attrs)
self.validate_secret_based_on_provider(provider.provider, secret_type, secret)
validated_secret = self.validate_secret_based_on_provider(
provider.provider, secret_type, secret
)
if provider.provider == Provider.ProviderChoices.ORACLECLOUD.value:
validated_attrs["secret"] = validated_secret
return validated_attrs
@@ -2716,6 +2757,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")
@@ -2848,7 +2920,7 @@ class BaseWriteIntegrationSerializer(BaseWriteSerializer):
)
class IntegrationSerializer(RLSSerializer):
class IntegrationSerializer(IntegrationProviderVisibilityMixin, RLSSerializer):
"""
Serializer for the Integration model.
"""
@@ -2877,15 +2949,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")}
@@ -2893,7 +2959,9 @@ class IntegrationSerializer(RLSSerializer):
return representation
class IntegrationCreateSerializer(BaseWriteIntegrationSerializer):
class IntegrationCreateSerializer(
IntegrationProviderVisibilityMixin, BaseWriteIntegrationSerializer
):
credentials = IntegrationCredentialField(write_only=True)
configuration = IntegrationConfigField()
providers = serializers.ResourceRelatedField(
@@ -2944,22 +3012,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(
@@ -3004,15 +3068,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:
@@ -3024,7 +3086,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(
+92 -39
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 (
@@ -281,6 +286,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
@@ -6652,27 +6658,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 +6698,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 +6733,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 +6782,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 +6827,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 +6870,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 +6915,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"]
+1
View File
@@ -74,6 +74,7 @@ celery_app.conf.task_annotations = {
for name in (
"scan-perform",
"scan-perform-scheduled",
"attack-paths-scan-perform",
"provider-deletion",
"tenant-deletion",
)
+2 -2
View File
@@ -312,8 +312,8 @@ ATTACK_PATHS_SCAN_INACTIVITY_THRESHOLD_MINUTES = env.int(
"ATTACK_PATHS_SCAN_INACTIVITY_THRESHOLD_MINUTES", 30
)
ATTACK_PATHS_SCAN_STALE_THRESHOLD_MINUTES = env.int(
"ATTACK_PATHS_SCAN_STALE_THRESHOLD_MINUTES", 2880
) # 48h
"ATTACK_PATHS_SCAN_STALE_THRESHOLD_MINUTES", 960
) # 16h
# Selects where the persistent attack-paths graph is stored. The scan
# temporary database is always Neo4j; only the sink is configurable.
@@ -91,6 +91,13 @@ def before_send(event, hint):
log_msg = log_record.getMessage()
log_lvl = log_record.levelno
if (
getattr(log_record, "name", "") == "cartography.graph.job"
and "Neo.ClientError.Database.DatabaseNotFound" in log_msg
and "db-tmp-scan-" in log_msg
):
return None
# The Neo4j driver logs transient connection errors (defunct
# connections, resets) at ERROR level via the `neo4j.io` logger.
# `RetryableSession` handles these with retries. If all retries
@@ -13,16 +13,17 @@ GITHUB_OAUTH_CALLBACK_URL = env("SOCIAL_GITHUB_OAUTH_CALLBACK_URL", default="")
ACCOUNT_LOGIN_METHODS = {"email"} # Use Email / Password authentication
ACCOUNT_SIGNUP_FIELDS = ["email*", "password1*", "password2*"]
ACCOUNT_EMAIL_VERIFICATION = "none" # Do not require email confirmation
ACCOUNT_EMAIL_NOTIFICATIONS = False
ACCOUNT_USER_MODEL_USERNAME_FIELD = None
REST_AUTH = {
"TOKEN_MODEL": None,
"REST_USE_JWT": True,
}
# django-allauth (social)
# Authenticate if local account with this email address already exists
SOCIALACCOUNT_EMAIL_AUTHENTICATION = True
# Connect local account and social account if local account with that email address already exists
SOCIALACCOUNT_EMAIL_AUTHENTICATION_AUTO_CONNECT = True
# Email-based account matching is handled by ProwlerSocialAccountAdapter, which
# verifies both the provider email and the existing account email before linking.
SOCIALACCOUNT_EMAIL_AUTHENTICATION = False
SOCIALACCOUNT_EMAIL_AUTHENTICATION_AUTO_CONNECT = False
SOCIALACCOUNT_ADAPTER = "api.adapters.ProwlerSocialAccountAdapter"
@@ -8,6 +8,8 @@ import aioboto3
import boto3
import botocore
import neo4j
import neo4j.exceptions
from api.attack_paths.database import DATABASE_NOT_FOUND_CODE
from api.models import (
AttackPathsScan as ProwlerAPIAttackPathsScan,
)
@@ -347,6 +349,12 @@ def sync_aws_account(
)
except Exception as e:
if (
isinstance(e, neo4j.exceptions.Neo4jError)
and e.code == DATABASE_NOT_FOUND_CODE
):
raise
logger.info(
f"Synced function {func_name} for AWS account {prowler_api_provider.uid} in {time.perf_counter() - func_t0:.3f}s (FAILED)"
)
@@ -10,13 +10,10 @@ NormalizedList = _provider_config.NormalizedList
PROVIDER_CONFIGS = _provider_config.PROVIDER_CONFIGS
ProviderConfig = _provider_config.ProviderConfig
# Batch size for Neo4j write operations (resource labeling, cleanup)
BATCH_SIZE = env.int("ATTACK_PATHS_BATCH_SIZE", 1000)
# Batch size for graph mutation operations (resource labeling and subgraph deletion)
GRAPH_MUTATION_BATCH_SIZE = env.int("ATTACK_PATHS_GRAPH_MUTATION_BATCH_SIZE", 1000)
# Batch size for Postgres findings fetch (keyset pagination page size)
FINDINGS_BATCH_SIZE = env.int("ATTACK_PATHS_FINDINGS_BATCH_SIZE", 1000)
# Batch size for temp-to-tenant graph sync (nodes and relationships per cursor page)
SYNC_BATCH_SIZE = env.int("ATTACK_PATHS_SYNC_BATCH_SIZE", 1000)
# Neo4j internal labels (Prowler-specific, not provider-specific)
# - `Internet`: Singleton node representing external internet access for exposed-resource queries
# - `ProwlerFinding`: Label for finding nodes created by Prowler and linked to cloud resources
@@ -21,8 +21,8 @@ from cartography.config import Config as CartographyConfig
from celery.utils.log import get_task_logger
from prowler.config import config as ProwlerConfig
from tasks.jobs.attack_paths.config import (
BATCH_SIZE,
FINDINGS_BATCH_SIZE,
GRAPH_MUTATION_BATCH_SIZE,
get_node_uid_field,
get_provider_resource_label,
get_root_node_label,
@@ -135,7 +135,7 @@ def add_resource_label(
while labeled_count > 0:
result = neo4j_session.run(
query,
{"provider_uid": provider_uid, "batch_size": BATCH_SIZE},
{"provider_uid": provider_uid, "batch_size": GRAPH_MUTATION_BATCH_SIZE},
)
labeled_count = result.single().get("labeled_count", 0)
total_labeled += labeled_count
+31 -12
View File
@@ -372,7 +372,19 @@ def run(tenant_id: str, scan_id: str, task_id: str) -> dict[str, Any]:
except Exception as e:
exception_message = utils.stringify_exception(e, "Attack Paths scan failed")
logger.exception(exception_message)
temporary_database_missing = (
isinstance(e, graph_database.GraphDatabaseQueryException)
and e.code == graph_database.DATABASE_NOT_FOUND_CODE
and tmp_database_name in str(e)
)
if temporary_database_missing:
logger.warning(exception_message)
else:
logger.exception(exception_message)
cleanup_log_level = (
logging.WARNING if temporary_database_missing else logging.ERROR
)
cleanup_exc_info = not temporary_database_missing
ingestion_exceptions["global_error"] = exception_message
# Recover `graph_data_ready` based on how far the swap got
@@ -387,19 +399,24 @@ def run(tenant_id: str, scan_id: str, task_id: str) -> dict[str, Any]:
)
except Exception:
logger.error(
f"Failed to recover `graph_data_ready` for provider {attack_paths_scan.provider_id}",
exc_info=True,
logger.log(
cleanup_log_level,
"Failed to recover `graph_data_ready` for provider "
f"{attack_paths_scan.provider_id}",
exc_info=cleanup_exc_info,
)
# Dropping the temporary database if it still exists
try:
graph_database.drop_database(tmp_cartography_config.neo4j_database)
except Exception as e:
logger.error(
f"Failed to drop temporary Neo4j database `{tmp_cartography_config.neo4j_database}` during cleanup: {e}",
exc_info=True,
except Exception as cleanup_error:
logger.log(
cleanup_log_level,
"Failed to drop temporary Neo4j database "
f"`{tmp_cartography_config.neo4j_database}` during cleanup: "
f"{cleanup_error}",
exc_info=cleanup_exc_info,
)
# Set Attack Paths scan state to FAILED
@@ -407,10 +424,12 @@ def run(tenant_id: str, scan_id: str, task_id: str) -> dict[str, Any]:
db_utils.finish_attack_paths_scan(
attack_paths_scan, StateChoices.FAILED, ingestion_exceptions
)
except Exception as e:
logger.error(
f"Could not mark Attack Paths scan {attack_paths_scan.id} as `FAILED` (row may have been deleted): {e}",
exc_info=True,
except Exception as cleanup_error:
logger.log(
cleanup_log_level,
f"Could not mark Attack Paths scan {attack_paths_scan.id} as `FAILED` "
f"(row may have been deleted): {cleanup_error}",
exc_info=cleanup_exc_info,
)
raise
@@ -30,7 +30,6 @@ from tasks.jobs.attack_paths.config import (
PROVIDER_CONFIGS,
PROVIDER_ISOLATION_PROPERTIES,
PROVIDER_RESOURCE_LABEL,
SYNC_BATCH_SIZE,
NormalizedList,
get_provider_label,
get_tenant_label,
@@ -116,6 +115,7 @@ def sync_nodes(
Source and target sessions are opened sequentially per batch to avoid
holding two Bolt connections simultaneously for the entire sync duration.
"""
batch_size = sink.sync_batch_size
t0 = time.perf_counter()
last_id = -1
parents_synced = 0
@@ -137,7 +137,7 @@ def sync_nodes(
with graph_database.get_session(source_database) as source_session:
result = source_session.run(
NODE_FETCH_QUERY,
{"last_id": last_id, "batch_size": SYNC_BATCH_SIZE},
{"last_id": last_id, "batch_size": batch_size},
)
for record in result:
batch_count += 1
@@ -156,17 +156,17 @@ def sync_nodes(
for labels, batch in parent_groups.items():
rendered_labels = _render_labels(labels, extra_labels)
for sink_batch in _iter_sink_batches(batch):
for sink_batch in _iter_sink_batches(batch, batch_size):
sink.write_nodes(target_database, rendered_labels, sink_batch)
for child_label, batch in child_groups.items():
rendered_labels = _render_labels((child_label,), extra_labels)
for sink_batch in _iter_sink_batches(batch):
for sink_batch in _iter_sink_batches(batch, batch_size):
sink.write_nodes(target_database, rendered_labels, sink_batch)
children_synced += len(batch)
for rel_type, batch in rel_groups.items():
for sink_batch in _iter_sink_batches(batch):
for sink_batch in _iter_sink_batches(batch, batch_size):
sink.write_relationships(
target_database, rel_type, provider_id, sink_batch
)
@@ -205,6 +205,7 @@ def sync_relationships(
Source and target sessions are opened sequentially per batch to avoid
holding two Bolt connections simultaneously for the entire sync duration.
"""
batch_size = sink.sync_batch_size
t0 = time.perf_counter()
last_id = -1
total_synced = 0
@@ -217,7 +218,7 @@ def sync_relationships(
with graph_database.get_session(source_database) as source_session:
result = source_session.run(
RELATIONSHIPS_FETCH_QUERY,
{"last_id": last_id, "batch_size": SYNC_BATCH_SIZE},
{"last_id": last_id, "batch_size": batch_size},
)
for record in result:
batch_count += 1
@@ -229,7 +230,7 @@ def sync_relationships(
break
for rel_type, batch in grouped.items():
for sink_batch in _iter_sink_batches(batch):
for sink_batch in _iter_sink_batches(batch, batch_size):
sink.write_relationships(
target_database, rel_type, provider_id, sink_batch
)
@@ -247,10 +248,9 @@ def sync_relationships(
def _iter_sink_batches(
rows: list[dict[str, Any]],
batch_size: int | None = None,
batch_size: int,
) -> Iterator[list[dict[str, Any]]]:
"""Yield final sink write batches after source rows have been transformed."""
batch_size = SYNC_BATCH_SIZE if batch_size is None else batch_size
if batch_size <= 0:
raise ValueError("Sink batch size must be greater than zero")
+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()
+327 -103
View File
@@ -1,3 +1,4 @@
import copy
import csv
import io
import json
@@ -6,7 +7,7 @@ import re
import time
import uuid
from collections import defaultdict
from collections.abc import Iterable
from collections.abc import Callable, Iterable
from datetime import UTC, datetime
from typing import Any
@@ -49,6 +50,7 @@ from celery.utils.log import get_task_logger
from config.django.base import DJANGO_FINDINGS_BATCH_SIZE
from config.env import env
from config.settings.celery import CELERY_DEADLOCK_ATTEMPTS
from django.core.exceptions import ImproperlyConfigured
from django.db import DatabaseError, IntegrityError, OperationalError, transaction
from django.db.models import (
Case,
@@ -99,6 +101,16 @@ COMPLIANCE_REQUIREMENT_COPY_COLUMNS = (
FINDINGS_MICRO_BATCH_SIZE = env.int("DJANGO_FINDINGS_MICRO_BATCH_SIZE", default=3000)
# Controls how many rows each ORM bulk_create/bulk_update call sends to Postgres.
SCAN_DB_BATCH_SIZE = env.int("DJANGO_SCAN_DB_BATCH_SIZE", default=1000)
# Rows per COPY statement when ingesting compliance requirement overviews. All
# batches of a scan share one transaction/commit; the batch size only bounds the
# client-side CSV buffer and how long each individual COPY statement runs on the
# writer (memory footprint, lock time and slow-statement logging under load).
COMPLIANCE_COPY_BATCH_SIZE = env.int("DJANGO_COMPLIANCE_COPY_BATCH_SIZE", default=2000)
if COMPLIANCE_COPY_BATCH_SIZE < 1:
raise ImproperlyConfigured(
"DJANGO_COMPLIANCE_COPY_BATCH_SIZE must be a positive integer, got "
f"{COMPLIANCE_COPY_BATCH_SIZE}"
)
# Throttle scan progress persistence: minimum progress delta (fraction 0-1)
# between two persisted progress updates.
PROGRESS_THROTTLE_DELTA = env.float("DJANGO_SCAN_PROGRESS_THROTTLE_DELTA", default=0.01)
@@ -356,30 +368,36 @@ def _bulk_update_resource_failed_findings_counts(
raise
def _copy_compliance_requirement_rows(
tenant_id: str, rows: list[dict[str, Any]]
) -> None:
"""Stream compliance requirement rows into Postgres using COPY.
class ComplianceRowScopeError(ValueError):
"""A compliance requirement row does not belong to the scan being ingested."""
We leverage the admin connection (when available) to bypass the COPY + RLS
restriction, writing only the fields required by
``ComplianceRequirementOverview``.
Args:
tenant_id: Target tenant UUID.
rows: List of row dictionaries prepared by
:func:`create_compliance_requirements`.
def _compliance_requirement_rows_to_csv(
rows: list[dict[str, Any]], tenant_id: str, scan_id: str
) -> io.StringIO:
"""Serialize compliance requirement rows into a CSV buffer for COPY.
COPY runs on the admin connection, which bypasses RLS, so every row is
checked against the expected tenant/scan before it is written: a mismatched
row would otherwise be inserted verbatim into another tenant's data.
"""
csv_buffer = io.StringIO()
writer = csv.writer(csv_buffer)
datetime_now = datetime.now(tz=UTC)
for row in rows:
row_tenant_id = str(row.get("tenant_id"))
row_scan_id = str(row.get("scan_id"))
if row_tenant_id != tenant_id or row_scan_id != scan_id:
raise ComplianceRowScopeError(
"Compliance requirement row does not belong to the scan being "
f"ingested (expected tenant {tenant_id} / scan {scan_id}, got "
f"tenant {row_tenant_id} / scan {row_scan_id})"
)
writer.writerow(
[
str(row.get("id")),
str(row.get("tenant_id")),
row_tenant_id,
(row.get("inserted_at") or datetime_now).isoformat(),
row.get("compliance_id") or "",
row.get("framework") or "",
@@ -393,65 +411,100 @@ def _copy_compliance_requirement_rows(
row.get("total_checks", 0),
row.get("passed_findings", 0),
row.get("total_findings", 0),
str(row.get("scan_id")),
row_scan_id,
]
)
csv_buffer.seek(0)
return csv_buffer
def _copy_compliance_requirement_rows(
tenant_id: str, scan_id: str, rows: Iterable[dict[str, Any]], batch_size: int
) -> int:
"""Replace a scan's compliance requirement rows using batched COPY.
We leverage the admin connection (when available) to bypass the COPY + RLS
restriction. The scan's DELETE and every COPY batch run on one connection
inside a single transaction with a single commit, so the writer takes one
fsync per scan instead of one per batch, and a failed ingest rolls back
without committing a partial delete/insert (which a retry would otherwise
delete again, feeding dead rows to autovacuum).
Args:
tenant_id: Target tenant UUID.
scan_id: Scan whose previous rows are replaced.
rows: Iterable of row dictionaries, consumed lazily batch by batch.
batch_size: Number of rows per COPY statement.
Returns:
int: total number of rows staged and committed.
Raises:
ComplianceRowScopeError: A row belongs to another tenant or scan.
"""
# Normalized once so the per-row scope check compares like with like even if
# the caller passes UUID instances instead of strings.
tenant_id = str(tenant_id)
scan_id = str(scan_id)
total_rows = 0
batch_num = 0
copy_sql = (
"COPY compliance_requirements_overviews ("
+ ", ".join(COMPLIANCE_REQUIREMENT_COPY_COLUMNS)
+ ") FROM STDIN WITH (FORMAT CSV, DELIMITER ',', QUOTE '\"', ESCAPE '\"', NULL '\\N')"
)
try:
with psycopg_connection(MainRouter.admin_db) as connection:
connection.autocommit = False
try:
with connection.cursor() as cursor:
cursor.execute(SET_CONFIG_QUERY, [POSTGRES_TENANT_VAR, tenant_id])
cursor.copy_expert(copy_sql, csv_buffer)
connection.commit()
except Exception:
connection.rollback()
raise
finally:
csv_buffer.close()
with psycopg_connection(MainRouter.admin_db) as connection:
connection.autocommit = False
try:
with connection.cursor() as cursor:
cursor.execute(SET_CONFIG_QUERY, [POSTGRES_TENANT_VAR, tenant_id])
# Idempotent re-run: clearing this scan's rows inside the same
# transaction keeps delete + reinsert atomic.
cursor.execute(
"DELETE FROM compliance_requirements_overviews "
"WHERE tenant_id = %s AND scan_id = %s",
[tenant_id, scan_id],
)
for batch, _is_last in batched(rows, batch_size):
if not batch:
continue
batch_num += 1
csv_buffer = _compliance_requirement_rows_to_csv(
batch, tenant_id, scan_id
)
try:
cursor.copy_expert(copy_sql, csv_buffer)
finally:
csv_buffer.close()
total_rows += len(batch)
logger.info(
f"Compliance COPY batch {batch_num}: staged {len(batch)} rows "
f"({total_rows} total)"
)
connection.commit()
except Exception:
connection.rollback()
raise
return total_rows
def _persist_compliance_requirement_rows(
tenant_id: str, rows: Iterable[dict[str, Any]], batch_size: int = 10000
def _bulk_create_compliance_requirement_rows(
tenant_id: str, scan_id: str, rows: Iterable[dict[str, Any]], batch_size: int
) -> int:
"""Persist compliance requirement rows using batched COPY with ORM fallback.
"""Replace a scan's compliance requirement rows via the ORM.
``rows`` is consumed lazily in batches, so peak memory stays at ~``batch_size``
rows instead of the full set. A batch that fails COPY falls back to an ORM
``bulk_create`` of just that batch.
Args:
tenant_id: Target tenant UUID.
rows: Iterable of row dictionaries reflecting the compliance overview
state for a scan.
batch_size: Number of rows per COPY batch (default: 10000).
Returns:
int: total number of rows persisted.
Fallback for when COPY is unavailable; the delete and every ``bulk_create``
share one RLS transaction so the replacement stays atomic.
"""
total_rows = 0
batch_num = 0
for batch, _is_last in batched(rows, batch_size):
if not batch:
continue
batch_num += 1
try:
_copy_compliance_requirement_rows(tenant_id, batch)
except Exception as error:
logger.exception(
f"COPY bulk insert for compliance requirements batch {batch_num} "
"failed; falling back to ORM bulk_create for this batch",
exc_info=error,
)
with rls_transaction(tenant_id):
ComplianceRequirementOverview.objects.filter(scan_id=scan_id).delete()
for batch, _is_last in batched(rows, batch_size):
if not batch:
continue
fallback_objects = [
ComplianceRequirementOverview(
id=row["id"],
@@ -473,20 +526,58 @@ def _persist_compliance_requirement_rows(
)
for row in batch
]
with rls_transaction(tenant_id):
ComplianceRequirementOverview.objects.bulk_create(
fallback_objects, batch_size=500
)
total_rows += len(batch)
logger.info(
f"Compliance COPY batch {batch_num}: inserted {len(batch)} rows "
f"({total_rows} total)"
)
ComplianceRequirementOverview.objects.bulk_create(
fallback_objects, batch_size=500
)
total_rows += len(batch)
return total_rows
def _persist_compliance_requirement_rows(
tenant_id: str,
scan_id: str,
rows_factory: Callable[[], Iterable[dict[str, Any]]],
batch_size: int | None = None,
) -> int:
"""Persist a scan's compliance requirement rows, replacing any previous ones.
``rows_factory`` must return a fresh row iterator on every call: the COPY
path consumes it lazily in batches (peak memory ~``batch_size`` rows), and
if COPY fails the whole ingest falls back to a single ORM transaction that
re-iterates the rows.
Args:
tenant_id: Target tenant UUID.
scan_id: Scan whose compliance overview rows are being replaced.
rows_factory: Callable returning an iterable of row dictionaries.
batch_size: Rows per COPY/bulk_create batch (default:
``COMPLIANCE_COPY_BATCH_SIZE``).
Returns:
int: total number of rows persisted.
"""
if batch_size is None:
batch_size = COMPLIANCE_COPY_BATCH_SIZE
try:
return _copy_compliance_requirement_rows(
tenant_id, scan_id, rows_factory(), batch_size
)
except ComplianceRowScopeError:
# Cross-tenant/scan rows are a bug in the caller, not a COPY failure:
# retrying through the ORM would persist the very rows we rejected.
raise
except Exception as error:
logger.exception(
"COPY bulk insert for compliance requirements failed; "
"falling back to ORM bulk_create",
exc_info=error,
)
return _bulk_create_compliance_requirement_rows(
tenant_id, scan_id, rows_factory(), batch_size
)
def _create_compliance_summaries(
tenant_id: str, scan_id: str, requirement_statuses: dict
) -> None:
@@ -605,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 = {}
@@ -643,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
@@ -678,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,
@@ -711,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()
@@ -758,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
]
@@ -860,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
@@ -885,15 +1103,19 @@ def _process_finding_micro_batch(
# Denormalized resource arrays populated directly on insert
# (was previously a separate bulk_update; saves a CASE WHEN
# over thousands of rows per micro-batch).
resource_regions=[resource_instance.region]
if resource_instance.region
else [],
resource_services=[resource_instance.service]
if resource_instance.service
else [],
resource_types=[resource_instance.type]
if resource_instance.type
else [],
resource_regions=(
[resource_instance.region]
if resource_instance.region
else []
),
resource_services=(
[resource_instance.service]
if resource_instance.service
else []
),
resource_types=(
[resource_instance.type] if resource_instance.type else []
),
)
findings_to_create.append(finding_instance)
resource_denormalized_data.append(
@@ -1013,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:
@@ -1034,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'} "
@@ -1708,8 +1932,10 @@ def create_compliance_requirements(tenant_id: str, scan_id: str):
)
# Yield rows lazily (consumed batch-by-batch by COPY) so peak memory
# stays bounded; tally requirement_statuses in the same pass.
# stays bounded; tally requirement_statuses in the same pass. The
# ORM fallback re-iterates from scratch, so the tally resets first.
def _iter_compliance_requirement_rows():
requirement_statuses.clear()
for region in regions:
region_stats = region_requirement_stats.get(region, {})
region_findings = findings_count_by_compliance.get(region, {})
@@ -1773,12 +1999,10 @@ def create_compliance_requirements(tenant_id: str, scan_id: str):
"total_findings": total_findings,
}
# Idempotent re-run: clear this scan's rows before re-inserting.
with rls_transaction(tenant_id):
ComplianceRequirementOverview.objects.filter(scan_id=scan_id).delete()
# The delete of the scan's previous rows happens inside the same
# transaction as the inserts (see _copy_compliance_requirement_rows).
requirements_created = _persist_compliance_requirement_rows(
tenant_id, _iter_compliance_requirement_rows()
tenant_id_str, scan_id_str, _iter_compliance_requirement_rows
)
# Create pre-aggregated summaries for fast compliance overview lookups
+31 -2
View File
@@ -11,6 +11,7 @@ from api.compliance import (
from api.db_router import READ_REPLICA_ALIAS
from api.db_utils import delete_related_daily_task, rls_transaction
from api.decorators import handle_provider_deletion, set_tenant
from api.exceptions import ProviderDeletedException
from api.models import (
Finding,
Integration,
@@ -666,7 +667,13 @@ class AttackPathsScanRLSTask(RLSTask):
scan_id = kwargs.get("scan_id")
if tenant_id and scan_id:
logger.error(f"Attack paths scan task {task_id} failed: {exc}")
if isinstance(exc, ProviderDeletedException):
logger.warning(
f"Attack paths scan task {task_id} stopped because its provider "
f"or tenant was deleted: {exc}"
)
else:
logger.error(f"Attack paths scan task {task_id} failed: {exc}")
attack_paths_db_utils.fail_attack_paths_scan(tenant_id, scan_id, str(exc))
@@ -790,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):
"""
@@ -0,0 +1,102 @@
from types import SimpleNamespace
from unittest.mock import MagicMock, patch
import neo4j.exceptions
import pytest
from tasks.jobs.attack_paths import aws
DATABASE_NOT_FOUND_CODE = "Neo.ClientError.Database.DatabaseNotFound"
def _make_neo4j_error(code: str) -> neo4j.exceptions.Neo4jError:
return neo4j.exceptions.Neo4jError._hydrate_neo4j(
code=code,
message="graph query failed",
)
def _resource_functions(failing_sync, following_sync):
return {
"failing_sync": failing_sync,
"following_sync": following_sync,
"permission_relationships": MagicMock(),
"resourcegroupstaggingapi": MagicMock(),
}
def test_sync_aws_account_reraises_database_not_found_immediately():
error = _make_neo4j_error(DATABASE_NOT_FOUND_CODE)
failing_sync = MagicMock(side_effect=error)
following_sync = MagicMock()
with (
patch.object(
aws.cartography_aws,
"RESOURCE_FUNCTIONS",
_resource_functions(failing_sync, following_sync),
),
patch.object(aws.db_utils, "update_attack_paths_scan_progress"),
patch.object(aws.utils, "stringify_exception") as stringify_exception,
patch.object(aws.logger, "warning") as warning,
pytest.raises(neo4j.exceptions.Neo4jError) as exc_info,
):
aws.sync_aws_account(
SimpleNamespace(uid="123456789012"),
[
"failing_sync",
"following_sync",
"permission_relationships",
"resourcegroupstaggingapi",
],
{},
MagicMock(),
)
assert exc_info.value is error
following_sync.assert_not_called()
stringify_exception.assert_not_called()
warning.assert_not_called()
@pytest.mark.parametrize(
"error",
[
_make_neo4j_error("Neo.ClientError.Statement.SyntaxError"),
RuntimeError("resource sync failed"),
],
ids=["different-neo4j-error", "non-neo4j-error"],
)
def test_sync_aws_account_warns_and_continues_for_other_exceptions(error):
failing_sync = MagicMock(side_effect=error)
following_sync = MagicMock()
with (
patch.object(
aws.cartography_aws,
"RESOURCE_FUNCTIONS",
_resource_functions(failing_sync, following_sync),
),
patch.object(aws.db_utils, "update_attack_paths_scan_progress"),
patch.object(
aws.utils,
"stringify_exception",
return_value="formatted failure",
),
patch.object(aws.logger, "warning") as warning,
):
failed_syncs = aws.sync_aws_account(
SimpleNamespace(uid="123456789012"),
[
"failing_sync",
"following_sync",
"permission_relationships",
"resourcegroupstaggingapi",
],
{},
MagicMock(),
)
assert failed_syncs == {"failing_sync": "formatted failure"}
following_sync.assert_called_once_with()
warning.assert_called_once()
assert "Continuing to the next AWS sync function" in warning.call_args.args[0]
@@ -1,3 +1,4 @@
import logging
from contextlib import nullcontext
from datetime import UTC, datetime, timedelta
from types import SimpleNamespace
@@ -5,7 +6,9 @@ from unittest.mock import MagicMock, call, patch
from uuid import uuid4
import pytest
from api.attack_paths.database import GraphDatabaseQueryException
from api.db_utils import rls_transaction
from api.exceptions import ProviderDeletedException
from api.models import (
AttackPathsScan,
Finding,
@@ -250,6 +253,32 @@ class TestAttackPathsRun:
mock_starting.assert_not_called()
mock_create_db.assert_not_called()
@pytest.mark.parametrize(
("ingestion_error", "temporary_database_missing"),
[
(RuntimeError("ingestion boom"), False),
(
GraphDatabaseQueryException(
message="Graph not found: db-scan-id",
code="Neo.ClientError.Database.DatabaseNotFound",
),
True,
),
(
GraphDatabaseQueryException(
message="Graph not found: db-tenant-id",
code="Neo.ClientError.Database.DatabaseNotFound",
),
False,
),
],
ids=[
"regular-error",
"temporary-database-missing",
"sink-database-missing",
],
)
@patch("tasks.jobs.attack_paths.scan.logger")
@patch(
"tasks.jobs.attack_paths.scan.utils.stringify_exception",
return_value="Cartography failed: ingestion boom",
@@ -302,6 +331,9 @@ class TestAttackPathsRun:
mock_drop_db,
mock_event_loop,
mock_stringify,
mock_logger,
ingestion_error,
temporary_database_missing,
tenants_fixture,
aws_provider,
scans_fixture,
@@ -321,7 +353,11 @@ class TestAttackPathsRun:
session_ctx = MagicMock()
session_ctx.__enter__.return_value = mock_session
session_ctx.__exit__.return_value = False
ingestion_fn = MagicMock(side_effect=RuntimeError("ingestion boom"))
ingestion_fn = MagicMock(side_effect=ingestion_error)
if temporary_database_missing:
mock_finish.side_effect = DatabaseError(
"Save with update_fields did not affect any rows"
)
with (
patch(
@@ -337,13 +373,28 @@ class TestAttackPathsRun:
return_value=ingestion_fn,
),
):
with pytest.raises(RuntimeError, match="ingestion boom"):
with pytest.raises(type(ingestion_error)):
attack_paths_run(str(tenant.id), str(scan.id), "task-456")
failure_args = mock_finish.call_args[0]
assert failure_args[0] is attack_paths_scan
assert failure_args[1] == StateChoices.FAILED
assert failure_args[2] == {"global_error": "Cartography failed: ingestion boom"}
mock_drop_db.assert_called_once_with("db-scan-id")
if temporary_database_missing:
mock_logger.warning.assert_any_call("Cartography failed: ingestion boom")
mock_logger.exception.assert_not_called()
mock_logger.log.assert_called_once_with(
logging.WARNING,
f"Could not mark Attack Paths scan {attack_paths_scan.id} as `FAILED` "
"(row may have been deleted): Save with update_fields did not affect "
"any rows",
exc_info=False,
)
else:
mock_logger.exception.assert_called_once_with(
"Cartography failed: ingestion boom"
)
@patch(
"tasks.jobs.attack_paths.scan.utils.stringify_exception",
@@ -1265,6 +1316,33 @@ class TestAttackPathsScanRLSTaskOnFailure:
mock_fail.assert_called_once_with("t-1", "s-1", "boom")
def test_on_failure_logs_provider_deletion_as_warning(self):
from tasks.tasks import AttackPathsScanRLSTask
task = AttackPathsScanRLSTask()
error = ProviderDeletedException("provider deleted")
with (
patch("tasks.tasks.logger") as mock_logger,
patch(
"tasks.tasks.attack_paths_db_utils.fail_attack_paths_scan"
) as mock_fail,
):
task.on_failure(
exc=error,
task_id="task-abc",
args=(),
kwargs={"tenant_id": "t-1", "scan_id": "s-1"},
_einfo=None,
)
mock_logger.warning.assert_called_once_with(
"Attack paths scan task task-abc stopped because its provider or tenant "
"was deleted: provider deleted"
)
mock_logger.error.assert_not_called()
mock_fail.assert_called_once_with("t-1", "s-1", "provider deleted")
def test_on_failure_skips_when_missing_kwargs(self):
from tasks.tasks import AttackPathsScanRLSTask
@@ -1896,7 +1974,7 @@ class TestSyncNodes:
mock_source_1.run.return_value = [row]
mock_source_2 = MagicMock()
mock_source_2.run.return_value = []
sink = MagicMock()
sink = MagicMock(sync_batch_size=1000)
with patch(
"tasks.jobs.attack_paths.sync.graph_database.get_session",
@@ -1933,7 +2011,7 @@ class TestSyncNodes:
src_1.run.return_value = [row]
src_2 = MagicMock()
src_2.run.return_value = []
sink = MagicMock()
sink = MagicMock(sync_batch_size=1000)
sink.write_nodes.side_effect = lambda *_a, **_kw: call_order.append(
"sink:write"
)
@@ -1969,18 +2047,15 @@ class TestSyncNodes:
src_2.run.return_value = [row_b]
src_3 = MagicMock()
src_3.run.return_value = []
sink = MagicMock()
sink = MagicMock(sync_batch_size=1)
with (
patch(
"tasks.jobs.attack_paths.sync.graph_database.get_session",
side_effect=[
_make_session_ctx(src_1),
_make_session_ctx(src_2),
_make_session_ctx(src_3),
],
),
patch("tasks.jobs.attack_paths.sync.SYNC_BATCH_SIZE", 1),
with patch(
"tasks.jobs.attack_paths.sync.graph_database.get_session",
side_effect=[
_make_session_ctx(src_1),
_make_session_ctx(src_2),
_make_session_ctx(src_3),
],
):
result = sync_module.sync_nodes("src", "tgt", "t-1", "p-1", sink, [])
@@ -2009,17 +2084,14 @@ class TestSyncNodes:
src_1.run.return_value = [row]
src_2 = MagicMock()
src_2.run.return_value = []
sink = MagicMock()
sink = MagicMock(sync_batch_size=2)
with (
patch(
"tasks.jobs.attack_paths.sync.graph_database.get_session",
side_effect=[
_make_session_ctx(src_1),
_make_session_ctx(src_2),
],
),
patch("tasks.jobs.attack_paths.sync.SYNC_BATCH_SIZE", 2),
with patch(
"tasks.jobs.attack_paths.sync.graph_database.get_session",
side_effect=[
_make_session_ctx(src_1),
_make_session_ctx(src_2),
],
):
result = sync_module.sync_nodes(
"src", "tgt", "t-1", "p-1", sink, normalized_lists
@@ -2037,7 +2109,7 @@ class TestSyncNodes:
def test_sync_nodes_empty_source_returns_zero(self):
src = MagicMock()
src.run.return_value = []
sink = MagicMock()
sink = MagicMock(sync_batch_size=1000)
with patch(
"tasks.jobs.attack_paths.sync.graph_database.get_session",
@@ -2066,7 +2138,7 @@ class TestSyncRelationships:
src_1.run.return_value = [row]
src_2 = MagicMock()
src_2.run.return_value = []
sink = MagicMock()
sink = MagicMock(sync_batch_size=1000)
sink.write_relationships.side_effect = lambda *_a, **_kw: call_order.append(
"sink:write"
)
@@ -2104,18 +2176,15 @@ class TestSyncRelationships:
src_2.run.return_value = [row_b]
src_3 = MagicMock()
src_3.run.return_value = []
sink = MagicMock()
sink = MagicMock(sync_batch_size=1)
with (
patch(
"tasks.jobs.attack_paths.sync.graph_database.get_session",
side_effect=[
_make_session_ctx(src_1),
_make_session_ctx(src_2),
_make_session_ctx(src_3),
],
),
patch("tasks.jobs.attack_paths.sync.SYNC_BATCH_SIZE", 1),
with patch(
"tasks.jobs.attack_paths.sync.graph_database.get_session",
side_effect=[
_make_session_ctx(src_1),
_make_session_ctx(src_2),
_make_session_ctx(src_3),
],
):
total = sync_module.sync_relationships("src", "tgt", "p-1", sink)
@@ -2140,17 +2209,14 @@ class TestSyncRelationships:
src_1.run.return_value = rows
src_2 = MagicMock()
src_2.run.return_value = []
sink = MagicMock()
sink = MagicMock(sync_batch_size=2)
with (
patch(
"tasks.jobs.attack_paths.sync.graph_database.get_session",
side_effect=[
_make_session_ctx(src_1),
_make_session_ctx(src_2),
],
),
patch("tasks.jobs.attack_paths.sync.SYNC_BATCH_SIZE", 2),
with patch(
"tasks.jobs.attack_paths.sync.graph_database.get_session",
side_effect=[
_make_session_ctx(src_1),
_make_session_ctx(src_2),
],
):
total = sync_module.sync_relationships("src", "tgt", "p-1", sink)
@@ -2163,7 +2229,7 @@ class TestSyncRelationships:
def test_sync_relationships_empty_source_returns_zero(self):
src = MagicMock()
src.run.return_value = []
sink = MagicMock()
sink = MagicMock(sync_batch_size=1000)
with patch(
"tasks.jobs.attack_paths.sync.graph_database.get_session",
@@ -3056,6 +3122,61 @@ class TestCleanupStaleAttackPathsScans:
ap_scan.refresh_from_db()
assert ap_scan.state == StateChoices.FAILED
@pytest.mark.parametrize(
("age_seconds", "should_clean"),
[
(960 * 60 - 1, False),
(960 * 60, False),
(960 * 60 + 1, True),
],
)
@patch("tasks.jobs.attack_paths.cleanup.recover_graph_data_ready")
@patch("tasks.jobs.attack_paths.cleanup.graph_database.drop_database")
@patch(
"tasks.jobs.attack_paths.cleanup.rls_transaction",
new=lambda *args, **kwargs: nullcontext(),
)
@patch("tasks.jobs.attack_paths.cleanup._revoke_task")
@patch("tasks.jobs.attack_paths.cleanup._ping_workers")
def test_stale_threshold_boundary_is_strict(
self,
mock_ping,
mock_revoke,
mock_drop_db,
mock_recover,
age_seconds,
should_clean,
tenants_fixture,
aws_provider,
):
from tasks.jobs.attack_paths.cleanup import cleanup_stale_attack_paths_scans
now = datetime.now(tz=UTC)
ap_scan, task_result = self._create_executing_scan(
tenants_fixture[0],
aws_provider,
started_at=now - timedelta(seconds=age_seconds),
worker="live-worker@host",
)
mock_ping.return_value = ({"live-worker@host"}, set())
with patch("tasks.jobs.attack_paths.cleanup.datetime") as mock_datetime:
mock_datetime.now.return_value = now
result = cleanup_stale_attack_paths_scans()
assert result["cleaned_up_count"] == int(should_clean)
ap_scan.refresh_from_db()
expected_state = StateChoices.FAILED if should_clean else StateChoices.EXECUTING
assert ap_scan.state == expected_state
if should_clean:
mock_revoke.assert_called_once_with(task_result, terminate=True)
mock_drop_db.assert_called_once()
mock_recover.assert_called_once()
else:
mock_revoke.assert_not_called()
mock_drop_db.assert_not_called()
mock_recover.assert_not_called()
@patch("tasks.jobs.attack_paths.cleanup.recover_graph_data_ready")
@patch("tasks.jobs.attack_paths.cleanup.graph_database.drop_database")
@patch(
@@ -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
File diff suppressed because it is too large Load Diff
+119
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"]}
@@ -3304,6 +3422,7 @@ class TestTaskTimeLimits:
for name in (
"scan-perform",
"scan-perform-scheduled",
"attack-paths-scan-perform",
"provider-deletion",
"tenant-deletion",
):
Generated
+4 -4
View File
@@ -4673,8 +4673,8 @@ wheels = [
[[package]]
name = "prowler"
version = "5.32.0"
source = { git = "https://github.com/prowler-cloud/prowler.git?rev=master#5dac8a0a53272e4db68c476fb969dc03e88beb68" }
version = "5.36.0"
source = { git = "https://github.com/prowler-cloud/prowler.git?rev=v5.36#2298d4a3f881abe9e2266195f764b60d34af07eb" }
dependencies = [
{ name = "alibabacloud-actiontrail20200706" },
{ name = "alibabacloud-credentials" },
@@ -4762,7 +4762,7 @@ dependencies = [
[[package]]
name = "prowler-api"
version = "1.35.0"
version = "1.37.1"
source = { virtual = "." }
dependencies = [
{ name = "cartography" },
@@ -4862,7 +4862,7 @@ requires-dist = [
{ name = "matplotlib", specifier = "==3.10.8" },
{ name = "neo4j", specifier = "==6.1.0" },
{ name = "openai", specifier = "==1.109.1" },
{ name = "prowler", git = "https://github.com/prowler-cloud/prowler.git?rev=master" },
{ name = "prowler", git = "https://github.com/prowler-cloud/prowler.git?rev=v5.36" },
{ name = "psycopg2-binary", specifier = "==2.9.9" },
{ name = "pytest-celery", extras = ["redis"], specifier = "==1.3.0" },
{ name = "reportlab", specifier = "==4.4.10" },
@@ -22,7 +22,7 @@
"api_key": {
"type": "string",
"title": "Prowler API key",
"description": "API key token used to authenticate with Prowler Cloud / Prowler App via the Prowler MCP server. Create one at https://cloud.prowler.com.",
"description": "API key token used to authenticate with Prowler (Prowler Cloud, Prowler Private Cloud, or Prowler Local Server) via the Prowler MCP server. Create one at https://cloud.prowler.com.",
"sensitive": true,
"required": true
}
@@ -38,12 +38,12 @@ If the framework is not supported, tell the user, suggest they request it or con
### 1.1 Connect to Prowler Cloud
Verify the Prowler MCP connection by calling `prowler_app_search_providers` — a successful response returns the list of providers. If the call fails, walk the user through troubleshooting: internet connectivity, Prowler Cloud credentials, and permissions on the Prowler Cloud account.
Verify the Prowler MCP connection by calling `prowler_search_providers` — a successful response returns the list of providers. If the call fails, walk the user through troubleshooting: internet connectivity, Prowler Cloud credentials, and permissions on the Prowler Cloud account.
For getting accurate information about configurations use `prowler_docs_search` to pull relevant instructions from the Prowler documentation.
### 1.2 Verify the provider is configured (or configure it)
Call `prowler_app_search_providers` to check whether the target provider (AWS account, Azure Subscription, GitHub Account...) exists in the user's Prowler Cloud account. Handle the result based on what's found:
Call `prowler_search_providers` to check whether the target provider (AWS account, Azure Subscription, GitHub Account...) exists in the user's Prowler Cloud account. Handle the result based on what's found:
- **Provider not present.** Guide the user through adding and configuring it. Retrieve the relevant connection, credential, and permission instructions with `prowler_docs_search`.
- **Provider present but misconfigured** (missing credentials, insufficient permissions, etc.). Walk the user through fixing the configuration, pulling the relevant guidance with `prowler_docs_search`.
@@ -57,15 +57,15 @@ Call `prowler_app_search_providers` to check whether the target provider (AWS ac
The flow needs at least one completed scan with a compliance report available.
Look for a completed scan first: call `prowler_app_list_scans` with the selected `provider_id` and `state: ["completed"]`, then call `prowler_app_get_compliance_overview` with each `scan_id` to find one whose compliance report is available. If one is found, continue to the next section.
Look for a completed scan first: call `prowler_list_scans` with the selected `provider_id` and `state: ["completed"]`, then call `prowler_get_compliance_overview` with each `scan_id` to find one whose compliance report is available. If one is found, continue to the next section.
If no completed scan has a report, call `prowler_app_list_scans` again with `state: ["available", "executing"]` to detect a scan in progress.
If no completed scan has a report, call `prowler_list_scans` again with `state: ["available", "executing"]` to detect a scan in progress.
> **Checkpoint — Scan-in-progress decision** *(conditional: an in-progress scan was detected)*
>
> Tell the user a scan is already running and ask whether to wait for it to complete or start a fresh one. Wait for the answer.
If no scan is running (or the user chose to start a fresh one), trigger a new scan with `prowler_app_trigger_scan` and the `provider_id`. The link `https://cloud.prowler.com/scans?filter%5Bprovider_uid__in%5D={provider_id}` lets the user monitor progress.
If no scan is running (or the user chose to start a fresh one), trigger a new scan with `prowler_trigger_scan` and the `provider_id`. The link `https://cloud.prowler.com/scans?filter%5Bprovider_uid__in%5D={provider_id}` lets the user monitor progress.
When a scan is in progress (either pre-existing and elected to wait, or just triggered), stop the flow and ask the user to return when it's completed — restart this section to re-check the results.
@@ -85,7 +85,7 @@ Status taxonomy for failed requirements and their findings:
### Report template
A fresh report is rendered like this (substituting values from the `prowler_app_get_compliance_framework_state_details` Prowler MCP tool response):
A fresh report is rendered like this (substituting values from the `prowler_get_compliance_framework_state_details` Prowler MCP tool response):
````markdown
# Compliance report: <compliance_id>
@@ -120,7 +120,7 @@ A fresh report is rendered like this (substituting values from the `prowler_app_
Resolve the report path for the current `compliance_id` and provider account.
If the file does not exist, call `prowler_app_get_compliance_framework_state_details` for the target scan, render the template above, and write the file with one initialization entry in the activity log.
If the file does not exist, call `prowler_get_compliance_framework_state_details` for the target scan, render the template above, and write the file with one initialization entry in the activity log.
If the file exists, read it and compare its `Scan ID` to the target scan from section 1.3. When the scan matches, reuse the file and summarize remaining `[FAIL]` and `[IN PROGRESS]` items in chat.
@@ -128,7 +128,7 @@ If the file exists, read it and compare its `Scan ID` to the target scan from se
>
> Tell the user the report on disk was generated from a different scan and ask whether to refresh it from the new scan. Wait for the answer.
On confirmation, regenerate the failed-requirements section from the new `prowler_app_get_compliance_framework_state_details` response, carry forward the **Global remediation approach** block and the full activity log, and append an activity-log entry noting the scan change.
On confirmation, regenerate the failed-requirements section from the new `prowler_get_compliance_framework_state_details` response, carry forward the **Global remediation approach** block and the full activity log, and append an activity-log entry noting the scan change.
Once the file is current, surface the top failing requirements in chat: sort by finding count descending, show the top 5 with their codes and counts, and point to the file path for the full list.
@@ -174,7 +174,7 @@ Once approved, the loop proceeds through the batch without further prompts unles
Pick the first `[FAIL]` requirement at the top of the failed-requirements section. Move its status and every finding under it to `[IN PROGRESS]`, and add a `**Fix plan**:` sub-bullet describing what will be done.
Call `prowler_app_get_finding_details` for each `finding_id` to retrieve the failing resource and the Prowler Hub's remediation guidance for that check using the tool `prowler_hub_get_check_details` with the `check_id` from the finding details. Summarize the guidance in chat, and append it to the `**Fix plan**` note for each finding.
Call `prowler_get_finding_details` for each `finding_id` to retrieve the failing resource and the Prowler Hub's remediation guidance for that check using the tool `prowler_hub_get_check_details` with the `check_id` from the finding details. Summarize the guidance in chat, and append it to the `**Fix plan**` note for each finding.
If a finding does not apply to the target resource (Organization-only check on a User account, paid-tier feature, missing resource type, etc.), set the requirement status to `[SKIPPED]` with the reason, log it in the activity log, and move on without attempting the fix — even if it was missed during §3.2.
@@ -194,6 +194,6 @@ Move to the next `[FAIL]` requirement and repeat from section 3.3.
> **Checkpoint — Rescan trigger** *(conditional: no `[FAIL]` requirements remain; all are `[FIXED-UNVERIFIED]` or `[SKIPPED]`)*
>
> Summarize what was applied, list any `[SKIPPED]` items with reasons, and ask whether to trigger a fresh scan with `prowler_app_trigger_scan` to verify the fixes end-to-end. Wait for the answer.
> Summarize what was applied, list any `[SKIPPED]` items with reasons, and ask whether to trigger a fresh scan with `prowler_trigger_scan` to verify the fixes end-to-end. Wait for the answer.
On confirmation, trigger the rescan. When it completes, restart section 2.1 with the carry-forward path — requirements no longer in the new FAIL list move to `[PASS]`, anything still failing reverts to `[FAIL]` with the previous fix attempt visible in the activity log.
@@ -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.
@@ -132,7 +132,7 @@ The MCP client manages connections to the Prowler MCP Server using a singleton p
- **Connection Management**: Retry logic with configurable attempts and delays
- **Tool Discovery**: Fetches available tools from MCP server on initialization
- **Authentication Injection**: Automatically adds JWT tokens to `prowler_app_*` tool calls
- **Authentication Injection**: Automatically adds JWT tokens to `prowler_*` tool calls
- **Reconnection**: Supports forced reconnection after server restarts
Key constants:
@@ -141,10 +141,14 @@ Key constants:
- `RECONNECT_INTERVAL_MS`: 5 minutes before retry after failure
```typescript
// Authentication injection for prowler_app tools
// Authentication injection for core prowler_ tools (Hub/Docs excluded)
private handleBeforeToolCall = ({ name, args }) => {
// Only inject auth for prowler_app_* tools (user-specific data)
if (!name.startsWith("prowler_app_")) {
// Only inject auth for prowler_* tools (user-specific data).
// The legacy prowler_app_ prefix is also accepted for a resilient rollout.
if (
!name.startsWith("prowler_") &&
!name.startsWith("prowler_app_")
) {
return { args };
}
@@ -307,7 +311,7 @@ MCP tools are organized into three namespaces based on authentication requiremen
| Namespace | Auth Required | Description |
|-----------|---------------|-------------|
| `prowler_app_*` | Yes (JWT) | Prowler Cloud and Prowler Local Server tools for findings, providers, scans, resources |
| `prowler_*` | Yes (JWT) | Prowler Cloud, Prowler Private Cloud, and Prowler Local Server tools for findings, providers, scans, resources |
| `prowler_hub_*` | No | Security checks catalog, compliance frameworks |
| `prowler_docs_*` | No | Documentation search and retrieval |
@@ -315,7 +319,7 @@ MCP tools are organized into three namespaces based on authentication requiremen
1. User authenticates with Prowler Local Server, receiving a JWT token
2. Token is stored in session and propagated via `authContextStorage`
3. MCP client injects `Authorization: Bearer <token>` header for `prowler_app_*` calls
3. MCP client injects `Authorization: Bearer <token>` header for `prowler_*` calls
4. MCP Server validates token and applies RLS filtering
### Tool Execution Pattern
@@ -323,7 +327,7 @@ MCP tools are organized into three namespaces based on authentication requiremen
The agent uses meta-tools rather than direct tool registration:
```
Agent needs data → describe_tool("prowler_app_search_findings")
Agent needs data → describe_tool("prowler_search_findings")
→ Returns parameter schema → execute_tool with parameters
→ MCP client adds auth header → MCP Server executes
→ Results returned to agent → Agent continues reasoning
+10 -6
View File
@@ -18,11 +18,15 @@ The Prowler MCP Server brings the entire Prowler ecosystem to AI assistants thro
The server follows a modular architecture with three independent sub-servers:
| Sub-Server | Auth Required | Description |
|------------|---------------|-------------|
| `prowler_app` | Yes | Full access to Prowler Cloud and Prowler Local Server features |
| Prowler Hub | No | Security checks catalog with **over 2,000 checks**, fixers, and **70+ compliance frameworks** |
| Prowler Documentation | No | Full-text search and retrieval of official documentation |
| Sub-Server | Tool Prefix | Auth Required | Description |
|------------|-------------|---------------|-------------|
| Prowler | `prowler_` | Yes | Full access to Prowler Cloud, Prowler Private Cloud, and Prowler Local Server features |
| Prowler Hub | `prowler_hub_` | No | Security checks catalog with **over 2,000 checks**, fixers, and **70+ compliance frameworks** |
| Prowler Documentation | `prowler_docs_` | No | Full-text search and retrieval of official documentation |
<Note>
The core Prowler sub-server is served under the `prowler_` tool prefix, while its source lives in the `prowler_app/` module for historical reasons. Tool names use the prefix; import paths use the module.
</Note>
<Note>
For a complete list of tools and their descriptions, see the [Tools Reference](/getting-started/basic-usage/prowler-mcp-tools).
@@ -413,7 +417,7 @@ uv run prowler-mcp
uv run prowler-mcp --transport http --host 0.0.0.0 --port 8000
# Run with environment variables
PROWLER_APP_API_KEY="pk_xxx" uv run prowler-mcp
PROWLER_API_KEY="pk_xxx" uv run prowler-mcp
```
For complete installation and deployment options, see:
+41 -4
View File
@@ -72,15 +72,32 @@
"group": "Prowler MCP",
"pages": [
"getting-started/products/prowler-mcp",
"getting-started/installation/prowler-mcp",
"getting-started/basic-usage/prowler-mcp",
"getting-started/basic-usage/prowler-mcp-tools"
"getting-started/basic-usage/prowler-mcp-tools",
"getting-started/installation/prowler-mcp"
]
},
{
"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"
]
}
]
@@ -195,6 +212,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 +245,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"
]
},
{
@@ -660,6 +693,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 & Prowler Local Server | 32 tools | Yes |
| Prowler Cloud, Private Cloud & Local Server | 32 tools | Yes |
## Tool Naming Convention
@@ -18,11 +18,11 @@ All tools follow a consistent naming pattern with prefixes:
- `prowler_hub_*` - Prowler Hub catalog and compliance tools
- `prowler_docs_*` - Prowler documentation search and retrieval
- `prowler_app_*` - Prowler Cloud and App (Self-Managed) management tools
- `prowler_*` - Prowler Cloud, Prowler Private Cloud & Prowler Local Server management tools
## Prowler Cloud and Prowler Local Server Tools
## Prowler Tools
Manage Prowler Cloud or Prowler Local Server features. **Requires authentication.**
Manage your Prowler deployment — Prowler Cloud, Prowler Private Cloud, or Prowler Local Server. **Requires authentication.**
<Note>
These tools require a valid API key. See the [Configuration Guide](/getting-started/basic-usage/prowler-mcp) for authentication setup.
@@ -32,44 +32,44 @@ These tools require a valid API key. See the [Configuration Guide](/getting-star
Tools for searching, viewing, and analyzing security findings across all cloud providers.
- **`prowler_app_search_security_findings`** - Search and filter security findings with advanced filtering options (severity, status, provider, region, service, check ID, date range, muted status)
- **`prowler_app_get_finding_details`** - Get comprehensive details about a specific finding including remediation guidance, check metadata, and resource relationships
- **`prowler_app_get_findings_overview`** - Get aggregate statistics and trends about security findings as a markdown report
- **`prowler_search_security_findings`** - Search and filter security findings with advanced filtering options (severity, status, provider, region, service, check ID, date range, muted status)
- **`prowler_get_finding_details`** - Get comprehensive details about a specific finding including remediation guidance, check metadata, and resource relationships
- **`prowler_get_findings_overview`** - Get aggregate statistics and trends about security findings as a markdown report
### Finding Groups Management
Tools for listing finding groups aggregated by check ID, viewing complete group counters, and drilling down into affected resources.
- **`prowler_app_list_finding_groups`** - List latest or historical finding groups with filters for provider, region, service, resource, category, check, severity, status, muted state, delta, date range, and sorting
- **`prowler_app_get_finding_group_details`** - Get complete details for a specific finding group including counters, description, timestamps, and impacted providers
- **`prowler_app_list_finding_group_resources`** - List actionable unmuted resources affected by a finding group by default, including nested resource and provider data plus the `finding_id` for remediation details. Set `include_muted` to include suppressed resources
- **`prowler_list_finding_groups`** - List latest or historical finding groups with filters for provider, region, service, resource, category, check, severity, status, muted state, delta, date range, and sorting
- **`prowler_get_finding_group_details`** - Get complete details for a specific finding group including counters, description, timestamps, and impacted providers
- **`prowler_list_finding_group_resources`** - List actionable unmuted resources affected by a finding group by default, including nested resource and provider data plus the `finding_id` for remediation details. Set `include_muted` to include suppressed resources
### Provider Management
Tools for managing cloud provider connections in Prowler.
- **`prowler_app_search_providers`** - Search and view configured providers with their connection status
- **`prowler_app_connect_provider`** - Register and connect a provider with credentials for security scanning
- **`prowler_app_delete_provider`** - Permanently remove a provider from Prowler
- **`prowler_search_providers`** - Search and view configured providers with their connection status
- **`prowler_connect_provider`** - Register and connect a provider with credentials for security scanning
- **`prowler_delete_provider`** - Permanently remove a provider from Prowler
### Scan Management
Tools for managing and monitoring security scans.
- **`prowler_app_list_scans`** - List and filter security scans across all providers
- **`prowler_app_get_scan`** - Get comprehensive details about a specific scan (progress, duration, resource counts)
- **`prowler_app_trigger_scan`** - Trigger a manual security scan for a provider
- **`prowler_app_schedule_daily_scan`** - Schedule automated daily scans for continuous monitoring
- **`prowler_app_update_scan`** - Update scan name for better organization
- **`prowler_list_scans`** - List and filter security scans across all providers
- **`prowler_get_scan`** - Get comprehensive details about a specific scan (progress, duration, resource counts)
- **`prowler_trigger_scan`** - Trigger a manual security scan for a provider
- **`prowler_schedule_daily_scan`** - Schedule automated daily scans for continuous monitoring
- **`prowler_update_scan`** - Update scan name for better organization
### Resources Management
Tools for searching, viewing, and analyzing cloud resources discovered by Prowler.
- **`prowler_app_list_resources`** - List and filter cloud resources with advanced filtering options (provider, region, service, resource type, tags)
- **`prowler_app_get_resource`** - Get comprehensive details about a specific resource including configuration, metadata, and finding relationships
- **`prowler_app_get_resource_events`** - Get the timeline of cloud API actions performed on a resource (AWS CloudTrail). Shows who did what and when, with full request/response payloads
- **`prowler_app_get_resources_overview`** - Get aggregate statistics about cloud resources as a markdown report
- **`prowler_list_resources`** - List and filter cloud resources with advanced filtering options (provider, region, service, resource type, tags)
- **`prowler_get_resource`** - Get comprehensive details about a specific resource including configuration, metadata, and finding relationships
- **`prowler_get_resource_events`** - Get the timeline of cloud API actions performed on a resource (AWS CloudTrail). Shows who did what and when, with full request/response payloads
- **`prowler_get_resources_overview`** - Get aggregate statistics about cloud resources as a markdown report
### Muting Management
@@ -77,33 +77,33 @@ Tools for managing finding muting, including pattern-based bulk muting (mutelist
#### Mutelist (Pattern-Based Muting)
- **`prowler_app_get_mutelist`** - Retrieve the current mutelist configuration for the tenant
- **`prowler_app_set_mutelist`** - Create or update the mutelist configuration for pattern-based bulk muting
- **`prowler_app_delete_mutelist`** - Remove the mutelist configuration from the tenant
- **`prowler_get_mutelist`** - Retrieve the current mutelist configuration for the tenant
- **`prowler_set_mutelist`** - Create or update the mutelist configuration for pattern-based bulk muting
- **`prowler_delete_mutelist`** - Remove the mutelist configuration from the tenant
#### Mute Rules (Finding-Specific Muting)
- **`prowler_app_list_mute_rules`** - Search and filter mute rules with pagination support
- **`prowler_app_get_mute_rule`** - Retrieve comprehensive details about a specific mute rule
- **`prowler_app_create_mute_rule`** - Create a new mute rule to mute specific findings with documentation and audit trail
- **`prowler_app_update_mute_rule`** - Update a mute rule's name, reason, or enabled status
- **`prowler_app_delete_mute_rule`** - Delete a mute rule from the system
- **`prowler_list_mute_rules`** - Search and filter mute rules with pagination support
- **`prowler_get_mute_rule`** - Retrieve comprehensive details about a specific mute rule
- **`prowler_create_mute_rule`** - Create a new mute rule to mute specific findings with documentation and audit trail
- **`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
### 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.
- **`prowler_app_list_attack_paths_scans`** - List Attack Paths scans with filtering by provider, provider type, and scan state (available, scheduled, executing, completed, failed, cancelled)
- **`prowler_app_list_attack_paths_queries`** - Discover available Attack Paths queries for a completed scan, including query names, descriptions, and required parameters
- **`prowler_app_run_attack_paths_query`** - Execute an Attack Paths query against a completed scan and retrieve graph results with nodes (cloud resources, findings, virtual nodes) and relationships (access paths, role assumptions, security group memberships)
- **`prowler_app_get_attack_paths_cartography_schema`** - Retrieve the Cartography graph schema (node labels, relationships, properties) for writing accurate custom openCypher queries
- **`prowler_list_attack_paths_scans`** - List Attack Paths scans with filtering by provider, provider type, and scan state (available, scheduled, executing, completed, failed, cancelled)
- **`prowler_list_attack_paths_queries`** - Discover available Attack Paths queries for a completed scan, including query names, descriptions, and required parameters
- **`prowler_run_attack_paths_query`** - Execute an Attack Paths query against a completed scan and retrieve graph results with nodes (cloud resources, findings, virtual nodes) and relationships (access paths, role assumptions, security group memberships)
- **`prowler_get_attack_paths_cartography_schema`** - Retrieve the Cartography graph schema (node labels, relationships, properties) for writing accurate custom openCypher queries
### Compliance Management
Tools for viewing compliance status and framework details across all cloud providers.
- **`prowler_app_get_compliance_overview`** - Get high-level compliance status across all frameworks for a specific scan or provider, including pass/fail statistics per framework
- **`prowler_app_get_compliance_framework_state_details`** - Get detailed requirement-level breakdown for a specific compliance framework, including failed requirements and associated finding IDs
- **`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
## Prowler Hub Tools
@@ -145,7 +145,7 @@ Search and access official Prowler documentation. **No authentication required.*
- Use natural language to interact with the tools through your AI assistant
- Tools can be combined for complex workflows
- Filter options are available on most list tools
- Authentication is only required for Prowler Cloud and Prowler Local Server tools
- Authentication is only required for Prowler tools (Prowler Cloud, Prowler Private Cloud, or Prowler Local Server)
## Additional Resources
@@ -7,10 +7,10 @@ Configure your MCP client to connect to Prowler MCP Server.
## Step 1: Get Your API Key
<Note>
**Authentication is optional**: Prowler Hub and Prowler Documentation features work without authentication. An API key is only required for Prowler Cloud and Prowler Local Server features.
**Authentication is optional**: Prowler Hub and Prowler Documentation features work without authentication. An API key is only required for Prowler tools (Prowler Cloud, Prowler Private Cloud, or Prowler Local Server).
</Note>
To use Prowler Cloud or Prowler Local Server features. To get the API key, please refer to the [API Keys](/user-guide/tutorials/prowler-app-api-keys) guide.
An API key authenticates the Prowler tools (Prowler Cloud, Prowler Private Cloud, or Prowler Local Server). To get the API key, please refer to the [API Keys](/user-guide/tutorials/prowler-app-api-keys) guide.
<Warning>
Keep the API key secure. Never share it publicly or commit it to version control.
@@ -18,12 +18,36 @@ Keep the API key secure. Never share it publicly or commit it to version control
## Step 2: Configure Your MCP Host/Client
Choose the configuration based on your deployment:
Most users should use the **Cloud MCP Server** — it needs no installation and is maintained by Prowler. The [Local MCP Server](#local-mcp-server-configuration) configuration is provided afterwards for users who run the server themselves.
- **HTTP Mode**: Prowler Cloud MCP Server or self-hosted Prowler MCP Server.
- **STDIO Mode**: Local installation only (runs as subprocess of your MCP client).
- **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).
### HTTP Mode
### 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.
<Tabs>
<Tab title="Generic Native HTTP Support">
@@ -61,10 +85,10 @@ Choose the configuration based on your deployment:
"args": [
"https://mcp.prowler.com/mcp", // or your self-hosted Prowler MCP Server URL
"--header",
"Authorization: Bearer ${PROWLER_APP_API_KEY}"
"Authorization: Bearer ${PROWLER_API_KEY}"
],
"env": {
"PROWLER_APP_API_KEY": "<your-api-key-here>"
"PROWLER_API_KEY": "<your-api-key-here>"
}
}
}
@@ -74,72 +98,11 @@ Choose the configuration based on your deployment:
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_APP_API_KEY}"
],
"env": {
"PROWLER_APP_API_KEY": "<your-api-key-here>"
}
}
}
}
```
</Tab>
<Tab title="Claude Code">
Run the following command:
```bash
export PROWLER_APP_API_KEY="<your-api-key-here>"
claude mcp add --transport http prowler https://mcp.prowler.com/mcp --header "Authorization: Bearer $PROWLER_APP_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>
### STDIO Mode
## Local MCP Server Configuration
STDIO mode is only available when running the MCP server locally.
STDIO mode is only available when running the **Local MCP Server** on your own machine. See the [Installation guide](/getting-started/installation/prowler-mcp) to set it up first.
<Tabs>
<Tab title="Generic uvx installation">
@@ -152,7 +115,7 @@ STDIO mode is only available when running the MCP server locally.
"command": "uvx",
"args": ["/absolute/path/to/prowler/mcp_server/"],
"env": {
"PROWLER_APP_API_KEY": "<your-api-key-here>",
"PROWLER_API_KEY": "<your-api-key-here>",
"API_BASE_URL": "https://api.prowler.com/api/v1"
}
}
@@ -179,7 +142,7 @@ STDIO mode is only available when running the MCP server locally.
"--rm",
"-i",
"--env",
"PROWLER_APP_API_KEY=<your-api-key-here>",
"PROWLER_API_KEY=<your-api-key-here>",
"--env",
"API_BASE_URL=https://api.prowler.com/api/v1",
"prowlercloud/prowler-mcp"
@@ -205,7 +168,7 @@ Restart your MCP client and start asking questions:
## Authentication Methods
Prowler MCP Server supports two authentication methods to connect to Prowler Cloud or Prowler Local Server:
Prowler MCP Server supports two authentication methods to connect to Prowler (Prowler Cloud, Prowler Private Cloud, or Prowler Local Server):
### API Key (Recommended)
@@ -128,8 +128,8 @@ To update the environment file:
Edit the `.env` file and change version values:
```env
PROWLER_UI_VERSION="5.33.0"
PROWLER_API_VERSION="5.33.0"
PROWLER_UI_VERSION="5.35.0"
PROWLER_API_VERSION="5.35.0"
```
<Note>
@@ -5,12 +5,12 @@ title: "Installation"
There are **two ways** to use Prowler MCP Server:
<CardGroup cols={2}>
<Card title="Option 1: Managed by Prowler" icon="cloud" color="#10B981">
<Card title="Cloud MCP Server (Recommended)" icon="cloud" color="#10B981">
**No installation required** - Just configuration
Use `https://mcp.prowler.com/mcp`
</Card>
<Card title="Option 2: Run Locally" icon="server" color="#6366F1">
<Card title="Local MCP Server" icon="server" color="#6366F1">
**Local installation** - Full control
Install via Docker, PyPI, or source code
@@ -18,8 +18,8 @@ There are **two ways** to use Prowler MCP Server:
</CardGroup>
For "Option 1: Managed by Prowler", go directly to the [Configuration Guide](/getting-started/basic-usage/prowler-mcp#hosted-server-configuration-recommended) to set up your Claude Desktop, Cursor, or other MCP client.
**This guide is focused on local installation, "Option 2: Run Locally"**.
For the Cloud MCP Server, go directly to the [Configuration Guide](/getting-started/basic-usage/prowler-mcp#cloud-mcp-server-configuration-recommended) to set up your Claude Desktop, Cursor, or other MCP client.
**This guide is focused on local installation, the Local MCP Server**.
## Installation Methods
@@ -51,7 +51,7 @@ Choose one of the following installation methods:
```bash
docker run --rm -i \
-e PROWLER_APP_API_KEY="pk_your_api_key" \
-e PROWLER_API_KEY="pk_your_api_key" \
-e API_BASE_URL="https://api.prowler.com/api/v1" \
prowlercloud/prowler-mcp
```
@@ -143,7 +143,7 @@ Choose one of the following installation methods:
## Updating Prowler MCP Server
When running Prowler MCP Server locally ("Option 2: Run Locally"), upgrade to the latest version using the same method chosen for installation. The hosted server (`https://mcp.prowler.com/mcp`) is always kept up to date by Prowler and requires no action.
When running the Local MCP Server, upgrade to the latest version using the same method chosen for installation. The Cloud MCP Server (`https://mcp.prowler.com/mcp`) is always kept up to date by Prowler and requires no action.
<Tabs>
<Tab title="Docker">
@@ -219,19 +219,19 @@ Configure the server using environment variables:
| Variable | Description | Required | Default |
|----------|-------------|----------|---------|
| `PROWLER_APP_API_KEY` | Prowler API key | Only for STDIO mode | - |
| `PROWLER_API_KEY` | Prowler API key | Only for STDIO mode | - |
| `API_BASE_URL` | Custom Prowler API endpoint | No | `https://api.prowler.com/api/v1` |
| `PROWLER_MCP_TRANSPORT_MODE` | Default transport mode (overwritten by `--transport` argument) | No | `stdio` |
<CodeGroup>
```bash macOS/Linux
export PROWLER_APP_API_KEY="pk_your_api_key_here"
export PROWLER_API_KEY="pk_your_api_key_here"
export API_BASE_URL="https://api.prowler.com/api/v1"
export PROWLER_MCP_TRANSPORT_MODE="http"
```
```bash Windows PowerShell
$env:PROWLER_APP_API_KEY="pk_your_api_key_here"
$env:PROWLER_API_KEY="pk_your_api_key_here"
$env:API_BASE_URL="https://api.prowler.com/api/v1"
$env:PROWLER_MCP_TRANSPORT_MODE="http"
```
@@ -246,7 +246,7 @@ Never commit your API key to version control. Use environment variables or secur
For convenience, create a `.env` file in the `mcp_server` directory:
```bash .env
PROWLER_APP_API_KEY=pk_your_api_key_here
PROWLER_API_KEY=pk_your_api_key_here
API_BASE_URL=https://api.prowler.com/api/v1
PROWLER_MCP_TRANSPORT_MODE=stdio
```
+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). |
@@ -24,6 +24,9 @@ The Agentic Cloud Defender does more than answer questions, it helps teams **fin
<Card title="Normal and Agentic Views" icon="table-columns">
Switch between the standard interface and a chat-first agentic view.
</Card>
<Card title="Side Panel on Every Page" icon="comment-dots">
Open Lighthouse AI as a side panel from any page to get help in context.
</Card>
<Card title="Provider Connection Checks" icon="plug-circle-check">
Credentials are validated automatically when a provider is configured.
</Card>
@@ -37,6 +40,20 @@ Promoting the chat to a top-level view gives Lighthouse AI the room it needs for
<img src="/images/prowler-app/lighthouse/prowler-cloud/main-chat-page.png" alt="Lighthouse AI chat view in Prowler Cloud" />
### Side Panel
You do not have to switch to the full chat view to reach Lighthouse AI. A side panel is available on every page of Prowler Cloud. While collapsed it stays out of the way; open it from any dashboard, findings list, or configuration screen to ask questions without leaving what you are working on. Open it using the Lighthouse AI button, circled in red in the image below.
<img src="/images/prowler-app/lighthouse/prowler-cloud/side-panel-closed.png" alt="Collapsed Lighthouse AI side panel on a Prowler Cloud page, with the button to open it circled in red" />
Once open, the panel slides in alongside your current page and shares the same agent, tools, and persistent chat sessions as the full Chat View, so a conversation started in the panel can be reopened and continued later from either place.
<img src="/images/prowler-app/lighthouse/prowler-cloud/side-panel-open.png" alt="Lighthouse AI side panel open alongside a Prowler Cloud page" />
- **Available everywhere:** Summon the assistant from any page while you keep working in the normal view.
- **Context-aware help:** Ask about the findings, resources, or compliance data you are currently looking at.
- **Continuous sessions:** Conversations opened in the side panel are saved alongside the rest of your chat history.
### Tool Usage
Lighthouse AI on Prowler Cloud renders the agent's work as it happens, so responses are easier to follow and to trust. Tool calls and reasoning steps appear in the order they occur within the conversation.
@@ -63,6 +80,53 @@ At the top of the configuration page, the optional **Business Context** field le
Lighthouse AI on Prowler Cloud supports OpenAI, Amazon Bedrock, and OpenAI-compatible providers, with GPT-5.5 as the default. For per-provider setup and how to switch the default provider or model, see [Using Multiple LLM Providers](/user-guide/tutorials/prowler-cloud-lighthouse-multi-llm).
## Capabilities
Lighthouse AI works through the [Prowler MCP Server](/getting-started/products/prowler-mcp), which gives the agent a growing catalog of tools to explore and act on your security data. These actions run inside Prowler and never modify your cloud resources. Everything the agent can do maps to one of the following capability areas.
### Findings and Finding Groups
- Search and filter security findings across every connected provider by severity, status, region, service, check, date range, and muted state.
- Retrieve full finding details, including remediation guidance, check metadata, and affected resources.
- Summarize findings with aggregate statistics and trends.
- Browse finding groups aggregated by check and drill down into the specific resources each group affects.
### Resources
- List and filter cloud resources by provider, region, service, resource type, and tags.
- Inspect a resource's configuration, metadata, and related findings.
- Review the timeline of cloud API actions performed on a resource (AWS CloudTrail), including who did what and when.
- Get an aggregate overview of the resources Prowler has discovered.
### Compliance
- Review high-level compliance status across all frameworks, with pass/fail statistics per framework.
- Get a requirement-level breakdown for a specific framework, including failed requirements and their associated findings.
### Attack Paths
- List Attack Paths scans and discover the queries available for each completed scan.
- Run graph-based queries to reveal privilege-escalation chains and exploitable misconfigurations.
- Retrieve the Cartography graph schema to build accurate custom queries.
### Scans and Providers
- List, inspect, and rename security scans across providers.
- Trigger manual scans and schedule automated daily scans for continuous monitoring.
- Search connected providers and check their connection status, connect new providers, or remove existing ones.
### Muting
- Manage the mutelist for pattern-based bulk muting.
- Create, update, list, and delete finding-specific mute rules, each with a documented reason and audit trail.
### Security Check Catalog and Documentation
- Browse and search the Prowler Hub catalog of security checks and compliance frameworks, including check code and automated fixers.
- Search and retrieve official Prowler documentation to answer how-to and product questions.
For the complete list of underlying tools, see the [Prowler MCP Tools Reference](/getting-started/basic-usage/prowler-mcp-tools).
## FAQ
**Which LLM providers are supported?**
@@ -71,7 +135,11 @@ OpenAI (GPT models, including the default GPT-5.5), Amazon Bedrock (Claude, Llam
**Can Lighthouse AI change my cloud environment?**
No. Lighthouse AI has read-only access to security data and no tools to modify resources, even when the connected cloud credentials would allow changes.
No. Lighthouse AI cannot modify the resources in your connected cloud providers (AWS, Azure, GCP, and others). It has read-only access to that environment and no tools to change it, even when the connected cloud credentials would allow it.
**Can Lighthouse AI change my Prowler Cloud environment?**
Yes. Lighthouse AI can take action within Prowler Cloud itself, such as connecting or removing providers, triggering and scheduling scans, and managing mute rules and the mutelist. See [Capabilities](#capabilities) for the full list of what it can do. These actions only affect your Prowler Cloud workspace, never the resources in your cloud providers.
## Looking for the Open Source Version?
@@ -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 to appear alongside Prowler branding in the console.
## 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 | Your logo alongside Prowler branding |
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** | Add customers, review their posture and billing, and open their Prowler Cloud tenant | 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 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>
+95 -51
View File
@@ -8,8 +8,29 @@ title: "Overview"
**Preview Feature**: This MCP server is currently under active development. Features and functionality may change. We welcome your feedback—please report any issues on [GitHub](https://github.com/prowler-cloud/prowler/issues) or join our [Slack community](https://goto.prowler.com/slack) to discuss and share your thoughts.
</Warning>
## Quickest Way to Connect: Cloud MCP Server
The fastest way to get started is the **Cloud MCP Server** at `https://mcp.prowler.com/mcp` — no installation, always up to date, and maintained by Prowler. Just point your MCP client at the URL and authenticate with a [Prowler API key](/user-guide/tutorials/prowler-app-api-keys) as a Bearer token:
```json
{
"mcpServers": {
"prowler": {
"url": "https://mcp.prowler.com/mcp",
"headers": {
"Authorization": "Bearer <your-api-key-here>"
}
}
}
}
```
<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 Code, Codex, Cursor, VS Code, and other agents.
</Card>
<Note>
Prowler MCP Server can run as a local instance, or as the hosted **Prowler MCP** at `https://mcp.prowler.com/mcp`. The hosted server also provides tools for Prowler Cloud-specific features such as [Alerts](/user-guide/tutorials/prowler-alerts), [Scan Scheduling](/user-guide/tutorials/prowler-scan-scheduling), and [Findings Triage](/user-guide/tutorials/prowler-app-findings-triage). See [Deployment Options](#deployment-options).
Prefer to run it yourself? The **Local MCP Server** runs on your own machine or infrastructure. The Cloud MCP Server additionally provides tools for Prowler Cloud-specific features such as [Alerts](/user-guide/tutorials/prowler-alerts), [Scan Scheduling](/user-guide/tutorials/prowler-scan-scheduling), and [Findings Triage](/user-guide/tutorials/prowler-app-findings-triage). See [Cloud vs Local MCP Server](#cloud-vs-local-mcp-server).
</Note>
## What is the Model Context Protocol?
@@ -20,9 +41,9 @@ The [Model Context Protocol (MCP)](https://modelcontextprotocol.io) is an open s
The Prowler MCP Server provides three main integration points:
### 1. Prowler Cloud and Prowler Local Server
### 1. Prowler Cloud, Private Cloud & Local Server
Full access to Prowler Cloud and Prowler Local Server for:
Full access to your Prowler deployment — Prowler Cloud, Prowler Private Cloud, or Prowler Local Server for:
- **Findings Analysis**: Query, filter, and analyze security findings across all your cloud environments
- **Provider Management**: Create, configure, and manage your configured Prowler providers (AWS, Azure, GCP, etc.)
- **Scan Orchestration**: Trigger on-demand scans and schedule recurring security assessments
@@ -48,12 +69,53 @@ Search and retrieve official Prowler documentation:
## MCP Server Architecture
The following diagram illustrates the Prowler MCP Server architecture and its integration points:
The following diagram illustrates the Prowler MCP Server architecture and its integration points. MCP clients connect to either the **Cloud MCP Server** (recommended) or a **Local MCP Server**; both expose the same tools and reach the same Prowler backends:
![Prowler MCP Server Schema](/images/prowler_mcp_schema.png)
```mermaid
flowchart LR
subgraph HOSTS["MCP Clients"]
chat["Chat Interfaces<br/>(Claude Desktop, LobeChat)"]
ide["IDEs and Code Editors<br/>(Claude Code, Cursor)"]
apps["Other AI Applications<br/>(5ire, custom agents)"]
end
subgraph SERVERS["Prowler MCP Server"]
direction TB
cloud["☁️ Cloud MCP Server (Recommended)<br/>mcp.prowler.com/mcp · HTTP<br/>Managed by Prowler · always up to date<br/>Adds Cloud-only tools (Alerts,<br/>Scan Scheduling, Findings Triage)"]
local["💻 Local MCP Server<br/>Self-run · STDIO or HTTP<br/>Python 3.12+ or Docker<br/>You manage updates"]
end
subgraph TOOLS["Prowler MCP Tools"]
prowler_tools["prowler_* tools<br/>(API key or JWT auth)<br/>Findings · Providers · Scans<br/>Resources · Muting · Compliance<br/>Attack Paths"]
hub_tools["prowler_hub_* tools<br/>(no auth)<br/>Checks Catalog · Check Code<br/>Fixers · Compliance Frameworks"]
docs_tools["prowler_docs_* tools<br/>(no auth)<br/>Search · Document Retrieval"]
end
api["Prowler API (REST)<br/>Cloud · Private Cloud · Local Server"]
hub["hub.prowler.com<br/>(REST)"]
docs["docs.prowler.com<br/>(Mintlify)"]
chat -->|HTTP| cloud
ide -->|HTTP| cloud
apps -->|HTTP| cloud
chat -->|STDIO or HTTP| local
ide -->|STDIO or HTTP| local
apps -->|STDIO or HTTP| local
cloud --> prowler_tools
cloud --> hub_tools
cloud --> docs_tools
local --> prowler_tools
local --> hub_tools
local --> docs_tools
prowler_tools -->|REST| api
hub_tools -->|REST| hub
docs_tools -->|REST| docs
```
The architecture shows how AI assistants connect through the MCP protocol to access Prowler's three main components:
- Prowler Cloud and Prowler Local Server for security operations
- Prowler Cloud, Prowler Private Cloud, or Prowler Local Server for security operations
- Prowler Hub for security knowledge
- Prowler Documentation for guidance and reference.
@@ -92,8 +154,8 @@ REQUIREMENTS:
DATA TO FETCH:
Use these MCP tools in this order:
1. Prowler app list providers - To get all available configured provider in the account
2. Prowler app get latest findings - To get findings information, if there are so many you can use the filter_fields to get less information, or pagination to get in different batches
1. Prowler list providers - To get all available configured provider in the account
2. Prowler get latest findings - To get findings information, if there are so many you can use the filter_fields to get less information, or pagination to get in different batches
3. For most critical findings you can get more context and remediation with Prowler Hub to get remediations for example
DESIGN REQUIREMENTS:
@@ -130,66 +192,48 @@ Generate the complete HTML file and display it
></iframe>
## Deployment Options
## Cloud vs Local MCP Server
Prowler MCP Server can be used in three ways:
There are two ways to run the Prowler MCP Server. For almost everyone, the **Cloud MCP Server** is the right choice — it needs no installation and is maintained by Prowler. The **Local MCP Server** exists for users who need to run it on their own machine or infrastructure.
### 1. Prowler Cloud MCP Server
| | ☁️ **Cloud MCP Server** (Recommended) | 💻 **Local MCP Server** |
|---|---|---|
| **Endpoint** | `https://mcp.prowler.com/mcp` | Runs on your machine or infrastructure |
| **Setup** | Just configure your MCP client | Install via Docker, or source |
| **Transport** | HTTP | STDIO (subprocess) or self-hosted HTTP |
| **Maintenance** | Managed by Prowler, always up to date | You manage updates |
| **Requirements** | None (just an MCP client) | Python 3.12+ or Docker |
| **Cloud-only tools** | ✅ Alerts, Scan Scheduling, Findings Triage | ❌ Not available |
| **Authentication** | API key or JWT token | API key/JWT (HTTP) or env vars (STDIO) |
**Use Prowler's managed MCP server at `https://mcp.prowler.com/mcp`**
### ☁️ Cloud MCP Server (Recommended)
- No installation required.
- Managed and maintained by Prowler team.
- Authentication to Prowler Cloud or Prowler Local Server via API key or JWT token.
- Includes tools for Prowler Cloud-specific features such as Alerts, Scan Scheduling, and Findings Triage.
Prowler's managed MCP server at `https://mcp.prowler.com/mcp`. No installation, always up to date, and it includes tools for Prowler Cloud-specific features such as Alerts, Scan Scheduling, and Findings Triage. This is the path we recommend for nearly all users — go straight to the [Configuration guide](/getting-started/basic-usage/prowler-mcp#cloud-mcp-server-configuration-recommended).
### 2. Local STDIO Mode
### 💻 Local MCP Server
**Run the server locally on your machine**
Run the server yourself when you need full control over the deployment. It connects to Prowler Cloud, Prowler Private Cloud, or Prowler Local Server and can run in two modes:
- Runs as a subprocess of the MCP client.
- Possibility to connect to Prowler Local Server.
- Authentication to Prowler Cloud or Prowler Local Server via environment variables.
- Requires Python 3.12+ or Docker.
- **STDIO mode** — the server runs as a subprocess of your MCP client. Authentication via environment variables.
- **Self-hosted HTTP mode** — deploy your own remote HTTP server. Authentication via API key or JWT token.
### 3. Self-Hosted HTTP Mode
**Deploy your own remote MCP server**
- Full control over deployment.
- Possibility to connect to Prowler Local Server.
- Authentication to Prowler Local Server via API key or JWT token.
- Requires Python 3.12+ or Docker.
## Requirements
Requirements vary based on deployment option:
**For Prowler Cloud MCP Server:**
- Prowler Cloud account and API key (only for Prowler Cloud and Prowler Local Server features)
**For self-hosted STDIO/HTTP Mode:**
- Python 3.12+ or Docker
- Network access to:
- `https://hub.prowler.com` (for Prowler Hub)
- `https://docs.prowler.com` (for Prowler Documentation)
- Prowler Cloud API or Prowler Local Server API (for Prowler Cloud and Prowler Local Server features)
Both require Python 3.12+ or Docker, plus network access to `https://hub.prowler.com` (Prowler Hub), `https://docs.prowler.com` (Prowler Documentation), and the Prowler API or Prowler Local Server API (Prowler features). See the [Installation guide](/getting-started/installation/prowler-mcp) to get started.
<Note>
**No Authentication Required**: Prowler Hub and Prowler Documentation features work without authentication in both deployment options. A Prowler API key is only required to access Prowler Cloud or Prowler Local Server features.
**No Authentication Required**: Prowler Hub and Prowler Documentation features work without authentication on both the Cloud and Local MCP Server. A Prowler API key is only required to access Prowler features (Prowler Cloud, Prowler Private Cloud, or Prowler Local Server).
</Note>
## Next Steps
<CardGroup cols={2}>
<Card title="Installation" icon="download" href="/getting-started/installation/prowler-mcp">
Install the Prowler MCP Server using uv or Docker
</Card>
<Card title="Configuration" icon="gear" href="/getting-started/basic-usage/prowler-mcp">
Configure your MCP client to connect to the server
Connect your MCP client to the Cloud MCP Server
</Card>
<Card title="Tools Reference" icon="wrench" href="/getting-started/basic-usage/prowler-mcp-tools">
Explore all available tools and capabilities
</Card>
</CardGroup>
<Card title="Tools Reference" icon="wrench" href="/getting-started/basic-usage/prowler-mcp-tools" horizontal>
Explore all available tools and capabilities
<Card title="Local Installation" icon="download" href="/getting-started/installation/prowler-mcp" horizontal>
Run the Local MCP Server yourself using Docker, source, or uvx
</Card>
+2 -2
View File
@@ -15,7 +15,7 @@ flowchart TB
llm["LLM Provider<br/>(OpenAI / Bedrock / OpenAI-compatible)"]
subgraph MCP["Prowler MCP Server"]
app_tools["prowler_app_* tools<br/>(auth required)"]
app_tools["prowler_* tools<br/>(auth required)"]
hub_tools["prowler_hub_* tools<br/>(no auth)"]
docs_tools["prowler_docs_* tools<br/>(no auth)"]
end
@@ -29,7 +29,7 @@ flowchart TB
agent <-->|LLM API| llm
agent --> metatools
metatools --> mcpclient
mcpclient -->|MCP HTTP · Bearer token<br/>for prowler_app_* only| app_tools
mcpclient -->|MCP HTTP · Bearer token<br/>for prowler_* only| app_tools
mcpclient -->|MCP HTTP| hub_tools
mcpclient -->|MCP HTTP| docs_tools
app_tools -->|REST| api
Binary file not shown.

Before

Width:  |  Height:  |  Size: 262 KiB

After

Width:  |  Height:  |  Size: 432 KiB

+27 -31
View File
@@ -3,41 +3,37 @@
<filter id="shadow" x="-4%" y="-4%" width="108%" height="108%">
<feDropShadow dx="0" dy="2" stdDeviation="3" flood-opacity="0.08"/>
</filter>
<marker id="arrowhead" markerWidth="10" markerHeight="7" refX="9" refY="3.5" orient="auto">
<polygon points="0 0, 10 3.5, 0 7" fill="#9aa0a6"/>
</marker>
</defs>
<!-- Title -->
<text x="550" y="40" text-anchor="middle" font-size="22" font-weight="700" fill="#4285F4">Onboarding Flow</text>
<!-- Step 1 -->
<rect x="30" y="70" width="220" height="220" rx="12" fill="#fff" stroke="#4285F4" stroke-width="2.5" stroke-dasharray="8 4" filter="url(#shadow)"/>
<rect x="30" y="70" width="220" height="220" rx="12" fill="#fff" stroke="#4285F4" stroke-width="2.5" filter="url(#shadow)"/>
<circle cx="140" cy="100" r="22" fill="#4285F4"/>
<text x="140" y="107" text-anchor="middle" font-size="16" font-weight="700" fill="#fff">1</text>
<text x="140" y="145" text-anchor="middle" font-size="15" font-weight="700" fill="#1a1a2e">Create Management</text>
<text x="140" y="165" text-anchor="middle" font-size="15" font-weight="700" fill="#1a1a2e">Account Role</text>
<rect x="60" y="185" width="160" height="24" rx="12" fill="#E8F0FE"/>
<text x="140" y="201" text-anchor="middle" font-size="11" font-weight="600" fill="#4285F4">Quick Create or Manual</text>
<text x="140" y="232" text-anchor="middle" font-size="12" fill="#5f6368">Allows Prowler to</text>
<text x="140" y="248" text-anchor="middle" font-size="12" fill="#5f6368">discover your org</text>
<text x="140" y="264" text-anchor="middle" font-size="12" fill="#5f6368">structure</text>
<text x="140" y="150" text-anchor="middle" font-size="15" font-weight="700" fill="#1a1a2e">Start the Wizard</text>
<rect x="75" y="168" width="130" height="24" rx="12" fill="#E8F0FE"/>
<text x="140" y="184" text-anchor="middle" font-size="11" font-weight="600" fill="#4285F4">In Prowler Cloud</text>
<text x="140" y="216" text-anchor="middle" font-size="12" fill="#5f6368">Enter your Org ID</text>
<text x="140" y="232" text-anchor="middle" font-size="12" fill="#5f6368">and OU/root target</text>
<!-- Arrow 1→2 -->
<path d="M260 180 L290 180" stroke="#9aa0a6" stroke-width="2" fill="none" marker-end="url(#arrowhead)"/>
<defs>
<marker id="arrowhead" markerWidth="10" markerHeight="7" refX="9" refY="3.5" orient="auto">
<polygon points="0 0, 10 3.5, 0 7" fill="#9aa0a6"/>
</marker>
</defs>
<!-- Step 2 -->
<rect x="300" y="70" width="220" height="220" rx="12" fill="#fff" stroke="#7B61FF" stroke-width="2.5" filter="url(#shadow)"/>
<circle cx="410" cy="100" r="22" fill="#7B61FF"/>
<text x="410" y="107" text-anchor="middle" font-size="16" font-weight="700" fill="#fff">2</text>
<text x="410" y="145" text-anchor="middle" font-size="15" font-weight="700" fill="#1a1a2e">Deploy StackSet</text>
<rect x="340" y="165" width="140" height="24" rx="12" fill="#F3F0FF"/>
<text x="410" y="181" text-anchor="middle" font-size="11" font-weight="600" fill="#7B61FF">In AWS Console</text>
<text x="410" y="212" text-anchor="middle" font-size="12" fill="#5f6368">Creates ProwlerScan</text>
<text x="410" y="228" text-anchor="middle" font-size="12" fill="#5f6368">role in every</text>
<text x="410" y="244" text-anchor="middle" font-size="12" fill="#5f6368">member account</text>
<text x="410" y="150" text-anchor="middle" font-size="15" font-weight="700" fill="#1a1a2e">Deploy the Roles</text>
<rect x="350" y="168" width="120" height="24" rx="12" fill="#F3F0FF"/>
<text x="410" y="184" text-anchor="middle" font-size="11" font-weight="600" fill="#7B61FF">Single CF Stack</text>
<text x="410" y="216" text-anchor="middle" font-size="12" fill="#5f6368">Management role +</text>
<text x="410" y="232" text-anchor="middle" font-size="12" fill="#5f6368">StackSet to members</text>
<text x="410" y="248" text-anchor="middle" font-size="12" fill="#5f6368">in one CF stack</text>
<!-- Arrow 2→3 -->
<path d="M530 180 L560 180" stroke="#9aa0a6" stroke-width="2" fill="none" marker-end="url(#arrowhead)"/>
@@ -46,11 +42,11 @@
<rect x="570" y="70" width="220" height="220" rx="12" fill="#fff" stroke="#00BFA5" stroke-width="2.5" filter="url(#shadow)"/>
<circle cx="680" cy="100" r="22" fill="#00BFA5"/>
<text x="680" y="107" text-anchor="middle" font-size="16" font-weight="700" fill="#fff">3</text>
<text x="680" y="145" text-anchor="middle" font-size="15" font-weight="700" fill="#1a1a2e">Run the Wizard</text>
<rect x="615" y="165" width="130" height="24" rx="12" fill="#E0F7F4"/>
<text x="680" y="181" text-anchor="middle" font-size="11" font-weight="600" fill="#00BFA5">In Prowler Cloud</text>
<text x="680" y="212" text-anchor="middle" font-size="12" fill="#5f6368">Discovers accounts,</text>
<text x="680" y="228" text-anchor="middle" font-size="12" fill="#5f6368">tests connections</text>
<text x="680" y="150" text-anchor="middle" font-size="15" font-weight="700" fill="#1a1a2e">Discover &amp; Connect</text>
<rect x="615" y="168" width="130" height="24" rx="12" fill="#E0F7F4"/>
<text x="680" y="184" text-anchor="middle" font-size="11" font-weight="600" fill="#00BFA5">In Prowler Cloud</text>
<text x="680" y="216" text-anchor="middle" font-size="12" fill="#5f6368">Discovers accounts,</text>
<text x="680" y="232" text-anchor="middle" font-size="12" fill="#5f6368">tests connections</text>
<!-- Arrow 3→4 -->
<path d="M800 180 L830 180" stroke="#9aa0a6" stroke-width="2" fill="none" marker-end="url(#arrowhead)"/>
@@ -59,13 +55,13 @@
<rect x="840" y="70" width="220" height="220" rx="12" fill="#fff" stroke="#F9AB00" stroke-width="2.5" filter="url(#shadow)"/>
<circle cx="950" cy="100" r="22" fill="#F9AB00"/>
<text x="950" y="107" text-anchor="middle" font-size="16" font-weight="700" fill="#fff">4</text>
<text x="950" y="145" text-anchor="middle" font-size="15" font-weight="700" fill="#1a1a2e">Launch Scans</text>
<rect x="898" y="165" width="104" height="24" rx="12" fill="#FEF7E0"/>
<text x="950" y="181" text-anchor="middle" font-size="11" font-weight="600" fill="#F9AB00">Automatic</text>
<text x="950" y="212" text-anchor="middle" font-size="12" fill="#5f6368">Scans run on all</text>
<text x="950" y="228" text-anchor="middle" font-size="12" fill="#5f6368">connected accounts</text>
<text x="950" y="244" text-anchor="middle" font-size="12" fill="#5f6368">on your schedule</text>
<text x="950" y="150" text-anchor="middle" font-size="15" font-weight="700" fill="#1a1a2e">Launch Scans</text>
<rect x="898" y="168" width="104" height="24" rx="12" fill="#FEF7E0"/>
<text x="950" y="184" text-anchor="middle" font-size="11" font-weight="600" fill="#F9AB00">Automatic</text>
<text x="950" y="216" text-anchor="middle" font-size="12" fill="#5f6368">Scans run on all</text>
<text x="950" y="232" text-anchor="middle" font-size="12" fill="#5f6368">connected accounts</text>
<text x="950" y="248" text-anchor="middle" font-size="12" fill="#5f6368">on your schedule</text>
<!-- Footer -->
<text x="550" y="340" text-anchor="middle" font-size="13" fill="#9aa0a6">Steps 1 and 2 are done once in AWS | Steps 3 and 4 are done in Prowler Cloud</text>
<text x="550" y="340" text-anchor="middle" font-size="13" fill="#9aa0a6">Step 2 runs once in AWS | Steps 1, 3 and 4 are in Prowler Cloud</text>
</svg>

Before

Width:  |  Height:  |  Size: 4.7 KiB

After

Width:  |  Height:  |  Size: 4.5 KiB

@@ -47,7 +47,7 @@
<!-- Deploy badge -->
<rect x="115" y="400" width="270" height="28" rx="14" fill="#FFF3E0" stroke="#F9AB00" stroke-width="1.5"/>
<text x="250" y="419" text-anchor="middle" font-size="12" font-weight="700" fill="#E65100">Deploy: Quick Create link or Manual</text>
<text x="250" y="419" text-anchor="middle" font-size="12" font-weight="700" fill="#E65100">Deploy: single stack or standalone</text>
<!-- ===== Prowler Cloud connector ===== -->
<rect x="490" y="195" width="120" height="36" rx="8" fill="#F5F5F5" stroke="#E0E0E0" stroke-width="1"/>
@@ -86,7 +86,7 @@
<!-- Deploy badge -->
<rect x="735" y="400" width="230" height="28" rx="14" fill="#E8F5E9" stroke="#66BB6A" stroke-width="1.5"/>
<text x="850" y="419" text-anchor="middle" font-size="12" font-weight="700" fill="#2E7D32">Deploy: via CloudFormation StackSet</text>
<text x="850" y="419" text-anchor="middle" font-size="12" font-weight="700" fill="#2E7D32">Deploy: StackSet (single stack)</text>
<!-- Footer labels -->
<text x="250" y="478" text-anchor="middle" font-size="14" font-weight="700" fill="#4285F4">Prowler discovers</text>

Before

Width:  |  Height:  |  Size: 6.0 KiB

After

Width:  |  Height:  |  Size: 5.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 662 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 878 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 508 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 293 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 266 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 335 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 469 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 574 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 412 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 337 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 445 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 140 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 334 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 357 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 396 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 248 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 370 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 84 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 85 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

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