Compare commits

..

131 Commits

Author SHA1 Message Date
Hugo P.Brito 1c028d0795 Revert "fix(ci): ignore generated lockfiles in changelog gate"
This reverts commit 8deb72ffa8.
2026-04-09 09:23:40 +01:00
Hugo P.Brito 8deb72ffa8 fix(ci): ignore generated lockfiles in changelog gate
- Keep api/poetry.lock and mcp_server/uv.lock from forcing changelog updates\n- Preserve the existing changelog requirement for real user-facing changes
2026-04-09 09:13:32 +01:00
Hugo P.Brito b2f34d2df0 docs(sdk): move unknown_device_blocked changelog entry to 5.23.0 2026-04-08 13:25:28 +01:00
Hugo P.Brito db6828ec28 Merge remote-tracking branch 'origin/master' into HEAD 2026-04-08 13:25:22 +01:00
Hugo P.Brito 505ff94166 fix(m365): correct metadata for unknown device blocked check
- Set ResourceType to NotDefined (no individual resource assessed)
- Replace broken AdditionalURLs with canonical Microsoft Learn links
- Clear RelatedTo (referenced check does not exist)
2026-04-08 13:25:22 +01:00
Hugo P.Brito 85195096be fix(m365): move KNOWN_PLATFORMS to entra_service and normalize case
Move the constant from the check module to the service module so it
can be reused, and switch to a lowercase frozenset for
case-insensitive comparison.
2026-04-08 13:25:17 +01:00
lydiavilchez bc38104903 feat(googleworkspace): add calendar service checks using Cloud Identity Policy API (#10597) 2026-04-08 13:26:56 +02:00
Andoni Alonso 9290d7e105 feat(sdk): warn when sensitive CLI flags receive explicit values (#10532) 2026-04-08 13:15:05 +02:00
Hugo P.Brito a491a50afd Merge remote-tracking branch 'origin/master' into HEAD
# Conflicts:
#	prowler/CHANGELOG.md
#	prowler/compliance/m365/iso27001_2022_m365.json
#	prowler/providers/m365/services/entra/entra_service.py
2026-04-08 11:50:37 +01:00
lydiavilchez 72e8f09c07 feat(googleworkspace): add directory check for CIS 1.1.3 - super admin only admin roles (#10488)
Co-authored-by: Daniel Barranquero <danielbo2001@gmail.com>
2026-04-08 12:05:15 +02:00
Pepe Fagoaga 1d43885230 docs: update architecture diagram (#10604) 2026-04-08 11:05:28 +02:00
Adrián Peña e6aedcb207 feat(api): support sort by delta on finding-groups endpoints (#10606) 2026-04-08 11:04:57 +02:00
Kay Agahd 89fe867944 fix(aws): recognize service-specific condition keys as restrictive in is_policy_public (#10600)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-08 10:55:55 +02:00
Pepe Fagoaga 2be2753c55 fix(codeartifact): only retrieve the latest version from a package (#10243)
Co-authored-by: Andoni Alonso <14891798+andoniaf@users.noreply.github.com>
2026-04-08 09:21:19 +02:00
Josema Camacho 283259f34c fix(sdk): resolve empty-set bug in _enabled_regions causing 36-region client creation and CI timeouts (#10598) 2026-04-08 08:40:58 +02:00
Adrián Peña abaacd7dbf feat(api): finding group first_seen_at semantics and resource delta (#10595) 2026-04-07 16:41:08 +02:00
Hugo P.Brito 6451740b27 fix(sdk): preserve entra platform condition compatibility
- Keep platform condition aliases compatible with existing checks
- Normalize platform values before evaluating unknown-device blocks
- Update tests to cover mixed casing and alias-based input
2026-04-07 15:28:49 +01:00
rchotacode 5e1e4bd8e4 fix(oci): Mutelist support (#10566)
Co-authored-by: Ronan Chota <ronan.chota@saic.com>
Co-authored-by: Hugo P.Brito <hugopbrito@users.noreply.github.com>
2026-04-07 13:23:51 +01:00
Davidm4r 33efd72b97 chore(deps): bump authlib from 1.6.5 to 1.6.9 (#10579)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-07 13:31:59 +02:00
Pepe Fagoaga b2788df8cc chore(issues): automate conversation lock on issue close (#10596) 2026-04-07 13:07:02 +02:00
Andoni Alonso b1b361af8b chore(ci): update Pablo user for labeling purposes (#10594) 2026-04-07 12:54:04 +02:00
Josema Camacho 8bc03f8d04 fix(api): remove clear_cache from attack paths read-only query endpoints (#10586) 2026-04-07 12:46:51 +02:00
Andoni Alonso ca03d9c0a9 docs: add Google Workspace SAML SSO configuration guide (#10564)
Co-authored-by: Alan Buscaglia <Alan-TheGentleman@users.noreply.github.com>
Co-authored-by: Adrián Jesús Peña Rodríguez <adrianjpr@gmail.com>
2026-04-07 12:03:21 +02:00
Kay Agahd 8985280621 fix(azure): create distinct report per key/secret in keyvault checks (#10332)
Co-authored-by: Hugo Pereira Brito <101209179+HugoPBrito@users.noreply.github.com>
Co-authored-by: Hugo P.Brito <hugopbrit@gmail.com>
2026-04-07 09:36:48 +01:00
Pepe Fagoaga b7ee2b9690 chore: rename UI tab regarding the environment (#10588) 2026-04-07 10:30:01 +02:00
Alejandro Bailo 6b2d9b5580 feat(ui): add Vercel provider (#10191)
Co-authored-by: Daniel Barranquero <danielbo2001@gmail.com>
2026-04-07 10:13:18 +02:00
kaiisfree c99ed991b7 fix: show all checks including threat-detection in --list-checks (#10578)
Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: kaiisfree <kai@users.noreply.github.com>
Co-authored-by: Hugo Pereira Brito <101209179+HugoPBrito@users.noreply.github.com>
2026-04-06 16:55:15 +01:00
Hugo Pereira Brito 7c0034524a fix(sdk): add missing __init__.py for codebuild GitHub orgs check (#10584) 2026-04-06 16:40:04 +01:00
Josema Camacho 749110de75 chore(sdk): bump cryptography to 46.0.6, oci to 2.169.0, and alibabacloud-tea-openapi to 0.4.4 (#10535) 2026-04-06 15:09:33 +02:00
Adrián Peña 5fff3b920d fix(api): exclude spurious retrieve from Jira docs and add known limitations (#10580) 2026-04-06 14:30:38 +02:00
Pablo Fernandez Guerra (PFE) 961f9c86da feat(ui): Add tenant management (#10491)
Co-authored-by: Pablo Fernandez <pfe@NB0240.local>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: Pablo F.G <pablo.fernandez@prowler.com>
Co-authored-by: David <david.copo@gmail.com>
2026-04-06 10:31:30 +02:00
Andoni Alonso 0f1da703d1 docs(image): add Prowler App documentation and authentication guide (#10527)
Co-authored-by: Daniel Barranquero <danielbo2001@gmail.com>
2026-04-06 09:59:56 +02:00
Pepe Fagoaga 07f3416493 feat(mcp): Add resource events tool (#10412) 2026-04-06 08:42:04 +02:00
Alan Buscaglia 509ec74c3d fix(ui): findings groups improvements — security fixes, code quality, and UX feedback (#10513)
Co-authored-by: alejandrobailo <alejandrobailo94@gmail.com>
2026-04-01 15:54:46 +02:00
Adrián Peña ab8e83da3f fix(api,ui): dynamically fetch Jira issue types instead of hardcoding "Task" (#10534)
Co-authored-by: alejandrobailo <alejandrobailo94@gmail.com>
2026-04-01 14:37:49 +02:00
Pablo Fernandez Guerra (PFE) 6ac90eb1b5 chore(ui): add pnpm supply chain security protections (#10471)
Co-authored-by: Pablo F.G <pablo.fernandez@prowler.com>
Co-authored-by: César Arroba <cesar@prowler.com>
2026-04-01 14:10:01 +02:00
Alejandro Bailo af6198e6c2 feat(api): integrate Vercel provider into API layer (#10190)
Co-authored-by: Daniel Barranquero <danielbo2001@gmail.com>
2026-04-01 13:20:49 +02:00
Josema Camacho dfe06a1077 fix(ui): allow selecting failed scans when graph data is available (#10531) 2026-04-01 11:08:34 +02:00
Alejandro Bailo 4f86667433 feat(sdk): add Vercel provider with 30 security checks (#10189)
Co-authored-by: Daniel Barranquero <danielbo2001@gmail.com>
Co-authored-by: Andoni A. <14891798+andoniaf@users.noreply.github.com>
2026-03-31 16:21:22 +02:00
Andoni Alonso 4bb1e5cff7 fix(sdk): redact sensitive CLI flags in HTML output (#10518) 2026-03-31 15:01:09 +02:00
Pedro Martín 99b80ebbd9 chore(actions): add pr-check-compliance-mapping action (#10526) 2026-03-31 13:38:20 +02:00
rchotacode d18c5a8974 fix(oci): fix identity clients (#10520)
Co-authored-by: Ronan Chota <ronan.chota@saic.com>
Co-authored-by: Daniel Barranquero <danielbo2001@gmail.com>
2026-03-31 09:42:19 +02:00
Hugo Pereira Brito ab00c2dce1 feat(m365): add entra_conditional_access_policy_block_elevated_insider_risk security check (#10234)
Co-authored-by: Daniel Barranquero <danielbo2001@gmail.com>
2026-03-30 17:27:00 +02:00
Pablo Fernandez Guerra (PFE) 765f9c72f2 docs: add missing pre-commit hooks setup for TruffleHog, Safety and Hadolint (#10448)
Co-authored-by: Pablo Fernandez <pfe@NB0240.local>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: Pablo F.G <pablo.fernandez@prowler.com>
2026-03-30 16:43:32 +02:00
Erich Blume de5bb94ff6 fix(image): pass registry arguments through init_global_provider (#10470)
Co-authored-by: Andoni Alonso <14891798+andoniaf@users.noreply.github.com>
2026-03-30 15:19:01 +02:00
lydiavilchez c009a2128a feat(google-workspace): add CISA SCuBA Baselines compliance (#10466)
Co-authored-by: Pedro Martín <pedromarting3@gmail.com>
2026-03-30 14:33:38 +02:00
Alejandro Bailo 50556df713 feat(ui): add findings grouped view (#10425)
Co-authored-by: Adrián Jesús Peña Rodríguez <adrianjpr@gmail.com>
Co-authored-by: Alan Buscaglia <gentlemanprogramming@gmail.com>
2026-03-30 14:17:36 +02:00
Hugo Pereira Brito 3b875484b0 feat(m365): add device registration MFA and harden Intune enrollment CA check (#10222)
Co-authored-by: Hugo Brito <hugopbrito@users.noreply.github.com>
Co-authored-by: Daniel Barranquero <danielbo2001@gmail.com>
2026-03-30 13:36:05 +02:00
lydiavilchez 442b379777 feat(google-workspace): add CIS Foundations Benchmark v1.3.0 compliance (#10462)
Co-authored-by: pedrooot <pedromarting3@gmail.com>
2026-03-30 12:57:12 +02:00
Adrián Peña 2a8b6261e1 fix(api): false 404 and sorting on finding group resources endpoints (#10510) 2026-03-30 12:47:16 +02:00
Alan Buscaglia 6df74529d6 refactor(ui): remove "Clear all" button from filter pills strip (#10481) 2026-03-30 12:26:01 +02:00
César Arroba 6f6d62f51f fix(ci): remove DOCKER_HUB_REPOSITORY secret and add toniblyx mirror push (#10512) 2026-03-30 11:53:04 +02:00
Hugo Pereira Brito 7148086410 feat(m365): add entra_conditional_access_policy_block_o365_elevated_insider_risk security check (#10232)
Co-authored-by: Daniel Barranquero <danielbo2001@gmail.com>
2026-03-30 11:49:29 +02:00
Alan Buscaglia 4ef0b1bf2c fix(ui): fix pre-commit hook skipping lint, tests, and build (#10494) 2026-03-30 10:44:59 +02:00
César Arroba de492a770c fix(ci): remove DOCKER_HUB_REPOSITORY secret from sdk container workflow (#10509) 2026-03-30 10:20:38 +02:00
César Arroba e9009f783b fix(ci): remove setup-buildx-action from create-manifest jobs (#10508) 2026-03-30 10:01:32 +02:00
Raajhesh Kannaa Chidambaram db1edf5ca7 feat(aws): add internet-exposed category to 13 checks (#10502)
Co-authored-by: Raajhesh Kannaa Chidambaram <495042+raajheshkannaa@users.noreply.github.com>
2026-03-30 08:59:29 +02:00
rchotacode 82d3ccec18 fix(oci): Add multi region filtering argument support (#10473)
Co-authored-by: Ronan Chota <ronan.chota@saic.com>
Co-authored-by: Daniel Barranquero <danielbo2001@gmail.com>
2026-03-30 08:45:16 +02:00
rchotacode ff46281f64 fix(oci): Fix service region support (#10472)
Co-authored-by: Ronan Chota <ronan.chota@saic.com>
2026-03-30 08:19:32 +02:00
Josema Camacho 94e234cefb fix(api): use raw FK ids in membership post_delete signal to avoid cascade lookup failures (#10497) 2026-03-27 16:16:28 +01:00
Pepe Fagoaga 8267fc4813 fix(step_security): keep notify in audit mode (#10496) 2026-03-27 16:01:24 +01:00
Josema Camacho 8bfeee238b feat(api): replace _provider_id property with label-based isolation and regex injection for custom queries (#10402) 2026-03-27 14:31:56 +01:00
Josema Camacho cc197ea901 feat(api): add periodic cleanup of stale Attack Paths scans with dead-worker detection (#10387) 2026-03-27 14:17:22 +01:00
Pepe Fagoaga 2b5d015e09 feat(security): add missing endpoints to allowlist (#10495)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-03-27 13:53:52 +01:00
Adrián Peña 73e0ac6892 chore: update dependencies (#10492) 2026-03-27 13:13:47 +01:00
Adrián Peña 700b51ddad chore: update Python version references from 3.9 to 3.10 (#10493) 2026-03-27 13:13:36 +01:00
Pepe Fagoaga 417be55604 feat(security): block mode for hardened runners (#10482) 2026-03-27 13:08:59 +01:00
Hugo Pereira Brito f75ce7b4dd feat(ui): add OpenCypher query editor (#10445) 2026-03-27 10:58:48 +00:00
Hugo Pereira Brito 269d9dfe41 feat(cli): add --resource-group flag to filter checks by resource group (#10479)
Co-authored-by: Daniel Barranquero <danielbo2001@gmail.com>
2026-03-27 11:55:28 +01:00
Apoorv Darshan 7b0ce7842b fix: remove return statements from finally blocks (#10102)
Co-authored-by: Pepe Fagoaga <pepe@prowler.com>
2026-03-27 09:23:15 +01:00
Terry Franklin 0a11ca4a68 feat(celery): VALKEY_SCHEME environment variable (#10420)
Co-authored-by: Pepe Fagoaga <pepe@prowler.com>
2026-03-27 09:22:35 +01:00
Adrián Peña c953fa7e67 fix(api): resolve check_title filter to check_id for consistent finding-group counts (#10486) 2026-03-27 09:05:02 +01:00
Pepe Fagoaga 73907db856 fix(trivy-scan): don't comment if PR from fork (#10490) 2026-03-27 08:37:19 +01:00
Raajhesh Kannaa Chidambaram 041f95b3df feat(ec2): add check for SG ingress from public IPs to any port (#10335)
Co-authored-by: Raajhesh Kannaa Chidambaram <495042+raajheshkannaa@users.noreply.github.com>
Co-authored-by: Daniel Barranquero <danielbo2001@gmail.com>
2026-03-26 17:21:16 +01:00
stepsecurity-app[bot] 716c130140 feat(security): security best practices from StepSecurity (#10480)
Signed-off-by: StepSecurity Bot <bot@stepsecurity.io>
Co-authored-by: stepsecurity-app[bot] <188008098+stepsecurity-app[bot]@users.noreply.github.com>
Co-authored-by: Pepe Fagoaga <pepe@prowler.com>
2026-03-26 13:58:19 +01:00
Hugo Pereira Brito c651f60e3a feat(m365): add entra_conditional_access_policy_mdm_compliant_device_required check (#10220)
Co-authored-by: Daniel Barranquero <danielbo2001@gmail.com>
2026-03-26 11:36:30 +01:00
Adrián Peña dd00d71a07 fix(api): fix finding groups muted filter, counters and reaggregation (#10477) 2026-03-26 10:35:21 +01:00
Sandiyo Christan 834d1bca49 feat(awslambda): enrich Function model with inventory fields and add 3 security checks (#10381)
Co-authored-by: Daniel Barranquero <danielbo2001@gmail.com>
2026-03-26 10:33:39 +01:00
Davidm4r 2cf45c72b6 fix(api): remove MANAGE_ACCOUNT permission requirement for listing or create a tenant (#10468) 2026-03-26 09:41:16 +01:00
Pepe Fagoaga 213e18724d fix: Prowler's changelog (#10475) 2026-03-25 16:07:45 +01:00
Pepe Fagoaga 571141f57c fix(aws): set partition's region for global services (#10458) 2026-03-25 15:47:51 +01:00
Adrián Peña 45f0909c3e chore(api): pin all unpinned dependencies to exact versions (#10469) 2026-03-25 13:27:04 +01:00
Alan Buscaglia b01fcc6cb2 fix(ui): refine filter clear and undo behavior in Findings page (#10446) 2026-03-25 13:20:10 +01:00
Adrián Peña 2ddd5b3091 chore: bump minimum Python to 3.10 and pin SDK dependencies (#10464) 2026-03-25 12:32:28 +01:00
Raajhesh Kannaa Chidambaram 6100932c60 feat(glue): add check for plaintext secrets in ETL job arguments (#10368)
Co-authored-by: Raajhesh Kannaa Chidambaram <495042+raajheshkannaa@users.noreply.github.com>
Co-authored-by: Daniel Barranquero <danielbo2001@gmail.com>
2026-03-25 12:25:36 +01:00
lydiavilchez 1c2b146e6e fix(docs): replace Google Workspace customer ID image with English version (#10467) 2026-03-25 11:49:30 +01:00
McRolly NWANGWU 833f3779ef feat(cloudfront): detect Standard Logging v2 via CloudWatch Log Delivery (#10090)
Co-authored-by: Pepe Fagoaga <pepe@prowler.com>
2026-03-25 10:09:21 +00:00
Daniel Barranquero c752811666 fix(oci): false positive for kms key rotation check (#10450) 2026-03-25 11:09:02 +01:00
Daniel Barranquero 4d1f7626f9 fix(oci): false positive for password policies (#10453) 2026-03-25 10:52:31 +01:00
Davidm4r 9bf2a13177 fix: resolve 403 error for admin users listing tenants (#10460) 2026-03-25 10:13:54 +01:00
Josema Camacho d15e67e2e5 fix(api): filter neo4j.io defunct connection logs in Sentry before_send (#10452) 2026-03-25 09:55:12 +01:00
Pepe Fagoaga 20cf5562b8 chore: update org members (#10461) 2026-03-25 09:36:10 +01:00
Pepe Fagoaga 36279f694c chore(gha): ignore zizmor rules and fix version comment (#10459) 2026-03-25 09:09:36 +01:00
César Arroba c991a1d0e8 chore: fix UI bump version (#10451) 2026-03-24 17:39:49 +01:00
Adrián Peña aa3641718b fix(api): populate compliance data in check_metadata for findings (#10449) 2026-03-24 17:19:53 +01:00
Adrián Peña bb80797392 fix(api): support finding-group aggregated filters (#10428) 2026-03-24 16:39:26 +01:00
Hugo Pereira Brito 435624fcd4 fix(sdk): support renamed OCI IdP mapping events (#10416) 2026-03-24 13:18:16 +00:00
Felix Dreissig 9e67f31913 feat(gcp): Add checks for GCP Gemini (Generative Language) API (#10280)
Co-authored-by: Daniel Barranquero <danielbo2001@gmail.com>
2026-03-24 14:11:27 +01:00
Prowler Bot 0984cfd75b chore(api): Bump version to v1.24.0 (#10440)
Co-authored-by: prowler-bot <179230569+prowler-bot@users.noreply.github.com>
2026-03-24 14:05:48 +01:00
Prowler Bot c1044ef491 chore(release): Bump version to v5.23.0 (#10439)
Co-authored-by: prowler-bot <179230569+prowler-bot@users.noreply.github.com>
2026-03-24 14:05:05 +01:00
Prowler Bot 19c4c9251c docs: Update version to v5.22.0 (#10441)
Co-authored-by: prowler-bot <179230569+prowler-bot@users.noreply.github.com>
2026-03-24 14:03:47 +01:00
Josema Camacho 55ed7a0663 docs(CHANGELOG): cutting for 5.22.0 (#10437) 2026-03-24 12:15:44 +01:00
Alan Buscaglia 0599040d4e feat(ui): add batch apply pattern to Findings filters (#10388) 2026-03-24 11:09:11 +01:00
lydiavilchez 737d20d2c1 docs(googleworkspace): add Cloud/App documentation (#10421)
Co-authored-by: Andoni A. <14891798+andoniaf@users.noreply.github.com>
2026-03-24 09:48:01 +01:00
Josema Camacho 844efbd046 perf(api): deduplicate nodes before ProwlerFinding lookup in Attack Paths queries (#10424) 2026-03-23 17:16:15 +01:00
Josema Camacho d60b4f0f52 fix(api): Update Flask and Werkzeug to address vulnerabilities (#10430) 2026-03-23 16:59:03 +01:00
Hugo Pereira Brito 49ba25ba07 feat(ui): add custom attack paths queries (#10397) 2026-03-23 15:36:37 +00:00
Daniel Barranquero 41629137ef docs: remove cookbook from k8s section (#10427) 2026-03-23 16:22:54 +01:00
Hugo Pereira Brito 114e86c0dc fix(sdk): ignore disabled users in Entra MFA check (#10426) 2026-03-23 15:21:31 +00:00
Prowler Bot 1015f1379f feat(aws): Update regions for AWS services (#10413)
Co-authored-by: prowler-bot <179230569+prowler-bot@users.noreply.github.com>
2026-03-23 15:28:51 +01:00
Prowler Bot c62ac6c71b feat(aws): Update regions for AWS services (#10076)
Co-authored-by: prowler-bot <179230569+prowler-bot@users.noreply.github.com>
2026-03-23 15:26:29 +01:00
Daniel Barranquero 14356e3187 docs: add cookbooks section (#10410)
Co-authored-by: Andoni A. <14891798+andoniaf@users.noreply.github.com>
2026-03-23 13:51:07 +01:00
Adrián Peña 591f5a8603 fix(api): align finding-group latest aggregation (#10419) 2026-03-23 12:43:45 +01:00
mintlify[bot] 93b8a7c74c docs(attack-paths): Lighthouse AI support and supported queries to Attack Paths (#10409)
Co-authored-by: mintlify[bot] <109931778+mintlify[bot]@users.noreply.github.com>
Co-authored-by: Josema Camacho <josema@prowler.com>
2026-03-23 11:12:26 +01:00
Hugo Pereira Brito 7df73a9d4f fix(sdk): use case-insensitive comparison for Azure MySQL flexible server checks (#10396) 2026-03-23 09:59:14 +00:00
Hugo Pereira Brito 1eda94140d fix(sdk): use case-insensitive comparison for Azure VM backup checks (#10395) 2026-03-23 09:45:08 +00:00
Adrián Peña ad6368a446 chore: add defusedxml as api dependency (#10401) 2026-03-19 18:26:55 +01:00
Adrián Peña 3361393b7d chore: update changelog (#10400) 2026-03-19 17:55:18 +01:00
Sandiyo Christan 0b7a21a70c fix(api): [security] use defusedxml to prevent XML bomb DoS in SAML metadata parsing (#10165)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: Adrián Peña <adrianjpr@gmail.com>
2026-03-19 17:44:52 +01:00
Josema Camacho 872e6e239c perf(api): replace JOINs with pre-check in threat score aggregation query (#10394) 2026-03-19 17:30:06 +01:00
Adrián Peña 2fe92cfce3 feat(api): add check title search for finding groups (#10377) 2026-03-19 16:48:26 +01:00
César Arroba cece2cb87e chore: pin Prowler version to lastest master commit on push (#10384) 2026-03-19 14:32:38 +01:00
Adrián Peña ab266080d0 perf(api): add trigram indexes for finding groups (#10378) 2026-03-19 13:54:50 +01:00
Prowler Bot 4638b39ed4 chore(api): Bump version to v1.23.0 (#10393)
Co-authored-by: prowler-bot <179230569+prowler-bot@users.noreply.github.com>
2026-03-19 13:42:46 +01:00
Prowler Bot 997f9bf64a docs: Update version to v5.21.0 (#10391)
Co-authored-by: prowler-bot <179230569+prowler-bot@users.noreply.github.com>
2026-03-19 13:40:33 +01:00
Prowler Bot aecc234f78 chore(release): Bump version to v5.22.0 (#10389)
Co-authored-by: prowler-bot <179230569+prowler-bot@users.noreply.github.com>
2026-03-19 13:40:22 +01:00
Pepe Fagoaga 8317eff67b chore(changelog): prepare for v5.21.0 (#10380) 2026-03-19 11:09:51 +01:00
Rubén De la Torre Vico 5c4ee0bc48 chore(mcp): bump MCP server version to 0.5.0 (#10383) 2026-03-19 10:47:46 +01:00
rchotacode 0f2fdcfb3f chore(oraclecloud): Add Oracle Defense Cloud Support (#10376)
Co-authored-by: Ronan Chota <ronan.chota@saic.com>
Co-authored-by: Hugo Pereira Brito <101209179+HugoPBrito@users.noreply.github.com>
Co-authored-by: Hugo P.Brito <hugopbrit@gmail.com>
2026-03-19 09:41:58 +00:00
Rubén De la Torre Vico 11a8873155 feat(ui): add attack path custom query skill for Lighthouse AI (#10323)
Co-authored-by: alejandrobailo <alejandrobailo94@gmail.com>
2026-03-18 19:35:50 +01:00
HugoPBrito 11ff512607 feat(m365): add entra_conditional_access_policy_unknown_device_blocked security check
Add new security check entra_conditional_access_policy_unknown_device_blocked for m365 provider.
Includes check implementation, metadata, and unit tests.
2026-03-03 13:28:35 +01:00
767 changed files with 50359 additions and 7102 deletions
+3
View File
@@ -78,6 +78,9 @@ TASK_RETRY_ATTEMPTS=5
# Valkey settings
# If running Valkey and celery on host, use localhost, else use 'valkey'
VALKEY_SCHEME=redis
VALKEY_USERNAME=
VALKEY_PASSWORD=
VALKEY_HOST=valkey
VALKEY_PORT=6379
VALKEY_DB=0
+4 -1
View File
@@ -117,7 +117,10 @@ runs:
INPUTS_IMAGE_TAG: ${{ inputs.image-tag }}
- name: Comment scan results on PR
if: inputs.create-pr-comment == 'true' && github.event_name == 'pull_request'
if: >-
inputs.create-pr-comment == 'true'
&& github.event_name == 'pull_request'
&& github.event.pull_request.head.repo.full_name == github.repository
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
env:
IMAGE_NAME: ${{ inputs.image-name }}
+7
View File
@@ -67,6 +67,11 @@ provider/googleworkspace:
- any-glob-to-any-file: "prowler/providers/googleworkspace/**"
- any-glob-to-any-file: "tests/providers/googleworkspace/**"
provider/vercel:
- changed-files:
- any-glob-to-any-file: "prowler/providers/vercel/**"
- any-glob-to-any-file: "tests/providers/vercel/**"
github_actions:
- changed-files:
- any-glob-to-any-file: ".github/workflows/*"
@@ -102,6 +107,8 @@ mutelist:
- any-glob-to-any-file: "tests/providers/openstack/lib/mutelist/**"
- any-glob-to-any-file: "prowler/providers/googleworkspace/lib/mutelist/**"
- any-glob-to-any-file: "tests/providers/googleworkspace/lib/mutelist/**"
- any-glob-to-any-file: "prowler/providers/vercel/lib/mutelist/**"
- any-glob-to-any-file: "tests/providers/vercel/lib/mutelist/**"
integration/s3:
- changed-files:
+8
View File
@@ -177,6 +177,14 @@ modules:
- tests/providers/llm/**
e2e: []
- name: sdk-vercel
match:
- prowler/providers/vercel/**
- prowler/compliance/vercel/**
tests:
- tests/providers/vercel/**
e2e: []
# ============================================
# SDK - Lib modules
# ============================================
+15
View File
@@ -27,6 +27,11 @@ jobs:
patch_version: ${{ steps.detect.outputs.patch_version }}
current_api_version: ${{ steps.get_api_version.outputs.current_api_version }}
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0
with:
egress-policy: audit
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
@@ -79,6 +84,11 @@ jobs:
contents: read
pull-requests: write
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0
with:
egress-policy: audit
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
@@ -204,6 +214,11 @@ jobs:
contents: read
pull-requests: write
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0
with:
egress-policy: audit
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
+10
View File
@@ -32,6 +32,16 @@ jobs:
working-directory: ./api
steps:
- name: Harden Runner
uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0
with:
egress-policy: block
allowed-endpoints: >
github.com:443
pypi.org:443
files.pythonhosted.org:443
api.github.com:443
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
+12
View File
@@ -41,6 +41,18 @@ jobs:
- 'python'
steps:
- name: Harden Runner
uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0
with:
egress-policy: block
allowed-endpoints: >
api.github.com:443
github.com:443
release-assets.githubusercontent.com:443
uploads.github.com:443
release-assets.githubusercontent.com:443
objects.githubusercontent.com:443
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
+66 -6
View File
@@ -18,9 +18,6 @@ on:
required: true
type: string
permissions:
contents: read
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: false
@@ -43,7 +40,14 @@ jobs:
timeout-minutes: 5
outputs:
short-sha: ${{ steps.set-short-sha.outputs.short-sha }}
permissions:
contents: read
steps:
- name: Harden Runner
uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0
with:
egress-policy: block
- name: Calculate short SHA
id: set-short-sha
run: echo "short-sha=${GITHUB_SHA::7}" >> $GITHUB_OUTPUT
@@ -55,7 +59,14 @@ jobs:
timeout-minutes: 5
outputs:
message-ts: ${{ steps.slack-notification.outputs.ts }}
permissions:
contents: read
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0
with:
egress-policy: audit
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
@@ -94,11 +105,37 @@ jobs:
packages: write
steps:
- name: Harden Runner
uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0
with:
egress-policy: block
allowed-endpoints: >
_http._tcp.deb.debian.org:443
aka.ms:443
auth.docker.io:443
cdn.powershellgallery.com:443
dc.services.visualstudio.com:443
debian.map.fastlydns.net:80
files.pythonhosted.org:443
github.com:443
powershellinfraartifacts-gkhedzdeaghdezhr.z01.azurefd.net:443
production.cloudflare.docker.com:443
pypi.org:443
registry-1.docker.io:443
release-assets.githubusercontent.com:443
www.powershellgallery.com:443
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Pin prowler SDK to latest master commit
if: github.event_name == 'push'
run: |
LATEST_SHA=$(git ls-remote https://github.com/prowler-cloud/prowler.git refs/heads/master | cut -f1)
sed -i "s|prowler-cloud/prowler.git@master|prowler-cloud/prowler.git@${LATEST_SHA}|" api/pyproject.toml
- name: Login to DockerHub
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
with:
@@ -126,17 +163,26 @@ jobs:
needs: [setup, container-build-push]
if: always() && needs.setup.result == 'success' && needs.container-build-push.result == 'success'
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- name: Harden Runner
uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0
with:
egress-policy: block
allowed-endpoints: >
github.com:443
release-assets.githubusercontent.com:443
registry-1.docker.io:443
auth.docker.io:443
production.cloudflare.docker.com:443
- name: Login to DockerHub
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
- name: Create and push manifests for push event
if: github.event_name == 'push'
run: |
@@ -178,7 +224,14 @@ jobs:
needs: [setup, notify-release-started, container-build-push, create-manifest]
runs-on: ubuntu-latest
timeout-minutes: 5
permissions:
contents: read
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0
with:
egress-policy: audit
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
@@ -221,6 +274,13 @@ jobs:
contents: read
steps:
- name: Harden Runner
uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0
with:
egress-policy: block
allowed-endpoints: >
api.github.com:443
- name: Trigger API deployment
uses: peter-evans/repository-dispatch@28959ce8df70de7be546dd1250a005dd32156697 # v4.0.1
with:
@@ -27,6 +27,13 @@ jobs:
contents: read
steps:
- name: Harden Runner
uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0
with:
egress-policy: block
allowed-endpoints: >
github.com:443
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
@@ -65,6 +72,30 @@ jobs:
pull-requests: write
steps:
- name: Harden Runner
uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0
with:
egress-policy: block
allowed-endpoints: >
api.github.com:443
mirror.gcr.io:443
check.trivy.dev:443
github.com:443
registry-1.docker.io:443
auth.docker.io:443
production.cloudflare.docker.com:443
debian.map.fastlydns.net:80
release-assets.githubusercontent.com:443
objects.githubusercontent.com:443
pypi.org:443
files.pythonhosted.org:443
www.powershellgallery.com:443
aka.ms:443
cdn.powershellgallery.com:443
_http._tcp.deb.debian.org:443
powershellinfraartifacts-gkhedzdeaghdezhr.z01.azurefd.net:443
get.trivy.dev:443
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
+13
View File
@@ -32,6 +32,19 @@ jobs:
working-directory: ./api
steps:
- name: Harden Runner
uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0
with:
egress-policy: block
allowed-endpoints: >
pypi.org:443
files.pythonhosted.org:443
github.com:443
auth.safetycli.com:443
pyup.io:443
data.safetycli.com:443
api.github.com:443
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
+19
View File
@@ -22,6 +22,9 @@ env:
POSTGRES_USER: prowler_user
POSTGRES_PASSWORD: prowler
POSTGRES_DB: postgres-db
VALKEY_SCHEME: redis
VALKEY_USERNAME: ""
VALKEY_PASSWORD: ""
VALKEY_HOST: localhost
VALKEY_PORT: 6379
VALKEY_DB: 0
@@ -72,6 +75,22 @@ jobs:
--health-retries 5
steps:
- name: Harden Runner
uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0
with:
egress-policy: block
allowed-endpoints: >
github.com:443
pypi.org:443
files.pythonhosted.org:443
cli.codecov.io:443
keybase.io:443
ingest.codecov.io:443
storage.googleapis.com:443
o26192.ingest.us.sentry.io:443
api.github.com:443
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
+7
View File
@@ -27,6 +27,13 @@ jobs:
pull-requests: write
steps:
- name: Harden Runner
uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0
with:
egress-policy: block
allowed-endpoints: >
api.github.com:443
- name: Check labels
id: label_check
uses: agilepathway/label-checker@c3d16ad512e7cea5961df85ff2486bb774caf3c5 # v1.6.65
+10
View File
@@ -33,6 +33,16 @@ jobs:
actions: read
steps:
- name: Harden Runner
uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0
with:
egress-policy: block
allowed-endpoints: >
github.com:443
ghcr.io:443
pkg-containers.githubusercontent.com:443
api.github.com:443
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
@@ -19,6 +19,11 @@ jobs:
pull-requests: write
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0
with:
egress-policy: audit
- name: Remove 'status/awaiting-response' label
env:
GH_TOKEN: ${{ github.token }}
@@ -25,6 +25,11 @@ jobs:
pull-requests: read
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0
with:
egress-policy: audit
- name: Check PR title format
uses: agenthunt/conventional-commit-checker-action@f1823f632e95a64547566dcd2c7da920e67117ad # v2.0.1
with:
@@ -22,6 +22,11 @@ jobs:
issues: write
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0
with:
egress-policy: audit
- name: Create backport label for minor releases
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+15
View File
@@ -27,6 +27,11 @@ jobs:
patch_version: ${{ steps.detect.outputs.patch_version }}
current_docs_version: ${{ steps.get_docs_version.outputs.current_docs_version }}
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0
with:
egress-policy: audit
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
@@ -79,6 +84,11 @@ jobs:
contents: read
pull-requests: write
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0
with:
egress-policy: audit
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
@@ -204,6 +214,11 @@ jobs:
contents: read
pull-requests: write
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0
with:
egress-policy: audit
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
+9
View File
@@ -22,6 +22,15 @@ jobs:
contents: read
steps:
- name: Harden Runner
uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0
with:
egress-policy: block
allowed-endpoints: >
github.com:443
ghcr.io:443
pkg-containers.githubusercontent.com:443
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
+5
View File
@@ -30,6 +30,11 @@ jobs:
contents: read
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0
with:
egress-policy: audit
- name: Checkout repository
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
with:
+5
View File
@@ -23,6 +23,11 @@ jobs:
packages: write
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0
with:
egress-policy: audit
- name: Checkout repository
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
with:
+51
View File
@@ -0,0 +1,51 @@
name: 'Tools: Lock Issue on Close'
on:
issues:
types:
- closed
concurrency:
group: ${{ github.workflow }}-${{ github.event.issue.number }}
cancel-in-progress: false
jobs:
lock:
if: |
github.repository == 'prowler-cloud/prowler' &&
github.event.issue.locked == false
runs-on: ubuntu-latest
timeout-minutes: 5
permissions:
issues: write
steps:
- name: Harden Runner
uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0
with:
egress-policy: block
allowed-endpoints: >
api.github.com:443
- name: Comment and lock issue
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
with:
script: |
const { owner, repo } = context.repo;
const issue_number = context.payload.issue.number;
try {
await github.rest.issues.createComment({
owner,
repo,
issue_number,
body: 'This issue is now locked as it has been closed. If you are still hitting a related problem, please open a new issue and link back to this one for context. Thanks!'
});
} catch (error) {
core.warning(`Failed to post lock comment on issue #${issue_number}: ${error.message}`);
}
const lockParams = { owner, repo, issue_number };
if (context.payload.issue.state_reason === 'completed') {
lockParams.lock_reason = 'resolved';
}
await github.rest.issues.lock(lockParams);
+30
View File
@@ -65,6 +65,11 @@ jobs:
text: ${{ steps.compute-text.outputs.text }}
title: ${{ steps.compute-text.outputs.title }}
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0
with:
egress-policy: audit
- name: Setup Scripts
uses: github/gh-aw/actions/setup@9382be3ca9ac18917e111a99d4e6bbff58d0dccc # v0.43.23
with:
@@ -129,6 +134,11 @@ jobs:
output_types: ${{ steps.collect_output.outputs.output_types }}
secret_verification_result: ${{ steps.validate-secret.outputs.verification_result }}
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0
with:
egress-policy: audit
- name: Setup Scripts
uses: github/gh-aw/actions/setup@9382be3ca9ac18917e111a99d4e6bbff58d0dccc # v0.43.23
with:
@@ -859,6 +869,11 @@ jobs:
tools_reported: ${{ steps.missing_tool.outputs.tools_reported }}
total_count: ${{ steps.missing_tool.outputs.total_count }}
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0
with:
egress-policy: audit
- name: Setup Scripts
uses: github/gh-aw/actions/setup@9382be3ca9ac18917e111a99d4e6bbff58d0dccc # v0.43.23
with:
@@ -966,6 +981,11 @@ jobs:
outputs:
success: ${{ steps.parse_results.outputs.success }}
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0
with:
egress-policy: audit
- name: Setup Scripts
uses: github/gh-aw/actions/setup@9382be3ca9ac18917e111a99d4e6bbff58d0dccc # v0.43.23
with:
@@ -1070,6 +1090,11 @@ jobs:
outputs:
activated: ${{ (steps.check_membership.outputs.is_team_member == 'true') && (steps.check_rate_limit.outputs.rate_limit_ok == 'true') }}
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0
with:
egress-policy: audit
- name: Setup Scripts
uses: github/gh-aw/actions/setup@9382be3ca9ac18917e111a99d4e6bbff58d0dccc # v0.43.23
with:
@@ -1138,6 +1163,11 @@ jobs:
process_safe_outputs_processed_count: ${{ steps.process_safe_outputs.outputs.processed_count }}
process_safe_outputs_temporary_id_map: ${{ steps.process_safe_outputs.outputs.temporary_id_map }}
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0
with:
egress-policy: audit
- name: Setup Scripts
uses: github/gh-aw/actions/setup@9382be3ca9ac18917e111a99d4e6bbff58d0dccc # v0.43.23
with:
+12 -1
View File
@@ -24,6 +24,11 @@ jobs:
pull-requests: write
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0
with:
egress-policy: audit
- name: Apply labels to PR
uses: actions/labeler@634933edcd8ababfe52f92936142cc22ac488b1b # v6.0.1
with:
@@ -38,6 +43,11 @@ jobs:
pull-requests: write
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0
with:
egress-policy: audit
- name: Check if author is org member
id: check_membership
env:
@@ -65,7 +75,8 @@ jobs:
"RosaRivasProwler"
"StylusFrost"
"toniblyx"
"vicferpoy"
"davidm4r"
"pfe-nazaries"
)
echo "Checking if $AUTHOR is a member of prowler-cloud organization"
+55 -6
View File
@@ -17,9 +17,6 @@ on:
required: true
type: string
permissions:
contents: read
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: false
@@ -42,7 +39,14 @@ jobs:
timeout-minutes: 5
outputs:
short-sha: ${{ steps.set-short-sha.outputs.short-sha }}
permissions:
contents: read
steps:
- name: Harden Runner
uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0
with:
egress-policy: block
- name: Calculate short SHA
id: set-short-sha
run: echo "short-sha=${GITHUB_SHA::7}" >> $GITHUB_OUTPUT
@@ -54,7 +58,14 @@ jobs:
timeout-minutes: 5
outputs:
message-ts: ${{ steps.slack-notification.outputs.ts }}
permissions:
contents: read
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0
with:
egress-policy: audit
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
@@ -92,6 +103,20 @@ jobs:
contents: read
packages: write
steps:
- name: Harden Runner
uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0
with:
egress-policy: block
allowed-endpoints: >
github.com:443
registry-1.docker.io:443
auth.docker.io:443
production.cloudflare.docker.com:443
ghcr.io:443
pkg-containers.githubusercontent.com:443
files.pythonhosted.org:443
pypi.org:443
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
@@ -132,17 +157,27 @@ jobs:
needs: [setup, container-build-push]
if: always() && needs.setup.result == 'success' && needs.container-build-push.result == 'success'
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- name: Harden Runner
uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0
with:
egress-policy: block
allowed-endpoints: >
registry-1.docker.io:443
auth.docker.io:443
production.cloudflare.docker.com:443
github.com:443
release-assets.githubusercontent.com:443
- name: Login to DockerHub
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
- name: Create and push manifests for push event
if: github.event_name == 'push'
run: |
@@ -184,7 +219,14 @@ jobs:
needs: [setup, notify-release-started, container-build-push, create-manifest]
runs-on: ubuntu-latest
timeout-minutes: 5
permissions:
contents: read
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0
with:
egress-policy: audit
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
@@ -227,6 +269,13 @@ jobs:
contents: read
steps:
- name: Harden Runner
uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0
with:
egress-policy: block
allowed-endpoints: >
api.github.com:443
- name: Trigger MCP deployment
uses: peter-evans/repository-dispatch@28959ce8df70de7be546dd1250a005dd32156697 # v4.0.1
with:
@@ -27,6 +27,13 @@ jobs:
contents: read
steps:
- name: Harden Runner
uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0
with:
egress-policy: block
allowed-endpoints: >
github.com:443
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
@@ -64,6 +71,23 @@ jobs:
pull-requests: write
steps:
- name: Harden Runner
uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0
with:
egress-policy: block
allowed-endpoints: >
github.com:443
registry-1.docker.io:443
auth.docker.io:443
production.cloudflare.docker.com:443
ghcr.io:443
pkg-containers.githubusercontent.com:443
files.pythonhosted.org:443
pypi.org:443
api.github.com:443
mirror.gcr.io:443
check.trivy.dev:443
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
+11 -1
View File
@@ -26,6 +26,11 @@ jobs:
major_version: ${{ steps.parse-version.outputs.major }}
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0
with:
egress-policy: audit
- name: Parse and validate version
id: parse-version
run: |
@@ -59,13 +64,18 @@ jobs:
url: https://pypi.org/project/prowler-mcp/
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0
with:
egress-policy: audit
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Install uv
uses: astral-sh/setup-uv@5a095e7a2014a4212f075830d4f7277575a9d098 # v7
uses: astral-sh/setup-uv@5a095e7a2014a4212f075830d4f7277575a9d098 # v7.3.1
with:
enable-cache: false
+8
View File
@@ -28,6 +28,14 @@ jobs:
MONITORED_FOLDERS: 'api ui prowler mcp_server'
steps:
- name: Harden Runner
uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0
with:
egress-policy: block
allowed-endpoints: >
api.github.com:443
github.com:443
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
@@ -0,0 +1,180 @@
name: 'Tools: Check Compliance Mapping'
on:
pull_request:
types:
- 'opened'
- 'synchronize'
- 'reopened'
- 'labeled'
- 'unlabeled'
branches:
- 'master'
- 'v5.*'
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number }}
cancel-in-progress: true
jobs:
check-compliance-mapping:
if: contains(github.event.pull_request.labels.*.name, 'no-compliance-check') == false
runs-on: ubuntu-latest
timeout-minutes: 15
permissions:
contents: read
pull-requests: write
steps:
- name: Harden Runner
uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0
with:
egress-policy: block
allowed-endpoints: >
api.github.com:443
github.com:443
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
fetch-depth: 0
# zizmor: ignore[artipacked]
persist-credentials: true # Required by tj-actions/changed-files to fetch PR branch
- name: Get changed files
id: changed-files
uses: tj-actions/changed-files@7dee1b0c1557f278e5c7dc244927139d78c0e22a # v47.0.4
with:
files: |
prowler/providers/**/services/**/*.metadata.json
prowler/compliance/**/*.json
- name: Check if new checks are mapped in compliance
id: compliance-check
run: |
ADDED_METADATA="${STEPS_CHANGED_FILES_OUTPUTS_ADDED_FILES}"
ALL_CHANGED="${STEPS_CHANGED_FILES_OUTPUTS_ALL_CHANGED_FILES}"
# Filter only new metadata files (new checks)
new_checks=""
for f in $ADDED_METADATA; do
case "$f" in *.metadata.json) new_checks="$new_checks $f" ;; esac
done
if [ -z "$(echo "$new_checks" | tr -d ' ')" ]; then
echo "No new checks detected."
echo "has_new_checks=false" >> "$GITHUB_OUTPUT"
exit 0
fi
# Collect compliance files changed in this PR
changed_compliance=""
for f in $ALL_CHANGED; do
case "$f" in prowler/compliance/*.json) changed_compliance="$changed_compliance $f" ;; esac
done
UNMAPPED=""
MAPPED=""
for metadata_file in $new_checks; do
check_dir=$(dirname "$metadata_file")
check_id=$(basename "$check_dir")
provider=$(echo "$metadata_file" | cut -d'/' -f3)
# Read CheckID from the metadata JSON for accuracy
if [ -f "$metadata_file" ]; then
json_check_id=$(python3 -c "import json; print(json.load(open('$metadata_file')).get('CheckID', ''))" 2>/dev/null || echo "")
if [ -n "$json_check_id" ]; then
check_id="$json_check_id"
fi
fi
# Search for the check ID in compliance files changed in this PR
found_in=""
for comp_file in $changed_compliance; do
if grep -q "\"${check_id}\"" "$comp_file" 2>/dev/null; then
found_in="${found_in}$(basename "$comp_file" .json), "
fi
done
if [ -n "$found_in" ]; then
found_in=$(echo "$found_in" | sed 's/, $//')
MAPPED="${MAPPED}- \`${check_id}\` (\`${provider}\`): ${found_in}"$'\n'
else
UNMAPPED="${UNMAPPED}- \`${check_id}\` (\`${provider}\`)"$'\n'
fi
done
echo "has_new_checks=true" >> "$GITHUB_OUTPUT"
if [ -n "$UNMAPPED" ]; then
echo "has_unmapped=true" >> "$GITHUB_OUTPUT"
else
echo "has_unmapped=false" >> "$GITHUB_OUTPUT"
fi
{
echo "unmapped<<EOF"
echo -e "${UNMAPPED}"
echo "EOF"
} >> "$GITHUB_OUTPUT"
{
echo "mapped<<EOF"
echo -e "${MAPPED}"
echo "EOF"
} >> "$GITHUB_OUTPUT"
env:
STEPS_CHANGED_FILES_OUTPUTS_ADDED_FILES: ${{ steps.changed-files.outputs.added_files }}
STEPS_CHANGED_FILES_OUTPUTS_ALL_CHANGED_FILES: ${{ steps.changed-files.outputs.all_changed_files }}
- name: Manage compliance review label
if: steps.compliance-check.outputs.has_new_checks == 'true'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
PR_NUMBER: ${{ github.event.pull_request.number }}
HAS_UNMAPPED: ${{ steps.compliance-check.outputs.has_unmapped }}
run: |
LABEL_NAME="needs-compliance-review"
if [ "$HAS_UNMAPPED" = "true" ]; then
echo "Adding compliance review label to PR #${PR_NUMBER}..."
gh pr edit "$PR_NUMBER" --add-label "$LABEL_NAME" --repo "${{ github.repository }}" || true
else
echo "Removing compliance review label from PR #${PR_NUMBER}..."
gh pr edit "$PR_NUMBER" --remove-label "$LABEL_NAME" --repo "${{ github.repository }}" || true
fi
- name: Find existing compliance comment
if: steps.compliance-check.outputs.has_new_checks == 'true' && github.event.pull_request.head.repo.full_name == github.repository
id: find-comment
uses: peter-evans/find-comment@b30e6a3c0ed37e7c023ccd3f1db5c6c0b0c23aad # v4.0.0
with:
issue-number: ${{ github.event.pull_request.number }}
comment-author: 'github-actions[bot]'
body-includes: '<!-- compliance-mapping-check -->'
- name: Create or update compliance comment
if: steps.compliance-check.outputs.has_new_checks == 'true' && github.event.pull_request.head.repo.full_name == github.repository
uses: peter-evans/create-or-update-comment@e8674b075228eee787fea43ef493e45ece1004c9 # v5.0.0
with:
issue-number: ${{ github.event.pull_request.number }}
comment-id: ${{ steps.find-comment.outputs.comment-id }}
edit-mode: replace
body: |
<!-- compliance-mapping-check -->
## Compliance Mapping Review
This PR adds new checks. Please verify that they have been mapped to the relevant compliance framework requirements.
${{ steps.compliance-check.outputs.unmapped != '' && format('### New checks not mapped to any compliance framework in this PR
{0}
> Please review whether these checks should be added to compliance framework requirements in `prowler/compliance/<provider>/`. Each compliance JSON has a `Checks` array inside each requirement — add the check ID there if it satisfies that requirement.', steps.compliance-check.outputs.unmapped) || '' }}
${{ steps.compliance-check.outputs.mapped != '' && format('### New checks already mapped in this PR
{0}', steps.compliance-check.outputs.mapped) || '' }}
Use the `no-compliance-check` label to skip this check.
@@ -25,6 +25,11 @@ jobs:
issues: write
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0
with:
egress-policy: audit
- name: Checkout PR head
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
+7
View File
@@ -23,6 +23,13 @@ jobs:
permissions:
contents: read
steps:
- name: Harden Runner
uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0
with:
egress-policy: block
allowed-endpoints: >
api.github.com:443
- name: Calculate short commit SHA
id: vars
run: |
+5
View File
@@ -26,6 +26,11 @@ jobs:
contents: write
pull-requests: write
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0
with:
egress-policy: audit
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
+15
View File
@@ -26,6 +26,11 @@ jobs:
minor_version: ${{ steps.detect.outputs.minor_version }}
patch_version: ${{ steps.detect.outputs.patch_version }}
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0
with:
egress-policy: audit
- name: Detect release type and parse version
id: detect
run: |
@@ -66,6 +71,11 @@ jobs:
contents: read
pull-requests: write
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0
with:
egress-policy: audit
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
@@ -175,6 +185,11 @@ jobs:
contents: read
pull-requests: write
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0
with:
egress-policy: audit
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
@@ -19,6 +19,13 @@ jobs:
contents: read
steps:
- name: Harden Runner
uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0
with:
egress-policy: block
allowed-endpoints: >
github.com:443
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
+9 -1
View File
@@ -24,12 +24,20 @@ jobs:
strategy:
matrix:
python-version:
- '3.9'
- '3.10'
- '3.11'
- '3.12'
steps:
- name: Harden Runner
uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0
with:
egress-policy: block
allowed-endpoints: >
github.com:443
pypi.org:443
files.pythonhosted.org:443
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
+10
View File
@@ -48,6 +48,16 @@ jobs:
- 'python'
steps:
- name: Harden Runner
uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0
with:
egress-policy: block
allowed-endpoints: >
api.github.com:443
github.com:443
release-assets.githubusercontent.com:443
uploads.github.com:443
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
+103 -11
View File
@@ -23,9 +23,6 @@ on:
required: true
type: string
permissions:
contents: read
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: false
@@ -45,6 +42,7 @@ env:
# Container registries
PROWLERCLOUD_DOCKERHUB_REPOSITORY: prowlercloud
PROWLERCLOUD_DOCKERHUB_IMAGE: prowler
TONIBLYX_DOCKERHUB_REPOSITORY: toniblyx
# AWS configuration (for ECR)
AWS_REGION: us-east-1
@@ -59,7 +57,18 @@ jobs:
prowler_version_major: ${{ steps.get-prowler-version.outputs.prowler_version_major }}
latest_tag: ${{ steps.get-prowler-version.outputs.latest_tag }}
stable_tag: ${{ steps.get-prowler-version.outputs.stable_tag }}
permissions:
contents: read
steps:
- name: Harden Runner
uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0
with:
egress-policy: block
allowed-endpoints: >
github.com:443
pypi.org:443
files.pythonhosted.org:443
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
@@ -115,7 +124,14 @@ jobs:
timeout-minutes: 5
outputs:
message-ts: ${{ steps.slack-notification.outputs.ts }}
permissions:
contents: read
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0
with:
egress-policy: audit
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
@@ -154,6 +170,27 @@ jobs:
packages: write
steps:
- name: Harden Runner
uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0
with:
egress-policy: block
allowed-endpoints: >
api.ecr-public.us-east-1.amazonaws.com:443
public.ecr.aws:443
registry-1.docker.io:443
production.cloudflare.docker.com:443
auth.docker.io:443
debian.map.fastlydns.net:80
github.com:443
release-assets.githubusercontent.com:443
pypi.org:443
files.pythonhosted.org:443
www.powershellgallery.com:443
aka.ms:443
cdn.powershellgallery.com:443
_http._tcp.deb.debian.org:443
powershellinfraartifacts-gkhedzdeaghdezhr.z01.azurefd.net:443
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
@@ -196,8 +233,24 @@ jobs:
needs: [setup, container-build-push]
if: always() && needs.setup.result == 'success' && needs.container-build-push.result == 'success'
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- name: Harden Runner
uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0
with:
egress-policy: block
allowed-endpoints: >
registry-1.docker.io:443
auth.docker.io:443
public.ecr.aws:443
production.cloudflare.docker.com:443
github.com:443
release-assets.githubusercontent.com:443
api.ecr-public.us-east-1.amazonaws.com:443
- name: Login to DockerHub
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
with:
@@ -213,15 +266,11 @@ jobs:
env:
AWS_REGION: ${{ env.AWS_REGION }}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
- name: Create and push manifests for push event
if: github.event_name == 'push'
run: |
docker buildx imagetools create \
-t ${{ env.PROWLERCLOUD_DOCKERHUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:${NEEDS_SETUP_OUTPUTS_LATEST_TAG} \
-t ${{ secrets.DOCKER_HUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:${NEEDS_SETUP_OUTPUTS_LATEST_TAG} \
-t ${{ secrets.PUBLIC_ECR_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:${NEEDS_SETUP_OUTPUTS_LATEST_TAG} \
${{ env.PROWLERCLOUD_DOCKERHUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:${NEEDS_SETUP_OUTPUTS_LATEST_TAG}-amd64 \
${{ env.PROWLERCLOUD_DOCKERHUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:${NEEDS_SETUP_OUTPUTS_LATEST_TAG}-arm64
@@ -232,12 +281,10 @@ jobs:
if: github.event_name == 'release' || github.event_name == 'workflow_dispatch'
run: |
docker buildx imagetools create \
-t ${{ secrets.DOCKER_HUB_REPOSITORY }}/${{ env.IMAGE_NAME }}:${NEEDS_SETUP_OUTPUTS_PROWLER_VERSION} \
-t ${{ secrets.DOCKER_HUB_REPOSITORY }}/${{ env.IMAGE_NAME }}:${NEEDS_SETUP_OUTPUTS_STABLE_TAG} \
-t ${{ secrets.PUBLIC_ECR_REPOSITORY }}/${{ env.IMAGE_NAME }}:${NEEDS_SETUP_OUTPUTS_PROWLER_VERSION} \
-t ${{ secrets.PUBLIC_ECR_REPOSITORY }}/${{ env.IMAGE_NAME }}:${NEEDS_SETUP_OUTPUTS_STABLE_TAG} \
-t ${{ env.PROWLERCLOUD_DOCKERHUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:${NEEDS_SETUP_OUTPUTS_PROWLER_VERSION} \
-t ${{ env.PROWLERCLOUD_DOCKERHUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:${NEEDS_SETUP_OUTPUTS_STABLE_TAG} \
-t ${{ secrets.PUBLIC_ECR_REPOSITORY }}/${{ env.IMAGE_NAME }}:${NEEDS_SETUP_OUTPUTS_PROWLER_VERSION} \
-t ${{ secrets.PUBLIC_ECR_REPOSITORY }}/${{ env.IMAGE_NAME }}:${NEEDS_SETUP_OUTPUTS_STABLE_TAG} \
${{ env.PROWLERCLOUD_DOCKERHUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:${NEEDS_SETUP_OUTPUTS_LATEST_TAG}-amd64 \
${{ env.PROWLERCLOUD_DOCKERHUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:${NEEDS_SETUP_OUTPUTS_LATEST_TAG}-arm64
env:
@@ -245,6 +292,39 @@ jobs:
NEEDS_SETUP_OUTPUTS_STABLE_TAG: ${{ needs.setup.outputs.stable_tag }}
NEEDS_SETUP_OUTPUTS_LATEST_TAG: ${{ needs.setup.outputs.latest_tag }}
# Push to toniblyx/prowler only for current version (latest/stable/release tags)
- name: Login to DockerHub (toniblyx)
if: needs.setup.outputs.latest_tag == 'latest'
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
with:
username: ${{ secrets.TONIBLYX_DOCKERHUB_USERNAME }}
password: ${{ secrets.TONIBLYX_DOCKERHUB_PASSWORD }}
- name: Push manifests to toniblyx for push event
if: needs.setup.outputs.latest_tag == 'latest' && github.event_name == 'push'
run: |
docker buildx imagetools create \
-t ${{ env.TONIBLYX_DOCKERHUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:latest \
${{ env.PROWLERCLOUD_DOCKERHUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:latest
- name: Push manifests to toniblyx for release event
if: needs.setup.outputs.latest_tag == 'latest' && (github.event_name == 'release' || github.event_name == 'workflow_dispatch')
run: |
docker buildx imagetools create \
-t ${{ env.TONIBLYX_DOCKERHUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:${NEEDS_SETUP_OUTPUTS_PROWLER_VERSION} \
-t ${{ env.TONIBLYX_DOCKERHUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:stable \
${{ env.PROWLERCLOUD_DOCKERHUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:stable
env:
NEEDS_SETUP_OUTPUTS_PROWLER_VERSION: ${{ needs.setup.outputs.prowler_version }}
# Re-login as prowlercloud for cleanup of intermediate tags
- name: Login to DockerHub (prowlercloud)
if: always()
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Install regctl
if: always()
uses: regclient/actions/regctl-installer@da9319db8e44e8b062b3a147e1dfb2f574d41a03 # main
@@ -264,7 +344,14 @@ jobs:
needs: [setup, notify-release-started, container-build-push, create-manifest]
runs-on: ubuntu-latest
timeout-minutes: 5
permissions:
contents: read
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0
with:
egress-policy: audit
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
@@ -307,6 +394,11 @@ jobs:
contents: read
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0
with:
egress-policy: audit
- name: Calculate short SHA
id: short-sha
run: echo "short_sha=${GITHUB_SHA::7}" >> $GITHUB_OUTPUT
@@ -26,6 +26,13 @@ jobs:
contents: read
steps:
- name: Harden Runner
uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0
with:
egress-policy: block
allowed-endpoints: >
github.com:443
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
@@ -64,6 +71,29 @@ jobs:
pull-requests: write
steps:
- name: Harden Runner
uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0
with:
egress-policy: block
allowed-endpoints: >
github.com:443
registry-1.docker.io:443
auth.docker.io:443
production.cloudflare.docker.com:443
api.github.com:443
mirror.gcr.io:443
check.trivy.dev:443
debian.map.fastlydns.net:80
release-assets.githubusercontent.com:443
pypi.org:443
files.pythonhosted.org:443
www.powershellgallery.com:443
aka.ms:443
cdn.powershellgallery.com:443
_http._tcp.deb.debian.org:443
powershellinfraartifacts-gkhedzdeaghdezhr.z01.azurefd.net:443
get.trivy.dev:443
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
+15
View File
@@ -25,6 +25,11 @@ jobs:
major_version: ${{ steps.parse-version.outputs.major }}
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0
with:
egress-policy: audit
- name: Parse and validate version
id: parse-version
run: |
@@ -58,6 +63,11 @@ jobs:
url: https://pypi.org/project/prowler/${{ needs.validate-release.outputs.prowler_version }}/
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0
with:
egress-policy: audit
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
@@ -91,6 +101,11 @@ jobs:
url: https://pypi.org/project/prowler-cloud/${{ needs.validate-release.outputs.prowler_version }}/
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0
with:
egress-policy: audit
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
@@ -24,6 +24,11 @@ jobs:
contents: write
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0
with:
egress-policy: audit
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
@@ -22,6 +22,11 @@ jobs:
contents: write
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0
with:
egress-policy: audit
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
@@ -72,12 +77,13 @@ jobs:
This PR updates the `OCI_COMMERCIAL_REGIONS` dictionary in `prowler/providers/oraclecloud/config.py` with the latest regions fetched from the OCI Identity API (`list_regions()`).
- Government regions (`OCI_GOVERNMENT_REGIONS`) are preserved unchanged
- DOD regions (`OCI_US_DOD_REGIONS`) are preserved unchanged
- Region display names are mapped from Oracle's official documentation
### Checklist
- [x] This is an automated update from OCI official sources
- [x] Government regions (us-langley-1, us-luke-1) preserved
- [x] Government regions (us-langley-1, us-luke-1) and DOD regions (us-gov-ashburn-1, us-gov-phoenix-1, us-gov-chicago-1) are preserved
- [x] No manual review of region data required
### License
+13
View File
@@ -23,6 +23,19 @@ jobs:
contents: read
steps:
- name: Harden Runner
uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0
with:
egress-policy: block
allowed-endpoints: >
pypi.org:443
files.pythonhosted.org:443
github.com:443
auth.safetycli.com:443
pyup.io:443
data.safetycli.com:443
api.github.com:443
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
+56 -3
View File
@@ -24,12 +24,41 @@ jobs:
strategy:
matrix:
python-version:
- '3.9'
- '3.10'
- '3.11'
- '3.12'
steps:
- name: Harden Runner
uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0
with:
egress-policy: block
allowed-endpoints: >
github.com:443
pypi.org:443
files.pythonhosted.org:443
api.github.com:443
release-assets.githubusercontent.com:443
*.amazonaws.com:443
*.googleapis.com:443
schema.ocsf.io:443
registry-1.docker.io:443
production.cloudflare.docker.com:443
powershellinfraartifacts-gkhedzdeaghdezhr.z01.azurefd.net:443
o26192.ingest.us.sentry.io:443
management.azure.com:443
login.microsoftonline.com:443
keybase.io:443
ingest.codecov.io:443
graph.microsoft.com:443
dc.services.visualstudio.com:443
cloud.mongodb.com:443
cli.codecov.io:443
auth.docker.io:443
api.vercel.com:443
api.atlassian.com:443
aka.ms:443
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
@@ -187,11 +216,11 @@ jobs:
echo "AWS service_paths='${STEPS_AWS_SERVICES_OUTPUTS_SERVICE_PATHS}'"
if [ "${STEPS_AWS_SERVICES_OUTPUTS_RUN_ALL}" = "true" ]; then
poetry run pytest -n auto --cov=./prowler/providers/aws --cov-report=xml:aws_coverage.xml tests/providers/aws
poetry run pytest -p no:randomly -n auto --cov=./prowler/providers/aws --cov-report=xml:aws_coverage.xml tests/providers/aws
elif [ -z "${STEPS_AWS_SERVICES_OUTPUTS_SERVICE_PATHS}" ]; then
echo "No AWS service paths detected; skipping AWS tests."
else
poetry run pytest -n auto --cov=./prowler/providers/aws --cov-report=xml:aws_coverage.xml ${STEPS_AWS_SERVICES_OUTPUTS_SERVICE_PATHS}
poetry run pytest -p no:randomly -n auto --cov=./prowler/providers/aws --cov-report=xml:aws_coverage.xml ${STEPS_AWS_SERVICES_OUTPUTS_SERVICE_PATHS}
fi
env:
STEPS_AWS_SERVICES_OUTPUTS_RUN_ALL: ${{ steps.aws-services.outputs.run_all }}
@@ -470,6 +499,30 @@ jobs:
flags: prowler-py${{ matrix.python-version }}-googleworkspace
files: ./googleworkspace_coverage.xml
# Vercel Provider
- name: Check if Vercel files changed
if: steps.check-changes.outputs.any_changed == 'true'
id: changed-vercel
uses: tj-actions/changed-files@7dee1b0c1557f278e5c7dc244927139d78c0e22a # v47.0.4
with:
files: |
./prowler/**/vercel/**
./tests/**/vercel/**
./poetry.lock
- name: Run Vercel tests
if: steps.changed-vercel.outputs.any_changed == 'true'
run: poetry run pytest -n auto --cov=./prowler/providers/vercel --cov-report=xml:vercel_coverage.xml tests/providers/vercel
- name: Upload Vercel coverage to Codecov
if: steps.changed-vercel.outputs.any_changed == 'true'
uses: codecov/codecov-action@671740ac38dd9b0130fbe1cec585b89eea48d3de # v5.5.2
env:
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
with:
flags: prowler-py${{ matrix.python-version }}-vercel
files: ./vercel_coverage.xml
# Lib
- name: Check if Lib files changed
if: steps.check-changes.outputs.any_changed == 'true'
@@ -45,8 +45,19 @@ jobs:
has-sdk-tests: ${{ steps.set-flags.outputs.has-sdk-tests }}
has-api-tests: ${{ steps.set-flags.outputs.has-api-tests }}
has-ui-e2e: ${{ steps.set-flags.outputs.has-ui-e2e }}
permissions:
contents: read
steps:
- name: Harden Runner
uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0
with:
egress-policy: block
allowed-endpoints: >
github.com:443
pypi.org:443
files.pythonhosted.org:443
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
+18 -3
View File
@@ -26,6 +26,11 @@ jobs:
minor_version: ${{ steps.detect.outputs.minor_version }}
patch_version: ${{ steps.detect.outputs.patch_version }}
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0
with:
egress-policy: audit
- name: Detect release type and parse version
id: detect
run: |
@@ -66,6 +71,11 @@ jobs:
contents: read
pull-requests: write
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0
with:
egress-policy: audit
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
@@ -89,7 +99,7 @@ jobs:
run: |
set -e
sed -i "s|NEXT_PUBLIC_PROWLER_RELEASE_VERSION=v${PROWLER_VERSION}|NEXT_PUBLIC_PROWLER_RELEASE_VERSION=v${NEXT_MINOR_VERSION}|" .env
sed -i "s|NEXT_PUBLIC_PROWLER_RELEASE_VERSION=.*|NEXT_PUBLIC_PROWLER_RELEASE_VERSION=v${NEXT_MINOR_VERSION}|" .env
echo "Files modified:"
git --no-pager diff
@@ -143,7 +153,7 @@ jobs:
run: |
set -e
sed -i "s|NEXT_PUBLIC_PROWLER_RELEASE_VERSION=v${PROWLER_VERSION}|NEXT_PUBLIC_PROWLER_RELEASE_VERSION=v${FIRST_PATCH_VERSION}|" .env
sed -i "s|NEXT_PUBLIC_PROWLER_RELEASE_VERSION=.*|NEXT_PUBLIC_PROWLER_RELEASE_VERSION=v${FIRST_PATCH_VERSION}|" .env
echo "Files modified:"
git --no-pager diff
@@ -179,6 +189,11 @@ jobs:
contents: read
pull-requests: write
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0
with:
egress-policy: audit
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
@@ -208,7 +223,7 @@ jobs:
run: |
set -e
sed -i "s|NEXT_PUBLIC_PROWLER_RELEASE_VERSION=v${PROWLER_VERSION}|NEXT_PUBLIC_PROWLER_RELEASE_VERSION=v${NEXT_PATCH_VERSION}|" .env
sed -i "s|NEXT_PUBLIC_PROWLER_RELEASE_VERSION=.*|NEXT_PUBLIC_PROWLER_RELEASE_VERSION=v${NEXT_PATCH_VERSION}|" .env
echo "Files modified:"
git --no-pager diff
+10
View File
@@ -44,6 +44,16 @@ jobs:
- 'javascript-typescript'
steps:
- name: Harden Runner
uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0
with:
egress-policy: block
allowed-endpoints: >
api.github.com:443
github.com:443
release-assets.githubusercontent.com:443
uploads.github.com:443
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
+55 -6
View File
@@ -17,9 +17,6 @@ on:
required: true
type: string
permissions:
contents: read
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: false
@@ -45,7 +42,14 @@ jobs:
timeout-minutes: 5
outputs:
short-sha: ${{ steps.set-short-sha.outputs.short-sha }}
permissions:
contents: read
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0
with:
egress-policy: audit
- name: Calculate short SHA
id: set-short-sha
run: echo "short-sha=${GITHUB_SHA::7}" >> $GITHUB_OUTPUT
@@ -57,7 +61,14 @@ jobs:
timeout-minutes: 5
outputs:
message-ts: ${{ steps.slack-notification.outputs.ts }}
permissions:
contents: read
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0
with:
egress-policy: audit
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
@@ -96,6 +107,20 @@ jobs:
packages: write
steps:
- name: Harden Runner
uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0
with:
egress-policy: block
allowed-endpoints: >
registry-1.docker.io:443
production.cloudflare.docker.com:443
auth.docker.io:443
registry.npmjs.org:443
dl-cdn.alpinelinux.org:443
fonts.googleapis.com:443
fonts.gstatic.com:443
github.com:443
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
@@ -131,17 +156,27 @@ jobs:
needs: [setup, container-build-push]
if: always() && needs.setup.result == 'success' && needs.container-build-push.result == 'success'
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- name: Harden Runner
uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0
with:
egress-policy: block
allowed-endpoints: >
github.com:443
release-assets.githubusercontent.com:443
registry-1.docker.io:443
auth.docker.io:443
production.cloudflare.docker.com:443
- name: Login to DockerHub
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
- name: Create and push manifests for push event
if: github.event_name == 'push'
run: |
@@ -183,7 +218,14 @@ jobs:
needs: [setup, notify-release-started, container-build-push, create-manifest]
runs-on: ubuntu-latest
timeout-minutes: 5
permissions:
contents: read
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0
with:
egress-policy: audit
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
@@ -226,6 +268,13 @@ jobs:
contents: read
steps:
- name: Harden Runner
uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0
with:
egress-policy: block
allowed-endpoints: >
api.github.com:443
- name: Trigger UI deployment
uses: peter-evans/repository-dispatch@28959ce8df70de7be546dd1250a005dd32156697 # v4.0.1
with:
+25
View File
@@ -27,6 +27,13 @@ jobs:
contents: read
steps:
- name: Harden Runner
uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0
with:
egress-policy: block
allowed-endpoints: >
github.com:443
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
@@ -65,6 +72,24 @@ jobs:
pull-requests: write
steps:
- name: Harden Runner
uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0
with:
egress-policy: block
allowed-endpoints: >
github.com:443
registry-1.docker.io:443
auth.docker.io:443
production.cloudflare.docker.com:443
registry.npmjs.org:443
dl-cdn.alpinelinux.org:443
fonts.googleapis.com:443
fonts.gstatic.com:443
api.github.com:443
mirror.gcr.io:443
check.trivy.dev:443
get.trivy.dev:443
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
+18 -5
View File
@@ -15,13 +15,12 @@ on:
- 'ui/**'
- 'api/**' # API changes can affect UI E2E
permissions:
contents: read
jobs:
# First, analyze which tests need to run
impact-analysis:
if: github.repository == 'prowler-cloud/prowler'
permissions:
contents: read
uses: ./.github/workflows/test-impact-analysis.yml
# Run E2E tests based on impact analysis
@@ -75,8 +74,15 @@ jobs:
# Pass E2E paths from impact analysis
E2E_TEST_PATHS: ${{ needs.impact-analysis.outputs.ui-e2e }}
RUN_ALL_TESTS: ${{ needs.impact-analysis.outputs.run-all }}
permissions:
contents: read
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0
with:
egress-policy: audit
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
@@ -157,9 +163,9 @@ jobs:
node-version: '24.13.0'
- name: Setup pnpm
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
with:
version: 10
package_json_file: ui/package.json
run_install: false
- name: Get pnpm store directory
@@ -273,7 +279,14 @@ jobs:
needs.impact-analysis.outputs.has-ui-e2e != 'true' &&
needs.impact-analysis.outputs.run-all != 'true'
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0
with:
egress-policy: audit
- name: No E2E tests needed
run: |
echo "## E2E Tests Skipped" >> $GITHUB_STEP_SUMMARY
+14 -2
View File
@@ -29,6 +29,18 @@ jobs:
working-directory: ./ui
steps:
- name: Harden Runner
uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0
with:
egress-policy: block
allowed-endpoints: >
github.com:443
registry.npmjs.org:443
fonts.googleapis.com:443
fonts.gstatic.com:443
api.github.com:443
release-assets.githubusercontent.com:443
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
@@ -84,9 +96,9 @@ jobs:
- name: Setup pnpm
if: steps.check-changes.outputs.any_changed == 'true'
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
with:
version: 10
package_json_file: ui/package.json
run_install: false
- name: Get pnpm store directory
+25
View File
@@ -0,0 +1,25 @@
rules:
secrets-outside-env:
ignore:
- api-bump-version.yml
- api-container-build-push.yml
- api-tests.yml
- backport.yml
- docs-bump-version.yml
- issue-triage.lock.yml
- mcp-container-build-push.yml
- pr-merged.yml
- prepare-release.yml
- sdk-bump-version.yml
- sdk-container-build-push.yml
- sdk-refresh-aws-services-regions.yml
- sdk-refresh-oci-regions.yml
- sdk-tests.yml
- ui-bump-version.yml
- ui-container-build-push.yml
- ui-e2e-tests-v2.yml
superfluous-actions:
ignore:
- pr-check-changelog.yml
- pr-conflict-checker.yml
- prepare-release.yml
+1
View File
@@ -60,6 +60,7 @@ htmlcov/
**/mcp-config.json
**/mcpServers.json
.mcp/
.mcp.json
# AI Coding Assistants - Cursor
.cursorignore
+1 -1
View File
@@ -140,7 +140,7 @@ Prowler is an open-source cloud security assessment tool supporting AWS, Azure,
| Component | Location | Tech Stack |
|-----------|----------|------------|
| SDK | `prowler/` | Python 3.9+, Poetry |
| SDK | `prowler/` | Python 3.10+, Poetry |
| API | `api/` | Django 5.1, DRF, Celery |
| UI | `ui/` | Next.js 15, React 19, Tailwind 4 |
| MCP Server | `mcp_server/` | FastMCP, Python 3.12+ |
+1 -1
View File
@@ -1,4 +1,4 @@
FROM python:3.12.11-slim-bookworm AS build
FROM python:3.12.11-slim-bookworm@sha256:519591d6871b7bc437060736b9f7456b8731f1499a57e22e6c285135ae657bf7 AS build
LABEL maintainer="https://github.com/prowler-cloud/prowler"
LABEL org.opencontainers.image.source="https://github.com/prowler-cloud/prowler"
+24 -5
View File
@@ -3,7 +3,7 @@
<img align="center" src="https://github.com/prowler-cloud/prowler/blob/master/docs/img/prowler-logo-white.png#gh-dark-mode-only" width="50%" height="50%">
</p>
<p align="center">
<b><i>Prowler</b> is the Open Cloud Security platform trusted by thousands to automate security and compliance in any cloud environment. With hundreds of ready-to-use checks and compliance frameworks, Prowler delivers real-time, customizable monitoring and seamless integrations, making cloud security simple, scalable, and cost-effective for organizations of any size.
<b><i>Prowler</b> is the Open Cloud Security Platform trusted by thousands to automate security and compliance in any cloud environment. With hundreds of ready-to-use checks and compliance frameworks, Prowler delivers real-time, customizable monitoring and seamless integrations, making cloud security simple, scalable, and cost-effective for organizations of any size.
</p>
<p align="center">
<b>Secure ANY cloud at AI Speed at <a href="https://prowler.com">prowler.com</i></b>
@@ -41,7 +41,7 @@
# Description
**Prowler** is the worlds most widely used _open-source cloud security platform_ that automates security and compliance across **any cloud environment**. With hundreds of ready-to-use security checks, remediation guidance, and compliance frameworks, Prowler is built to _“Secure ANY cloud at AI Speed”_. Prowler delivers **AI-driven**, **customizable**, and **easy-to-use** assessments, dashboards, reports, and integrations, making cloud security **simple**, **scalable**, and **cost-effective** for organizations of any size.
**Prowler** is the worlds most widely used _Open-Source Cloud Security Platform_ that automates security and compliance across **any cloud environment**. With hundreds of ready-to-use security checks, remediation guidance, and compliance frameworks, Prowler is built to _“Secure ANY Cloud at AI Speed”_. Prowler delivers **AI-driven**, **customizable**, and **easy-to-use** assessments, dashboards, reports, and integrations, making cloud security **simple**, **scalable**, and **cost-effective** for organizations of any size.
Prowler includes hundreds of built-in controls to ensure compliance with standards and frameworks, including:
@@ -119,6 +119,7 @@ Every AWS provider scan will enqueue an Attack Paths ingestion job automatically
| Image | N/A | N/A | N/A | N/A | Official | CLI, API |
| Google Workspace | 1 | 1 | 0 | 1 | Official | CLI |
| OpenStack | 27 | 4 | 0 | 8 | Official | UI, API, CLI |
| Vercel | 30 | 6 | 0 | 5 | Official | CLI |
| NHN | 6 | 2 | 1 | 0 | Unofficial | CLI |
> [!Note]
@@ -239,9 +240,24 @@ pnpm start
> Once configured, access the Prowler App at http://localhost:3000. Sign up using your email and password to get started.
**Pre-commit Hooks Setup**
Some pre-commit hooks require tools installed on your system:
1. **Install [TruffleHog](https://github.com/trufflesecurity/trufflehog#install)** (secret scanning) — see the [official installation options](https://github.com/trufflesecurity/trufflehog#install).
2. **Install [Safety](https://github.com/pyupio/safety)** (dependency vulnerability checking):
```console
# Requires a Python environment (e.g. via pyenv)
pip install safety
```
3. **Install [Hadolint](https://github.com/hadolint/hadolint#install)** (Dockerfile linting) — see the [official installation options](https://github.com/hadolint/hadolint#install).
## Prowler CLI
### Pip package
Prowler CLI is available as a project in [PyPI](https://pypi.org/project/prowler-cloud/). Consequently, it can be installed using pip with Python >3.9.1, <3.13:
Prowler CLI is available as a project in [PyPI](https://pypi.org/project/prowler-cloud/). Consequently, it can be installed using pip with Python >=3.10, <3.13:
```console
pip install prowler
@@ -273,7 +289,7 @@ The container images are available here:
### From GitHub
Python >3.9.1, <3.13 is required with pip and Poetry:
Python >=3.10, <3.13 is required with pip and Poetry:
``` console
git clone https://github.com/prowler-cloud/prowler
@@ -301,7 +317,10 @@ python prowler-cli.py -v
- **Prowler SDK**: A Python SDK designed to extend the functionality of the Prowler CLI for advanced capabilities.
- **Prowler MCP Server**: A Model Context Protocol server that provides AI tools for Lighthouse, the AI-powered security assistant. This is a critical dependency for Lighthouse functionality.
![Prowler App Architecture](docs/products/img/prowler-app-architecture.png)
![Prowler App Architecture](docs/images/products/prowler-app-architecture.png)
<!-- Diagram source: docs/images/products/prowler-app-architecture.mmd — edit there, re-render at https://mermaid.live, and replace the PNG. -->
## Prowler CLI
+67 -3
View File
@@ -2,26 +2,90 @@
All notable changes to the **Prowler API** are documented in this file.
## [1.22.0] (Prowler UNRELEASED)
## [1.24.0] (Prowler UNRELEASED)
### 🚀 Added
- Pin all unpinned dependencies to exact versions to prevent supply chain attacks and ensure reproducible builds [(#10469)](https://github.com/prowler-cloud/prowler/pull/10469)
- Filter RBAC role lookup by `tenant_id` to prevent cross-tenant privilege leak [(#10491)](https://github.com/prowler-cloud/prowler/pull/10491)
- `VALKEY_SCHEME`, `VALKEY_USERNAME`, and `VALKEY_PASSWORD` environment variables to configure Celery broker TLS/auth connection details for Valkey/ElastiCache [(#10420)](https://github.com/prowler-cloud/prowler/pull/10420)
- `Vercel` provider support [(#10190)](https://github.com/prowler-cloud/prowler/pull/10190)
- Finding groups list and latest endpoints support `sort=delta`, ordering by `new_count` then `changed_count` so groups with the most new findings rank highest [(#10606)](https://github.com/prowler-cloud/prowler/pull/10606)
### 🔄 Changed
- Attack Paths: Periodic cleanup of stale scans with dead-worker detection via Celery inspect, marking orphaned `EXECUTING` scans as `FAILED` and recovering `graph_data_ready` [(#10387)](https://github.com/prowler-cloud/prowler/pull/10387)
- Attack Paths: Replace `_provider_id` property with `_Provider_{uuid}` label for provider isolation, add regex-based label injection for custom queries [(#10402)](https://github.com/prowler-cloud/prowler/pull/10402)
### 🐞 Fixed
- Finding groups list/latest now apply computed status/severity filters and finding-level prefilters (delta, region, service, category, resource group, scan, resource type), plus `check_title` support for sort/filter consistency [(#10428)](https://github.com/prowler-cloud/prowler/pull/10428)
- Populate compliance data inside `check_metadata` for findings, which was always returned as `null` [(#10449)](https://github.com/prowler-cloud/prowler/pull/10449)
- 403 error for admin users listing tenants due to roles query not using the admin database connection [(#10460)](https://github.com/prowler-cloud/prowler/pull/10460)
- Filter transient Neo4j defunct connection logs in Sentry `before_send` to suppress false-positive alerts handled by `RetryableSession` retries [(#10452)](https://github.com/prowler-cloud/prowler/pull/10452)
- `MANAGE_ACCOUNT` permission no longer required for listing and creating tenants [(#10468)](https://github.com/prowler-cloud/prowler/pull/10468)
- Finding groups muted filter, counters, metadata extraction and mute reaggregation [(#10477)](https://github.com/prowler-cloud/prowler/pull/10477)
- Finding groups `check_title__icontains` resolution, `name__icontains` resource filter and `resource_group` field in `/resources` response [(#10486)](https://github.com/prowler-cloud/prowler/pull/10486)
- Membership `post_delete` signal using raw FK ids to avoid `DoesNotExist` during cascade deletions [(#10497)](https://github.com/prowler-cloud/prowler/pull/10497)
- Finding group resources endpoints returning false 404 when filters match no results, and `sort` parameter being ignored [(#10510)](https://github.com/prowler-cloud/prowler/pull/10510)
- Jira integration failing with `JiraInvalidIssueTypeError` on non-English Jira instances due to hardcoded `"Task"` issue type; now dynamically fetches available issue types per project [(#10534)](https://github.com/prowler-cloud/prowler/pull/10534)
- Finding group `first_seen_at` now reflects when a new finding appeared in the scan instead of the oldest carry-forward date across all unchanged findings [(#10595)](https://github.com/prowler-cloud/prowler/pull/10595)
- Attack Paths: Remove `clear_cache` call from read-only query endpoints; cache clearing belongs to the scan/ingestion flow, not API queries [(#10586)](https://github.com/prowler-cloud/prowler/pull/10586)
### 🔐 Security
- Pin all unpinned dependencies to exact versions to prevent supply chain attacks and ensure reproducible builds [(#10469)](https://github.com/prowler-cloud/prowler/pull/10469)
- `authlib` bumped from 1.6.6 to 1.6.9 to fix CVE-2026-28802 (JWT `alg: none` validation bypass) [(#10579)](https://github.com/prowler-cloud/prowler/pull/10579)
---
## [1.23.0] (Prowler v5.22.0)
### 🚀 Added
- Finding groups support `check_title` substring filtering [(#10377)](https://github.com/prowler-cloud/prowler/pull/10377)
### 🐞 Fixed
- Finding groups latest endpoint now aggregates the latest snapshot per provider before check-level totals, keeping impacted resources aligned across providers [(#10419)](https://github.com/prowler-cloud/prowler/pull/10419)
- Mute rule creation now triggers finding-group summary re-aggregation after historical muting, keeping stats in sync after mute operations [(#10419)](https://github.com/prowler-cloud/prowler/pull/10419)
- Attack Paths: Deduplicate nodes before ProwlerFinding lookup in Attack Paths Cypher queries, reducing execution time [(#10424)](https://github.com/prowler-cloud/prowler/pull/10424)
### 🔐 Security
- Replace stdlib XML parser with `defusedxml` in SAML metadata parsing to prevent XML bomb (billion laughs) DoS attacks [(#10165)](https://github.com/prowler-cloud/prowler/pull/10165)
- Bump `flask` to 3.1.3 (CVE-2026-27205) and `werkzeug` to 3.1.6 (CVE-2026-27199) [(#10430)](https://github.com/prowler-cloud/prowler/pull/10430)
---
## [1.22.1] (Prowler v5.21.1)
### 🐞 Fixed
- Threat score aggregation query to eliminate unnecessary JOINs and `COUNT(DISTINCT)` overhead [(#10394)](https://github.com/prowler-cloud/prowler/pull/10394)
---
## [1.22.0] (Prowler v5.21.0)
### 🚀 Added
- `CORS_ALLOWED_ORIGINS` configurable via environment variable [(#10355)](https://github.com/prowler-cloud/prowler/pull/10355)
- Attack Paths: Tenant and provider related labels to the nodes so they can be easily filtered on custom queries [(#10308)](https://github.com/prowler-cloud/prowler/pull/10308)
### 🔄 Changed
- Attack Paths: Complete migration to private graph labels and properties, removing deprecated dual-write support [(#10268)](https://github.com/prowler-cloud/prowler/pull/10268)
- Attack Paths: Added tenant and provider related labels to the nodes so they can be easily filtered on custom queries [(#10308)](https://github.com/prowler-cloud/prowler/pull/10308)
- Attack Paths: Reduce sync and findings memory usage with smaller batches, cursor iteration, and sequential sessions [(#10359)](https://github.com/prowler-cloud/prowler/pull/10359)
### 🐞 Fixed
- Attack Paths: Recover `graph_data_ready` flag when scan fails during graph swap, preventing query endpoints from staying blocked until the next successful scan [(#10354)](https://github.com/prowler-cloud/prowler/pull/10354)
- Rewrite `rls_transaction` to retry mid-query read replica failures with primary DB fallback, fixing scan crashes during RDS replica recovery [(#10374)](https://github.com/prowler-cloud/prowler/pull/10374)
### 🔐 Security
- Use `psycopg2.sql` to safely compose DDL in `PostgresEnumMigration`, preventing SQL injection via f-string interpolation [(#10166)](https://github.com/prowler-cloud/prowler/pull/10166)
- Replace stdlib XML parser with `defusedxml` in SAML metadata parsing to prevent XML bomb (billion laughs) DoS attacks [(#10165)](https://github.com/prowler-cloud/prowler/pull/10165)
---
+1 -1
View File
@@ -1,4 +1,4 @@
FROM python:3.12.10-slim-bookworm AS build
FROM python:3.12.10-slim-bookworm@sha256:fd95fa221297a88e1cf49c55ec1828edd7c5a428187e67b5d1805692d11588db AS build
LABEL maintainer="https://github.com/prowler-cloud/api"
+20 -1
View File
@@ -30,9 +30,28 @@ start_prod_server() {
poetry run gunicorn -c config/guniconf.py config.wsgi:application
}
resolve_worker_hostname() {
TASK_ID=""
if [ -n "$ECS_CONTAINER_METADATA_URI_V4" ]; then
TASK_ID=$(wget -qO- --timeout=2 "${ECS_CONTAINER_METADATA_URI_V4}/task" | \
python3 -c "import sys,json; print(json.load(sys.stdin)['TaskARN'].split('/')[-1])" 2>/dev/null)
fi
if [ -z "$TASK_ID" ]; then
TASK_ID=$(python3 -c "import uuid; print(uuid.uuid4().hex)")
fi
echo "${TASK_ID}@$(hostname)"
}
start_worker() {
echo "Starting the worker..."
poetry run python -m celery -A config.celery worker -l "${DJANGO_LOGGING_LEVEL:-info}" -Q celery,scans,scan-reports,deletion,backfill,overview,integrations,compliance,attack-paths-scans -E --max-tasks-per-child 1
poetry run python -m celery -A config.celery worker \
-n "$(resolve_worker_hostname)" \
-l "${DJANGO_LOGGING_LEVEL:-info}" \
-Q celery,scans,scan-reports,deletion,backfill,overview,integrations,compliance,attack-paths-scans \
-E --max-tasks-per-child 1
}
start_worker_beat() {
+114 -124
View File
@@ -1,4 +1,4 @@
# This file is automatically @generated by Poetry 2.1.4 and should not be changed by hand.
# This file is automatically @generated by Poetry 2.2.1 and should not be changed by hand.
[[package]]
name = "about-time"
@@ -943,14 +943,14 @@ files = [
[[package]]
name = "authlib"
version = "1.6.6"
version = "1.6.9"
description = "The ultimate Python library in building OAuth and OpenID Connect servers and clients."
optional = false
python-versions = ">=3.9"
groups = ["dev"]
files = [
{file = "authlib-1.6.6-py2.py3-none-any.whl", hash = "sha256:7d9e9bc535c13974313a87f53e8430eb6ea3d1cf6ae4f6efcd793f2e949143fd"},
{file = "authlib-1.6.6.tar.gz", hash = "sha256:45770e8e056d0f283451d9996fbb59b70d45722b45d854d58f32878d0a40c38e"},
{file = "authlib-1.6.9-py2.py3-none-any.whl", hash = "sha256:f08b4c14e08f0861dc18a32357b33fbcfd2ea86cfe3fe149484b4d764c4a0ac3"},
{file = "authlib-1.6.9.tar.gz", hash = "sha256:d8f2421e7e5980cc1ddb4e32d3f5fa659cfaf60d8eaf3281ebed192e4ab74f04"},
]
[package.dependencies]
@@ -2469,22 +2469,18 @@ toml = ["tomli ; python_full_version <= \"3.11.0a6\""]
[[package]]
name = "cron-descriptor"
version = "2.0.6"
version = "1.4.5"
description = "A Python library that converts cron expressions into human readable strings."
optional = false
python-versions = ">=3.9"
python-versions = "*"
groups = ["main"]
files = [
{file = "cron_descriptor-2.0.6-py3-none-any.whl", hash = "sha256:3a1c0d837c0e5a32e415f821b36cf758eb92d510e6beff8fbfe4fa16573d93d6"},
{file = "cron_descriptor-2.0.6.tar.gz", hash = "sha256:e39d2848e1d8913cfb6e3452e701b5eec662ee18bea8cc5aa53ee1a7bb217157"},
{file = "cron_descriptor-1.4.5-py3-none-any.whl", hash = "sha256:736b3ae9d1a99bc3dbfc5b55b5e6e7c12031e7ba5de716625772f8b02dcd6013"},
{file = "cron_descriptor-1.4.5.tar.gz", hash = "sha256:f51ce4ffc1d1f2816939add8524f206c376a42c87a5fca3091ce26725b3b1bca"},
]
[package.dependencies]
typing_extensions = "*"
[package.extras]
dev = ["mypy", "polib", "ruff"]
test = ["pytest"]
dev = ["polib"]
[[package]]
name = "crowdstrike-falconpy"
@@ -2700,23 +2696,17 @@ files = [
]
[[package]]
name = "deprecated"
version = "1.3.1"
description = "Python @deprecated decorator to deprecate old python classes, functions or methods."
name = "defusedxml"
version = "0.7.1"
description = "XML bomb protection for Python stdlib modules"
optional = false
python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,>=2.7"
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*"
groups = ["main"]
files = [
{file = "deprecated-1.3.1-py2.py3-none-any.whl", hash = "sha256:597bfef186b6f60181535a29fbe44865ce137a5079f295b479886c82729d5f3f"},
{file = "deprecated-1.3.1.tar.gz", hash = "sha256:b1b50e0ff0c1fddaa5708a2c6b0a6588bb09b892825ab2b214ac9ea9d92a5223"},
{file = "defusedxml-0.7.1-py2.py3-none-any.whl", hash = "sha256:a352e7e428770286cc899e2542b6cdaedb2b4953ff269a210103ec58f6198a61"},
{file = "defusedxml-0.7.1.tar.gz", hash = "sha256:1bb3032db185915b62d7c6209c5a8792be6a32ab2fedacc84e01b52c51aa3e69"},
]
[package.dependencies]
wrapt = ">=1.10,<3"
[package.extras]
dev = ["PyTest", "PyTest-Cov", "bump2version (<1)", "setuptools ; python_version >= \"3.12\"", "tox"]
[[package]]
name = "detect-secrets"
version = "1.5.0"
@@ -2807,14 +2797,14 @@ bcrypt = ["bcrypt"]
[[package]]
name = "django-allauth"
version = "65.14.0"
version = "65.15.0"
description = "Integrated set of Django applications addressing authentication, registration, account management as well as 3rd party (social) account authentication."
optional = false
python-versions = ">=3.8"
python-versions = ">=3.10"
groups = ["main"]
files = [
{file = "django_allauth-65.14.0-py3-none-any.whl", hash = "sha256:448f5f7877f95fcbe1657256510fe7822d7871f202521a29e23ef937f3325a97"},
{file = "django_allauth-65.14.0.tar.gz", hash = "sha256:5529227aba2b1377d900e9274a3f24496c645e65400fbae3cad5789944bc4d0b"},
{file = "django_allauth-65.15.0-py3-none-any.whl", hash = "sha256:ad9fc49c49a9368eaa5bb95456b76e2a4f377b3c6862ee8443507816578c098d"},
{file = "django_allauth-65.15.0.tar.gz", hash = "sha256:b404d48cf0c3ee14dacc834c541f30adedba2ff1c433980ecc494d6cb0b395a8"},
]
[package.dependencies]
@@ -2837,20 +2827,20 @@ steam = ["python3-openid (>=3.0.8,<4)"]
[[package]]
name = "django-celery-beat"
version = "2.8.1"
version = "2.9.0"
description = "Database-backed Periodic Tasks."
optional = false
python-versions = ">=3.8"
groups = ["main"]
files = [
{file = "django_celery_beat-2.8.1-py3-none-any.whl", hash = "sha256:da2b1c6939495c05a551717509d6e3b79444e114a027f7b77bf3727c2a39d171"},
{file = "django_celery_beat-2.8.1.tar.gz", hash = "sha256:dfad0201c0ac50c91a34700ef8fa0a10ee098cc7f3375fe5debed79f2204f80a"},
{file = "django_celery_beat-2.9.0-py3-none-any.whl", hash = "sha256:4a9e5ebe26d6f8d7215e1fc5c46e466016279dc102435a28141108649bdf2157"},
{file = "django_celery_beat-2.9.0.tar.gz", hash = "sha256:92404650f52fcb44cf08e2b09635cb1558327c54b1a5d570f0e2d3a22130934c"},
]
[package.dependencies]
celery = ">=5.2.3,<6.0"
cron-descriptor = ">=1.2.32"
Django = ">=2.2,<6.0"
cron-descriptor = ">=1.2.32,<2.0.0"
Django = ">=2.2,<6.1"
django-timezone-field = ">=5.0"
python-crontab = ">=2.3.4"
tzdata = "*"
@@ -3358,14 +3348,14 @@ files = [
[[package]]
name = "flask"
version = "3.1.2"
version = "3.1.3"
description = "A simple framework for building complex web applications."
optional = false
python-versions = ">=3.9"
groups = ["main"]
files = [
{file = "flask-3.1.2-py3-none-any.whl", hash = "sha256:ca1d8112ec8a6158cc29ea4858963350011b5c846a414cdb7a954aa9e967d03c"},
{file = "flask-3.1.2.tar.gz", hash = "sha256:bf656c15c80190ed628ad08cdfd3aaa35beb087855e2f494910aa3774cc4fd87"},
{file = "flask-3.1.3-py3-none-any.whl", hash = "sha256:f4bcbefc124291925f1a26446da31a5178f9483862233b23c0c96a20701f670c"},
{file = "flask-3.1.3.tar.gz", hash = "sha256:0ef0e52b8a9cd932855379197dd8f94047b359ca0a78695144304cb45f87c9eb"},
]
[package.dependencies]
@@ -3382,62 +3372,62 @@ dotenv = ["python-dotenv"]
[[package]]
name = "fonttools"
version = "4.61.1"
version = "4.62.1"
description = "Tools to manipulate font files"
optional = false
python-versions = ">=3.10"
groups = ["main"]
files = [
{file = "fonttools-4.61.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:7c7db70d57e5e1089a274cbb2b1fd635c9a24de809a231b154965d415d6c6d24"},
{file = "fonttools-4.61.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:5fe9fd43882620017add5eabb781ebfbc6998ee49b35bd7f8f79af1f9f99a958"},
{file = "fonttools-4.61.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d8db08051fc9e7d8bc622f2112511b8107d8f27cd89e2f64ec45e9825e8288da"},
{file = "fonttools-4.61.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a76d4cb80f41ba94a6691264be76435e5f72f2cb3cab0b092a6212855f71c2f6"},
{file = "fonttools-4.61.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:a13fc8aeb24bad755eea8f7f9d409438eb94e82cf86b08fe77a03fbc8f6a96b1"},
{file = "fonttools-4.61.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:b846a1fcf8beadeb9ea4f44ec5bdde393e2f1569e17d700bfc49cd69bde75881"},
{file = "fonttools-4.61.1-cp310-cp310-win32.whl", hash = "sha256:78a7d3ab09dc47ac1a363a493e6112d8cabed7ba7caad5f54dbe2f08676d1b47"},
{file = "fonttools-4.61.1-cp310-cp310-win_amd64.whl", hash = "sha256:eff1ac3cc66c2ac7cda1e64b4e2f3ffef474b7335f92fc3833fc632d595fcee6"},
{file = "fonttools-4.61.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:c6604b735bb12fef8e0efd5578c9fb5d3d8532d5001ea13a19cddf295673ee09"},
{file = "fonttools-4.61.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:5ce02f38a754f207f2f06557523cd39a06438ba3aafc0639c477ac409fc64e37"},
{file = "fonttools-4.61.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:77efb033d8d7ff233385f30c62c7c79271c8885d5c9657d967ede124671bbdfb"},
{file = "fonttools-4.61.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:75c1a6dfac6abd407634420c93864a1e274ebc1c7531346d9254c0d8f6ca00f9"},
{file = "fonttools-4.61.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:0de30bfe7745c0d1ffa2b0b7048fb7123ad0d71107e10ee090fa0b16b9452e87"},
{file = "fonttools-4.61.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:58b0ee0ab5b1fc9921eccfe11d1435added19d6494dde14e323f25ad2bc30c56"},
{file = "fonttools-4.61.1-cp311-cp311-win32.whl", hash = "sha256:f79b168428351d11e10c5aeb61a74e1851ec221081299f4cf56036a95431c43a"},
{file = "fonttools-4.61.1-cp311-cp311-win_amd64.whl", hash = "sha256:fe2efccb324948a11dd09d22136fe2ac8a97d6c1347cf0b58a911dcd529f66b7"},
{file = "fonttools-4.61.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:f3cb4a569029b9f291f88aafc927dd53683757e640081ca8c412781ea144565e"},
{file = "fonttools-4.61.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:41a7170d042e8c0024703ed13b71893519a1a6d6e18e933e3ec7507a2c26a4b2"},
{file = "fonttools-4.61.1-cp312-cp312-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:10d88e55330e092940584774ee5e8a6971b01fc2f4d3466a1d6c158230880796"},
{file = "fonttools-4.61.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:15acc09befd16a0fb8a8f62bc147e1a82817542d72184acca9ce6e0aeda9fa6d"},
{file = "fonttools-4.61.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e6bcdf33aec38d16508ce61fd81838f24c83c90a1d1b8c68982857038673d6b8"},
{file = "fonttools-4.61.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5fade934607a523614726119164ff621e8c30e8fa1ffffbbd358662056ba69f0"},
{file = "fonttools-4.61.1-cp312-cp312-win32.whl", hash = "sha256:75da8f28eff26defba42c52986de97b22106cb8f26515b7c22443ebc9c2d3261"},
{file = "fonttools-4.61.1-cp312-cp312-win_amd64.whl", hash = "sha256:497c31ce314219888c0e2fce5ad9178ca83fe5230b01a5006726cdf3ac9f24d9"},
{file = "fonttools-4.61.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:8c56c488ab471628ff3bfa80964372fc13504ece601e0d97a78ee74126b2045c"},
{file = "fonttools-4.61.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:dc492779501fa723b04d0ab1f5be046797fee17d27700476edc7ee9ae535a61e"},
{file = "fonttools-4.61.1-cp313-cp313-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:64102ca87e84261419c3747a0d20f396eb024bdbeb04c2bfb37e2891f5fadcb5"},
{file = "fonttools-4.61.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4c1b526c8d3f615a7b1867f38a9410849c8f4aef078535742198e942fba0e9bd"},
{file = "fonttools-4.61.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:41ed4b5ec103bd306bb68f81dc166e77409e5209443e5773cb4ed837bcc9b0d3"},
{file = "fonttools-4.61.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:b501c862d4901792adaec7c25b1ecc749e2662543f68bb194c42ba18d6eec98d"},
{file = "fonttools-4.61.1-cp313-cp313-win32.whl", hash = "sha256:4d7092bb38c53bbc78e9255a59158b150bcdc115a1e3b3ce0b5f267dc35dd63c"},
{file = "fonttools-4.61.1-cp313-cp313-win_amd64.whl", hash = "sha256:21e7c8d76f62ab13c9472ccf74515ca5b9a761d1bde3265152a6dc58700d895b"},
{file = "fonttools-4.61.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:fff4f534200a04b4a36e7ae3cb74493afe807b517a09e99cb4faa89a34ed6ecd"},
{file = "fonttools-4.61.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:d9203500f7c63545b4ce3799319fe4d9feb1a1b89b28d3cb5abd11b9dd64147e"},
{file = "fonttools-4.61.1-cp314-cp314-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:fa646ecec9528bef693415c79a86e733c70a4965dd938e9a226b0fc64c9d2e6c"},
{file = "fonttools-4.61.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:11f35ad7805edba3aac1a3710d104592df59f4b957e30108ae0ba6c10b11dd75"},
{file = "fonttools-4.61.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b931ae8f62db78861b0ff1ac017851764602288575d65b8e8ff1963fed419063"},
{file = "fonttools-4.61.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:b148b56f5de675ee16d45e769e69f87623a4944f7443850bf9a9376e628a89d2"},
{file = "fonttools-4.61.1-cp314-cp314-win32.whl", hash = "sha256:9b666a475a65f4e839d3d10473fad6d47e0a9db14a2f4a224029c5bfde58ad2c"},
{file = "fonttools-4.61.1-cp314-cp314-win_amd64.whl", hash = "sha256:4f5686e1fe5fce75d82d93c47a438a25bf0d1319d2843a926f741140b2b16e0c"},
{file = "fonttools-4.61.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:e76ce097e3c57c4bcb67c5aa24a0ecdbd9f74ea9219997a707a4061fbe2707aa"},
{file = "fonttools-4.61.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:9cfef3ab326780c04d6646f68d4b4742aae222e8b8ea1d627c74e38afcbc9d91"},
{file = "fonttools-4.61.1-cp314-cp314t-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:a75c301f96db737e1c5ed5fd7d77d9c34466de16095a266509e13da09751bd19"},
{file = "fonttools-4.61.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:91669ccac46bbc1d09e9273546181919064e8df73488ea087dcac3e2968df9ba"},
{file = "fonttools-4.61.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c33ab3ca9d3ccd581d58e989d67554e42d8d4ded94ab3ade3508455fe70e65f7"},
{file = "fonttools-4.61.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:664c5a68ec406f6b1547946683008576ef8b38275608e1cee6c061828171c118"},
{file = "fonttools-4.61.1-cp314-cp314t-win32.whl", hash = "sha256:aed04cabe26f30c1647ef0e8fbb207516fd40fe9472e9439695f5c6998e60ac5"},
{file = "fonttools-4.61.1-cp314-cp314t-win_amd64.whl", hash = "sha256:2180f14c141d2f0f3da43f3a81bc8aa4684860f6b0e6f9e165a4831f24e6a23b"},
{file = "fonttools-4.61.1-py3-none-any.whl", hash = "sha256:17d2bf5d541add43822bcf0c43d7d847b160c9bb01d15d5007d84e2217aaa371"},
{file = "fonttools-4.61.1.tar.gz", hash = "sha256:6675329885c44657f826ef01d9e4fb33b9158e9d93c537d84ad8399539bc6f69"},
{file = "fonttools-4.62.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:ad5cca75776cd453b1b035b530e943334957ae152a36a88a320e779d61fc980c"},
{file = "fonttools-4.62.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0b3ae47e8636156a9accff64c02c0924cbebad62854c4a6dbdc110cd5b4b341a"},
{file = "fonttools-4.62.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c9b9e288b4da2f64fd6180644221749de651703e8d0c16bd4b719533a3a7d6e3"},
{file = "fonttools-4.62.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7bca7a1c1faf235ffe25d4f2e555246b4750220b38de8261d94ebc5ce8a23c23"},
{file = "fonttools-4.62.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:b4e0fcf265ad26e487c56cb12a42dffe7162de708762db951e1b3f755319507d"},
{file = "fonttools-4.62.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:2d850f66830a27b0d498ee05adb13a3781637b1826982cd7e2b3789ef0cc71ae"},
{file = "fonttools-4.62.1-cp310-cp310-win32.whl", hash = "sha256:486f32c8047ccd05652aba17e4a8819a3a9d78570eb8a0e3b4503142947880ed"},
{file = "fonttools-4.62.1-cp310-cp310-win_amd64.whl", hash = "sha256:5a648bde915fba9da05ae98856987ca91ba832949a9e2888b48c47ef8b96c5a9"},
{file = "fonttools-4.62.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:40975849bac44fb0b9253d77420c6d8b523ac4dcdcefeff6e4d706838a5b80f7"},
{file = "fonttools-4.62.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:9dde91633f77fa576879a0c76b1d89de373cae751a98ddf0109d54e173b40f14"},
{file = "fonttools-4.62.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6acb4109f8bee00fec985c8c7afb02299e35e9c94b57287f3ea542f28bd0b0a7"},
{file = "fonttools-4.62.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1c5c25671ce8805e0d080e2ffdeca7f1e86778c5cbfbeae86d7f866d8830517b"},
{file = "fonttools-4.62.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:a5d8825e1140f04e6c99bb7d37a9e31c172f3bc208afbe02175339e699c710e1"},
{file = "fonttools-4.62.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:268abb1cb221e66c014acc234e872b7870d8b5d4657a83a8f4205094c32d2416"},
{file = "fonttools-4.62.1-cp311-cp311-win32.whl", hash = "sha256:942b03094d7edbb99bdf1ae7e9090898cad7bf9030b3d21f33d7072dbcb51a53"},
{file = "fonttools-4.62.1-cp311-cp311-win_amd64.whl", hash = "sha256:e8514f4924375f77084e81467e63238b095abda5107620f49421c368a6017ed2"},
{file = "fonttools-4.62.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:90365821debbd7db678809c7491ca4acd1e0779b9624cdc6ddaf1f31992bf974"},
{file = "fonttools-4.62.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:12859ff0b47dd20f110804c3e0d0970f7b832f561630cd879969011541a464a9"},
{file = "fonttools-4.62.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9c125ffa00c3d9003cdaaf7f2c79e6e535628093e14b5de1dccb08859b680936"},
{file = "fonttools-4.62.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:149f7d84afca659d1a97e39a4778794a2f83bf344c5ee5134e09995086cc2392"},
{file = "fonttools-4.62.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:0aa72c43a601cfa9273bb1ae0518f1acadc01ee181a6fc60cd758d7fdadffc04"},
{file = "fonttools-4.62.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:19177c8d96c7c36359266e571c5173bcee9157b59cfc8cb0153c5673dc5a3a7d"},
{file = "fonttools-4.62.1-cp312-cp312-win32.whl", hash = "sha256:a24decd24d60744ee8b4679d38e88b8303d86772053afc29b19d23bb8207803c"},
{file = "fonttools-4.62.1-cp312-cp312-win_amd64.whl", hash = "sha256:9e7863e10b3de72376280b515d35b14f5eeed639d1aa7824f4cf06779ec65e42"},
{file = "fonttools-4.62.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:c22b1014017111c401469e3acc5433e6acf6ebcc6aa9efb538a533c800971c79"},
{file = "fonttools-4.62.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:68959f5fc58ed4599b44aad161c2837477d7f35f5f79402d97439974faebfebe"},
{file = "fonttools-4.62.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ef46db46c9447103b8f3ff91e8ba009d5fe181b1920a83757a5762551e32bb68"},
{file = "fonttools-4.62.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:6706d1cb1d5e6251a97ad3c1b9347505c5615c112e66047abbef0f8545fa30d1"},
{file = "fonttools-4.62.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:2e7abd2b1e11736f58c1de27819e1955a53267c21732e78243fa2fa2e5c1e069"},
{file = "fonttools-4.62.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:403d28ce06ebfc547fbcb0cb8b7f7cc2f7a2d3e1a67ba9a34b14632df9e080f9"},
{file = "fonttools-4.62.1-cp313-cp313-win32.whl", hash = "sha256:93c316e0f5301b2adbe6a5f658634307c096fd5aae60a5b3412e4f3e1728ab24"},
{file = "fonttools-4.62.1-cp313-cp313-win_amd64.whl", hash = "sha256:7aa21ff53e28a9c2157acbc44e5b401149d3c9178107130e82d74ceb500e5056"},
{file = "fonttools-4.62.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:fa1d16210b6b10a826d71bed68dd9ec24a9e218d5a5e2797f37c573e7ec215ca"},
{file = "fonttools-4.62.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:aa69d10ed420d8121118e628ad47d86e4caa79ba37f968597b958f6cceab7eca"},
{file = "fonttools-4.62.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bd13b7999d59c5eb1c2b442eb2d0c427cb517a0b7a1f5798fc5c9e003f5ff782"},
{file = "fonttools-4.62.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8d337fdd49a79b0d51c4da87bc38169d21c3abbf0c1aa9367eff5c6656fb6dae"},
{file = "fonttools-4.62.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:d241cdc4a67b5431c6d7f115fdf63335222414995e3a1df1a41e1182acd4bcc7"},
{file = "fonttools-4.62.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:c05557a78f8fa514da0f869556eeda40887a8abc77c76ee3f74cf241778afd5a"},
{file = "fonttools-4.62.1-cp314-cp314-win32.whl", hash = "sha256:49a445d2f544ce4a69338694cad575ba97b9a75fff02720da0882d1a73f12800"},
{file = "fonttools-4.62.1-cp314-cp314-win_amd64.whl", hash = "sha256:1eecc128c86c552fb963fe846ca4e011b1be053728f798185a1687502f6d398e"},
{file = "fonttools-4.62.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:1596aeaddf7f78e21e68293c011316a25267b3effdaccaf4d59bc9159d681b82"},
{file = "fonttools-4.62.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:8f8fca95d3bb3208f59626a4b0ea6e526ee51f5a8ad5d91821c165903e8d9260"},
{file = "fonttools-4.62.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee91628c08e76f77b533d65feb3fbe6d9dad699f95be51cf0d022db94089cdc4"},
{file = "fonttools-4.62.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5f37df1cac61d906e7b836abe356bc2f34c99d4477467755c216b72aa3dc748b"},
{file = "fonttools-4.62.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:92bb00a947e666169c99b43753c4305fc95a890a60ef3aeb2a6963e07902cc87"},
{file = "fonttools-4.62.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:bdfe592802ef939a0e33106ea4a318eeb17822c7ee168c290273cbd5fabd746c"},
{file = "fonttools-4.62.1-cp314-cp314t-win32.whl", hash = "sha256:b820fcb92d4655513d8402d5b219f94481c4443d825b4372c75a2072aa4b357a"},
{file = "fonttools-4.62.1-cp314-cp314t-win_amd64.whl", hash = "sha256:59b372b4f0e113d3746b88985f1c796e7bf830dd54b28374cd85c2b8acd7583e"},
{file = "fonttools-4.62.1-py3-none-any.whl", hash = "sha256:7487782e2113861f4ddcc07c3436450659e3caa5e470b27dc2177cade2d8e7fd"},
{file = "fonttools-4.62.1.tar.gz", hash = "sha256:e54c75fd6041f1122476776880f7c3c3295ffa31962dc6ebe2543c00dca58b5d"},
]
[package.extras]
@@ -5052,18 +5042,18 @@ tests = ["psutil", "pytest (!=3.3.0)", "pytest-cov"]
[[package]]
name = "markdown"
version = "3.9"
version = "3.10.2"
description = "Python implementation of John Gruber's Markdown."
optional = false
python-versions = ">=3.9"
python-versions = ">=3.10"
groups = ["main"]
files = [
{file = "markdown-3.9-py3-none-any.whl", hash = "sha256:9f4d91ed810864ea88a6f32c07ba8bee1346c0cc1f6b1f9f6c822f2a9667d280"},
{file = "markdown-3.9.tar.gz", hash = "sha256:d2900fe1782bd33bdbbd56859defef70c2e78fc46668f8eb9df3128138f2cb6a"},
{file = "markdown-3.10.2-py3-none-any.whl", hash = "sha256:e91464b71ae3ee7afd3017d9f358ef0baf158fd9a298db92f1d4761133824c36"},
{file = "markdown-3.10.2.tar.gz", hash = "sha256:994d51325d25ad8aa7ce4ebaec003febcce822c3f8c911e3b17c52f7f589f950"},
]
[package.extras]
docs = ["mdx_gh_links (>=0.2)", "mkdocs (>=1.6)", "mkdocs-gen-files", "mkdocs-literate-nav", "mkdocs-nature (>=0.6)", "mkdocs-section-index", "mkdocstrings[python]"]
docs = ["mdx_gh_links (>=0.2)", "mkdocs (>=1.6)", "mkdocs-gen-files", "mkdocs-literate-nav", "mkdocs-nature (>=0.6)", "mkdocs-section-index", "mkdocstrings[python] (>=0.28.3)"]
testing = ["coverage", "pyyaml"]
[[package]]
@@ -5837,14 +5827,14 @@ files = [
[[package]]
name = "nltk"
version = "3.9.2"
version = "3.9.4"
description = "Natural Language Toolkit"
optional = false
python-versions = ">=3.9"
python-versions = ">=3.10"
groups = ["dev"]
files = [
{file = "nltk-3.9.2-py3-none-any.whl", hash = "sha256:1e209d2b3009110635ed9709a67a1a3e33a10f799490fa71cf4bec218c11c88a"},
{file = "nltk-3.9.2.tar.gz", hash = "sha256:0f409e9b069ca4177c1903c3e843eef90c7e92992fa4931ae607da6de49e1419"},
{file = "nltk-3.9.4-py3-none-any.whl", hash = "sha256:f2fa301c3a12718ce4a0e9305c5675299da5ad9e26068218b69d692fda84828f"},
{file = "nltk-3.9.4.tar.gz", hash = "sha256:ed03bc098a40481310320808b2db712d95d13ca65b27372f8a403949c8b523d0"},
]
[package.dependencies]
@@ -6642,10 +6632,10 @@ files = [
[[package]]
name = "prowler"
version = "5.19.0"
version = "5.23.0"
description = "Prowler is an Open Source security tool to perform AWS, GCP and Azure security best practices assessments, audits, incident response, continuous monitoring, hardening and forensics readiness. It contains hundreds of controls covering CIS, NIST 800, NIST CSF, CISA, RBI, FedRAMP, PCI-DSS, GDPR, HIPAA, FFIEC, SOC2, GXP, AWS Well-Architected Framework Security Pillar, AWS Foundational Technical Review (FTR), ENS (Spanish National Security Scheme) and your custom security frameworks."
optional = false
python-versions = ">3.9.1,<3.13"
python-versions = ">=3.10,<3.13"
groups = ["main"]
files = []
develop = false
@@ -6700,6 +6690,7 @@ colorama = "0.4.6"
cryptography = "44.0.3"
dash = "3.1.1"
dash-bootstrap-components = "2.0.3"
defusedxml = ">=0.7.1"
detect-secrets = "1.5.0"
dulwich = "0.23.0"
google-api-python-client = "2.163.0"
@@ -6707,7 +6698,7 @@ google-auth-httplib2 = ">=0.1,<0.3"
h2 = "4.3.0"
jsonschema = "4.23.0"
kubernetes = "32.0.1"
markdown = "3.9.0"
markdown = "3.10.2"
microsoft-kiota-abstractions = "1.9.2"
msgraph-sdk = "1.23.0"
numpy = "2.0.2"
@@ -6717,7 +6708,7 @@ pandas = "2.2.3"
py-iam-expand = "0.1.0"
py-ocsf-models = "0.8.1"
pydantic = ">=2.0,<3.0"
pygithub = "2.5.0"
pygithub = "2.8.0"
python-dateutil = ">=2.9.0.post0,<3.0.0"
pytz = "2025.1"
schema = "0.7.5"
@@ -6725,12 +6716,13 @@ shodan = "1.31.0"
slack-sdk = "3.39.0"
tabulate = "0.9.0"
tzlocal = "5.3.1"
uuid6 = "2024.7.10"
[package.source]
type = "git"
url = "https://github.com/prowler-cloud/prowler.git"
reference = "master"
resolved_reference = "b31145616064bd6727139777dca1cea9b977346a"
resolved_reference = "6ac90eb1b58590b6f2f51645dbef17b9231053f4"
[[package]]
name = "psutil"
@@ -7103,22 +7095,21 @@ typing-extensions = ">=4.14.1"
[[package]]
name = "pygithub"
version = "2.5.0"
version = "2.8.0"
description = "Use the full Github API v3"
optional = false
python-versions = ">=3.8"
groups = ["main"]
files = [
{file = "PyGithub-2.5.0-py3-none-any.whl", hash = "sha256:b0b635999a658ab8e08720bdd3318893ff20e2275f6446fcf35bf3f44f2c0fd2"},
{file = "pygithub-2.5.0.tar.gz", hash = "sha256:e1613ac508a9be710920d26eb18b1905ebd9926aa49398e88151c1b526aad3cf"},
{file = "pygithub-2.8.0-py3-none-any.whl", hash = "sha256:11a3473c1c2f1c39c525d0ee8c559f369c6d46c272cb7321c9b0cabc7aa1ce7d"},
{file = "pygithub-2.8.0.tar.gz", hash = "sha256:72f5f2677d86bc3a8843aa720c6ce4c1c42fb7500243b136e3d5e14ddb5c3386"},
]
[package.dependencies]
Deprecated = "*"
pyjwt = {version = ">=2.4.0", extras = ["crypto"]}
pynacl = ">=1.4.0"
requests = ">=2.14.0"
typing-extensions = ">=4.0.0"
typing-extensions = ">=4.5.0"
urllib3 = ">=1.26.0"
[[package]]
@@ -7354,14 +7345,14 @@ dev = ["argcomplete", "attrs (>=19.2)", "hypothesis (>=3.56)", "mock", "pygments
[[package]]
name = "pytest-celery"
version = "1.2.1"
version = "1.3.0"
description = "Pytest plugin for Celery"
optional = false
python-versions = "<4.0,>=3.8"
python-versions = "<4.0,>=3.9"
groups = ["main"]
files = [
{file = "pytest_celery-1.2.1-py3-none-any.whl", hash = "sha256:0441ab0c2a712b775be16ffda3d7deb31995fd7b5e9d71630e7ea98b474346a3"},
{file = "pytest_celery-1.2.1.tar.gz", hash = "sha256:7873fb3cf4fbfe9b0dd15d359bdb8bbab4a41c7e48f5b0adb7d36138d3704d52"},
{file = "pytest_celery-1.3.0-py3-none-any.whl", hash = "sha256:f02201d7770584a0c412a1ded329a142170c24012467c7046f2c72cc8205ad5d"},
{file = "pytest_celery-1.3.0.tar.gz", hash = "sha256:bd9e5b0f594ec5de9ab97cf27e3a11c644718a761bab6b997d01800fd7394f64"},
]
[package.dependencies]
@@ -7372,14 +7363,13 @@ kombu = "*"
psutil = ">=7.0.0"
pytest-docker-tools = ">=3.1.3"
redis = {version = "*", optional = true, markers = "extra == \"all\" or extra == \"redis\""}
setuptools = {version = ">=75.8.0", markers = "python_version >= \"3.9\" and python_version < \"4.0\""}
tenacity = ">=9.0.0"
[package.extras]
all = ["boto3", "botocore", "python-memcached", "redis", "urllib3 (>=1.26.16,<2.0)"]
all = ["boto3", "botocore", "pycurl (>=7.43) ; sys_platform != \"win32\" and platform_python_implementation == \"CPython\"", "python-memcached", "redis", "urllib3 (>=1.26.16,<2.0)"]
memcached = ["python-memcached"]
redis = ["redis"]
sqs = ["boto3", "botocore", "urllib3 (>=1.26.16,<2.0)"]
sqs = ["boto3", "botocore", "pycurl (>=7.43) ; sys_platform != \"win32\" and platform_python_implementation == \"CPython\"", "urllib3 (>=1.26.16,<2.0)"]
[[package]]
name = "pytest-cov"
@@ -7864,14 +7854,14 @@ files = [
[[package]]
name = "reportlab"
version = "4.4.9"
version = "4.4.10"
description = "The Reportlab Toolkit"
optional = false
python-versions = "<4,>=3.9"
groups = ["main"]
files = [
{file = "reportlab-4.4.9-py3-none-any.whl", hash = "sha256:68e2d103ae8041a37714e8896ec9b79a1c1e911d68c3bd2ea17546568cf17bfd"},
{file = "reportlab-4.4.9.tar.gz", hash = "sha256:7cf487764294ee791a4781f5a157bebce262a666ae4bbb87786760a9676c9378"},
{file = "reportlab-4.4.10-py3-none-any.whl", hash = "sha256:5abc815746ae2bc44e7ff25db96814f921349ca814c992c7eac3c26029bf7c24"},
{file = "reportlab-4.4.10.tar.gz", hash = "sha256:5cbbb34ac3546039d0086deb2938cdec06b12da3cdb836e813258eb33cd28487"},
]
[package.dependencies]
@@ -8293,14 +8283,14 @@ contextlib2 = ">=0.5.5"
[[package]]
name = "sentry-sdk"
version = "2.51.0"
version = "2.56.0"
description = "Python client for Sentry (https://sentry.io)"
optional = false
python-versions = ">=3.6"
groups = ["main"]
files = [
{file = "sentry_sdk-2.51.0-py2.py3-none-any.whl", hash = "sha256:e21016d318a097c2b617bb980afd9fc737e1efc55f9b4f0cdc819982c9717d5f"},
{file = "sentry_sdk-2.51.0.tar.gz", hash = "sha256:b89d64577075fd8c13088bc3609a2ce77a154e5beb8cba7cc16560b0539df4f7"},
{file = "sentry_sdk-2.56.0-py2.py3-none-any.whl", hash = "sha256:5afafb744ceb91d22f4cc650c6bd048ac6af5f7412dcc6c59305a2e36f4dbc02"},
{file = "sentry_sdk-2.56.0.tar.gz", hash = "sha256:fdab72030b69625665b2eeb9738bdde748ad254e8073085a0ce95382678e8168"},
]
[package.dependencies]
@@ -8773,14 +8763,14 @@ test = ["pytest", "websockets"]
[[package]]
name = "werkzeug"
version = "3.1.5"
version = "3.1.7"
description = "The comprehensive WSGI web application library."
optional = false
python-versions = ">=3.9"
groups = ["main"]
files = [
{file = "werkzeug-3.1.5-py3-none-any.whl", hash = "sha256:5111e36e91086ece91f93268bb39b4a35c1e6f1feac762c9c822ded0a4e322dc"},
{file = "werkzeug-3.1.5.tar.gz", hash = "sha256:6a548b0e88955dd07ccb25539d7d0cc97417ee9e179677d22c7041c8f078ce67"},
{file = "werkzeug-3.1.7-py3-none-any.whl", hash = "sha256:4b314d81163a3e1a169b6a0be2a000a0e204e8873c5de6586f453c55688d422f"},
{file = "werkzeug-3.1.7.tar.gz", hash = "sha256:fb8c01fe6ab13b9b7cdb46892b99b1d66754e1d7ab8e542e865ec13f526b5351"},
]
[package.dependencies]
@@ -9382,4 +9372,4 @@ files = [
[metadata]
lock-version = "2.1"
python-versions = ">=3.11,<3.13"
content-hash = "6e38c38b1f8dc05b881f49703fa445eec299527e6697992b18e4613534fbcdb6"
content-hash = "167d4549788b8bc8bb7772b9a81ade1eab73d8f354251a8d6af4901223cc7f67"
+21 -20
View File
@@ -5,43 +5,44 @@ requires = ["poetry-core"]
[project]
authors = [{name = "Prowler Engineering", email = "engineering@prowler.com"}]
dependencies = [
"celery (>=5.4.0,<6.0.0)",
"celery (==5.6.2)",
"dj-rest-auth[with_social,jwt] (==7.0.1)",
"django (==5.1.15)",
"django-allauth[saml] (>=65.13.0,<66.0.0)",
"django-celery-beat (>=2.7.0,<3.0.0)",
"django-celery-results (>=2.5.1,<3.0.0)",
"django-allauth[saml] (==65.15.0)",
"django-celery-beat (==2.9.0)",
"django-celery-results (==2.6.0)",
"django-cors-headers==4.4.0",
"django-environ==0.11.2",
"django-filter==24.3",
"django-guid==3.5.0",
"django-postgres-extra (>=2.0.8,<3.0.0)",
"django-postgres-extra (==2.0.9)",
"djangorestframework==3.15.2",
"djangorestframework-jsonapi==7.0.2",
"djangorestframework-simplejwt (>=5.3.1,<6.0.0)",
"drf-nested-routers (>=0.94.1,<1.0.0)",
"djangorestframework-simplejwt (==5.5.1)",
"drf-nested-routers (==0.95.0)",
"drf-spectacular==0.27.2",
"drf-spectacular-jsonapi==0.5.1",
"defusedxml==0.7.1",
"gunicorn==23.0.0",
"lxml==5.3.2",
"prowler @ git+https://github.com/prowler-cloud/prowler.git@master",
"psycopg2-binary==2.9.9",
"pytest-celery[redis] (>=1.0.1,<2.0.0)",
"sentry-sdk[django] (>=2.20.0,<3.0.0)",
"pytest-celery[redis] (==1.3.0)",
"sentry-sdk[django] (==2.56.0)",
"uuid6==2024.7.10",
"openai (>=1.82.0,<2.0.0)",
"openai (==1.109.1)",
"xmlsec==1.3.14",
"h2 (==4.3.0)",
"markdown (>=3.9,<4.0)",
"markdown (==3.10.2)",
"drf-simple-apikey (==2.2.1)",
"matplotlib (>=3.10.6,<4.0.0)",
"reportlab (>=4.4.4,<5.0.0)",
"neo4j (>=6.0.0,<7.0.0)",
"matplotlib (==3.10.8)",
"reportlab (==4.4.10)",
"neo4j (==6.1.0)",
"cartography (==0.132.0)",
"gevent (>=25.9.1,<26.0.0)",
"werkzeug (>=3.1.4)",
"sqlparse (>=0.5.4)",
"fonttools (>=4.60.2)"
"gevent (==25.9.1)",
"werkzeug (==3.1.7)",
"sqlparse (==0.5.5)",
"fonttools (==4.62.1)"
]
description = "Prowler's API (Django/DRF)"
license = "Apache-2.0"
@@ -49,7 +50,7 @@ name = "prowler-api"
package-mode = false
# Needed for the SDK compatibility
requires-python = ">=3.11,<3.13"
version = "1.22.0"
version = "1.24.0"
[project.scripts]
celery = "src.backend.config.settings.celery"
@@ -61,7 +62,7 @@ django-silk = "5.3.2"
docker = "7.1.0"
filelock = "3.20.3"
freezegun = "1.5.1"
marshmallow = ">=3.15.0,<4.0.0"
marshmallow = "==3.26.2"
mypy = "1.10.1"
pylint = "3.2.5"
pytest = "8.2.2"
+20 -21
View File
@@ -45,27 +45,26 @@ class ProwlerSocialAccountAdapter(DefaultSocialAccountAdapter):
tenant = Tenant.objects.using(MainRouter.admin_db).create(
name=f"{user.email.split('@')[0]} default tenant"
)
for attempt in rls_transaction(str(tenant.id)):
with attempt:
Membership.objects.using(MainRouter.admin_db).create(
user=user, tenant=tenant, role=Membership.RoleChoices.OWNER
)
role = Role.objects.using(MainRouter.admin_db).create(
name="admin",
tenant_id=tenant.id,
manage_users=True,
manage_account=True,
manage_billing=True,
manage_providers=True,
manage_integrations=True,
manage_scans=True,
unlimited_visibility=True,
)
UserRoleRelationship.objects.using(MainRouter.admin_db).create(
user=user,
role=role,
tenant_id=tenant.id,
)
with rls_transaction(str(tenant.id)):
Membership.objects.using(MainRouter.admin_db).create(
user=user, tenant=tenant, role=Membership.RoleChoices.OWNER
)
role = Role.objects.using(MainRouter.admin_db).create(
name="admin",
tenant_id=tenant.id,
manage_users=True,
manage_account=True,
manage_billing=True,
manage_providers=True,
manage_integrations=True,
manage_scans=True,
unlimited_visibility=True,
)
UserRoleRelationship.objects.using(MainRouter.admin_db).create(
user=user,
role=role,
tenant_id=tenant.id,
)
else:
request.session["saml_user_created"] = str(user.id)
@@ -0,0 +1,170 @@
"""
Cypher sanitizer for custom (user-supplied) Attack Paths queries.
Two responsibilities:
1. **Validation** - reject queries containing SSRF or dangerous procedure
patterns (defense-in-depth; the primary control is ``neo4j.READ_ACCESS``).
2. **Provider-scoped label injection** - inject a dynamic
``_Provider_{uuid}`` label into every node pattern so the database can
use its native label index for provider isolation.
Label-injection pipeline:
1. **Protect** string literals and line comments (placeholder replacement).
2. **Split** by top-level clause keywords to track clause context.
3. **Pass A** - inject into *labeled* node patterns in ALL segments.
4. **Pass B** - inject into *bare* node patterns in MATCH segments only.
5. **Restore** protected regions.
"""
import re
from rest_framework.exceptions import ValidationError
from tasks.jobs.attack_paths.config import get_provider_label
# Step 1 - String / comment protection
# Single combined regex: strings first, then line comments.
# The regex engine finds the leftmost match, so a string like 'https://prowler.com'
# is consumed as a string before the // inside it can match as a comment.
_PROTECTED_RE = re.compile(r"'(?:[^'\\]|\\.)*'|\"(?:[^\"\\]|\\.)*\"|//[^\n]*")
# Step 2 - Clause splitting
# OPTIONAL MATCH must come before MATCH to avoid partial matching.
_CLAUSE_RE = re.compile(
r"\b(OPTIONAL\s+MATCH|MATCH|WHERE|RETURN|WITH|ORDER\s+BY"
r"|SKIP|LIMIT|UNION|UNWIND|CALL)\b",
re.IGNORECASE,
)
# Pass A - Labeled node patterns (all segments)
# Matches node patterns that have at least one :Label.
# (?<!\w)\( - open paren NOT preceded by a word char (excludes function calls).
# Group 1: optional variable + one or more :Label
# Group 2: optional {properties} + closing paren
_LABELED_NODE_RE = re.compile(
r"(?<!\w)\("
r"("
r"\s*(?:[a-zA-Z_]\w*)?"
r"(?:\s*:\s*(?:`[^`]*`|[a-zA-Z_]\w*))+"
r")"
r"("
r"\s*(?:\{[^}]*\})?"
r"\s*\)"
r")"
)
# Pass B - Bare node patterns (MATCH segments only)
# Matches (identifier) or (identifier {properties}) without any :Label.
# Only applied in MATCH/OPTIONAL MATCH segments.
_BARE_NODE_RE = re.compile(
r"(?<!\w)\(" r"(\s*[a-zA-Z_]\w*)" r"(\s*(?:\{[^}]*\})?)" r"\s*\)"
)
_MATCH_CLAUSES = frozenset({"MATCH", "OPTIONAL MATCH"})
def _inject_labeled(segment: str, label: str) -> str:
"""Inject provider label into all node patterns that have existing labels."""
return _LABELED_NODE_RE.sub(rf"(\1:{label}\2", segment)
def _inject_bare(segment: str, label: str) -> str:
"""Inject provider label into bare `(identifier)` node patterns."""
def _replace(match):
var = match.group(1)
props = match.group(2).strip()
if props:
return f"({var}:{label} {props})"
return f"({var}:{label})"
return _BARE_NODE_RE.sub(_replace, segment)
def inject_provider_label(cypher: str, provider_id: str) -> str:
"""Rewrite a Cypher query to scope every node pattern to a provider.
Args:
cypher: The original Cypher query string.
provider_id: The provider UUID (will be converted to a label via
`get_provider_label`).
Returns:
The rewritten Cypher with `:_Provider_{uuid}` appended to every
node pattern.
"""
label = get_provider_label(provider_id)
# Step 1: Protect strings and comments (single pass, leftmost-first)
protected: list[str] = []
def _save(match):
protected.append(match.group(0))
return f"\x00P{len(protected) - 1}\x00"
work = _PROTECTED_RE.sub(_save, cypher)
# Step 2: Split by clause keywords
parts = _CLAUSE_RE.split(work)
# Steps 3-4: Apply injection passes per segment
result: list[str] = []
current_clause: str | None = None
for i, part in enumerate(parts):
if i % 2 == 1:
# Keyword token - normalize for clause tracking
current_clause = re.sub(r"\s+", " ", part.strip()).upper()
result.append(part)
else:
# Content segment - apply injection based on clause context
part = _inject_labeled(part, label)
if current_clause in _MATCH_CLAUSES:
part = _inject_bare(part, label)
result.append(part)
work = "".join(result)
# Step 5: Restore protected regions
for i, original in enumerate(protected):
work = work.replace(f"\x00P{i}\x00", original)
return work
# ---------------------------------------------------------------------------
# Validation
# ---------------------------------------------------------------------------
# Patterns that indicate SSRF or dangerous procedure calls
# Defense-in-depth layer - the primary control is `neo4j.READ_ACCESS`
_BLOCKED_PATTERNS = [
re.compile(r"\bLOAD\s+CSV\b", re.IGNORECASE),
re.compile(r"\bapoc\.load\b", re.IGNORECASE),
re.compile(r"\bapoc\.import\b", re.IGNORECASE),
re.compile(r"\bapoc\.export\b", re.IGNORECASE),
re.compile(r"\bapoc\.cypher\b", re.IGNORECASE),
re.compile(r"\bapoc\.systemdb\b", re.IGNORECASE),
re.compile(r"\bapoc\.config\b", re.IGNORECASE),
re.compile(r"\bapoc\.periodic\b", re.IGNORECASE),
re.compile(r"\bapoc\.do\b", re.IGNORECASE),
re.compile(r"\bapoc\.trigger\b", re.IGNORECASE),
re.compile(r"\bapoc\.custom\b", re.IGNORECASE),
]
def validate_custom_query(cypher: str) -> None:
"""Reject queries containing known SSRF or dangerous procedure patterns.
Raises ValidationError if a blocked pattern is found.
String literals and comments are stripped before matching to avoid
false positives.
"""
stripped = _PROTECTED_RE.sub("", cypher)
for pattern in _BLOCKED_PATTERNS:
if pattern.search(stripped):
raise ValidationError({"query": "Query contains a blocked operation"})
+7 -13
View File
@@ -11,8 +11,8 @@ from config.env import env
from django.conf import settings
from tasks.jobs.attack_paths.config import (
BATCH_SIZE,
PROVIDER_ID_PROPERTY,
PROVIDER_RESOURCE_LABEL,
get_provider_label,
)
from api.attack_paths.retryable_session import RetryableSession
@@ -163,11 +163,8 @@ def drop_subgraph(database: str, provider_id: str) -> int:
Uses batched deletion to avoid memory issues with large graphs.
Silently returns 0 if the database doesn't exist.
"""
provider_label = get_provider_label(provider_id)
deleted_nodes = 0
parameters = {
"provider_id": provider_id,
"batch_size": BATCH_SIZE,
}
try:
with get_session(database) as session:
@@ -175,12 +172,12 @@ def drop_subgraph(database: str, provider_id: str) -> int:
while deleted_count > 0:
result = session.run(
f"""
MATCH (n:{PROVIDER_RESOURCE_LABEL} {{{PROVIDER_ID_PROPERTY}: $provider_id}})
MATCH (n:{PROVIDER_RESOURCE_LABEL}:`{provider_label}`)
WITH n LIMIT $batch_size
DETACH DELETE n
RETURN COUNT(n) AS deleted_nodes_count
""",
parameters,
{"batch_size": BATCH_SIZE},
)
deleted_count = result.single().get("deleted_nodes_count", 0)
deleted_nodes += deleted_count
@@ -199,15 +196,12 @@ def has_provider_data(database: str, provider_id: str) -> bool:
Returns `False` if the database doesn't exist.
"""
query = (
f"MATCH (n:{PROVIDER_RESOURCE_LABEL} "
f"{{{PROVIDER_ID_PROPERTY}: $provider_id}}) "
"RETURN 1 LIMIT 1"
)
provider_label = get_provider_label(provider_id)
query = f"MATCH (n:{PROVIDER_RESOURCE_LABEL}:`{provider_label}`) RETURN 1 LIMIT 1"
try:
with get_session(database, default_access_mode=neo4j.READ_ACCESS) as session:
result = session.run(query, {"provider_id": provider_id})
result = session.run(query)
return result.single() is not None
except GraphDatabaseQueryException as exc:
File diff suppressed because it is too large Load Diff
@@ -1,13 +1,18 @@
from tasks.jobs.attack_paths.config import PROVIDER_ID_PROPERTY, PROVIDER_RESOURCE_LABEL
from tasks.jobs.attack_paths.config import PROVIDER_RESOURCE_LABEL, get_provider_label
def get_cartography_schema_query(provider_id: str) -> str:
"""Build the Cartography schema metadata query scoped to a provider label."""
provider_label = get_provider_label(provider_id)
return f"""
MATCH (n:{PROVIDER_RESOURCE_LABEL}:`{provider_label}`)
WHERE n._module_name STARTS WITH 'cartography:'
AND NOT n._module_name IN ['cartography:ontology', 'cartography:prowler']
AND n._module_version IS NOT NULL
RETURN n._module_name AS module_name, n._module_version AS module_version
LIMIT 1
"""
CARTOGRAPHY_SCHEMA_METADATA = f"""
MATCH (n:{PROVIDER_RESOURCE_LABEL} {{{PROVIDER_ID_PROPERTY}: $provider_id}})
WHERE n._module_name STARTS WITH 'cartography:'
AND NOT n._module_name IN ['cartography:ontology', 'cartography:prowler']
AND n._module_version IS NOT NULL
RETURN n._module_name AS module_name, n._module_version AS module_version
LIMIT 1
"""
GITHUB_SCHEMA_URL = (
"https://github.com/cartography-cncf/cartography/blob/"
@@ -1,22 +1,26 @@
import logging
import re
from typing import Any, Iterable
import neo4j
from rest_framework.exceptions import APIException, PermissionDenied, ValidationError
from api.attack_paths import database as graph_database, AttackPathsQueryDefinition
from api.attack_paths.cypher_sanitizer import (
inject_provider_label,
validate_custom_query,
)
from api.attack_paths.queries.schema import (
CARTOGRAPHY_SCHEMA_METADATA,
GITHUB_SCHEMA_URL,
RAW_SCHEMA_URL,
get_cartography_schema_query,
)
from config.custom_logging import BackendLogger
from tasks.jobs.attack_paths.config import (
INTERNAL_LABELS,
INTERNAL_PROPERTIES,
PROVIDER_ID_PROPERTY,
get_provider_label,
is_dynamic_isolation_label,
)
@@ -72,7 +76,6 @@ def prepare_parameters(
clean_parameters = {
"provider_uid": str(provider_uid),
"provider_id": str(provider_id),
}
for definition_parameter in definition.parameters:
@@ -123,38 +126,6 @@ def execute_query(
# Custom query helpers
# Patterns that indicate SSRF or dangerous procedure calls
# Defense-in-depth layer - the primary control is `neo4j.READ_ACCESS`
_BLOCKED_PATTERNS = [
re.compile(r"\bLOAD\s+CSV\b", re.IGNORECASE),
re.compile(r"\bapoc\.load\b", re.IGNORECASE),
re.compile(r"\bapoc\.import\b", re.IGNORECASE),
re.compile(r"\bapoc\.export\b", re.IGNORECASE),
re.compile(r"\bapoc\.cypher\b", re.IGNORECASE),
re.compile(r"\bapoc\.systemdb\b", re.IGNORECASE),
re.compile(r"\bapoc\.config\b", re.IGNORECASE),
re.compile(r"\bapoc\.periodic\b", re.IGNORECASE),
re.compile(r"\bapoc\.do\b", re.IGNORECASE),
re.compile(r"\bapoc\.trigger\b", re.IGNORECASE),
re.compile(r"\bapoc\.custom\b", re.IGNORECASE),
]
# Strip string literals so patterns inside quotes don't cause false positives
# Handles escaped quotes (\' and \") inside strings
_STRING_LITERALS = re.compile(r"'(?:[^'\\]|\\.)*'|\"(?:[^\"\\]|\\.)*\"")
def validate_custom_query(cypher: str) -> None:
"""Reject queries containing known SSRF or dangerous procedure patterns.
Raises ValidationError if a blocked pattern is found.
String literals are stripped before matching to avoid false positives.
"""
stripped = _STRING_LITERALS.sub("", cypher)
for pattern in _BLOCKED_PATTERNS:
if pattern.search(stripped):
raise ValidationError({"query": "Query contains a blocked operation"})
def normalize_custom_query_payload(raw_data):
if not isinstance(raw_data, dict):
@@ -173,7 +144,15 @@ def execute_custom_query(
cypher: str,
provider_id: str,
) -> dict[str, Any]:
# Defense-in-depth for custom queries:
# 1. neo4j.READ_ACCESS — prevents mutations at the driver level
# 2. inject_provider_label() — regex-based label injection scopes node patterns
# 3. _serialize_graph() — post-query filter drops nodes without the provider label
#
# Layer 2 is best-effort (regex can't fully parse Cypher);
# layer 3 is the safety net that guarantees provider isolation.
validate_custom_query(cypher)
cypher = inject_provider_label(cypher, provider_id)
try:
graph = graph_database.execute_read_query(
@@ -208,10 +187,7 @@ def get_cartography_schema(
with graph_database.get_session(
database_name, default_access_mode=neo4j.READ_ACCESS
) as session:
result = session.run(
CARTOGRAPHY_SCHEMA_METADATA,
{"provider_id": provider_id},
)
result = session.run(get_cartography_schema_query(provider_id))
record = result.single()
except graph_database.GraphDatabaseQueryException as exc:
logger.error(f"Cartography schema query failed: {exc}")
@@ -255,10 +231,12 @@ def _truncate_graph(graph: dict[str, Any]) -> dict[str, Any]:
def _serialize_graph(graph, provider_id: str) -> dict[str, Any]:
provider_label = get_provider_label(provider_id)
nodes = []
kept_node_ids = set()
for node in graph.nodes:
if node._properties.get(PROVIDER_ID_PROPERTY) != provider_id:
if provider_label not in node.labels:
continue
kept_node_ids.add(node.element_id)
@@ -273,14 +251,11 @@ def _serialize_graph(graph, provider_id: str) -> dict[str, Any]:
filtered_count = len(graph.nodes) - len(nodes)
if filtered_count > 0:
logger.debug(
f"Filtered {filtered_count} nodes without matching provider_id={provider_id}"
f"Filtered {filtered_count} nodes without provider label {provider_label}"
)
relationships = []
for relationship in graph.relationships:
if relationship._properties.get(PROVIDER_ID_PROPERTY) != provider_id:
continue
if (
relationship.start_node.element_id not in kept_node_ids
or relationship.end_node.element_id not in kept_node_ids
+25 -37
View File
@@ -1,10 +1,10 @@
from django.conf import settings
from django.core.exceptions import ObjectDoesNotExist
from django.db import transaction
from rest_framework import permissions
from rest_framework.exceptions import NotAuthenticated
from rest_framework.filters import SearchFilter
from rest_framework.permissions import SAFE_METHODS
from rest_framework.response import Response
from rest_framework_json_api import filters
from rest_framework_json_api.views import ModelViewSet
@@ -12,7 +12,7 @@ from api.authentication import CombinedJWTOrAPIKeyAuthentication
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, Tenant
from api.models import Role, UserRoleRelationship
from api.rbac.permissions import HasPermissions
@@ -90,12 +90,11 @@ class BaseRLSViewSet(BaseViewSet):
if tenant_id is None:
raise NotAuthenticated("Tenant ID is not present in token")
for attempt in rls_transaction(
with rls_transaction(
tenant_id, using=getattr(self, "db_alias", MainRouter.default_db)
):
with attempt:
self.request.tenant_id = tenant_id
return super().initial(request, *args, **kwargs)
self.request.tenant_id = tenant_id
return super().initial(request, *args, **kwargs)
def get_serializer_context(self):
context = super().get_serializer_context()
@@ -114,27 +113,22 @@ class BaseTenantViewset(BaseViewSet):
if request is not None:
request.db_alias = self.db_alias
with transaction.atomic(using=self.db_alias):
tenant = super().dispatch(request, *args, **kwargs)
try:
# If the request is a POST, create the admin role
if request.method == "POST":
isinstance(tenant, dict) and self._create_admin_role(
tenant.data["id"]
)
except Exception as e:
self._handle_creation_error(e, tenant)
raise
return tenant
if request.method == "POST":
with transaction.atomic(using=MainRouter.admin_db):
tenant = super().dispatch(request, *args, **kwargs)
if isinstance(tenant, Response) and tenant.status_code == 201:
self._create_admin_role(tenant.data["id"])
return tenant
else:
with transaction.atomic(using=self.db_alias):
return super().dispatch(request, *args, **kwargs)
finally:
if alias_token is not None:
reset_read_db_alias(alias_token)
self.db_alias = MainRouter.default_db
def _create_admin_role(self, tenant_id):
Role.objects.using(MainRouter.admin_db).create(
admin_role = Role.objects.using(MainRouter.admin_db).create(
name="admin",
tenant_id=tenant_id,
manage_users=True,
@@ -145,15 +139,11 @@ class BaseTenantViewset(BaseViewSet):
manage_scans=True,
unlimited_visibility=True,
)
def _handle_creation_error(self, error, tenant):
if tenant.data.get("id"):
try:
Tenant.objects.using(MainRouter.admin_db).filter(
id=tenant.data["id"]
).delete()
except ObjectDoesNotExist:
pass # Tenant might not exist, handle gracefully
UserRoleRelationship.objects.using(MainRouter.admin_db).create(
user=self.request.user,
role=admin_role,
tenant_id=tenant_id,
)
def initial(self, request, *args, **kwargs):
if request.auth is None:
@@ -164,13 +154,12 @@ class BaseTenantViewset(BaseViewSet):
raise NotAuthenticated("Tenant ID is not present in token")
user_id = str(request.user.id)
for attempt in rls_transaction(
with rls_transaction(
value=user_id,
parameter=POSTGRES_USER_VAR,
using=getattr(self, "db_alias", MainRouter.default_db),
):
with attempt:
return super().initial(request, *args, **kwargs)
return super().initial(request, *args, **kwargs)
class BaseUserViewset(BaseViewSet):
@@ -202,9 +191,8 @@ class BaseUserViewset(BaseViewSet):
if tenant_id is None:
raise NotAuthenticated("Tenant ID is not present in token")
for attempt in rls_transaction(
with rls_transaction(
tenant_id, using=getattr(self, "db_alias", MainRouter.default_db)
):
with attempt:
self.request.tenant_id = tenant_id
return super().initial(request, *args, **kwargs)
self.request.tenant_id = tenant_id
return super().initial(request, *args, **kwargs)
+66 -169
View File
@@ -71,14 +71,21 @@ def psycopg_connection(database_alias: str):
@contextmanager
def _rls_transaction_context_manager(
def rls_transaction(
value: str,
parameter: str = POSTGRES_TENANT_VAR,
using: str | None = None,
retry_on_replica: bool = True,
):
"""Internal context manager that opens a single RLS transaction.
"""
Creates a new database transaction setting the given configuration value for Postgres RLS. It validates the
if the value is a valid UUID.
Callers should use ``rls_transaction`` (the public class) instead.
Args:
value (str): Database configuration parameter value.
parameter (str): Database configuration parameter name, by default is 'api.tenant_id'.
using (str | None): Optional database alias to run the transaction against. Defaults to the
active read alias (if any) or Django's default connection.
"""
requested_alias = using or get_read_db_alias()
db_alias = requested_alias or DEFAULT_DB_ALIAS
@@ -86,164 +93,54 @@ def _rls_transaction_context_manager(
db_alias = DEFAULT_DB_ALIAS
alias = db_alias
router_token = None
conn = connections[alias]
try:
if alias != DEFAULT_DB_ALIAS:
router_token = set_read_db_alias(alias)
is_replica = READ_REPLICA_ALIAS and alias == READ_REPLICA_ALIAS
max_attempts = REPLICA_MAX_ATTEMPTS if is_replica and retry_on_replica else 1
with transaction.atomic(using=alias):
with conn.cursor() as cursor:
try:
uuid.UUID(str(value))
except ValueError:
raise ValidationError("Must be a valid UUID")
cursor.execute(SET_CONFIG_QUERY, [parameter, value])
yield cursor
finally:
if router_token is not None:
reset_read_db_alias(router_token)
for attempt in range(1, max_attempts + 1):
router_token = None
yielded_cursor = False
class rls_transaction:
"""RLS transaction with retry and replica-to-primary fallback.
Usage::
for attempt in rls_transaction(tenant_id, using=READ_REPLICA_ALIAS):
with attempt:
result = Model.objects.filter(...)
When ``using`` points to a read replica and ``retry_on_replica`` is True,
the iterator yields up to ``REPLICA_MAX_ATTEMPTS`` attempts on the replica
followed by one final attempt on the primary DB. For primary-only calls
the iterator yields a single attempt with no retry.
"""
def __init__(
self,
value: str,
parameter: str = POSTGRES_TENANT_VAR,
using: str | None = None,
retry_on_replica: bool = True,
):
self.value = value
self.parameter = parameter
self.using = using
self.retry_on_replica = retry_on_replica
def __iter__(self):
return _RLSRetryIterator(self)
class _RLSRetryIterator:
def __init__(self, parent: rls_transaction):
self._parent = parent
self._attempt = 0
self._done = False
requested = parent.using or get_read_db_alias()
self._db_alias = requested or DEFAULT_DB_ALIAS
if self._db_alias not in connections:
self._db_alias = DEFAULT_DB_ALIAS
self._is_replica = bool(
READ_REPLICA_ALIAS and self._db_alias == READ_REPLICA_ALIAS
)
if self._is_replica and parent.retry_on_replica:
# N replica attempts + 1 primary fallback
self._max_attempts = REPLICA_MAX_ATTEMPTS + 1
else:
self._max_attempts = 1
def __iter__(self):
return self
def __next__(self):
if self._done or self._attempt >= self._max_attempts:
raise StopIteration
self._attempt += 1
is_final = self._attempt == self._max_attempts
if is_final and self._is_replica and self._parent.retry_on_replica:
# On final attempt, fallback to primary
if attempt == max_attempts and is_replica:
logger.warning(
f"RLS transaction failed after {attempt - 1} attempts on replica, "
f"falling back to primary DB"
)
alias = DEFAULT_DB_ALIAS
if self._attempt > 1:
logger.warning(
f"RLS transaction failed after {self._attempt - 1} attempts on replica, "
f"falling back to primary DB"
)
else:
alias = self._db_alias
return _RLSAttempt(self, alias, is_final)
class _RLSAttempt:
def __init__(self, iterator: _RLSRetryIterator, alias: str, is_final: bool):
self._iterator = iterator
self._alias = alias
self._is_final = is_final
self._context_manager = None
def __enter__(self):
# Retry loop for connection-setup errors (pre-yield).
# Python does NOT call __exit__ when __enter__ raises, so we
# must handle retries here for errors like "connection refused".
while True:
try:
self._context_manager = _rls_transaction_context_manager(
self._iterator._parent.value,
self._iterator._parent.parameter,
using=self._alias,
)
return self._context_manager.__enter__()
except OperationalError as exc:
if self._is_final or not self._iterator._is_replica:
raise
self._handle_retry(exc)
# Consume the next attempt from the iterator
next_att = next(self._iterator)
self._alias = next_att._alias
self._is_final = next_att._is_final
def __exit__(self, exc_type, exc_val, exc_tb):
conn = connections[alias]
try:
self._context_manager.__exit__(exc_type, exc_val, exc_tb)
except OperationalError as exc:
if self._is_final or not self._iterator._is_replica:
if alias != DEFAULT_DB_ALIAS:
router_token = set_read_db_alias(alias)
with transaction.atomic(using=alias):
with conn.cursor() as cursor:
try:
# just in case the value is a UUID object
uuid.UUID(str(value))
except ValueError:
raise ValidationError("Must be a valid UUID")
cursor.execute(SET_CONFIG_QUERY, [parameter, value])
yielded_cursor = True
yield cursor
return
except OperationalError as e:
if yielded_cursor:
raise
# If on primary or max attempts reached, raise
if not is_replica or attempt == max_attempts:
raise
self._handle_retry(exc)
return True
if exc_type is None:
self._iterator._done = True
return False
if (
issubclass(exc_type, OperationalError)
and not self._is_final
and self._iterator._is_replica
):
self._handle_retry(exc_val)
return True
return False
def _handle_retry(self, error):
try:
connections[self._alias].close()
except Exception:
pass
attempt = self._iterator._attempt
max_att = self._iterator._max_attempts
delay = REPLICA_RETRY_BASE_DELAY * (2 ** (attempt - 1))
logger.info(
f"RLS transaction failed on replica (attempt {attempt}/{max_att}), "
f"retrying in {delay:.1f}s. Error: {error}"
)
time.sleep(delay)
# Retry with exponential backoff
delay = REPLICA_RETRY_BASE_DELAY * (2 ** (attempt - 1))
logger.info(
f"RLS transaction failed on replica (attempt {attempt}/{max_attempts}), "
f"retrying in {delay}s. Error: {e}"
)
time.sleep(delay)
finally:
if router_token is not None:
reset_read_db_alias(router_token)
class CustomUserManager(BaseUserManager):
@@ -300,21 +197,23 @@ def batch_delete(tenant_id, queryset, batch_size=settings.DJANGO_DELETION_BATCH_
deletion_summary = {}
while True:
for attempt in rls_transaction(tenant_id, POSTGRES_TENANT_VAR):
with attempt:
# Get a batch of IDs to delete
batch_ids = set(
queryset.values_list("id", flat=True).order_by("id")[:batch_size]
)
if not batch_ids:
return total_deleted, deletion_summary
with rls_transaction(tenant_id, POSTGRES_TENANT_VAR):
# Get a batch of IDs to delete
batch_ids = set(
queryset.values_list("id", flat=True).order_by("id")[:batch_size]
)
if not batch_ids:
# No more objects to delete
break
deleted_count, deleted_info = queryset.filter(id__in=batch_ids).delete()
deleted_count, deleted_info = queryset.filter(id__in=batch_ids).delete()
total_deleted += deleted_count
for model_label, count in deleted_info.items():
deletion_summary[model_label] = deletion_summary.get(model_label, 0) + count
return total_deleted, deletion_summary
def delete_related_daily_task(provider_id: str):
"""
@@ -346,9 +245,8 @@ def create_objects_in_batches(
total = len(objects)
for i in range(0, total, batch_size):
chunk = objects[i : i + batch_size]
for attempt in rls_transaction(value=tenant_id, parameter=POSTGRES_TENANT_VAR):
with attempt:
model.objects.bulk_create(chunk, batch_size)
with rls_transaction(value=tenant_id, parameter=POSTGRES_TENANT_VAR):
model.objects.bulk_create(chunk, batch_size)
def update_objects_in_batches(
@@ -370,9 +268,8 @@ def update_objects_in_batches(
total = len(objects)
for start in range(0, total, batch_size):
chunk = objects[start : start + batch_size]
for attempt in rls_transaction(value=tenant_id, parameter=POSTGRES_TENANT_VAR):
with attempt:
model.objects.bulk_update(chunk, fields, batch_size)
with rls_transaction(value=tenant_id, parameter=POSTGRES_TENANT_VAR):
model.objects.bulk_update(chunk, fields, batch_size)
# Postgres Enums
+15 -16
View File
@@ -97,24 +97,23 @@ def handle_provider_deletion(func):
tenant_id = kwargs.get("tenant_id")
provider_id = kwargs.get("provider_id")
for attempt in rls_transaction(tenant_id, using=READ_REPLICA_ALIAS):
with attempt:
if provider_id is None:
scan_id = kwargs.get("scan_id")
if scan_id is None:
raise AssertionError(
"This task does not have provider or scan in the kwargs"
)
scan = Scan.objects.filter(pk=scan_id).first()
if scan is None:
raise ProviderDeletedException(
f"Provider for scan '{scan_id}' was deleted during the scan"
) from None
provider_id = str(scan.provider_id)
if not Provider.objects.filter(pk=provider_id).exists():
with rls_transaction(tenant_id, using=READ_REPLICA_ALIAS):
if provider_id is None:
scan_id = kwargs.get("scan_id")
if scan_id is None:
raise AssertionError(
"This task does not have provider or scan in the kwargs"
)
scan = Scan.objects.filter(pk=scan_id).first()
if scan is None:
raise ProviderDeletedException(
f"Provider '{provider_id}' was deleted during the scan"
f"Provider for scan '{scan_id}' was deleted during the scan"
) from None
provider_id = str(scan.provider_id)
if not Provider.objects.filter(pk=provider_id).exists():
raise ProviderDeletedException(
f"Provider '{provider_id}' was deleted during the scan"
) from None
raise
return wrapper
+171 -12
View File
@@ -15,6 +15,7 @@ from django_filters.rest_framework import (
from rest_framework_json_api.django_filters.backends import DjangoFilterBackend
from rest_framework_json_api.serializers import ValidationError
from api.constants import SEVERITY_ORDER
from api.db_utils import (
FindingDeltaEnumField,
InvitationStateEnumField,
@@ -43,6 +44,7 @@ from api.models import (
ProviderGroup,
ProviderSecret,
Resource,
ResourceFindingMapping,
ResourceTag,
Role,
Scan,
@@ -196,17 +198,13 @@ class CommonFindingFilters(FilterSet):
field_name="resource_services", lookup_expr="icontains"
)
resource_uid = CharFilter(field_name="resources__uid")
resource_uid__in = CharInFilter(field_name="resources__uid", lookup_expr="in")
resource_uid__icontains = CharFilter(
field_name="resources__uid", lookup_expr="icontains"
)
resource_uid = CharFilter(method="filter_resource_uid")
resource_uid__in = CharInFilter(method="filter_resource_uid_in")
resource_uid__icontains = CharFilter(method="filter_resource_uid_icontains")
resource_name = CharFilter(field_name="resources__name")
resource_name__in = CharInFilter(field_name="resources__name", lookup_expr="in")
resource_name__icontains = CharFilter(
field_name="resources__name", lookup_expr="icontains"
)
resource_name = CharFilter(method="filter_resource_name")
resource_name__in = CharInFilter(method="filter_resource_name_in")
resource_name__icontains = CharFilter(method="filter_resource_name_icontains")
resource_type = CharFilter(method="filter_resource_type")
resource_type__in = CharInFilter(field_name="resource_types", lookup_expr="overlap")
@@ -264,6 +262,52 @@ class CommonFindingFilters(FilterSet):
)
return queryset.filter(overall_query).distinct()
def filter_check_title_icontains(self, queryset, name, value):
# Resolve from the summary table (has check_title column + trigram
# GIN index) instead of scanning JSON in the findings table.
matching_check_ids = (
FindingGroupDailySummary.objects.filter(
check_title__icontains=value,
)
.values_list("check_id", flat=True)
.distinct()
)
return queryset.filter(check_id__in=matching_check_ids)
# --- Resource subquery filters ---
# Resolve resource → RFM → finding_ids first, then filter findings
# by id__in. This avoids a 3-way JOIN driven from the (huge)
# findings side and lets PostgreSQL start from the resources
# unique-constraint index instead.
@staticmethod
def _finding_ids_for_resources(**lookup):
return ResourceFindingMapping.objects.filter(
resource__in=Resource.objects.filter(**lookup).values("id")
).values("finding_id")
def filter_resource_uid(self, queryset, name, value):
return queryset.filter(id__in=self._finding_ids_for_resources(uid=value))
def filter_resource_uid_in(self, queryset, name, value):
return queryset.filter(id__in=self._finding_ids_for_resources(uid__in=value))
def filter_resource_uid_icontains(self, queryset, name, value):
return queryset.filter(
id__in=self._finding_ids_for_resources(uid__icontains=value)
)
def filter_resource_name(self, queryset, name, value):
return queryset.filter(id__in=self._finding_ids_for_resources(name=value))
def filter_resource_name_in(self, queryset, name, value):
return queryset.filter(id__in=self._finding_ids_for_resources(name__in=value))
def filter_resource_name_icontains(self, queryset, name, value):
return queryset.filter(
id__in=self._finding_ids_for_resources(name__icontains=value)
)
class TenantFilter(FilterSet):
inserted_at = DateFilter(field_name="inserted_at", lookup_expr="date")
@@ -390,6 +434,7 @@ class ScanFilter(ProviderRelationshipFilterSet):
class Meta:
model = Scan
fields = {
"id": ["exact", "in"],
"provider": ["exact", "in"],
"name": ["exact", "icontains"],
"started_at": ["gte", "lte"],
@@ -803,11 +848,15 @@ class FindingGroupFilter(CommonFindingFilters):
check_id = CharFilter(field_name="check_id", lookup_expr="exact")
check_id__in = CharInFilter(field_name="check_id", lookup_expr="in")
check_id__icontains = CharFilter(field_name="check_id", lookup_expr="icontains")
check_title__icontains = CharFilter(method="filter_check_title_icontains")
scan = UUIDFilter(field_name="scan_id", lookup_expr="exact")
scan__in = UUIDInFilter(field_name="scan_id", lookup_expr="in")
class Meta:
model = Finding
fields = {
"check_id": ["exact", "in", "icontains"],
"scan": ["exact", "in"],
}
def filter_queryset(self, queryset):
@@ -895,15 +944,31 @@ class LatestFindingGroupFilter(CommonFindingFilters):
check_id = CharFilter(field_name="check_id", lookup_expr="exact")
check_id__in = CharInFilter(field_name="check_id", lookup_expr="in")
check_id__icontains = CharFilter(field_name="check_id", lookup_expr="icontains")
check_title__icontains = CharFilter(method="filter_check_title_icontains")
scan = UUIDFilter(field_name="scan_id", lookup_expr="exact")
scan__in = UUIDInFilter(field_name="scan_id", lookup_expr="in")
class Meta:
model = Finding
fields = {
"check_id": ["exact", "in", "icontains"],
"scan": ["exact", "in"],
}
class FindingGroupSummaryFilter(FilterSet):
class _CheckTitleToCheckIdMixin:
"""Resolve check_title search to check_ids so all provider rows are kept."""
def filter_check_title_to_check_ids(self, queryset, name, value):
matching_check_ids = (
queryset.filter(check_title__icontains=value)
.values_list("check_id", flat=True)
.distinct()
)
return queryset.filter(check_id__in=matching_check_ids)
class FindingGroupSummaryFilter(_CheckTitleToCheckIdMixin, FilterSet):
"""
Filter for FindingGroupDailySummary queries.
@@ -926,6 +991,7 @@ class FindingGroupSummaryFilter(FilterSet):
check_id = CharFilter(field_name="check_id", lookup_expr="exact")
check_id__in = CharInFilter(field_name="check_id", lookup_expr="in")
check_id__icontains = CharFilter(field_name="check_id", lookup_expr="icontains")
check_title__icontains = CharFilter(method="filter_check_title_to_check_ids")
# Provider filters
provider_id = UUIDFilter(field_name="provider_id", lookup_expr="exact")
@@ -1013,7 +1079,7 @@ class FindingGroupSummaryFilter(FilterSet):
return dt
class LatestFindingGroupSummaryFilter(FilterSet):
class LatestFindingGroupSummaryFilter(_CheckTitleToCheckIdMixin, FilterSet):
"""
Filter for FindingGroupDailySummary /latest endpoint.
@@ -1025,6 +1091,7 @@ class LatestFindingGroupSummaryFilter(FilterSet):
check_id = CharFilter(field_name="check_id", lookup_expr="exact")
check_id__in = CharInFilter(field_name="check_id", lookup_expr="in")
check_id__icontains = CharFilter(field_name="check_id", lookup_expr="icontains")
check_title__icontains = CharFilter(method="filter_check_title_to_check_ids")
# Provider filters
provider_id = UUIDFilter(field_name="provider_id", lookup_expr="exact")
@@ -1042,6 +1109,98 @@ class LatestFindingGroupSummaryFilter(FilterSet):
}
class FindingGroupAggregatedComputedFilter(FilterSet):
"""Filter aggregated finding-group rows by computed status/severity/muted."""
STATUS_CHOICES = (
("FAIL", "Fail"),
("PASS", "Pass"),
("MUTED", "Muted"),
)
status = ChoiceFilter(method="filter_status", choices=STATUS_CHOICES)
status__in = CharInFilter(method="filter_status_in", lookup_expr="in")
severity = ChoiceFilter(method="filter_severity", choices=SeverityChoices)
severity__in = CharInFilter(method="filter_severity_in", lookup_expr="in")
include_muted = BooleanFilter(method="filter_include_muted")
def filter_status(self, queryset, name, value):
return queryset.filter(aggregated_status=value)
def filter_status_in(self, queryset, name, value):
values = value
if isinstance(value, str):
values = [part.strip() for part in value.split(",") if part.strip()]
allowed = {choice[0] for choice in self.STATUS_CHOICES}
invalid = [
status_value for status_value in values if status_value not in allowed
]
if invalid:
raise ValidationError(
[
{
"detail": f"invalid status filter: {invalid[0]}",
"status": "400",
"source": {"pointer": "/data"},
"code": "invalid",
}
]
)
if not values:
return queryset
return queryset.filter(aggregated_status__in=values)
def filter_severity(self, queryset, name, value):
severity_order = SEVERITY_ORDER.get(value)
if severity_order is None:
raise ValidationError(
[
{
"detail": f"invalid severity filter: {value}",
"status": "400",
"source": {"pointer": "/data"},
"code": "invalid",
}
]
)
return queryset.filter(severity_order=severity_order)
def filter_severity_in(self, queryset, name, value):
values = value
if isinstance(value, str):
values = [part.strip() for part in value.split(",") if part.strip()]
orders = []
for severity_value in values:
severity_order = SEVERITY_ORDER.get(severity_value)
if severity_order is None:
raise ValidationError(
[
{
"detail": f"invalid severity filter: {severity_value}",
"status": "400",
"source": {"pointer": "/data"},
"code": "invalid",
}
]
)
orders.append(severity_order)
if not orders:
return queryset
return queryset.filter(severity_order__in=orders)
def filter_include_muted(self, queryset, name, value):
if value is True:
return queryset
# include_muted=false: exclude fully-muted groups
return queryset.exclude(fail_count=0, pass_count=0, muted_count__gt=0)
class ProviderSecretFilter(FilterSet):
inserted_at = DateFilter(
field_name="inserted_at",
@@ -97,29 +97,27 @@ class Command(BaseCommand):
):
possible_types.append(check_metadata.ResourceType)
for attempt in rls_transaction(tenant_id):
with attempt:
provider, _ = Provider.all_objects.get_or_create(
tenant_id=tenant_id,
provider="aws",
connected=True,
uid=str(random.randint(100000000000, 999999999999)),
defaults={
"alias": alias,
},
)
with rls_transaction(tenant_id):
provider, _ = Provider.all_objects.get_or_create(
tenant_id=tenant_id,
provider="aws",
connected=True,
uid=str(random.randint(100000000000, 999999999999)),
defaults={
"alias": alias,
},
)
for attempt in rls_transaction(tenant_id):
with attempt:
scan = Scan.all_objects.create(
tenant_id=tenant_id,
provider=provider,
name=alias,
trigger="manual",
state="executing",
progress=0,
started_at=datetime.now(timezone.utc),
)
with rls_transaction(tenant_id):
scan = Scan.all_objects.create(
tenant_id=tenant_id,
provider=provider,
name=alias,
trigger="manual",
state="executing",
progress=0,
started_at=datetime.now(timezone.utc),
)
scan_state = "completed"
try:
@@ -143,15 +141,13 @@ class Command(BaseCommand):
num_batches = ceil(len(resources) / batch_size)
self.stdout.write(self.style.WARNING("Creating resources..."))
for i in tqdm(range(0, len(resources), batch_size), total=num_batches):
for attempt in rls_transaction(tenant_id):
with attempt:
Resource.all_objects.bulk_create(resources[i : i + batch_size])
with rls_transaction(tenant_id):
Resource.all_objects.bulk_create(resources[i : i + batch_size])
self.stdout.write(self.style.SUCCESS("Resources created successfully.\n\n"))
for attempt in rls_transaction(tenant_id):
with attempt:
scan.progress = 33
scan.save()
with rls_transaction(tenant_id):
scan.progress = 33
scan.save()
# Create Findings
findings = []
@@ -197,15 +193,13 @@ class Command(BaseCommand):
num_batches = ceil(len(findings) / batch_size)
self.stdout.write(self.style.WARNING("Creating findings..."))
for i in tqdm(range(0, len(findings), batch_size), total=num_batches):
for attempt in rls_transaction(tenant_id):
with attempt:
Finding.all_objects.bulk_create(findings[i : i + batch_size])
with rls_transaction(tenant_id):
Finding.all_objects.bulk_create(findings[i : i + batch_size])
self.stdout.write(self.style.SUCCESS("Findings created successfully.\n\n"))
for attempt in rls_transaction(tenant_id):
with attempt:
scan.progress = 66
scan.save()
with rls_transaction(tenant_id):
scan.progress = 66
scan.save()
# Create ResourceFindingMapping
mappings = []
@@ -233,21 +227,19 @@ class Command(BaseCommand):
self.style.WARNING("Creating resource-finding mappings...")
)
for i in tqdm(range(0, len(mappings), batch_size), total=num_batches):
for attempt in rls_transaction(tenant_id):
with attempt:
ResourceFindingMapping.objects.bulk_create(
mappings[i : i + batch_size]
)
with rls_transaction(tenant_id):
ResourceFindingMapping.objects.bulk_create(
mappings[i : i + batch_size]
)
self.stdout.write(
self.style.SUCCESS(
"Resource-finding mappings created successfully.\n\n"
)
)
for attempt in rls_transaction(tenant_id):
with attempt:
scan.progress = 99
scan.save()
with rls_transaction(tenant_id):
scan.progress = 99
scan.save()
self.stdout.write(self.style.WARNING("Creating finding filter values..."))
resource_scan_summaries = [
@@ -262,18 +254,16 @@ class Command(BaseCommand):
for resource_id, service, region, resource_type in scan_resource_cache
]
num_batches = ceil(len(resource_scan_summaries) / batch_size)
for attempt in rls_transaction(tenant_id):
with attempt:
for i in tqdm(
range(0, len(resource_scan_summaries), batch_size),
total=num_batches,
):
for inner_attempt in rls_transaction(tenant_id):
with inner_attempt:
ResourceScanSummary.objects.bulk_create(
resource_scan_summaries[i : i + batch_size],
ignore_conflicts=True,
)
with rls_transaction(tenant_id):
for i in tqdm(
range(0, len(resource_scan_summaries), batch_size),
total=num_batches,
):
with rls_transaction(tenant_id):
ResourceScanSummary.objects.bulk_create(
resource_scan_summaries[i : i + batch_size],
ignore_conflicts=True,
)
self.stdout.write(
self.style.SUCCESS("Finding filter values created successfully.\n\n")
@@ -289,8 +279,7 @@ class Command(BaseCommand):
scan.progress = 100
scan.state = scan_state
scan.unique_resource_count = num_resources
for attempt in rls_transaction(tenant_id):
with attempt:
scan.save()
with rls_transaction(tenant_id):
scan.save()
self.stdout.write(self.style.NOTICE("Successfully populated test data."))
@@ -29,17 +29,16 @@ def migrate_daily_scheduled_scan_tasks(apps, schema_editor):
else:
next_scan_date = scheduled_time_today + timedelta(days=1)
for attempt in rls_transaction(tenant_id):
with attempt:
Scan.objects.create(
tenant_id=tenant_id,
name="Daily scheduled scan",
provider_id=provider_id,
trigger=Scan.TriggerChoices.SCHEDULED,
state=StateChoices.SCHEDULED,
scheduled_at=next_scan_date,
scheduler_task_id=daily_scheduled_scan_task.id,
)
with rls_transaction(tenant_id):
Scan.objects.create(
tenant_id=tenant_id,
name="Daily scheduled scan",
provider_id=provider_id,
trigger=Scan.TriggerChoices.SCHEDULED,
state=StateChoices.SCHEDULED,
scheduled_at=next_scan_date,
scheduler_task_id=daily_scheduled_scan_task.id,
)
class Migration(migrations.Migration):
@@ -0,0 +1,31 @@
# Generated by Django 5.1.15 on 2026-03-18
from django.contrib.postgres.indexes import GinIndex, OpClass
from django.contrib.postgres.operations import AddIndexConcurrently
from django.db import migrations
from django.db.models.functions import Upper
class Migration(migrations.Migration):
atomic = False
dependencies = [
("api", "0084_googleworkspace_provider"),
]
operations = [
AddIndexConcurrently(
model_name="findinggroupdailysummary",
index=GinIndex(
OpClass(Upper("check_id"), name="gin_trgm_ops"),
name="fgds_check_id_trgm_idx",
),
),
AddIndexConcurrently(
model_name="findinggroupdailysummary",
index=GinIndex(
OpClass(Upper("check_title"), name="gin_trgm_ops"),
name="fgds_check_title_trgm_idx",
),
),
]
@@ -0,0 +1,49 @@
from django.db import migrations
TASK_NAME = "attack-paths-cleanup-stale-scans"
INTERVAL_HOURS = 1
def create_periodic_task(apps, schema_editor):
IntervalSchedule = apps.get_model("django_celery_beat", "IntervalSchedule")
PeriodicTask = apps.get_model("django_celery_beat", "PeriodicTask")
schedule, _ = IntervalSchedule.objects.get_or_create(
every=INTERVAL_HOURS,
period="hours",
)
PeriodicTask.objects.update_or_create(
name=TASK_NAME,
defaults={
"task": TASK_NAME,
"interval": schedule,
"enabled": True,
},
)
def delete_periodic_task(apps, schema_editor):
IntervalSchedule = apps.get_model("django_celery_beat", "IntervalSchedule")
PeriodicTask = apps.get_model("django_celery_beat", "PeriodicTask")
PeriodicTask.objects.filter(name=TASK_NAME).delete()
# Clean up the schedule if no other task references it
IntervalSchedule.objects.filter(
every=INTERVAL_HOURS,
period="hours",
periodictask__isnull=True,
).delete()
class Migration(migrations.Migration):
dependencies = [
("api", "0085_finding_group_daily_summary_trgm_indexes"),
("django_celery_beat", "0019_alter_periodictasks_options"),
]
operations = [
migrations.RunPython(create_periodic_task, delete_periodic_task),
]
@@ -0,0 +1,40 @@
from django.db import migrations
import api.db_utils
class Migration(migrations.Migration):
dependencies = [
("api", "0086_attack_paths_cleanup_periodic_task"),
]
operations = [
migrations.AlterField(
model_name="provider",
name="provider",
field=api.db_utils.ProviderEnumField(
choices=[
("aws", "AWS"),
("azure", "Azure"),
("gcp", "GCP"),
("kubernetes", "Kubernetes"),
("m365", "M365"),
("github", "GitHub"),
("mongodbatlas", "MongoDB Atlas"),
("iac", "IaC"),
("oraclecloud", "Oracle Cloud Infrastructure"),
("alibabacloud", "Alibaba Cloud"),
("cloudflare", "Cloudflare"),
("openstack", "OpenStack"),
("image", "Image"),
("googleworkspace", "Google Workspace"),
("vercel", "Vercel"),
],
default="aws",
),
),
migrations.RunSQL(
"ALTER TYPE provider ADD VALUE IF NOT EXISTS 'vercel';",
reverse_sql=migrations.RunSQL.noop,
),
]
+23 -1
View File
@@ -1,14 +1,15 @@
import json
import logging
import re
import xml.etree.ElementTree as ET
from datetime import datetime, timedelta, timezone
from uuid import UUID, uuid4
import defusedxml
from allauth.socialaccount.models import SocialApp
from config.custom_logging import BackendLogger
from config.settings.social_login import SOCIALACCOUNT_PROVIDERS
from cryptography.fernet import Fernet, InvalidToken
from defusedxml import ElementTree as ET
from django.conf import settings
from django.contrib.auth.models import AbstractBaseUser
from django.contrib.postgres.fields import ArrayField
@@ -294,6 +295,7 @@ class Provider(RowLevelSecurityProtectedModel):
OPENSTACK = "openstack", _("OpenStack")
IMAGE = "image", _("Image")
GOOGLEWORKSPACE = "googleworkspace", _("Google Workspace")
VERCEL = "vercel", _("Vercel")
@staticmethod
def validate_aws_uid(value):
@@ -437,6 +439,15 @@ class Provider(RowLevelSecurityProtectedModel):
pointer="/data/attributes/uid",
)
@staticmethod
def validate_vercel_uid(value):
if not re.match(r"^team_[a-zA-Z0-9]{16,32}$", value):
raise ModelValidationError(
detail="Vercel provider ID must be a valid Vercel Team ID (e.g., team_xxxxxxxxxxxxxxxxxxxxxxxx).",
code="vercel-uid",
pointer="/data/attributes/uid",
)
@staticmethod
def validate_image_uid(value):
if not re.match(r"^[a-zA-Z0-9][a-zA-Z0-9._/:@-]{2,249}$", value):
@@ -1783,6 +1794,15 @@ class FindingGroupDailySummary(RowLevelSecurityProtectedModel):
fields=["tenant_id", "provider", "inserted_at"],
name="fgds_tenant_prov_ins_idx",
),
# Trigram indexes for case-insensitive search
GinIndex(
OpClass(Upper("check_id"), name="gin_trgm_ops"),
name="fgds_check_id_trgm_idx",
),
GinIndex(
OpClass(Upper("check_title"), name="gin_trgm_ops"),
name="fgds_check_title_trgm_idx",
),
]
class JSONAPIMeta:
@@ -2058,6 +2078,8 @@ class SAMLConfiguration(RowLevelSecurityProtectedModel):
root = ET.fromstring(self.metadata_xml)
except ET.ParseError as e:
raise ValidationError({"metadata_xml": f"Invalid XML: {e}"})
except defusedxml.DefusedXmlException as e:
raise ValidationError({"metadata_xml": f"Unsafe XML content rejected: {e}"})
# Entity ID
entity_id = root.attrib.get("entityID")
+19 -7
View File
@@ -1,7 +1,7 @@
from enum import Enum
from typing import Optional
from django.db.models import QuerySet
from rest_framework.exceptions import PermissionDenied
from rest_framework.permissions import BasePermission
from api.db_router import MainRouter
@@ -29,8 +29,17 @@ class HasPermissions(BasePermission):
if not required_permissions:
return True
tenant_id = getattr(request, "tenant_id", None)
if not tenant_id:
tenant_id = request.auth.get("tenant_id") if request.auth else None
if not tenant_id:
return False
user_roles = (
User.objects.using(MainRouter.admin_db).get(id=request.user.id).roles.all()
User.objects.using(MainRouter.admin_db)
.get(id=request.user.id)
.roles.using(MainRouter.admin_db)
.filter(tenant_id=tenant_id)
)
if not user_roles:
return False
@@ -42,14 +51,17 @@ class HasPermissions(BasePermission):
return True
def get_role(user: User) -> Optional[Role]:
def get_role(user: User, tenant_id: str) -> Role:
"""
Retrieve the first role assigned to the given user.
Retrieve the role assigned to the given user in the specified tenant.
Returns:
The user's first Role instance if the user has any roles, otherwise None.
Raises:
PermissionDenied: If the user has no role in the given tenant.
"""
return user.roles.first()
role = user.roles.using(MainRouter.admin_db).filter(tenant_id=tenant_id).first()
if role is None:
raise PermissionDenied("User has no role in this tenant.")
return role
def get_providers(role: Role) -> QuerySet[Provider]:
+10 -5
View File
@@ -1,3 +1,5 @@
from contextlib import nullcontext
from rest_framework.renderers import BaseRenderer
from rest_framework_json_api.renderers import JSONRenderer
@@ -26,8 +28,11 @@ class APIJSONRenderer(JSONRenderer):
db_alias = getattr(request, "db_alias", None) if request else None
include_param_present = "include" in request.query_params if request else False
if tenant_id and include_param_present:
for attempt in rls_transaction(tenant_id, using=db_alias):
with attempt:
return super().render(data, accepted_media_type, renderer_context)
return super().render(data, accepted_media_type, renderer_context)
# Use rls_transaction if needed for included resources, otherwise do nothing
context_manager = (
rls_transaction(tenant_id, using=db_alias)
if tenant_id and include_param_present
else nullcontext()
)
with context_manager:
return super().render(data, accepted_media_type, renderer_context)
+1 -1
View File
@@ -61,7 +61,7 @@ def revoke_membership_api_keys(sender, instance, **kwargs): # noqa: F841
in that tenant should be revoked to prevent further access.
"""
TenantAPIKey.objects.filter(
entity=instance.user, tenant_id=instance.tenant.id
entity_id=instance.user_id, tenant_id=instance.tenant_id
).update(revoked=True)
+133 -1
View File
@@ -1,7 +1,7 @@
openapi: 3.0.3
info:
title: Prowler API
version: 1.22.0
version: 1.24.0
description: |-
Prowler API specification.
@@ -372,6 +372,7 @@ paths:
- mongodbatlas
- openstack
- oraclecloud
- vercel
description: |-
* `aws` - AWS
* `azure` - Azure
@@ -387,6 +388,7 @@ paths:
* `openstack` - OpenStack
* `image` - Image
* `googleworkspace` - Google Workspace
* `vercel` - Vercel
- in: query
name: filter[provider_type__in]
schema:
@@ -409,6 +411,7 @@ paths:
- mongodbatlas
- openstack
- oraclecloud
- vercel
description: |-
Multiple values may be separated by commas.
@@ -426,6 +429,7 @@ paths:
* `openstack` - OpenStack
* `image` - Image
* `googleworkspace` - Google Workspace
* `vercel` - Vercel
explode: false
style: form
- in: query
@@ -1351,6 +1355,7 @@ paths:
- mongodbatlas
- openstack
- oraclecloud
- vercel
description: |-
* `aws` - AWS
* `azure` - Azure
@@ -1366,6 +1371,7 @@ paths:
* `openstack` - OpenStack
* `image` - Image
* `googleworkspace` - Google Workspace
* `vercel` - Vercel
- in: query
name: filter[provider_type__in]
schema:
@@ -1827,6 +1833,7 @@ paths:
- mongodbatlas
- openstack
- oraclecloud
- vercel
description: |-
* `aws` - AWS
* `azure` - Azure
@@ -1842,6 +1849,7 @@ paths:
* `openstack` - OpenStack
* `image` - Image
* `googleworkspace` - Google Workspace
* `vercel` - Vercel
- in: query
name: filter[provider_type__in]
schema:
@@ -1864,6 +1872,7 @@ paths:
- mongodbatlas
- openstack
- oraclecloud
- vercel
description: |-
Multiple values may be separated by commas.
@@ -1881,6 +1890,7 @@ paths:
* `openstack` - OpenStack
* `image` - Image
* `googleworkspace` - Google Workspace
* `vercel` - Vercel
explode: false
style: form
- in: query
@@ -2429,6 +2439,7 @@ paths:
- mongodbatlas
- openstack
- oraclecloud
- vercel
description: |-
* `aws` - AWS
* `azure` - Azure
@@ -2444,6 +2455,7 @@ paths:
* `openstack` - OpenStack
* `image` - Image
* `googleworkspace` - Google Workspace
* `vercel` - Vercel
- in: query
name: filter[provider_type__in]
schema:
@@ -2466,6 +2478,7 @@ paths:
- mongodbatlas
- openstack
- oraclecloud
- vercel
description: |-
Multiple values may be separated by commas.
@@ -2483,6 +2496,7 @@ paths:
* `openstack` - OpenStack
* `image` - Image
* `googleworkspace` - Google Workspace
* `vercel` - Vercel
explode: false
style: form
- in: query
@@ -2939,6 +2953,7 @@ paths:
- mongodbatlas
- openstack
- oraclecloud
- vercel
description: |-
* `aws` - AWS
* `azure` - Azure
@@ -2954,6 +2969,7 @@ paths:
* `openstack` - OpenStack
* `image` - Image
* `googleworkspace` - Google Workspace
* `vercel` - Vercel
- in: query
name: filter[provider_type__in]
schema:
@@ -2976,6 +2992,7 @@ paths:
- mongodbatlas
- openstack
- oraclecloud
- vercel
description: |-
Multiple values may be separated by commas.
@@ -2993,6 +3010,7 @@ paths:
* `openstack` - OpenStack
* `image` - Image
* `googleworkspace` - Google Workspace
* `vercel` - Vercel
explode: false
style: form
- in: query
@@ -3447,6 +3465,7 @@ paths:
- mongodbatlas
- openstack
- oraclecloud
- vercel
description: |-
* `aws` - AWS
* `azure` - Azure
@@ -3462,6 +3481,7 @@ paths:
* `openstack` - OpenStack
* `image` - Image
* `googleworkspace` - Google Workspace
* `vercel` - Vercel
- in: query
name: filter[provider_type__in]
schema:
@@ -3484,6 +3504,7 @@ paths:
- mongodbatlas
- openstack
- oraclecloud
- vercel
description: |-
Multiple values may be separated by commas.
@@ -3501,6 +3522,7 @@ paths:
* `openstack` - OpenStack
* `image` - Image
* `googleworkspace` - Google Workspace
* `vercel` - Vercel
explode: false
style: form
- in: query
@@ -3943,6 +3965,7 @@ paths:
- mongodbatlas
- openstack
- oraclecloud
- vercel
description: |-
* `aws` - AWS
* `azure` - Azure
@@ -3958,6 +3981,7 @@ paths:
* `openstack` - OpenStack
* `image` - Image
* `googleworkspace` - Google Workspace
* `vercel` - Vercel
- in: query
name: filter[provider_type__in]
schema:
@@ -3980,6 +4004,7 @@ paths:
- mongodbatlas
- openstack
- oraclecloud
- vercel
description: |-
Multiple values may be separated by commas.
@@ -3997,6 +4022,7 @@ paths:
* `openstack` - OpenStack
* `image` - Image
* `googleworkspace` - Google Workspace
* `vercel` - Vercel
explode: false
style: form
- in: query
@@ -5780,6 +5806,7 @@ paths:
- mongodbatlas
- openstack
- oraclecloud
- vercel
description: |-
* `aws` - AWS
* `azure` - Azure
@@ -5795,6 +5822,7 @@ paths:
* `openstack` - OpenStack
* `image` - Image
* `googleworkspace` - Google Workspace
* `vercel` - Vercel
- in: query
name: filter[provider_type__in]
schema:
@@ -5817,6 +5845,7 @@ paths:
- mongodbatlas
- openstack
- oraclecloud
- vercel
description: |-
Multiple values may be separated by commas.
@@ -5834,6 +5863,7 @@ paths:
* `openstack` - OpenStack
* `image` - Image
* `googleworkspace` - Google Workspace
* `vercel` - Vercel
explode: false
style: form
- name: filter[search]
@@ -5955,6 +5985,7 @@ paths:
- mongodbatlas
- openstack
- oraclecloud
- vercel
description: |-
* `aws` - AWS
* `azure` - Azure
@@ -5970,6 +6001,7 @@ paths:
* `openstack` - OpenStack
* `image` - Image
* `googleworkspace` - Google Workspace
* `vercel` - Vercel
- in: query
name: filter[provider_type__in]
schema:
@@ -5992,6 +6024,7 @@ paths:
- mongodbatlas
- openstack
- oraclecloud
- vercel
description: |-
Multiple values may be separated by commas.
@@ -6009,6 +6042,7 @@ paths:
* `openstack` - OpenStack
* `image` - Image
* `googleworkspace` - Google Workspace
* `vercel` - Vercel
explode: false
style: form
- name: filter[search]
@@ -6119,6 +6153,7 @@ paths:
- mongodbatlas
- openstack
- oraclecloud
- vercel
description: |-
* `aws` - AWS
* `azure` - Azure
@@ -6134,6 +6169,7 @@ paths:
* `openstack` - OpenStack
* `image` - Image
* `googleworkspace` - Google Workspace
* `vercel` - Vercel
- in: query
name: filter[provider_type__in]
schema:
@@ -6155,6 +6191,7 @@ paths:
- mongodbatlas
- openstack
- oraclecloud
- vercel
description: |-
Multiple values may be separated by commas.
@@ -6172,6 +6209,7 @@ paths:
* `openstack` - OpenStack
* `image` - Image
* `googleworkspace` - Google Workspace
* `vercel` - Vercel
explode: false
style: form
- name: filter[search]
@@ -6314,6 +6352,7 @@ paths:
- mongodbatlas
- openstack
- oraclecloud
- vercel
description: |-
* `aws` - AWS
* `azure` - Azure
@@ -6329,6 +6368,7 @@ paths:
* `openstack` - OpenStack
* `image` - Image
* `googleworkspace` - Google Workspace
* `vercel` - Vercel
- in: query
name: filter[provider_type__in]
schema:
@@ -6351,6 +6391,7 @@ paths:
- mongodbatlas
- openstack
- oraclecloud
- vercel
description: |-
Multiple values may be separated by commas.
@@ -6368,6 +6409,7 @@ paths:
* `openstack` - OpenStack
* `image` - Image
* `googleworkspace` - Google Workspace
* `vercel` - Vercel
explode: false
style: form
- in: query
@@ -6523,6 +6565,7 @@ paths:
- mongodbatlas
- openstack
- oraclecloud
- vercel
description: |-
* `aws` - AWS
* `azure` - Azure
@@ -6538,6 +6581,7 @@ paths:
* `openstack` - OpenStack
* `image` - Image
* `googleworkspace` - Google Workspace
* `vercel` - Vercel
- in: query
name: filter[provider_type__in]
schema:
@@ -6560,6 +6604,7 @@ paths:
- mongodbatlas
- openstack
- oraclecloud
- vercel
description: |-
Multiple values may be separated by commas.
@@ -6577,6 +6622,7 @@ paths:
* `openstack` - OpenStack
* `image` - Image
* `googleworkspace` - Google Workspace
* `vercel` - Vercel
explode: false
style: form
- in: query
@@ -6726,6 +6772,7 @@ paths:
- mongodbatlas
- openstack
- oraclecloud
- vercel
description: |-
* `aws` - AWS
* `azure` - Azure
@@ -6741,6 +6788,7 @@ paths:
* `openstack` - OpenStack
* `image` - Image
* `googleworkspace` - Google Workspace
* `vercel` - Vercel
- in: query
name: filter[provider_type__in]
schema:
@@ -6762,6 +6810,7 @@ paths:
- mongodbatlas
- openstack
- oraclecloud
- vercel
description: |-
Multiple values may be separated by commas.
@@ -6779,6 +6828,7 @@ paths:
* `openstack` - OpenStack
* `image` - Image
* `googleworkspace` - Google Workspace
* `vercel` - Vercel
explode: false
style: form
- name: filter[search]
@@ -6970,6 +7020,7 @@ paths:
- mongodbatlas
- openstack
- oraclecloud
- vercel
description: |-
* `aws` - AWS
* `azure` - Azure
@@ -6985,6 +7036,7 @@ paths:
* `openstack` - OpenStack
* `image` - Image
* `googleworkspace` - Google Workspace
* `vercel` - Vercel
- in: query
name: filter[provider_type__in]
schema:
@@ -7007,6 +7059,7 @@ paths:
- mongodbatlas
- openstack
- oraclecloud
- vercel
description: |-
Multiple values may be separated by commas.
@@ -7024,6 +7077,7 @@ paths:
* `openstack` - OpenStack
* `image` - Image
* `googleworkspace` - Google Workspace
* `vercel` - Vercel
explode: false
style: form
- in: query
@@ -7144,6 +7198,7 @@ paths:
- mongodbatlas
- openstack
- oraclecloud
- vercel
description: |-
* `aws` - AWS
* `azure` - Azure
@@ -7159,6 +7214,7 @@ paths:
* `openstack` - OpenStack
* `image` - Image
* `googleworkspace` - Google Workspace
* `vercel` - Vercel
- in: query
name: filter[provider_type__in]
schema:
@@ -7181,6 +7237,7 @@ paths:
- mongodbatlas
- openstack
- oraclecloud
- vercel
description: |-
Multiple values may be separated by commas.
@@ -7198,6 +7255,7 @@ paths:
* `openstack` - OpenStack
* `image` - Image
* `googleworkspace` - Google Workspace
* `vercel` - Vercel
explode: false
style: form
- in: query
@@ -7342,6 +7400,7 @@ paths:
- mongodbatlas
- openstack
- oraclecloud
- vercel
description: |-
* `aws` - AWS
* `azure` - Azure
@@ -7357,6 +7416,7 @@ paths:
* `openstack` - OpenStack
* `image` - Image
* `googleworkspace` - Google Workspace
* `vercel` - Vercel
- in: query
name: filter[provider_type__in]
schema:
@@ -7379,6 +7439,7 @@ paths:
- mongodbatlas
- openstack
- oraclecloud
- vercel
description: |-
Multiple values may be separated by commas.
@@ -7396,6 +7457,7 @@ paths:
* `openstack` - OpenStack
* `image` - Image
* `googleworkspace` - Google Workspace
* `vercel` - Vercel
explode: false
style: form
- in: query
@@ -8181,6 +8243,7 @@ paths:
- mongodbatlas
- openstack
- oraclecloud
- vercel
description: |-
* `aws` - AWS
* `azure` - Azure
@@ -8196,6 +8259,7 @@ paths:
* `openstack` - OpenStack
* `image` - Image
* `googleworkspace` - Google Workspace
* `vercel` - Vercel
- in: query
name: filter[provider__in]
schema:
@@ -8218,6 +8282,7 @@ paths:
- mongodbatlas
- openstack
- oraclecloud
- vercel
description: |-
Multiple values may be separated by commas.
@@ -8235,6 +8300,7 @@ paths:
* `openstack` - OpenStack
* `image` - Image
* `googleworkspace` - Google Workspace
* `vercel` - Vercel
explode: false
style: form
- in: query
@@ -8257,6 +8323,7 @@ paths:
- mongodbatlas
- openstack
- oraclecloud
- vercel
description: |-
* `aws` - AWS
* `azure` - Azure
@@ -8272,6 +8339,7 @@ paths:
* `openstack` - OpenStack
* `image` - Image
* `googleworkspace` - Google Workspace
* `vercel` - Vercel
- in: query
name: filter[provider_type__in]
schema:
@@ -8294,6 +8362,7 @@ paths:
- mongodbatlas
- openstack
- oraclecloud
- vercel
description: |-
Multiple values may be separated by commas.
@@ -8311,6 +8380,7 @@ paths:
* `openstack` - OpenStack
* `image` - Image
* `googleworkspace` - Google Workspace
* `vercel` - Vercel
explode: false
style: form
- name: filter[search]
@@ -8980,6 +9050,7 @@ paths:
- mongodbatlas
- openstack
- oraclecloud
- vercel
description: |-
* `aws` - AWS
* `azure` - Azure
@@ -8995,6 +9066,7 @@ paths:
* `openstack` - OpenStack
* `image` - Image
* `googleworkspace` - Google Workspace
* `vercel` - Vercel
- in: query
name: filter[provider_type__in]
schema:
@@ -9017,6 +9089,7 @@ paths:
- mongodbatlas
- openstack
- oraclecloud
- vercel
description: |-
Multiple values may be separated by commas.
@@ -9034,6 +9107,7 @@ paths:
* `openstack` - OpenStack
* `image` - Image
* `googleworkspace` - Google Workspace
* `vercel` - Vercel
explode: false
style: form
- in: query
@@ -9527,6 +9601,7 @@ paths:
- mongodbatlas
- openstack
- oraclecloud
- vercel
description: |-
* `aws` - AWS
* `azure` - Azure
@@ -9542,6 +9617,7 @@ paths:
* `openstack` - OpenStack
* `image` - Image
* `googleworkspace` - Google Workspace
* `vercel` - Vercel
- in: query
name: filter[provider_type__in]
schema:
@@ -9564,6 +9640,7 @@ paths:
- mongodbatlas
- openstack
- oraclecloud
- vercel
description: |-
Multiple values may be separated by commas.
@@ -9581,6 +9658,7 @@ paths:
* `openstack` - OpenStack
* `image` - Image
* `googleworkspace` - Google Workspace
* `vercel` - Vercel
explode: false
style: form
- in: query
@@ -9887,6 +9965,7 @@ paths:
- mongodbatlas
- openstack
- oraclecloud
- vercel
description: |-
* `aws` - AWS
* `azure` - Azure
@@ -9902,6 +9981,7 @@ paths:
* `openstack` - OpenStack
* `image` - Image
* `googleworkspace` - Google Workspace
* `vercel` - Vercel
- in: query
name: filter[provider_type__in]
schema:
@@ -9924,6 +10004,7 @@ paths:
- mongodbatlas
- openstack
- oraclecloud
- vercel
description: |-
Multiple values may be separated by commas.
@@ -9941,6 +10022,7 @@ paths:
* `openstack` - OpenStack
* `image` - Image
* `googleworkspace` - Google Workspace
* `vercel` - Vercel
explode: false
style: form
- in: query
@@ -10253,6 +10335,7 @@ paths:
- mongodbatlas
- openstack
- oraclecloud
- vercel
description: |-
* `aws` - AWS
* `azure` - Azure
@@ -10268,6 +10351,7 @@ paths:
* `openstack` - OpenStack
* `image` - Image
* `googleworkspace` - Google Workspace
* `vercel` - Vercel
- in: query
name: filter[provider_type__in]
schema:
@@ -10290,6 +10374,7 @@ paths:
- mongodbatlas
- openstack
- oraclecloud
- vercel
description: |-
Multiple values may be separated by commas.
@@ -10307,6 +10392,7 @@ paths:
* `openstack` - OpenStack
* `image` - Image
* `googleworkspace` - Google Workspace
* `vercel` - Vercel
explode: false
style: form
- in: query
@@ -11129,6 +11215,7 @@ paths:
- mongodbatlas
- openstack
- oraclecloud
- vercel
description: |-
* `aws` - AWS
* `azure` - Azure
@@ -11144,6 +11231,7 @@ paths:
* `openstack` - OpenStack
* `image` - Image
* `googleworkspace` - Google Workspace
* `vercel` - Vercel
- in: query
name: filter[provider_type__in]
schema:
@@ -11166,6 +11254,7 @@ paths:
- mongodbatlas
- openstack
- oraclecloud
- vercel
description: |-
Multiple values may be separated by commas.
@@ -11183,6 +11272,7 @@ paths:
* `openstack` - OpenStack
* `image` - Image
* `googleworkspace` - Google Workspace
* `vercel` - Vercel
explode: false
style: form
- in: query
@@ -18463,6 +18553,15 @@ components:
required:
- clouds_yaml_content
- clouds_yaml_cloud
- type: object
title: Vercel API Token
properties:
api_token:
type: string
description: Vercel API token for authentication. Can be scoped
to a specific team.
required:
- api_token
writeOnly: true
required:
- secret
@@ -19465,6 +19564,7 @@ components:
- openstack
- image
- googleworkspace
- vercel
type: string
description: |-
* `aws` - AWS
@@ -19481,6 +19581,7 @@ components:
* `openstack` - OpenStack
* `image` - Image
* `googleworkspace` - Google Workspace
* `vercel` - Vercel
x-spec-enum-id: c0d56cad8ab9abe5
uid:
type: string
@@ -19601,6 +19702,7 @@ components:
- openstack
- image
- googleworkspace
- vercel
type: string
x-spec-enum-id: c0d56cad8ab9abe5
description: |-
@@ -19620,6 +19722,7 @@ components:
* `openstack` - OpenStack
* `image` - Image
* `googleworkspace` - Google Workspace
* `vercel` - Vercel
uid:
type: string
title: Unique identifier for the provider, set by the provider
@@ -19671,6 +19774,7 @@ components:
- openstack
- image
- googleworkspace
- vercel
type: string
x-spec-enum-id: c0d56cad8ab9abe5
description: |-
@@ -19690,6 +19794,7 @@ components:
* `openstack` - OpenStack
* `image` - Image
* `googleworkspace` - Google Workspace
* `vercel` - Vercel
uid:
type: string
minLength: 3
@@ -20539,6 +20644,15 @@ components:
required:
- clouds_yaml_content
- clouds_yaml_cloud
- type: object
title: Vercel API Token
properties:
api_token:
type: string
description: Vercel API token for authentication. Can be scoped
to a specific team.
required:
- api_token
writeOnly: true
required:
- secret_type
@@ -20955,6 +21069,15 @@ components:
required:
- clouds_yaml_content
- clouds_yaml_cloud
- type: object
title: Vercel API Token
properties:
api_token:
type: string
description: Vercel API token for authentication. Can be scoped
to a specific team.
required:
- api_token
writeOnly: true
required:
- secret_type
@@ -21381,6 +21504,15 @@ components:
required:
- clouds_yaml_content
- clouds_yaml_cloud
- type: object
title: Vercel API Token
properties:
api_token:
type: string
description: Vercel API token for authentication. Can be scoped
to a specific team.
required:
- api_token
writeOnly: true
required:
- secret
@@ -215,6 +215,21 @@ class TestTokenSwitchTenant:
tenant_id = tenants_fixture[0].id
user_instance = User.objects.get(email=test_user)
Membership.objects.create(user=user_instance, tenant_id=tenant_id)
# Assign an admin role in the target tenant so the user can access resources
target_role = Role.objects.create(
name="admin",
tenant_id=tenant_id,
manage_users=True,
manage_account=True,
manage_billing=True,
manage_providers=True,
manage_integrations=True,
manage_scans=True,
unlimited_visibility=True,
)
UserRoleRelationship.objects.create(
user=user_instance, role=target_role, tenant_id=tenant_id
)
# Check that using our new user's credentials we can authenticate and get the providers
access_token, _ = get_api_tokens(client, test_user, test_password)
@@ -301,7 +316,7 @@ class TestTokenSwitchTenant:
assert invalid_tenant_response.status_code == 400
assert invalid_tenant_response.json()["errors"][0]["code"] == "invalid"
assert invalid_tenant_response.json()["errors"][0]["detail"] == (
"Tenant does not exist or user is not a " "member."
"Tenant does not exist or user is not a member."
)
@@ -912,10 +927,9 @@ class TestAPIKeyLifecycle:
auth_response = client.get(reverse("provider-list"), headers=api_key_headers)
# Must return 401 Unauthorized, not 500 Internal Server Error
assert auth_response.status_code == 401, (
f"Expected 401 but got {auth_response.status_code}: "
f"{auth_response.json()}"
)
assert (
auth_response.status_code == 401
), f"Expected 401 but got {auth_response.status_code}: {auth_response.json()}"
# Verify error message is present
response_json = auth_response.json()
@@ -17,26 +17,23 @@ class TestRLSTransaction:
def test_success_on_primary(self, tenant):
"""Basic: transaction succeeds on primary database."""
for attempt in rls_transaction(str(tenant.id), using=DEFAULT_DB_ALIAS):
with attempt as cursor:
cursor.execute("SELECT 1")
result = cursor.fetchone()
assert result == (1,)
with rls_transaction(str(tenant.id), using=DEFAULT_DB_ALIAS) as cursor:
cursor.execute("SELECT 1")
result = cursor.fetchone()
assert result == (1,)
def test_invalid_uuid_raises_validation_error(self):
"""Invalid UUID raises ValidationError before DB operations."""
with pytest.raises(ValidationError, match="Must be a valid UUID"):
for attempt in rls_transaction("not-a-uuid", using=DEFAULT_DB_ALIAS):
with attempt:
pass
with rls_transaction("not-a-uuid", using=DEFAULT_DB_ALIAS):
pass
def test_custom_parameter_name(self, tenant):
"""Test custom RLS parameter name."""
custom_param = "api.custom_id"
for attempt in rls_transaction(
with rls_transaction(
str(tenant.id), parameter=custom_param, using=DEFAULT_DB_ALIAS
):
with attempt as cursor:
cursor.execute("SELECT current_setting(%s, true)", [custom_param])
result = cursor.fetchone()
assert result == (str(tenant.id),)
) as cursor:
cursor.execute("SELECT current_setting(%s, true)", [custom_param])
result = cursor.fetchone()
assert result == (str(tenant.id),)
+17 -13
View File
@@ -10,11 +10,11 @@ from django.conf import settings
import api
import api.apps as api_apps_module
from api.apps import (
ApiConfig,
PRIVATE_KEY_FILE,
PUBLIC_KEY_FILE,
SIGNING_KEY_ENV,
VERIFYING_KEY_ENV,
ApiConfig,
)
@@ -187,9 +187,10 @@ def test_ready_initializes_driver_for_api_process(monkeypatch):
_set_argv(monkeypatch, ["gunicorn"])
_set_testing(monkeypatch, False)
with patch.object(ApiConfig, "_ensure_crypto_keys", return_value=None), patch(
"api.attack_paths.database.init_driver"
) as init_driver:
with (
patch.object(ApiConfig, "_ensure_crypto_keys", return_value=None),
patch("api.attack_paths.database.init_driver") as init_driver,
):
config.ready()
init_driver.assert_called_once()
@@ -200,9 +201,10 @@ def test_ready_skips_driver_for_celery(monkeypatch):
_set_argv(monkeypatch, ["celery", "-A", "api"])
_set_testing(monkeypatch, False)
with patch.object(ApiConfig, "_ensure_crypto_keys", return_value=None), patch(
"api.attack_paths.database.init_driver"
) as init_driver:
with (
patch.object(ApiConfig, "_ensure_crypto_keys", return_value=None),
patch("api.attack_paths.database.init_driver") as init_driver,
):
config.ready()
init_driver.assert_not_called()
@@ -213,9 +215,10 @@ def test_ready_skips_driver_for_manage_py_skip_command(monkeypatch):
_set_argv(monkeypatch, ["manage.py", "migrate"])
_set_testing(monkeypatch, False)
with patch.object(ApiConfig, "_ensure_crypto_keys", return_value=None), patch(
"api.attack_paths.database.init_driver"
) as init_driver:
with (
patch.object(ApiConfig, "_ensure_crypto_keys", return_value=None),
patch("api.attack_paths.database.init_driver") as init_driver,
):
config.ready()
init_driver.assert_not_called()
@@ -226,9 +229,10 @@ def test_ready_skips_driver_when_testing(monkeypatch):
_set_argv(monkeypatch, ["gunicorn"])
_set_testing(monkeypatch, True)
with patch.object(ApiConfig, "_ensure_crypto_keys", return_value=None), patch(
"api.attack_paths.database.init_driver"
) as init_driver:
with (
patch.object(ApiConfig, "_ensure_crypto_keys", return_value=None),
patch("api.attack_paths.database.init_driver") as init_driver,
):
config.ready()
init_driver.assert_not_called()
+24 -97
View File
@@ -11,7 +11,7 @@ from api.attack_paths import database as graph_database
from api.attack_paths import views_helpers
from tasks.jobs.attack_paths.config import (
PROVIDER_ELEMENT_ID_PROPERTY,
PROVIDER_ID_PROPERTY,
get_provider_label,
)
@@ -53,7 +53,7 @@ def test_prepare_parameters_includes_provider_and_casts(
)
assert result["provider_uid"] == "123456789012"
assert result["provider_id"] == "test-provider-id"
assert "provider_id" not in result
assert result["limit"] == 5
@@ -107,12 +107,12 @@ def test_execute_query_serializes_graph(
parameters = {"provider_uid": "123"}
provider_id = "test-provider-123"
plabel = get_provider_label(provider_id)
node = attack_paths_graph_stub_classes.Node(
element_id="node-1",
labels=["AWSAccount"],
labels=["AWSAccount", plabel],
properties={
"name": "account",
PROVIDER_ID_PROPERTY: provider_id,
"complex": {
"items": [
attack_paths_graph_stub_classes.NativeValue("value"),
@@ -121,15 +121,13 @@ def test_execute_query_serializes_graph(
},
},
)
node_2 = attack_paths_graph_stub_classes.Node(
"node-2", ["RDSInstance"], {PROVIDER_ID_PROPERTY: provider_id}
)
node_2 = attack_paths_graph_stub_classes.Node("node-2", ["RDSInstance", plabel], {})
relationship = attack_paths_graph_stub_classes.Relationship(
element_id="rel-1",
rel_type="OWNS",
start_node=node,
end_node=node_2,
properties={"weight": 1, PROVIDER_ID_PROPERTY: provider_id},
properties={"weight": 1},
)
graph = SimpleNamespace(nodes=[node, node_2], relationships=[relationship])
@@ -213,29 +211,27 @@ def test_execute_query_raises_permission_denied_on_read_only(
)
def test_serialize_graph_filters_by_provider_id(attack_paths_graph_stub_classes):
def test_serialize_graph_filters_by_provider_label(attack_paths_graph_stub_classes):
provider_id = "provider-keep"
plabel = get_provider_label(provider_id)
other_label = get_provider_label("provider-other")
node_keep = attack_paths_graph_stub_classes.Node(
"n1", ["AWSAccount"], {PROVIDER_ID_PROPERTY: provider_id}
)
node_keep = attack_paths_graph_stub_classes.Node("n1", ["AWSAccount", plabel], {})
node_drop = attack_paths_graph_stub_classes.Node(
"n2", ["AWSAccount"], {PROVIDER_ID_PROPERTY: "provider-other"}
"n2", ["AWSAccount", other_label], {}
)
rel_keep = attack_paths_graph_stub_classes.Relationship(
"r1", "OWNS", node_keep, node_keep, {PROVIDER_ID_PROPERTY: provider_id}
)
rel_drop_by_provider = attack_paths_graph_stub_classes.Relationship(
"r2", "OWNS", node_keep, node_drop, {PROVIDER_ID_PROPERTY: "provider-other"}
"r1", "OWNS", node_keep, node_keep, {}
)
# Relationship connecting a kept node to a dropped node — filtered by endpoint check
rel_drop_orphaned = attack_paths_graph_stub_classes.Relationship(
"r3", "OWNS", node_keep, node_drop, {PROVIDER_ID_PROPERTY: provider_id}
"r2", "OWNS", node_keep, node_drop, {}
)
graph = SimpleNamespace(
nodes=[node_keep, node_drop],
relationships=[rel_keep, rel_drop_by_provider, rel_drop_orphaned],
relationships=[rel_keep, rel_drop_orphaned],
)
result = views_helpers._serialize_graph(graph, provider_id)
@@ -354,7 +350,6 @@ def test_serialize_properties_filters_internal_fields():
"_module_name": "cartography:aws",
"_module_version": "0.98.0",
# Provider isolation
PROVIDER_ID_PROPERTY: "42",
PROVIDER_ELEMENT_ID_PROPERTY: "42:abc123",
}
@@ -449,14 +444,11 @@ def test_execute_custom_query_serializes_graph(
attack_paths_graph_stub_classes,
):
provider_id = "test-provider-123"
node_1 = attack_paths_graph_stub_classes.Node(
"node-1", ["AWSAccount"], {PROVIDER_ID_PROPERTY: provider_id}
)
node_2 = attack_paths_graph_stub_classes.Node(
"node-2", ["RDSInstance"], {PROVIDER_ID_PROPERTY: provider_id}
)
plabel = get_provider_label(provider_id)
node_1 = attack_paths_graph_stub_classes.Node("node-1", ["AWSAccount", plabel], {})
node_2 = attack_paths_graph_stub_classes.Node("node-2", ["RDSInstance", plabel], {})
relationship = attack_paths_graph_stub_classes.Relationship(
"rel-1", "OWNS", node_1, node_2, {PROVIDER_ID_PROPERTY: provider_id}
"rel-1", "OWNS", node_1, node_2, {}
)
graph_result = MagicMock()
@@ -471,10 +463,11 @@ def test_execute_custom_query_serializes_graph(
"db-tenant-test", "MATCH (n) RETURN n", provider_id
)
mock_execute.assert_called_once_with(
database="db-tenant-test",
cypher="MATCH (n) RETURN n",
)
mock_execute.assert_called_once()
call_kwargs = mock_execute.call_args[1]
assert call_kwargs["database"] == "db-tenant-test"
# The cypher is rewritten with the provider label injection
assert plabel in call_kwargs["cypher"]
assert len(result["nodes"]) == 2
assert result["relationships"][0]["label"] == "OWNS"
assert result["truncated"] is False
@@ -511,72 +504,6 @@ def test_execute_custom_query_wraps_graph_errors():
mock_logger.error.assert_called_once()
# -- validate_custom_query ------------------------------------------------
@pytest.mark.parametrize(
"cypher",
[
"LOAD CSV FROM 'http://169.254.169.254/' AS x RETURN x",
"load csv from 'http://evil.com' as row return row",
"CALL apoc.load.json('http://evil.com/') YIELD value RETURN value",
"CALL apoc.load.csvParams('http://evil.com/', {}, null) YIELD list RETURN list",
"CALL apoc.import.csv([{fileName: 'f'}], [], {}) YIELD node RETURN node",
"CALL apoc.export.csv.all('file.csv', {})",
"CALL apoc.cypher.run('CREATE (n)', {}) YIELD value RETURN value",
"CALL apoc.systemdb.graph() YIELD nodes RETURN nodes",
"CALL apoc.config.list() YIELD key, value RETURN key, value",
"CALL apoc.periodic.iterate('MATCH (n) RETURN n', 'DELETE n', {batchSize: 100})",
"CALL apoc.do.when(true, 'CREATE (n) RETURN n', '', {}) YIELD value RETURN value",
"CALL apoc.trigger.add('t', 'RETURN 1', {phase: 'before'})",
"CALL apoc.custom.asProcedure('myProc', 'RETURN 1')",
],
ids=[
"LOAD_CSV",
"LOAD_CSV_lowercase",
"apoc.load.json",
"apoc.load.csvParams",
"apoc.import.csv",
"apoc.export.csv",
"apoc.cypher.run",
"apoc.systemdb.graph",
"apoc.config.list",
"apoc.periodic.iterate",
"apoc.do.when",
"apoc.trigger.add",
"apoc.custom.asProcedure",
],
)
def test_validate_custom_query_rejects_blocked_patterns(cypher):
with pytest.raises(ValidationError) as exc:
views_helpers.validate_custom_query(cypher)
assert "blocked operation" in str(exc.value.detail)
@pytest.mark.parametrize(
"cypher",
[
"MATCH (n:AWSAccount) RETURN n LIMIT 10",
"MATCH (a)-[r]->(b) RETURN a, r, b",
"MATCH (n) WHERE n.name CONTAINS 'load' RETURN n",
"CALL apoc.create.vNode(['Label'], {}) YIELD node RETURN node",
"MATCH (n) WHERE n.name = 'apoc.load.json' RETURN n",
'MATCH (n) WHERE n.description = "LOAD CSV is cool" RETURN n',
],
ids=[
"simple_match",
"traversal",
"contains_load_substring",
"apoc_virtual_node",
"apoc_load_inside_single_quotes",
"load_csv_inside_double_quotes",
],
)
def test_validate_custom_query_allows_clean_queries(cypher):
views_helpers.validate_custom_query(cypher)
# -- _truncate_graph ----------------------------------------------------------
@@ -0,0 +1,43 @@
import pytest
from config.settings.celery import _build_celery_broker_url
class TestBuildCeleryBrokerUrl:
def test_without_credentials(self):
broker_url = _build_celery_broker_url("redis", "", "", "valkey", "6379", "0")
assert broker_url == "redis://valkey:6379/0"
def test_with_password_only(self):
broker_url = _build_celery_broker_url(
"rediss", "", "secret", "cache.example.com", "6379", "0"
)
assert broker_url == "rediss://:secret@cache.example.com:6379/0"
def test_with_username_and_password(self):
broker_url = _build_celery_broker_url(
"rediss", "default", "secret", "cache.example.com", "6379", "0"
)
assert broker_url == "rediss://default:secret@cache.example.com:6379/0"
def test_with_username_only(self):
broker_url = _build_celery_broker_url(
"redis", "admin", "", "valkey", "6379", "0"
)
assert broker_url == "redis://admin@valkey:6379/0"
def test_url_encodes_credentials(self):
broker_url = _build_celery_broker_url(
"rediss", "user@name", "p@ss:word", "cache.example.com", "6379", "0"
)
assert (
broker_url == "rediss://user%40name:p%40ss%3Aword@cache.example.com:6379/0"
)
def test_invalid_scheme_raises_error(self):
with pytest.raises(ValueError, match="Invalid VALKEY_SCHEME 'http'"):
_build_celery_broker_url("http", "", "", "valkey", "6379", "0")
@@ -0,0 +1,429 @@
"""Unit tests for the Cypher sanitizer (validation + provider-label injection)."""
from unittest.mock import patch
import pytest
from rest_framework.exceptions import ValidationError
from api.attack_paths.cypher_sanitizer import (
inject_provider_label,
validate_custom_query,
)
PROVIDER_ID = "019c41ee-7df3-7dec-a684-d839f95619f8"
LABEL = "_Provider_019c41ee7df37deca684d839f95619f8"
def _inject(cypher: str) -> str:
"""Shortcut that patches `get_provider_label` to avoid config imports."""
with patch(
"api.attack_paths.cypher_sanitizer.get_provider_label", return_value=LABEL
):
return inject_provider_label(cypher, PROVIDER_ID)
# ---------------------------------------------------------------------------
# Pass A - Labeled node patterns (all clauses)
# ---------------------------------------------------------------------------
class TestLabeledNodes:
def test_single_label(self):
result = _inject("MATCH (n:AWSRole) RETURN n")
assert f"(n:AWSRole:{LABEL})" in result
def test_label_with_properties(self):
result = _inject("MATCH (n:AWSRole {name: 'admin'}) RETURN n")
assert f"(n:AWSRole:{LABEL} {{name: 'admin'}})" in result
def test_multiple_labels(self):
result = _inject("MATCH (n:AWSRole:AWSPrincipal) RETURN n")
assert f"(n:AWSRole:AWSPrincipal:{LABEL})" in result
def test_anonymous_labeled(self):
result = _inject(
"MATCH (:AWSPrincipal {arn: 'ecs-tasks.amazonaws.com'}) RETURN 1"
)
assert f"(:AWSPrincipal:{LABEL} {{arn: 'ecs-tasks.amazonaws.com'}})" in result
def test_backtick_label(self):
result = _inject("MATCH (n:`My Label`) RETURN n")
assert f"(n:`My Label`:{LABEL})" in result
def test_labeled_in_where_clause(self):
"""Labeled nodes in WHERE (pattern existence) still get the label."""
result = _inject(
"MATCH (n:AWSRole) WHERE EXISTS((n)-[:REL]->(:Target)) RETURN n"
)
assert f"(n:AWSRole:{LABEL})" in result
assert f"(:Target:{LABEL})" in result
def test_labeled_in_return_clause(self):
"""Labeled nodes in RETURN still get the label (they're always node patterns)."""
result = _inject("MATCH (n:AWSRole) RETURN (n:AWSRole)")
assert result.count(f":AWSRole:{LABEL}") == 2
def test_labeled_in_optional_match(self):
result = _inject(
"OPTIONAL MATCH (pf:ProwlerFinding {status: 'FAIL'}) RETURN pf"
)
assert f"(pf:ProwlerFinding:{LABEL} {{status: 'FAIL'}})" in result
# ---------------------------------------------------------------------------
# Pass B - Bare node patterns (MATCH/OPTIONAL MATCH only)
# ---------------------------------------------------------------------------
class TestBareNodes:
def test_bare_in_match(self):
result = _inject("MATCH (a)-[:HAS_POLICY]->(b) RETURN a, b")
assert f"(a:{LABEL})" in result
assert f"(b:{LABEL})" in result
def test_bare_with_properties_in_match(self):
result = _inject("MATCH (n {name: 'x'}) RETURN n")
assert f"(n:{LABEL} {{name: 'x'}})" in result
def test_bare_in_optional_match(self):
result = _inject("OPTIONAL MATCH (n)-[r]-(m) RETURN n")
assert f"(n:{LABEL})" in result
assert f"(m:{LABEL})" in result
def test_bare_not_injected_in_return(self):
"""Bare (identifier) in RETURN could be expression grouping."""
cypher = "MATCH (n:AWSRole) RETURN (n)"
result = _inject(cypher)
# The labeled (n:AWSRole) gets the label, but the bare (n) in RETURN should not
assert f"(n:AWSRole:{LABEL})" in result
# Count how many times the label appears - should be 1 (from MATCH only)
assert result.count(LABEL) == 1
def test_bare_not_injected_in_where(self):
cypher = "MATCH (n:AWSRole) WHERE (n.x > 1) RETURN n"
result = _inject(cypher)
# (n.x > 1) is an expression group, not a node pattern - should be untouched
assert "(n.x > 1)" in result
def test_bare_not_injected_in_with(self):
cypher = "MATCH (n:AWSRole) WITH (n) RETURN n"
result = _inject(cypher)
assert result.count(LABEL) == 1
def test_bare_not_injected_in_unwind(self):
cypher = "UNWIND nodes(path) as n OPTIONAL MATCH (n)-[r]-(m) RETURN n"
result = _inject(cypher)
# (n) and (m) in OPTIONAL MATCH get injected, but nodes(path) in UNWIND does not
assert f"(n:{LABEL})" in result
assert f"(m:{LABEL})" in result
# ---------------------------------------------------------------------------
# Function call exclusion
# ---------------------------------------------------------------------------
class TestFunctionCallExclusion:
@pytest.mark.parametrize(
"func_call",
[
"collect(DISTINCT pf)",
"any(x IN stmt.action WHERE toLower(x) = 'iam:*')",
"toLower(action)",
"nodes(path)",
"count(n)",
"apoc.create.vNode(labels)",
"EXISTS(n.prop)",
"size(n.list)",
],
)
def test_function_calls_not_injected(self, func_call):
cypher = f"MATCH (n:AWSRole) WHERE {func_call} RETURN n"
result = _inject(cypher)
# The function call should remain unchanged
assert func_call in result
# Only the MATCH labeled node should get the label
assert result.count(LABEL) == 1
# ---------------------------------------------------------------------------
# String and comment protection
# ---------------------------------------------------------------------------
class TestProtection:
def test_string_with_fake_node_pattern(self):
cypher = "MATCH (n:AWSRole) WHERE n.name = '(fake:Label)' RETURN n"
result = _inject(cypher)
assert "'(fake:Label)'" in result
assert result.count(LABEL) == 1
def test_double_quoted_string(self):
cypher = 'MATCH (n:AWSRole) WHERE n.name = "(fake:Label)" RETURN n'
result = _inject(cypher)
assert '"(fake:Label)"' in result
assert result.count(LABEL) == 1
def test_line_comment_with_node_pattern(self):
cypher = "// (n:Fake)\nMATCH (n:AWSRole) RETURN n"
result = _inject(cypher)
assert "// (n:Fake)" in result
assert result.count(LABEL) == 1
def test_string_containing_double_slash(self):
"""Strings with // inside should be consumed as strings, not comments."""
cypher = "MATCH (n:AWSRole {url: 'https://example.com'}) RETURN n"
result = _inject(cypher)
assert "'https://example.com'" in result
assert f"(n:AWSRole:{LABEL}" in result
def test_escaped_quotes_in_string(self):
cypher = r"MATCH (n:AWSRole) WHERE n.name = 'it\'s a test' RETURN n"
result = _inject(cypher)
assert result.count(LABEL) == 1
# ---------------------------------------------------------------------------
# Clause splitting
# ---------------------------------------------------------------------------
class TestClauseSplitting:
def test_case_insensitive_keywords(self):
cypher = "match (n:AWSRole) where n.x = 1 return n"
result = _inject(cypher)
assert f"(n:AWSRole:{LABEL})" in result
def test_optional_match_with_extra_whitespace(self):
cypher = "OPTIONAL MATCH (n:AWSRole) RETURN n"
result = _inject(cypher)
assert f"(n:AWSRole:{LABEL})" in result
def test_multiple_match_clauses(self):
cypher = (
"MATCH (a:AWSAccount)--(b:AWSRole) "
"MATCH (b)--(c:AWSPolicy) "
"RETURN a, b, c"
)
result = _inject(cypher)
assert f"(a:AWSAccount:{LABEL})" in result
assert f"(b:AWSRole:{LABEL})" in result
assert f"(c:AWSPolicy:{LABEL})" in result
# (b) in second MATCH is bare and gets injected
assert result.count(LABEL) == 4 # a, b (labeled), b (bare in 2nd MATCH), c
# ---------------------------------------------------------------------------
# Real-world query patterns from aws.py
# ---------------------------------------------------------------------------
class TestRealWorldQueries:
def test_basic_resource_query(self):
cypher = (
"MATCH path = (aws:AWSAccount {id: $provider_uid})--(rds:RDSInstance)\n"
"UNWIND nodes(path) as n\n"
"OPTIONAL MATCH (n)-[pfr]-(pf:ProwlerFinding {status: 'FAIL'})\n"
"RETURN path, collect(DISTINCT pf) as dpf"
)
result = _inject(cypher)
assert f"(aws:AWSAccount:{LABEL} {{id: $provider_uid}})" in result
assert f"(rds:RDSInstance:{LABEL})" in result
assert f"(n:{LABEL})" in result
assert f"(pf:ProwlerFinding:{LABEL} {{status: 'FAIL'}})" in result
assert "nodes(path)" in result # function call untouched
assert "collect(DISTINCT pf)" in result # function call untouched
def test_privilege_escalation_query(self):
cypher = (
"MATCH path_principal = (aws:AWSAccount {id: $uid})"
"--(principal:AWSPrincipal)--(pol:AWSPolicy)\n"
"WHERE pol.effect = 'Allow'\n"
"MATCH (principal)--(cfn_policy:AWSPolicy)"
"--(stmt_cfn:AWSPolicyStatement)\n"
"WHERE any(action IN stmt_cfn.action WHERE toLower(action) = 'iam:passrole')\n"
"MATCH path_target = (aws)--(target_role:AWSRole)"
"-[:TRUSTS_AWS_PRINCIPAL]->(:AWSPrincipal {arn: 'cloudformation.amazonaws.com'})\n"
"RETURN path_principal, path_target"
)
result = _inject(cypher)
assert f"(aws:AWSAccount:{LABEL} {{id: $uid}})" in result
assert f"(principal:AWSPrincipal:{LABEL})" in result
assert f"(pol:AWSPolicy:{LABEL})" in result
assert f"(principal:{LABEL})" in result # bare in 2nd MATCH
assert f"(cfn_policy:AWSPolicy:{LABEL})" in result
assert f"(stmt_cfn:AWSPolicyStatement:{LABEL})" in result
assert f"(aws:{LABEL})" in result # bare in 3rd MATCH
assert f"(target_role:AWSRole:{LABEL})" in result
assert (
f"(:AWSPrincipal:{LABEL} {{arn: 'cloudformation.amazonaws.com'}})" in result
)
# Function calls in WHERE untouched
assert "any(action IN" in result
assert "toLower(action)" in result
def test_custom_bare_query(self):
cypher = (
"MATCH (a)-[:HAS_POLICY]->(b)\n"
"WHERE a.name CONTAINS 'admin'\n"
"RETURN a, b"
)
result = _inject(cypher)
assert f"(a:{LABEL})" in result
assert f"(b:{LABEL})" in result
assert result.count(LABEL) == 2
def test_internet_via_path_connectivity(self):
"""Post-refactor pattern: Internet reached via CAN_ACCESS, not standalone."""
cypher = (
"MATCH path = (aws:AWSAccount {id: $provider_uid})--(ec2:EC2Instance)\n"
"WHERE ec2.exposed_internet = true\n"
"OPTIONAL MATCH (internet:Internet)-[can_access:CAN_ACCESS]->(ec2)\n"
"RETURN path, internet, can_access"
)
result = _inject(cypher)
assert f"(aws:AWSAccount:{LABEL}" in result
assert f"(ec2:EC2Instance:{LABEL})" in result
assert f"(internet:Internet:{LABEL})" in result
# ec2 in OPTIONAL MATCH is bare, but already labeled via Pass A won't match it
# because it has no label. It IS bare, so Pass B injects.
assert f"(ec2:{LABEL})" in result
# ---------------------------------------------------------------------------
# Edge cases
# ---------------------------------------------------------------------------
class TestEdgeCases:
def test_empty_query(self):
assert _inject("") == ""
def test_no_node_patterns(self):
cypher = "RETURN 1 + 2"
assert _inject(cypher) == cypher
def test_anonymous_empty_parens_not_injected(self):
"""Empty () in MATCH is extremely rare but should not be injected."""
cypher = "MATCH ()--(m:AWSRole) RETURN m"
result = _inject(cypher)
assert "()" in result # empty parens untouched
assert f"(m:AWSRole:{LABEL})" in result
def test_fully_anonymous_query_bypasses_injection(self):
"""All-anonymous patterns bypass injection entirely.
MATCH ()--()--() has no labels and no variables, so neither Pass A
(labeled) nor Pass B (bare identifier) can inject the provider label.
This is safe because _serialize_graph() (Layer 3) filters every
returned node by provider label, dropping cross-provider data before
it reaches the user.
"""
cypher = "MATCH ()--()--() RETURN *"
result = _inject(cypher)
assert result == cypher # completely unmodified
assert LABEL not in result
def test_relationship_patterns_untouched(self):
cypher = "MATCH (a:X)-[r:REL_TYPE {x: 1}]->(b:Y) RETURN a"
result = _inject(cypher)
assert "[r:REL_TYPE {x: 1}]" in result # relationship untouched
assert f"(a:X:{LABEL})" in result
assert f"(b:Y:{LABEL})" in result
def test_call_subquery(self):
cypher = (
"CALL {\n"
" MATCH (inner:AWSRole) RETURN inner\n"
"}\n"
"MATCH (outer:AWSAccount) RETURN outer, inner"
)
result = _inject(cypher)
assert f"(inner:AWSRole:{LABEL})" in result
assert f"(outer:AWSAccount:{LABEL})" in result
def test_multiple_protected_regions(self):
cypher = (
"MATCH (n:X {a: 'hello'}) " 'WHERE n.b = "world" ' "// comment\n" "RETURN n"
)
result = _inject(cypher)
assert "'hello'" in result
assert '"world"' in result
assert "// comment" in result
assert f"(n:X:{LABEL}" in result
def test_idempotent_on_already_injected(self):
"""Running injection twice should add the label twice (not ideal, but predictable)."""
first = _inject("MATCH (n:AWSRole) RETURN n")
second = _inject(first)
# The label appears twice (stacked)
assert second.count(LABEL) == 2
# ---------------------------------------------------------------------------
# Validation
# ---------------------------------------------------------------------------
class TestValidation:
@pytest.mark.parametrize(
"cypher",
[
"LOAD CSV FROM 'http://169.254.169.254/' AS x RETURN x",
"load csv from 'http://evil.com' as row return row",
"CALL apoc.load.json('http://evil.com/') YIELD value RETURN value",
"CALL apoc.load.csvParams('http://evil.com/', {}, null) YIELD list RETURN list",
"CALL apoc.import.csv([{fileName: 'f'}], [], {}) YIELD node RETURN node",
"CALL apoc.export.csv.all('file.csv', {})",
"CALL apoc.cypher.run('CREATE (n)', {}) YIELD value RETURN value",
"CALL apoc.systemdb.graph() YIELD nodes RETURN nodes",
"CALL apoc.config.list() YIELD key, value RETURN key, value",
"CALL apoc.periodic.iterate('MATCH (n) RETURN n', 'DELETE n', {batchSize: 100})",
"CALL apoc.do.when(true, 'CREATE (n) RETURN n', '', {}) YIELD value RETURN value",
"CALL apoc.trigger.add('t', 'RETURN 1', {phase: 'before'})",
"CALL apoc.custom.asProcedure('myProc', 'RETURN 1')",
],
ids=[
"LOAD_CSV",
"LOAD_CSV_lowercase",
"apoc.load.json",
"apoc.load.csvParams",
"apoc.import.csv",
"apoc.export.csv",
"apoc.cypher.run",
"apoc.systemdb.graph",
"apoc.config.list",
"apoc.periodic.iterate",
"apoc.do.when",
"apoc.trigger.add",
"apoc.custom.asProcedure",
],
)
def test_rejects_blocked_patterns(self, cypher):
with pytest.raises(ValidationError) as exc:
validate_custom_query(cypher)
assert "blocked operation" in str(exc.value.detail)
@pytest.mark.parametrize(
"cypher",
[
"MATCH (n:AWSAccount) RETURN n LIMIT 10",
"MATCH (a)-[r]->(b) RETURN a, r, b",
"MATCH (n) WHERE n.name CONTAINS 'load' RETURN n",
"CALL apoc.create.vNode(['Label'], {}) YIELD node RETURN node",
"MATCH (n) WHERE n.name = 'apoc.load.json' RETURN n",
'MATCH (n) WHERE n.description = "LOAD CSV is cool" RETURN n',
],
ids=[
"simple_match",
"traversal",
"contains_load_substring",
"apoc_virtual_node",
"apoc_load_inside_single_quotes",
"load_csv_inside_double_quotes",
],
)
def test_allows_clean_queries(self, cypher):
validate_custom_query(cypher)
+99 -290
View File
@@ -364,32 +364,29 @@ class TestRlsTransaction:
tenant = tenants_fixture[0]
tenant_id = str(tenant.id)
for attempt in rls_transaction(tenant_id):
with attempt as cursor:
assert cursor is not None
cursor.execute("SELECT current_setting(%s)", [POSTGRES_TENANT_VAR])
result = cursor.fetchone()
assert result[0] == tenant_id
with rls_transaction(tenant_id) as cursor:
assert cursor is not None
cursor.execute("SELECT current_setting(%s)", [POSTGRES_TENANT_VAR])
result = cursor.fetchone()
assert result[0] == tenant_id
def test_rls_transaction_valid_uuid_object(self, tenants_fixture):
"""Test rls_transaction with UUID object."""
tenant = tenants_fixture[0]
for attempt in rls_transaction(tenant.id):
with attempt as cursor:
assert cursor is not None
cursor.execute("SELECT current_setting(%s)", [POSTGRES_TENANT_VAR])
result = cursor.fetchone()
assert result[0] == str(tenant.id)
with rls_transaction(tenant.id) as cursor:
assert cursor is not None
cursor.execute("SELECT current_setting(%s)", [POSTGRES_TENANT_VAR])
result = cursor.fetchone()
assert result[0] == str(tenant.id)
def test_rls_transaction_invalid_uuid_raises_validation_error(self):
"""Test rls_transaction raises ValidationError for invalid UUID."""
invalid_uuid = "not-a-valid-uuid"
with pytest.raises(ValidationError, match="Must be a valid UUID"):
for attempt in rls_transaction(invalid_uuid):
with attempt:
pass
with rls_transaction(invalid_uuid):
pass
def test_rls_transaction_uses_default_database_when_no_alias(self, tenants_fixture):
"""Test rls_transaction uses DEFAULT_DB_ALIAS when no alias specified."""
@@ -405,9 +402,8 @@ class TestRlsTransaction:
mock_connections.__contains__.return_value = True
with patch("api.db_utils.transaction.atomic"):
for attempt in rls_transaction(tenant_id):
with attempt:
pass
with rls_transaction(tenant_id):
pass
mock_connections.__getitem__.assert_called_with(DEFAULT_DB_ALIAS)
@@ -428,9 +424,8 @@ class TestRlsTransaction:
with patch("api.db_utils.set_read_db_alias") as mock_set_alias:
with patch("api.db_utils.reset_read_db_alias") as mock_reset_alias:
mock_set_alias.return_value = "test_token"
for attempt in rls_transaction(tenant_id, using=custom_alias):
with attempt:
pass
with rls_transaction(tenant_id, using=custom_alias):
pass
mock_connections.__getitem__.assert_called_with(custom_alias)
mock_set_alias.assert_called_once_with(custom_alias)
@@ -457,9 +452,8 @@ class TestRlsTransaction:
"api.db_utils.reset_read_db_alias"
) as mock_reset_alias:
mock_set_alias.return_value = "test_token"
for attempt in rls_transaction(tenant_id):
with attempt:
pass
with rls_transaction(tenant_id):
pass
mock_connections.__getitem__.assert_called()
mock_set_alias.assert_called_once()
@@ -486,9 +480,8 @@ class TestRlsTransaction:
mock_connections.__getitem__.return_value = mock_conn
with patch("api.db_utils.transaction.atomic"):
for attempt in rls_transaction(tenant_id):
with attempt:
pass
with rls_transaction(tenant_id):
pass
mock_connections.__getitem__.assert_called_with(DEFAULT_DB_ALIAS)
@@ -510,20 +503,15 @@ class TestRlsTransaction:
with patch("api.db_utils.transaction.atomic"):
with patch("api.db_utils.set_read_db_alias", return_value="token"):
with patch("api.db_utils.reset_read_db_alias"):
for attempt in rls_transaction(tenant_id):
with attempt:
pass
with rls_transaction(tenant_id):
pass
assert mock_cursor.execute.call_count == 1
def test_rls_transaction_retry_with_exponential_backoff_on_operational_error(
self, tenants_fixture, enable_read_replica
):
"""Test retry with exponential backoff on OperationalError on replica.
REPLICA_MAX_ATTEMPTS=3 means 3 replica tries + 1 primary fallback = 4 total.
First 3 attempts fail, 4th succeeds on primary.
"""
"""Test retry with exponential backoff on OperationalError on replica."""
tenant = tenants_fixture[0]
tenant_id = str(tenant.id)
@@ -540,7 +528,7 @@ class TestRlsTransaction:
def atomic_side_effect(*args, **kwargs):
nonlocal call_count
call_count += 1
if call_count <= 3:
if call_count < 3:
raise OperationalError("Connection error")
return MagicMock(
__enter__=MagicMock(return_value=None),
@@ -556,24 +544,48 @@ class TestRlsTransaction:
):
with patch("api.db_utils.reset_read_db_alias"):
with patch("api.db_utils.logger") as mock_logger:
for attempt in rls_transaction(tenant_id):
with attempt:
pass
with rls_transaction(tenant_id):
pass
assert mock_sleep.call_count == 3
assert mock_sleep.call_count == 2
mock_sleep.assert_any_call(0.5)
mock_sleep.assert_any_call(1.0)
mock_sleep.assert_any_call(2.0)
assert mock_logger.info.call_count == 3
assert mock_logger.warning.call_count == 1
assert mock_logger.info.call_count == 2
def test_rls_transaction_operational_error_inside_context_no_retry(
self, tenants_fixture, enable_read_replica
):
"""Test OperationalError raised inside context does not retry."""
tenant = tenants_fixture[0]
tenant_id = str(tenant.id)
with patch("api.db_utils.get_read_db_alias", return_value=enable_read_replica):
with patch("api.db_utils.connections") as mock_connections:
mock_conn = MagicMock()
mock_cursor = MagicMock()
mock_conn.cursor.return_value.__enter__.return_value = mock_cursor
mock_connections.__getitem__.return_value = mock_conn
mock_connections.__contains__.return_value = True
with patch("api.db_utils.transaction.atomic") as mock_atomic:
mock_atomic.return_value.__enter__.return_value = None
mock_atomic.return_value.__exit__.return_value = False
with patch("api.db_utils.time.sleep") as mock_sleep:
with patch(
"api.db_utils.set_read_db_alias", return_value="token"
):
with patch("api.db_utils.reset_read_db_alias"):
with pytest.raises(OperationalError):
with rls_transaction(tenant_id):
raise OperationalError("Conflict with recovery")
mock_sleep.assert_not_called()
def test_rls_transaction_max_three_attempts_for_replica(
self, tenants_fixture, enable_read_replica
):
"""Test maximum attempts for replica database.
REPLICA_MAX_ATTEMPTS=3 means 3 replica + 1 primary = 4 total attempts.
"""
"""Test maximum 3 attempts for replica database."""
tenant = tenants_fixture[0]
tenant_id = str(tenant.id)
@@ -594,11 +606,10 @@ class TestRlsTransaction:
):
with patch("api.db_utils.reset_read_db_alias"):
with pytest.raises(OperationalError):
for attempt in rls_transaction(tenant_id):
with attempt:
pass
with rls_transaction(tenant_id):
pass
assert mock_atomic.call_count == 4
assert mock_atomic.call_count == 3
def test_rls_transaction_replica_no_retry_when_disabled(
self, tenants_fixture, enable_read_replica
@@ -624,11 +635,10 @@ class TestRlsTransaction:
):
with patch("api.db_utils.reset_read_db_alias"):
with pytest.raises(OperationalError):
for attempt in rls_transaction(
with rls_transaction(
tenant_id, retry_on_replica=False
):
with attempt:
pass
pass
assert mock_atomic.call_count == 1
mock_sleep.assert_not_called()
@@ -650,19 +660,15 @@ class TestRlsTransaction:
mock_atomic.side_effect = OperationalError("Primary error")
with pytest.raises(OperationalError):
for attempt in rls_transaction(tenant_id):
with attempt:
pass
with rls_transaction(tenant_id):
pass
assert mock_atomic.call_count == 1
def test_rls_transaction_fallback_to_primary_after_max_attempts(
self, tenants_fixture, enable_read_replica
):
"""Test fallback to primary DB after max attempts on replica.
First 3 attempts fail on replica, 4th succeeds on primary.
"""
"""Test fallback to primary DB after max attempts on replica."""
tenant = tenants_fixture[0]
tenant_id = str(tenant.id)
@@ -679,7 +685,7 @@ class TestRlsTransaction:
def atomic_side_effect(*args, **kwargs):
nonlocal call_count
call_count += 1
if call_count <= 3:
if call_count < 3:
raise OperationalError("Replica error")
return MagicMock(
__enter__=MagicMock(return_value=None),
@@ -695,9 +701,8 @@ class TestRlsTransaction:
):
with patch("api.db_utils.reset_read_db_alias"):
with patch("api.db_utils.logger") as mock_logger:
for attempt in rls_transaction(tenant_id):
with attempt:
pass
with rls_transaction(tenant_id):
pass
mock_logger.warning.assert_called_once()
warning_msg = mock_logger.warning.call_args[0][0]
@@ -706,10 +711,7 @@ class TestRlsTransaction:
def test_rls_transaction_logger_warning_on_fallback(
self, tenants_fixture, enable_read_replica
):
"""Test logger warnings are emitted on fallback to primary.
3 replica failures produce 3 info logs, then 1 warning on fallback.
"""
"""Test logger warnings are emitted on fallback to primary."""
tenant = tenants_fixture[0]
tenant_id = str(tenant.id)
@@ -726,7 +728,7 @@ class TestRlsTransaction:
def atomic_side_effect(*args, **kwargs):
nonlocal call_count
call_count += 1
if call_count <= 3:
if call_count < 3:
raise OperationalError("Replica error")
return MagicMock(
__enter__=MagicMock(return_value=None),
@@ -742,11 +744,10 @@ class TestRlsTransaction:
):
with patch("api.db_utils.reset_read_db_alias"):
with patch("api.db_utils.logger") as mock_logger:
for attempt in rls_transaction(tenant_id):
with attempt:
pass
with rls_transaction(tenant_id):
pass
assert mock_logger.info.call_count == 3
assert mock_logger.info.call_count == 2
assert mock_logger.warning.call_count == 1
def test_rls_transaction_operational_error_raised_immediately_on_primary(
@@ -769,16 +770,15 @@ class TestRlsTransaction:
with patch("api.db_utils.time.sleep") as mock_sleep:
with pytest.raises(OperationalError):
for attempt in rls_transaction(tenant_id):
with attempt:
pass
with rls_transaction(tenant_id):
pass
mock_sleep.assert_not_called()
def test_rls_transaction_operational_error_raised_after_max_attempts(
self, tenants_fixture, enable_read_replica
):
"""Test OperationalError raised after all 4 attempts (3 replica + 1 primary)."""
"""Test OperationalError raised after max attempts on replica."""
tenant = tenants_fixture[0]
tenant_id = str(tenant.id)
@@ -801,9 +801,8 @@ class TestRlsTransaction:
):
with patch("api.db_utils.reset_read_db_alias"):
with pytest.raises(OperationalError):
for attempt in rls_transaction(tenant_id):
with attempt:
pass
with rls_transaction(tenant_id):
pass
def test_rls_transaction_router_token_set_for_non_default_alias(
self, tenants_fixture
@@ -824,9 +823,8 @@ class TestRlsTransaction:
with patch("api.db_utils.set_read_db_alias") as mock_set_alias:
with patch("api.db_utils.reset_read_db_alias") as mock_reset_alias:
mock_set_alias.return_value = "test_token"
for attempt in rls_transaction(tenant_id, using=custom_alias):
with attempt:
pass
with rls_transaction(tenant_id, using=custom_alias):
pass
mock_set_alias.assert_called_once_with(custom_alias)
mock_reset_alias.assert_called_once_with("test_token")
@@ -850,11 +848,8 @@ class TestRlsTransaction:
with patch("api.db_utils.set_read_db_alias", return_value="test_token"):
with patch("api.db_utils.reset_read_db_alias") as mock_reset_alias:
with pytest.raises(Exception):
for attempt in rls_transaction(
tenant_id, using=custom_alias
):
with attempt:
pass
with rls_transaction(tenant_id, using=custom_alias):
pass
mock_reset_alias.assert_called_once_with("test_token")
@@ -878,9 +873,8 @@ class TestRlsTransaction:
with patch(
"api.db_utils.reset_read_db_alias"
) as mock_reset_alias:
for attempt in rls_transaction(tenant_id):
with attempt:
pass
with rls_transaction(tenant_id):
pass
mock_set_alias.assert_not_called()
mock_reset_alias.assert_not_called()
@@ -892,11 +886,10 @@ class TestRlsTransaction:
tenant = tenants_fixture[0]
tenant_id = str(tenant.id)
for attempt in rls_transaction(tenant_id):
with attempt as cursor:
cursor.execute("SELECT current_setting(%s)", [POSTGRES_TENANT_VAR])
result = cursor.fetchone()
assert result[0] == tenant_id
with rls_transaction(tenant_id) as cursor:
cursor.execute("SELECT current_setting(%s)", [POSTGRES_TENANT_VAR])
result = cursor.fetchone()
assert result[0] == tenant_id
def test_rls_transaction_custom_parameter(self, tenants_fixture):
"""Test rls_transaction with custom parameter name."""
@@ -904,205 +897,21 @@ class TestRlsTransaction:
tenant_id = str(tenant.id)
custom_param = "api.user_id"
for attempt in rls_transaction(tenant_id, parameter=custom_param):
with attempt as cursor:
cursor.execute("SELECT current_setting(%s)", [custom_param])
result = cursor.fetchone()
assert result[0] == tenant_id
with rls_transaction(tenant_id, parameter=custom_param) as cursor:
cursor.execute("SELECT current_setting(%s)", [custom_param])
result = cursor.fetchone()
assert result[0] == tenant_id
def test_rls_transaction_cursor_yielded_correctly(self, tenants_fixture):
"""Test cursor is yielded correctly."""
tenant = tenants_fixture[0]
tenant_id = str(tenant.id)
for attempt in rls_transaction(tenant_id):
with attempt as cursor:
assert cursor is not None
cursor.execute("SELECT 1")
result = cursor.fetchone()
assert result[0] == 1
def test_rls_transaction_for_with_retries_mid_body_error(
self, tenants_fixture, enable_read_replica
):
"""Test that OperationalError raised inside the body triggers retry."""
tenant = tenants_fixture[0]
tenant_id = str(tenant.id)
with patch("api.db_utils.get_read_db_alias", return_value=enable_read_replica):
with patch("api.db_utils.connections") as mock_connections:
mock_conn = MagicMock()
mock_cursor = MagicMock()
mock_conn.cursor.return_value.__enter__.return_value = mock_cursor
mock_connections.__getitem__.return_value = mock_conn
mock_connections.__contains__.return_value = True
with patch("api.db_utils.transaction.atomic"):
with patch("api.db_utils.time.sleep") as mock_sleep:
with patch(
"api.db_utils.set_read_db_alias", return_value="token"
):
with patch("api.db_utils.reset_read_db_alias"):
body_call_count = 0
for attempt in rls_transaction(tenant_id):
with attempt:
body_call_count += 1
if body_call_count == 1:
raise OperationalError(
"Conflict with recovery"
)
assert body_call_count == 2
mock_connections.__getitem__.return_value.close.assert_called()
assert mock_sleep.call_count == 1
def test_rls_transaction_for_with_success_first_attempt(
self, tenants_fixture, enable_read_replica
):
"""Test happy path on replica: body succeeds on first attempt, no retry."""
tenant = tenants_fixture[0]
tenant_id = str(tenant.id)
with patch("api.db_utils.get_read_db_alias", return_value=enable_read_replica):
with patch("api.db_utils.connections") as mock_connections:
mock_conn = MagicMock()
mock_cursor = MagicMock()
mock_conn.cursor.return_value.__enter__.return_value = mock_cursor
mock_connections.__getitem__.return_value = mock_conn
mock_connections.__contains__.return_value = True
with patch("api.db_utils.transaction.atomic"):
with patch("api.db_utils.time.sleep") as mock_sleep:
with patch(
"api.db_utils.set_read_db_alias", return_value="token"
):
with patch("api.db_utils.reset_read_db_alias"):
iterations = 0
for attempt in rls_transaction(tenant_id):
with attempt:
iterations += 1
assert iterations == 1
mock_sleep.assert_not_called()
def test_rls_transaction_for_with_closes_stale_connection(
self, tenants_fixture, enable_read_replica
):
"""Test that stale connection is closed on OperationalError retry."""
tenant = tenants_fixture[0]
tenant_id = str(tenant.id)
with patch("api.db_utils.get_read_db_alias", return_value=enable_read_replica):
with patch("api.db_utils.connections") as mock_connections:
mock_conn = MagicMock()
mock_cursor = MagicMock()
mock_conn.cursor.return_value.__enter__.return_value = mock_cursor
mock_connections.__getitem__.return_value = mock_conn
mock_connections.__contains__.return_value = True
with patch("api.db_utils.transaction.atomic"):
with patch("api.db_utils.time.sleep"):
with patch(
"api.db_utils.set_read_db_alias", return_value="token"
):
with patch("api.db_utils.reset_read_db_alias"):
body_call_count = 0
for attempt in rls_transaction(tenant_id):
with attempt:
body_call_count += 1
if body_call_count == 1:
raise OperationalError("stale connection")
mock_conn.close.assert_called()
def test_rls_transaction_for_with_non_operational_error_propagates(
self, tenants_fixture, enable_read_replica
):
"""Test that non-OperationalError propagates immediately without retry."""
tenant = tenants_fixture[0]
tenant_id = str(tenant.id)
with patch("api.db_utils.get_read_db_alias", return_value=enable_read_replica):
with patch("api.db_utils.connections") as mock_connections:
mock_conn = MagicMock()
mock_cursor = MagicMock()
mock_conn.cursor.return_value.__enter__.return_value = mock_cursor
mock_connections.__getitem__.return_value = mock_conn
mock_connections.__contains__.return_value = True
with patch("api.db_utils.transaction.atomic"):
with patch("api.db_utils.time.sleep") as mock_sleep:
with patch(
"api.db_utils.set_read_db_alias", return_value="token"
):
with patch("api.db_utils.reset_read_db_alias"):
with pytest.raises(ValueError, match="bad value"):
for attempt in rls_transaction(tenant_id):
with attempt:
raise ValueError("bad value")
mock_sleep.assert_not_called()
def test_rls_transaction_for_with_primary_no_retry(self, tenants_fixture):
"""Test that OperationalError on primary propagates immediately."""
tenant = tenants_fixture[0]
tenant_id = str(tenant.id)
with patch("api.db_utils.get_read_db_alias", return_value=None):
with patch("api.db_utils.connections") as mock_connections:
mock_conn = MagicMock()
mock_cursor = MagicMock()
mock_conn.cursor.return_value.__enter__.return_value = mock_cursor
mock_connections.__getitem__.return_value = mock_conn
mock_connections.__contains__.return_value = True
with patch("api.db_utils.transaction.atomic"):
with patch("api.db_utils.time.sleep") as mock_sleep:
with pytest.raises(OperationalError):
for attempt in rls_transaction(tenant_id):
with attempt:
raise OperationalError("primary failure")
mock_sleep.assert_not_called()
def test_rls_transaction_for_with_replica_max_attempts_semantics(
self, tenants_fixture, enable_read_replica
):
"""Test that REPLICA_MAX_ATTEMPTS=3 produces 4 atomic calls (3 replica + 1 primary).
When transaction.atomic always fails, _RLSAttempt.__enter__ retries
internally and consumes all attempts. The for-loop body never
executes, so we assert on mock_atomic.call_count instead.
"""
tenant = tenants_fixture[0]
tenant_id = str(tenant.id)
with patch("api.db_utils.get_read_db_alias", return_value=enable_read_replica):
with patch("api.db_utils.connections") as mock_connections:
mock_conn = MagicMock()
mock_cursor = MagicMock()
mock_conn.cursor.return_value.__enter__.return_value = mock_cursor
mock_connections.__getitem__.return_value = mock_conn
mock_connections.__contains__.return_value = True
with patch("api.db_utils.transaction.atomic") as mock_atomic:
mock_atomic.side_effect = OperationalError("always fails")
with patch("api.db_utils.time.sleep"):
with patch(
"api.db_utils.set_read_db_alias", return_value="token"
):
with patch("api.db_utils.reset_read_db_alias"):
with pytest.raises(OperationalError):
for attempt in rls_transaction(tenant_id):
with attempt:
pass
# 3 replica attempts + 1 primary fallback
assert mock_atomic.call_count == 4
# Last call should use the default (primary) alias
last_call = mock_atomic.call_args_list[-1]
assert last_call.kwargs.get("using") == DEFAULT_DB_ALIAS
with rls_transaction(tenant_id) as cursor:
assert cursor is not None
cursor.execute("SELECT 1")
result = cursor.fetchone()
assert result[0] == 1
class TestPostgresEnumMigration:
+15 -8
View File
@@ -1,5 +1,5 @@
import uuid
from unittest.mock import MagicMock, call, patch
from unittest.mock import call, patch
import pytest
from django.core.exceptions import ObjectDoesNotExist
@@ -65,7 +65,8 @@ class TestHandleProviderDeletionDecorator:
tenant = tenants_fixture[0]
deleted_provider_id = str(uuid.uuid4())
mock_rls.return_value = [MagicMock()]
mock_rls.return_value.__enter__ = lambda s: None
mock_rls.return_value.__exit__ = lambda s, *args: None
mock_filter.return_value.exists.return_value = False
@handle_provider_deletion
@@ -88,7 +89,8 @@ class TestHandleProviderDeletionDecorator:
scan_id = str(uuid.uuid4())
provider_id = str(uuid.uuid4())
mock_rls.return_value = [MagicMock()]
mock_rls.return_value.__enter__ = lambda s: None
mock_rls.return_value.__exit__ = lambda s, *args: None
mock_scan = type("MockScan", (), {"provider_id": provider_id})()
mock_scan_filter.return_value.first.return_value = mock_scan
@@ -110,7 +112,8 @@ class TestHandleProviderDeletionDecorator:
tenant = tenants_fixture[0]
scan_id = str(uuid.uuid4())
mock_rls.return_value = [MagicMock()]
mock_rls.return_value.__enter__ = lambda s: None
mock_rls.return_value.__exit__ = lambda s, *args: None
mock_scan_filter.return_value.first.return_value = None
@handle_provider_deletion
@@ -131,7 +134,8 @@ class TestHandleProviderDeletionDecorator:
tenant = tenants_fixture[0]
provider = providers_fixture[0]
mock_rls.return_value = [MagicMock()]
mock_rls.return_value.__enter__ = lambda s: None
mock_rls.return_value.__exit__ = lambda s, *args: None
mock_filter.return_value.exists.return_value = True
@handle_provider_deletion
@@ -150,7 +154,8 @@ class TestHandleProviderDeletionDecorator:
tenant = tenants_fixture[0]
deleted_provider_id = str(uuid.uuid4())
mock_rls.return_value = [MagicMock()]
mock_rls.return_value.__enter__ = lambda s: None
mock_rls.return_value.__exit__ = lambda s, *args: None
mock_filter.return_value.exists.return_value = False
@handle_provider_deletion
@@ -169,7 +174,8 @@ class TestHandleProviderDeletionDecorator:
tenant = tenants_fixture[0]
deleted_provider_id = str(uuid.uuid4())
mock_rls.return_value = [MagicMock()]
mock_rls.return_value.__enter__ = lambda s: None
mock_rls.return_value.__exit__ = lambda s, *args: None
mock_filter.return_value.exists.return_value = False
@handle_provider_deletion
@@ -188,7 +194,8 @@ class TestHandleProviderDeletionDecorator:
tenant = tenants_fixture[0]
provider = providers_fixture[0]
mock_rls.return_value = [MagicMock()]
mock_rls.return_value.__enter__ = lambda s: None
mock_rls.return_value.__exit__ = lambda s, *args: None
mock_filter.return_value.exists.return_value = True
@handle_provider_deletion
+33
View File
@@ -243,6 +243,39 @@ class TestSAMLConfigurationModel:
assert "Invalid XML" in errors["metadata_xml"][0]
assert "not well-formed" in errors["metadata_xml"][0]
def test_xml_bomb_rejected(self, tenants_fixture):
"""
Regression test: a 'billion laughs' XML bomb in the SAML metadata field
must be rejected and not allowed to exhaust server memory / CPU.
Before the fix, xml.etree.ElementTree was used directly, which does not
protect against entity-expansion attacks. The fix switches to defusedxml
which raises an exception for any XML containing entity definitions.
"""
tenant = tenants_fixture[0]
xml_bomb = (
"<?xml version='1.0'?>"
"<!DOCTYPE bomb ["
" <!ENTITY a 'aaaaaaaaaa'>"
" <!ENTITY b '&a;&a;&a;&a;&a;&a;&a;&a;&a;&a;'>"
" <!ENTITY c '&b;&b;&b;&b;&b;&b;&b;&b;&b;&b;'>"
" <!ENTITY d '&c;&c;&c;&c;&c;&c;&c;&c;&c;&c;'>"
"]>"
"<md:EntityDescriptor entityID='&d;' "
"xmlns:md='urn:oasis:names:tc:SAML:2.0:metadata'/>"
)
config = SAMLConfiguration(
email_domain="xmlbomb.com",
metadata_xml=xml_bomb,
tenant=tenant,
)
with pytest.raises(ValidationError) as exc_info:
config._parse_metadata()
errors = exc_info.value.message_dict
assert "metadata_xml" in errors
def test_metadata_missing_sso_fails(self, tenants_fixture):
tenant = tenants_fixture[0]
xml = """<md:EntityDescriptor entityID="x" xmlns:md="urn:oasis:names:tc:SAML:2.0:metadata">
+64 -1
View File
@@ -2,7 +2,7 @@ import json
from unittest.mock import ANY, Mock, patch
import pytest
from conftest import TODAY
from conftest import TEST_PASSWORD, TODAY
from django.urls import reverse
from rest_framework import status
@@ -830,3 +830,66 @@ class TestUserRoleLinkPermissions:
)
assert response.status_code == status.HTTP_403_FORBIDDEN
@pytest.mark.django_db
class TestCrossTenantRoleLeak:
"""Regression tests for get_role() cross-tenant privilege leak.
get_role() must query admin_db (bypassing RLS) so that a user with a role
in tenant A cannot accidentally pass role checks when authenticated against
tenant B where they have no role.
"""
def test_user_with_role_in_tenant_a_denied_in_tenant_b(self, tenants_fixture):
"""User has admin role in tenant A, membership in tenant B but no role.
Hitting an RBAC-protected endpoint with a tenant-B token must return 403."""
from rest_framework.test import APIClient
tenant_a = tenants_fixture[0]
tenant_b = tenants_fixture[1]
user = User.objects.create_user(
name="cross_tenant_user",
email="cross_tenant@test.com",
password=TEST_PASSWORD,
)
Membership.objects.create(
user=user, tenant=tenant_a, role=Membership.RoleChoices.OWNER
)
Membership.objects.create(
user=user, tenant=tenant_b, role=Membership.RoleChoices.OWNER
)
# Role only in tenant A
role = Role.objects.create(
name="admin",
tenant_id=tenant_a.id,
manage_users=True,
manage_account=True,
manage_billing=True,
manage_providers=True,
manage_integrations=True,
manage_scans=True,
unlimited_visibility=True,
)
UserRoleRelationship.objects.create(user=user, role=role, tenant_id=tenant_a.id)
# Mint token scoped to tenant B (where user has NO role)
serializer = TokenSerializer(
data={
"type": "tokens",
"email": "cross_tenant@test.com",
"password": TEST_PASSWORD,
"tenant_id": tenant_b.id,
}
)
serializer.is_valid(raise_exception=True)
access_token = serializer.validated_data["access"]
client = APIClient()
client.defaults["HTTP_AUTHORIZATION"] = f"Bearer {access_token}"
# user-list requires manage_users permission via HasPermissions
response = client.get(reverse("user-list"))
assert response.status_code == status.HTTP_403_FORBIDDEN
+58 -12
View File
@@ -4,14 +4,25 @@ from unittest.mock import MagicMock
from config.settings.sentry import before_send
def _make_log_record(msg, level=logging.ERROR, name="test", args=None):
"""Build a real LogRecord so getMessage() works like in production."""
record = logging.LogRecord(
name=name,
level=level,
pathname="",
lineno=0,
msg=msg,
args=args,
exc_info=None,
)
return record
def test_before_send_ignores_log_with_ignored_exception():
"""Test that before_send ignores logs containing ignored exceptions."""
log_record = MagicMock()
log_record.msg = "Provider kubernetes is not connected"
log_record.levelno = logging.ERROR # 40
log_record = _make_log_record("Provider kubernetes is not connected")
hint = {"log_record": log_record}
event = MagicMock()
result = before_send(event, hint)
@@ -36,12 +47,9 @@ def test_before_send_ignores_exception_with_ignored_exception():
def test_before_send_passes_through_non_ignored_log():
"""Test that before_send passes through logs that don't contain ignored exceptions."""
log_record = MagicMock()
log_record.msg = "Some other error message"
log_record.levelno = logging.ERROR # 40
log_record = _make_log_record("Some other error message")
hint = {"log_record": log_record}
event = MagicMock()
result = before_send(event, hint)
@@ -66,15 +74,53 @@ def test_before_send_passes_through_non_ignored_exception():
def test_before_send_handles_warning_level():
"""Test that before_send handles warning level logs."""
log_record = MagicMock()
log_record.msg = "Provider kubernetes is not connected"
log_record.levelno = logging.WARNING # 30
log_record = _make_log_record(
"Provider kubernetes is not connected", level=logging.WARNING
)
hint = {"log_record": log_record}
event = MagicMock()
result = before_send(event, hint)
# Assert that the event was dropped (None returned)
assert result is None
def test_before_send_ignores_neo4j_defunct_connection():
"""Test that before_send drops neo4j.io defunct connection logs.
The Neo4j driver logs transient connection errors at ERROR level
before RetryableSession retries them. These are noise.
The driver uses %s formatting, so "defunct" is in the args, not
in the template. This test mirrors the real LogRecord structure.
"""
log_record = _make_log_record(
msg="[#%04X] _: <CONNECTION> error: %s: %r",
name="neo4j.io",
args=(
0xE5CC,
"Failed to read from defunct connection "
"IPv4Address(('cloud-neo4j.prowler.com', 7687))",
ConnectionResetError(104, "Connection reset by peer"),
),
)
hint = {"log_record": log_record}
event = MagicMock()
assert before_send(event, hint) is None
def test_before_send_passes_non_defunct_neo4j_log():
"""Test that before_send passes through neo4j.io logs that are not about defunct connections."""
log_record = _make_log_record(
msg="Some other neo4j transport error",
name="neo4j.io",
)
hint = {"log_record": log_record}
event = MagicMock()
assert before_send(event, hint) == event
+59 -11
View File
@@ -33,6 +33,7 @@ from prowler.providers.m365.m365_provider import M365Provider
from prowler.providers.mongodbatlas.mongodbatlas_provider import MongodbatlasProvider
from prowler.providers.openstack.openstack_provider import OpenstackProvider
from prowler.providers.oraclecloud.oraclecloud_provider import OraclecloudProvider
from prowler.providers.vercel.vercel_provider import VercelProvider
class TestMergeDicts:
@@ -128,6 +129,7 @@ class TestReturnProwlerProvider:
(Provider.ProviderChoices.CLOUDFLARE.value, CloudflareProvider),
(Provider.ProviderChoices.OPENSTACK.value, OpenstackProvider),
(Provider.ProviderChoices.IMAGE.value, ImageProvider),
(Provider.ProviderChoices.VERCEL.value, VercelProvider),
],
)
def test_return_prowler_provider(self, provider_type, expected_provider):
@@ -218,6 +220,24 @@ class TestProwlerProviderConnectionTest:
registry_token="tok123",
)
@patch("api.utils.return_prowler_provider")
def test_prowler_provider_connection_test_vercel_provider(
self, mock_return_prowler_provider
):
"""Test connection test for Vercel provider passes team_id."""
provider = MagicMock()
provider.uid = "team_abcdef1234567890"
provider.provider = Provider.ProviderChoices.VERCEL.value
provider.secret.secret = {"api_token": "vercel_token_123"}
mock_return_prowler_provider.return_value = MagicMock()
prowler_provider_connection_test(provider)
mock_return_prowler_provider.return_value.test_connection.assert_called_once_with(
api_token="vercel_token_123",
team_id="team_abcdef1234567890",
raise_on_exception=False,
)
@patch("api.utils.return_prowler_provider")
def test_prowler_provider_connection_test_image_provider_no_creds(
self, mock_return_prowler_provider
@@ -284,6 +304,10 @@ class TestGetProwlerProviderKwargs:
Provider.ProviderChoices.OPENSTACK.value,
{},
),
(
Provider.ProviderChoices.VERCEL.value,
{"team_id": "provider_uid"},
),
],
)
def test_get_prowler_provider_kwargs(self, provider_type, expected_extra_kwargs):
@@ -782,15 +806,20 @@ class TestProwlerIntegrationConnectionTest:
}
integration.configuration = {}
# Mock successful JIRA connection with projects
# Mock successful JIRA connection with projects and issue types
mock_connection = MagicMock()
mock_connection.is_connected = True
mock_connection.error = None
mock_connection.projects = {"PROJ1": "Project 1", "PROJ2": "Project 2"}
mock_connection.issue_types = {
"PROJ1": ["Task", "Bug"],
"PROJ2": ["Task", "Story"],
}
mock_jira_class.test_connection.return_value = mock_connection
# Mock rls_transaction as an iterable yielding a context manager
mock_rls_transaction.return_value = [MagicMock()]
# Mock rls_transaction context manager
mock_rls_transaction.return_value.__enter__ = MagicMock()
mock_rls_transaction.return_value.__exit__ = MagicMock()
result = prowler_integration_connection_test(integration)
@@ -814,6 +843,12 @@ class TestProwlerIntegrationConnectionTest:
"PROJ2": "Project 2",
}
# Verify issue types were saved to integration configuration
assert integration.configuration["issue_types"] == {
"PROJ1": ["Task", "Bug"],
"PROJ2": ["Task", "Story"],
}
# Verify integration.save() was called
integration.save.assert_called_once()
@@ -837,10 +872,12 @@ class TestProwlerIntegrationConnectionTest:
mock_connection.is_connected = False
mock_connection.error = Exception("Authentication failed: Invalid credentials")
mock_connection.projects = {} # Empty projects when connection fails
mock_connection.issue_types = {} # Empty issue types when connection fails
mock_jira_class.test_connection.return_value = mock_connection
# Mock rls_transaction as an iterable yielding a context manager
mock_rls_transaction.return_value = [MagicMock()]
# Mock rls_transaction context manager
mock_rls_transaction.return_value.__enter__ = MagicMock()
mock_rls_transaction.return_value.__exit__ = MagicMock()
result = prowler_integration_connection_test(integration)
@@ -861,6 +898,9 @@ class TestProwlerIntegrationConnectionTest:
# Verify empty projects dict was saved to integration configuration
assert integration.configuration["projects"] == {}
# Verify empty issue types dict was saved to integration configuration
assert integration.configuration["issue_types"] == {}
# Verify integration.save() was called even on connection failure
integration.save.assert_called_once()
@@ -879,11 +919,11 @@ class TestProwlerIntegrationConnectionTest:
"domain": "example.atlassian.net",
}
integration.configuration = {
"issue_types": ["Task"], # Existing configuration
"issue_types": {"OLD_PROJ": ["Task"]}, # Existing configuration
"projects": {"OLD_PROJ": "Old Project"}, # Will be overwritten
}
# Mock successful JIRA connection with new projects
# Mock successful JIRA connection with new projects and issue types
mock_connection = MagicMock()
mock_connection.is_connected = True
mock_connection.error = None
@@ -891,10 +931,15 @@ class TestProwlerIntegrationConnectionTest:
"NEW_PROJ1": "New Project 1",
"NEW_PROJ2": "New Project 2",
}
mock_connection.issue_types = {
"NEW_PROJ1": ["Task", "Bug"],
"NEW_PROJ2": ["Story"],
}
mock_jira_class.test_connection.return_value = mock_connection
# Mock rls_transaction as an iterable yielding a context manager
mock_rls_transaction.return_value = [MagicMock()]
# Mock rls_transaction context manager
mock_rls_transaction.return_value.__enter__ = MagicMock()
mock_rls_transaction.return_value.__exit__ = MagicMock()
result = prowler_integration_connection_test(integration)
@@ -907,8 +952,11 @@ class TestProwlerIntegrationConnectionTest:
"NEW_PROJ2": "New Project 2",
}
# Verify other configuration fields were preserved
assert integration.configuration["issue_types"] == ["Task"]
# Verify issue types were also updated
assert integration.configuration["issue_types"] == {
"NEW_PROJ1": ["Task", "Bug"],
"NEW_PROJ2": ["Story"],
}
# Verify integration.save() was called
integration.save.assert_called_once()
File diff suppressed because it is too large Load Diff
+31 -12
View File
@@ -39,6 +39,7 @@ if TYPE_CHECKING:
)
from prowler.providers.openstack.openstack_provider import OpenstackProvider
from prowler.providers.oraclecloud.oraclecloud_provider import OraclecloudProvider
from prowler.providers.vercel.vercel_provider import VercelProvider
class CustomOAuth2Client(OAuth2Client):
@@ -94,6 +95,7 @@ def return_prowler_provider(
| MongodbatlasProvider
| OpenstackProvider
| OraclecloudProvider
| VercelProvider
):
"""Return the Prowler provider class based on the given provider type.
@@ -175,6 +177,10 @@ def return_prowler_provider(
from prowler.providers.image.image_provider import ImageProvider
prowler_provider = ImageProvider
case Provider.ProviderChoices.VERCEL.value:
from prowler.providers.vercel.vercel_provider import VercelProvider
prowler_provider = VercelProvider
case _:
raise ValueError(f"Provider type {provider.provider} not supported")
return prowler_provider
@@ -235,6 +241,11 @@ def get_prowler_provider_kwargs(
# clouds_yaml_content, clouds_yaml_cloud and provider_id are validated
# in the provider itself, so it's not needed here.
pass
elif provider.provider == Provider.ProviderChoices.VERCEL.value:
prowler_provider_kwargs = {
**prowler_provider_kwargs,
"team_id": provider.uid,
}
elif provider.provider == Provider.ProviderChoices.IMAGE.value:
# Detect whether uid is a registry URL (e.g. "docker.io/andoniaf") or
# a concrete image reference (e.g. "docker.io/andoniaf/myimage:latest").
@@ -281,6 +292,7 @@ def initialize_prowler_provider(
| MongodbatlasProvider
| OpenstackProvider
| OraclecloudProvider
| VercelProvider
):
"""Initialize a Prowler provider instance based on the given provider type.
@@ -332,6 +344,13 @@ def prowler_provider_connection_test(provider: Provider) -> Connection:
"raise_on_exception": False,
}
return prowler_provider.test_connection(**openstack_kwargs)
elif provider.provider == Provider.ProviderChoices.VERCEL.value:
vercel_kwargs = {
**prowler_provider_kwargs,
"team_id": provider.uid,
"raise_on_exception": False,
}
return prowler_provider.test_connection(**vercel_kwargs)
elif provider.provider == Provider.ProviderChoices.IMAGE.value:
image_kwargs = {
"image": provider.uid,
@@ -415,10 +434,13 @@ def prowler_integration_connection_test(integration: Integration) -> Connection:
raise_on_exception=False,
)
project_keys = jira_connection.projects if jira_connection.is_connected else {}
for attempt in rls_transaction(str(integration.tenant_id)):
with attempt:
integration.configuration["projects"] = project_keys
integration.save()
issue_types = (
jira_connection.issue_types if jira_connection.is_connected else {}
)
with rls_transaction(str(integration.tenant_id)):
integration.configuration["projects"] = project_keys
integration.configuration["issue_types"] = issue_types
integration.save()
return jira_connection
elif integration.integration_type == Integration.IntegrationChoices.SLACK:
pass
@@ -545,12 +567,9 @@ def initialize_prowler_integration(integration: Integration) -> Jira:
try:
return Jira(**integration.credentials)
except JiraBasicAuthError as jira_auth_error:
for attempt in rls_transaction(str(integration.tenant_id)):
with attempt:
integration.configuration["projects"] = {}
integration.connected = False
integration.connection_last_checked_at = datetime.now(
tz=timezone.utc
)
integration.save()
with rls_transaction(str(integration.tenant_id)):
integration.configuration["projects"] = {}
integration.connected = False
integration.connection_last_checked_at = datetime.now(tz=timezone.utc)
integration.save()
raise jira_auth_error
@@ -69,8 +69,10 @@ class SecurityHubConfigSerializer(BaseValidateSerializer):
class JiraConfigSerializer(BaseValidateSerializer):
domain = serializers.CharField(read_only=True)
issue_types = serializers.ListField(
read_only=True, child=serializers.CharField(), default=["Task"]
issue_types = serializers.DictField(
read_only=True,
child=serializers.ListField(child=serializers.CharField()),
default={},
)
projects = serializers.DictField(read_only=True)
@@ -404,6 +404,17 @@ from rest_framework_json_api import serializers
},
"required": ["clouds_yaml_content", "clouds_yaml_cloud"],
},
{
"type": "object",
"title": "Vercel API Token",
"properties": {
"api_token": {
"type": "string",
"description": "Vercel API token for authentication. Can be scoped to a specific team.",
},
},
"required": ["api_token"],
},
]
}
)
+45 -3
View File
@@ -1573,6 +1573,8 @@ class BaseWriteProviderSecretSerializer(BaseWriteSerializer):
serializer = OpenStackCloudsYamlProviderSecret(data=secret)
elif provider_type == Provider.ProviderChoices.IMAGE.value:
serializer = ImageProviderSecret(data=secret)
elif provider_type == Provider.ProviderChoices.VERCEL.value:
serializer = VercelProviderSecret(data=secret)
else:
raise serializers.ValidationError(
{"provider": f"Provider type not supported {provider_type}"}
@@ -1779,6 +1781,13 @@ class ImageProviderSecret(serializers.Serializer):
return attrs
class VercelProviderSecret(serializers.Serializer):
api_token = serializers.CharField()
class Meta:
resource_name = "provider-secrets"
class AlibabaCloudProviderSecret(serializers.Serializer):
access_key_id = serializers.CharField()
access_key_secret = serializers.CharField()
@@ -2713,11 +2722,11 @@ class BaseWriteIntegrationSerializer(BaseWriteSerializer):
)
config_serializer = JiraConfigSerializer
# Create non-editable configuration for JIRA integration
default_jira_issue_types = ["Task"]
# issue_types will be populated per project when connection is tested
configuration.update(
{
"projects": {},
"issue_types": default_jira_issue_types,
"issue_types": {},
"domain": credentials.get("domain"),
}
)
@@ -2932,13 +2941,25 @@ class IntegrationUpdateSerializer(BaseWriteIntegrationSerializer):
return representation
class IntegrationJiraIssueTypesSerializer(BaseSerializerV1):
"""
Serializer for Jira issue types response.
"""
project_key = serializers.CharField(read_only=True)
issue_types = serializers.ListField(child=serializers.CharField(), read_only=True)
class JSONAPIMeta:
resource_name = "jira-issue-types"
class IntegrationJiraDispatchSerializer(BaseSerializerV1):
"""
Serializer for dispatching findings to JIRA integration.
"""
project_key = serializers.CharField(required=True)
issue_type = serializers.ChoiceField(required=True, choices=["Task"])
issue_type = serializers.CharField(required=True)
class JSONAPIMeta:
resource_name = "integrations-jira-dispatches"
@@ -2967,6 +2988,23 @@ class IntegrationJiraDispatchSerializer(BaseSerializerV1):
}
)
issue_type = attrs.get("issue_type")
available_issue_types = integration_instance.configuration.get(
"issue_types", {}
)
# Handle old format where issue_types was a flat list (e.g., ["Task"])
if not isinstance(available_issue_types, dict):
available_issue_types = {}
project_issue_types = available_issue_types.get(project_key, [])
if project_issue_types and issue_type not in project_issue_types:
raise ValidationError(
{
"issue_type": f"The issue type '{issue_type}' is not available for project '{project_key}'. "
f"Available types: {', '.join(project_issue_types)}. "
"Refresh the connection if this is an error."
}
)
return validated_attrs
@@ -4178,8 +4216,10 @@ class FindingGroupResourceSerializer(BaseSerializerV1):
provider = serializers.SerializerMethodField()
status = serializers.CharField()
severity = serializers.CharField()
delta = serializers.CharField(required=False, allow_null=True)
first_seen_at = serializers.DateTimeField(required=False, allow_null=True)
last_seen_at = serializers.DateTimeField(required=False, allow_null=True)
muted_reason = serializers.CharField(required=False, allow_null=True)
class JSONAPIMeta:
resource_name = "finding-group-resources"
@@ -4193,6 +4233,7 @@ class FindingGroupResourceSerializer(BaseSerializerV1):
"service": {"type": "string"},
"region": {"type": "string"},
"type": {"type": "string"},
"resource_group": {"type": "string"},
},
}
)
@@ -4204,6 +4245,7 @@ class FindingGroupResourceSerializer(BaseSerializerV1):
"service": obj.get("resource_service", ""),
"region": obj.get("resource_region", ""),
"type": obj.get("resource_type", ""),
"resource_group": obj.get("resource_group", ""),
}
@extend_schema_field(
File diff suppressed because it is too large Load Diff
+6 -7
View File
@@ -57,11 +57,10 @@ class RLSTask(Task):
from api.db_utils import rls_transaction
tenant_id = kwargs.get("tenant_id")
for attempt in rls_transaction(tenant_id):
with attempt:
APITask.objects.update_or_create(
id=task_result_instance.task_id,
tenant_id=tenant_id,
defaults={"task_runner_task": task_result_instance},
)
with rls_transaction(tenant_id):
APITask.objects.update_or_create(
id=task_result_instance.task_id,
tenant_id=tenant_id,
defaults={"task_runner_task": task_result_instance},
)
return result
+5
View File
@@ -299,3 +299,8 @@ DJANGO_DELETION_BATCH_SIZE = env.int("DJANGO_DELETION_BATCH_SIZE", 5000)
# SAML requirement
CSRF_COOKIE_SECURE = True
SESSION_COOKIE_SECURE = True
# Attack Paths
ATTACK_PATHS_SCAN_STALE_THRESHOLD_MINUTES = env.int(
"ATTACK_PATHS_SCAN_STALE_THRESHOLD_MINUTES", 2880
) # 48h
+43 -1
View File
@@ -1,10 +1,52 @@
from urllib.parse import quote
from config.env import env
_VALID_SCHEMES = {"redis", "rediss"}
def _build_celery_broker_url(
scheme: str,
username: str,
password: str,
host: str,
port: str,
db: str,
) -> str:
if scheme not in _VALID_SCHEMES:
raise ValueError(
f"Invalid VALKEY_SCHEME '{scheme}'. Must be one of: {', '.join(sorted(_VALID_SCHEMES))}"
)
encoded_username = quote(username, safe="") if username else ""
encoded_password = quote(password, safe="") if password else ""
auth = ""
if encoded_username and encoded_password:
auth = f"{encoded_username}:{encoded_password}@"
elif encoded_password:
auth = f":{encoded_password}@"
elif encoded_username:
auth = f"{encoded_username}@"
return f"{scheme}://{auth}{host}:{port}/{db}"
VALKEY_SCHEME = env("VALKEY_SCHEME", default="redis")
VALKEY_USERNAME = env("VALKEY_USERNAME", default="")
VALKEY_PASSWORD = env("VALKEY_PASSWORD", default="")
VALKEY_HOST = env("VALKEY_HOST", default="valkey")
VALKEY_PORT = env("VALKEY_PORT", default="6379")
VALKEY_DB = env("VALKEY_DB", default="0")
CELERY_BROKER_URL = f"redis://{VALKEY_HOST}:{VALKEY_PORT}/{VALKEY_DB}"
CELERY_BROKER_URL = _build_celery_broker_url(
VALKEY_SCHEME,
VALKEY_USERNAME,
VALKEY_PASSWORD,
VALKEY_HOST,
VALKEY_PORT,
VALKEY_DB,
)
CELERY_RESULT_BACKEND = "django-db"
CELERY_TASK_TRACK_STARTED = True

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